Skip to content

Commit 0e1ffe4

Browse files
perf: prune per-thread client state and evict idle VCS status cache
Long-lived sessions grew without bound in three places. On the client, deleting a thread left entries behind in the right-panel, diff-panel, mini-player, and ui-state stores (the zustand removeThread helpers existed but had no callers), previewStateAtom used Atom.keepAlive so one registry node per thread ever visited lived for the process lifetime, and a released browser surface left a tombstone in byTabId per preview tab ever opened. On the server, VcsStatusBroadcaster kept its per-cwd status cache for the broadcaster's lifetime after the last poller subscriber released. Deletion now prunes every per-thread store (both the live and archived paths), preview atoms use a 5-minute idle TTL (threads with live sessions stay resident via activePreviewSessionsAtom; idle threads re-seed from the next server snapshot), surface release deletes the entry, and the final poller release drops the cwd's cache entry inside the same critical section that removes the poller so a concurrent retain cannot lose its fresh entry. The orchestration read-model half of #4176 is not ported: orchestration V2 loads per-thread state from indexed SQLite inside each command and holds no process-wide in-memory read model, so the O(N) scans that change removed no longer exist. Closes #4178. Co-Authored-By: Claude Code <[email protected]>
1 parent 77823bd commit 0e1ffe4

9 files changed

Lines changed: 164 additions & 29 deletions

‎apps/server/src/vcs/VcsStatusBroadcaster.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,13 +1108,28 @@ describe("VcsStatusBroadcaster", () => {
11081108
yield* Deferred.await(remoteStarted);
11091109

11101110
assert.equal(state.remoteStatusCalls, 1);
1111+
assert.equal(state.localStatusCalls, 1);
11111112

11121113
yield* Scope.close(firstScope, Exit.void);
11131114
assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted)));
11141115

11151116
yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped);
11161117
yield* Deferred.await(remoteInterrupted);
11171118
assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted)));
1119+
1120+
const nextSnapshot = yield* Deferred.make<VcsStatusStreamEvent>();
1121+
const nextScope = yield* Scope.make();
1122+
yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
1123+
event._tag === "snapshot"
1124+
? Deferred.succeed(nextSnapshot, event).pipe(Effect.ignore)
1125+
: Effect.void,
1126+
).pipe(Effect.forkIn(nextScope));
1127+
yield* Deferred.await(nextSnapshot);
1128+
1129+
// Releasing the final poller also evicts its cwd cache entry, so a later
1130+
// subscription reloads local status instead of retaining state forever.
1131+
assert.equal(state.localStatusCalls, 2);
1132+
yield* Scope.close(nextScope, Exit.void);
11181133
}).pipe(Effect.provide(testLayer));
11191134
});
11201135
});

‎apps/server/src/vcs/VcsStatusBroadcaster.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -695,10 +695,24 @@ export const make = Effect.gen(function* () {
695695
);
696696
}
697697

698-
return Effect.succeed([
699-
existing.fiber,
700-
new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)),
701-
] as const);
698+
// Drop the cached status for this cwd in the same critical section that
699+
// removes the poller, so a concurrent retainRemotePoller (which reloads
700+
// the cache and installs a fresh poller) cannot have its new entry wiped
701+
// by this release. Otherwise the cache keeps one entry per cwd for the
702+
// broadcaster's lifetime; a future subscriber re-loads and re-seeds it.
703+
return Ref.update(cacheRef, (cache) => {
704+
if (!cache.has(cwd)) {
705+
return cache;
706+
}
707+
const nextCache = new Map(cache);
708+
nextCache.delete(cwd);
709+
return nextCache;
710+
}).pipe(
711+
Effect.as([
712+
existing.fiber,
713+
new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)),
714+
] as const),
715+
);
702716
});
703717

704718
if (pollerToInterrupt) {

‎apps/web/src/browser/browserSurfaceStore.test.ts‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,16 @@ describe("browserSurfaceStore", () => {
150150
});
151151
});
152152

