let an idle browser go without taking the agent with it
Officer's hour-long idle timer was doing two unrelated jobs: collecting its own in-memory binding, which is its business, and terminating the agent, which is the sidecar's. It could not do the first without the second, because `unsub` was a closure reachable only through `kill`. So a browser that went away killed a live agent an hour later — including one the sidecar had deliberately protected. The sidecar already refuses to collect a session that is mid-turn or holding background tasks: `task:started` disarms its idle GC, and `armIdle` re-checks and re-arms rather than firing once. Officer had no view of any of that. A laptop running out of battery overnight took a `run_in_background` job with it for no reason. `detach` now sits beside `kill` on both streaming handles, and `_sidecarUnsub` — declared and called for a long time, never once assigned — is populated at all three sites. `releaseSession` unsubscribes and forgets the record without killing; the idle timer points at it. `deleteSession` is unchanged, so an explicit disconnect still ends the session. The third assignment site was not in the plan: `adoptOrphanedSession` sets `_claudeKill` but nothing else, so an adopted session that later idled out would have dropped its record while the listener stayed subscribed — a leak of one per adopt-then-leave. No double subscription: releasing unsubscribes first, so a returning browser either adopts with a fresh listener or starts a first turn with none behind it. Step 1 of docs/chat-session-lifetime.md. Step 2 (a list verb, so running sessions can be found after a restart) is still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -344,7 +344,9 @@ async function handleClaudeCodeChat(
|
||||
try {
|
||||
if (!session._claudeKill) {
|
||||
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
||||
// (survives turn-end so background task:notifications keep flowing). handle.kill tears both down.
|
||||
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
|
||||
// explicit disconnect; `detach` drops only the listener, which is what the idle GC uses so an
|
||||
// absent browser stops taking a live agent with it.
|
||||
const handle = await sendClaudeCodeStreaming({
|
||||
userId,
|
||||
email,
|
||||
@@ -359,6 +361,7 @@ async function handleClaudeCodeChat(
|
||||
});
|
||||
session.piProcess = sessionId as any;
|
||||
session._claudeKill = handle.kill;
|
||||
session._sidecarUnsub = handle.detach;
|
||||
} else {
|
||||
// Session already live: push this turn onto the existing persistent session (no new subscription).
|
||||
await sidecar.spawnClaudeStreaming({
|
||||
@@ -450,6 +453,7 @@ async function handleOpenCodeChat(
|
||||
// Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle).
|
||||
session.piProcess = sessionId as any;
|
||||
session._claudeKill = handle.kill;
|
||||
session._sidecarUnsub = handle.detach;
|
||||
} catch (err) {
|
||||
logger.error('Failed to start OpenCode streaming', { sessionId, error: String(err) });
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start OpenCode' });
|
||||
@@ -583,6 +587,10 @@ function adoptOrphanedSession(ws: ServerWebSocket<WSData>, sessionId: string, mo
|
||||
else sidecar.killOpenCode(sessionId);
|
||||
unsub();
|
||||
};
|
||||
// An adopted session can idle out and be released like any other, and releasing detaches through this
|
||||
// field alone. Leaving it unset would drop the record while the listener stayed subscribed — a leak
|
||||
// that grows by one every time a browser adopts a session and then goes away.
|
||||
session._sidecarUnsub = unsub;
|
||||
|
||||
logger.info('Adopted orphaned chat session after restart', { sessionId, model });
|
||||
return session;
|
||||
|
||||
Reference in New Issue
Block a user