Skip to content

Commit 5124d2a

Browse files
fix: consolidate memory leak fixes from 23+ community PRs
Addresses the remaining memory leaks identified in anomalyco#16697 by consolidating the best fixes from 23+ open community PRs into a single coherent changeset. Fixes consolidated from PRs: anomalyco#16695, anomalyco#16346, anomalyco#14650, anomalyco#15646, - Plugin subscriber stacking: unsub before re-subscribing in init() - Subagent deallocation: Session.remove() after task completion - SSE stream cleanup: centralized cleanup with done guard (3 endpoints) - Compaction data trimming: clear output/attachments on prune - Process exit cleanup: Instance.disposeAll() with 5s timeout - Serve cmd: graceful shutdown instead of blocking forever - Bash tool: ring buffer with 10MB cap instead of O(n²) concat - LSP index teardown: clear clients/broken/spawning on dispose - LSP open-files cap: evict oldest when >1000 tracked files - Format subscription: store and cleanup unsub handle - Permission/Question clearSession: reject pending on session delete - Session.remove() cleanup chain: FileTime, Permission, Question - ShareNext subscription cleanup: store unsub handles, cleanup on dispose - OAuth transport: close existing before replacing Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent f8b738c commit 5124d2a

17 files changed

Lines changed: 213 additions & 77 deletions

File tree

packages/opencode/src/cli/cmd/serve.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Flag } from "../../flag/flag"
55
import { Workspace } from "../../control-plane/workspace"
66
import { Project } from "../../project/project"
77
import { Installation } from "../../installation"
8+
import { Instance } from "../../project/instance"
89

910
export const ServeCommand = cmd({
1011
command: "serve",
@@ -18,7 +19,13 @@ export const ServeCommand = cmd({
1819
const server = Server.listen(opts)
1920
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
2021

21-
await new Promise(() => {})
22+
// Wait for termination signal instead of blocking forever
23+
await new Promise<void>((resolve) => {
24+
const shutdown = () => resolve()
25+
process.on("SIGTERM", shutdown)
26+
process.on("SIGINT", shutdown)
27+
})
28+
await Instance.disposeAll()
2229
await server.stop()
2330
},
2431
})

packages/opencode/src/control-plane/workspace-server/routes.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,25 @@ export function WorkspaceServerRoutes() {
77
c.header("X-Accel-Buffering", "no")
88
c.header("X-Content-Type-Options", "nosniff")
99
return streamSSE(c, async (stream) => {
10+
let done = false
11+
let resolveStream: (() => void) | undefined
12+
13+
const cleanup = () => {
14+
if (done) return
15+
done = true
16+
clearInterval(heartbeat)
17+
GlobalBus.off("event", handler)
18+
resolveStream?.()
19+
}
20+
1021
const send = async (event: unknown) => {
11-
await stream.writeSSE({
12-
data: JSON.stringify(event),
13-
})
22+
try {
23+
await stream.writeSSE({
24+
data: JSON.stringify(event),
25+
})
26+
} catch {
27+
cleanup()
28+
}
1429
}
1530
const handler = async (event: { directory?: string; payload: unknown }) => {
1631
await send(event.payload)
@@ -22,11 +37,8 @@ export function WorkspaceServerRoutes() {
2237
}, 10_000)
2338

2439
await new Promise<void>((resolve) => {
25-
stream.onAbort(() => {
26-
clearInterval(heartbeat)
27-
GlobalBus.off("event", handler)
28-
resolve()
29-
})
40+
resolveStream = resolve
41+
stream.onAbort(cleanup)
3042
})
3143
})
3244
})

packages/opencode/src/format/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,13 @@ export namespace Format {
101101
return result
102102
}
103103

