Dynamic Plugin
The template for plugin hosts: a custom CommandChannel that advertises its commands at runtime, dispatches execute.command.request messages into an isolated handler map, and cleans up every command it owns when the channel closes.
Typical use cases: a scripting runtime (QuickJS, Lua, WASM) where each loaded plugin exports a handful of functions, an FFI bridge that surfaces shared-library entry points as commands, or any host that needs to hot-reload a set of commands as a group.
The example in rust/examples/dynamic-plugin/ uses a plain HashMap<String, Fn> in place of a real sandbox so it’s runnable without a scripting runtime dependency.
make rs-start-example dynamic-plugin# orcargo run -p dynamic-pluginWhat the example proves
Section titled “What the example proves”- Dynamic command advertisement.
PluginChannel::start()sends oneregister.command.requestper exported function. TheCommandSchemais built at runtime — no compile-time types. - Runtime dispatch.
execute.command.requestmessages are routed into the sandbox handler map and responded to withexecute.command.responseon the channel’s outbound side. - Automatic cleanup on unload.
plugin.close().awaitcauses the registry’s driver loop to observe EOF and run its channel-close cleanup, removing every command owned by the channel. There is nounregister_commandAPI — close the channel and the commands are gone. - Loose-mode invocation.
registry.execute_dyn(id, json!({..}))is the natural call path for runtime commands. - Permissive schemas.
CommandSchema::permissive()is the realistic fallback when an exported function’s signature isany → any.CommandSchema::empty().with_request(..).with_response(..)is the builder for partial schemas.
Expected output
Section titled “Expected output”─── advertising plugin commands ────────────────────────────── plugin.echo — Echo the request payload back, wrapped plugin.greet — Greet someone by name
─── calling plugin commands via execute_dyn ────────────────── plugin.greet {name:"Ada"} => {"greeting":"hello, Ada"} plugin.echo {x:1, y:[…]} => {"you_sent":{"x":1,"y":[true,false]}} plugin.echo null => err: invalid request for command plugin.echo: expected a non-null payload
─── closing the plugin channel (unloads the plugin) ────────── commands visible to root after close: (none — cleanup worked)
─── attempting to call a command after close ───────────────── plugin.greet => err: command not found: plugin.greetKey pieces
Section titled “Key pieces”PluginChannel::start — advertise commands
Section titled “PluginChannel::start — advertise commands”fn start(&self) -> BoxFuture<'_, Result<(), ChannelError>> { async move { for (_, (def, _)) in self.sandbox.commands.iter() { self.outbound_tx .unbounded_send(Message::RegisterCommandRequest { id: MessageId::new_v4(), command: def.clone(), }) .map_err(|e| ChannelError::Send(e.to_string()))?; } Ok(()) } .boxed()}The registry’s driver loop pulls those RegisterCommandRequests off recv() and records each command as remote, owned by this channel.
PluginChannel::send — dispatch into the sandbox
Section titled “PluginChannel::send — dispatch into the sandbox”Message::ExecuteCommandRequest { id, command_id, request } => { let response = self.dispatch(command_id, request.unwrap_or(Value::Null)); let tx = self.outbound_tx.clone(); std::thread::spawn(move || { let response = block_on(response); let _ = tx.unbounded_send(Message::ExecuteCommandResponse { id: MessageId::new_v4(), thid: id, response, }); });}Real hosts would run the sandbox on its own event loop instead of spawning a thread per call, but the shape is the same: take the request, run the handler, correlate the response via thid.
Cleanup on close
Section titled “Cleanup on close”plugin.close().await;// Registry's driver loop observes EOF and runs handle_channel_close,// which drops every remote command owned by this channel. No manual// unregister required.assert!(registry.list_commands().is_empty());Porting to a real runtime
Section titled “Porting to a real runtime”- Replace the
HashMap<String, Fn>sandbox with your runtime’s context (a JS VM like QuickJS viarquickjs, a Lua state, a WASM instance, a loadeddlopened library — whatever your plugin host runs). - In
start(), introspect the plugin’s exports to buildCommandDefs. UseCommandSchema::permissive()when the exported function’s shape isn’t introspectable; otherwise translate the runtime’s type info into JSON Schema and pass via.with_request(..)/.with_response(..). - In
dispatch(), invoke the plugin function inside the sandbox, capture its return value (or thrown error), and map toExecuteResult. - On
close(), tear down the runtime context so resources are released before the channel goes away. Callplugin.close().awaitfrom your unload path so the runtime is gone before the caller observes the plugin as removed; the registry will then clean up the command entries on its own.
See also
Section titled “See also”CommandChanneltrait — the four-method interface every custom channel implements.DynCommand/BoxedDynCommand— the registration-side counterpart for in-process runtime commands (no custom channel needed).- InMemoryChannel — the simplest custom-channel shape.