mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-09 15:19:56 +03:00
Fix application failure reporting
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user