Files
boxmaker/docs/plans/M4a/09-gatewayd-ws-conn.md
T
kyleandClaude Opus 5.5 0339dc13b2 Plan M4a: gatewayd in 15 tasks, with skeletons and given tests
Each task's tests were run against a reference at its end state; the end states were replayed
from master in order with the gate at each step (650 to 762 tests); each skeleton compiles
against its tests and fails them. The reference is kept off this machine. Lessons T27 (every
wait in a test has a limit) and T28 (mutate the reference before hand-over) come from this work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 19:05:44 -07:00

3.4 KiB

M4a task 09: the WebSocket connection

Branch: m4a (run git switch m4a; git status --short must be empty, otherwise stop) Commit subject: gatewayd: ws conn, messages, pings, closing and a dead peer

Goal

The connection that uses tasks 07 and 08: open it, send text, and wait for the next message while keeping the connection alive (spec section 6):

  • A ping from the server is answered with a pong carrying the same payload.
  • We send a ping every ping_every. No bytes at all for dead_after means the peer is dead.
  • A close frame is answered with a close frame, and the connection ends (Closed); so does the end of the stream.
  • Any protocol error ends the connection. gatewayd then reconnects (task 14); nothing panics.

Files

  • Copy: crates/gatewayd/tests/ws_conn.rs, crates/gatewayd/tests/support/ws_server.rs, and the skeleton crates/gatewayd/src/ws/conn.rs
  • Modify: crates/gatewayd/src/ws/mod.rs (pub mod conn;), docs/implementer-log.md

The skeleton

pub const PATH: &str = "/api/v4/websocket";
pub struct Timing { pub ping_every: Duration, pub dead_after: Duration }   // Clone, Copy
pub struct Ws { stream, decoder, random, timing, last_heard, last_ping }

impl Ws {
    pub fn open(connector: &Connector, token: &str, timing: Timing,
                mut random: Box<dyn Read + Send>) -> Result<Ws, WsError>;
    fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError>;
    pub fn send_text(&mut self, text: &str) -> Result<(), WsError>;
    pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError>;
    pub fn close(mut self);
}
pub fn host_header(server: &ServerUrl) -> String;

All are todo!(), each with its steps above it. poll is the one with the most in it: it returns the next text message, or None after about wait with none, and does the pinging, the pong answers and the dead-peer check on the way. Its read timeout is always the time to the next thing it must do (the end of wait, the next ping, or the dead-after limit), so a quiet connection neither spins nor oversleeps. Write it as its comment says, step by step; a helper function for step 1 is fine.

random gives the handshake key and a fresh 4-byte mask for every frame we send (in gatewayd, /dev/urandom).

Steps

  • 1. Copy. git switch m4a, then cp docs/plans/M4a/files/crates/gatewayd/tests/ws_conn.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/ws_server.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/ws/conn.rs crates/gatewayd/src/ws/. Add pub mod conn; to crates/gatewayd/src/ws/mod.rs.
  • 2. See it fail. cargo test -p gatewayd --test ws_conn. Expected: it compiles and 10 tests fail.
  • 3. Fill host_header, send, send_text, open, close, then poll, cargo check -p gatewayd after each.
  • 4. See it pass. cargo test -p gatewayd --test ws_conn, five times. Expected: 10 passed each time, in under a second.
  • 5. Run the gate. cargo fmt --all, then make gate. Expected last line: gate: ok.
  • 6. Log and commit. git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit

Done when

  • cargo test -p gatewayd --test ws_conn passes five times running; make gate prints gate: ok.

Stop and report if

  • A test passes only sometimes, or takes seconds.