Order Gateway: TCP Sessions
Framing, sessions, and one task per connection.
Production · Gateway, Protocols, Risk & Resilience
Why this week
Engine v1 is a deterministic function on one thread. This week it gets clients. An order gateway accepts TCP connections, turns a byte stream into framed messages, runs a session protocol (logon, sequence numbers, heartbeats, logout) for each connection, and hands valid orders to the engine. It then routes every execution report back to the socket that should receive it.
This is where the two halves of the system meet, and the seam is the hard part. The I/O side is concurrent, bursty and full of clients you don't control. The engine side is single-threaded, must never block, and must never be slowed down by one bad connection. You'll build the two pieces that live on every connection, a frame decoder and a session state machine, and in the capstone you'll wire them across the async/sync boundary with the queues from week 5.
Phase 2 is about production shape, not maximum speed. A Tokio gateway on kernel TCP will be in the tens of microseconds for a round trip. Weeks 30–33 show where those microseconds go and how to remove them. This week, get the structure right: ownership, backpressure, and failure behaviour.
Day 1 — The shape of a gateway
Tasks, threads and who owns what
One task per connection, one thread for the engine
The pattern for this week: a task owns its state exclusively and others talk to it through channels. Pay attention to the section on handles and to how shutdown falls out of channel closure. Each connection is an actor, and the engine is one too, just on its own OS thread.
The layout you'll build:
Tokio runtime (I/O) engine thread (std::thread)
socket ─▶ reader task: FrameDecoder → Session → decode ─┐
socket ─▶ reader task ... ├─▶ MPSC queue (w05) ─▶ engine v1 (w12)
socket ─▶ reader task ... ─┘ │ events tagged
▼ with session id
socket ◀─ writer task ◀─ per-session bounded queue ◀───────── dispatcher: session id → queue- Each connection gets a reader task and a writer task. The reader owns the
FrameDecoderand theSession. The writer owns the write half of the socket and drains that session's outbound queue. Splitting the socket (TcpStream::into_split) means a slow write never stalls reads, and the reverse. - The engine runs on a dedicated OS thread, not a Tokio task. It busy-polls one MPSC queue of
(session_id, command)pairs. Every Tokio worker thread can push into that queue, so it has to be multi-producer. - Outbound traffic is routed by session id. The engine's events name exchange order ids. A router on the engine thread maps each id back to the session that owns the order, and a dispatcher looks up that session's outbound queue.
Why run the matching engine on a dedicated std::thread instead of as one more Tokio task?
Day 2 — Framing a byte stream
TCP has no messages
Drill 1: an incremental decoder that survives any read pattern
Read how the tutorial's connection keeps a read buffer, tries to parse a frame, and reads more on 'incomplete'. Your FrameDecoder is the same loop without the async parts, so you can unit-test it byte by byte.
The production version of today's drill. Note the configuration knobs (length field width, offset, endianness, max_frame_length) and that an oversized length is reported as an error rather than buffered.
TCP delivers an ordered stream of bytes, with no message boundaries. A 64-byte order you sent with one write can arrive as 64 bytes, as 1 byte and then 63, or glued to the end of the previous order. The sender's Nagle algorithm, segmentation, receive offloads (GRO) and simple timing all reshape it. The frame decoder restores message boundaries:
- Append whatever the socket returned to a buffer.
- If at least 2 bytes are buffered, read the length header and validate it immediately.
- If header plus payload are buffered, return the payload and advance. Otherwise wait for more.
- Reclaim consumed space so the buffer stays bounded by about one maximum frame, not by the connection's lifetime traffic.
Why reject an oversized length as soon as the 2-byte header arrives, rather than when the payload is complete?
Incremental frame decoder over partial reads
// Frame = 2-byte little-endian payload length, then the payload.
// A payload length of 0 is invalid (every message has at least a type byte).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameError {
Empty,
TooLarge { len: usize, max: usize },
}
pub struct FrameDecoder {
buf: Vec<u8>,
start: usize, // first unconsumed byte in buf
max_payload: usize,
}
impl FrameDecoder {
pub fn new(max_payload: usize) -> Self {
FrameDecoder { buf: Vec::with_capacity(4096), start: 0, max_payload }
}
/// Append the bytes from one socket read. Reclaim consumed space so
/// the buffer stays bounded no matter how long the connection lives.
pub fn feed(&mut self, bytes: &[u8]) {
todo!()
}
/// The next complete payload, borrowed from the internal buffer, or
/// Ok(None) if more bytes are needed. Reject a bad length as soon as the
/// header is available. After an error the caller disconnects.
pub fn next_frame(&mut self) -> Result<Option<&[u8]>, FrameError> {
todo!()
}
/// Bytes received but not yet returned as frames.
pub fn buffered(&self) -> usize {
self.buf.len() - self.start
}
pub fn capacity(&self) -> usize {
self.buf.capacity()
}
}
Day 3 — Sessions
A protocol is a state machine
Drill 2: logon, sequence numbers, heartbeats, logout
The session layer under OUCH order entry. It's short. List its packet types (login request/accepted/rejected, sequenced and unsequenced data, client and server heartbeats, logout, end of session) and note what the server does when the client goes quiet. Your gateway's session layer is a simplified version of the same idea.
Every order-entry protocol has a session layer around the business messages. It authenticates once at logon, numbers messages so that loss or duplication is detectable, proves liveness with heartbeats when there's no traffic, and ends cleanly with a logout. Write it as an explicit state machine, as a pure struct with on_message(now, msg, out) and on_tick(now, out), not as if statements scattered through an async handler:
- Pure and synchronous. No sockets, no clock reads. Time comes in as an argument, so a test can play a scripted hour of session life in microseconds, which is exactly what the harness does.
- Actions out, not side effects. The session pushes
Forward,LogonAckorDisconnectinto a caller-ownedVec. The async shell performs them. One reusedVecper connection means no allocation per message. - Fail closed. Anything unexpected (a message before logon, a sequence gap, a second logon) disconnects. A session that tries to "recover" from a confused client tends to forward orders the client never meant to send.
Session state machine
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
AwaitingLogon,
Active,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Inbound {
Logon { seq: u64, token: u64 },
NewOrder { seq: u64, order_id: u64 },
Heartbeat { seq: u64 },
Logout { seq: u64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
BadSeq { expected: u64, got: u64 },
BadToken,
NotLoggedOn,
DuplicateLogon,
Timeout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
LogonAck,
Forward { order_id: u64 },
LogoutAck,
Disconnect(Reason),
}
pub struct Session {
state: State,
token: u64,
next_seq: u64,
last_rx_ns: u64,
timeout_ns: u64,
}
impl Session {
pub fn new(now_ns: u64, token: u64, timeout_ns: u64) -> Self {
Session { state: State::AwaitingLogon, token, next_seq: 1, last_rx_ns: now_ns, timeout_ns }
}
pub fn state(&self) -> State {
self.state
}
/// Push actions into `out` (do not clear it). Rules, in order:
/// - Closed: ignore everything.
/// - seq != expected (first expected is 1): Disconnect(BadSeq).
/// - otherwise expected += 1, last receive time = now_ns, then
/// AwaitingLogon: Logon with the right token -> LogonAck, Active;
/// wrong token -> Disconnect(BadToken);
/// any other message -> Disconnect(NotLoggedOn).
/// Active: Logon -> Disconnect(DuplicateLogon); NewOrder -> Forward;
/// Heartbeat -> nothing; Logout -> LogoutAck, Closed.
/// - every Disconnect moves the session to Closed.
pub fn on_message(&mut self, now_ns: u64, msg: Inbound, out: &mut Vec<Action>) {
todo!()
}
/// Timer. If not Closed and now - last receive > timeout_ns:
/// Disconnect(Timeout). This also applies while awaiting logon.
pub fn on_tick(&mut self, now_ns: u64, out: &mut Vec<Action>) {
todo!()
}
}
TCP has keepalives. Why does every trading protocol add its own application heartbeats anyway?
Day 4 — Crossing the async/sync boundary
Queues in both directions
The engine must never wait for a socket
Inbound (sessions → engine): many reader tasks on several Tokio worker threads push (session_id, command) into one queue the engine thread drains. That's the bounded MPSC from week 5. When it's full, the reader task, not the engine, has to wait. Make it wait by yielding (tokio::task::yield_now, or a short async sleep with backoff), not by blocking the worker thread. A full inbound queue means the engine is saturated, so applying backpressure to clients is correct. The TCP window will eventually push back on them.
The explainer below runs this pipeline as it looks by week 24, with a journal and standby behind the engine. Choose Ack after match for this week's shape, then drag the load past the engine's capacity: the bounded inbound queue fills and the push-back reaches the gateway (here as busy rejects, the week 24 policy; this week the reader waits and TCP pushes back).
Outbound (engine → sessions): the engine produces execution reports for many sessions. It must never block on any of them, because one stalled client socket would freeze matching for everyone. Give each session a bounded outbound queue and have the engine use a non-blocking push. tokio::sync::mpsc::Sender::try_send is synchronous and safe to call from a plain thread, and it wakes the writer task if it's parked. If the push fails because the queue is full, the client isn't keeping up. Mark the session for disconnect rather than buffering without bound.
The engine thread has an execution report for session 17, whose outbound queue is full. What should it do, and why not just wait?
Two costs you'll see in the round-trip benchmark: a wakeup of a parked Tokio worker from another thread is a futex syscall plus a scheduler decision, which costs microseconds rather than nanoseconds. An engine that sleeps when idle pays a similar wakeup on every order. Busy-polling the engine thread removes the second cost. Week 30 measures both properly.
A client's connection drops while it has 40 resting orders. Should the exchange cancel them? Argue both sides (protection against stale quotes vs. surprising a client who reconnects in 200 ms), then specify the per-session option you would offer, how it is configured at logon, and exactly when the cancels are sequenced relative to other orders.
Day 5 — Sockets and their defaults
What the kernel does to your 64 bytes
Nagle, buffers and loopback numbers
Read TCP_NODELAY, TCP_QUICKACK, the keepalive options and the socket buffer sysctls. For each one, decide whether the gateway sets it and why.
Dot = typical modern x86 server; band = plausible range across hardware. Click a row for details.
Set TCP_NODELAY on every accepted socket. Nagle's algorithm holds back a small write while earlier data is unacknowledged, hoping to coalesce it with the next one. Combined with the receiver's delayed ACK, that can add tens of milliseconds to an order. On Linux the delayed-ACK timer starts at 40 ms. For a protocol of small request/response messages, Nagle is pure harm. Do your own batching instead: when several execution reports for one session are queued, write them in one syscall.
Socket buffer sizes (SO_RCVBUF / SO_SNDBUF) set how much the kernel will absorb before applying TCP flow control. Large buffers hide a slow consumer for longer, so they help throughput and hurt your ability to detect lag. Moderate sizes plus an application-level outbound bound give you control.
Expect a small-message ping-pong over loopback to land in roughly the 10–30 µs range with ordinary blocking sockets and an async runtime on a modern Linux machine, and lower when both ends busy-poll. Most of that is syscalls and wakeups, not the TCP protocol. The exact figure depends on CPU, kernel version and mitigations, which is why week 31 measures it on your own machine.
Day 6 — Failure modes
What clients do to gateways
Design for the client you didn't write
List the behaviours your gateway must survive, and decide what it does about each:
- A slow reader: the client stops reading. Its outbound queue fills, and the gateway disconnects it.
- A flood: a client sends faster than its share. Week 17 adds throttles. For now, the bounded inbound queue applies backpressure.
- Half-open connections: the client vanished without a FIN. A heartbeat timeout disconnects it. The repo's binary protocol has no heartbeats, so there the backstop is TCP keepalive, which week 31's socket tuning turns on.
- Garbage: bad lengths, unknown message types, sequence gaps. Fail closed, disconnect, log the session id and the offending bytes.
- A connect storm at the open: thousands of logons within a second. Logon validation runs in the reader task, off the engine thread. The engine thread only registers the session and queues its
LogonAck.
Write the gateway's slow-consumer policy as you'd put it in a client-facing spec: the outbound queue bound (in messages or bytes), what happens when it's exceeded, what the client sees, and how it recovers its order state after reconnecting.
One reader and one writer task per connection is simple. At what connection count, if any, would you move to a fixed set of I/O threads each serving many sockets? What measurements would tell you it's time?
Capstone
Build crates/gateway, a Tokio TCP server that puts engine v1 on the network. Gateway::start(GatewayConfig) returns once the gateway accepts connections, and Gateway::shutdown(timeout) returns a ShutdownReport.
- Accept loop that sets
TCP_NODELAY, splits each socket, and spawns a reader and a writer task, each session's stop signal a child of the gateway'sCancelToken(week 3). Gateway shutdown cancels every session and joins every task within a deadline. - Reader task: a reusable read buffer feeds
wire::FrameDecoder(week 11). Each frame goes toSession::on_frame, which parses it with a zero-copywire::InboundViewand returns oneAction:Logon,Forward(command),Disconnect(reason)orIgnore. The repo's session is simpler than Day 3's drill: the first message must be aLogonwhose account and token passAuth, withinlogon_timeout, and the binary protocol has no sequence numbers or heartbeats.Forwardactions become(session_id, command)pushes into the engine's inbound MPSC queue. - Engine thread: a plain
std::threadthat polls the inbound queue and runs aSequencer: oneEngineper instrument (driven withEngine::process), theOrderRouterand the hooks.EngineIdle::BusyPollspins; the default,EngineIdle::Backoff, spins and then sleeps 50 µs between polls so an idle gateway costs no CPU. Each batch's reports go to the owning sessions' outbound queues with a non-blockingtry_send. - Writer task: drains the outbound queue, encodes execution reports, and writes everything that's ready in one syscall.
- Session registry: session id → outbound sender, kept on the engine thread (the reader hands its sender over at logon). Ids are assigned at accept. The
OrderRoutermaps each exchange order id back to its session, so a fill on a resting order finds its owner after the order's session has sent other messages. - Pre-trade hook:
SequencercallsPreTrade::checkon every new order before it reaches the engine. A rejected order goes back asRejectedand is never sequenced. The default isAllowAll; week 17 plugs in the realRiskGate.
Decisions and pitfalls:
- Who assigns order ids? Clients send their own ids (like FIX's ClOrdID), which are unique only per session. The engine needs exchange-wide ids. Keep the mapping in exactly one place. In the repo that's the gateway's
OrderRouter: a new order gets the next exchange id (1, 2, 3, … in sequence order, so a replay assigns the same ids), a cancel or reduce is resolved within the sending session only, and a client may reuse an id once its order is done. Reports carry only the client's own ids, because the wire's report messages have no field for the exchange id. In a trade report the counterparty's id is 0 (exchanges don't disclose who you traded with), unless both orders belong to the same session. - Disconnect with orders resting: implement the cancel-on-disconnect option from Day 4 as commands sequenced through the engine, not as a side channel. The journal then explains every cancel. In the repo it is one gateway-wide switch,
GatewayConfig::cancel_on_disconnect(on by default), rather than a per-session logon option.Sequencer::closecancels the session's live orders in ascending exchange-id order. - Shutdown order: stop accepting, stop reading, let the engine drain its queue, flush writers, then close sockets. Reverse that order and you lose acknowledgements for orders the engine already accepted.
- Tokio worker count: the engine thread needs its own core, so leave one free. A runtime with as many workers as cores competes with the engine for CPU.
How to think about the round-trip target: client order to acknowledgement over loopback at a p99 around 60 µs leaves plenty of room for the engine (a few µs at most). What remains is two kernel TCP traversals and the thread handoffs: client to reader task, reader to engine, engine to writer task. The tail comes from wakeups. A parked Tokio worker, an engine thread that sleeps, or the client's own blocking read can each cost several microseconds, and much more under scheduler noise. If p50 is fine and p99 isn't, look for sleeping threads before you look at code. The benchmark (w13.roundtrip) runs one blocking client that sends an IOC order and times it until the Accepted arrives, with one Tokio worker and the engine thread on EngineIdle::BusyPoll.
crates/gatewayA TCP gateway in front of engine v1
Framed, logged-on sessions on Tokio; orders routed to a dedicated engine thread; execution reports routed back to the right socket.
cargo xtask test --week 13 # tests named w13_*
cargo xtask bench --week 13 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 13 tests pass w13_*
- Client order → ack over loopback TCP w13.roundtriptarget p99 ≤ 100 µs
Feeds into
- Week 14 builds FIX 4.4 as a sans-IO library (
crates/fix) that a gateway session can drive as a second protocol. Its tests exercise the crate on its own. - Week 16 applies the same slow-consumer policy to market data fan-out, with a broadcast ring and conflation in place of per-client queues.
- Week 22 gives the gateway typed configuration and a release build.
- Week 23 runs simulated traders through risk, engine and market data in process. Its
Venuetrait is shaped like a network session, so this gateway can become a second venue. - Week 31 measures and tunes the kernel networking costs you met on Day 5.