From a82f4f2affcf083f7e7ac24414a7464f00d08ed0 Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Mon, 20 Jul 2026 15:47:36 +0200 Subject: [PATCH 1/2] Cleanup disposable on failed startup On "cleanup()" of a disposable with "auto_cleanup" enabled, if the state is: - Running: "_auto_cleanup" is called by the event "domain-shutdown" and not on "cleanup()", to avoid recalling it which would cause problems; - Halted: "_auto_cleanup" should be called if domain is not running, but that check is unreliable due to many moving parts, so "DispVM.start" should contain a "cleanup(force=True)" because in that case, we are sure to certain degree that it works, per my tests. Bug introduced on commit f1cbf21598178959708045125f95e261bcb3772a, which did not enforce "_auto_cleanup" on "DispVM.start" For: https://github.com/QubesOS/qubes-core-admin/pull/748 Fixes: https://github.com/qubesos/qubes-issues/issues/10928 --- qubes/vm/dispvm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qubes/vm/dispvm.py b/qubes/vm/dispvm.py index b51206054..e61fb5bce 100644 --- a/qubes/vm/dispvm.py +++ b/qubes/vm/dispvm.py @@ -1011,7 +1011,7 @@ async def start(self, **kwargs): await super().start(**kwargs) except: # Cleanup also on failed startup - await self.cleanup() + await self.cleanup(force=True) raise def create_qdb_entries(self) -> None: From f296a889ddfefccce90442b801190f5aec6771dd Mon Sep 17 00:00:00 2001 From: Ben Grande Date: Mon, 20 Jul 2026 17:22:33 +0200 Subject: [PATCH 2/2] Test early startup error of insufficient reserve The "request_mem" is done very early on "QubesVM.start", the error handling doesn't have "self.kill" as the domain is not running yet, and therefore it was skipping "_auto_cleanup". Make sure that early startup failures are property handled, not remaining disposable in the domains collection nor in the preload-dispvm list. --- qubes/tests/integ/dispvm.py | 49 +++++++++++++++++++++++++++++++++++++ qubes/vm/mix/dvmtemplate.py | 3 +-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/qubes/tests/integ/dispvm.py b/qubes/tests/integ/dispvm.py index a3613b51c..e718626a7 100644 --- a/qubes/tests/integ/dispvm.py +++ b/qubes/tests/integ/dispvm.py @@ -410,6 +410,8 @@ async def wait_preload( preload_unfinished = preload_dispvm for _ in range(timeout): for qube in preload_unfinished.copy(): + if qube not in self.app.domains: + break if self.app.domains[qube].preload_complete.is_set(): logger.info("preload completed for '%s'", qube) preload_unfinished.remove(qube) @@ -1104,6 +1106,53 @@ def mock_open_mem_threshold(file, *args, **kwargs): ) self.assertEqual(1, len(self.disp_base.get_feat_preload())) + def test_012_preload_low_mem_early_startup(self): + """Test preloading with low memory on early startup""" + self.loop.run_until_complete( + self._test_012_preload_low_mem_early_startup() + ) + + async def _test_012_preload_low_mem_early_startup(self): + # pylint: disable=unspecified-encoding + logger.info("start") + unpatched_open = open + + def mock_open_mem_raise(file, *args, **kwargs): + if file == qubes.config.qmemman_avail_mem_file: + raise FileNotFoundError(2, "No such file or directory", file) + return unpatched_open(file, *args, **kwargs) + + preload_max = 2 + with patch("builtins.open", side_effect=mock_open_mem_raise): + logger.info("insufficient memory reserve (early startup failure)") + old_memory = self.disp_base.memory + old_dispvms = [ + qube.name + for qube in self.app.domains + if getattr(qube, "is_preload", False) + ] + self.disp_base.memory = 999999999999999999999 + self.disp_base.features["preload-dispvm-max"] = str(preload_max) + await self.wait_preload( + preload_max, fail_on_timeout=False, timeout=15 + ) + self.assertEqual(0, len(self.disp_base.get_feat_preload())) + new_dispvms = [ + qube.name + for qube in self.app.domains + if getattr(qube, "is_preload", False) + ] + self.assertEqual(new_dispvms, old_dispvms) + + # Nothing will be done here, just to prepare to the next test. + self.disp_base.features["preload-dispvm-max"] = "0" + with patch("builtins.open", side_effect=mock_open_mem_raise): + logger.info("enough memory reserve but no avail-mem file") + self.disp_base.memory = old_memory + self.disp_base.features["preload-dispvm-max"] = str(preload_max) + await self.wait_preload(preload_max) + self.assertEqual(2, len(self.disp_base.get_feat_preload())) + logger.info("end") def test_015_preload_race_more(self): diff --git a/qubes/vm/mix/dvmtemplate.py b/qubes/vm/mix/dvmtemplate.py index e5abfdc55..1b8795764 100644 --- a/qubes/vm/mix/dvmtemplate.py +++ b/qubes/vm/mix/dvmtemplate.py @@ -516,7 +516,6 @@ async def on_domain_preload_dispvm_used( return avail_mem_file = qubes.config.qmemman_avail_mem_file - available_memory = None try: with open(avail_mem_file, "r", encoding="ascii") as file: available_memory = max( @@ -525,7 +524,7 @@ async def on_domain_preload_dispvm_used( except FileNotFoundError: can_preload = want_preload self.log.warning("File containing available memory was not found") - if available_memory is not None: + else: memory = getattr(self, "memory", 0) * 1024**2 unrestricted_preload = int(available_memory / memory) can_preload = min(unrestricted_preload, want_preload)