diff --git a/Furious/Plugins/AGENTS.md b/Furious/Plugins/AGENTS.md index c5f078b..383bec1 100644 --- a/Furious/Plugins/AGENTS.md +++ b/Furious/Plugins/AGENTS.md @@ -19,6 +19,9 @@ - Registries strongly own process-lifetime plugin instances, capabilities/factories, descriptors, and immutable metadata. They do not own factory-created widgets, active runtimes, replies, or controller state. Factories return a fresh object per request; invalid QObject results are explicitly destroyed. +- Once a runtime factory returns a valid launch, startup transfers that exact runtime to the calling connection attempt + even when `start()` raises, so the attempt can stop and dispose partial resources. A controlled failure returns `None` + only when no valid runtime was acquired. - External entry points and plugin-returned data are a boundary even when plugins are trusted for execution. Validate types and required fields, isolate optional provider failure where the operation can continue, and keep the primary failure observable when it cannot. diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py index 18d2e43..25f0a39 100644 --- a/Furious/Plugins/Registry.py +++ b/Furious/Plugins/Registry.py @@ -1100,6 +1100,8 @@ class PluginRegistry: def startCoreRuntime(self, config, routing, **kwargs): """Create and start the core runtime selected for *config*.""" + launch = None + try: launch = self.createCoreRuntime(config, routing, **kwargs) @@ -1116,7 +1118,10 @@ class PluginRegistry: logger.error(f'core runtime start failed for {factoryId!r}: {ex}') - return None, False + # Once construction succeeds, ownership must still cross to the + # connection transaction even if start() raises. Returning None + # here would abandon a partially acquired process/thread/runtime. + return (launch.runtime if launch is not None else None), False def prepareDownloadTest(self, config, port: int): """Create a proxy-only test configuration through its runtime factory.""" diff --git a/tests/test_plugin_architecture.py b/tests/test_plugin_architecture.py index 12e7f1d..ee5fa5b 100644 --- a/tests/test_plugin_architecture.py +++ b/tests/test_plugin_architecture.py @@ -539,6 +539,44 @@ class PluginFailureIsolationTest(unittest.TestCase): finally: registry.shutdown() + def testCoreRuntimeStartExceptionReturnsRuntimeForCallerRollback(self): + """Transfer a partially started runtime to the connection transaction.""" + + class RaisingStartRuntime(FixtureCoreRuntime): + def start(self, configuration, *args, **kwargs): + raise RuntimeError('start fixture') + + runtime = RaisingStartRuntime() + + class RaisingStartFactory(CoreRuntimeFactory): + factoryId = 'raising-start-factory' + configurationTypes = (FixtureConfiguration,) + runtimeTypes = (RaisingStartRuntime,) + + def create(self, request: CoreRuntimeRequest): + return CoreRuntimeLaunch(runtime, request.configuration) + + class RaisingStartPlugin(FuriousPlugin): + metadata = PluginMetadata( + 'tests.raising-start-factory', + 'Raising start factory', + ) + capabilities = (RaisingStartFactory(),) + + registry = PluginRegistry() + registry.register(RaisingStartPlugin()) + + try: + returnedRuntime, success = registry.startCoreRuntime( + FixtureConfiguration({'type': 'fixture'}), + 'direct', + ) + + self.assertIs(returnedRuntime, runtime) + self.assertFalse(success) + finally: + registry.shutdown() + def testShutdownExceptionDoesNotBlockOtherPlugins(self): """Run every shutdown hook once even when one hook raises.""" stopped = []