# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Verify typed runtime lifecycle, ownership transfer, and Qt-thread handoff.""" from Furious.Core import MultiprocessingRuntime, ProcessLaunchSpec from Furious.Interface import ( CoreRuntime, RuntimeExitReason, RuntimeStartError, RuntimeState, ) from Furious.Service.RuntimeLease import RuntimeEventRouter, RuntimeLease from PySide6 import QtCore from tests.support import application, collectAtBoundary, processQtEvents, waitFor from unittest import TestCase, mock import threading import weakref class _Runtime(CoreRuntime): """Provide an execution-free runtime for lease tests.""" def __init__(self, **kwargs): super().__init__(**kwargs) self.stopCount = 0 @staticmethod def name(): return 'Lease fixture' @staticmethod def version(): return '1' def start(self): self.setState(RuntimeState.Alive) def stop(self): self.stopCount += 1 self.setState(RuntimeState.Exited) def publishCode(self, exitcode): """Interpret and publish one fixture process-exit code.""" self.publishExit(self.interpretExit(exitcode)) class _ProcessRuntime(MultiprocessingRuntime): """Provide names around the reusable multiprocessing implementation.""" def __init__(self, **kwargs): super().__init__( lambda _output: ProcessLaunchSpec(lambda: None), **kwargs, ) @staticmethod def name(): return 'Process fixture' @staticmethod def version(): return '1' class _AttemptOwner(QtCore.QObject): """Record runtime exits and the Qt thread that consumed them.""" def __init__(self): super().__init__() self.events = [] def runtimeExited(self, runtime, event): self.events.append((runtime, event, QtCore.QThread.currentThread())) class RuntimeLifecycleTest(TestCase): """Exercise semantic exits independently from connection readiness.""" @classmethod def setUpClass(cls): cls.app = application() def testWatcherThreadExitIsConsumedOnRouterQtThread(self): """Queue a worker-thread publication onto the Qt owner's thread.""" owner = _AttemptOwner() router = RuntimeEventRouter() runtime = _Runtime(exitCallback=router.publish) router.attach(runtime, owner) lease = RuntimeLease(runtime, router) thread = threading.Thread(target=runtime.publishCode, args=(9,)) thread.start() thread.join() self.assertTrue(waitFor(lambda: bool(owner.events))) self.assertIs(owner.events[0][2], self.app.thread()) self.assertEqual(owner.events[0][1].code, 9) lease.release() def testRouterRejectsAnUntypedRuntimeExit(self): """Require interpretation at the concrete runtime boundary.""" router = RuntimeEventRouter() runtime = _Runtime() with self.assertRaisesRegex(TypeError, 'requires a RuntimeExit'): router.publish(runtime, 23) router.deleteLater() def testRepeatedRoutersAvoidPackagedBoundMethodRetention(self): """Keep released queued routers out of Nuitka-like callback protection.""" originalConnect = QtCore.SignalInstance.connect protectedCallbacks = [] references = [] def protectingConnect(signal, callback, *args, **kwargs): if getattr(callback, '__self__', None) is not None: protectedCallbacks.append(callback) return originalConnect(signal, callback, *args, **kwargs) with mock.patch.object( QtCore.SignalInstance, 'connect', protectingConnect, ): for _index in range(32): router = RuntimeEventRouter() references.append(weakref.ref(router)) router.finishRelease() router.deleteLater() del router collectAtBoundary() self.assertEqual(protectedCallbacks, []) self.assertTrue(all(reference() is None for reference in references)) def testCommitNearQueuedExitUsesOneStableRoute(self): """Deliver an already queued exit exactly once to the committed owner.""" owner = _AttemptOwner() committed = [] router = RuntimeEventRouter() runtime = _Runtime(exitCallback=router.publish) router.attach(runtime, owner) lease = RuntimeLease(runtime, router) runtime.publishCode(11) lease.commit(lambda current, event: committed.append((current, event))) processQtEvents() self.assertEqual(owner.events, []) self.assertEqual(len(committed), 1) self.assertIs(committed[0][0], runtime) self.assertEqual(committed[0][1].code, 11) lease.release() def testPostCommitDuplicateExitIsDeliveredOnce(self): """Keep one terminal authority after ownership has transferred.""" owner = _AttemptOwner() committed = [] router = RuntimeEventRouter() runtime = _Runtime(exitCallback=router.publish) router.attach(runtime, owner) lease = RuntimeLease(runtime, router) lease.commit(lambda current, event: committed.append((current, event))) runtime.publishCode(12) runtime.publishCode(13) processQtEvents() self.assertEqual(owner.events, []) self.assertEqual(len(committed), 1) self.assertEqual(committed[0][1].code, 12) lease.release() def testReleaseSuppressesLateQueuedExitAndIsIdempotent(self): """Ignore late notifications and clean the exact runtime only once.""" owner = _AttemptOwner() router = RuntimeEventRouter() runtime = _Runtime(exitCallback=router.publish) router.attach(runtime, owner) lease = RuntimeLease(runtime, router) runtime.publishCode(13) lease.release() lease.release() processQtEvents() self.assertEqual(owner.events, []) self.assertEqual(runtime.stopCount, 1) self.assertIs(runtime.state, RuntimeState.Disposed) def testMultiprocessingRuntimePublishesOneInterpretedExit(self): """Detect, reap, and publish one raw child transition internally.""" process = mock.Mock() process.is_alive.side_effect = (True, True, False) process.exitcode = CoreRuntime.ExitCode.ConfigurationError.value events = [] with mock.patch( 'Furious.Core.MultiprocessingRuntime.multiprocessing.Process', return_value=process, ): runtime = _ProcessRuntime( exitCallback=lambda current, event: events.append((current, event)) ) runtime.start() self.assertIs(runtime.state, RuntimeState.Alive) self.assertTrue(runtime.isRunning()) runtime._pollProcess() runtime._pollProcess() self.assertEqual(len(events), 1) self.assertIs(events[0][1].reason, RuntimeExitReason.InvalidConfiguration) self.assertEqual(events[0][1].code, 23) process.close.assert_called_once_with() runtime.dispose() self.assertIsNone(runtime._monitorConnection) self.assertIsNone(runtime._output._timerConnection) runtime.dispose() def testMultiprocessingSpawnFailureRaisesStructuredError(self): """Represent expected acquisition failure without mutable side state.""" process = mock.Mock() process.start.side_effect = OSError('fixture spawn failure') with mock.patch( 'Furious.Core.MultiprocessingRuntime.multiprocessing.Process', return_value=process, ): runtime = _ProcessRuntime() with self.assertRaises(RuntimeStartError) as raised: runtime.start() self.assertIs(raised.exception.reason, RuntimeExitReason.StartFailure) self.assertIn('fixture spawn failure', raised.exception.details) runtime.dispose() self.assertIsNone(runtime._monitorConnection) self.assertIsNone(runtime._output._timerConnection) if __name__ == '__main__': import unittest unittest.main()