Say no to a connection: admission, kicks and timeouts
For Rust developers running a plaza server who need to refuse, remove and time out connections
You will build a small chat lounge on plaza_session's TCP transport on localhost and give its server the right to say no. A door reads each client's credential before the connection registers, so a banned name, an unreadable credential and a duplicate login are all settled before the lounge hears of anyone. Inside, the host can kick a guest with a reason that the guest reads before its socket closes. The lounge can also tell that kick apart from a dropped link. An OpGuard refuses ops before the rules see them. Finally, an idle rule and a session deadline close connections on a timer.
Before you start
You need Rust with Cargo. plaza comes from crates.io:
plaza holds the controller and your rules. plaza_session puts a real transport under them; this tutorial only needs its TCP transport and JSON, so the default WebSocket features are turned off. plaza_wire is the wire format, which the test client uses to write frames by hand. Your [dependencies] section should look like this:
The project has four source files. src/lounge.rs holds the rules, src/door.rs decides who gets in, src/client.rs is a small test client and src/main.rs runs each scenario. The server and its clients all live in one process, but every client talks to the server over a real TCP socket on 127.0.0.1.
Start with the rules. A guest who arrives is welcomed by name and anything a guest says is repeated to everyone. Write src/lounge.rs:
src/lounge.rs
usestd::sync::Arc;useasync_trait::async_trait;useplaza::session::TargetedOp;useplaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};useserde::{Deserialize, Serialize};usecrate::door::Door;#[derive(Clone, Debug, Serialize, Deserialize)]pubenumLoungeOp{ Say(String), Welcome { name:String}, Said { who:String, text:String},}#[derive(Debug, Default)]pubstructLoungeState{pubguests:Vec<u64>,}pubstructLounge{pubdoor:Arc<Door>,}#[async_trait]implStateLogic<LoungeOp, u64, LoungeState>forLounge{ async fnprocess_input(&self,state:&mut LoungeState,input:LogicInput<LoungeOp, u64>,)->Result<LogicOutput<LoungeOp, u64>, StateLogicError>{match input {LogicInput::AgentJoined { agent }=>{letSome(key)= agent.id_cloned()else{returnOk(LogicOutput::none());}; state.guests.push(key);let name =self.door.name_of(key);let welcome =LoungeOp::Welcome { name };Ok(vec![TargetedOp::new_system_to(key,vec![welcome])].into())}LogicInput::AgentOps { source, ops }=>{letSome(key)= source.id_cloned()else{returnOk(LogicOutput::none());};let who =self.door.name_of(key);letmut out =Vec::new();for op in ops {ifletLoungeOp::Say(text)= op {let said =LoungeOp::Said { who: who.clone(), text,}; out.push(TargetedOp::new_system_all(vec![said]));}}Ok(out.into())}LogicInput::AgentLeft { agent_id }=>{ state.guests.retain(|key|*key != agent_id);self.door.left(agent_id);Ok(LogicOutput::none())}_=>Ok(LogicOutput::none()),}}}
The lounge never sees a socket. It gets AgentJoined, AgentOps and AgentLeft from the controller and returns the ops to send.
The door is a ConnectionAdmitter. A client sends a Kind::Credential frame after it connects, the session holds the socket unregistered until the admitter answers and only an Admitted answer registers it. Here the credential is simply the guest's name. Plaza verifies nothing itself, so a real door would check a token here instead. Write src/door.rs:
The door mints a fresh u64 key for every connection it admits and keeps the name beside it. That key is the agent id the controller sees. Giving each connection its own id matters later: when the same name logs in twice, the two connections need two ids to act on. Every AgentLeft then names exactly one of them.
The client speaks the wire by hand. On TCP each frame is a 4-byte length followed by the frame itself. Every frame starts with a kind byte. The client sends a Credential frame and Ops frames, answers the session's latency probes with answer_ping and keeps the Goodbye frame the server writes before it closes a connection. Each thing it hears goes on a channel and hear prints the next one. Write src/client.rs:
The reader task only holds a weak handle on the writer. Dropping a Client therefore drops the write half, which closes the socket without any goodbye. A later step uses that to fake a dropped link.
Now src/main.rs. bind_with_admitter binds the listener with the door in front of it. The controller uses without_snapshots because a guest needs no catch-up when it joins:
The door can refuse as well as admit. A refusal is a ConnectionAdmission::Refused carrying a Farewell: a close code plus detail bytes the session writes into a Goodbye frame before closing the socket. Codes 4000 to 4999 belong to the application. Add a ban list to src/door.rs:
The session also refuses two kinds of socket on its own. A socket that sends an Ops frame before its credential is closed with 4401. A socket that presents nothing within Limits::credential_timeout is closed with 4408. The default timeout is five seconds, which this program shortens to 300 milliseconds so it does not have to wait. Replace src/main.rs:
Every refused socket hears its goodbye first and is closed after it. None of them registered, so the lounge never saw a join or a departure for them and no other guest heard anything. refused counts three: the ban, the unreadable credential and the ops frame sent too early. The silent socket is counted as timed out instead.
When a name that is already inside logs in again, someone has to lose. Plaza leaves the choice to you. To refuse the newcomer, return a Refused with a code of your own. This door does the other thing and closes the older connection.
To close a connection the door needs the session's ConnectionManager. The door has to exist before the session it admits into, so it takes the manager afterwards through a OnceLock. Replace src/door.rs:
deregister_agent closes every connection the key holds and sends each one the farewell first. The older login can be closed from inside admit because it is registered and the newcomer is not yet.
In src/main.rs, call attach once the session exists. Alice logs in from her laptop and then from her phone:
A kick is the same close as the duplicate login, ordered by the lounge instead of the door. The lounge needs the manager for that, so it gets a manager field.
The harder part is what happens afterwards. Every departure reaches the rules as the same AgentLeft, whether the host kicked the guest or the guest's network went away. Only the code that ordered the close knows why. ClosureLog is where that code writes it down. order records a reason and returns true the first time for a key, so a guest is sent one goodbye even when two rules decide to close it. departed reads the reason back on AgentLeft and answers Netdrop when nobody ordered anything.
Bob reads the reason before his socket closes. The code 4001 tells his client it was removed rather than dropped. The lounge reports bob as kicked and carol as a netdrop, from the same AgentLeft event.
Right now any guest can kick any other. Whether an agent may submit an op at all is an authorization question. An OpGuard answers it in one place. The controller runs the guard on every op a client sends, before process_input, with the state read-only. A refused op never reaches the rules.
The guard needs to know who the host is. The first guest to arrive becomes the host. In src/lounge.rs, add use plaza::agent::Agent; and use plaza::op_guard::OpClearance; to the imports. Add a Refused op and a host field, then write the guard as a plain function:
src/lounge.rs
#[derive(Clone, Debug, Serialize, Deserialize)]pubenumLoungeOp{ Say(String), Kick { who:String, reason:String}, Welcome { name:String}, Said { who:String, text:String}, Gone { who:String, why:String}, Refused(String),}#[derive(Debug, Default)]pubstructLoungeState{pubguests:Vec<u64>,pubhost:Option<u64>,pubpartings:ClosureLog<u64, String>,}pubfnscreen(state:&LoungeState, source:&Agent<u64>, op:&LoungeOp)->OpClearance<LoungeOp>{match op {LoungeOp::Say(_)=>OpClearance::Cleared,LoungeOp::Kick {..}if source.id()== state.host.as_ref()=>OpClearance::Cleared,LoungeOp::Kick {..}=>OpClearance::Refused { reply:Some(LoungeOp::Refused("only the host can kick".into())),},_=>OpClearance::Refused { reply:None},}}
Then record the host when a guest joins, right after the guest is added to guests:
src/lounge.rs
state.host.get_or_insert(key);
Say is cleared for everyone and Kick only for the host. A refused kick gets a Refused reply so the client knows what happened. Every other op is one the server sends, such as Said or Welcome, so a client that sends one is trying to forge it. That op is dropped with no reply.
GuardFn turns the function into a guard and StateControllerBuilder::guard installs it. controller.stats() hands back the controller's counters, which include ops_refused. Replace src/main.rs:
Bob tries to kick alice, then tries to forge a message in alice's name, then says something real. Run it:
$cargo runalice: [Welcome { name: "alice" }]bob: [Welcome { name: "bob" }]bob: [Refused("only the host can kick")]alice: [Said { who: "bob", text: "fine" }]bob: [Said { who: "bob", text: "fine" }]ops refused by the guard: 2
Bob's kick was answered with a refusal and alice heard nothing about it. The forged Said vanished and alice only heard what bob really said. Both refusals are counted. A client's ops are handled in the order it sent them, so by the time bob's real message arrives both refusals have happened.
The session offers two ways to close a connection on a timer and decides no timeout itself.
agent_idle_for reports how long a connection has been silent, counted from its last data frame. The session answers latency probes itself, so probe traffic never counts as activity. This is why an away-from-keyboard rule has to read the session's number: a rule written against the frames on the socket would see the probes and never fire. The session only reports the number. Your code checks it on its own schedule and decides what is too long.
set_deadline limits how long a connection may stay open and the connection's own task enforces it. Expiry goes through the same flush-then-goodbye close as a kick. Setting it again moves the deadline, which is how a renewal works. Setting it to None clears it.
In src/lounge.rs, add use std::time::Duration; to the imports. Add two codes next to KICKED and three settings to Lounge:
A guest on the trial list gets a deadline when it joins. The deadline needs a connection id rather than an agent id, so connections_of resolves the key. Add this to the AgentJoined arm, after the name lookup:
Arming the deadline is the lounge ordering a close, so it records the reason in the ClosureLog right away. That also stops the idle rule from sending the same guest a second goodbye.
The idle rule runs on every time step. Add a TimeStep arm before the AgentLeft arm and delete the _ => Ok(LogicOutput::none()) arm, since the four arms now cover every input:
src/lounge.rs
LogicInput::TimeStep {..}=>{for&key in&state.guests {let idle =self.manager.agent_idle_for(&key).unwrap_or_default();if idle >=self.away_after && state.partings.order(key,"away".to_owned()){let farewell =Farewell::new(AWAY).with_detail("away from the lounge".as_bytes());self.manager.deregister_agent(&key, farewell);}}Ok(LogicOutput::none())}
Time steps come from a TickDriver, which sends the controller one every 100 milliseconds here. Replace src/main.rs:
Erin says one thing and then goes quiet. Frank is on a 300 millisecond trial and the lounge removes anyone silent for 600 milliseconds. Run it:
$cargo runerin: [Welcome { name: "erin" }]frank: [Welcome { name: "frank" }]erin: [Said { who: "erin", text: "back in a minute" }]frank: [Said { who: "erin", text: "back in a minute" }]frank: goodbye 4002 "your trial is over"frank: socket closederin: [Gone { who: "frank", why: "trial over" }]erin: goodbye 4010 "away from the lounge"erin: socket closed
Frank's deadline fires first and erin is told why he left. Erin is removed for being away, even though her client answered every latency probe the session sent her the whole time. The exact moments both closes happen depend on the 100 millisecond tick and the scheduler, but the trial always ends before the idle rule fires, so the order of the lines stays the same.
This tutorial left out two of the session's tools for saying no. rate_limit_inbound sets a per-connection Rate that the session enforces before a frame reaches the queue every connection shares, so a flood costs only the flooder. disconnect_all sends every connection the same farewell and closes them all, which is a graceful shutdown in one call.
Guide chapter 40, The right to say no explains where each mechanism sits and why plaza ships no policy defaults. Its two labs are worth reading next to this tutorial: door_policy adds a per-address cap, a capacity rule and a credit that buys time through set_deadline. table_manners adds flood shedding, a drain and seats that survive a netdrop but not a kick.
The plaza_session usage guide on GitHub covers admission, close codes, deadlines and the per-connection readers. The plaza usage guide covers OpGuard and the controller.