stanchion GitHub

Out-of-process hosting

Isolation and capabilities bound what a plugin can reach. Neither bounds what a plugin can do to the process it runs in: a sandbox cannot stop a segfault in a C rock, and an instruction limit cannot rescue a state whose allocator already failed. The remote feature moves plugins into a child process, where those failures are survivable.

cargo install stanchion --features lua54,vendored,remote   # provides `plugin-host`
plugin-host --config host.toml --plugins plugins/
use serde_json::json;
use stanchion::remote::{RemoteOptions, RemoteRegistry};

let mut remote = RemoteRegistry::launch(
    RemoteOptions::new("./plugin-host").config("host.toml").plugins("plugins/"),
)?;

let greeting: String = remote.call("greeter", "greet", [json!("world")])?;

for outcome in remote.dispatch("on_event", [json!("tick")])? {
    match (outcome.value, outcome.error) {
        (Some(value), _) => println!("{}: {value}", outcome.plugin),
        // Per-plugin, exactly as in-process dispatch behaves.
        (_, Some(error)) => eprintln!("{} failed: {error}", outcome.plugin),
        _ => {}
    }
}

A runnable example covers the whole path, including a plugin that kills its own process.

Transport and protocol

JSON-RPC 2.0 over stdio, one message per line. Not a bespoke encoding: a host can be written in any language that can read newline-delimited JSON on a pipe. No ports, no socket files, and stderr stays free for logs, which is where the host’s built-in log capability writes.

Methods are namespaced:

MethodDoes
plugins/loaddiscover and load every plugin under a root
plugins/listreport the loaded plugins
plugins/auditreport what plugins request, running none of their code
plugins/callcall one method on one plugin
plugins/dispatchcall the same method on every plugin
plugins/reloadre-read one plugin from disk
plugins/revokeunbind a capability from a live plugin
host/infodescribe the host
host/shutdownfinish serving and exit
capability/<name>host to application: a plugin calling a capability

jsonrpsee-types supplies the parts where conformance matters — request ids, the "2.0" marker, the standard error codes. The framing lives in this crate because jsonrpsee’s own transports are HTTP and WebSocket only. Envelopes are owned rather than borrowed, since a message read off a pipe outlives the buffer it arrived in.

Calls are dynamically typed, and that is forced rather than chosen: a host binary is compiled before anyone writes a plugin, so it cannot know a #[lua_class] trait. Plugins load as DynClass — any table with a constructor — and methods resolve by name at call time. Arguments and results cross as JSON, converted at the Lua boundary by mlua’s serde support. In-process hosting keeps the typed contract; use it when you can.

Calling back into your application

The channel is bidirectional. A capability listed under callbacks becomes a Lua function that forwards to the process that launched the host:

[capabilities]
allow = ["log"]        # answered by the host itself
callbacks = ["kv"]     # forwarded to your application
use stanchion::remote::CallbackCall;

let mut remote = RemoteRegistry::launch(options)?.on_callback(|call: &CallbackCall| {
    match call.capability.as_str() {
        "kv" => Ok(json!(lookup(&call.args))),
        other => Err(format!("`{other}` is not offered")),
    }
});

Three properties worth knowing:

Requests that arrive while a callback is in flight are queued rather than dropped, so your application can keep sending while a plugin is mid-call.

What a crash looks like

In-processOut-of-process
while true do endinstruction limitinstruction limit
allocation stormmemory limitmemory limit
os.exit, or a segfault in a C rockyour application diesRemoteError::HostGone
interpreter panicunwinds into your stackchild dies, you keep serving

RemoteError::HostGone { status } is the interesting one: the client distinguishes a dead child from an ordinary transport error by reaping it, so a plugin that kills its process becomes one error value rather than an outage. Dropping a RemoteRegistry closes the host’s stdin and kills anything that ignores it, so a dropped client never leaks a process.

Host configuration

Policy comes from a file the operator controls, never from plugin manifests:

plugins = "plugins/"

[sandbox]
libs = ["string", "table", "math", "coroutine", "package"]
deny = ["dofile", "loadfile", "package.loadlib"]
memory_limit = 67108864        # default 64 MiB
instruction_limit = 50000000   # default 50M, per call
shared = false                 # default: a state per plugin

[capabilities]
allow = ["log"]
callbacks = []

[signatures]
required = false

Every key is optional; the defaults above are what the host uses with no config file at all. shared defaults to false because a separate process should not stop isolating at the process boundary. An unknown standard-library name is a configuration error rather than a silent omission.

Client surface

RemoteRegistry mirrors the in-process registry, minus the typing:

MethodReturns
load(root)LoadResult { loaded, failures }
list()Vec<PluginInfo> — name, version, granted capabilities, signer
audit(root)Vec<AuditEntry> — requests and signer, nothing executed
call(plugin, method, args)any DeserializeOwned
dispatch(method, args)Vec<Outcome> — one per plugin
reload(plugin), revoke(plugin, capability)()
info()HostInfo { version, isolation, signatures_required }
is_alive(), shutdown()liveness, and an orderly stop

← Documentation index