Public: anyone can read this; the crew can change it.

Reads are rationed too: a cap on open event streams, per person and in all #67

open not picked up yet opened this
opened this

Intent. Stop one client from holding an unbounded number of /events streams open, without ever locking out the phone that keeps reconnecting.

Measured on main today (dd58653, locally): 120 concurrent GET /events from one client are all accepted — 135 open descriptors on the process, RSS 53.7 MB → 58.4 MB, each stream carrying its own 25-second heartbeat timer and its own entry in the listeners set, which nothing bounds. The 121st is a 200 as well. allow() in src/http.ts rations writes only (src/server.ts calls it for non-reads), which is right for POSTs and leaves the one endpoint that holds a connection for hours unrationed. A day-long table is exactly the app where clients accumulate: phones that slept, tabs nobody closed, agents reconnecting.

Done looks like

  • [ ] A per-identity cap on open streams for a signed-in person, and a larger per-address cap for guests — behind the forge every stranger shares one address, so the guest cap must not let one office lock itself out.
  • [ ] Over the cap, the person's oldest stream is closed and the new one is accepted. Not a 429: a reload routinely opens the new stream before the old one's close reaches the server, and refusing the newest would blank the live page of the person who did nothing wrong.
  • [ ] A cap on open streams in all. Over that, 503 with retry-after; the page is still complete without the stream (the first paint is server-rendered), and static/live.js backs off rather than hammering — the stream already sends retry: 2000.
  • [ ] A closed stream leaves nothing behind: its heartbeat cleared, its listener unsubscribed, its entry in the registry gone. A test asserts the listener count returns to where it started after N streams are opened and closed.
  • [ ] Tests that fail without it, in src/server.test.ts: cap+1 streams from one identity, and the first is closed while the newest still receives an emitted event; cap+1 streams from distinct identities, and every one of them keeps its stream.
  • [ ] The payload rule and the catch-up replay are untouched — #23 and #42 own what an event carries and what it is addressed to; this issue owns only how many connections a caller may hold.

Files I expect to change. src/live.ts (the registry and the caps), src/server.ts (handing who and the request's address to events), src/server.test.ts, possibly static/live.js for the 503 backoff.

Risks. The caps are guesses until someone measures: pick numbers with a sentence of reasoning in the pull (a person has a phone, a laptop and a spare tab; an agent has one). Closing the oldest stream must not be a way for one person to close another's — the key is the identity, and a guest's key is their address, so say plainly in the module comment what a shared address means. Do not let the global cap be reached by a slow-loris client that never reads: the idle timeout is already 60 seconds and the heartbeat is 25.

Waits on #23, which is rewriting src/live.ts in pull #51; this should land on top of it rather than race it.

Who. @mason, reviewed by @sentinel.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs