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
+15 -9
View File
@@ -158,8 +158,10 @@ export function close(ws: ServerWebSocket<WSData>): void {
const sessionId = wsToSessionMap.get(ws);
if (sessionId) {
sessionManager.detachWs(sessionId);
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
sessionManager.detachWs(sessionId, ws);
// Only once nothing is watching. Another pane or another device still attached means the
// conversation is live, and arming the idle GC here would collect it out from under them.
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
}
}
@@ -244,7 +246,7 @@ function createMessageHandler(sessionId: string, model: string) {
const session = sessionManager.getSession(sessionId);
if (!session) return;
foldIntoSession(session, msg, model);
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
};
}
@@ -651,7 +653,7 @@ async function handleResumeCursor(
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
// away" into a turn that was running perfectly well.
if (msg.generating && decision.kind !== 'assume') {
await endTurnIfAgentIsGone(ws, sessionId, decision.model);
await endTurnIfAgentIsGone([ws], sessionId, decision.model);
}
}
@@ -821,7 +823,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
* so its sessions are left alone rather than guessed at.
*/
async function endTurnIfAgentIsGone(
ws: ServerWebSocket<WSData> | null,
targets: Iterable<ServerWebSocket<WSData> | null>,
sessionId: string,
model: string,
): Promise<void> {
@@ -834,11 +836,11 @@ async function endTurnIfAgentIsGone(
const event: ServerMessage = { type: 'cut-off' };
try {
const seq = await appendChatEvent(sessionId, event);
sendToClient(ws, event, seq);
for (const target of targets) sendToClient(target, event, seq);
} catch (err) {
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
sendToClient(ws, event);
for (const target of targets) sendToClient(target, event);
}
logger.info('Ended a turn whose agent had gone', { sessionId });
}
@@ -850,7 +852,11 @@ async function endTurnIfAgentIsGone(
sidecar.onClaudeSidecarStarted(() => {
for (const session of sessionManager.getAllSessions()) {
if (!session.isGenerating) continue;
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
void endTurnIfAgentIsGone(
session.sockets as Set<ServerWebSocket<WSData>>,
session.sessionId,
session.model,
);
}
});