153-
it("hides a surface when its current lease is released", () => {
153+
it("removes a surface entry when its current lease is released", () => {
154154
const tabId = "released-browser-surface";
155155
const lease = acquireBrowserSurface(tabId);
156156
lease.present({ x: 10, y: 20, width: 900, height: 640 }, true);
157157

158158
lease.release();
159+
// A stale present() from the released lease must not resurrect the entry.
159160
lease.present({ x: 0, y: 0, width: 1, height: 1 }, true);
160161

161-
expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
162-
visible: false,
163-
owner: null,
164-
});
162+
expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toBeUndefined();
165163
});
166164

167165
it("keeps the requested layer with the active surface lease", () => {
@@ -190,11 +188,7 @@ describe("browserSurfaceStore", () => {
190188

191189
fittedLease.release();
192190

193-
expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
194-
fittedSourceContent: null,
195-
fitSourceContent: false,
196-
owner: null,
197-
visible: false,
198-
});
191+
// Release removes the entry entirely, fitted presentation state included.
192+
expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toBeUndefined();
199193
});
200194
});

‎apps/web/src/browser/browserSurfaceStore.ts‎

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -188,19 +188,12 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
188188
set((state) => {
189189
const current = state.byTabId[tabId];
190190
if (current?.owner !== owner) return state;
191-
return {
192-
byTabId: {
193-
...state.byTabId,
194-
[tabId]: {
195-
...current,
196-
visible: false,
197-
fittedSourceContent: null,
198-
fitSourceContent: false,
199-
updatedAt: Date.now(),
200-
owner: null,
201-
},
202-
},
203-
};
191+
// Delete the entry instead of leaving a released tombstone: readers only
192+
// consider `visible` entries, so removal is behavior-preserving and
193+
// byTabId does not accumulate one dead entry per preview tab ever opened.
194+
// A stale present() from the released lease cannot resurrect it.
195+
const { [tabId]: _released, ...byTabId } = state.byTabId;
196+
return { byTabId };
204197
}),
205198
}));
206199

‎apps/web/src/hooks/useThreadActions.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { useCallback, useMemo, useRef } from "react";
1717

1818
import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
1919
import { useComposerDraftStore } from "../composerDraftStore";
20+
import { useDiffPanelStore } from "../diffPanelStore";
21+
import { usePreviewMiniPlayerStore } from "../previewMiniPlayerStore";
22+
import { removePreviewThread } from "../previewStateStore";
23+
import { useRightPanelStore } from "../rightPanelStore";
2024
import { terminalEnvironment } from "../state/terminal";
2125
import { appAtomRegistry } from "../rpc/atomRegistry";
2226
import { environmentServerConfigsAtom } from "../state/server";
@@ -193,6 +197,24 @@ export async function navigateAfterThreadDeletion(navigate: () => Promise<void>)
193197
}
194198
}
195199

