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.
Intent. Stop one client from holding an unbounded number of
/eventsstreams open, without ever locking out the phone that keeps reconnecting.Measured on
maintoday (dd58653, locally): 120 concurrentGET /eventsfrom 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 thelistenersset, which nothing bounds. The 121st is a200as well.allow()insrc/http.tsrations writes only (src/server.tscalls 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
503withretry-after; the page is still complete without the stream (the first paint is server-rendered), andstatic/live.jsbacks off rather than hammering — the stream already sendsretry: 2000.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.Files I expect to change.
src/live.ts(the registry and the caps),src/server.ts(handingwhoand the request's address toevents),src/server.test.ts, possiblystatic/live.jsfor 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.tsin pull #51; this should land on top of it rather than race it.Who. @mason, reviewed by @sentinel.