diff --git a/Furious/Frozenlib/Mixins.py b/Furious/Frozenlib/Mixins.py index a13080d..3985439 100644 --- a/Furious/Frozenlib/Mixins.py +++ b/Furious/Frozenlib/Mixins.py @@ -215,23 +215,30 @@ class Mixins: """Handle cleanup all for the cleanup on exit.""" Mixins.CleanupOnExit.ObjectsPool.prune(Mixins.qObjectIsValid) - for ob in list(Mixins.CleanupOnExit.ObjectsPool): - assert isinstance(ob, Mixins.CleanupOnExit) + try: + for ob in list(Mixins.CleanupOnExit.ObjectsPool): + assert isinstance(ob, Mixins.CleanupOnExit) - if ob.uniqueCleanup: - obtype = str(type(ob)) + if ob.uniqueCleanup: + obtype = str(type(ob)) - if not Mixins.CleanupOnExit.VisitedType.get(obtype, False): - ob.cleanup() + if Mixins.CleanupOnExit.VisitedType.get(obtype, False): + continue Mixins.CleanupOnExit.VisitedType[obtype] = True - else: - pass - else: - ob.cleanup() - Mixins.CleanupOnExit.ObjectsPool.clear() - Mixins.CleanupOnExit.VisitedType.clear() + try: + ob.cleanup() + except Exception: + # Any non-exit exceptions + + # Cleanup is an isolation boundary. One failed owner + # must not prevent unrelated resources from being + # released during application shutdown. + logger.exception(f'cleanup failed for {type(ob).__name__}') + finally: + Mixins.CleanupOnExit.ObjectsPool.clear() + Mixins.CleanupOnExit.VisitedType.clear() class QSetDisabledContext: """Manage the q set disabled context.""" diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py index 24a309d..18d2e43 100644 --- a/Furious/Plugins/Registry.py +++ b/Furious/Plugins/Registry.py @@ -1334,10 +1334,20 @@ class _PluginRegistryManager: """Build a registry with host plugins taking discovery precedence.""" registry = PluginRegistry() - for pluginType in pluginTypes: - registry.register(pluginType()) + try: + for pluginType in pluginTypes: + registry.register(pluginType()) - registry.discover() + registry.discover() + except Exception: + # Any non-exit exceptions + + # The manager publishes the registry only after this method + # succeeds. Release plugins initialized earlier in this attempt + # before abandoning the unpublished registry. + registry.shutdown() + + raise return registry diff --git a/tests/test_frozenlib.py b/tests/test_frozenlib.py index c8edb1a..516a118 100644 --- a/tests/test_frozenlib.py +++ b/tests/test_frozenlib.py @@ -123,6 +123,47 @@ class FrozenlibQtContextTest(unittest.TestCase): processQtEvents() +class CleanupOnExitTest(unittest.TestCase): + """Verify application cleanup isolates independently owned resources.""" + + def testCleanupFailureDoesNotSkipLaterOwners(self): + """Continue cleanup after one participant raises and reset registry state.""" + calls = [] + poolType = type(Mixins.CleanupOnExit.ObjectsPool) + + class Participant(Mixins.CleanupOnExit): + def __init__(self, name: str, raises=False): + self.name = name + self.raises = raises + + super().__init__(uniqueCleanup=False) + + def cleanup(self): + calls.append(self.name) + + if self.raises: + raise RuntimeError('cleanup fixture') + + with mock.patch.object( + Mixins.CleanupOnExit, + 'ObjectsPool', + poolType(), + ), mock.patch.object(Mixins.CleanupOnExit, 'VisitedType', {}): + participants = ( + Participant('first'), + Participant('failing', raises=True), + Participant('last'), + ) + + with self.assertLogs('Furious.Frozenlib.Mixins', level='ERROR'): + Mixins.CleanupOnExit.cleanupAll() + + self.assertEqual(calls, ['first', 'failing', 'last']) + self.assertEqual(len(Mixins.CleanupOnExit.ObjectsPool), 0) + self.assertEqual(Mixins.CleanupOnExit.VisitedType, {}) + self.assertEqual(len(participants), 3) + + class FrozenlibUtilityTest(unittest.TestCase): """Verify bounded caches, commands, throttling, and dual-stack probes.""" diff --git a/tests/test_plugin_architecture.py b/tests/test_plugin_architecture.py index 6367a6c..12e7f1d 100644 --- a/tests/test_plugin_architecture.py +++ b/tests/test_plugin_architecture.py @@ -266,6 +266,41 @@ class PluginRegistryManagerTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, 'already registered'): PluginRegistryModule.initializePluginRegistry((ConflictingPlugin,)) + def testFailedInitialCreationShutsDownEarlierHostPlugins(self): + """Release initialized plugins when a later host plugin fails.""" + initializedPlugin = FixturePlugin() + + class InitializedPluginType: + """Return the observable plugin instance owned by this test.""" + + def __new__(cls): + return initializedPlugin + + class FailingPluginType: + """Fail before the process registry can be published.""" + + def __new__(cls): + raise RuntimeError('host plugin construction failed') + + with mock.patch.object(PluginRegistry, 'discover') as discover: + with self.assertRaisesRegex( + RuntimeError, + 'host plugin construction failed', + ): + PluginRegistryModule.initializePluginRegistry( + (InitializedPluginType, FailingPluginType) + ) + + self.assertEqual(initializedPlugin.initialized, 1) + self.assertEqual(initializedPlugin.stopped, 1) + discover.assert_not_called() + + with mock.patch.object(PluginRegistry, 'discover') as discover: + registry = PluginRegistryModule.initializePluginRegistry() + self.addCleanup(registry.shutdown) + + discover.assert_called_once_with() + class PluginRegistryTest(unittest.TestCase): """Exercise capability dispatch without the process-wide registry."""