diff --git a/doc/manpages/qvm-features.rst b/doc/manpages/qvm-features.rst index 18d8df83..0432515f 100644 --- a/doc/manpages/qvm-features.rst +++ b/doc/manpages/qvm-features.rst @@ -460,6 +460,18 @@ disposables. Ensures preloaded disposables do not consume all available memory. | **Type**: `int` | **Default**: `0` +preload-dispvm-early-gui +^^^^^^^^^^^^^^^^^^^^^^^ + +If `True`, disposables that are preloaded after this feature is set on the +disposable template will wait for the disposable to have a GUI session to +complete the preload stage. + +| +| **Valid on**: disposable template and preloaded disposable +| **Type**: `bool` +| **Default**: `False` + preload-dispvm ^^^^^^^^^^^^^^ diff --git a/qubesadmin/app.py b/qubesadmin/app.py index 2080ee43..5c948a9c 100644 --- a/qubesadmin/app.py +++ b/qubesadmin/app.py @@ -501,10 +501,10 @@ def clone_vm( raise for feature, value in src_vm.features.items(): - if ( - feature.startswith("preload-dispvm") - and feature != "preload-dispvm-max" - ): + if feature.startswith("preload-dispvm") and feature not in [ + "preload-dispvm-max", + "preload-dispvm-early-gui", + ]: continue try: dst_vm.features[feature] = value @@ -520,8 +520,7 @@ def clone_vm( if vm_notes: dst_vm.set_notes(vm_notes) except qubesadmin.exc.QubesException as e: - dst_vm.log.error( - 'Failed to clone qube notes: {!s}'.format(e)) + dst_vm.log.error("Failed to clone qube notes: {!s}".format(e)) if not ignore_errors: raise diff --git a/qubesadmin/tests/mock_app.py b/qubesadmin/tests/mock_app.py index b00f39eb..3992cefe 100644 --- a/qubesadmin/tests/mock_app.py +++ b/qubesadmin/tests/mock_app.py @@ -234,8 +234,9 @@ def default_string(self): "last-updates-check", "last-update", "prohibit-start", - "preload-dispvm-max", "preload-dispvm-threshold", + "preload-dispvm-max", + "preload-dispvm-early-gui", "preload-dispvm", "preload-dispvm-in-progress", "preload-dispvm-completed", diff --git a/qubesadmin/tests/tools/qvm_start_daemon.py b/qubesadmin/tests/tools/qvm_start_daemon.py index 666175da..9d7fd4a4 100644 --- a/qubesadmin/tests/tools/qvm_start_daemon.py +++ b/qubesadmin/tests/tools/qvm_start_daemon.py @@ -431,7 +431,7 @@ def test_031_start_gui_for_stubdomain_forced(self): async def mock_coroutine(self, mock, *args, **kwargs): mock(*args, **kwargs) - def test_038_start_gui_skip_preload(self): + def test_035_start_gui_skip_preload(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self.addCleanup(loop.close) @@ -450,6 +450,9 @@ def test_038_start_gui_skip_preload(self): ('test-disp', 'admin.vm.feature.CheckWithTemplate', 'no-monitor-layout', None)] = \ b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.Get', 'preload-dispvm-early-gui', None)] = \ + b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' self.app.expected_calls[ ('test-disp', 'admin.vm.property.Get', 'virt_mode', None)] = \ b'0\x00default=False type=str hvm' @@ -486,7 +489,7 @@ def test_038_start_gui_skip_preload(self): finally: unittest.mock.patch.stopall() - def test_039_start_gui_for_preload(self): + def test_036_start_gui_for_preload(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self.addCleanup(loop.close) @@ -548,6 +551,71 @@ def test_039_start_gui_for_preload(self): mock_start_audio.assert_called_once_with(vm) mock_start.assert_called_once_with(vm, monitor_layout=None) + def test_037_start_gui_for_early_preload(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self.addCleanup(loop.close) + + self.app.expected_calls[ + ('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00test-disp class=DispVM state=Running\n' \ + b'gui-vm class=AppVM state=Running' + self.app.expected_calls[ + ('test-disp', 'admin.vm.CurrentState', None, None)] = \ + b'0\x00power_state=Running' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.CheckWithTemplate', 'gui', None)] = \ + b'0\x00True' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.CheckWithTemplate', + 'no-monitor-layout', None)] = \ + b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'virt_mode', None)] = \ + b'0\x00default=False type=str hvm' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'xid', None)] = \ + b'0\x00default=False type=int 3000' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'stubdom_xid', None)] = \ + b'0\x00default=False type=int 3001' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'guivm', None)] = \ + b'0\x00default=False type=vm gui-vm' + self.app.expected_calls[ + ('test-disp', 'admin.vm.property.Get', 'is_preload', None)] = \ + b'0\x00default=False type=bool True' + self.app.expected_calls[ + ('test-disp', 'admin.vm.feature.Get', 'preload-dispvm-early-gui', None)] = \ + b'0\x00True' + + # pylint: disable=protected-access + self.app._local_name = 'gui-vm' + vm = self.app.domains['test-disp'] + + with \ + unittest.mock.patch('asyncio.ensure_future'), \ + unittest.mock.patch.object( + self.launcher, 'common_guid_args', lambda vm: [] + ), \ + unittest.mock.patch.object( + qubesadmin.tools.qvm_start_daemon, + 'get_monitor_layout', + unittest.mock.Mock(return_value=None) + ), \ + unittest.mock.patch.object( + self.launcher, 'start_gui', unittest.mock.Mock() + ) as mock_start, \ + unittest.mock.patch.object( + self.launcher, 'start_audio', unittest.mock.Mock() + ) as mock_start_audio: + # Execute and validate + self.launcher.on_property_preload_set( + vm, 'property-reset:is_preload' + ) + mock_start_audio.assert_called_once_with(vm) + mock_start.assert_called_once_with(vm, monitor_layout=None) + def test_040_start_gui(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) diff --git a/qubesadmin/tools/qvm_start_daemon.py b/qubesadmin/tools/qvm_start_daemon.py index a6ff78df..032b81b9 100644 --- a/qubesadmin/tools/qvm_start_daemon.py +++ b/qubesadmin/tools/qvm_start_daemon.py @@ -565,7 +565,10 @@ async def send_monitor_layout(self, vm, layout=None, startup=False): if ( vm.features.check_with_template("no-monitor-layout", False) or not vm.is_running() - or getattr(vm, "is_preload", False) + or ( + getattr(vm, "is_preload", False) + and not vm.features.get("preload-dispvm-early-gui", None) + ) ): return @@ -611,12 +614,14 @@ def send_monitor_layout_all(self): or vm.klass == "AdminVM" or not vm.is_running() or not vm.features.check_with_template("gui", True) - or getattr(vm, "is_preload", False) ): continue - asyncio.ensure_future( - self.send_monitor_layout(vm, monitor_layout) - ) + if ( + getattr(vm, "is_preload", False) + and not vm.features.get("preload-dispvm-early-gui", None) + ): + continue + asyncio.ensure_future(self.send_monitor_layout(vm, monitor_layout)) @staticmethod def kde_guid_args(vm): @@ -724,7 +729,9 @@ async def start_gui_for_vm(self, vm, monitor_layout=None): :param monitor_layout: monitor layout to send; if None, fetch it from local X server. """ - if getattr(vm, "is_preload", False): + if getattr(vm, "is_preload", False) and not vm.features.get( + "preload-dispvm-early-gui", None + ): return guid_cmd = self.common_guid_args(vm) guid_cmd.extend(["-d", str(vm.xid)]) @@ -752,7 +759,9 @@ async def start_gui_for_stubdomain(self, vm, force=False): This function is a coroutine. """ - if getattr(vm, "is_preload", False): + if getattr(vm, "is_preload", False) and not vm.features.get( + "preload-dispvm-early-gui", None + ): return want_stubdom = force if not want_stubdom and vm.features.check_with_template( @@ -785,7 +794,9 @@ async def start_audio_for_vm(self, vm): :param vm: VM for which start AUDIO daemon """ - if getattr(vm, "is_preload", False): + if getattr(vm, "is_preload", False) and not vm.features.get( + "preload-dispvm-early-gui", None + ): return pacat_cmd = [ PACAT_DAEMON_PATH, @@ -807,7 +818,9 @@ async def start_gui(self, vm, force_stubdom=False, monitor_layout=None): one for target AppVM is running. :param monitor_layout: monitor layout configuration """ - if getattr(vm, "is_preload", False): + if getattr(vm, "is_preload", False) and not vm.features.get( + "preload-dispvm-early-gui", None + ): return guivm = getattr(vm, "guivm", None) if guivm != vm.app.local_name: @@ -830,7 +843,9 @@ async def start_audio(self, vm): :param vm: VM for which AUDIO daemon should be started """ - if getattr(vm, "is_preload", False): + if getattr(vm, "is_preload", False) and not vm.features.get( + "preload-dispvm-early-gui", None + ): return audiovm = getattr(vm, "audiovm", None) if audiovm != vm.app.local_name: @@ -849,7 +864,10 @@ async def start_audio(self, vm): def on_domain_spawn(self, vm, _event, **kwargs): """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain""" - if not self.is_watched(vm) or getattr(vm, "is_preload", False): + if not self.is_watched(vm) or ( + getattr(vm, "is_preload", False) + and not vm.features.get("preload-dispvm-early-gui", None) + ): return try: @@ -874,7 +892,10 @@ def on_domain_start(self, vm, _event, **kwargs): """Handler of 'domain-start' event, starts GUI/AUDIO daemon for actual VM""" - if not self.is_watched(vm) or getattr(vm, "is_preload", False): + if not self.is_watched(vm) or ( + getattr(vm, "is_preload", False) + and not vm.features.get("preload-dispvm-early-gui", None) + ): return self.xid_cache[vm.name] = vm.xid, vm.stubdom_xid @@ -911,7 +932,12 @@ def on_connection_established(self, _subject, _event, **_kwargs): if ( vm.klass == "AdminVM" or not self.is_watched(vm) - or getattr(vm, "is_preload", False) + or ( + getattr(vm, "is_preload", False) + and not vm.features.get( + "preload-dispvm-early-gui", None + ) + ) ): continue @@ -960,7 +986,10 @@ def on_property_preload_set(self, vm, _event, **_kwargs): if ( not vm.klass == "DispVM" or not self.is_watched(vm) - or getattr(vm, "is_preload", False) + or ( + getattr(vm, "is_preload", False) + and not vm.features.get("preload-dispvm-early-gui", None) + ) or not vm.is_running() ): return