diff --git a/qubes_config/tests/test_gtk_utils.py b/qubes_config/tests/test_gtk_utils.py index c9f95fd7..c01ba97b 100644 --- a/qubes_config/tests/test_gtk_utils.py +++ b/qubes_config/tests/test_gtk_utils.py @@ -18,7 +18,8 @@ # You should have received a copy of the GNU Lesser General Public License along # with this program; if not, see . """Tests for gtk utils""" -from unittest.mock import patch, call +import asyncio +from unittest.mock import patch, call, Mock import gi @@ -32,6 +33,8 @@ ask_question, show_error, is_theme_light, + show_dialog_with_icon_async, + RESPONSES_OK, ) @@ -74,6 +77,35 @@ def test_ask_question(): assert call.new().destroy() in mock_dialog.mock_calls +def test_show_dialog_with_icon_async_without_running_loop(): + captured = {} + mock_dialog = Mock() + mock_dialog.connect.side_effect = captured.setdefault + + with patch( + "qubes_config.widgets.gtk_utils._setup_dialog", + return_value=mock_dialog, + ): + coro = show_dialog_with_icon_async( + None, "Title", "Text", RESPONSES_OK, "qubes-info" + ) + + # step to `await future`: must reach it (fallback loop) instead of + # raising RuntimeError('no running event loop') + pending = coro.send(None) + assert asyncio.isfuture(pending) + + captured["response"](mock_dialog, Gtk.ResponseType.OK) # click + + try: + coro.send(None) + except StopIteration as stop: + assert stop.value == Gtk.ResponseType.OK + mock_dialog.destroy.assert_called_once() + else: + raise AssertionError("coroutine did not finish after response") + + def test_get_theme(): """test getting light/dark theme""" # first test dark theme diff --git a/qubes_config/widgets/gtk_utils.py b/qubes_config/widgets/gtk_utils.py index 7406fb0b..d9469e20 100644 --- a/qubes_config/widgets/gtk_utils.py +++ b/qubes_config/widgets/gtk_utils.py @@ -18,6 +18,7 @@ # You should have received a copy of the GNU Lesser General Public License along # with this program; if not, see . """Utility functions using Gtk""" +import asyncio import contextlib import importlib.resources import os @@ -114,20 +115,21 @@ def ask_question(parent, title: str, text: str): ) -def show_dialog_with_icon( +def _setup_dialog( parent: Optional[Gtk.Widget], title: str, text: Union[str, Gtk.Widget], buttons: Dict[str, Gtk.ResponseType], icon_name: str, -) -> Gtk.ResponseType: - """ - Helper function to show a dialog with icon given by name. - """ +) -> Gtk.Dialog: icon = Gtk.Image.new_from_pixbuf(load_icon(icon_name, 48, 48)) - dialog = show_dialog(parent, title, text, buttons, icon) - response = dialog.run() - dialog.destroy() + return show_dialog(parent, title, text, buttons, icon) + + +def _process_response( + response: Gtk.ResponseType, + buttons: Dict[str, Gtk.ResponseType], +) -> Gtk.ResponseType: if response == Gtk.ResponseType.DELETE_EVENT: if Gtk.ResponseType.CANCEL in buttons.values(): # treat exiting from the window as cancel if it's one of the @@ -139,6 +141,55 @@ def show_dialog_with_icon( return response +def show_dialog_with_icon( + parent: Optional[Gtk.Widget], + title: str, + text: Union[str, Gtk.Widget], + buttons: Dict[str, Gtk.ResponseType], + icon_name: str, +) -> Gtk.ResponseType: + """Helper function to show a dialog with icon given by name.""" + dialog = _setup_dialog(parent, title, text, buttons, icon_name) + + response = dialog.run() + dialog.destroy() + + return _process_response(response, buttons) + + +async def show_dialog_with_icon_async( + parent: Optional[Gtk.Widget], + title: str, + text: Union[str, Gtk.Widget], + buttons: Dict[str, Gtk.ResponseType], + icon_name: str, +) -> Gtk.ResponseType: + """Async, non-blocking variant of :py:func:`show_dialog_with_icon`.""" + dialog = _setup_dialog(parent, title, text, buttons, icon_name) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # To be solved during migration to newer asyncio versions. + # May be awaited before the loop is "running", e.g. + # the updater's "nothing to do" dialog runs during GApplication.run(), + # where there is not set the running loop yet. + loop = asyncio.get_event_loop() + future: asyncio.Future = loop.create_future() + + def _on_response(_dialog, response_): + if not future.done(): + future.set_result(response_) + + dialog.connect("response", _on_response) + + try: + response = await future + finally: + dialog.destroy() + + return _process_response(response, buttons) + + def show_dialog( parent: Gtk.Widget, title: str, diff --git a/qui/updater/progress_page.py b/qui/updater/progress_page.py index 6c5e86fc..0da57078 100644 --- a/qui/updater/progress_page.py +++ b/qui/updater/progress_page.py @@ -18,11 +18,15 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. +import asyncio +import contextlib +import errno +import gc +import io import re import signal import subprocess -import threading -import time +import sys import gi from typing import Dict @@ -38,6 +42,37 @@ from qui.updater.utils import UpdateStatus, RowWrapper +@contextlib.contextmanager +def pipe_ebadf_silencer(): + """Workaround: Silence "Exception ignored ... Bad file descriptor". + + To be fixed in newer versions of asyncio and GTK. + + asyncio.create_subprocess_exec's stdout/stderr pipe fds are double-closed + at GC under gi.events GLibEventLoop (by loop and by FileIO finalizer). + Drop exactly that case and let others unraisable exception through. + """ + previous_hook = sys.unraisablehook + + def hook(unraisable): + exc = unraisable.exc_value + obj = unraisable.object + if ( + isinstance(exc, OSError) + and exc.errno == errno.EBADF + and isinstance(obj, io.FileIO) + ): + return + previous_hook(unraisable) + + sys.unraisablehook = hook + try: + yield + finally: + gc.collect() + sys.unraisablehook = previous_hook + + class ProgressPage: def __init__( @@ -50,7 +85,7 @@ def __init__( self.cancel_button = cancel_button self.vms_to_update = None self.exit_triggered = False - self.update_thread = None + self.update_task = None self.after_update_callback = callback self.retcode = None @@ -84,7 +119,7 @@ def is_visible(self): return self.stack.get_visible_child() == self.page def init_update(self, vms_to_update, settings): - """Starts `perform_update` in new thread.""" + """Schedules `perform_update` as an asyncio task on the main loop.""" self.log.info("Prepare updating") self.vms_to_update = vms_to_update self.progress_list.set_model(vms_to_update.list_store_raw) @@ -96,10 +131,8 @@ def init_update(self, vms_to_update, settings): self.header_label.set_text(l("Update in progress...")) self.header_label.set_halign(Gtk.Align.CENTER) - self.update_thread = threading.Thread( - target=self.perform_update, args=(settings,) - ) - self.update_thread.start() + loop = asyncio.get_running_loop() + self.update_task = loop.create_task(self.perform_update(settings)) def interrupt_update(self): """ @@ -109,19 +142,19 @@ def interrupt_update(self): self.exit_triggered = True GLib.idle_add(self.header_label.set_text, l("Interrupting the update...")) - def perform_update(self, settings): + async def perform_update(self, settings): """Uses qubes-vm-update to update dom0 and then other vms.""" GLib.idle_add(self.set_total_progress, 0) if self.vms_to_update: - self.update_selected(self.vms_to_update, settings) + await self.update_selected(self.vms_to_update, settings) GLib.idle_add(self.header_label.set_text, l("Update finished")) GLib.idle_add(self.cancel_button.set_visible, False) GLib.idle_add(self.next_button.set_sensitive, True) self.after_update_callback() - def update_selected(self, to_update, settings): + async def update_selected(self, to_update, settings): """Updates templates and standalones and then sets update statuses.""" if self.exit_triggered: self.log.info("Update canceled: skip templateVM updating") @@ -143,7 +176,7 @@ def update_selected(self, to_update, settings): try: rows = {row.name: row for row in to_update} - self.do_update_selected(rows, settings) + await self.do_update_selected(rows, settings) GLib.idle_add(self.set_total_progress, 100) except subprocess.CalledProcessError as ex: for row in to_update: @@ -156,7 +189,7 @@ def update_selected(self, to_update, settings): GLib.idle_add(row.set_status, UpdateStatus.Error) self.update_details.update_buffer() - def do_update_selected(self, rows: Dict[str, RowWrapper], settings: Settings): + async def do_update_selected(self, rows: Dict[str, RowWrapper], settings: Settings): """Runs `qubes-vm-update` command.""" targets = ",".join((name for name in rows.keys())) @@ -164,9 +197,8 @@ def do_update_selected(self, rows: Dict[str, RowWrapper], settings: Settings): if settings.max_concurrency is not None: args.extend(("--max-concurrency", str(settings.max_concurrency))) - # pylint: disable=consider-using-with - proc = subprocess.Popen( - [ + with pipe_ebadf_silencer(): + proc = await asyncio.create_subprocess_exec( "qubes-vm-update", "--show-output", "--just-print-progress", @@ -174,36 +206,35 @@ def do_update_selected(self, rows: Dict[str, RowWrapper], settings: Settings): *args, "--targets", targets, - ], - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - ) + stderr=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) - read_err_thread = threading.Thread(target=self.read_stderrs, args=(proc, rows)) - read_out_thread = threading.Thread(target=self.read_stdouts, args=(proc, rows)) - read_err_thread.start() - read_out_thread.start() + reading = asyncio.gather( + self.read_stderrs(proc, rows), + self.read_stdouts(proc, rows), + ) - while ( - proc.poll() is None - or read_out_thread.is_alive() - or read_err_thread.is_alive() - ): - time.sleep(1) - if self.exit_triggered and proc.poll() is None: - proc.send_signal(signal.SIGINT) - proc.wait() - read_err_thread.join() - read_out_thread.join() - self.retcode = proc.returncode - - def read_stderrs(self, proc, rows): - for untrusted_line in iter(proc.stderr.readline, ""): - if untrusted_line: - self.handle_err_line(untrusted_line, rows) - else: + while proc.returncode is None: + try: + await asyncio.wait_for(proc.wait(), timeout=1) + except asyncio.TimeoutError: + if self.exit_triggered: + proc.send_signal(signal.SIGINT) + await proc.wait() + + await reading + self.retcode = proc.returncode + # allow gc.collect() finalize the pipe transports + del proc + del reading + + async def read_stderrs(self, proc, rows): + while True: + untrusted_line = await proc.stderr.readline() + if not untrusted_line: break - proc.stderr.close() + self.handle_err_line(untrusted_line, rows) def handle_err_line(self, untrusted_line, rows): line = self._sanitize_line(untrusted_line) @@ -228,29 +259,28 @@ def handle_err_line(self, untrusted_line, rows): except KeyError: return - def read_stdouts(self, proc, rows): + async def read_stdouts(self, proc, rows): curr_name_out = "" - for untrusted_line in iter(proc.stdout.readline, ""): - if untrusted_line: - line = self._sanitize_line(untrusted_line) - try: - maybe_name, text = line.split(" ", 1) - except ValueError: - continue - suffix = len(":out:") - if maybe_name[:-suffix] in rows.keys(): - curr_name_out = maybe_name[:-suffix] - if curr_name_out: - rows[curr_name_out].append_text_view(text) - if ( - self.update_details.active_row is not None - and curr_name_out == self.update_details.active_row.name - ): - self.update_details.update_buffer() - else: + while True: + untrusted_line = await proc.stdout.readline() + if not untrusted_line: break + line = self._sanitize_line(untrusted_line) + try: + maybe_name, text = line.split(" ", 1) + except ValueError: + continue + suffix = len(":out:") + if maybe_name[:-suffix] in rows.keys(): + curr_name_out = maybe_name[:-suffix] + if curr_name_out: + rows[curr_name_out].append_text_view(text) + if ( + self.update_details.active_row is not None + and curr_name_out == self.update_details.active_row.name + ): + self.update_details.update_buffer() self.update_details.update_buffer() - proc.stdout.close() @staticmethod def _sanitize_line(untrusted_line: bytes) -> str: diff --git a/qui/updater/summary_page.py b/qui/updater/summary_page.py index bf14d4b7..6baa8320 100644 --- a/qui/updater/summary_page.py +++ b/qui/updater/summary_page.py @@ -19,8 +19,6 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. import asyncio -import threading -import time from enum import Enum from gettext import ngettext @@ -31,13 +29,12 @@ from typing import Optional, Any import qubesadmin -from qubesadmin.events.utils import wait_for_domain_shutdown +import qubesadmin.utils from qubes_config.widgets.gtk_utils import ( load_icon, show_dialog, - show_dialog_with_icon, - show_error, + show_dialog_with_icon_async, RESPONSES_OK, ) from qubes_config.widgets.utils import get_boolean_feature @@ -67,7 +64,7 @@ def __init__(self, builder, log, next_button, cancel_button, back_by_row_selecti self.log = log self.next_button = next_button self.cancel_button = cancel_button - self.restart_thread = None + self.restart_task = None self.disable_checkboxes = False self.status = RestartStatus.NONE self.err = "" @@ -236,18 +233,16 @@ def select_rows(self): or AppVMType.EXCLUDED in self.head_checkbox.allowed ) - def restart_selected_vms(self, show_only_error: bool): + async def restart_selected_vms(self, show_only_error: bool): self.log.debug("Start restarting") - self.restart_thread = threading.Thread(target=self.perform_restart) - - self.restart_thread.start() + loop = asyncio.get_running_loop() + self.restart_task = loop.create_task(self.perform_restart()) spinner = None dialog = None # wait a little to check if waiting dialog is needed at all - time.sleep(0.01) - - if self.restart_thread.is_alive(): + done, _pending = await asyncio.wait({self.restart_task}, timeout=0.1) + if not done: # show waiting dialog spinner = Gtk.Spinner() spinner.start() @@ -262,20 +257,17 @@ def restart_selected_vms(self, show_only_error: bool): dialog.show() self.log.debug("Show restart dialog") - # wait for thread and spin spinner - while self.restart_thread.is_alive(): - while Gtk.events_pending(): - Gtk.main_iteration() - time.sleep(0.1) + # wait for the restart to finish + await self.restart_task # cleanup if dialog: spinner.stop() dialog.destroy() self.log.debug("Hide restart dialog") - self._show_status_dialog(show_only_error) + await self._show_status_dialog(show_only_error) - def perform_restart(self): + async def perform_restart(self): tmpls_to_shutdown = [ row.vm for row in self.updated_tmpls if row.vm.is_running() @@ -297,67 +289,59 @@ 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) + await self.shutdown_domains(tmpls_to_shutdown) + await self.restart_vms(to_restart) + await 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. """ - 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 - - def restart_vms(self, to_restart): + failed = await qubesadmin.utils.shutdown( + domains=to_shutdown, force=True, wait=True + ) + + if not failed: + return to_shutdown + + self.status = RestartStatus.ERROR_TMPL_DOWN + for qube, exc in failed.items(): + self.err += f"{qube.name} cannot shutdown: {exc}\n" + self.log.error("Cannot shutdown %s: %s", qube.name, str(exc)) + return [qube for qube in to_shutdown if qube not in failed] + + async def restart_vms(self, to_restart): """ Try to restart vms. """ - shutdowns = self.shutdown_domains(to_restart) - - # restart shutdown qubes - for vm in shutdowns: - try: - vm.start() - 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 - - def _show_status_dialog(self, show_only_error: bool): + shutdowns = await self.shutdown_domains(to_restart) + + # restart the qubes that were successfully shut down + failed = await qubesadmin.utils.start(domains=shutdowns) + 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 + + async def _show_status_dialog(self, show_only_error: bool): if self.status == RestartStatus.OK and not show_only_error: - show_dialog_with_icon( + await show_dialog_with_icon_async( None, l("Success"), l("All qubes were restarted/shutdown successfully."), - buttons=RESPONSES_OK, - icon_name="qubes-check-yes", + RESPONSES_OK, + "qubes-check-yes", ) elif self.status.is_error(): - show_error( + await show_dialog_with_icon_async( None, - "Failure", + l("Failure"), l("During restarting following errors occurs: ") + self.err, + RESPONSES_OK, + "qubes-info", ) self.log.error("Restart error: %s", self.err) self.status = RestartStatus.ERROR_APP_START diff --git a/qui/updater/tests/conftest.py b/qui/updater/tests/conftest.py index eb5f5763..e6c9dc9a 100644 --- a/qui/updater/tests/conftest.py +++ b/qui/updater/tests/conftest.py @@ -19,11 +19,24 @@ # with this program; if not, see . """Conftest helper pytest file: fixtures container here are reachable by all tests""" +import asyncio import pytest import importlib.resources import gi + +def run_coroutine(coroutine): + """Run a coroutine to completion on an event loop.""" + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + return loop.run_until_complete(coroutine) + + from qubesadmin.tests.mock_app import MockQube, MockQubesComplete from qui.updater.intro_page import UpdateRowWrapper from qui.updater.summary_page import RestartRowWrapper diff --git a/qui/updater/tests/test_progress_page.py b/qui/updater/tests/test_progress_page.py index 48980f0a..d0e888bd 100644 --- a/qui/updater/tests/test_progress_page.py +++ b/qui/updater/tests/test_progress_page.py @@ -18,10 +18,10 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. +import asyncio import gi -import subprocess -from unittest.mock import patch, call, Mock +from unittest.mock import patch, call, Mock, AsyncMock import pytest @@ -31,14 +31,13 @@ from qui.updater.intro_page import UpdateRowWrapper from qui.updater.progress_page import ProgressPage, QubeUpdateDetails -from qui.updater.tests.conftest import mock_settings, expected_row +from qui.updater.tests.conftest import mock_settings, expected_row, run_coroutine from qui.updater.utils import ListWrapper, UpdateStatus -@patch("threading.Thread") +@patch("asyncio.get_running_loop") def test_init_update( - mock_threading, - mock_thread, + mock_get_running_loop, real_builder, test_qapp, mock_next_button, @@ -47,8 +46,10 @@ def test_init_update( mock_tree_view, all_vms_list, ): - - mock_threading.return_value = mock_thread + sentinel = object() + mock_loop = Mock() + mock_loop.create_task.return_value = sentinel + mock_get_running_loop.return_value = mock_loop mock_log = Mock() mock_callback = Mock() sut = ProgressPage( @@ -61,6 +62,8 @@ def test_init_update( ) sut.progress_list = mock_tree_view + # avoid creating a real (never-awaited) coroutine object + sut.perform_update = Mock() sut.init_update(all_vms_list, mock_settings) @@ -68,7 +71,8 @@ def test_init_update( assert mock_cancel_button.sensitive assert mock_cancel_button.visible assert mock_cancel_button.label == "_Cancel updates" - assert mock_thread.started + mock_loop.create_task.assert_called_once() + assert sut.update_task is sentinel assert mock_label.text == "Update in progress..." assert mock_label.halign == Gtk.Align.CENTER @@ -100,12 +104,12 @@ def test_perform_update( sut.vms_to_update = updateable_vms_list class VMConsumer: - def __call__(self, vm_rows, *args, **kwargs): + async def __call__(self, vm_rows, *args, **kwargs): self.vm_rows = vm_rows sut.update_selected = VMConsumer() - sut.perform_update(mock_settings) + run_coroutine(sut.perform_update(mock_settings)) assert len(sut.update_selected.vm_rows) == 4 @@ -148,7 +152,7 @@ def test_update_templates( mock_callback, ) - sut.do_update_selected = Mock() + sut.do_update_selected = AsyncMock() total_progress = [] sut.set_total_progress = lambda prog: total_progress.append(prog) @@ -160,7 +164,7 @@ def test_update_templates( if interrupted: sut.interrupt_update() - sut.update_selected(updateable_vms_list, mock_settings) + run_coroutine(sut.update_selected(updateable_vms_list, mock_settings)) sut.update_details.set_active_row(updateable_vms_list[2]) @@ -175,9 +179,7 @@ def test_update_templates( mock_callback.assert_not_called() -@patch("subprocess.Popen") def test_do_update_selected( - mock_subprocess, real_builder, test_qapp, mock_next_button, @@ -186,24 +188,16 @@ def test_do_update_selected( mock_list_store, mock_settings, ): - class MockPorc: - def __init__(self, finish_after_n_polls=2): - self.polls = 0 - self.returncode = 40 - self.finish_after_n_polls = finish_after_n_polls + class MockProc: + def __init__(self): + self.returncode = None - def wait(self): - """Mock waiting.""" - pass - - def poll(self): - """After several polls return 0 (process finished).""" - self.polls += 1 - if self.polls < self.finish_after_n_polls: - return None + async def wait(self): + self.returncode = 40 return self.returncode - mock_subprocess.return_value = MockPorc() + mock_proc = MockProc() + mock_create = AsyncMock(return_value=mock_proc) mock_log = Mock() mock_callback = Mock() @@ -215,8 +209,8 @@ def poll(self): mock_cancel_button, mock_callback, ) - sut.read_stderrs = lambda *_args, **_kwargs: None - sut.read_stdouts = lambda *_args, **_kwargs: None + sut.read_stderrs = AsyncMock() + sut.read_stdouts = AsyncMock() to_update = ListWrapper(UpdateRowWrapper, mock_list_store) for vm in test_qapp.domains: @@ -226,23 +220,21 @@ def poll(self): rows = {row.name: row for row in to_update} - sut.do_update_selected(rows, mock_settings) - - calls = [ - call( - [ - "qubes-vm-update", - "--show-output", - "--just-print-progress", - "--force-update", - "--targets", - "dom0,fedora-35,fedora-36,test-standalone", - ], - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - ) - ] - mock_subprocess.assert_has_calls(calls) + with patch("asyncio.create_subprocess_exec", mock_create): + run_coroutine(sut.do_update_selected(rows, mock_settings)) + + mock_create.assert_called_once_with( + "qubes-vm-update", + "--show-output", + "--just-print-progress", + "--force-update", + "--targets", + "dom0,fedora-35,fedora-36,test-standalone", + stderr=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + sut.read_stderrs.assert_called_once_with(mock_proc, rows) + sut.read_stdouts.assert_called_once_with(mock_proc, rows) mock_callback.assert_not_called() assert sut.retcode == 40 diff --git a/qui/updater/tests/test_summary_page.py b/qui/updater/tests/test_summary_page.py index 6e19fafc..6d8893d6 100644 --- a/qui/updater/tests/test_summary_page.py +++ b/qui/updater/tests/test_summary_page.py @@ -19,11 +19,11 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. import pytest -from unittest.mock import patch, call, Mock +from unittest.mock import patch, Mock, AsyncMock import gi -from qui.updater.tests.conftest import expected_row +from qui.updater.tests.conftest import expected_row, run_coroutine gi.require_version("Gtk", "3.0") # isort:skip from gi.repository import Gtk # isort:skip @@ -255,27 +255,26 @@ def test_populate_restart_list( assert sum(row.selected for row in sut.list_store) == expected -@patch("qubes_config.widgets.gtk_utils.show_dialog") +@patch("qui.updater.summary_page.show_dialog_with_icon_async") @patch("qui.updater.summary_page.show_dialog") @patch("gi.repository.Gtk.Image.new_from_pixbuf") -@patch("threading.Thread") +@patch("asyncio.wait") @pytest.mark.parametrize( - "alive_requests_max, status", + "show_waiting_dialog, status", ( - pytest.param(3, RestartStatus.OK), - pytest.param(0, RestartStatus.OK), - pytest.param(1, RestartStatus.ERROR_TMPL_DOWN), - pytest.param(1, RestartStatus.NOTHING_TO_DO), + pytest.param(True, RestartStatus.OK), + pytest.param(False, RestartStatus.OK), + pytest.param(True, RestartStatus.ERROR_TMPL_DOWN), + pytest.param(True, RestartStatus.NOTHING_TO_DO), ), ) def test_restart_selected_vms( - mock_threading, + mock_wait, mock_new_from_pixbuf, mock_show_dialog_qui, - mock_show_dialog, - alive_requests_max, + mock_status_dialog_async, + show_waiting_dialog, status, - mock_thread, test_qapp, real_builder, mock_next_button, @@ -290,22 +289,22 @@ def test_restart_selected_vms( mock_cancel_button, back_by_row_selection=lambda *args: None, # callback ) - mock_thread.alive_requests_max = alive_requests_max - mock_threading.return_value = mock_thread + sut.perform_restart = AsyncMock() + # asyncio.wait decides if the waiting dialog is shown: an empty "done" + # set means the restart did not finish within expected time + if show_waiting_dialog: + mock_wait.return_value = (set(), {object()}) + else: + mock_wait.return_value = ({object()}, set()) icon = "icon" mock_new_from_pixbuf.return_value = icon class MockDialog: def __init__(self): - self.run_calls = 0 self.destroy_calls = 0 self.show_calls = 0 self.deletable = True - def run(self): - self.run_calls += 1 - return Gtk.ResponseType.DELETE_EVENT - def destroy(self): self.destroy_calls += 1 @@ -315,64 +314,43 @@ def show(self): def set_deletable(self, deletable): self.deletable = deletable - mock_final_dialog = MockDialog() mock_waiting_dialog = MockDialog() - mock_show_dialog.return_value = mock_final_dialog mock_show_dialog_qui.return_value = mock_waiting_dialog sut.status = status # ACT - sut.restart_selected_vms(show_only_error=False) + run_coroutine(sut.restart_selected_vms(show_only_error=False)) # ASSERT # waiting dialog cannot be closed - if alive_requests_max: - assert mock_waiting_dialog.run_calls == 0 + if show_waiting_dialog: assert mock_waiting_dialog.show_calls == 1 assert mock_waiting_dialog.destroy_calls == 1 assert not mock_waiting_dialog.deletable else: - assert mock_waiting_dialog.run_calls == 0 assert mock_waiting_dialog.show_calls == 0 assert mock_waiting_dialog.destroy_calls == 0 + # the status dialog is now shown as non-blocking if status == RestartStatus.NOTHING_TO_DO: - mock_show_dialog.assert_not_called() - else: - # final dialog is blocking (run) - assert mock_final_dialog.run_calls == 1 - assert mock_final_dialog.show_calls == 0 - assert mock_final_dialog.destroy_calls == 1 - assert mock_final_dialog.deletable - - calls = [] - if status == RestartStatus.OK: - calls = [ - call( - None, - "Success", - "All qubes were restarted/shutdown successfully.", - RESPONSES_OK, - icon, - ) - ] - if status.is_error(): - calls = [ - call( - None, - "Failure", - "During restarting following errors occurs: " + sut.err, - RESPONSES_OK, - icon, - ) - ] - mock_show_dialog.assert_has_calls(calls) - - -@patch("qui.updater.summary_page.wait_for_domain_shutdown") + mock_status_dialog_async.assert_not_awaited() + elif status == RestartStatus.OK: + mock_status_dialog_async.assert_awaited_once_with( + None, + "Success", + "All qubes were restarted/shutdown successfully.", + RESPONSES_OK, + "qubes-check-yes", + ) + else: # error status + mock_status_dialog_async.assert_awaited_once() + await_args = mock_status_dialog_async.await_args.args + assert await_args[1] == "Failure" + assert await_args[4] == "qubes-info" + + def test_perform_restart( - _mock_wait_for_domain_shutdown, test_qapp, real_builder, mock_next_button, @@ -405,7 +383,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" @@ -443,7 +421,7 @@ def test_perform_restart( sut.list_store[-1].selected = True # ACT - sut.perform_restart() + run_coroutine(sut.perform_restart()) # ASSERT expected = set( diff --git a/qui/updater/tests/test_updater.py b/qui/updater/tests/test_updater.py index 1082af23..82fb4611 100644 --- a/qui/updater/tests/test_updater.py +++ b/qui/updater/tests/test_updater.py @@ -18,40 +18,287 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. -from unittest.mock import patch, call, Mock +import asyncio +from unittest.mock import patch, call, Mock, AsyncMock import pytest from qui.updater.updater import QubesUpdater, parse_args -from qui.updater.summary_page import SummaryPage, RestartStatus +from qui.updater.summary_page import RestartStatus +from qui.updater.tests.conftest import run_coroutine from qubes_config.widgets import gtk_utils +def _make_updater(test_qapp, cliargs): + """make QubesUpdater with a mock asyncio loop.""" + real_loop = asyncio.get_event_loop() + loop = Mock() + loop.scheduled = [] + loop.create_task.side_effect = lambda coro: loop.scheduled.append(coro) or Mock() + loop.create_future.side_effect = real_loop.create_future + return QubesUpdater(test_qapp, cliargs, loop) + + @patch("logging.FileHandler") @patch("logging.getLogger") @patch("qui.updater.intro_page.IntroPage.populate_vm_list") -def test_setup(populate_vm_list, _mock_logging, __mock_logging, test_qapp): - sut = QubesUpdater(test_qapp, parse_args((), test_qapp)) +def test_setup(populate_vm_list, _mock_loger, _mock_log_handler, test_qapp): + sut = _make_updater(test_qapp, parse_args((), test_qapp)) sut.perform_setup() calls = [call(sut.qapp, sut.settings)] populate_vm_list.assert_has_calls(calls) +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("qui.updater.intro_page.IntroPage.populate_vm_list") +def test_updater_enables_cache_and_owns_event_listener( + _populate_vm_list, + _get_logger, + _file_handler, + _listen_for_events, + test_qapp, +): + sut = _make_updater(test_qapp, parse_args((), test_qapp)) + + assert test_qapp.cache_enabled is True + assert sut.dispatcher is not None + + sut.loop.create_task.assert_called_once() + assert sut.listen_events_task is not None + + +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("qui.updater.intro_page.IntroPage.populate_vm_list") +def test_exit_cancels_event_listener( + _populate_vm_list, + _get_logger, + _file_handler, + _listen_for_events, + test_qapp, +): + sut = _make_updater(test_qapp, parse_args((), test_qapp)) + sut.primary = True + sut.listen_events_task = Mock() + + sut.exit_updater() + + sut.listen_events_task.cancel.assert_called_once() + assert sut.exit_future.done() + + +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("qui.updater.intro_page.IntroPage.populate_vm_list") +def test_window_close_interrupts_running_update( + _populate_vm_list, + _get_logger, + _file_handler, + _listen_for_events, + test_qapp, +): + sut = _make_updater(test_qapp, parse_args((), test_qapp)) + sut.progress_page = Mock() + running = Mock() + running.done.return_value = False + sut.progress_page.update_task = running + sut.progress_page.exit_triggered = False + sut.exit_updater = Mock() + + result = sut.window_close() + + # window must stay alive and exit is deferred to the scheduled coroutine + assert result is True + sut.exit_updater.assert_not_called() + # `_interrupt_and_wait` coroutine was scheduled on the loop + interrupt_coro = sut.loop.scheduled[-1] + assert asyncio.iscoroutine(interrupt_coro) + interrupt_coro.close() # avoid 'coroutine never awaited' warning + + +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("qui.updater.intro_page.IntroPage.populate_vm_list") +def test_window_close_exits_when_idle( + _populate_vm_list, + _get_logger, + _file_handler, + _listen_for_events, + test_qapp, +): + sut = _make_updater(test_qapp, parse_args((), test_qapp)) + sut.progress_page = Mock() + sut.progress_page.update_task = None + sut.progress_page.exit_triggered = False + sut.exit_updater = Mock() + + result = sut.window_close() + + assert result is False + sut.exit_updater.assert_called_once() + + +@patch("qui.updater.updater.load_icon") +@patch("gi.repository.Gtk.Image.new_from_pixbuf") +@patch("qui.updater.updater.show_dialog") +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("qui.updater.intro_page.IntroPage.populate_vm_list") +def test_interrupt_and_wait_waits_then_exits( + _populate_vm_list, + _get_logger, + _file_handler, + _listen_for_events, + mock_show_dialog, + _new_from_pixbuf, + _load_icon, + test_qapp, +): + mock_show_dialog.return_value = Mock() + sut = _make_updater(test_qapp, parse_args((), test_qapp)) + sut.main_window = Mock() + sut.progress_page = Mock() + sut.exit_updater = Mock() + # window close + sut._exit_after_update = True + + async def scenario(): + sut.progress_page.update_task = asyncio.sleep(0) + await sut._interrupt_and_wait() + + run_coroutine(scenario()) + + sut.exit_updater.assert_called_once() + + +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("qui.updater.intro_page.IntroPage.populate_vm_list") +def test_cancel_twice_schedules_single_notice( + _populate_vm_list, + _get_logger, + _file_handler, + _listen_for_events, + test_qapp, +): + sut = _make_updater(test_qapp, parse_args((), test_qapp)) + sut.progress_page = Mock() + running = Mock() + running.done.return_value = False + sut.progress_page.update_task = running + sut.progress_page.exit_triggered = False + + # `interrupt_update()` sets `exit_triggered` synchronously + def interrupt(): + sut.progress_page.exit_triggered = True + + sut.progress_page.interrupt_update = Mock(side_effect=interrupt) + + sut.cancel_updates() + sut.cancel_updates() # second click + + sut.progress_page.interrupt_update.assert_called_once() + # ignore the coroutine scheduled at construction + notices = [ + c + for c in sut.loop.scheduled + if asyncio.iscoroutine(c) and c.__qualname__.endswith("_interrupt_and_wait") + ] + assert len(notices) == 1 + for coro in sut.loop.scheduled: + if asyncio.iscoroutine(coro): + coro.close() + + +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("qui.updater.intro_page.IntroPage.populate_vm_list") +def test_close_after_cancel_still_exits( + _populate_vm_list, + _get_logger, + _file_handler, + _listen_for_events, + test_qapp, +): + sut = _make_updater(test_qapp, parse_args((), test_qapp)) + sut.progress_page = Mock() + running = Mock() + running.done.return_value = False + sut.progress_page.update_task = running + sut.progress_page.exit_triggered = False + + def interrupt(): + sut.progress_page.exit_triggered = True + + sut.progress_page.interrupt_update = Mock(side_effect=interrupt) + + sut.cancel_updates() # Cancel: does not request exit + assert sut._exit_after_update is False + + result = sut.window_close() # then close the window + assert result is True + # closing + assert sut._exit_after_update is True + # and no second notice (ignore the coroutine scheduled at construction) + notices = [ + c + for c in sut.loop.scheduled + if asyncio.iscoroutine(c) and c.__qualname__.endswith("_interrupt_and_wait") + ] + assert len(notices) == 1 + for coro in sut.loop.scheduled: + if asyncio.iscoroutine(coro): + coro.close() + + @patch("logging.FileHandler") @patch("logging.getLogger") @patch("subprocess.check_output") @patch("qui.updater.intro_page.IntroPage.select_rows_ignoring_conditions") @patch("qui.updater.intro_page.IntroPage.get_vms_to_update") def test_setup_non_interactive_nothing_to_do( - get_vms, select, subproc, _mock_logging, __mock_logging, test_qapp + get_vms, select, subproc, _mock_loger, _mock_log_handler, test_qapp ): - sut = QubesUpdater(test_qapp, parse_args(("-n",), test_qapp)) + sut = _make_updater(test_qapp, parse_args(("-n",), test_qapp)) subproc.return_value = b"The admin VM will not be updated." get_vms.return_value = () sut.perform_setup() select.assert_called_once() get_vms.assert_called_once() + assert sut.do_nothing is True + assert sut.retcode == 100 + + +@patch("logging.FileHandler") +@patch("logging.getLogger") +@patch("subprocess.check_output") +@patch("qui.updater.intro_page.IntroPage.get_vms_to_update") +def test_setup_non_interactive_defers_update_start( + get_vms, subproc, _mock_loger, _mock_log_handler, test_qapp +): + sut = _make_updater(test_qapp, parse_args(("-n",), test_qapp)) + subproc.return_value = ( + b"Following templates and standalones will be updated:fedora-43" + ) + get_vms.return_value = ("fedora-43",) + sut.next_clicked = Mock() + + sut.perform_setup() + + # auto-start is only flagged; it must NOT be run during perform_setup + # (app.run() has no running loop yet) + assert sut.start_update is True + assert not sut.do_nothing + sut.next_clicked.assert_not_called() @patch("logging.FileHandler") @@ -59,9 +306,9 @@ def test_setup_non_interactive_nothing_to_do( @patch("qui.updater.intro_page.IntroPage.populate_vm_list") @patch("qui.updater.intro_page.IntroPage.select_rows") def test_setup_update_if_available( - select, populate_vm_list, _mock_logging, __mock_logging, test_qapp + select, populate_vm_list, _mock_loger, _mock_log_handler, test_qapp ): - sut = QubesUpdater(test_qapp, parse_args(("--update-if-available",), test_qapp)) + sut = _make_updater(test_qapp, parse_args(("--update-if-available",), test_qapp)) sut.perform_setup() calls = [call(sut.qapp, sut.settings)] populate_vm_list.assert_has_calls(calls) @@ -74,9 +321,9 @@ def test_setup_update_if_available( @patch("qui.updater.intro_page.IntroPage.populate_vm_list") @patch("qui.updater.intro_page.IntroPage.select_rows") def test_setup_force_update( - select, populate_vm_list, _mock_logging, __mock_logging, test_qapp + select, populate_vm_list, _mock_loger, _mock_log_handler, test_qapp ): - sut = QubesUpdater(test_qapp, parse_args(("--force-update",), test_qapp)) + sut = _make_updater(test_qapp, parse_args(("--force-update",), test_qapp)) sut.perform_setup() calls = [call(sut.qapp, sut.settings)] populate_vm_list.assert_has_calls(calls) @@ -87,8 +334,6 @@ def test_setup_force_update( @patch("logging.FileHandler") @patch("logging.getLogger") @patch("qui.updater.intro_page.IntroPage.populate_vm_list") -@patch("qui.updater.intro_page.IntroPage.select_rows") -@patch("qui.updater.updater_settings.get_boolean_feature") @pytest.mark.parametrize( "args, sys, non_sys", ( @@ -98,17 +343,15 @@ def test_setup_force_update( ), ) def test_setup_apply( - get_feature, - __select, populate_vm_list, - _mock_logging, - __mock_logging, + _mock_loger, + _mock_log_handler, test_qapp, args, sys, non_sys, ): - sut = QubesUpdater(test_qapp, parse_args(args, test_qapp)) + sut = _make_updater(test_qapp, parse_args(args, test_qapp)) sut.perform_setup() calls = [call(sut.qapp, sut.settings)] populate_vm_list.assert_has_calls(calls) @@ -141,13 +384,13 @@ def test_setup_apply( ) def test_retcode( _populate_vm_list, - _mock_logging, - __mock_logging, + _mock_loger, + _mock_log_handler, update_results, ret_code, test_qapp, ): - sut = QubesUpdater(test_qapp, parse_args((), test_qapp)) + sut = _make_updater(test_qapp, parse_args((), test_qapp)) sut.perform_setup() sut.intro_page.get_vms_to_update = Mock() @@ -192,24 +435,22 @@ def populate(**_kwargs): sut.summary_page.show.assert_called_once_with(*expected_summary) -@patch("threading.Thread") -@patch("qui.updater.updater.show_dialog_with_icon") -@patch("qui.updater.summary_page.show_dialog_with_icon") +@patch("qui.updater.updater.show_dialog_with_icon_async") +@patch("qui.updater.summary_page.show_dialog_with_icon_async") @patch("logging.FileHandler") @patch("logging.getLogger") @patch("qui.updater.intro_page.IntroPage.populate_vm_list") +@patch("qubesadmin.events.EventsDispatcher.listen_for_events") def test_dialog( + _listen_for_events, _populate_vm_list, - _mock_logging, - __mock_logging, - dialog, - dialog2, - thread, + _mock_loger, + _mock_log_handler, + summary_dialog_async, + updater_dialog_async, test_qapp, - monkeypatch, ): - monkeypatch.setattr(SummaryPage, "perform_restart", lambda *_: None) - sut = QubesUpdater(test_qapp, parse_args((), test_qapp)) + sut = _make_updater(test_qapp, parse_args((), test_qapp)) sut.perform_setup() sut.cliargs.non_interactive = True @@ -241,26 +482,19 @@ def populate(**_kwargs): sut.summary_page.show = Mock() sut.summary_page.show.return_value = None - def ok(**_kwargs): - sut.summary_page.status = RestartStatus.OK - t = Mock() - t.is_alive = Mock(return_value=False) - return t - - thread.side_effect = ok + sut.summary_page.restart_selected_vms = AsyncMock() sut.summary_page.status = RestartStatus.OK sut.next_clicked(None) - dialog2.assert_has_calls( - calls=[ - call( - None, - "Success", - "Qubes OS is up to date.", - buttons=gtk_utils.RESPONSES_OK, - icon_name="qubes-check-yes", - ) - ] + # next_clicked scheduled the restart phase on the loop, we run it now + run_coroutine(sut.loop.scheduled[-1]) + + updater_dialog_async.assert_awaited_once_with( + None, + "Success", + "Qubes OS is up to date.", + buttons=gtk_utils.RESPONSES_OK, + icon_name="qubes-check-yes", ) - dialog.assert_not_called() + summary_dialog_async.assert_not_awaited() diff --git a/qui/updater/updater.py b/qui/updater/updater.py index 76528a17..630e23f6 100644 --- a/qui/updater/updater.py +++ b/qui/updater/updater.py @@ -5,15 +5,16 @@ import asyncio import logging import sys -import time import importlib.resources import gi # isort:skip from qubes_config.widgets.gtk_utils import ( + load_icon, load_icon_at_gtk_size, load_theme, - show_dialog_with_icon, + show_dialog, + show_dialog_with_icon_async, RESPONSES_OK, ) from qui.updater.progress_page import ProgressPage @@ -57,17 +58,25 @@ class QubesUpdater(Gtk.Application): LOGPATH = "/var/log/qubes/qui.updater.log" LOG_FORMAT = "%(asctime)s %(message)s" - def __init__(self, qapp, cliargs): + def __init__(self, qapp, cliargs, loop): super().__init__( application_id="org.gnome.example", flags=Gio.ApplicationFlags.FLAGS_NONE, ) + # the single asyncio loop bound to the GLib context + self.loop = loop self.qapp = qapp self.primary = False self.do_nothing = False + self.start_update = False + self.finishing = False self.connect("activate", self.do_activate) self.cliargs = cliargs self.retcode = 0 + # user close the window (not just Cancel) quit just after updates (ASAP) + self._exit_after_update = False + # awaited by main() -> loop keeps running as long as the window is open + self.exit_future = self.loop.create_future() log_handler = logging.FileHandler(QubesUpdater.LOGPATH, encoding="utf-8") log_formatter = logging.Formatter(QubesUpdater.LOG_FORMAT) @@ -76,23 +85,34 @@ def __init__(self, qapp, cliargs): self.log = logging.getLogger("vm-update.agent.PackageManager") self.log.addHandler(log_handler) self.log.setLevel(self.cliargs.log) - dispatcher = qubesadmin.events.EventsDispatcher(qapp) - asyncio.ensure_future(dispatcher.listen_for_events()) + self.dispatcher = qubesadmin.events.EventsDispatcher(qapp) + self.listen_events_task = self.loop.create_task( + self.dispatcher.listen_for_events() + ) def do_activate(self, *_args, **_kwargs): if not self.primary: self.log.debug("Primary activation") self.perform_setup() self.primary = True - self.hold() + self.finish_if_nothing_to_do() else: self.log.debug("Secondary activation") if self.do_nothing: - self._show_success_dialog() - self.window_close() + self.finish_if_nothing_to_do() else: self.main_window.present() + def finish_if_nothing_to_do(self): + """Schedule the do-nothing dialog *once*.""" + if self.do_nothing and not self.finishing: + self.finishing = True + self.loop.create_task(self._finish_do_nothing()) + + async def _finish_do_nothing(self): + await self._show_success_dialog() + self.exit_updater() + def perform_setup(self, *_args, **_kwargs): self.log.debug("Setup") # pylint: disable=attribute-defined-outside-init @@ -210,8 +230,10 @@ def cell_data_func(_column, cell, model, it, data): self.intro_page.select_rows_ignoring_conditions(cliargs=self.cliargs) if len(self.intro_page.get_vms_to_update()) == 0: self.do_nothing = True + # `main()` change it to 0 unless `--signal-no-updates` is set + self.retcode = 100 return - self.next_clicked(None, skip_intro=True) + self.start_update = True else: # default update_if_stale -> do nothing if self.cliargs.update_if_available: @@ -268,27 +290,30 @@ def next_clicked(self, _emitter, skip_intro=False): if failed or cancelled or not self.cliargs.non_interactive: self.summary_page.show(updated, no_updates, failed + cancelled) else: - # at this point retcode is in (0, 100) - self._restart_phase(show_only_error=self.cliargs.non_interactive) - # at thi point retcode is in (0, 100) - # or an error message have been already shown - if self.cliargs.non_interactive and self.retcode in (0, 100): - self._show_success_dialog() + # at this point retcode is in (0, 100) + self.loop.create_task( + self._restart_phase( + show_only_error=self.cliargs.non_interactive, + show_success=self.cliargs.non_interactive, + ) + ) elif self.summary_page.is_visible: - self._restart_phase() + self.loop.create_task(self._restart_phase()) - def _restart_phase(self, show_only_error: bool = True): + async def _restart_phase( + self, show_only_error: bool = True, show_success: bool = False + ): self.main_window.hide() self.log.debug("Hide main window") - # ensuring that main_window will be hidden - while Gtk.events_pending(): - Gtk.main_iteration() - self.summary_page.restart_selected_vms(show_only_error) + await self.summary_page.restart_selected_vms(show_only_error) if self.summary_page.status.is_error(): self.retcode = self.summary_page.status.value + # at this point retcode is in (0, 100) or an error was already shown + if show_success and self.retcode in (0, 100): + await self._show_success_dialog() self.exit_updater() - def _show_success_dialog(self): + async def _show_success_dialog(self): """ We should show the user a success confirmation. @@ -311,7 +336,7 @@ def _show_success_dialog(self): msg = "All selected qubes have been updated." elif self.retcode == 100: msg = "There are no updates available for the selected Qubes." - show_dialog_with_icon( + await show_dialog_with_icon_async( None, l("Success"), l(msg), @@ -330,28 +355,49 @@ def cancel_clicked(self, _emitter): def cancel_updates(self, *_args, **_kwargs): self.log.info("User initialize interruption") - if ( - self.progress_page.update_thread - and self.progress_page.update_thread.is_alive() - ): + self._interrupt(exit_after=False) + + def _interrupt(self, exit_after: bool): + """Interrupt the running update at most once. + + `exit_after` means that the window will be closed + """ + task = self.progress_page.update_task + if task is None or task.done(): + if exit_after: + self.exit_updater() + return + if exit_after: + self._exit_after_update = True + if not self.progress_page.exit_triggered: + # synchronously so a second event cannot schedule a second notice self.progress_page.interrupt_update() - self.log.info("Update interrupted") - show_dialog_with_icon( - self.main_window, - l("Updating cancelled"), - l( - "Waiting for current qube to finish updating." - " Updates for remaining qubes have been cancelled." - ), - buttons=RESPONSES_OK, - icon_name="qubes-info", - ) + self.loop.create_task(self._interrupt_and_wait()) + + async def _interrupt_and_wait(self): + self.log.info("Update interrupted") + # We don't use `show_dialog_with_icon()` which uses a nested GLib loop + # and re-enters the running update task. + # Show buttonless, non-modal notice instead until the update is finished + icon = Gtk.Image.new_from_pixbuf(load_icon("qubes-info", 48, 48)) + notice = show_dialog( + self.main_window, + l("Updating cancelled"), + l( + "Waiting for current qube to finish updating." + " Updates for remaining qubes have been cancelled." + ), + {}, + icon, + ) + notice.set_modal(False) + notice.set_deletable(False) - self.log.debug("Waiting to finish ongoing updates") - while self.progress_page.update_thread.is_alive(): - while Gtk.events_pending(): - Gtk.main_iteration() - time.sleep(0.1) + self.log.debug("Waiting to finish ongoing updates") + await self.progress_page.update_task + notice.destroy() + if self._exit_after_update: + self.exit_updater() def check_escape(self, _widget, event, _data=None): if event.keyval == Gdk.KEY_Escape: @@ -360,16 +406,21 @@ def check_escape(self, _widget, event, _data=None): def window_close(self, *_args, **_kwargs): self.log.debug("Close window") - if self.progress_page.exit_triggered: - self.cancel_updates() - else: - self.cancel_updates() - self.exit_updater() + task = self.progress_page.update_task + if task is not None and not task.done(): + # update is going: keep the window until current update finish + self._interrupt(exit_after=True) + return True + self.exit_updater() + return False def exit_updater(self, _emitter=None): if self.primary: self.log.debug("Exit") - self.release() + if getattr(self, "listen_events_task", None) is not None: + self.listen_events_task.cancel() + if not self.exit_future.done(): + self.exit_future.set_result(None) def parse_args(args, app): @@ -526,8 +577,14 @@ def skip_intro_if_args(args): def main(args=None): qapp = Qubes() cliargs = parse_args(args, qapp) - app = QubesUpdater(qapp, cliargs) + loop = asyncio.get_event_loop() + app = QubesUpdater(qapp, cliargs, loop) app.run() + if app.start_update: + # Start the non-interactive update. + loop.call_soon(app.next_clicked, None, True) + if not app.exit_future.done(): + loop.run_until_complete(app.exit_future) if app.retcode == 100 and not app.cliargs.signal_no_updates: app.retcode = 0 sys.exit(app.retcode)