diff --git a/Furious/Interface/AGENTS.md b/Furious/Interface/AGENTS.md index 8d91414b..c56a15de 100644 --- a/Furious/Interface/AGENTS.md +++ b/Furious/Interface/AGENTS.md @@ -30,8 +30,8 @@ Read `Furious/Interface/Runtime.py` with `tests/test_interface.py`; paths are re - `ApplicationRunner.ExitCode` is the outer application process protocol; it is not interchangeable with a core's raw exit code or `RuntimeExitReason`. Preserve the meaning at each boundary instead of translating every nonzero value into one generic failure. - Declaring the exit enum does not route exceptions into it. Verify the concrete process boundary separately from - an exception hook's mapping; bootstrap interception can bypass that hook. + Declaring the exit enum does not route exceptions into it. Concrete process wrappers must translate escaping + non-exit failures before bootstrap interception; verify real child results separately from direct hook mapping. - Model encoders may raise, while configuration construction deliberately captures diagnostics. Callers must inspect the contract they consume; successful construction alone proves neither serialization nor backend acceptance. Preserve `RuntimeStartError`'s reason/code/details and `RuntimeExit`'s typed meaning across adapters; exception text diff --git a/Furious/Utility/AGENTS.md b/Furious/Utility/AGENTS.md index 2230e753..2457d320 100644 --- a/Furious/Utility/AGENTS.md +++ b/Furious/Utility/AGENTS.md @@ -14,11 +14,12 @@ source tree's root. application factory; the parent must not construct a Qt application to pass across the process boundary. Signal handlers are installed only after the factory returns, so pre-construction signals are outside this wrapper's handler coverage. Preserve semantic exit codes and original exception/traceback context; crash-log failure is secondary. - Verify this through a real spawned child: multiprocessing bootstrap can intercept an uncaught factory/run failure - before `sys.excepthook`. Direct hook tests prove its mapping only, not dispatch from every child failure path; - compare the actual exit and crash flag before claiming supervision coverage. - The current wrapper installs the hook but does not explicitly catch exceptions escaping the supplied factory - or `application.run()`. Keep this coverage gap visible instead of describing hook installation as complete dispatch. + The wrapper explicitly routes non-exit exceptions escaping the factory or runner through its reporting hook + before multiprocessing bootstrap intercepts them, then exits the child with the semantic code. Qt callback + exceptions instead request event-loop shutdown. Preserve intentional `SystemExit` and other `BaseException` + control flow. A partially constructed QApplication may lack its log manager; save the traceback without assuming + full composition. Spawned cases in `tests/test_application_process.py` verify classification, original diagnostics, + the shared crash flag, unavailable crash output and intentional exits, separately from direct hook mapping. - The parent entry point joins only the child it created and shows the fallback Qt report only for a nonzero result. That join follows the GUI session lifetime; it is not a short startup-readiness deadline. Tests must bound their own waits and reap their exact child if the fixture fails. A child stuck in cooperative worker cleanup can diff --git a/Furious/Utility/AppMainProcess.py b/Furious/Utility/AppMainProcess.py index 4cb3b646..63114041 100644 --- a/Furious/Utility/AppMainProcess.py +++ b/Furious/Utility/AppMainProcess.py @@ -61,7 +61,7 @@ class AppMainProcess(ProcessContext.Process): self.func = func self.application = None - def exceptHook(self, exceptionType, exceptionValue, tb): + def exceptHook(self, exceptionType, exceptionValue, tb, *, raiseSystemExit=False): """Handle except hook for the app main process.""" if logger.level < logging.CRITICAL: traceback.print_exception(exceptionType, exceptionValue, tb) @@ -75,7 +75,7 @@ class AppMainProcess(ProcessContext.Process): self.saveCrashLog(exceptionType, exceptionValue, tb) - if APP() is not None: + if not raiseSystemExit and APP() is not None: APP().exit(exitcode) else: sys.exit(exitcode) @@ -99,16 +99,13 @@ class AppMainProcess(ProcessContext.Process): traceback.format_exception(exceptionType, exceptionValue, tb), ) - if APP() is None: - crashLog = f'{stackLog}' - else: - logManager = AppLogManager() + logManager = getattr(APP(), 'logManager', None) - crashLog = ( - f'{logManager.plainText()}\n{stackLog}' - if logManager is not None - else f'{stackLog}' - ) + crashLog = ( + f'{logManager.plainText()}\n{stackLog}' + if logManager is not None + else f'{stackLog}' + ) with open(CRASH_LOG_DIR / self.logFileName, 'w', encoding='utf-8') as file: file.write(crashLog) @@ -132,9 +129,16 @@ class AppMainProcess(ProcessContext.Process): """Run the app main process task.""" sys.excepthook = self.exceptHook - self.application = self.func() + try: + self.application = self.func() - for sig in [signal.SIGTERM, signal.SIGINT]: - signal.signal(sig, self.handler) + for sig in [signal.SIGTERM, signal.SIGINT]: + signal.signal(sig, self.handler) - sys.exit(self.application.run()) + sys.exit(self.application.run()) + except Exception: + # Any non-exit exceptions + + # Multiprocessing catches escaping failures before sys.excepthook. + # The factory/run frame has ended, so no Qt loop can honor exit(). + self.exceptHook(*sys.exc_info(), raiseSystemExit=True) diff --git a/tests/README.md b/tests/README.md index b272a948..fa8c0cc1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -100,7 +100,7 @@ worker. Choose tests by the changed contract rather than by filename alone. | Module | Coverage | | --- | --- | -| [test_application_process.py](test_application_process.py) | Exact application-child ownership, shared crash flag, exception/signal handling, temporary crash logs, command-line dispatch. | +| [test_application_process.py](test_application_process.py) | Exact application-child ownership, spawned factory/runner failure classification and crash reporting, partial Qt startup, crash-write failure, intentional exits, exception/signal handling and command-line dispatch. | | [test_architecture_refactors.py](test_architecture_refactors.py) | Startup acquisition/rollback including partial controller construction and cleanup retry, singleton election and real isolated IPC race, tray/exit policy, host integration ownership, bounded core-log transport, connection transactions, stylesheet composition. | | [test_connection_startup_async.py](test_connection_startup_async.py) | Real local-listener readiness, timeout/cancel/replacement, semantic exits, DNS reply lifetime, mocked platform-specific TUN sequencing. | | [test_controllers.py](test_controllers.py) | Connection state/error/reconnect transitions, reentrant cancellation/replacement before launch and during completion, startup restoration, shared settings, routing fallback persistence and tray/selector agreement after custom-routing disable/re-enable. | diff --git a/tests/test_application_process.py b/tests/test_application_process.py index 9191e78e..713a3917 100644 --- a/tests/test_application_process.py +++ b/tests/test_application_process.py @@ -22,11 +22,14 @@ from __future__ import annotations from Furious.Interface import ApplicationRunner from Furious.Utility.AppMainProcess import AppMainProcess +from tests.support import application + from pathlib import Path from types import SimpleNamespace from unittest import mock import importlib +import functools import multiprocessing import signal import sys @@ -54,9 +57,159 @@ def _successfulApplication(): return _SuccessfulApplication() +class _FailingApplication(_SuccessfulApplication): + """Raise one supplied exception after the factory has returned.""" + + def __init__(self, exceptionType): + self.exceptionType = exceptionType + + def run(self): + raise self.exceptionType('runner fixture failure') + + +def _failingApplication(stage, exceptionType, crashDirectory, *, createQt=False): + """Confine crash reporting and optional partial Qt state to the exact child.""" + AppMainProcessModule.CRASH_LOG_DIR = Path(crashDirectory) + + if createQt: + app = application() + del app.logManager + + # There is no running event loop after an escaping factory/run failure. + app.exit = mock.Mock(side_effect=AssertionError('unexpected Qt exit request')) + + if stage == 'factory': + raise exceptionType('factory fixture failure') + + return _FailingApplication(exceptionType) + + +class _ExitingApplication(_SuccessfulApplication): + def run(self): + sys.exit(17) + + +def _exitingApplication(stage, crashDirectory): + AppMainProcessModule.CRASH_LOG_DIR = Path(crashDirectory) + + if stage == 'factory': + sys.exit(17) + + return _ExitingApplication() + + class AppMainProcessTest(unittest.TestCase): """Verify exact child ownership, crash mapping, and signal safety.""" + def _checkSpawnedFailure(self, stage, *, createQt=False, failCrashWrite=False): + for exceptionType, expected in ( + (AssertionError, ApplicationRunner.ExitCode.AssertionError), + (RuntimeError, ApplicationRunner.ExitCode.UnknownException), + ): + with self.subTest(stage=stage, exception=exceptionType.__name__): + with tempfile.TemporaryDirectory() as directory: + crashDirectory = Path(directory) / 'crashes' + + if failCrashWrite: + crashDirectory.write_text( + 'keep existing file', encoding='utf-8' + ) + + process = AppMainProcess( + functools.partial( + _failingApplication, + stage, + exceptionType, + str(crashDirectory), + createQt=createQt, + ) + ) + + try: + process.start() + process.join(15) + + self.assertFalse(process.is_alive(), 'child did not finish') + self.assertEqual(process.exitcode, expected.value) + self.assertEqual(process.fileWritten.value, not failCrashWrite) + + if failCrashWrite: + self.assertEqual( + crashDirectory.read_text(encoding='utf-8'), + 'keep existing file', + ) + else: + logs = list(crashDirectory.iterdir()) + self.assertEqual(len(logs), 1) + + diagnostic = logs[0].read_text(encoding='utf-8') + message = ( + 'factory fixture failure' + if stage == 'factory' + else 'runner fixture failure' + ) + + self.assertIn( + 'Traceback (most recent call last):', diagnostic + ) + self.assertIn( + f'{exceptionType.__name__}: {message}', diagnostic + ) + self.assertNotIn('unexpected Qt exit request', diagnostic) + finally: + if process.is_alive(): + process.terminate() + process.join(5) + + if process.is_alive(): + process.kill() + process.join(5) + + process.close() + + def testFactoryFailuresReportSemanticExitAndCrashLog(self): + self._checkSpawnedFailure('factory') + + def testRunnerFailuresReportSemanticExitAndCrashLog(self): + self._checkSpawnedFailure('runner') + + def testFactoryFailureWithPartialQtApplicationReportsAndExits(self): + self._checkSpawnedFailure('factory', createQt=True) + + def testRunnerFailureWithQtApplicationReportsAndExits(self): + self._checkSpawnedFailure('runner', createQt=True) + + def testCrashWriteFailurePreservesFactoryAndRunnerExitClassification(self): + for stage in ('factory', 'runner'): + self._checkSpawnedFailure(stage, failCrashWrite=True) + + def testExplicitFactoryAndRunnerExitsDoNotCreateCrashReports(self): + for stage in ('factory', 'runner'): + with self.subTest(stage=stage), tempfile.TemporaryDirectory() as directory: + crashDirectory = Path(directory) / 'crashes' + process = AppMainProcess( + functools.partial(_exitingApplication, stage, str(crashDirectory)) + ) + + try: + process.start() + process.join(15) + + self.assertFalse(process.is_alive(), 'child did not finish') + self.assertEqual(process.exitcode, 17) + self.assertFalse(process.fileWritten.value) + self.assertFalse(crashDirectory.exists()) + finally: + if process.is_alive(): + process.terminate() + process.join(5) + + if process.is_alive(): + process.kill() + process.join(5) + + process.close() + def testSharedCrashFlagDoesNotCreateAManagerServer(self): """Use one synchronized scalar without spawning an unmanaged manager.""" with mock.patch.object(