35 lines
3.9 KiB
Markdown
35 lines
3.9 KiB
Markdown
# 0011. A dedicated authority stream for server-pushed state
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-07-31
|
|
|
|
## Context
|
|
|
|
The simulation is server-authoritative ([ADR-0004](0004-server-authoritative-simulation.md)), so a category of traffic exists that the client never asks for: state the server pushes on its own cadence. Simulation snapshots are the eventual bulk of it; periodic server diagnostics were the first concrete instance.
|
|
|
|
Two existing streams could have absorbed that traffic, and both are a poor fit:
|
|
|
|
- The **control stream** (stream 0) carries the handshake and disconnect. It is request/response and effectively one-shot per connection. Adding a recurring push to it mixes lifecycle negotiation with steady-state traffic, and a burst of pushed state would sit in the same ordered stream as a disconnect notice that should arrive promptly.
|
|
- The **chunk stream** (stream 3) is bidirectional and carries large frames (a 1 MiB cap). Head-of-line blocking is per-stream in QUIC, so a small, time-sensitive state push queued behind a multi-hundred-kilobyte chunk delivery would inherit that chunk's latency. That is precisely the coupling separate streams exist to avoid.
|
|
|
|
Stream assignment is a wire contract shared by both peers: `StreamLayout` fixes the ids, and changing one is a protocol break. The decision is therefore made once, ahead of the snapshot work that will depend on it, rather than discovered later.
|
|
|
|
## Decision
|
|
|
|
Server-pushed authoritative state travels on its own unidirectional-in-practice stream, `StreamLayout::authority` (stream 2), carrying `shared::protocol::authority::AuthorityMessage`.
|
|
|
|
- The stream is **server => client only**. Nothing the client sends belongs on it; client input gets its own stream when it lands.
|
|
- `AuthorityMessage` is an enum, so new pushed payloads are added as variants rather than as new streams. `ServerStats` is the first variant; simulation snapshots will join it.
|
|
- Frames use the existing length-prefixed `postcard` codec with `MAX_AUTHORITY_FRAME_LEN` (64 KiB), well above a fixed-shape diagnostics record, and set to bound what a malformed length prefix can make a peer allocate.
|
|
- The async/sync bridge follows the pattern established for chunk delivery ([ADR-0010](0010-net-crate-async-runtime.md)): the simulation loop holds an `AuthoritySink`, a synchronous non-blocking handle wrapping a `tokio` MPSC sender, so neither `server` nor `client` names a `tokio` type.
|
|
- A send on a departed connection is logged at debug and dropped. The simulation loop cannot act on a disconnected client, and pushed state is by definition unsolicited, so failure to deliver it is not an error condition for the sender.
|
|
|
|
## Consequences
|
|
|
|
- Latency of pushed state is independent of chunk delivery volume. A client pulling its initial region at full rate still receives snapshots on time.
|
|
- Adding a pushed payload is one enum variant, with no new stream to negotiate on either peer and no `StreamLayout` change.
|
|
- The stream layout now commits four ids (control 0, reserved 1, authority 2, chunk LOD0 3). Reassigning any of them is a `PROTOCOL_VERSION` bump.
|
|
- Loss and ordering semantics are per-stream: authority messages are ordered relative to each other and unordered relative to chunk deliveries. Anything requiring a snapshot to be interpreted against a specific delivered chunk must carry its own correlation (a tick number or chunk version), rather than relying on arrival order across streams.
|
|
- The sink is fire-and-forget and unbounded. That is appropriate for a low-rate diagnostics push, but snapshots at tick rate will need a bound and a drop policy: a slow client must not be allowed to grow the server's queue without limit. This is the known follow-up before snapshots ship.
|
|
- Diagnostics being *on* the authority stream rather than beside it means they are subject to the same server-authoritative framing: the client reports what the server measured, never what it inferred. See [`docs/diagnostics.md`](../diagnostics.md).
|