Fix application failure reporting

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-08 21:01:07 +08:00
parent af8e5a5205
commit 89202d3a60
5 changed files with 181 additions and 23 deletions
+2 -2
View File
@@ -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
+6 -5
View File
@@ -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
+19 -15
View File
@@ -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)
+1 -1
View File
@@ -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. |
+153
View File
@@ -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(