stanchion GitHub

Plugin registry

Discovering, loading, calling and reloading a directory of plugins.

See also isolation, capabilities, dependency chains, signatures, LuaRocks and out-of-process hosting.

The registry feature adds Registry<C>, which loads a directory of plugins into either one shared Lua state or one state per plugin.

plugins/
  greeter/
    plugin.toml
    init.lua
  formatter/
    plugin.toml
    init.lua
# plugins/greeter/plugin.toml
name = "greeter"
version = "1.2.0"     # semver; absent means 0.0.0, which satisfies only "*"
entry = "init.lua"    # optional, defaults to init.lua

[dependencies]
formatter = "^1.0"
logger = { version = "^2.0", optional = true }

[config]
greeting = "hello"

The constructor (new by default, Registry::with_constructor to change it) receives the manifest’s [config] table and a deps table holding what this plugin is wired to:

function Greeter.new(config, deps)
  return setmetatable({
    greeting = config.greeting,
    fmt = deps.formatter,          -- nil if declared optional and absent
  }, Greeter)
end

Lua ignores extra arguments, so a plugin that only wants config can keep taking one.

let mut registry: Registry<GreeterClass> = Registry::new(Lua::new());
let report = registry.load_dir("plugins/")?;

for outcome in registry.dispatch(|p| p.greet("world".to_string())) {
    match outcome.result {
        Ok(text) => println!("{}: {text}", outcome.name),
        Err(err) => eprintln!("{} failed: {err}", outcome.name),
    }
}

Reload

registry.reload("greeter") re-reads the manifest and chunk and swaps in a fresh instance. A handle to the plugin’s own instance taken before the reload keeps talking to the old object until it is dropped, so in-flight calls do not break.

Dependents do not receive an exports table directly; they receive a stable proxy whose metatable forwards reads and writes to the current one. Reloading a provider repoints that metatable, so every dependent sees the new surface without being rebuilt and without holding a stale capture:

let beta = registry.get("beta").unwrap().instance().clone();
assert_eq!(beta.greet("world".to_string())?, "[hello] world");

registry.reload("alpha")?;   // beta is never rebuilt

assert_eq!(beta.greet("world".to_string())?, "[howdy] world");

Forwarding __newindex as well as __index is what keeps this honest: a method doing self.count = self.count + 1 through the proxy writes to the real table instead of shadowing the field on the proxy and silently forking state.

Two caveats: rawget/rawset bypass the proxy, and identity comparisons see the proxy rather than the exports table. A reload that withdraws a previously published exports fails and leaves the old instance in place.

Failure isolation

Only an unreadable plugin root is fatal. Everything else lands in LoadReport::failures while the remaining plugins still load:

FailureReasonCause
Manifestplugin.toml is not valid TOML, or a name collides
Iothe manifest or entry file could not be read
Luathe chunk failed, returned an invalid class, or the constructor errored
MissingDependencya required dependency is not in the plugin root
IncompatibleDependencya dependency is present, but its version fails the requirement
MissingExportsa dependency publishes no exports table
MissingRocka declared rock is not installed in the tree
IncompatibleRocka declared rock is installed at an unsatisfying version
Rocksrocks were declared with no tree configured, or the feature is off
CrossStateDependencya dependency’s exports cannot reach a per-plugin state
CapabilityDeniedpolicy refused a capability the plugin requires
UnknownCapabilitythe plugin requested something the host does not offer
Unsignedno signature, and the registry requires one
SignatureInvalida signature was present but did not verify
UntrustedSignercryptographically sound, but the signer is not trusted
DigestMismatcha file changed between verification and loading
Revokedthe build or its signer is on the host’s revocation list
Lockthe plugin is not the build the lockfile pins, or is not pinned at all
DependencyFaileda dependency failed, so this plugin was skipped
DependencyCyclethis plugin is part of a cycle

Dispatch is isolated the same way: dispatch returns one Outcome per plugin, so a plugin that errors does not stop the others.

Async dispatch

With async + registry, dispatch_async awaits each plugin in turn. Calls are sequential by design: they all reach the same Lua state, so running them concurrently would only contend on it.


← Documentation index