Refactor Qtile LifeCycle
Refactors the process lifecycle related parts of Qtile to properly manage resource finalization and just generally cleans up the code. Instead of running os.execv before python's builtin finalization routines, this uses those same finalization routines to move os.execv to the last thing done by the interpreter before termination.master
parent
1bc4455aec
commit
35a3cfe5a1
|
@ -28,7 +28,9 @@ this_dir = os.path.dirname(__file__)
|
|||
base_dir = os.path.abspath(os.path.join(this_dir, ".."))
|
||||
sys.path.insert(0, base_dir)
|
||||
|
||||
from libqtile.scripts.main import main
|
||||
# import lifecycle early so no atexit handlers are lost on restart
|
||||
import libqtile.core.lifecycle # noqa: F401, E402
|
||||
from libqtile.scripts.main import main # noqa: E402
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
|
@ -183,7 +183,7 @@ class Core(base.Core):
|
|||
return self._numlock_mask, self._valid_mask
|
||||
|
||||
def setup_listener(
|
||||
self, qtile: "Qtile", eventloop: asyncio.AbstractEventLoop
|
||||
self, qtile: "Qtile"
|
||||
) -> None:
|
||||
"""Setup a listener for the given qtile instance
|
||||
|
||||
|
@ -195,7 +195,7 @@ class Core(base.Core):
|
|||
logger.debug("Adding io watch")
|
||||
self.qtile = qtile
|
||||
self.fd = self.conn.conn.get_file_descriptor()
|
||||
eventloop.add_reader(self.fd, self._xpoll)
|
||||
asyncio.get_running_loop().add_reader(self.fd, self._xpoll)
|
||||
|
||||
def remove_listener(self) -> None:
|
||||
"""Remove the listener from the given event loop"""
|
||||
|
@ -206,7 +206,7 @@ class Core(base.Core):
|
|||
def _remove_listener(self) -> None:
|
||||
if self.fd is not None:
|
||||
logger.debug("Removing io watch")
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.remove_reader(self.fd)
|
||||
self.fd = None
|
||||
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import atexit
|
||||
import enum
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from libqtile.log_utils import logger
|
||||
|
||||
__all__ = [
|
||||
'lifecycle',
|
||||
]
|
||||
|
||||
Behavior = enum.Enum('Behavior', 'NONE TERMINATE RESTART')
|
||||
|
||||
|
||||
class LifeCycle:
|
||||
# This class exists mostly to move os.execv to the absolute last thing that
|
||||
# the python VM does before termination.
|
||||
# Be very careful about what references this class owns. Any object
|
||||
# referenced here when atexit fires will NOT be finalized properly.
|
||||
def __init__(self) -> None:
|
||||
self.behavior = Behavior.NONE
|
||||
self.state_file: Optional[str] = None
|
||||
atexit.register(self._atexit)
|
||||
|
||||
def _atexit(self) -> None:
|
||||
if self.behavior is Behavior.RESTART:
|
||||
argv = [sys.executable] + sys.argv
|
||||
if '--no-spawn' not in argv:
|
||||
argv.append('--no-spawn')
|
||||
argv = [s for s in argv if not s.startswith('--with-state')]
|
||||
if self.state_file is not None:
|
||||
argv.append('--with-state=' + self.state_file)
|
||||
logger.warning('Restarting Qtile with os.execv(...)')
|
||||
# No other code will execute after the following line does
|
||||
os.execv(sys.executable, argv)
|
||||
elif self.behavior is Behavior.TERMINATE:
|
||||
logger.warning('Qtile will now terminate')
|
||||
elif self.behavior is Behavior.NONE:
|
||||
pass
|
||||
|
||||
|
||||
lifecycle = LifeCycle()
|
|
@ -0,0 +1,95 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
from typing import Awaitable, Optional
|
||||
|
||||
from libqtile.log_utils import logger
|
||||
|
||||
|
||||
class QtileLoop(contextlib.AbstractAsyncContextManager):
|
||||
def __init__(self, qtile):
|
||||
super().__init__()
|
||||
self.qtile = qtile
|
||||
self._glib_loop: Optional[Awaitable] = None
|
||||
|
||||
async def __aenter__(self) -> 'QtileLoop':
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.add_signal_handler(signal.SIGINT, self.qtile.stop)
|
||||
loop.add_signal_handler(signal.SIGTERM, self.qtile.stop)
|
||||
loop.set_exception_handler(self._handle_exception)
|
||||
|
||||
with contextlib.suppress(ImportError):
|
||||
self._glib_loop = self._setup_glib_loop()
|
||||
|
||||
if self._glib_loop is None:
|
||||
logger.warning('importing dbus/gobject failed, dbus will not work.')
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
if self._glib_loop is not None:
|
||||
await self._teardown_glib_loop(self._glib_loop)
|
||||
self._glib_loop = None
|
||||
|
||||
await self._cancel_all_tasks()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
loop.remove_signal_handler(signal.SIGTERM)
|
||||
loop.set_exception_handler(None)
|
||||
|
||||
async def _cancel_all_tasks(self):
|
||||
# we don't want to cancel this task, so filter all_tasks
|
||||
# generator to filter in place
|
||||
pending = (
|
||||
task for task in asyncio.all_tasks()
|
||||
if task is not asyncio.current_task()
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task()
|
||||
|
||||
def _setup_glib_loop(self):
|
||||
# This is a little strange. python-dbus internally depends on gobject,
|
||||
# so gobject's threads need to be running, and a gobject 'main loop
|
||||
# thread' needs to be spawned, but we try to let it only interact with
|
||||
# us via calls to asyncio's call_soon_threadsafe.
|
||||
# We import dbus here to thrown an ImportError if it isn't
|
||||
# available. Since the only reason we're running this thread is
|
||||
# because of dbus, if dbus isn't around there's no need to run
|
||||
# this thread.
|
||||
import dbus # noqa
|
||||
from gi.repository import GLib # type: ignore
|
||||
|
||||
def gobject_thread():
|
||||
ctx = GLib.main_context_default()
|
||||
while not self.qtile.is_stopped():
|
||||
try:
|
||||
ctx.iteration(True)
|
||||
except Exception:
|
||||
logger.exception('got exception from gobject')
|
||||
|
||||
return asyncio.get_running_loop().run_in_executor(None, gobject_thread)
|
||||
|
||||
async def _teardown_glib_loop(self, glib_loop):
|
||||
try:
|
||||
from gi.repository import GLib # type: ignore
|
||||
GLib.idle_add(lambda: None)
|
||||
await glib_loop
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def _handle_exception(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
context: dict,
|
||||
) -> None:
|
||||
# message is always present, but we'd prefer the exception if available
|
||||
if 'exception' in context:
|
||||
exc = context['exception']
|
||||
# CancelledErrors happen when we simply cancel the main task during
|
||||
# a normal restart procedure
|
||||
if not isinstance(exc, asyncio.CancelledError):
|
||||
logger.exception(context['exception'])
|
||||
else:
|
||||
logger.error(f'unhandled error in event loop: {context["msg"]}')
|
|
@ -26,8 +26,6 @@ import pickle
|
|||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
|
||||
|
@ -59,29 +57,21 @@ from libqtile.utils import get_cache_dir, send_notification
|
|||
from libqtile.widget.base import _Widget
|
||||
|
||||
|
||||
def handle_exception(loop, context):
|
||||
if "exception" in context:
|
||||
logger.error(context["exception"], exc_info=True)
|
||||
else:
|
||||
logger.error("exception in event loop: %s", context)
|
||||
|
||||
|
||||
class Qtile(CommandObject):
|
||||
"""This object is the `root` of the command graph"""
|
||||
def __init__(
|
||||
self,
|
||||
kore,
|
||||
config,
|
||||
eventloop,
|
||||
no_spawn=False,
|
||||
state=None
|
||||
):
|
||||
self._restart = False
|
||||
self.core = kore
|
||||
self.no_spawn = no_spawn
|
||||
self._state = state
|
||||
|
||||
self._stopped_event = None
|
||||
self.should_restart = False
|
||||
|
||||
self._drag = None
|
||||
self.mouse_map = None
|
||||
|
@ -102,58 +92,11 @@ class Qtile(CommandObject):
|
|||
|
||||
libqtile.init(self)
|
||||
|
||||
self._eventloop = None
|
||||
self.server = IPCCommandServer(self)
|
||||
self.config = config
|
||||
self.load_config()
|
||||
|
||||
self._eventloop = eventloop
|
||||
self.setup_eventloop()
|
||||
|
||||
self._process_screens()
|
||||
self.current_screen = self.screens[0]
|
||||
self._drag = None
|
||||
|
||||
self.conn.flush()
|
||||
self.conn.xsync()
|
||||
self.core._xpoll()
|
||||
|
||||
# Map and Grab keys
|
||||
for key in self.config.keys:
|
||||
self.grab_key(key)
|
||||
|
||||
self.mouse_map = {}
|
||||
for i in self.config.mouse:
|
||||
if self.mouse_map.get(i.button_code) is None:
|
||||
self.mouse_map[i.button_code] = []
|
||||
self.mouse_map[i.button_code].append(i)
|
||||
|
||||
self.grab_mouse()
|
||||
|
||||
# no_spawn is set when we are restarting; we only want to run the
|
||||
# startup hook once.
|
||||
if not no_spawn:
|
||||
hook.fire("startup_once")
|
||||
hook.fire("startup")
|
||||
|
||||
if state:
|
||||
try:
|
||||
with open(state, 'rb') as f:
|
||||
st = pickle.load(f)
|
||||
st.apply(self)
|
||||
except: # noqa: E722
|
||||
logger.exception("failed restoring state")
|
||||
finally:
|
||||
os.remove(state)
|
||||
|
||||
self.core.scan()
|
||||
if state:
|
||||
for screen in self.screens:
|
||||
screen.group.layout_all()
|
||||
self.update_net_desktops()
|
||||
hook.subscribe.setgroup(self.update_net_desktops)
|
||||
|
||||
hook.fire("startup_complete")
|
||||
|
||||
def load_config(self):
|
||||
try:
|
||||
self.config.load()
|
||||
|
@ -210,6 +153,63 @@ class Qtile(CommandObject):
|
|||
pass
|
||||
self.config.mouse += (Click([], "Button1", lazy.function(noop), focus="after"),)
|
||||
|
||||
def dump_state(self, buf):
|
||||
try:
|
||||
pickle.dump(QtileState(self), buf, protocol=0)
|
||||
except: # noqa: E722
|
||||
logger.exception('Unable to pickle qtile state')
|
||||
|
||||
def _configure(self):
|
||||
"""
|
||||
This is the part of init that needs to happen after the event loop is
|
||||
fully set up. asyncio is required to listen and respond to backend
|
||||
events.
|
||||
"""
|
||||
self._process_screens()
|
||||
self.current_screen = self.screens[0]
|
||||
|
||||
self.conn.flush()
|
||||
self.conn.xsync()
|
||||
self.core._xpoll()
|
||||
|
||||
# Map and Grab keys
|
||||
for key in self.config.keys:
|
||||
self.grab_key(key)
|
||||
|
||||
self.mouse_map = {}
|
||||
for i in self.config.mouse:
|
||||
if self.mouse_map.get(i.button_code) is None:
|
||||
self.mouse_map[i.button_code] = []
|
||||
self.mouse_map[i.button_code].append(i)
|
||||
|
||||
self.grab_mouse()
|
||||
|
||||
# no_spawn is set when we are restarting; we only want to run the
|
||||
# startup hook once.
|
||||
if not self.no_spawn:
|
||||
hook.fire("startup_once")
|
||||
hook.fire("startup")
|
||||
|
||||
if self._state:
|
||||
try:
|
||||
with open(self._state, 'rb') as f:
|
||||
st = pickle.load(f)
|
||||
st.apply(self)
|
||||
except: # noqa: E722
|
||||
logger.exception("failed restoring state")
|
||||
finally:
|
||||
os.remove(self._state)
|
||||
|
||||
self.core.scan()
|
||||
if self._state:
|
||||
for screen in self.screens:
|
||||
screen.group.layout_all()
|
||||
self._state = None
|
||||
self.update_net_desktops()
|
||||
hook.subscribe.setgroup(self.update_net_desktops)
|
||||
|
||||
hook.fire("startup_complete")
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
return self.core._root
|
||||
|
@ -222,97 +222,42 @@ class Qtile(CommandObject):
|
|||
def selection(self):
|
||||
return self.core._selection
|
||||
|
||||
def setup_eventloop(self) -> None:
|
||||
self._eventloop.add_signal_handler(signal.SIGINT, self.stop)
|
||||
self._eventloop.add_signal_handler(signal.SIGTERM, self.stop)
|
||||
self._eventloop.set_exception_handler(handle_exception)
|
||||
|
||||
logger.debug('Adding io watch')
|
||||
self.core.setup_listener(self, self._eventloop)
|
||||
|
||||
self._stopped_event = asyncio.Event()
|
||||
|
||||
# This is a little strange. python-dbus internally depends on gobject,
|
||||
# so gobject's threads need to be running, and a gobject "main loop
|
||||
# thread" needs to be spawned, but we try to let it only interact with
|
||||
# us via calls to asyncio's call_soon_threadsafe.
|
||||
try:
|
||||
# We import dbus here to thrown an ImportError if it isn't
|
||||
# available. Since the only reason we're running this thread is
|
||||
# because of dbus, if dbus isn't around there's no need to run
|
||||
# this thread.
|
||||
import dbus # noqa
|
||||
from gi.repository import GLib # type: ignore
|
||||
|
||||
def gobject_thread():
|
||||
ctx = GLib.main_context_default()
|
||||
while not self._stopped_event.is_set():
|
||||
try:
|
||||
ctx.iteration(True)
|
||||
except Exception:
|
||||
logger.exception("got exception from gobject")
|
||||
self._glib_loop = self.run_in_executor(gobject_thread)
|
||||
except ImportError:
|
||||
logger.warning("importing dbus/gobject failed, dbus will not work.")
|
||||
self._glib_loop = None
|
||||
|
||||
async def async_loop(self) -> None:
|
||||
"""Run the event loop
|
||||
|
||||
Finalizes the Qtile instance on exit.
|
||||
"""
|
||||
self._eventloop = asyncio.get_running_loop()
|
||||
self._stopped_event = asyncio.Event()
|
||||
self.core.setup_listener(self)
|
||||
self._configure()
|
||||
try:
|
||||
await self._stopped_event.wait()
|
||||
finally:
|
||||
await self.finalize()
|
||||
|
||||
self._eventloop.stop()
|
||||
|
||||
def maybe_restart(self) -> None:
|
||||
"""If set, restart the qtile instance"""
|
||||
if self._restart:
|
||||
logger.warning('Restarting Qtile with os.execv(...)')
|
||||
os.execv(*self._restart)
|
||||
self.finalize()
|
||||
self.core.remove_listener()
|
||||
|
||||
def stop(self):
|
||||
# stop gets called in a variety of ways, including from restart().
|
||||
# let's only do a real shutdown if we're not about to re-exec.
|
||||
if not self._restart:
|
||||
hook.fire("shutdown")
|
||||
self.graceful_shutdown()
|
||||
|
||||
logger.debug('Stopping qtile')
|
||||
self._stopped_event.set()
|
||||
hook.fire("shutdown")
|
||||
self.graceful_shutdown()
|
||||
self._stop()
|
||||
|
||||
def restart(self):
|
||||
hook.fire("restart")
|
||||
argv = [sys.executable] + sys.argv
|
||||
if '--no-spawn' not in argv:
|
||||
argv.append('--no-spawn')
|
||||
state_file = os.path.join(tempfile.gettempdir(), "qtile-state")
|
||||
try:
|
||||
with open(state_file, 'wb') as f:
|
||||
pickle.dump(QtileState(self), f, protocol=0)
|
||||
except: # noqa: E722
|
||||
logger.error("Unable to pickle qtile state")
|
||||
argv = [s for s in argv if not s.startswith('--with-state')]
|
||||
argv.append('--with-state=' + state_file)
|
||||
self._restart = (sys.executable, argv)
|
||||
self.stop()
|
||||
self.should_restart = True
|
||||
self._stop()
|
||||
|
||||
async def finalize(self):
|
||||
self._eventloop.remove_signal_handler(signal.SIGINT)
|
||||
self._eventloop.remove_signal_handler(signal.SIGTERM)
|
||||
self._eventloop.set_exception_handler(None)
|
||||
def _stop(self):
|
||||
logger.debug('Stopping qtile')
|
||||
if self._stopped_event is not None:
|
||||
self._stopped_event.set()
|
||||
|
||||
if self._glib_loop:
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
GLib.idle_add(lambda: None)
|
||||
await self._glib_loop
|
||||
except ImportError:
|
||||
pass
|
||||
def is_stopped(self):
|
||||
if self._stopped_event is not None:
|
||||
return self._stopped_event.is_set()
|
||||
return False
|
||||
|
||||
def finalize(self):
|
||||
try:
|
||||
for widget in self.widgets_map.values():
|
||||
widget.finalize()
|
||||
|
@ -326,7 +271,6 @@ class Qtile(CommandObject):
|
|||
bar.finalize()
|
||||
except: # noqa: E722
|
||||
logger.exception('exception during finalize')
|
||||
self.core.remove_listener()
|
||||
|
||||
def _process_fake_screens(self):
|
||||
"""
|
||||
|
@ -1542,7 +1486,7 @@ class Qtile(CommandObject):
|
|||
def cmd_get_state(self):
|
||||
"""Get pickled state for restarting qtile"""
|
||||
buf = io.BytesIO()
|
||||
pickle.dump(QtileState(self), buf, protocol=0)
|
||||
self.dump_state(buf)
|
||||
state = buf.getvalue().decode(errors="backslashreplace")
|
||||
logger.debug('State = ')
|
||||
logger.debug(''.join(state.split('\n')))
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from libqtile import ipc
|
||||
from libqtile.backend import base
|
||||
from libqtile.core.lifecycle import lifecycle
|
||||
from libqtile.core.loop import QtileLoop
|
||||
from libqtile.core.manager import Qtile
|
||||
|
||||
|
||||
|
@ -24,15 +28,19 @@ class SessionManager:
|
|||
:param state:
|
||||
The state to restart the qtile instance with.
|
||||
"""
|
||||
self.eventloop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.eventloop)
|
||||
lifecycle.behavior = lifecycle.behavior.TERMINATE
|
||||
|
||||
self.qtile = Qtile(kore, config, self.eventloop, no_spawn=no_spawn, state=state)
|
||||
self.qtile = Qtile(kore, config, no_spawn=no_spawn, state=state)
|
||||
self.server = ipc.Server(
|
||||
self._prepare_socket(fname),
|
||||
self.qtile.server.call,
|
||||
)
|
||||
|
||||
def _prepare_socket(self, fname: Optional[str] = None) -> str:
|
||||
if fname is None:
|
||||
# Dots might appear in the host part of the display name
|
||||
# during remote X sessions. Let's strip the host part first
|
||||
display_name = kore.display_name
|
||||
display_name = self.qtile.core.display_name
|
||||
display_number = display_name.partition(":")[2]
|
||||
if "." not in display_number:
|
||||
display_name += ".0"
|
||||
|
@ -40,19 +48,23 @@ class SessionManager:
|
|||
|
||||
if os.path.exists(fname):
|
||||
os.unlink(fname)
|
||||
self.server = ipc.Server(fname, self.qtile.server.call)
|
||||
|
||||
return fname
|
||||
|
||||
def _restart(self):
|
||||
lifecycle.behavior = lifecycle.behavior.RESTART
|
||||
state_file = os.path.join(tempfile.gettempdir(), 'qtile-state')
|
||||
with open(state_file, 'wb') as f:
|
||||
self.qtile.dump_state(f)
|
||||
lifecycle.state_file = state_file
|
||||
|
||||
def loop(self) -> None:
|
||||
"""Run the event loop"""
|
||||
try:
|
||||
# replace with asyncio.run(...) on Python 3.7+
|
||||
self.eventloop.run_until_complete(self.async_loop())
|
||||
asyncio.run(self.async_loop())
|
||||
finally:
|
||||
self.eventloop.run_until_complete(self.eventloop.shutdown_asyncgens())
|
||||
self.eventloop.close()
|
||||
|
||||
self.qtile.maybe_restart()
|
||||
if self.qtile.should_restart:
|
||||
self._restart()
|
||||
|
||||
async def async_loop(self) -> None:
|
||||
async with self.server:
|
||||
async with QtileLoop(self.qtile), self.server:
|
||||
await self.qtile.async_loop()
|
||||
|
|
|
@ -119,7 +119,6 @@ class Client:
|
|||
Pack and unpack messages as json
|
||||
"""
|
||||
self.fname = fname
|
||||
self.loop = asyncio.get_event_loop()
|
||||
self.is_json = is_json
|
||||
|
||||
def call(self, data: Any) -> Any:
|
||||
|
@ -131,7 +130,7 @@ class Client:
|
|||
If any exception is raised by the server, that will propogate out of
|
||||
this call.
|
||||
"""
|
||||
return self.loop.run_until_complete(self.async_send(msg))
|
||||
return asyncio.run(self.async_send(msg))
|
||||
|
||||
async def async_send(self, msg: Any) -> Any:
|
||||
"""Send the message to the server
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
|
@ -336,7 +337,8 @@ def test_incompatible_widget(manager_nospawn):
|
|||
# Ensure that adding a widget that doesn't support the orientation of the
|
||||
# bar raises ConfigError
|
||||
with pytest.raises(libqtile.confreader.ConfigError):
|
||||
manager_nospawn.create_manager(config)
|
||||
m = manager_nospawn.create_manager(config)
|
||||
asyncio.run(m.qtile._configure())
|
||||
|
||||
|
||||
def test_basic(manager_nospawn):
|
||||
|
|
Loading…
Reference in New Issue