From 87c06d8d1f6c3536f31ce402ec16be1244b60ec0 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Fri, 18 Sep 2026 22:03:07 +0800 Subject: [PATCH] Dispose output resources when runtime preparation fails Signed-off-by: Loren Eteval --- Furious/Core/AGENTS.md | 4 +++- Furious/Core/MultiprocessingRuntime.py | 10 ++++++--- tests/README.md | 2 ++ tests/test_runtime_lifecycle.py | 29 ++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Furious/Core/AGENTS.md b/Furious/Core/AGENTS.md index 6f47e49..b8a4d64 100644 --- a/Furious/Core/AGENTS.md +++ b/Furious/Core/AGENTS.md @@ -7,7 +7,9 @@ This scope owns reusable embedded execution machinery and application tun2socks; Core owns its separate direct `subprocess.Popen`; neither layer owns controller, repository, UI, or protocol policy. - A launch spec describes prepared child construction, never semantic connection readiness. Serialization and launch arguments are prepared before execution starts; constructors may create owned timers/queues that still need - disposal if execution never starts. The service observes endpoints/process survival and commits later. + disposal if execution never starts. Failed launch-factory construction must dispose resources already acquired + before propagating the failure, because no service has received ownership yet. The service observes + endpoints/process survival and commits later. - `CoreRuntime` execution state, typed terminal exit, and readiness are separate contracts. A process becoming alive is not proof that its proxy/TUN endpoint is ready, while a readiness timeout must not overwrite an already observed typed exit. diff --git a/Furious/Core/MultiprocessingRuntime.py b/Furious/Core/MultiprocessingRuntime.py index f655926..0e6de97 100644 --- a/Furious/Core/MultiprocessingRuntime.py +++ b/Furious/Core/MultiprocessingRuntime.py @@ -86,12 +86,16 @@ class MultiprocessingRuntime(CoreRuntime): self._exitPublished = False self._output = MsgQueue(msgCallback=msgCallback) - launch = launchFactory(self._output) + try: + launch = launchFactory(self._output) - if not isinstance(launch, ProcessLaunchSpec): + if not isinstance(launch, ProcessLaunchSpec): + raise TypeError('launch factory must return ProcessLaunchSpec') + except BaseException: + # Construction has not transferred this resource to a service owner. self._output.dispose() - raise TypeError('launch factory must return ProcessLaunchSpec') + raise self._launch = launch self._monitor = QtCore.QTimer() diff --git a/tests/README.md b/tests/README.md index 9d0afc8..8971f73 100644 --- a/tests/README.md +++ b/tests/README.md @@ -227,3 +227,5 @@ instead. `test_repository_contracts.py` verifies all-or-nothing hydration and preservation of original stored bytes after malformed records or plugin parsing failures. +`test_runtime_lifecycle.py` verifies real Qt timer destruction and queue disposal when +launch preparation fails before a service acquires the runtime. diff --git a/tests/test_runtime_lifecycle.py b/tests/test_runtime_lifecycle.py index c5f415d..efab2e0 100644 --- a/tests/test_runtime_lifecycle.py +++ b/tests/test_runtime_lifecycle.py @@ -254,6 +254,35 @@ class RuntimeLifecycleTest(TestCase): runtime.dispose() + def testLaunchFactoryFailureDisposesAcquiredOutput(self): + """Release the real queue, callback, and Qt timer before returning failure.""" + outputs = [] + destroyed = [] + + def fail(output): + outputs.append(output) + output.timer.destroyed.connect(lambda: destroyed.append(True)) + raise ValueError('fixture preparation failure') + + runtime = _ProcessRuntime.__new__(_ProcessRuntime) + + try: + with self.assertRaisesRegex(ValueError, 'fixture preparation failure'): + MultiprocessingRuntime.__init__( + runtime, fail, msgCallback=lambda _: None + ) + + processQtEvents() + + self.assertEqual(destroyed, [True]) + self.assertTrue(outputs[0]._closed) + self.assertIsNone(outputs[0].callback) + self.assertIsNone(outputs[0]._timerConnection) + finally: + outputs[0].dispose() + + processQtEvents() + def testMultiprocessingSpawnFailureRaisesStructuredError(self): """Represent expected acquisition failure without mutable side state.""" process = mock.Mock()