104+
let unsubFormatted: (() => void) | undefined
105+
104106
export function init() {
105107
log.info("init")
106-
Bus.subscribe(File.Event.Edited, async (payload) => {
108+
// Unsubscribe previous subscription to prevent stacking on re-init
109+
unsubFormatted?.()
110+
unsubFormatted = Bus.subscribe(File.Event.Edited, async (payload) => {
107111
const file = payload.properties.file
108112
log.info("formatting", { file })
109113
const ext = path.extname(file)

packages/opencode/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ try {
210210
}
211211
process.exitCode = 1
212212
} finally {
213+
// Dispose all instances (LSP, MCP, PTY child processes) to prevent zombies.
214+
// Race with a 5-second timeout so we don't hang on unresponsive subprocesses.
215+
const { Instance } = await import("./project/instance")
216+
await Promise.race([Instance.disposeAll(), new Promise((r) => setTimeout(r, 5000))]).catch(() => {})
213217
// Some subprocesses don't react properly to SIGTERM and similar signals.
214218
// Most notably, some docker-container-based MCP servers don't handle such signals unless
215219
// run using `docker run --init`.

packages/opencode/src/lsp/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export namespace LSPClient {
156156
})
157157
}
158158

159+
const MAX_OPEN_FILES = 1000
159160
const files: {
160161
[path: string]: number
161162
} = {}
@@ -224,6 +225,12 @@ export namespace LSPClient {
224225
},
225226
})
226227
files[input.path] = 0
228+
// Evict oldest file if we exceed the limit
229+
const keys = Object.keys(files)
230+
if (keys.length > MAX_OPEN_FILES) {
231+
const oldest = keys[0]
232+
delete files[oldest]
233+
}
227234
return
228235
},
229236
},
@@ -263,6 +270,7 @@ export namespace LSPClient {
263270
l.info("shutting down")
264271
diagnostics.clear()
265272
diagnosticOrder.length = 0
273+
for (const key of Object.keys(files)) delete files[key]
266274
connection.end()
267275
connection.dispose()
268276
input.server.process.kill()

packages/opencode/src/lsp/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ export namespace LSP {
141141
},
142142
async (state) => {
143143
await Promise.all(state.clients.map((client) => client.shutdown()))
144+
state.clients.length = 0
145+
state.broken.clear()
146+
state.spawning.clear()
144147
},
145148
)
146149

packages/opencode/src/mcp/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,9 @@ export namespace MCP {
420420
duration: 8000,
421421
}).catch((e) => log.debug("failed to show toast", { error: e }))
422422
} else {
423-
// Store transport for later finishAuth call
423+
// Close any existing pending transport before storing the new one
424+
const existing = pendingOAuthTransports.get(key)
425+
if (existing) existing.close?.().catch(() => {})
424426
pendingOAuthTransports.set(key, transport)
425427
status = { status: "needs_auth" as const }
426428
// Show toast for needs_auth
@@ -942,6 +944,8 @@ export namespace MCP {
942944
export async function removeAuth(mcpName: string): Promise<void> {
943945
await McpAuth.remove(mcpName)
944946
McpOAuthCallback.cancelPending(mcpName)
947+
const transport = pendingOAuthTransports.get(mcpName)
948+
if (transport) transport.close?.().catch(() => {})
945949
pendingOAuthTransports.delete(mcpName)
946950
await McpAuth.clearOAuthState(mcpName)
947951
log.info("removed oauth credentials", { mcpName })

packages/opencode/src/permission/next.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,21 @@ export namespace PermissionNext {
278278
}
279279
}
280280

281+
export async function clearSession(sessionID: string) {
282+
const s = await state()
283+
for (const [id, pending] of Object.entries(s.pending)) {
284+
if (pending.info.sessionID === sessionID) {
285+
delete s.pending[id]
286+
Bus.publish(Event.Replied, {
287+
sessionID: pending.info.sessionID,
288+
requestID: pending.info.id,
289+
reply: "reject",
290+
})
291+
pending.reject(new RejectedError())
292+
}
293+
}
294+
}
295+
281296
export async function list() {
282297
const s = await state()
283298
return Array.from(s.pending.values(), (x) => x.info)

packages/opencode/src/plugin/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,18 @@ export namespace Plugin {
130130
return state().then((x) => x.hooks)
131131
}
132132

133+
let unsub: (() => void) | undefined
134+
133135
export async function init() {
134136
const hooks = await state().then((x) => x.hooks)
135137
const config = await Config.get()
136138
for (const hook of hooks) {
137139
// @ts-expect-error this is because we haven't moved plugin to sdk v2
138140
await hook.config?.(config)
139141
}
140-
Bus.subscribeAll(async (input) => {
142+
// Unsubscribe previous wildcard subscriber to prevent stacking on re-init
143+
unsub?.()
144+
unsub = Bus.subscribeAll(async (input) => {
141145
const hooks = await state().then((x) => x.hooks)
142146
for (const hook of hooks) {
143147
hook["event"]?.({

packages/opencode/src/question/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ export namespace Question {
161161
}
162162
}
163163

164+
export async function clearSession(sessionID: string) {
165+
const s = await state()
166+
for (const [id, pending] of Object.entries(s.pending)) {
167+
if (pending.info.sessionID === sessionID) {
168+
delete s.pending[id]
169+
Bus.publish(Event.Rejected, {
170+
sessionID: pending.info.sessionID,
171+
requestID: pending.info.id,
172+
})
173+
pending.reject(new RejectedError())
174+
}
175+
}
176+
}
177+
164178
export async function list() {
165179
return state().then((x) => Array.from(x.pending.values(), (x) => x.info))
166180
}

0 commit comments

Comments
 (0)