200+
/**
201+
* Prune per-thread client state that would otherwise accumulate forever.
202+
*
203+
* These stores keep one entry per thread (preview atom, right-panel surfaces,
204+
* diff-panel selection, floating mini player, visited/expanded ui state) that
205+
* was never cleaned up on deletion — a long-lived tab leaked one entry per
206+
* thread ever visited, persisted to localStorage. The zustand cleanup functions
207+
* already existed but had no callers; this wires them into deletion.
208+
*/
209+
function clearPerThreadClientState(ref: ScopedThreadRef): void {
210+
removePreviewThread(ref);
211+
useRightPanelStore.getState().removeThread(ref);
212+
useDiffPanelStore.getState().removeThread(ref);
213+
usePreviewMiniPlayerStore.getState().removeThread(ref);
214+
// uiStateStore is keyed by the scoped thread key (see markThreadVisited).
215+
useUiStateStore.getState().removeThread(scopedThreadKey(ref));
216+
}
217+
196218
/**
197219
* Marks a thread unread. Servers with visited tracking own the unread marker
198220
* (thread.mark-unread rewinds the server-side visited watermark, syncing the
@@ -421,6 +443,10 @@ export function useThreadActions() {
421443
});
422444
if (result._tag === "Success") {
423445
refreshArchivedThreadsForEnvironment(target.environmentId);
446+
releaseComposerDraftUploads(target);
447+
clearComposerDraftForThread(target);
448+
clearTerminalUiState(target);
449+
clearPerThreadClientState(target);
424450
}
425451
return result;
426452
}
@@ -524,6 +550,7 @@ export function useThreadActions() {
524550
threadRef,
525551
);
526552
clearTerminalUiState(threadRef);
553+
clearPerThreadClientState(threadRef);
527554

528555
if (shouldNavigateToFallback) {
529556
const fallbackThread = fallbackThreadId

‎apps/web/src/previewStateStore.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
previewStateAtom,
1818
readThreadPreviewState,
1919
reconcilePreviewServerSessions,
20+
removePreviewThread,
2021
rememberPreviewUrl,
2122
resetPreviewStateForTests,
2223
setActivePreviewTab,
@@ -72,6 +73,27 @@ describe("previewStateStore (single-tab)", () => {
7273
expect(readThreadPreviewState(otherRef)).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE);
7374
});
7475

76+
it("removePreviewThread resets a deleted thread's state and leaves other threads intact", () => {
77+
applyPreviewServerSnapshot(ref, makeSnapshot());
78+
applyPreviewServerSnapshot(otherRef, makeSnapshot({ threadId: "thread-2", tabId: "tab_b" }));
79+
80+
removePreviewThread(ref);
81+
82+
expect(readThreadPreviewState(ref)).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE);
83+
expect(readThreadPreviewState(otherRef).snapshot?.tabId).toBe("tab_b");
84+
85+
// A later event for the removed thread re-seeds from empty and still lands.
86+
const reopened = makeSnapshot({ updatedAt: "2026-01-01T00:01:00.000Z" });
87+
applyPreviewServerEvent(ref, {
88+
type: "opened",
89+
threadId: "thread-1",
90+
tabId: reopened.tabId,
91+
createdAt: reopened.updatedAt,
92+
snapshot: reopened,
93+
});
94+
expect(readThreadPreviewState(ref).snapshot?.tabId).toBe("tab_a");
95+
});
96+
7597
it("opened event seeds the snapshot and remembers the URL", () => {
7698
const snapshot = makeSnapshot();
7799
applyPreviewServerEvent(ref, {

‎apps/web/src/previewStateStore.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,16 @@ const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW
6565
Atom.withLabel("preview:empty-thread"),
6666
);
6767

68+
// Idle-TTL, not keepAlive: keepAlive pinned one registry node per thread key
69+
// ever visited for the process lifetime. Threads with live sessions stay
70+
// resident because the observed aggregate `activePreviewSessionsAtom` reads
71+
// them; only idle threads are collected, re-seeding from EMPTY on next use and
72+
// repopulating from the next server snapshot (see usePreviewSession).
73+
const PREVIEW_STATE_IDLE_TTL_MS = 5 * 60_000;
74+
6875
export const previewStateAtom = Atom.family((threadKey: string) =>
6976
Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(
70-
Atom.keepAlive,
77+
Atom.setIdleTTL(PREVIEW_STATE_IDLE_TTL_MS),
7178
Atom.withLabel(`preview:thread:${threadKey}`),
7279
),
7380
);
@@ -464,6 +471,24 @@ export function isPreviewSupportedInRuntime(): boolean {
464471
return Boolean(window.desktopBridge?.preview);
465472
}
466473

474+
/**
475+
* Drop a deleted thread's preview state. Resets the atom to EMPTY (the family
476+
* entry is only weakly held, so the registry drops the node once idle) and
477+
* removes the key from the active-session index so the desktop aggregate view
478+
* stops enumerating it.
479+
*/
480+
export function removePreviewThread(ref: ScopedThreadRef): void {
481+
const threadKey = scopedThreadKey(ref);
482+
appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);
483+
changedPreviewThreadKeys.delete(threadKey);
484+
appAtomRegistry.update(activePreviewThreadKeysAtom, (current) => {
485+
if (!current.keys.has(threadKey)) return current;
486+
const keys = new Set(current.keys);
487+
keys.delete(threadKey);
488+
return { keys };
489+
});
490+
}
491+
467492
export function resetPreviewStateForTests(): void {
468493
for (const threadKey of changedPreviewThreadKeys) {
469494
appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);

‎apps/web/src/uiStateStore.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
markThreadUnread,
77
markThreadVisited,
88
parsePersistedState,
9+
removeThreadUiState,
910
PERSISTED_STATE_KEY,
1011
type PersistedUiState,
1112
persistState,
@@ -56,6 +57,27 @@ describe("uiStateStore pure functions", () => {
5657
expect(markThreadUnread(next, threadId, null)).toBe(next);
5758
});
5859

60+
it("removes all per-thread ui state for a deleted thread", () => {
61+
const threadId = ThreadId.make("thread-1");
62+
const otherId = ThreadId.make("thread-2");
63+
const state = makeUiState({
64+
threadLastVisitedAtById: {
65+
[threadId]: "2026-02-25T12:30:00.000Z",
66+
[otherId]: "2026-02-25T12:31:00.000Z",
67+
},
68+
threadChangedFilesExpandedById: {
69+
[threadId]: { "turn-1": false },
70+
[otherId]: { "turn-1": false },
71+
},
72+
});
73+
74+
const next = removeThreadUiState(state, threadId);
75+
expect(next.threadLastVisitedAtById).toEqual({ [otherId]: "2026-02-25T12:31:00.000Z" });
76+
expect(next.threadChangedFilesExpandedById).toEqual({ [otherId]: { "turn-1": false } });
77+
// No-op (same reference) when the thread has no state.
78+
expect(removeThreadUiState(next, threadId)).toBe(next);
79+
});
80+
5981
it("resolves project expansion from logical, physical, and legacy preference keys", () => {
6082
const physicalKey = "environment:/repo/project";
6183
const legacyKey = legacyProjectCwdPreferenceKey("/repo/project");

‎apps/web/src/uiStateStore.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,27 @@ export function markThreadUnread(
295295
};
296296
}
297297

298+
/**
299+
* Drop all per-thread UI state for a deleted thread. Without this, both
300+
* `threadLastVisitedAtById` and `threadChangedFilesExpandedById` keep one entry
301+
* per thread ever visited, persisted to localStorage indefinitely.
302+
*/
303+
export function removeThreadUiState(state: UiState, threadId: string): UiState {
304+
const hasVisited = threadId in state.threadLastVisitedAtById;
305+
const hasChangedFiles = threadId in state.threadChangedFilesExpandedById;
306+
if (!hasVisited && !hasChangedFiles) {
307+
return state;
308+
}
309+
const { [threadId]: _visited, ...threadLastVisitedAtById } = state.threadLastVisitedAtById;
310+
const { [threadId]: _changed, ...threadChangedFilesExpandedById } =
311+
state.threadChangedFilesExpandedById;
312+
return {
313+
...state,
314+
threadLastVisitedAtById,
315+
threadChangedFilesExpandedById,
316+
};
317+
}
318+
298319
export function setThreadChangedFilesExpanded(
299320
state: UiState,
300321
threadId: string,
@@ -426,6 +447,7 @@ export function reorderProjects(
426447
interface UiStateStore extends UiState {
427448
markThreadVisited: (threadId: string, visitedAt: string) => void;
428449
markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
450+
removeThread: (threadId: string) => void;
429451
setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
430452
setDefaultAdvertisedEndpointKey: (key: string | null) => void;
431453
setSidebarProjectScopeKey: (projectKey: string | null) => void;
@@ -444,6 +466,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
444466
set((state) => markThreadVisited(state, threadId, visitedAt)),
445467
markThreadUnread: (threadId, latestTurnCompletedAt) =>
446468
set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),
469+
removeThread: (threadId) => set((state) => removeThreadUiState(state, threadId)),
447470
setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
448471
set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
449472
setDefaultAdvertisedEndpointKey: (key) =>

0 commit comments

Comments
 (0)