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
Drew Ditthardt 2021-01-04 17:10:23 -05:00 committed by Tycho Andersen
parent 1bc4455aec
commit 35a3cfe5a1
8 changed files with 253 additions and 156 deletions

View File

@ -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()

View File

@ -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

View File

@ -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()

95
libqtile/core/loop.py Normal file
View File

@ -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"]}')

View File

@ -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')))

View File

@ -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()

View File

@ -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

View File

@ -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):