Cookbook
Real-time
The platform owns the socket; the core owns the policy. Which close to retry, how long to wait and what to resend are written once, in Rust, and tested with a fake clock.
The recipe#
- One task.
Live::startspawns it. It connects, reads, and when the connection ends sleepsBackoff(500 ms doubling to 30 s, jitter from theRngport) on theTimerport, then connects again. - Every end is typed. A refused upgrade with
401or403stops for good, because the credentials are the problem. A normal close (1000) is a goodbye. Anything else is retried. - A server that accepts and drops keeps backing off: the attempt count resets when a message arrives, not when the socket opens.
- Fallback. Every third failed attempt the core tries server-sent events (
Fallback), resuming from the last event id, for networks that cut WebSockets. - Backpressure is the core's pace. Messages are a stream the core pulls, sixteen at a time: a core that stops reading stops the socket, on the platforms that can pause one.
Show the Rust
async fn run(self) {
let backoff = Backoff::default();
let mut attempt: u32 = 0;
loop {
if self.stopped() {
return;
}
let Ok(ctx) = self.ctx.upgrade() else { return };
match WsConnection::connect(&ctx, &self.ws_url, WsOptions::default()).await {
Ok(conn) => {
drop(ctx);
*lock(&self.shared.conn) = Some(conn.clone());
self.link.set(Link::Open);
let mut inbound = conn.messages();
let mut first = true;
while let Some(item) = next(&mut inbound).await {
if self.stopped() {
return;
}
match item {
Ok(WsMessage::Text(text)) => {
if first {
// A server that accepts and drops at once must keep backing off:
// the count resets when a message arrives, not when the socket opens.
first = false;
attempt = 0;
}
self.push(text);
}
Ok(WsMessage::Binary(_)) => {}
// The server said goodbye on purpose: do not hammer it.
Err(WsError::Closed { code: 1000, .. }) => {
return self.give_up("the server closed the connection");
}
Err(_) => break,
}
}
lock(&self.shared.conn).take();
}
// The credentials are the problem, not the network: another attempt fails the same way.
Err(WsError::Refused {
status: Some(401 | 403),
..
}) => return self.give_up("the server refused the credentials"),
Err(_) => drop(ctx),
}
if self.stopped() {
return;
}
attempt += 1;
self.link.set(Link::Reconnecting { attempt });
if attempt % FALLBACK_EVERY == 0 && !self.sse_url.is_empty() {
self.fall_back().await;
if self.stopped() {
return;
}
self.link.set(Link::Reconnecting { attempt });
}
let Ok(ctx) = self.ctx.upgrade() else { return };
let delay = backoff.delay(attempt, &*ctx.rng());
drop(ctx);
if self.ctx.sleep(delay).await.is_err() {
return;
}
}
}On the platform#
The UI observes link (idle, open, reconnecting, fallback, stopped) and the keyed list messages like any store. It never sees a socket. The platform's job is the adapter: URLSessionWebSocketTask, the Kotlin runtime's client, the browser's WebSocket.
What the tests prove#
- A dropped connection shows
Reconnectingand opens again after the backoff; the wait doubles while the server stays down. - Refused credentials stop for good (two minutes of fake time, no new connection); a
1000close is a goodbye. - A network that cuts WebSockets reaches the event stream on the third attempt, and the next stream resumes with the last event id.
sendneeds an open connection;stopcloses with1000and does not come back; the list keeps the last hundred.
Limits#
- WebSocket and Sse are opt-in ports (WebSocket & server-sent events): the module is behind the cookbook's
realtimefeature. - A message sent while the link is down is not queued: presence and chat are facts about now. Anything that must arrive is a mutation.
- A browser cannot send headers on a WebSocket, so a credential goes in the URL or a subprotocol.