mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
412 lines
14 KiB
Python
412 lines
14 KiB
Python
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||
#
|
||
# 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 <https://www.gnu.org/licenses/>.
|
||
|
||
"""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,
|
||
RuntimeLeaseState,
|
||
)
|
||
from Furious.Service.ConnectionManager import ConnectionManager, _ConnectionStartAttempt
|
||
|
||
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 testFailedLeaseReleaseRemainsOwnedUntilSuccessfulRetry(self):
|
||
"""Rollback retains failed resources and blocks replacement admission."""
|
||
manager = ConnectionManager()
|
||
attempt = _ConnectionStartAttempt(manager, None)
|
||
owner = _AttemptOwner()
|
||
runtime = _Runtime()
|
||
router = RuntimeEventRouter()
|
||
router.attach(runtime, owner)
|
||
lease = attempt.ownRuntime(runtime, router)
|
||
with mock.patch.object(
|
||
runtime, 'dispose', side_effect=RuntimeError('still owned')
|
||
):
|
||
attempt.rollback()
|
||
self.assertEqual(manager._pendingReleases, [lease])
|
||
self.assertIs(lease.state, RuntimeLeaseState.Releasing)
|
||
runtime.publishCode(0)
|
||
processQtEvents()
|
||
self.assertEqual(owner.events, [])
|
||
for start in (manager.start, manager.startAsync):
|
||
with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'):
|
||
start(None, '')
|
||
with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'):
|
||
manager.stopAll()
|
||
self.assertEqual(manager._pendingReleases, [lease])
|
||
resolver = mock.Mock()
|
||
manager._dnsResolver = resolver
|
||
with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'):
|
||
manager.cleanup()
|
||
resolver.dispose.assert_called_once_with()
|
||
self.assertIsNone(manager._dnsResolver)
|
||
self.assertEqual(manager._pendingReleases, [lease])
|
||
manager.stopAll()
|
||
self.assertEqual(manager._pendingReleases, [])
|
||
self.assertIs(lease.state, RuntimeLeaseState.Released)
|
||
manager.cleanup()
|
||
owner.deleteLater()
|
||
processQtEvents()
|
||
|
||
def testFailedCommittedReleaseStillCleansIndependentRuntimes(self):
|
||
"""One refusal must not prevent reverse release of other committed leases."""
|
||
manager = ConnectionManager()
|
||
first, second = _Runtime(), _Runtime()
|
||
leases = [
|
||
RuntimeLease(runtime, RuntimeEventRouter()) for runtime in (first, second)
|
||
]
|
||
manager._leases.extend(leases)
|
||
with mock.patch.object(second, 'dispose', side_effect=RuntimeError('retry')):
|
||
with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'):
|
||
manager.stopAll()
|
||
self.assertIs(first.state, RuntimeState.Disposed)
|
||
self.assertEqual(manager._pendingReleases, [leases[1]])
|
||
manager.stopAll()
|
||
manager.cleanup()
|
||
processQtEvents()
|
||
|
||
def testMultiprocessingFailedEscalationRetainsLiveChild(self):
|
||
"""Do not manufacture an exit or lose the handle of a surviving child."""
|
||
runtime = _ProcessRuntime()
|
||
child = mock.Mock()
|
||
child.is_alive.return_value = True
|
||
child.exitcode = None
|
||
runtime._process = child
|
||
runtime.setState(RuntimeState.Alive)
|
||
with self.assertRaisesRegex(RuntimeError, 'did not stop'):
|
||
runtime.dispose()
|
||
self.assertTrue(child.is_alive())
|
||
self.assertIs(runtime.process, child)
|
||
self.assertIsNone(runtime.lastExit)
|
||
child.close.assert_not_called()
|
||
child.is_alive.return_value = False
|
||
child.exitcode = 0
|
||
runtime.dispose()
|
||
self.assertIsNone(runtime.process)
|
||
self.assertIs(runtime.state, RuntimeState.Disposed)
|
||
|
||
def testMultiprocessingCloseFailureRetainsHandleForRetry(self):
|
||
"""Physical exit and handle release need independent evidence."""
|
||
runtime = _ProcessRuntime()
|
||
child = mock.Mock()
|
||
child.is_alive.return_value = False
|
||
child.exitcode = 0
|
||
child.close.side_effect = OSError('handle still owned')
|
||
runtime._process = child
|
||
with self.assertRaisesRegex(RuntimeError, 'could not be closed'):
|
||
runtime.dispose()
|
||
self.assertIs(runtime.process, child)
|
||
self.assertIsNot(runtime.state, RuntimeState.Disposed)
|
||
child.close.side_effect = None
|
||
runtime.dispose()
|
||
self.assertIsNone(runtime.process)
|
||
self.assertIs(runtime.state, RuntimeState.Disposed)
|
||
|
||
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 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()
|
||
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()
|