diff options
| author | adamelmore <[email protected]> | 2026-01-27 14:51:34 -0600 |
|---|---|---|
| committer | adamelmore <[email protected]> | 2026-01-27 15:25:07 -0600 |
| commit | 842f17d6d97c52d1efac66a8dca298f6ca692a56 (patch) | |
| tree | fd45e06f014dc5a7e72e509bb46f4c3ec1fb444c /packages/app/src/context | |
| parent | 1ebf63c70c552c95794325f40bbd278ba3e0c725 (diff) | |
| download | opencode-842f17d6d97c52d1efac66a8dca298f6ca692a56.tar.gz opencode-842f17d6d97c52d1efac66a8dca298f6ca692a56.zip | |
perf(app): better memory management
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/file.tsx | 72 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 47 | ||||
| -rw-r--r-- | packages/app/src/context/permission.tsx | 24 |
3 files changed, 130 insertions, 13 deletions
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 16deacfe8..7509334ed 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -151,6 +151,28 @@ const WORKSPACE_KEY = "__workspace__" const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 + +const contentLru = new Map<string, number>() + +function approxBytes(content: FileContent) { + const patchBytes = + content.patch?.hunks.reduce((total, hunk) => { + return total + hunk.lines.reduce((sum, line) => sum + line.length, 0) + }, 0) ?? 0 + + return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2 +} + +function touchContent(path: string, bytes?: number) { + const prev = contentLru.get(path) + if (prev === undefined && bytes === undefined) return + const value = bytes ?? prev ?? 0 + contentLru.delete(path) + contentLru.set(path, value) +} + type ViewSession = ReturnType<typeof createViewSession> type ViewCacheEntry = { @@ -315,10 +337,40 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ dir: { "": { expanded: true } }, }) + const evictContent = (keep?: Set<string>) => { + const protectedSet = keep ?? new Set<string>() + const total = () => { + return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0) + } + + while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) { + const path = contentLru.keys().next().value + if (!path) return + + if (protectedSet.has(path)) { + touchContent(path) + if (contentLru.size <= protectedSet.size) return + continue + } + + contentLru.delete(path) + if (!store.file[path]) continue + setStore( + "file", + path, + produce((draft) => { + draft.content = undefined + draft.loaded = false + }), + ) + } + } + createEffect(() => { scope() inflight.clear() treeInflight.clear() + contentLru.clear() setStore("file", {}) setTree("node", {}) setTree("dir", { "": { expanded: true } }) @@ -399,15 +451,20 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ .read({ path }) .then((x) => { if (scope() !== directory) return + const content = x.data setStore( "file", path, produce((draft) => { draft.loaded = true draft.loading = false - draft.content = x.data + draft.content = content }), ) + + if (!content) return + touchContent(path, approxBytes(content)) + evictContent(new Set([path])) }) .catch((e) => { if (scope() !== directory) return @@ -597,7 +654,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ listDir(parent, { force: true }) }) - const get = (input: string) => store.file[normalize(input)] + const get = (input: string) => { + const path = normalize(input) + const file = store.file[path] + const content = file?.content + if (!content) return file + if (contentLru.has(path)) { + touchContent(path) + return file + } + touchContent(path, approxBytes(content)) + return file + } const scrollTop = (input: string) => view().scrollTop(normalize(input)) const scrollLeft = (input: string) => view().scrollLeft(normalize(input)) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 4efbf62aa..fb67193ab 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -546,6 +546,37 @@ function createGlobalSync() { return promise } + function purgeMessageParts(setStore: SetStoreFunction<State>, messageID: string | undefined) { + if (!messageID) return + setStore( + produce((draft) => { + delete draft.part[messageID] + }), + ) + } + + function purgeSessionData(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string | undefined) { + if (!sessionID) return + + const messages = store.message[sessionID] + const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id) + + setStore( + produce((draft) => { + 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] + + for (const messageID of messageIDs) { + delete draft.part[messageID] + } + }), + ) + } + const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details @@ -651,9 +682,7 @@ function createGlobalSync() { }), ) } - cleanupSessionCaches(info.id) - if (info.parentID) break setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -679,9 +708,7 @@ function createGlobalSync() { }), ) } - cleanupSessionCaches(sessionID) - if (event.properties.info.parentID) break setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -757,15 +784,19 @@ function createGlobalSync() { break } case "message.part.removed": { - const parts = store.part[event.properties.messageID] + const messageID = event.properties.messageID + const parts = store.part[messageID] if (!parts) break const result = Binary.search(parts, event.properties.partID, (p) => p.id) if (result.found) { setStore( - "part", - event.properties.messageID, produce((draft) => { - draft.splice(result.index, 1) + const list = draft.part[messageID] + if (!list) return + const next = Binary.search(list, event.properties.partID, (p) => p.id) + if (!next.found) return + list.splice(next.index, 1) + if (list.length === 0) delete draft.part[messageID] }), ) } diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index d85f2ef24..a701dbd1f 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -67,7 +67,21 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }), ) - const responded = new Set<string>() + const MAX_RESPONDED = 1000 + const RESPONDED_TTL_MS = 60 * 60 * 1000 + const responded = new Map<string, number>() + + function pruneResponded(now: number) { + for (const [id, ts] of responded) { + if (now - ts < RESPONDED_TTL_MS) break + responded.delete(id) + } + + for (const id of responded.keys()) { + if (responded.size <= MAX_RESPONDED) break + responded.delete(id) + } + } const respond: PermissionRespondFn = (input) => { globalSDK.client.permission.respond(input).catch(() => { @@ -76,8 +90,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple } function respondOnce(permission: PermissionRequest, directory?: string) { - if (responded.has(permission.id)) return - responded.add(permission.id) + const now = Date.now() + const hit = responded.has(permission.id) + responded.delete(permission.id) + responded.set(permission.id, now) + pruneResponded(now) + if (hit) return respond({ sessionID: permission.sessionID, permissionID: permission.id, |
