Skip to content

Commit 58ae016

Browse files
binarydoublingclaude
authored andcommitted
fix: resolve multiple memory leaks causing unbounded growth
- Cap SDK event queue to prevent unbounded growth during high event throughput - Clean up message parts when trimming excess messages in sync store - Evict previous session data from memory when switching sessions - Bound LSP diagnostics map with LRU eviction and clear on shutdown - Reject pending callbacks on session cancel to prevent promise/closure leaks Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent f7a1974 commit 58ae016

4 files changed

Lines changed: 65 additions & 3 deletions

File tree

packages/opencode/src/cli/cmd/tui/context/sdk.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
3838
[key in Event["type"]]: Extract<Event, { type: key }>
3939
}>()
4040

41+
const MAX_EVENT_QUEUE = 1000
4142
let queue: Event[] = []
4243
let timer: Timer | undefined
4344
let last = 0
@@ -57,6 +58,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
5758
}
5859

5960
const handleEvent = (event: Event) => {
61+
// Drop oldest events if queue is too large to prevent unbounded memory growth
62+
if (queue.length >= MAX_EVENT_QUEUE) {
63+
queue.splice(0, queue.length - MAX_EVENT_QUEUE + 1)
64+
}
6065
queue.push(event)
6166
const elapsed = Date.now() - last
6267

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,19 +254,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
254254
)
255255
const updated = store.message[event.properties.info.sessionID]
256256
if (updated.length > 100) {
257-
const oldest = updated[0]
257+
// Remove excess messages beyond the limit, cleaning up their parts too
258+
const excess = updated.length - 100
259+
const removedMessages = updated.slice(0, excess)
258260
batch(() => {
259261
setStore(
260262
"message",
261263
event.properties.info.sessionID,
262264
produce((draft) => {
263-
draft.shift()
265+
draft.splice(0, excess)
264266
}),
265267
)
266268
setStore(
267269
"part",
268270
produce((draft) => {
269-
delete draft[oldest.id]
271+
for (const msg of removedMessages) {
272+
delete draft[msg.id]
273+
}
270274
}),
271275
)
272276
})
@@ -442,6 +446,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
442446
})
443447

444448
const fullSyncedSessions = new Set<string>()
449+
let currentSessionID: string | undefined
445450
const result = {
446451
data: store,
447452
set: setStore,
@@ -468,6 +473,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
468473
return last.time.completed ? "idle" : "working"
469474
},
470475
async sync(sessionID: string) {
476+
// Clean up previous session's data from memory when switching sessions
477+
if (currentSessionID && currentSessionID !== sessionID) {
478+
const oldMessages = store.message[currentSessionID]
479+
if (oldMessages) {
480+
setStore(
481+
produce((draft) => {
482+
// Clean up parts for old session's messages
483+
for (const msg of oldMessages) {
484+
delete draft.part[msg.id]
485+
}
486+
// Clean up old session's messages
487+
delete draft.message[currentSessionID!]
488+
// Clean up old session's diff
489+
delete draft.session_diff[currentSessionID!]
490+
}),
491+
)
492+
}
493+
fullSyncedSessions.delete(currentSessionID)
494+
}
495+
currentSessionID = sessionID
496+
471497
if (fullSyncedSessions.has(sessionID)) return
472498
const [session, messages, todo, diff] = await Promise.all([
473499
sdk.client.session.get({ sessionID }, { throwOnError: true }),

packages/opencode/src/lsp/client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,39 @@ export namespace LSPClient {
4848
new StreamMessageWriter(input.server.process.stdin as any),
4949
)
5050

51+
const MAX_DIAGNOSTIC_FILES = 200
5152
const diagnostics = new Map<string, Diagnostic[]>()
53+
const diagnosticOrder: string[] = [] // track insertion order for eviction
5254
connection.onNotification("textDocument/publishDiagnostics", (params) => {
5355
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
5456
l.info("textDocument/publishDiagnostics", {
5557
path: filePath,
5658
count: params.diagnostics.length,
5759
})
5860
const exists = diagnostics.has(filePath)
61+
62+
// If empty diagnostics, just remove the entry to free memory
63+
if (params.diagnostics.length === 0) {
64+
diagnostics.delete(filePath)
65+
const idx = diagnosticOrder.indexOf(filePath)
66+
if (idx !== -1) diagnosticOrder.splice(idx, 1)
67+
if (exists) Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
68+
return
69+
}
70+
5971
diagnostics.set(filePath, params.diagnostics)
72+
73+
// Update insertion order (move to end)
74+
const idx = diagnosticOrder.indexOf(filePath)
75+
if (idx !== -1) diagnosticOrder.splice(idx, 1)
76+
diagnosticOrder.push(filePath)
77+
78+
// Evict oldest entries if we exceed the limit
79+
while (diagnosticOrder.length > MAX_DIAGNOSTIC_FILES) {
80+
const oldest = diagnosticOrder.shift()!
81+
diagnostics.delete(oldest)
82+
}
83+
6084
if (!exists && input.serverID === "typescript") return
6185
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
6286
})
@@ -237,6 +261,8 @@ export namespace LSPClient {
237261
},
238262
async shutdown() {
239263
l.info("shutting down")
264+
diagnostics.clear()
265+
diagnosticOrder.length = 0
240266
connection.end()
241267
connection.dispose()
242268
input.server.process.kill()

packages/opencode/src/session/prompt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,11 @@ export namespace SessionPrompt {
263263
return
264264
}
265265
match.abort.abort()
266+
// Reject any pending callbacks to prevent promise/closure leaks
267+
for (const cb of match.callbacks) {
268+
cb.reject(new Error("Session cancelled"))
269+
}
270+
match.callbacks.length = 0
266271
delete s[sessionID]
267272
SessionStatus.set(sessionID, { type: "idle" })
268273
return

0 commit comments

Comments
 (0)