Plugin Lifecycle
Every plugin goes through a fixed sequence of stages: discovery, dependency resolution, loading, enabling, active operation, and shutdown. The PluginManager orchestrates this sequence and handles errors at each step.
Lifecycle stages
discover_all() load_and_enable_all() shutdown()
│ │ │
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐
│ Discover │─▶│ Resolve │─▶│ Loading │─▶│ Enabled │─▶│ Disabled │─▶│ Unloaded │
│ │ │ deps │ │ │ │ │ │ │ │ │
└──────────┘ └───────────┘ └─────┬─────┘ └─────────┘ └──────────┘ └──────────┘
│
▼
┌─────────┐
│ Error │
└─────────┘2
3
4
5
6
7
8
9
10
11
12
Discover. Each PluginLoader scans the plugin directory and returns PluginMetadata for every plugin it can load. The manager refuses a plugin whose id breaks the id rule, and a plugin whose id another loader already provides: the loader listed first keeps the id, so a plugin compiled into the proxy wins over a .wasm with the same id.
Resolve deps. The manager runs a topological sort (Kahn's algorithm) on the collected metadata. This determines load order so that dependencies are enabled before the plugins that need them. A plugin with a missing hard dependency, or in a dependency cycle, is refused here, see Resolution rules.
Each refusal concerns one plugin: it is logged at error, the plugin's state becomes Error with the same message, and load_and_enable_all() returns it with the other errors. The other plugins go on. discover_all() itself fails only when a loader cannot discover at all, such as the WASM loader on a plugins_dir it cannot read.
Loading. Before any plugin loads, the manager calls loader.on_load() once per loader so loaders that host a runtime (the WASM loader, for example) can initialize it. Then for each plugin in the resolved order it calls loader.load() and sets the plugin's state to Loading. If a loader's on_load() fails, all of that loader's plugins are skipped and marked Error.
Enabled. The manager calls plugin.on_enable(ctx). If it succeeds, state moves to Enabled. If it fails, state moves to Error and the context is cleaned up immediately.
Disabled. During shutdown, the manager disables the plugins that depend on others before the plugins they depend on, and the plugins of one dependency level all at once. It calls on_disable(), waiting at most 5 seconds, then runs automatic cleanup regardless of whether on_disable succeeded. A single plugin can also be disabled while the proxy runs, see Disabling one plugin.
Unloaded. Once a plugin is disabled, the manager calls loader.unload() for it to release loader-level resources. After the last plugin, it calls loader.on_shutdown() once per loader (only for loaders whose on_load() succeeded) to tear down the runtime.
PluginState
The infrarust_api::plugin::PluginState enum tracks where a plugin is in the lifecycle:
pub enum PluginState {
Loading, // on_enable() in progress
Enabled, // Active and operational
Disabled, // After shutdown
Error(String), // Failed during init, message explains why
}2
3
4
5
6
as_str() gives the lowercase label (enabled, error, ...). Query a plugin's state programmatically via PluginManager::plugin_state(), which returns Option<&PluginState>, or through ctx.plugin_registry(), whose PluginInfo carries the plugin's metadata, state and runtime. The in-game plugins subcommand lists loaded plugins, and plugin <id> lists or runs a plugin's commands, but neither exposes the raw lifecycle state.
PluginInfo::runtime is an Option<PluginRuntimeStatus>, read from the plugin each time the registry is asked. It is None for a native plugin. A WASM plugin fills it: health (a PluginHealth: Healthy, Recovering { retry_in }, Quarantined { retry_in } or Stopped), generation, restarts in the recovery window, last_fault, and queue with the depth and the waits of its call queue over the last minute. The lifecycle state of a quarantined WASM plugin stays Enabled; see Watching a plugin's health. PluginManager::plugin_runtime(id) returns the same value.
The Plugin trait
The Plugin trait is the entry point for all plugins. It's defined in crates/infrarust-api/src/plugin.rs:
pub trait Plugin: Send + Sync {
fn metadata(&self) -> PluginMetadata;
fn on_enable<'a>(
&'a self,
ctx: &'a dyn PluginContext,
) -> BoxFuture<'a, Result<(), PluginError>>;
fn on_disable(&self) -> BoxFuture<'_, Result<(), PluginError>> {
Box::pin(async { Ok(()) }) // default: no-op
}
fn runtime_status(&self) -> Option<PluginRuntimeStatus> {
None // default: no supervised runtime
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
metadata() returns the plugin's identity and dependency declarations. Called multiple times throughout the lifecycle.
on_enable() receives a PluginContext for registering event listeners, commands, limbo handlers, config providers, and filters. This is the only place you should register resources, because the context tracks everything for automatic cleanup.
on_disable() is optional. Override it only if your plugin holds external resources (database connections, open files, network sockets) that need explicit teardown. Event listeners, commands, filters, and scheduled tasks are cleaned up automatically.
runtime_status() is for loaders that supervise the plugins they host. The WASM loader returns the health and call queue of the plugin's instance; a native plugin keeps the default None.
WARNING
on_enable and on_disable return BoxFuture because the trait uses manual async dispatch. Wrap your implementation in Box::pin(async move { ... }).
PluginMetadata
PluginMetadata identifies your plugin and declares its dependencies:
pub struct PluginMetadata {
pub id: String, // Unique id, see the rule below
pub name: String, // Human-readable name
pub version: String, // Semver version string
pub authors: Vec<String>, // Author list
pub description: Option<String>, // Optional description
pub dependencies: Vec<PluginDependency>,
}2
3
4
5
6
7
8
Build metadata using the constructor and builder methods:
PluginMetadata::new("my_plugin", "My Plugin", "1.0.0")
.author("Alice")
.author("Bob")
.description("Does something useful")
.depends_on("core_plugin") // required dependency
.optional_dependency("extra_plugin") // optional dependency2
3
4
5
6
The id must be unique across all loaded plugins, and follow the plugin id rule: 1 to 64 lowercase letters, digits, - and _, starting with a letter or a digit. It names the plugin's data directory, plugins_dir/<id>. The manager refuses a plugin whose id breaks the rule, and a second plugin with an id already taken.
Dependencies
Each dependency is a PluginDependency with two fields:
pub struct PluginDependency {
pub id: String, // ID of the required plugin
pub optional: bool, // true = plugin works without it
}2
3
4
Use .depends_on("plugin_id") for required dependencies and .optional_dependency("plugin_id") for optional ones.
Resolution rules
The dependency resolver in crates/infrarust-core/src/plugin/dependency.rs applies these rules:
- If a required dependency is missing, the plugin that declared it is refused, and so is every plugin that requires a refused one. The others load.
- If an optional dependency is missing, it's skipped. The declaring plugin still loads.
- If an optional dependency is present, it still affects load order. The dependency loads first.
- Circular dependencies (A depends on B, B depends on A) are detected. Every plugin in the cycle is refused, with an error naming the cycle, and so is every plugin that requires one of them.
The resolver uses Kahn's algorithm for topological sorting. Plugins with no dependencies load first, then plugins whose dependencies are satisfied, and so on. Among plugins that are free to load, discovery order wins, so the same set of plugins always loads in the same order.
// Example: discovered as [auth, motd, database]
// auth depends on database
// motd has no dependencies
//
// Resolved order: [motd, database, auth]
// database always loads before auth2
3
4
5
6
A plugin refused for a missing dependency gets the error plugin 'x' requires 'y', which was not found; one that requires a refused plugin gets plugin 'x' requires 'y', which is not enabled.
Enable flow in detail
When load_and_enable_all() runs, it first calls on_load() on every loader and records which ones succeeded. Then it processes each plugin in the resolved order:
- If the plugin is disabled with
enabled = false, marks itDisabledand skips it. - Checks every required dependency. If one is not
Enabled(disabled in the config, refused, failed to load, or failed inon_enable), the plugin is markedError("plugin 'x' requires 'y', which is not enabled"), the error is collected, and the plugin is skipped. Since plugins are processed in dependency order, this carries on to the plugins that require it. Optional dependencies are not checked. - Finds the correct loader for the plugin (based on discovery mapping). If that loader's
on_load()failed, the plugin is markedErrorand skipped. - Calls
loader.load(plugin_id, context_factory)to instantiate the plugin. - Creates a per-plugin
PluginContextvia the context factory. - Sets state to
Loading. - Calls
plugin.on_enable(ctx). - On success: state becomes
Enabled, the plugin is stored for later shutdown, and the proxy posts aPluginEnabledEvent. - On failure: state becomes
Error(message), the context is immediately cleaned up, and the error is collected.
Errors during loading or enabling don't stop other plugins. The manager collects all errors and continues with the next plugin in the load order.
Shutdown flow
shutdown() first cancels the proxy_shutdown() token, if the proxy has not already done so. Then it groups the plugins into dependency levels: level 0 holds the plugins no enabled plugin depends on, level 1 the plugins only level 0 depends on, and so on, counting hard and optional dependencies alike. It disables one level at a time, starting with level 0, and every plugin of a level at once. For each plugin:
- Skips
on_disableand cleanup for a plugin not in theEnabledstate. - Sets state to
Disabled. - Calls
plugin.on_disable()and waits for it at most 5 seconds. Errors are logged but don't stop the shutdown. A future still running after 5 seconds is dropped with a warning,Plugin on_disable() did not return in time during shutdown; stopping the plugin without it, and the plugin is unloaded before its cleanup. - Runs
cleanup()on the plugin's context. This happens even ifon_disablefailed or was stopped. - Posts a
PluginDisabledEvent. - Calls
loader.unload()for the plugin.
The next level starts when every plugin of the current one is done. The whole plugin phase, ProxyShutdownEvent listeners included, is bounded at 10 seconds: once it runs out, on_disable is no longer waited for, but every remaining plugin is still cleaned up and unloaded. Last, the manager calls loader.on_shutdown() on each loader that booted successfully, in reverse order. Shutdown is idempotent, so calling it twice runs on_shutdown() only once.
Keep on_disable short: flush what you must keep and return. Anything longer than 5 seconds is cut off at shutdown. The limits are fixed for the infrarust binary; a program that embeds the proxy can set them with ProxyRuntimeBuilder::plugin_disable_timeout and plugin_shutdown_timeout, or with PluginManager::set_shutdown_limits. A second SIGTERM or Ctrl-C while the proxy is stopping exits the process at once, without waiting for any plugin.
Disabling one plugin
PluginManager::disable_plugin(id) (and RunningProxy::disable_plugin(id), which calls it) disables a single enabled plugin while the proxy keeps running. It runs the same steps as shutdown for that plugin: on_disable(), cleanup, PluginDisabledEvent, then loader.unload().
It refuses with an error when the plugin is not enabled, or when another enabled plugin lists it with depends_on. A plugin that declared it with optional_dependency does not block it, so such a plugin must cope with the dependency going away, for example by looking its services up each time instead of caching them.
Lifecycle events
The plugin manager posts two reserved events:
| Event | Fields | Posted |
|---|---|---|
PluginEnabledEvent | plugin_id, version | Right after a plugin's on_enable succeeded |
PluginDisabledEvent | plugin_id | Right after a plugin's cleanup, on shutdown or disable_plugin |
The manager waits for each event to be delivered before it moves to the next plugin, so the events arrive in enable order, and in disable order from one dependency level to the next. The plugins of one level are disabled at once, so their PluginDisabledEvents can arrive in any order. A plugin receives the PluginEnabledEvent of itself and of every plugin enabled after it; use the plugin registry for those enabled before. The registry lists the plugins that are enabled right now, each with the state enabled: the manager adds a plugin as soon as its on_enable returns, before its PluginEnabledEvent, and removes it when its disabling starts, before on_disable, on shutdown as with disable_plugin. A plugin whose on_enable failed never appears in it, and a plugin does not find itself there during its own on_enable or on_disable. A plugin never receives its own PluginDisabledEvent, because its listeners are removed in the cleanup that comes first. On shutdown, the disabled events are posted after ProxyShutdownEvent.
Automatic resource cleanup
Each plugin gets its own PluginContext backed by a PluginContextImpl that wraps proxy services with tracking decorators. When you register a listener, command, or scheduled task through the context, the registration is recorded.
During cleanup (on disable or on enable failure), the context automatically:
- Unsubscribes all event listeners registered through
ctx.event_bus() - Unregisters all commands registered through
ctx.command_manager()or its handle, and only those: a registration the proxy refused was never recorded - Removes every codec and transport filter it owns, registered through
ctx.codec_filters()andctx.transport_filters(), and rebuilds the transport filter chain. Connections opened afterwards run none of its filter code; connections its transport filters already accepted still get theiron_closewhen they end - Cancels all scheduled tasks registered through
ctx.scheduler()orctx.scheduler(), async and blocking ones included - Removes its limbo handlers. A player one of them holds is released with the "Limbo handler unavailable" denial rather than left waiting
- Withdraws every service it provided through
ctx.services(), posting aServiceRemovedEventfor each - Cancels config provider watch tokens
- Removes active provider route entries from the domain router
This means you don't need to manually unsubscribe listeners or cancel tasks in on_disable. The proxy handles it.
// During on_enable: just register, don't store the handle
ctx.event_bus().subscribe::<PostLoginEvent, _>(
EventPriority::NORMAL,
|event| {
tracing::info!("{} joined", event.profile.username);
},
);
// During shutdown: the proxy unsubscribes this automatically2
3
4
5
6
7
8
9
TIP
Only override on_disable if you have resources the proxy can't track: database connections, open file handles, background threads you spawned yourself, etc.
The PluginContext
PluginContext is a sealed trait. Only the proxy implements it. Plugins receive it as &dyn PluginContext during on_enable.
Some of the available services:
| Method | Returns | Purpose |
|---|---|---|
event_bus() | Arc<dyn EventBus> | Subscribe to events |
command_manager() | Arc<dyn CommandManager> | Register console/player commands, during or after on_enable |
scheduler() | Arc<dyn Scheduler> | Schedule delayed or repeating tasks |
player_registry() | Arc<dyn PlayerRegistry> | Look up connected players |
server_manager() | Arc<dyn ServerManager> | Query and manage backend servers |
ban_service() | Arc<dyn BanService> | Ban/unban players |
config_service() | Arc<dyn ConfigService> | Read proxy configuration |
plugin_registry() | Arc<dyn PluginRegistry> | Inspect other loaded plugins |
register_limbo_handler() | Result<LimboHandlerRegistration, LimboHandlerError> | Register a limbo handler, at any time |
services() | Arc<dyn ServiceRegistry> | Provide an API to other plugins or use theirs |
register_config_provider() | () | Register a config provider |
codec_filters() | Option<&dyn CodecFilterRegistry> | Register packet-level filters, Some only with the CodecFilter capability |
transport_filters() | Option<&dyn TransportFilterRegistry> | Register TCP-level filters, Some only with the TransportFilter capability |
plugin_id() | &str | This plugin's ID |
data_dir() | PathBuf | This plugin's data directory, <plugins_dir>/<plugin_id>, created if missing |
proxy_shutdown() | CancellationToken | A token that fires when the proxy shuts down |
proxy_info() | &ProxyInfo | Proxy version and runtime settings |
capabilities() | &CapabilitySet | Capabilities granted to this plugin |
The two filter registries return None unless the plugin holds the matching capability. Compiled-in native plugins are trusted and receive both; WASM plugins are gated by config.
Every service accessor returns an Arc, so the handle can be captured in a closure or moved into a spawned async task; cloning it is cheap.
Plugin loaders
The PluginLoader trait abstracts how plugins are discovered and instantiated. Different loaders support different plugin formats.
pub trait PluginLoader: Send + Sync {
fn name(&self) -> &str;
fn discover<'a>(
&'a self, plugin_dir: &'a Path,
) -> BoxFuture<'a, Result<Vec<PluginMetadata>, LoaderError>>;
// Defaults to None. Where a discovered plugin comes from, for error messages.
fn plugin_source(&self, plugin_id: &str) -> Option<PathBuf> { /* ... */ }
// Defaults to a no-op. Runs once, before any of this loader's plugins load.
fn on_load<'a>(
&'a self, context_factory: &'a dyn PluginContextFactory,
) -> BoxFuture<'a, Result<(), LoaderError>> { /* ... */ }
fn load<'a>(
&'a self, plugin_id: &'a str,
context_factory: &'a dyn PluginContextFactory,
) -> BoxFuture<'a, Result<Box<dyn Plugin>, LoaderError>>;
fn unload<'a>(
&'a self, plugin_id: &'a str,
) -> BoxFuture<'a, Result<(), LoaderError>>;
// Defaults to a no-op. Runs once, after all this loader's plugins unload.
fn on_shutdown<'a>(&'a self) -> BoxFuture<'a, Result<(), LoaderError>> { /* ... */ }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
discover() runs before the context factory exists, so a loader cannot rely on its own hosted runtime to enumerate plugins. It returns an error only when it cannot discover anything, which stops the proxy; a loader skips a single bad plugin, logs it, and returns the others. plugin_source() lets the manager name the file of a plugin it refuses, such as a .wasm whose id is already taken. on_load() is where a runtime gets initialized, and on_shutdown() is where it is torn down. These three have default implementations, so simple loaders only implement name, discover, load, and unload.
Infrarust ships with a StaticPluginLoader that loads plugins compiled directly into the binary. Plugins are registered with a metadata struct and a factory closure. Its discover() ignores the plugin directory argument and returns the plugins in registration order, and register() panics if you register two plugins with the same ID.
let loader = StaticPluginLoader::new();
loader.register(
PluginMetadata::new("greet", "Greet Plugin", "0.1.0"),
|| Box::new(GreetPlugin),
);2
3
4
5
The WASM loader is a second PluginLoader implementation that discovers and runs wasm32-wasip2 components from the plugins directory. New formats slot in the same way, by adding another PluginLoader.
Error handling
Errors during the lifecycle surface as PluginError or LoaderError depending on the source.
LoaderError covers loader-level failures:
| Variant | When |
|---|---|
DirectoryNotAccessible | Plugin directory can't be read |
PluginNotFound | load() called for an unknown ID |
InvalidFormat | Plugin file is corrupt or unreadable |
LoadFailed | Plugin instantiation failed |
UnloadFailed | Cleanup after disable failed |
PluginError has two variants:
| Variant | When |
|---|---|
InitFailed(String) | Dependency resolution failures (missing required dependency, circular dependency), loader discovery or on_load failures, and the message the manager records for a failed load(). LimboHandlerError and ServiceError convert into it, so ? on a refused registration fails on_enable. |
Custom(String) | Anything else. String and &str convert into this variant via From, so a plugin can return Err("reason".into()) from on_enable. |
discover_all() returns a single PluginError on the first fatal problem (a duplicate ID, a loader discovery failure, or an unresolvable dependency graph), and no plugins load. load_and_enable_all() is more forgiving: it collects every error into a Vec<PluginError> and returns them. An error returned from a plugin's own on_enable is collected as-is, not re-wrapped. Each failed plugin is marked PluginState::Error while the remaining plugins continue loading.
During shutdown, errors from on_disable and unload are logged but don't interrupt the process. Every plugin gets its cleanup pass regardless of errors or time-outs in other plugins.