Skip to content

Commit b395d19

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, anomalyco#13186, anomalyco#10392, anomalyco#7914, anomalyco#9145, anomalyco#9146, anomalyco#7049, anomalyco#16616, anomalyco#16241 - 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 <noreply@anthropic.com>
1 parent adba475 commit b395d19

File tree

17 files changed

+204
-69
lines changed

17 files changed

+204
-69
lines changed

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
@@ -208,6 +208,10 @@ try {
208208
}
209209
process.exitCode = 1
210210
} finally {
211+
// Dispose all instances (LSP, MCP, PTY child processes) to prevent zombies.
212+
// Race with a 5-second timeout so we don't hang on unresponsive subprocesses.
213+
const { Instance } = await import("./project/instance")
214+
await Promise.race([Instance.disposeAll(), new Promise((r) => setTimeout(r, 5000))]).catch(() => {})
211215
// Some subprocesses don't react properly to SIGTERM and similar signals.
212216
// Most notably, some docker-container-based MCP servers don't handle such signals unless
213217
// 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
@@ -140,6 +140,9 @@ export namespace LSP {
140140
},
141141
async (state) => {
142142
await Promise.all(state.clients.map((client) => client.shutdown()))
143+
state.clients.length = 0
144+
state.broken.clear()
145+
state.spawning.clear()
143146
},
144147
)
145148

packages/opencode/src/mcp/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,9 @@ export namespace MCP {
414414
duration: 8000,
415415
}).catch((e) => log.debug("failed to show toast", { error: e }))
416416
} else {
417-
// Store transport for later finishAuth call
417+
// Close any existing pending transport before storing the new one
418+
const existing = pendingOAuthTransports.get(key)
419+
if (existing) existing.close?.().catch(() => {})
418420
pendingOAuthTransports.set(key, transport)
419421
status = { status: "needs_auth" as const }
420422
// Show toast for needs_auth
@@ -936,6 +938,8 @@ export namespace MCP {
936938
export async function removeAuth(mcpName: string): Promise<void> {
937939
await McpAuth.remove(mcpName)
938940
McpOAuthCallback.cancelPending(mcpName)
941+
const transport = pendingOAuthTransports.get(mcpName)
942+
if (transport) transport.close?.().catch(() => {})
939943
pendingOAuthTransports.delete(mcpName)
940944
await McpAuth.clearOAuthState(mcpName)
941945
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
@@ -279,6 +279,21 @@ export namespace PermissionNext {
279279
}
280280
}
281281

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

packages/opencode/src/plugin/index.ts

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

127+
let unsub: (() => void) | undefined
128+
127129
export async function init() {
128130
const hooks = await state().then((x) => x.hooks)
129131
const config = await Config.get()
130132
for (const hook of hooks) {
131133
// @ts-expect-error this is because we haven't moved plugin to sdk v2
132134
await hook.config?.(config)
133135
}
134-
Bus.subscribeAll(async (input) => {
136+
// Unsubscribe previous wildcard subscriber to prevent stacking on re-init
137+
unsub?.()
138+
unsub = Bus.subscribeAll(async (input) => {
135139
const hooks = await state().then((x) => x.hooks)
136140
for (const hook of hooks) {
137141
hook["event"]?.({

packages/opencode/src/question/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,20 @@ export namespace Question {
165165
}
166166
}
167167

168+
export async function clearSession(sessionID: string) {
169+
const s = await state()
170+
for (const [id, pending] of Object.entries(s.pending)) {
171+
if (pending.info.sessionID === sessionID) {
172+
delete s.pending[id]
173+
Bus.publish(Event.Rejected, {
174+
sessionID: pending.info.sessionID,
175+
requestID: pending.info.id,
176+
})
177+
pending.reject(new RejectedError())
178+
}
179+
}
180+
}
181+
168182
export async function list() {
169183
return state().then((x) => Object.values(x.pending).map((x) => x.info))
170184
}

0 commit comments

Comments
 (0)