re-land: deliver a chat turn to every socket watching it, not the newest one

This is 7726c9f, reverted a few hours ago as collateral with the tabs-and-panes work. It was never
a panes feature — it is the fix for a bug that predates them, and tonight it was reproduced by hand.

The symptom: on https://macbook.pastilhas.dev a chat connects, works briefly, and is dead after a
refresh, never coming back. On http://localhost:9010 the same build is fine.

The cause is ordering. A refresh means socket B attaches before socket A's close is delivered, and
`detachWs(sessionId)` took no socket argument — it nulled the session's single `ws` field, so the
dying socket silenced the live one that had already replaced it. Nothing re-attaches afterwards,
which is why it never came back. Over loopback the close usually lands first and it survives; via
NPM on alpha and back to this host the extra latency makes the late close the ordinary case. That
is the whole of the localhost/domain asymmetry.

`sockets: Set` plus `detachWs(sessionId, ws)` removes only the socket that actually closed, and
delivery fans out to whatever is still attached. `hasSockets` then gates the idle GC, which used to
arm on ANY close — a second pane closing could collect a conversation out from under the first.

Ruled out on the way, so none of it is re-investigated: the reverse proxy relays upgrades correctly
(a clean 101 through openresty, and a full turn streamed end to end over wss:// with deltas and a
cost line); origin validation is off (ALLOW_ANY_ORIGIN defaults true and is unset here) and never
runs on the upgrade, which is a literal Bun route and never reaches Hono; authenticated HTTP is 200
through both doors; the passkeys table is empty, so no origin-bound credential is involved; and the
token-resolution fix 52d5678 — which I nearly re-landed first — was the WRONG diagnosis, because
signin writes localStorage.BEARER_TOKEN, exactly where the socket url reads. That one is still
worth having for embedded and ?officerToken= hosts, but it was never this.

Not verified: a browser refresh against the domain, which is Andre's to confirm — it is the only
step I cannot drive from here. Typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 01:57:04 +01:00
co-authored by Claude Opus 5
parent b89a562614
commit d9857eef7c
3 changed files with 36 additions and 14 deletions
+14 -4
View File
@@ -24,7 +24,7 @@ class SessionManager {
cwd,
model,
piProcess: null,
ws: null,
sockets: new Set(),
lastActivity: Date.now(),
idleTimer: null,
streamBuffer: '',
@@ -145,7 +145,7 @@ class SessionManager {
attachWs(sessionId: string, ws: any): void {
const session = this.sessions.get(sessionId);
if (session) {
session.ws = ws;
session.sockets.add(ws);
session.lastActivity = Date.now();
if (session.idleTimer) {
@@ -155,14 +155,24 @@ class SessionManager {
}
}
detachWs(sessionId: string): void {
/**
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
* session's only socket field, so a stale client's close event silenced whichever client had attached
* after it. A close is only the end of the conversation when nothing else is still watching.
*/
detachWs(sessionId: string, ws: any): void {
const session = this.sessions.get(sessionId);
if (session) {
session.ws = null;
session.sockets.delete(ws);
session.lastActivity = Date.now();
}
}
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
hasSockets(sessionId: string): boolean {
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
}
setIdleTimeout(sessionId: string, timeoutMs: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;