From 4503123cd61ac809b9ea578df1819a2c0152fcd1 Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Tue, 12 May 2026 14:51:05 +0200 Subject: [PATCH 1/7] Schedule synchronous function to separate thread Waiting for the server to send the result of the API calls, allows dealing with failures/exceptions: - QubesVM.shutdown -> .shutdown(wait=True) - QubesVM.run_service -> .run_service_for_stdio It also does not block the widget from being opened again even if an action is still running. This allows, for example: - To create disposable qube and attach device to it (which takes 10s), but not block the widget from opening - To detach and shutdown disposable that is hanging, without hanging the widget For: https://github.com/QubesOS/qubes-issues/issues/10648 For: https://github.com/QubesOS/qubes-issues/issues/10651 For: https://github.com/QubesOS/qubes-issues/issues/10835 Requires: https://github.com/QubesOS/qubes-core-admin/pull/807 Requires: https://github.com/QubesOS/qubes-core-admin-client/pull/469 --- debian/control | 2 +- qui/devices/actionable_widgets.py | 28 +++++------ qui/tray/domains.py | 47 ++++++++++-------- qui/updater/summary_page.py | 51 ++++++++++++-------- rpm_spec/qubes-desktop-linux-manager.spec.in | 2 +- 5 files changed, 73 insertions(+), 57 deletions(-) diff --git a/debian/control b/debian/control index 81780915..26cbaaec 100644 --- a/debian/control +++ b/debian/control @@ -26,7 +26,7 @@ Description: Qubes UI Applications Package: python3-qui Architecture: any Depends: - python3-qubesadmin (>= 4.3.19), + python3-qubesadmin (>= 4.3.33), python3-gi, ${python3:Depends}, ${misc:Depends} diff --git a/qui/devices/actionable_widgets.py b/qui/devices/actionable_widgets.py index 468e0c4b..860478e9 100644 --- a/qui/devices/actionable_widgets.py +++ b/qui/devices/actionable_widgets.py @@ -26,6 +26,7 @@ import asyncio import functools import pathlib +import time from typing import Iterable, Callable, Optional, List import qubesadmin @@ -38,7 +39,6 @@ from gi.repository import Gtk, GdkPixbuf, GLib # isort:skip from . import backend -import time def load_icon(icon_name: str, backup_name: str, size: int = 24): @@ -247,7 +247,7 @@ def __init__(self, vm: backend.VM, device: backend.Device): self.actionable = False async def widget_action(self, *_args): - self.device.attach_to_vm(self.vm) + await asyncio.to_thread(self.device.attach_to_vm, self.vm) class DetachWidget(ActionableWidget, SimpleActionWidget): @@ -259,7 +259,7 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark" self.device = device async def widget_action(self, *_args): - self.device.detach_from_vm(self.vm, False) + await asyncio.to_thread(self.device.detach_from_vm, self.vm, False) class DetachWithWidget(ActionableWidget, SimpleActionWidget): @@ -278,7 +278,7 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark" self.device = device async def widget_action(self, *_args): - self.device.detach_from_vm(self.vm, True) + await asyncio.to_thread(self.device.detach_from_vm, self.vm, True) class DetachAndShutdownWidget(ActionableWidget, SimpleActionWidget): @@ -292,8 +292,8 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark" self.device = device async def widget_action(self, *_args): - self.device.detach_from_vm(self.vm, True) - self.vm.vm_object.shutdown() + await asyncio.to_thread(self.device.detach_from_vm, self.vm, True) + await asyncio.to_thread(self.vm.vm_object.shutdown) class DetachAndAttachWidget(ActionableWidget, VMWithIcon): @@ -306,8 +306,8 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark" async def widget_action(self, *_args): for vm in self.device.attachments: - self.device.detach_from_vm(vm, True) - self.device.attach_to_vm(self.vm) + await asyncio.to_thread(self.device.detach_from_vm, vm, True) + await asyncio.to_thread(self.device.attach_to_vm, self.vm) class AttachDisposableWidget(ActionableWidget, VMWithIcon): @@ -320,9 +320,9 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark" async def widget_action(self, *_args): new_dispvm = qubesadmin.vm.DispVM.from_appvm(self.vm.vm_object.app, self.vm) - new_dispvm.start() + await asyncio.to_thread(new_dispvm.start) - self.device.attach_to_vm(backend.VM(new_dispvm)) + await asyncio.to_thread(self.device.attach_to_vm, backend.VM(new_dispvm)) class DetachAndAttachDisposableWidget(ActionableWidget, VMWithIcon): @@ -334,11 +334,11 @@ def __init__(self, vm: backend.VM, device: backend.Device, variant: str = "dark" self.device = device async def widget_action(self, *_args): - self.device.detach_from_vm(self.vm) + await asyncio.to_thread(self.device.detach_from_vm, self.vm) new_dispvm = qubesadmin.vm.DispVM.from_appvm(self.vm.vm_object.app, self.vm) - new_dispvm.start() + await asyncio.to_thread(new_dispvm.start) - self.device.attach_to_vm(backend.VM(new_dispvm)) + await asyncio.to_thread(self.device.attach_to_vm, backend.VM(new_dispvm)) class ToggleFeatureItem(ActionableWidget, SimpleActionWidget): @@ -379,7 +379,7 @@ def __init__(self, usbvm: backend.VM, variant: str = "dark"): self.usbvm = usbvm async def widget_action(self, *_args): - self.usbvm.vm_object.start() + await asyncio.to_thread(self.usbvm.vm_object.start) #### Configuration-related actions diff --git a/qui/tray/domains.py b/qui/tray/domains.py index 46ba6e79..c23736cb 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -157,7 +157,7 @@ def __init__(self, vm, icon_cache): async def perform_action(self): try: - self.vm.pause() + await asyncio.to_thread(self.vm.pause) except exc.QubesException as ex: show_error( _("Error pausing qube"), @@ -178,7 +178,7 @@ def __init__(self, vm, icon_cache): async def perform_action(self): try: - self.vm.unpause() + await asyncio.to_thread(self.vm.unpause) except exc.QubesException as ex: show_error( _("Error unpausing qube"), @@ -215,7 +215,7 @@ def set_force(self, force): async def perform_action(self): try: - self.vm.shutdown(force=self.force) + await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True) except exc.QubesException as ex: if self.force: show_error( @@ -271,19 +271,22 @@ async def perform_action(self): GLib.idle_add(dialog.show) def react_to_question(self, widget, response, action): + asyncio.create_task(self.react_to_question_async(widget, response, action)) + + async def react_to_question_async(self, widget, response, action): if response not in (Gtk.ResponseType.OK, Gtk.ResponseType.YES): widget.destroy() return try: if action == "force": - self.vm.shutdown(force=True) + await asyncio.to_thread(self.vm.shutdown, force=True, wait=True) elif action == "timeout": if response == Gtk.ResponseType.YES: - self.vm.kill() + await asyncio.to_thread(self.vm.kill) elif response == Gtk.ResponseType.OK: - self.vm.shutdown(force=False) + await asyncio.to_thread(self.vm.shutdown, force=False, wait=True) elif action == "kill" and response == Gtk.ResponseType.OK: - self.vm.kill() + await asyncio.to_thread(self.vm.kill) except exc.QubesException as ex: show_error( _("Error shutting down qube"), @@ -320,7 +323,7 @@ def set_force(self, force): async def perform_action(self, *_args, **_kwargs): try: - self.vm.shutdown(force=self.force) + await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True) except exc.QubesException as ex: if self.force: # we already tried forcing it, let's just give up @@ -351,12 +354,7 @@ async def perform_action(self, *_args, **_kwargs): if self.give_up: return await asyncio.sleep(1) - proc = await asyncio.create_subprocess_exec( - "qvm-start", self.vm.name, stderr=asyncio.subprocess.PIPE - ) - _stdout, stderr = await proc.communicate() - if proc.returncode != 0: - raise exc.QubesException(stderr) + await asyncio.to_thread(self.vm.start) except exc.QubesException as ex: show_error( _("Error restarting qube"), @@ -367,9 +365,12 @@ async def perform_action(self, *_args, **_kwargs): ) def react_to_question(self, widget, response): + asyncio.create_task(self.react_to_question_async(widget, response)) + + async def react_to_question_async(self, widget, response): if response == Gtk.ResponseType.OK: try: - self.vm.shutdown(force=True) + await asyncio.to_thread(self.vm.shutdown, force=True, wait=True) except exc.QubesException as ex: show_error( _("Error shutting down qube"), @@ -392,13 +393,13 @@ def __init__(self, vm, icon_cache): async def perform_action(self, *_args, **_kwargs): try: - self.vm.kill() + await asyncio.to_thread(self.vm.kill) except exc.QubesException as ex: show_error( _("Error shutting down qube"), _( - "The following error occurred while attempting to shut" - "down qube {0}:\n{1}" + "The following error occurred while attempting to kill" + "qube {0}:\n{1}" ).format(self.vm.name, str(ex)), ) @@ -459,7 +460,11 @@ async def perform_action(self): if self.as_root: service_args["user"] = "root" try: - self.vm.run_service("qubes.StartApp+qubes-run-terminal", **service_args) + await asyncio.to_thread( + self.vm.run_service_for_stdio, + "qubes.StartApp+qubes-run-terminal", + **service_args, + ) except exc.QubesException as ex: show_error( _("Error starting terminal"), @@ -508,7 +513,9 @@ def __init__(self, vm, icon_cache): async def perform_action(self): try: - self.vm.run_service("qubes.StartApp+qubes-open-file-manager") + await asyncio.to_thread( + self.vm.run_service_for_stdio, "qubes.StartApp+qubes-open-file-manager" + ) except exc.QubesException as ex: show_error( _("Error opening file manager"), diff --git a/qui/updater/summary_page.py b/qui/updater/summary_page.py index bf14d4b7..e6632fe1 100644 --- a/qui/updater/summary_page.py +++ b/qui/updater/summary_page.py @@ -31,7 +31,6 @@ from typing import Optional, Any import qubesadmin -from qubesadmin.events.utils import wait_for_domain_shutdown from qubes_config.widgets.gtk_utils import ( load_icon, @@ -308,25 +307,26 @@ def shutdown_domains(self, to_shutdown): """ Try to shut down vms and wait to finish. """ - wait_for = [] - for vm in to_shutdown: - try: - vm.shutdown(force=True) - wait_for.append(vm) - self.log.info("Shutdown %s", vm.name) - except qubesadmin.exc.QubesVMError as err: - self.err += vm.name + " cannot shutdown: " + str(err) + "\n" - self.log.error("Cannot shutdown %s because %s", vm.name, str(err)) - self.status = RestartStatus.ERROR_TMPL_DOWN try: loop = asyncio.get_event_loop() except RuntimeError: # changes between GLib versions and python versions mean that the above # can fail on some dom0/gui domain configurations loop = asyncio.new_event_loop() - loop.run_until_complete(wait_for_domain_shutdown(wait_for)) - - return wait_for + tasks = [asyncio.to_thread(vm.shutdown, force=True, wait=True) for vm in to_shutdown] + results = loop.run_until_complete( + asyncio.gather(*tasks, return_exceptions=True) + ) + done = [] + for vm, res in zip(to_shutdown, results): + if not isinstance(res, BaseException): + self.log.info("Shutdown %s", vm.name) + done.append(vm) + continue + self.err += vm.name + " cannot shutdown: " + str(res) + "\n" + self.log.error("Cannot shutdown %s because %s", vm.name, str(res)) + self.status = RestartStatus.ERROR_TMPL_DOWN + return done def restart_vms(self, to_restart): """ @@ -335,14 +335,23 @@ def restart_vms(self, to_restart): shutdowns = self.shutdown_domains(to_restart) # restart shutdown qubes - for vm in shutdowns: - try: - vm.start() + try: + loop = asyncio.get_event_loop() + except RuntimeError: + # changes between GLib versions and python versions mean that the above + # can fail on some dom0/gui domain configurations + loop = asyncio.new_event_loop() + tasks = [asyncio.to_thread(vm.start) for vm in shutdowns] + results = loop.run_until_complete( + asyncio.gather(*tasks, return_exceptions=True) + ) + for vm, res in zip(shutdowns, results): + if not isinstance(res, qubesadmin.exc.QubesVMError): self.log.info("Restart %s", vm.name) - except qubesadmin.exc.QubesVMError as err: - self.err += vm.name + " cannot start: " + str(err) + "\n" - self.log.error("Cannot start %s because %s", vm.name, str(err)) - self.status = RestartStatus.ERROR_APP_DOWN + continue + self.err += vm.name + " cannot start: " + str(res) + "\n" + self.log.error("Cannot start %s because %s", vm.name, str(res)) + self.status = RestartStatus.ERROR_APP_DOWN def _show_status_dialog(self, show_only_error: bool): if self.status == RestartStatus.OK and not show_only_error: diff --git a/rpm_spec/qubes-desktop-linux-manager.spec.in b/rpm_spec/qubes-desktop-linux-manager.spec.in index e9332033..c14002fa 100644 --- a/rpm_spec/qubes-desktop-linux-manager.spec.in +++ b/rpm_spec/qubes-desktop-linux-manager.spec.in @@ -57,7 +57,7 @@ Requires: python%{python3_pkgversion}-inotify Requires: libappindicator-gtk3 Requires: python%{python3_pkgversion}-systemd Requires: gtk3 -Requires: python%{python3_pkgversion}-qubesadmin >= 4.3.19 +Requires: python%{python3_pkgversion}-qubesadmin >= 4.3.33 # FIXME: we need some way for applying updates from GUI VM #Requires: qubes-mgmt-salt-dom0-update >= 4.0.5 Requires: qubes-artwork >= 4.1.5 From f07f9ed794cdb015dd276e9856df6a0074932581 Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Fri, 22 May 2026 14:48:02 +0200 Subject: [PATCH 2/7] Use admin utils wrapper for vm actions --- qui/updater/summary_page.py | 71 +++++++++++--------------- qui/updater/tests/test_summary_page.py | 4 +- 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/qui/updater/summary_page.py b/qui/updater/summary_page.py index e6632fe1..1f1ccfd7 100644 --- a/qui/updater/summary_page.py +++ b/qui/updater/summary_page.py @@ -31,6 +31,8 @@ from typing import Optional, Any import qubesadmin +from qubesadmin.utils.shutdown import shutdown +from qubesadmin.utils.start import start from qubes_config.widgets.gtk_utils import ( load_icon, @@ -296,61 +298,48 @@ def perform_restart(self): # clear err and perform shutdown/start self.err = "" - self.shutdown_domains(tmpls_to_shutdown) - self.restart_vms(to_restart) - self.shutdown_domains(to_shutdown) + try: + loop = asyncio.get_event_loop() + except RuntimeError: + # changes between GLib versions and python versions mean that the above + # can fail on some dom0/gui domain configurations + loop = asyncio.new_event_loop() + loop.run_until_complete(self.shutdown_domains(tmpls_to_shutdown)) + loop.run_until_complete(self.restart_vms(to_restart)) + loop.run_until_complete(self.shutdown_domains(to_shutdown)) if self.status is RestartStatus.NONE: self.status = RestartStatus.OK - def shutdown_domains(self, to_shutdown): + async def shutdown_domains(self, to_shutdown): """ Try to shut down vms and wait to finish. """ - try: - loop = asyncio.get_event_loop() - except RuntimeError: - # changes between GLib versions and python versions mean that the above - # can fail on some dom0/gui domain configurations - loop = asyncio.new_event_loop() - tasks = [asyncio.to_thread(vm.shutdown, force=True, wait=True) for vm in to_shutdown] - results = loop.run_until_complete( - asyncio.gather(*tasks, return_exceptions=True) - ) - done = [] - for vm, res in zip(to_shutdown, results): - if not isinstance(res, BaseException): - self.log.info("Shutdown %s", vm.name) - done.append(vm) - continue - self.err += vm.name + " cannot shutdown: " + str(res) + "\n" - self.log.error("Cannot shutdown %s because %s", vm.name, str(res)) - self.status = RestartStatus.ERROR_TMPL_DOWN + failed = await shutdown(domains=to_shutdown, force=True, wait=True) + if not failed: + return to_shutdown + self.status = RestartStatus.ERROR_TMPL_DOWN + all_failed = [] + for qube, exc in failed.items(): + all_failed.append(qube) + self.err += qube.name + " cannot shutdown: unknown reason\n" + self.log.error("Cannot shutdown %s: %s", qube.name, str(exc)) + done = [qube for qube in to_shutdown if qube not in all_failed] return done - def restart_vms(self, to_restart): + async def restart_vms(self, to_restart): """ Try to restart vms. """ - shutdowns = self.shutdown_domains(to_restart) + shutdowns = await self.shutdown_domains(to_restart) # restart shutdown qubes - try: - loop = asyncio.get_event_loop() - except RuntimeError: - # changes between GLib versions and python versions mean that the above - # can fail on some dom0/gui domain configurations - loop = asyncio.new_event_loop() - tasks = [asyncio.to_thread(vm.start) for vm in shutdowns] - results = loop.run_until_complete( - asyncio.gather(*tasks, return_exceptions=True) - ) - for vm, res in zip(shutdowns, results): - if not isinstance(res, qubesadmin.exc.QubesVMError): - self.log.info("Restart %s", vm.name) - continue - self.err += vm.name + " cannot start: " + str(res) + "\n" - self.log.error("Cannot start %s because %s", vm.name, str(res)) + failed = await start(domains=shutdowns) + if not failed: + return + for qube, exc in failed.items(): + self.err += qube.name + " cannot start: " + str(exc) + "\n" + self.log.error("Cannot start %s: %s", qube.name, str(exc)) self.status = RestartStatus.ERROR_APP_DOWN def _show_status_dialog(self, show_only_error: bool): diff --git a/qui/updater/tests/test_summary_page.py b/qui/updater/tests/test_summary_page.py index 6e19fafc..7c60130e 100644 --- a/qui/updater/tests/test_summary_page.py +++ b/qui/updater/tests/test_summary_page.py @@ -370,9 +370,7 @@ def set_deletable(self, deletable): mock_show_dialog.assert_has_calls(calls) -@patch("qui.updater.summary_page.wait_for_domain_shutdown") def test_perform_restart( - _mock_wait_for_domain_shutdown, test_qapp, real_builder, mock_next_button, @@ -405,7 +403,7 @@ def test_perform_restart( "vault", ) expected_shutdown_calls = [ - (tmpl, "admin.vm.Shutdown", "force", None) for tmpl in to_shutdown + (tmpl, "admin.vm.Shutdown", "force+wait", None) for tmpl in to_shutdown ] for call_ in expected_shutdown_calls: test_qapp.expected_calls[call_] = b"0\x00" From 7b6850d60cb5cdac2e48895ff5a51f1608d813da Mon Sep 17 00:00:00 2001 From: Jayant-Kernel Date: Tue, 12 May 2026 10:51:17 +0530 Subject: [PATCH 3/7] Adjust buttons on shutdown failure Co-authored-by: Ben Grande Fixes: https://github.com/QubesOS/qubes-issues/issues/10651 Fixes: https://github.com/QubesOS/qubes-issues/issues/10835 --- qui/tray/domains.py | 258 ++++++++++++++++++++++---------------------- 1 file changed, 127 insertions(+), 131 deletions(-) diff --git a/qui/tray/domains.py b/qui/tray/domains.py index c23736cb..b684ff5c 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -192,21 +192,36 @@ async def perform_action(self): class ShutdownItem(VMActionMenuItem): """Shutdown menu Item. When activated shutdowns the domain.""" - def __init__(self, vm, icon_cache, force=False): + def __init__( + self, + vm, + icon_cache, + force=False, + follow_shift=True, + label=None, + force_label=None, + icon_name="shutdown", + ): if force: super().__init__( vm, - label=_("Force shutdown"), + label=force_label or _("Force shutdown"), icon_cache=icon_cache, - icon_name="shutdown", + icon_name=icon_name, ) else: super().__init__( - vm, label=_("Shutdown"), icon_cache=icon_cache, icon_name="shutdown" + vm, + label=label or _("Shutdown"), + icon_cache=icon_cache, + icon_name=icon_name, ) self.force = force + self.follow_shift = follow_shift def set_force(self, force): + if not self.follow_shift: + return self.force = force if self.force: self.label.set_text(_("Force shutdown")) @@ -217,58 +232,52 @@ async def perform_action(self): try: await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True) except exc.QubesException as ex: - if self.force: - show_error( - _("Error shutting down qube"), - _( - "The following error occurred while attempting to " - "shut down qube {0}:\n{1}" - ).format(self.vm.name, str(ex)), - ) - return - if isinstance(ex, exc.QubesVMInUseError): - title = _("Qube {0} is in use").format(self.vm.name) - markup = _( - "The qube {0} could not be shut down because it " - "is in use by connected qubes.\n\n" - "Warning: force shutdown may cause unexpected " - "issues in connected qubes." - ).format(self.vm.name) - button_label = _("Force shutdown") - action = "force" - elif isinstance(ex, exc.QubesVMShutdownTimeoutError): - title = _("Qube {0} shutdown timed out").format(self.vm.name) - markup = _( - "The qube {0} did not shut down within the " - "expected time.\n\nYou can retry the shutdown or, " - "if the problem persists, kill the qube." - ).format(self.vm.name) - button_label = None # timeout uses custom buttons below - action = "timeout" - else: - title = _("Error shutting down qube {0}").format(self.vm.name) - markup = _( - "The qube {0} could not be shut down. " - "The following error occurred:\n" - "{1}\n\n" - "Would you like to kill the qube?" - ).format(self.vm.name, str(ex)) - button_label = _("Kill") - action = "kill" - - dialog = Gtk.MessageDialog( - None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.NONE - ) - dialog.set_title(title) - dialog.set_markup(markup) - dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) - if action == "timeout": - dialog.add_button(_("Retry shutdown"), Gtk.ResponseType.OK) - dialog.add_button(_("Kill"), Gtk.ResponseType.YES) - else: - dialog.add_button(button_label, Gtk.ResponseType.OK) - dialog.connect("response", self.react_to_question, action) - GLib.idle_add(dialog.show) + self.show_shutdown_dialog(ex) + + def shutdown_exception_body(self, ex): + if isinstance(ex, exc.QubesVMInUseError): + title = _("Qube {0} is in use").format(self.vm.name) + markup = _( + "The qube {0} could not be shut down because it " + "is in use by connected qubes.\n\n" + "Warning: force shutdown may cause unexpected " + "issues in connected qubes." + ).format(self.vm.name) + action = "force" + elif isinstance(ex, exc.QubesVMShutdownTimeoutError): + title = _("Qube {0} shutdown timed out").format(self.vm.name) + markup = _( + "The qube {0} did not shut down within the " + "expected time.\n\nYou can retry the shutdown or, " + "if the problem persists, kill the qube." + ).format(self.vm.name) + action = "timeout" + else: + title = _("Error shutting down qube {0}").format(self.vm.name) + markup = _( + "The qube {0} could not be shut down. " + "The following error occurred:\n" + "{1}\n\n" + "Would you like to kill the qube?" + ).format(self.vm.name, str(ex)) + action = "kill" + return title, markup, action + + def show_shutdown_dialog(self, ex): + title, markup, action = self.shutdown_exception_body(ex) + dialog = Gtk.MessageDialog(None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.NONE) + dialog.set_title(title) + dialog.set_markup(markup) + dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) + if action == "force": + dialog.add_button(_("Force shutdown"), Gtk.ResponseType.OK) + elif action == "timeout": + dialog.add_button(_("Retry shutdown"), Gtk.ResponseType.OK) + dialog.add_button(_("Kill"), Gtk.ResponseType.YES) + elif action == "kill": + dialog.add_button(_("Kill"), Gtk.ResponseType.OK) + dialog.connect("response", self.react_to_question, action) + GLib.idle_add(dialog.show) def react_to_question(self, widget, response, action): asyncio.create_task(self.react_to_question_async(widget, response, action)) @@ -278,40 +287,36 @@ async def react_to_question_async(self, widget, response, action): widget.destroy() return try: - if action == "force": - await asyncio.to_thread(self.vm.shutdown, force=True, wait=True) - elif action == "timeout": - if response == Gtk.ResponseType.YES: - await asyncio.to_thread(self.vm.kill) - elif response == Gtk.ResponseType.OK: - await asyncio.to_thread(self.vm.shutdown, force=False, wait=True) - elif action == "kill" and response == Gtk.ResponseType.OK: - await asyncio.to_thread(self.vm.kill) + await self.shutdown_from_response(response, action) except exc.QubesException as ex: - show_error( - _("Error shutting down qube"), - _( - "The following error occurred while attempting to " - "shut down qube {0}:\n{1}" - ).format(self.vm.name, str(ex)), - ) + self.show_shutdown_dialog(ex) widget.destroy() + async def shutdown_from_response(self, response, action): + if action == "force": + await asyncio.to_thread(self.vm.shutdown, force=True, wait=True) + elif action == "timeout": + if response == Gtk.ResponseType.YES: + await asyncio.to_thread(self.vm.kill) + elif response == Gtk.ResponseType.OK: + await asyncio.to_thread(self.vm.shutdown, force=False, wait=True) + elif action == "kill" and response == Gtk.ResponseType.OK: + await asyncio.to_thread(self.vm.kill) -class RestartItem(VMActionMenuItem): + +class RestartItem(ShutdownItem): """Restart menu Item. When activated shutdowns the domain and then starts it again.""" def __init__(self, vm, icon_cache, force=False): - if force: - super().__init__( - vm, label=_("Force restart"), icon_cache=icon_cache, icon_name="restart" - ) - else: - super().__init__( - vm, label=_("Restart"), icon_cache=icon_cache, icon_name="restart" - ) - self.force = force + super().__init__( + vm, + icon_cache, + force=force, + label=_("Restart"), + force_label=_("Force restart"), + icon_name="restart", + ) self.give_up = False def set_force(self, force): @@ -325,62 +330,32 @@ async def perform_action(self, *_args, **_kwargs): try: await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True) except exc.QubesException as ex: - if self.force: - # we already tried forcing it, let's just give up - show_error( - _("Error restarting qube"), - _( - "The following error occurred while attempting to restart" - "qube {0}:\n{1}" - ).format(self.vm.name, str(ex)), - ) - return - dialog = Gtk.MessageDialog( - None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK_CANCEL - ) - dialog.set_title("Error restarting qube") - dialog.set_markup( - f"The qube {self.vm.name} couldn't be shut down " - "normally. The following error occurred: \n" - f"{str(ex)}\n\n" - "Do you want to force shutdown? \n\nWarning: " - "this may cause unexpected issues in connected qubes." - ) - dialog.connect("response", self.react_to_question) - GLib.idle_add(dialog.show) - + self.show_shutdown_dialog(ex) + if self.give_up: + return try: - while self.vm.is_running(): - if self.give_up: - return - await asyncio.sleep(1) await asyncio.to_thread(self.vm.start) except exc.QubesException as ex: show_error( _("Error restarting qube"), _( - "The following error occurred while attempting to restart" - "qube {0}:\n{1}" + "The following error occurred while attempting to start" + " qube {0}:\n{1}" ).format(self.vm.name, str(ex)), ) - def react_to_question(self, widget, response): - asyncio.create_task(self.react_to_question_async(widget, response)) + def react_to_question(self, widget, response, action): + asyncio.create_task(self.react_to_question_async(widget, response, action)) - async def react_to_question_async(self, widget, response): - if response == Gtk.ResponseType.OK: - try: - await asyncio.to_thread(self.vm.shutdown, force=True, wait=True) - except exc.QubesException as ex: - show_error( - _("Error shutting down qube"), - _( - "The following error occurred while attempting to " - "shut down qube {0}:\n{1}" - ).format(self.vm.name, str(ex)), - ) - self.give_up = True - else: + async def react_to_question_async(self, widget, response, action): + if response not in (Gtk.ResponseType.OK, Gtk.ResponseType.YES): + self.give_up = True + widget.destroy() + return + try: + await self.shutdown_from_response(response, action) + except exc.QubesException as ex: + self.show_shutdown_dialog(ex) self.give_up = True widget.destroy() @@ -554,7 +529,7 @@ def __init__(self, is_preload=False): class StartedMenu(Gtk.Menu): """The sub-menu for a started domain""" - def __init__(self, vm, app, icon_cache): + def __init__(self, vm, app, icon_cache, shutdown_failed=False): super().__init__() self.vm = vm self.app = app @@ -568,7 +543,17 @@ def __init__(self, vm, app, icon_cache): self.add(PreferencesItem(self.vm, icon_cache)) self.add(PauseItem(self.vm, icon_cache)) - self.add(ShutdownItem(self.vm, icon_cache, force=app.shift_pressed)) + self.add( + ShutdownItem( + self.vm, + icon_cache, + force=app.shift_pressed and not shutdown_failed, + follow_shift=not shutdown_failed, + ) + ) + if shutdown_failed: + self.add(ShutdownItem(self.vm, icon_cache, force=True, follow_shift=False)) + self.add(KillItem(self.vm, icon_cache)) if self.vm.klass != "DispVM" or not self.vm.auto_cleanup: self.add(RestartItem(self.vm, icon_cache, force=app.shift_pressed)) @@ -716,6 +701,7 @@ def __init__(self, vm, app, icon_cache, state=None): self.vm = vm self.app = app self.icon_cache = icon_cache + self.shutdown_failed = False self.decorator = qui.decorators.DomainDecorator(vm) # Main horizontal box @@ -773,7 +759,12 @@ def _set_submenu(self, state): is_preload=is_preload, ) elif state == "Running": - submenu = StartedMenu(self.vm, self.app, self.icon_cache) + submenu = StartedMenu( + self.vm, + self.app, + self.icon_cache, + shutdown_failed=self.shutdown_failed, + ) elif state == "Paused": submenu = PausedMenu(self.vm, self.icon_cache) else: @@ -1189,6 +1180,11 @@ def update_domain_item(self, vm, event, **kwargs): # it's a fragile DispVM state = "Transient" + if event == "domain-shutdown-failed": + item.shutdown_failed = True + elif event in ("domain-start", "domain-pre-start", "domain-shutdown"): + item.shutdown_failed = False + item.update_state(state) if event == "property-reset:is_preload": From 69d49fbd8e6eb5dcd729dd19ed99739e574f5227 Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Wed, 27 May 2026 11:57:28 +0200 Subject: [PATCH 4/7] Restart qube when retrying shutdown --- qui/tray/domains.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/qui/tray/domains.py b/qui/tray/domains.py index b684ff5c..e1f40119 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -326,11 +326,7 @@ def set_force(self, force): else: self.label.set_text(_("Restart")) - async def perform_action(self, *_args, **_kwargs): - try: - await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True) - except exc.QubesException as ex: - self.show_shutdown_dialog(ex) + async def start(self): if self.give_up: return try: @@ -344,6 +340,14 @@ async def perform_action(self, *_args, **_kwargs): ).format(self.vm.name, str(ex)), ) + async def perform_action(self, *_args, **_kwargs): + try: + await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True) + except exc.QubesException as ex: + self.show_shutdown_dialog(ex) + else: + await self.start() + def react_to_question(self, widget, response, action): asyncio.create_task(self.react_to_question_async(widget, response, action)) @@ -356,7 +360,8 @@ async def react_to_question_async(self, widget, response, action): await self.shutdown_from_response(response, action) except exc.QubesException as ex: self.show_shutdown_dialog(ex) - self.give_up = True + else: + await self.start() widget.destroy() From 62c095fbfec590ae8c13c769c7989ca4728f71c5 Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Wed, 27 May 2026 10:52:38 +0200 Subject: [PATCH 5/7] Destroy widget on early reaction Allows the widget to be destroyed before the action completes. --- qui/tray/domains.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/qui/tray/domains.py b/qui/tray/domains.py index e1f40119..398bcec0 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -283,14 +283,11 @@ def react_to_question(self, widget, response, action): asyncio.create_task(self.react_to_question_async(widget, response, action)) async def react_to_question_async(self, widget, response, action): - if response not in (Gtk.ResponseType.OK, Gtk.ResponseType.YES): - widget.destroy() - return + widget.destroy() try: await self.shutdown_from_response(response, action) except exc.QubesException as ex: self.show_shutdown_dialog(ex) - widget.destroy() async def shutdown_from_response(self, response, action): if action == "force": @@ -352,9 +349,9 @@ def react_to_question(self, widget, response, action): asyncio.create_task(self.react_to_question_async(widget, response, action)) async def react_to_question_async(self, widget, response, action): + widget.destroy() if response not in (Gtk.ResponseType.OK, Gtk.ResponseType.YES): self.give_up = True - widget.destroy() return try: await self.shutdown_from_response(response, action) @@ -362,7 +359,6 @@ async def react_to_question_async(self, widget, response, action): self.show_shutdown_dialog(ex) else: await self.start() - widget.destroy() class KillItem(VMActionMenuItem): From e6894e057431a752ed3167ac7060a97ccd76cd48 Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Wed, 27 May 2026 11:10:55 +0200 Subject: [PATCH 6/7] Force shutdown from now on when requested once --- qui/tray/domains.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/qui/tray/domains.py b/qui/tray/domains.py index 398bcec0..6f9f42de 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -272,7 +272,10 @@ def show_shutdown_dialog(self, ex): if action == "force": dialog.add_button(_("Force shutdown"), Gtk.ResponseType.OK) elif action == "timeout": - dialog.add_button(_("Retry shutdown"), Gtk.ResponseType.OK) + if self.force: + dialog.add_button(_("Retry force shutdown"), Gtk.ResponseType.OK) + else: + dialog.add_button(_("Retry shutdown"), Gtk.ResponseType.OK) dialog.add_button(_("Kill"), Gtk.ResponseType.YES) elif action == "kill": dialog.add_button(_("Kill"), Gtk.ResponseType.OK) @@ -291,12 +294,13 @@ async def react_to_question_async(self, widget, response, action): async def shutdown_from_response(self, response, action): if action == "force": + self.set_force(True) await asyncio.to_thread(self.vm.shutdown, force=True, wait=True) elif action == "timeout": if response == Gtk.ResponseType.YES: await asyncio.to_thread(self.vm.kill) elif response == Gtk.ResponseType.OK: - await asyncio.to_thread(self.vm.shutdown, force=False, wait=True) + await asyncio.to_thread(self.vm.shutdown, force=self.force, wait=True) elif action == "kill" and response == Gtk.ResponseType.OK: await asyncio.to_thread(self.vm.kill) From ad0f5e38682526c0ee5b9a41e60bf281504d0599 Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Wed, 27 May 2026 11:18:29 +0200 Subject: [PATCH 7/7] Cleanup shutdown label initialization --- qui/tray/domains.py | 20 ++++++++------------ qui/updater/summary_page.py | 9 +++++---- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/qui/tray/domains.py b/qui/tray/domains.py index 6f9f42de..8b541c6b 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -203,19 +203,15 @@ def __init__( icon_name="shutdown", ): if force: - super().__init__( - vm, - label=force_label or _("Force shutdown"), - icon_cache=icon_cache, - icon_name=icon_name, - ) + init_label = force_label or _("Force shutdown") else: - super().__init__( - vm, - label=label or _("Shutdown"), - icon_cache=icon_cache, - icon_name=icon_name, - ) + init_label = label or _("Shutdown") + super().__init__( + vm, + label=init_label, + icon_cache=icon_cache, + icon_name=icon_name, + ) self.force = force self.follow_shift = follow_shift diff --git a/qui/updater/summary_page.py b/qui/updater/summary_page.py index 1f1ccfd7..88c3e479 100644 --- a/qui/updater/summary_page.py +++ b/qui/updater/summary_page.py @@ -31,8 +31,7 @@ from typing import Optional, Any import qubesadmin -from qubesadmin.utils.shutdown import shutdown -from qubesadmin.utils.start import start +import qubesadmin.utils from qubes_config.widgets.gtk_utils import ( load_icon, @@ -315,7 +314,9 @@ async def shutdown_domains(self, to_shutdown): """ Try to shut down vms and wait to finish. """ - failed = await shutdown(domains=to_shutdown, force=True, wait=True) + failed = await qubesadmin.utils.shutdown( + domains=to_shutdown, force=True, wait=True + ) if not failed: return to_shutdown self.status = RestartStatus.ERROR_TMPL_DOWN @@ -334,7 +335,7 @@ async def restart_vms(self, to_restart): shutdowns = await self.shutdown_domains(to_restart) # restart shutdown qubes - failed = await start(domains=shutdowns) + failed = await qubesadmin.utils.start(domains=shutdowns) if not failed: return for qube, exc in failed.items():