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)