From a139e9297d2a269308c66efbc7ed2b7a53a59a16 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 8 Mar 2026 17:40:00 +0530 Subject: [PATCH] fix: prune and evict stale app session caches (#16584) --- packages/app/src/context/global-sync.tsx | 4 +- .../context/global-sync/event-reducer.test.ts | 58 +++++++++- .../src/context/global-sync/event-reducer.ts | 60 ++++++----- .../context/global-sync/session-cache.test.ts | 102 ++++++++++++++++++ .../src/context/global-sync/session-cache.ts | 62 +++++++++++ packages/app/src/context/sync.tsx | 78 ++++++++++++++ packages/app/src/pages/layout.tsx | 32 ++++-- packages/app/src/pages/session.tsx | 10 +- 8 files changed, 365 insertions(+), 41 deletions(-) create mode 100644 packages/app/src/context/global-sync/session-cache.test.ts create mode 100644 packages/app/src/context/global-sync/session-cache.ts diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b3a351382..4090699a8 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -27,7 +27,7 @@ import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" -import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" +import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" import { createRefreshQueue } from "./global-sync/queue" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" @@ -189,6 +189,7 @@ function createGlobalSync() { }) if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) + cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo) } children.unpin(directory) return @@ -220,6 +221,7 @@ function createGlobalSync() { }), ) setStore("session", reconcile(sessions, { key: "id" })) + cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) sessionMeta.set(directory, { limit }) }) .catch((err) => { diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index ab7f99cef..cf2da135c 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" -import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" +import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" const rootSession = (input: { id: string; parentID?: string; archived?: number }) => ({ @@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => { } }) + test("cleans caches for trimmed sessions on session.created", () => { + const dropped = rootSession({ id: "ses_b" }) + const kept = rootSession({ id: "ses_a" }) + const message = userMessage("msg_1", dropped.id) + const todos: string[] = [] + const [store, setStore] = createStore( + baseState({ + limit: 1, + session: [dropped], + message: { [dropped.id]: [message] }, + part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] }, + session_diff: { [dropped.id]: [] }, + todo: { [dropped.id]: [] }, + permission: { [dropped.id]: [] }, + question: { [dropped.id]: [] }, + session_status: { [dropped.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: kept } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + setSessionTodo(sessionID, value) { + if (value !== undefined) return + todos.push(sessionID) + }, + }) + + expect(store.session.map((x) => x.id)).toEqual([kept.id]) + expect(store.message[dropped.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[dropped.id]).toBeUndefined() + expect(store.todo[dropped.id]).toBeUndefined() + expect(store.permission[dropped.id]).toBeUndefined() + expect(store.question[dropped.id]).toBeUndefined() + expect(store.session_status[dropped.id]).toBeUndefined() + expect(todos).toEqual([dropped.id]) + }) + + test("cleanupDroppedSessionCaches clears part-only orphan state", () => { + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "ses_keep" })], + part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] }, + }), + ) + + cleanupDroppedSessionCaches(store, setStore, store.session) + + expect(store.part.msg_1).toBeUndefined() + }) + test("upserts and removes messages while clearing orphaned parts", () => { const sessionID = "ses_1" const [store, setStore] = createStore( diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 241dfb14d..b8eda0573 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -13,6 +13,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" +import { dropSessionCaches } from "./session-cache" export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } @@ -40,37 +41,44 @@ export function applyGlobalEvent(input: { } function cleanupSessionCaches( - store: Store, setStore: SetStoreFunction, sessionID: string, setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, ) { if (!sessionID) return - const hasAny = - store.message[sessionID] !== undefined || - store.session_diff[sessionID] !== undefined || - store.todo[sessionID] !== undefined || - store.permission[sessionID] !== undefined || - store.question[sessionID] !== undefined || - store.session_status[sessionID] !== undefined setSessionTodo?.(sessionID, undefined) - if (!hasAny) return setStore( produce((draft) => { - const messages = draft.message[sessionID] - if (messages) { - for (const message of messages) { - const id = message?.id - if (!id) continue - delete draft.part[id] - } - } - delete draft.message[sessionID] - delete draft.session_diff[sessionID] - delete draft.todo[sessionID] - delete draft.permission[sessionID] - delete draft.question[sessionID] - delete draft.session_status[sessionID] + dropSessionCaches(draft, [sessionID]) + }), + ) +} + +export function cleanupDroppedSessionCaches( + store: Store, + setStore: SetStoreFunction, + next: Session[], + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + const keep = new Set(next.map((item) => item.id)) + const stale = [ + ...Object.keys(store.message), + ...Object.keys(store.session_diff), + ...Object.keys(store.todo), + ...Object.keys(store.permission), + ...Object.keys(store.question), + ...Object.keys(store.session_status), + ...Object.values(store.part) + .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) + .filter((sessionID): sessionID is string => !!sessionID), + ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) + if (stale.length === 0) return + for (const sessionID of stale) { + setSessionTodo?.(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) }), ) } @@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: { next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) break } @@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: { next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) break } case "session.deleted": { @@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts new file mode 100644 index 000000000..8e11110e3 --- /dev/null +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" + +const msg = (id: string, sessionID: string) => + ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, + }) as Message + +const part = (id: string, sessionID: string, messageID: string) => + ({ + id, + sessionID, + messageID, + type: "text", + text: id, + }) as Part + +describe("app session cache", () => { + test("dropSessionCaches clears orphaned parts without message rows", () => { + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record + } = { + session_status: { ses_1: { type: "busy" } as SessionStatus }, + session_diff: { ses_1: [] }, + todo: { ses_1: [] as Todo[] }, + message: {}, + part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, + permission: { ses_1: [] as PermissionRequest[] }, + question: { ses_1: [] as QuestionRequest[] }, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part.msg_1).toBeUndefined() + expect(store.todo.ses_1).toBeUndefined() + expect(store.session_diff.ses_1).toBeUndefined() + expect(store.session_status.ses_1).toBeUndefined() + expect(store.permission.ses_1).toBeUndefined() + expect(store.question.ses_1).toBeUndefined() + }) + + test("dropSessionCaches clears message-backed parts", () => { + const m = msg("msg_1", "ses_1") + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record + } = { + session_status: {}, + session_diff: {}, + todo: {}, + message: { ses_1: [m] }, + part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, + permission: {}, + question: {}, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part[m.id]).toBeUndefined() + }) + + test("pickSessionCacheEvictions preserves requested sessions", () => { + const seen = new Set(["ses_1", "ses_2", "ses_3"]) + + const stale = pickSessionCacheEvictions({ + seen, + keep: "ses_4", + limit: 2, + preserve: ["ses_1"], + }) + + expect(stale).toEqual(["ses_2", "ses_3"]) + expect([...seen]).toEqual(["ses_1", "ses_4"]) + }) +}) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts new file mode 100644 index 000000000..0177ebbe1 --- /dev/null +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -0,0 +1,62 @@ +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" + +export const SESSION_CACHE_LIMIT = 40 + +type SessionCache = { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record +} + +export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { + const stale = new Set(Array.from(sessionIDs).filter(Boolean)) + if (stale.size === 0) return + + for (const key of Object.keys(store.part)) { + const parts = store.part[key] + if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue + delete store.part[key] + } + + for (const sessionID of stale) { + delete store.message[sessionID] + delete store.todo[sessionID] + delete store.session_diff[sessionID] + delete store.session_status[sessionID] + delete store.permission[sessionID] + delete store.question[sessionID] + } +} + +export function pickSessionCacheEvictions(input: { + seen: Set + keep: string + limit: number + preserve?: Iterable +}) { + const stale: string[] = [] + const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])]) + if (input.seen.has(input.keep)) input.seen.delete(input.keep) + input.seen.add(input.keep) + for (const id of input.seen) { + if (input.seen.size - stale.length <= input.limit) break + if (keep.has(id)) continue + stale.push(id) + } + for (const id of stale) { + input.seen.delete(id) + } + return stale +} diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 562a2d19c..5623a2c7c 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" function sortParts(parts: Part[]) { return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) @@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() + const maxDirs = 30 + const seen = new Map>() const [meta, setMeta] = createStore({ limit: {} as Record, complete: {} as Record, @@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return undefined } + const seenFor = (directory: string) => { + const existing = seen.get(directory) + if (existing) { + seen.delete(directory) + seen.set(directory, existing) + return existing + } + const created = new Set() + seen.set(directory, created) + while (seen.size > maxDirs) { + const first = seen.keys().next().value + if (!first) break + const stale = [...(seen.get(first) ?? [])] + seen.delete(first) + const [, setStore] = globalSync.child(first, { bootstrap: false }) + evict(first, setStore, stale) + } + return created + } + + const clearMeta = (directory: string, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + setMeta( + produce((draft) => { + for (const sessionID of sessionIDs) { + const key = keyFor(directory, sessionID) + delete draft.limit[key] + delete draft.complete[key] + delete draft.loading[key] + } + }), + ) + } + + const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + for (const sessionID of sessionIDs) { + globalSync.todo.set(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, sessionIDs) + }), + ) + clearMeta(directory, sessionIDs) + } + + const touch = (directory: string, setStore: Setter, sessionID: string) => { + const stale = pickSessionCacheEvictions({ + seen: seenFor(directory), + keep: sessionID, + limit: SESSION_CACHE_LIMIT, + }) + evict(directory, setStore, stale) + } + const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => { const messages = await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }), @@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } } + const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false + const loadMessages = async (input: { directory: string client: typeof sdk.client @@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setMeta("loading", key, true) await fetchMessages(input) .then((next) => { + if (!tracked(input.directory, input.sessionID)) return batch(() => { input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) for (const p of next.part) { @@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }) .finally(() => { + if (!tracked(input.directory, input.sessionID)) return setMeta("loading", key, false) }) } @@ -224,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + touch(directory, setStore, sessionID) + + if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return + const limit = meta.limit[key] ?? messagePageSize const sessionReq = hasSession ? Promise.resolve() : retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return const data = session.data if (!data) return setStore( @@ -258,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) if (store.session_diff[sessionID] !== undefined) return const key = keyFor(directory, sessionID) return runInflight(inflightDiff, key, () => retry(() => client.session.diff({ sessionID })).then((diff) => { + if (!tracked(directory, sessionID)) return setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) }), ) @@ -271,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) const existing = store.todo[sessionID] const cached = globalSync.data.session_todo[sessionID] if (existing !== undefined) { @@ -287,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) return runInflight(inflightTodo, key, () => retry(() => client.session.todo({ sessionID })).then((todo) => { + if (!tracked(directory, sessionID)) return const list = todo.data ?? [] setStore("todo", sessionID, reconcile(list, { key: "id" })) globalSync.todo.set(sessionID, list) @@ -310,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) const step = count ?? messagePageSize if (meta.loading[key]) return @@ -325,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }, }, + evict(sessionID: string, directory = sdk.directory) { + const [, setStore] = globalSync.child(directory) + seenFor(directory).delete(sessionID) + evict(directory, setStore, [sessionID]) + }, fetch: async (count = 10) => { const directory = sdk.directory const client = sdk.client diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 70114623e..30925191f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -34,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { clearWorkspaceTerminals } from "@/context/terminal" +import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" @@ -657,25 +658,24 @@ export default function Layout(props: ParentProps) { const prefetchQueues = new Map() const PREFETCH_MAX_SESSIONS_PER_DIR = 10 - const prefetchedByDir = new Map>() + const prefetchedByDir = new Map>() const lruFor = (directory: string) => { const existing = prefetchedByDir.get(directory) if (existing) return existing - const created = new Map() + const created = new Set() prefetchedByDir.set(directory, created) return created } const markPrefetched = (directory: string, sessionID: string) => { const lru = lruFor(directory) - if (lru.has(sessionID)) lru.delete(sessionID) - lru.set(sessionID, true) - while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) { - const oldest = lru.keys().next().value as string | undefined - if (!oldest) return - lru.delete(oldest) - } + return pickSessionCacheEvictions({ + seen: lru, + keep: sessionID, + limit: PREFETCH_MAX_SESSIONS_PER_DIR, + preserve: directory === params.dir && params.id ? [params.id] : undefined, + }) } createEffect(() => { @@ -724,6 +724,7 @@ export default function Layout(props: ParentProps) { return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { if (prefetchToken.value !== token) return + if (!lruFor(directory).has(sessionID)) return const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) @@ -787,7 +788,18 @@ export default function Layout(props: ParentProps) { const lru = lruFor(directory) const known = lru.has(session.id) if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return - markPrefetched(directory, session.id) + const stale = markPrefetched(directory, session.id) + if (stale.length > 0) { + const [, setStore] = globalSync.child(directory, { bootstrap: false }) + for (const id of stale) { + globalSync.todo.set(id, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) + }), + ) + } if (priority === "high") q.pending.unshift(session.id) if (priority !== "high") q.pending.push(session.id) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index eefcdfe10..4967eaa55 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -426,10 +426,12 @@ export default function Page() { createEffect( on( - () => params.id, - (id, prev) => { - if (id || !prev) return - resetSessionModel(local) + () => ({ dir: params.dir, id: params.id }), + (next, prev) => { + if (!prev) return + if (next.dir === prev.dir && next.id === prev.id) return + if (prev.id) sync.session.evict(prev.id, prev.dir) + if (!next.id) resetSessionModel(local) }, { defer: true }, ),