let every client watching a chat receive it, not the newest one
Reported from two devices at once: typing on the iPad, reading the reply on the Mac. Sending from the Mac produced nothing there. Both halves are one field. A session held `ws`, a single socket, and `attachWs` assigned it. So the newest attach silently took the turn away from whoever was already watching — and with a tab now holding up to three panes, plus a phone and a laptop on the same conversation, several sockets per session stopped being exotic and became the ordinary case. Now a Set, and every message goes to all of them. `detachWs(sessionId)` was worse, because it named no socket: it nulled the field on ANY close. A stale client going away therefore killed delivery for the client that had attached after it, which is the "nothing happens on the Mac" half. It takes the socket now and removes only that one, and the idle GC is armed only once nothing is left watching — otherwise a close would collect a session another pane is still reading. endTurnIfAgentIsGone takes the whole set for the same reason: a cut-off notice explains a spinner that will otherwise never stop, and telling one of three clients leaves two spinning. Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing identically on master before this change. Nobody has clicked it; the two devices that reported it are the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user