Limbo Handlers
A limbo session is a player the proxy holds in a minimal world of its own instead of connecting them to a backend. The player sees a flat void, can receive chat, titles, and action-bar messages, and can run commands, but no backend is involved until the handler releases them. Use limbo for an auth gate, a queue, or a maintenance screen.
This page covers the WASM limbo API: the LimboHandler trait, the borrowed LimboSession, the storable SessionHandle, and the outcome types that release, deny, or redirect a held player.
Capability
Limbo is opt-in. Declare it in the plugin's config permissions; the capability string is limbo.
[plugins.my-gate]
path = "plugins/my-gate.wasm"
permissions = ["limbo"]2
3
Without the capability, reg.add still compiles and runs, but the host refuses the registration and the handler never fires. register-limbo-handler returns result<_, host-error> and the host answers permission-denied, but the SDK's LimboRegistrar::add drops that error, so the plugin cannot tell; the host logs the refusal at error (once a minute at most) and warns at load that the plugin imports register-limbo-handler without limbo. With strict_capabilities = true the plugin is refused at load instead.
A plugin holds at most [wasm.quotas] limbo_handlers handler names (64 by default). Past it the host answers limit-exceeded, which LimboRegistrar::add drops like any other refusal: the handler never fires and the host logs a warning naming the plugin and the quota.
INFO
Capabilities are listed in kebab-case. The baseline set (event bus, player read/write, command, scheduler, config read) is granted to every WASM plugin; limbo is one of the opt-ins you must list. See Capabilities for the full table.
Registering a handler
Implement Plugin::register_limbo_handlers and call reg.add(name, handler). The name is what a server's limbo_handlers config references.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct MyPlugin;
struct Gate;
impl LimboHandler for Gate {
fn on_player_enter(&self, session: &LimboSession) -> HandlerOutcome {
session
.send_message(Component::text("Type /continue to proceed"))
.ok();
HandlerOutcome::Hold
}
fn on_command(&self, session: &LimboSession, command: &str, _args: &[String]) {
if command == "continue" {
session.complete(HandlerOutcome::Accept).ok();
}
}
}
#[plugin(id = "my-gate", name = "My Gate")]
impl Plugin for MyPlugin {
fn on_enable(&self, _ctx: &Context) -> Result<(), PluginError> {
Ok(())
}
fn register_limbo_handlers(reg: &mut LimboRegistrar) {
reg.add("gate", Gate);
}
}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
28
29
30
31
32
Reference the registered name from the server config:
limbo_handlers = ["gate"]The proxy routes a player into limbo for that server using the chain in limbo_handlers, executed in order. See Server configuration for where limbo_handlers lives.
The LimboHandler trait
on_player_enter is the entry point and the only required method. It runs once when the player enters limbo and returns a HandlerOutcome. The rest have default no-op implementations.
pub trait LimboHandler {
fn on_player_enter(&self, session: &LimboSession) -> HandlerOutcome;
fn on_command(&self, session: &LimboSession, command: &str, args: &[String]) {}
fn on_chat(&self, session: &LimboSession, message: &str) {}
fn on_disconnect(&self, player: PlayerId) {}
fn on_session_end(&self, player: PlayerId, reason: SessionEndReason) {}
}2
3
4
5
6
7
| Method | When it fires | Receives |
|---|---|---|
on_player_enter | Player enters limbo | &LimboSession, returns HandlerOutcome |
on_command | Player runs a command while held | &LimboSession, command name, args |
on_chat | Player sends a chat message while held | &LimboSession, message text |
on_disconnect | Player's connection drops | PlayerId |
on_session_end | Session ends for any reason | PlayerId, SessionEndReason |
TIP
The plugin state is single-threaded with no async runtime. Keep mutable handler state in Cell/RefCell fields, as the fixture's Gate does with a RefCell<HashSet<PlayerId>> of waiting players.
HandlerOutcome
The value on_player_enter returns, and the value you pass to complete to end a hold. complete takes the terminal variants only: Hold and HoldWithTimeout are refused there (see Completing a hold). The variants:
| Variant | Effect |
|---|---|
Accept | Release the player to their original destination |
Deny(Component) | Disconnect the player with the given message |
Hold | Keep the player in limbo; release later via complete |
HoldWithTimeout { after, on_timeout } | Hold, then resolve with on_timeout if complete is not called within after |
Redirect(ServerId) | Send the player to the named server |
SendToLimbo(Vec<String>) | Route the player through another limbo-handler chain |
pub enum HandlerOutcome {
Accept,
Deny(Component),
Hold,
HoldWithTimeout { after: Duration, on_timeout: TimeoutOutcome },
Redirect(ServerId),
SendToLimbo(Vec<String>),
}2
3
4
5
6
7
8
TimeoutOutcome
HoldWithTimeout carries a TimeoutOutcome, not a HandlerOutcome. A timeout is terminal and cannot re-arm another hold; the type forbids it.
pub enum TimeoutOutcome {
Accept,
Deny(Component),
Redirect(ServerId),
SendToLimbo(Vec<String>),
}2
3
4
5
6
The engine owns the timer for HoldWithTimeout. It fires even if the plugin's own scheduled tasks die, so a timed gate stays bounded without the plugin tracking a deadline.
fn on_player_enter(&self, session: &LimboSession) -> HandlerOutcome {
session
.send_message(Component::text("Type /continue within 5s"))
.ok();
HandlerOutcome::HoldWithTimeout {
after: Duration::from_secs(5),
on_timeout: TimeoutOutcome::Deny(Component::text("Timed out")),
}
}2
3
4
5
6
7
8
9
LimboSession
LimboSession is borrowed. It is valid only for the duration of the dispatch call that received it (on_player_enter, on_command, on_chat). Do not store the reference. To act on a held player after the call returns, take a SessionHandle.
| Method | Returns | Purpose |
|---|---|---|
player_id() | PlayerId | The held player's id |
profile() | GameProfile | The player's game profile |
entry_context() | EntryContext | Why the player entered limbo |
send_message(impl Into<Component>) | Result<(), Error> | Send a chat message |
send_title(&TitleData) | Result<(), Error> | Send a title |
send_action_bar(impl Into<Component>) | Result<(), Error> | Send an action-bar message |
complete(HandlerOutcome) | Result<(), Error> | Release, deny, or redirect a held player; InvalidArgument when the outcome is Hold or HoldWithTimeout, or its text is invalid |
handle() | SessionHandle | Mint a storable handle for later completion |
EntryContext
entry_context() reports how the player arrived in limbo:
pub enum EntryContext {
InitialConnection(ServerId),
KickedFromServer { server: ServerId, reason: Component },
PluginRedirect(Option<ServerId>),
}2
3
4
5
Branch on this to vary the gate, for example a maintenance message for InitialConnection versus a reconnect prompt for KickedFromServer.
SessionHandle
SessionHandle is owned and storable across calls. Mint one with session.handle() while a LimboSession is live, move it into a scheduled task or an event handler, and complete the hold later.
| Method | Returns | Purpose |
|---|---|---|
player_id() | PlayerId | The held player's id |
send_message(impl Into<Component>) | Result<(), Error> | Send a chat message |
send_title(&TitleData) | Result<(), Error> | Send a title |
send_action_bar(impl Into<Component>) | Result<(), Error> | Send an action-bar message |
complete(HandlerOutcome) | Result<(), Error> | Release, deny, or redirect the held player; InvalidArgument when the outcome is Hold or HoldWithTimeout, or its text is invalid |
cancelled() | bool | True once the session has ended |
complete on a stale handle is a safe no-op: the host captures the hold generation when the handle is minted, so a completion that arrives after the session advanced or ended does nothing. cancelled() returns true once the engine has ended the session, which lets a repeating task know to stop.
The send_* methods of a LimboSession and a SessionHandle return these error kinds:
ErrorKind | When |
|---|---|
PlayerGone | The limbo session has ended: the player left, or was released, redirected or sent to another chain. The player can no longer be reached through this session; cancelled() is true or about to be. |
Unavailable | The message could not be queued: the player's outgoing queue is full, or the proxy could not encode it for the player's version. The session is still live and a later send may go through. |
InvalidArgument | The text component or title is invalid, such as a component nested deeper than 64 levels. |
PlayerGone is the kind every player-facing call uses for a player who left, so a stored handle can tell "stop" from "try again".
Completing a hold
complete ends a hold with Accept, Deny, Redirect or SendToLimbo. It cannot start another hold: complete(Hold) and complete(HoldWithTimeout { .. }) return an InvalidArgument error and change nothing. The player stays held, and a deadline set by HoldWithTimeout keeps its original time and on_timeout; the handle can still complete the hold later.
A HoldWithTimeout deadline cannot be moved. A gate that needs to extend its wait returns Hold from on_player_enter and keeps its own deadline: a Context::new().delay task that completes the hold with Deny, which the gate cancels and schedules again to extend the wait.
A native handler gets the same treatment. LimboSession::complete and SessionHandle::complete return (), so they cannot report the refusal: a hold passed to them is ignored with a warning in the log, the player stays held, and the current deadline keeps its time.
Async hold pattern
Return Hold from on_player_enter, store the handle in a scheduled task, and complete the hold when the work finishes. Check cancelled() first so a disconnected player is not acted on.
struct DelayedGate;
impl LimboHandler for DelayedGate {
fn on_player_enter(&self, session: &LimboSession) -> HandlerOutcome {
let handle = session.handle();
let scheduled = Context::new().delay(Duration::from_millis(50), move || {
if !handle.cancelled() {
handle.complete(HandlerOutcome::Accept).ok();
}
});
match scheduled {
Ok(_) => HandlerOutcome::Hold,
Err(_) => HandlerOutcome::Accept,
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Context::new() is a cheap handle to the runtime, so a LimboHandler that does not receive a &Context can still schedule tasks and subscribe to events. delay runs the task once after the duration; interval runs it repeatedly. Both return Result<TaskHandle, Error>; the example accepts the player at once when the task could not be scheduled, rather than holding them with nothing to release them.
Session lifecycle
sequenceDiagram
participant P as Player
participant H as Host
participant G as Guest handler
P->>H: Enters limbo
H->>G: on_player_enter(session)
G-->>H: HandlerOutcome::Hold
Note over P,H: Player held in limbo world
P->>H: /continue
H->>G: on_command(session, "continue", [])
G->>H: session.complete(Accept)
H->>G: on_session_end(player_id, Released)
H->>P: Released to destination2
3
4
5
6
7
8
9
10
11
12
13
on_session_end
on_session_end fires when the player's limbo session ends, for any reason but one. Use it to drop a stored SessionHandle and cancel scheduled tasks tied to that player.
The exception is SendToLimbo, from on_player_enter, from complete or as the on_timeout of a timed hold: the player goes on to the other handler chain without leaving the limbo world, and neither on_session_end nor on_disconnect reaches the handlers the player leaves. The session they saw has still ended: a SessionHandle minted in it reports cancelled(), and complete on it does nothing. The host stops holding that player for your plugin, so a later fault of the plugin does not deny them. Drop what you stored for the player when cancelled() turns true.
pub enum SessionEndReason {
Disconnected,
Released,
Kicked,
Redirected,
TimedOut,
Shutdown,
}2
3
4
5
6
7
8
| Reason | Cause |
|---|---|
Disconnected | Player's connection dropped |
Released | Handler completed with Accept |
Kicked | Handler completed with Deny |
Redirected | Handler completed with Redirect |
TimedOut | A HoldWithTimeout deadline elapsed |
Shutdown | The proxy is shutting down |
WARNING
A handle stored across a hold outlives the session if you never drop it. Clean up in on_session_end (or on_disconnect for connection drops) so stored handles and tasks do not accumulate per player.
Fail-closed behavior
The host treats a guest trap as a denial, never a silent pass. A trap also discards the instance: the host never runs guest code in it again and starts a fresh instance instead (see Fault model).
| Dispatch | On trap |
|---|---|
on_player_enter | Session is denied with "Limbo handler unavailable" |
on_command | The command is dropped |
on_chat | The chat message is dropped |
on_disconnect | Cleanup is skipped |
on_session_end | Cleanup is skipped |
Every player the discarded instance was still holding (its handler returned Hold or HoldWithTimeout and has not completed yet) is released at once with the same "Limbo handler unavailable" denial, so no player waits on a handler that no longer exists. A player the plugin held who has gone on to another handler chain with SendToLimbo is no longer held by it and is left alone. Handlers are known by name: when the fresh instance registers the same handler names in on_enable, the proxy's existing handlers route to it. A name it does not register again denies the players who reach it. While the plugin is quarantined, its handlers deny every player that enters.
A handler that panics in on_player_enter denies the player rather than leaving them stuck in limbo:
struct Boom;
impl LimboHandler for Boom {
fn on_player_enter(&self, _session: &LimboSession) -> HandlerOutcome {
panic!("boom: this handler always traps");
}
}2
3
4
5
6
7
The host also denies the session if it cannot upgrade the instance reference or cannot lend the native session to the guest. An outcome returned from on_player_enter whose text the host cannot accept, such as a Deny component nested deeper than 64 levels, still applies, with a placeholder text and a warning in the log.
See also
- Services: player, server, ban, and config accessors usable from a handler
- Capabilities: the full capability table and the baseline set
- API Reference: every exported type and method
- Examples: complete plugin sources, including limbo gates
- Architecture: how the host lends sessions to the guest
- Fault model: what happens to held players when an instance is replaced
- Native plugin guide: the async native API, which differs from this synchronous WASM API