diff --git a/README.md b/README.md index 2cc4fc2..1463e03 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # XtendR -![Latest Version](https://gitpot.lerk.ing/badges/badge/static?label=Latest+version&message=0.5.1&color=blue) +![Latest Version](https://gitpot.lerk.ing/badges/badge/static?label=Latest+version&message=9.5.2&color=blue) A very basic Python 3.12 friendly plugin system based on the K.I.S.S principle. diff --git a/plugins/example_plugin/example_plugin.py b/plugins/example_plugin/example_plugin.py index 5524d68..6429444 100644 --- a/plugins/example_plugin/example_plugin.py +++ b/plugins/example_plugin/example_plugin.py @@ -37,3 +37,6 @@ class ExamplePlugin(XtendRBase): def pre_load(self, callback): time.sleep(5) # Indicate long running pre-load. callback() + + def unload(self): + print("ExamplePlugin has unloaded!") # release signal handlers, timers, widgets, etc. here diff --git a/setup.py b/setup.py index 3906a20..ff5b722 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ if __name__ == "__main__": setup( name="XtendR", - version="0.5.1", + version="0.5.2", packages=find_packages(), install_requires=[], author="Jan Lerking", diff --git a/xtendr/xtendrbase.py b/xtendr/xtendrbase.py index 3a85940..a300059 100644 --- a/xtendr/xtendrbase.py +++ b/xtendr/xtendrbase.py @@ -2,19 +2,43 @@ from abc import ABC, abstractmethod class XtendRBase(ABC): """Abstract base class for all plugins. - + + Lifecycle: pre_load() -> run() -> stop() -> unload(). XtendRSystem calls + unload() exactly once, right before it drops its own references to the + plugin instance and removes its module from sys.modules. This is the + plugin's only chance to release anything it holds that XtendRSystem + doesn't know about and can't clean up on its own, for example: + + - GObject/GTK signal handler ids from .connect() (disconnect them, or + the GObject side keeps a reference to the bound method, which keeps + the plugin instance -- and everything it references -- alive). + - Widgets the plugin inserted into the app's widget tree (remove them + from their parent; a parented widget is kept alive by GTK regardless + of what Python does with its own references). + - GLib.timeout_add / idle_add source ids (GLib.source_remove them). + - Any threads it started that aren't daemon threads, or open files/ + sockets/subprocesses. + + unload() has a default no-op implementation so existing plugins that + don't hold any such resources keep working unchanged; override it only + when there's something to release. + Example: >>> class TestPlugin(XtendRBase): ... def run(self): ... print("Running TestPlugin") ... def stop(self): ... print("Stopping TestPlugin") + ... def unload(self): + ... print("Unloading TestPlugin") >>> plugin = TestPlugin() >>> plugin.run() Running TestPlugin >>> plugin.stop() Stopping TestPlugin + >>> plugin.unload() + Unloading TestPlugin """ @abstractmethod def run(self, *args, **kwargs): @@ -27,4 +51,9 @@ class XtendRBase(ABC): @abstractmethod def pre_load(self, *args): pass - \ No newline at end of file + + def unload(self): + """Release any resources the plugin holds. Called once by + XtendRSystem.detach(), after stop(). Optional to override; + default is a no-op.""" + pass diff --git a/xtendr/xtendrsystem.py b/xtendr/xtendrsystem.py index 62c19d5..c9d4fd5 100644 --- a/xtendr/xtendrsystem.py +++ b/xtendr/xtendrsystem.py @@ -10,7 +10,7 @@ from pathlib import Path from xtendr.xtendrbase import XtendRBase from xtendr import signing as xsign -__version__ = "0.5.0" +__version__ = "0.5.1" logger = logging.getLogger("xtendr") @@ -37,7 +37,7 @@ class XtendRSystem: Example: >>> system = XtendRSystem() >>> system.version() - XtendR v0.5.0 + XtendR v0.5.1 >>> system.attach("example_plugin", lambda: None) >>> system.run("example_plugin") ExamplePlugin is running! @@ -45,11 +45,23 @@ class XtendRSystem: ExamplePlugin has stopped! >>> system.detach("example_plugin") Detached plugin 'example_plugin'. + + A system can be marked `protected=True` for plugins that must stay + attached for the lifetime of the process (e.g. an application's + built-ins). Protected systems still attach/run/stop normally; only + detach() is restricted, and only for ordinary callers -- detach(..., + force=True) and detach_all(..., force=True) remain available for the + host application's own shutdown path. There is deliberately no way to + force a single detach() without also opting in via the same keyword a + UI action would have to expose, so "not detachable by the user" is a + property of the call site, not of hidden state. """ - def __init__(self, pluginpath="plugins", public_key_path=None, whitelist_path=None, whitelist_passphrase=None): + def __init__(self, pluginpath="plugins", public_key_path=None, whitelist_path=None, + whitelist_passphrase=None, protected=False): self.pluginspath = pluginpath self.plugins = {} + self.protected = protected self._lock = threading.RLock() # -- signature verification setup --------------------------------- @@ -208,6 +220,7 @@ class XtendRSystem: "module_key": None, "disabled": True, "signature": sig, + "pre_load_thread": None, } return @@ -244,6 +257,7 @@ class XtendRSystem: "module_key": qualified_name, "disabled": False, "signature": sig, + "pre_load_thread": None, } logger.info("Attached plugin '%s'.", name) logger.info("Running pre-load on '%s'.", name) @@ -255,45 +269,125 @@ class XtendRSystem: logger.error("Plugin '%s' raised during pre_load.", name, exc_info=True) thread = threading.Thread(target=_pre_load_worker, daemon=True) + self.plugins[name]["pre_load_thread"] = thread thread.start() def run(self, name: str, *args, **kwargs): """Run the plugin's 'run' method if available.""" with self._lock: entry = self.plugins.get(name) - if entry is None: - logger.error("Plugin '%s' not found or has no 'run' method.", name) - return - if entry.get("disabled"): - logger.error("Plugin '%s' is disabled (failed signature verification) and cannot run.", name) - return - entry["running"] = True + if entry is None: + logger.error("Plugin '%s' not found or has no 'run' method.", name) + return + if entry.get("disabled"): + logger.error("Plugin '%s' is disabled (failed signature verification) and cannot run.", name) + return + entry["running"] = True + instance = entry["instance"] try: - return entry["instance"].run(*args, **kwargs) + return instance.run(*args, **kwargs) except Exception: # noqa: BLE001 logger.error("Plugin '%s' raised during run.", name, exc_info=True) - entry["running"] = False + with self._lock: + entry["running"] = False def stop(self, name: str) -> None: """Stop the plugin if it's running.""" with self._lock: entry = self.plugins.get(name) - if entry is None or not entry["running"]: - logger.info("Plugin '%s' is not running.", name) - return - entry["running"] = False + if entry is None or not entry["running"]: + logger.info("Plugin '%s' is not running.", name) + return + entry["running"] = False + instance = entry["instance"] try: - entry["instance"].stop() + instance.stop() except Exception: # noqa: BLE001 logger.error("Plugin '%s' raised during stop.", name, exc_info=True) - def detach(self, name: str) -> None: - """Unload a plugin.""" + def detach(self, name: str, *, timeout: float = 5.0, force: bool = False) -> None: + """Unload a plugin: stop it if running, let it release its own + resources via unload(), then drop every reference XtendRSystem + holds to it (dict entry, sys.modules entry) so nothing outside the + plugin's own cleanup keeps it alive. + + Protected systems (see __init__) refuse this unless force=True is + passed explicitly -- the caller has to opt in on purpose, so a + generic "detach" UI action wired up against this system can't + accidentally (or maliciously) unload a built-in. + """ + if self.protected and not force: + logger.error( + "Refusing to detach '%s': this plugin system is protected " + "and cannot be detached from without force=True.", name, + ) + return + with self._lock: entry = self.plugins.pop(name, None) if entry is None: logger.info("Plugin '%s' is not attached.", name) return - if entry.get("module_key"): - sys.modules.pop(entry["module_key"], None) - logger.info("Detached plugin '%s'.", name) + was_running = entry["running"] + entry["running"] = False + instance = entry["instance"] + thread = entry.get("pre_load_thread") + + # pre_load() may still be running in its own thread (e.g. detach + # called right after attach). Give it a bounded chance to finish + # before we call stop()/unload(), so a plugin doesn't get stopped + # out from under itself mid pre_load. If it doesn't finish in + # time we proceed anyway -- it's a daemon thread and holds its + # own reference to the instance, so this is a correctness/race + # concern rather than a leak. + if thread is not None and thread.is_alive(): + thread.join(timeout) + if thread.is_alive(): + logger.warning( + "Plugin '%s' pre_load() did not finish within %.1fs; " + "detaching anyway.", name, timeout, + ) + + if instance is not None: + if was_running: + try: + instance.stop() + except Exception: # noqa: BLE001 + logger.error("Plugin '%s' raised during stop.", name, exc_info=True) + try: + instance.unload() + except Exception: # noqa: BLE001 + logger.error("Plugin '%s' raised during unload.", name, exc_info=True) + + if entry.get("module_key"): + sys.modules.pop(entry["module_key"], None) + + # Drop our own strong references explicitly rather than letting + # them idle until this frame unwinds -- entry/instance are the + # last references XtendRSystem holds, so this makes the plugin + # object (and anything it exclusively owns) collectible the + # moment its unload() has actually let go of its own resources. + entry["instance"] = None + entry["pre_load_thread"] = None + + logger.info("Detached plugin '%s'.", name) + + def detach_all(self, *, force: bool = False) -> None: + """Detach every currently-attached plugin. Convenience for clean + shutdown; equivalent to calling detach() on every plugin name. + + For a protected system this is a no-op unless force=True (see + detach()) -- shutdown code that genuinely needs to tear a + protected system down (e.g. the host application closing) passes + force=True explicitly; nothing else can. + """ + if self.protected and not force: + logger.error( + "Refusing detach_all(): this plugin system is protected " + "and cannot be detached from without force=True." + ) + return + with self._lock: + names = list(self.plugins.keys()) + for name in names: + self.detach(name, force=force)