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>
This commit is contained in:
2026-09-23 19:05:44 -07:00
co-authored by Claude Opus 5.5
parent f38dc8d474
commit 0339dc13b2
69 changed files with 7790 additions and 3 deletions
@@ -0,0 +1,89 @@
//! One WebSocket connection to Mattermost (M4a spec, section 6): open it, send text, and poll for
//! the next text message while answering pings, sending our own, and noticing a dead peer.
use std::io::{Read, Write};
use std::time::{Duration, Instant};
use crate::config::ServerUrl;
use crate::net::{Connector, Stream};
use crate::ws::WsError;
use crate::ws::frame::{CLOSE, Decoder, Incoming, PING, PONG, TEXT, encode};
use crate::ws::handshake::handshake;
/// Mattermost's WebSocket path.
pub const PATH: &str = "/api/v4/websocket";
/// How often we ping, and how long silence may last.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timing {
pub ping_every: Duration,
pub dead_after: Duration,
}
pub struct Ws {
stream: Stream,
decoder: Decoder,
random: Box<dyn Read + Send>,
timing: Timing,
last_heard: Instant,
last_ping: Instant,
}
impl Ws {
/// Connect, and complete the handshake with `token`. `random` supplies the key and every mask
/// (in `gatewayd`, `/dev/urandom`).
pub fn open(
connector: &Connector,
token: &str,
timing: Timing,
mut random: Box<dyn Read + Send>,
) -> Result<Ws, WsError> {
// 1. `connector.connect(timing.dead_after)`; its error becomes
// WsError::Handshake(e.to_string()).
// 2. `handshake(&mut stream, &host_header(connector.server()), PATH, token, &mut random)?`.
// 3. A Ws with a new Decoder, and last_heard and last_ping both now.
todo!()
}
fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError> {
// 4 mask bytes from `random` (read_exact), then write `encode(opcode, payload, mask)` and
// flush.
todo!()
}
pub fn send_text(&mut self, text: &str) -> Result<(), WsError> {
// `send` with TEXT.
todo!()
}
/// The next text message, or `None` after about `wait` with none. Pings are answered and sent
/// here; a close frame is answered and ends the connection (`Closed`); silence past the
/// dead-after limit is `Dead`.
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError> {
// Loop, with `until = now + wait`:
// 1. Every whole message the decoder has: Text -> return it; Ping(p) -> send PONG with p;
// Pong -> nothing; Close(code, _) -> send CLOSE with the code as 2 bytes (or empty),
// ignore that error, return Closed.
// 2. Silence since last_heard >= dead_after -> Dead.
// 3. Since last_ping >= ping_every -> send an empty PING, last_ping = now.
// 4. now >= until -> Ok(None).
// 5. Read timeout: the least of (until - now), (last_ping + ping_every - now) and
// (last_heard + dead_after - now), at least 1 ms. Read into a 16 KiB buffer: 0 bytes ->
// Closed; n bytes -> feed them, last_heard = now; WouldBlock, TimedOut or Interrupted ->
// go round; any other error -> Io.
todo!()
}
/// Send a close frame, best effort, and drop the connection.
pub fn close(mut self) {
// Send CLOSE with 1000 as 2 big-endian bytes; ignore the error.
todo!()
}
}
/// The `Host` header for a server: the port is written only when it is not the scheme's default.
pub fn host_header(server: &ServerUrl) -> String {
// The host alone when the port is the default for the scheme (443 for tls, 80 otherwise), else
// "host:port".
todo!()
}
@@ -0,0 +1,91 @@
//! WebSocket frames (RFC 6455, section 5), without I/O. `Decoder` is fed the bytes as they arrive,
//! in any pieces, and yields whole messages; `encode` builds our masked frames. Everything the
//! server sends is untrusted: every length is checked before anything is allocated.
use crate::ws::WsError;
/// The largest message we accept, counted from the length fields.
pub const MAX_MESSAGE: usize = 1 << 20;
pub const CONTINUATION: u8 = 0x0;
pub const TEXT: u8 = 0x1;
pub const CLOSE: u8 = 0x8;
pub const PING: u8 = 0x9;
pub const PONG: u8 = 0xA;
/// A whole message from the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Incoming {
Text(String),
Ping(Vec<u8>),
Pong(Vec<u8>),
/// A close frame: the status code if there is one, and the reason.
Close(Option<u16>, String),
}
/// Reassembles frames into messages.
#[derive(Debug, Default)]
pub struct Decoder {
buf: Vec<u8>,
/// A text message whose first frame has come and whose last has not.
partial: Option<Vec<u8>>,
}
/// A parsed header: what it says and how long it is.
struct Header {
fin: bool,
opcode: u8,
header_len: usize,
payload_len: usize,
}
impl Decoder {
pub fn new() -> Decoder {
Decoder::default()
}
/// Append bytes as they arrived.
pub fn feed(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes);
}
/// The next whole message, `None` if more bytes are needed, or the error that ends the
/// connection. After an error, do not call again.
pub fn next_message(&mut self) -> Result<Option<Incoming>, WsError> {
// Loop: `self.header()?`, None -> Ok(None). If the buffer holds less than header_len +
// payload_len, Ok(None). Otherwise take the payload out and drain the frame from the
// buffer, then by opcode: PING -> Ping, PONG -> Pong, CLOSE -> `close(&payload)`. TEXT
// starts a new message, CONTINUATION extends `partial`; with fin the whole message must be
// UTF-8 (else Protocol) and is returned as Text; without fin it is kept in `partial` and
// the loop goes on. Any other opcode is Protocol.
todo!()
}
/// The next frame's header, once it is all here, checked against every rule that does not need
/// the payload.
fn header(&self) -> Result<Option<Header>, WsError> {
// Return Ok(None) while the bytes the header needs have not all arrived. The rules, each an
// error: a reserved bit (0x70 of byte 0) is Protocol; the mask bit (0x80 of byte 1) is
// Protocol; an opcode that is not CONTINUATION, TEXT, CLOSE, PING or PONG is Protocol.
// Length 126: a u16 in the next 2 bytes, below 126 is Protocol (not the shortest form).
// 127: a u64 in the next 8 bytes; top bit set is Protocol; <= 0xFFFF is Protocol. Control
// frames (opcode & 0x8): not fin, or over 125 bytes, is Protocol. Data frames: TEXT while
// `partial` is Some, or CONTINUATION while it is None, is Protocol; a payload over
// MAX_MESSAGE minus what `partial` holds is TooLarge. All of it before any allocation.
todo!()
}
}
fn close(payload: &[u8]) -> Result<Incoming, WsError> {
// Empty: Close(None, ""). One byte: Protocol. Otherwise a big-endian u16 code and a UTF-8
// reason (else Protocol).
todo!()
}
/// One whole frame from us: FIN set, masked with `mask`.
pub fn encode(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
// Byte 0: 0x80 | opcode. Byte 1: 0x80 | length, where the length is the 7-bit form below 126,
// 126 then a u16 up to 0xFFFF, else 127 then a u64. Then the 4 mask bytes, then each payload
// byte XOR mask[i % 4]. No `as` casts: use try_from.
todo!()
}
@@ -0,0 +1,60 @@
//! The opening handshake (RFC 6455, section 4.1) and the base64 it needs.
use std::io::{Read, Write};
use crate::http::{Head, read_head};
use crate::ws::WsError;
/// RFC 6455's magic string, appended to the key before hashing.
pub const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 with padding (RFC 4648, section 4).
pub fn base64(bytes: &[u8]) -> String {
// Standard alphabet (ALPHABET), "=" padding: each 3 bytes become 4 characters; a last group of
// 1 or 2 bytes becomes 2 or 3 characters and 2 or 1 "=". No indexing that can go out of bounds.
todo!()
}
/// The `Sec-WebSocket-Accept` a server must send for `key`.
pub fn accept_for(key: &str) -> String {
// base64(sha1(key + GUID)), with `proto::sha1::sha1`.
todo!()
}
/// A fresh key: 16 bytes from `random` (in `gatewayd`, `/dev/urandom`), in base64.
pub fn new_key(random: &mut dyn Read) -> std::io::Result<String> {
// 16 bytes read from `random` with read_exact, then base64.
todo!()
}
/// The request, exactly.
pub fn request_text(host: &str, path: &str, key: &str, token: &str) -> String {
format!(
"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nAuthorization: Bearer {token}\r\n\r\n"
)
}
/// Is `head` a server's acceptance of `key`? Status 101, `Upgrade: websocket`, a `Connection`
/// holding the token `upgrade`, and the right `Sec-WebSocket-Accept` (case matters there).
pub fn check_response(head: &Head, key: &str) -> Result<(), WsError> {
// In this order, each a Handshake error: status is not 101 ("status <n>"); no Upgrade header
// equal to "websocket" ignoring case; no Connection header with a comma-separated token equal
// to "upgrade" ignoring case; Sec-WebSocket-Accept missing, or not exactly `accept_for(key)`.
todo!()
}
/// The whole handshake on `stream`. Nothing after the server's head is read.
pub fn handshake(
stream: &mut (impl Read + Write),
host: &str,
path: &str,
token: &str,
random: &mut dyn Read,
) -> Result<(), WsError> {
// A new key; write `request_text` and flush; `read_head` (its error is a Handshake error); then
// `check_response`. Read nothing after the head.
todo!()
}
@@ -0,0 +1,42 @@
//! The WebSocket client (RFC 6455; M4a spec, section 6): the handshake, the frame codec, and the
//! connection that uses them.
pub mod handshake;
/// Why a WebSocket ended or could not start. Every one of these ends the connection; `gatewayd`
/// then reconnects.
#[derive(Debug)]
pub enum WsError {
/// The server's answer to the handshake was not an upgrade to a WebSocket.
Handshake(String),
/// A frame broke the protocol.
Protocol(String),
/// A message over `MAX_MESSAGE`, refused from its length fields.
TooLarge,
/// The server closed the connection (a close frame, or the end of the stream).
Closed,
/// Nothing was heard for the dead-after limit.
Dead,
Io(std::io::Error),
}
impl std::fmt::Display for WsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WsError::Handshake(why) => write!(f, "WebSocket handshake failed: {why}"),
WsError::Protocol(why) => write!(f, "WebSocket protocol error: {why}"),
WsError::TooLarge => write!(f, "WebSocket message too large"),
WsError::Closed => write!(f, "WebSocket closed"),
WsError::Dead => write!(f, "WebSocket silent for too long"),
WsError::Io(e) => write!(f, "WebSocket I/O: {e}"),
}
}
}
impl std::error::Error for WsError {}
impl From<std::io::Error> for WsError {
fn from(e: std::io::Error) -> Self {
WsError::Io(e)
}
}