@@ -404,49 +404,113 @@ async def _load_backend_module(
404404 )
405405
406406 module = importlib .util .module_from_spec (spec )
407- sys .modules [module_name ] = module
408- module .__package__ = module_name
409- module .__path__ = [plugin_dir_str ]
410- spec .loader .exec_module (module )
411-
412- if not hasattr (module , "plugin" ):
413- raise AttributeError (
414- "Plugin module must export 'plugin' object" ,
415- )
416407
417- plugin_def = module .plugin
418-
419- manifest_dict = {
420- "id" : manifest .id ,
421- "name" : manifest .name ,
422- "version" : manifest .version ,
423- "description" : manifest .description ,
424- "author" : manifest .author ,
425- "dependencies" : manifest .dependencies ,
426- "min_version" : manifest .min_version ,
427- "max_version" : manifest .max_version ,
428- "qwenpaw_version" : (
429- manifest .qwenpaw_version .model_dump ()
430- if manifest .qwenpaw_version
431- else None
432- ),
433- "meta" : manifest .meta ,
434- }
435- api = PluginApi (plugin_id , config or {}, manifest_dict )
436- api .set_registry (self .registry )
437- self .registry .register_plugin_manifest (plugin_id , manifest_dict )
438-
439- if hasattr (plugin_def , "register" ):
440- result = plugin_def .register (api )
441- if inspect .iscoroutine (result ) or inspect .isawaitable (result ):
442- await result
443- else :
444- raise AttributeError (
445- "Plugin must implement 'register(api)' method" ,
408+ try :
409+ sys .modules [module_name ] = module
410+ module .__package__ = module_name
411+ module .__path__ = [plugin_dir_str ]
412+ spec .loader .exec_module (module )
413+
414+ if not hasattr (module , "plugin" ):
415+ raise AttributeError (
416+ "Plugin module must export 'plugin' object" ,
417+ )
418+
419+ plugin_def = module .plugin
420+
421+ if manifest .qwenpaw_version is not None :
422+ qv_dict = manifest .qwenpaw_version .model_dump ()
423+ else :
424+ qv_dict = {
425+ "min" : manifest .min_version ,
426+ "max" : manifest .max_version ,
427+ }
428+ manifest_dict = {
429+ "id" : manifest .id ,
430+ "name" : manifest .name ,
431+ "version" : manifest .version ,
432+ "description" : manifest .description ,
433+ "author" : manifest .author ,
434+ "dependencies" : manifest .dependencies ,
435+ "qwenpaw_version" : qv_dict ,
436+ "meta" : manifest .meta ,
437+ }
438+ api = PluginApi (plugin_id , config or {}, manifest_dict )
439+ api .set_registry (self .registry )
440+ self .registry .register_plugin_manifest (plugin_id , manifest_dict )
441+
442+ if hasattr (plugin_def , "register" ):
443+ result = plugin_def .register (api )
444+ if inspect .iscoroutine (result ) or inspect .isawaitable (result ):
445+ await result
446+ else :
447+ raise AttributeError (
448+ "Plugin must implement 'register(api)' method" ,
449+ )
450+ except Exception :
451+ self ._cleanup_failed_load (
452+ plugin_id ,
453+ module_name ,
454+ source_path ,
446455 )
456+ raise
447457
448458 return plugin_def
449459
460+ def _cleanup_failed_load (
461+ self ,
462+ plugin_id : str ,
463+ module_name : str ,
464+ source_path : Path ,
465+ ) -> None :
466+ """Roll back side effects after a failed plugin load.
467+
468+ Mirrors the cleanup logic in ``unload_plugin`` (registry,
469+ ``sys.modules``, ``sys.path``) so that a failed load leaves no
470+ orphan state that could interfere with other plugins or a
471+ subsequent retry.
472+
473+ .. note::
474+ NOT thread-safe. ``sys.modules`` and ``sys.path`` mutations
475+ are not guarded by a lock. This is fine because
476+ ``load_all_plugins`` loads plugins sequentially, but callers
477+ must not invoke this method concurrently.
478+ """
479+ logger .warning (
480+ "Cleaning up failed plugin load for '%s'" ,
481+ plugin_id ,
482+ )
483+
484+ # 1. Registry (manifest, providers, hooks, middleware, routes, …)
485+ self .registry .unregister_plugin (plugin_id )
486+
487+ # 2. sys.modules — by module-name prefix
488+ prefix = module_name + "."
489+ stale = [
490+ k for k in sys .modules if k == module_name or k .startswith (prefix )
491+ ]
492+ for k in stale :
493+ sys .modules .pop (k , None )
494+
495+ # 3. sys.modules — by __file__ path (catches bare imports that
496+ # bypassed the plugin_<id> namespace, e.g. ``import utils``
497+ # after the plugin inserted its dir into sys.path).
498+ source_resolved = os .path .realpath (str (source_path )) + os .sep
499+ stale_by_file = [
500+ k
501+ for k , mod in list (sys .modules .items ())
502+ if (mod_file := getattr (mod , "__file__" , None )) is not None
503+ and os .path .realpath (mod_file ).startswith (source_resolved )
504+ ]
505+ for k in stale_by_file :
506+ sys .modules .pop (k , None )
507+
508+ # 4. sys.path — remove the plugin directory if it was added
509+ plugin_dir_real = os .path .realpath (str (source_path ))
510+ sys .path [:] = [
511+ p for p in sys .path if os .path .realpath (p ) != plugin_dir_real
512+ ]
513+
450514 async def load_plugin (
451515 self ,
452516 manifest : PluginManifest ,
0 commit comments