From d0f1c6619e221aedc7ed94a62528bcc8be8b559b Mon Sep 17 00:00:00 2001 From: KryptoBasedDev Date: Sat, 21 Feb 2026 20:58:11 -0800 Subject: [PATCH 1/2] fix: resolve memory leak issues across multiple subsystems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 6 of the 10 open issues from the memory-leak work plan (L-01, L-02, L-03, L-04, I-7046-B, I-7046-D). L-01 (CRITICAL) — event-reducer.ts: cap accumulated delta string at 1 MB per part field; drops oldest bytes on overflow rather than growing the V8 heap without bound. Primary driver of observed 25 GB RSS growth. L-02 (CRITICAL) — serverSentEvents.gen.ts (v1 + v2): guard the SSE read loop with a 10 MB MAX_BUFFER_SIZE; throws BufferOverflowError before the string can grow unbounded during stalled or large streams. L-03 (HIGH) — mcp/index.ts: replace bare pendingOAuthTransports.set/ delete with helper functions that attach a 5-minute TTL timer per entry. Stale transports from abandoned or failed OAuth flows are now automatically evicted. Timer state is cleared on state teardown. L-04 (LOW) — sdk/js server.ts (v1 + v2): pass { once: true } to the AbortSignal "abort" listener added during server startup. The listener is now auto-removed after firing, eliminating long-lived accumulation on signals that outlive the startup promise. I-7046-B (HIGH) — lsp/client.ts: enforce a 2 000-file cap on the diagnostics Map and a 1 000-file cap on the open-files tracking object. Oldest entries are evicted (FIFO) on overflow; evicted files re-open cleanly on next access. I-7046-D (MEDIUM) — permission/next.ts + session/index.ts: add PermissionNext.clearSession(sessionID) which rejects and removes all pending permission requests keyed to a session. Session.remove() now calls clearSession() before the DB delete, so short-lived subagent sessions no longer leave dangling permission entries. Co-Authored-By: Claude Sonnet 4.6 --- .../src/context/global-sync/event-reducer.ts | 7 ++++- packages/opencode/src/lsp/client.ts | 12 +++++++ packages/opencode/src/mcp/index.ts | 31 ++++++++++++++++--- packages/opencode/src/permission/next.ts | 14 +++++++++ packages/opencode/src/session/index.ts | 1 + .../js/src/gen/core/serverSentEvents.gen.ts | 6 ++++ packages/sdk/js/src/server.ts | 12 ++++--- .../src/v2/gen/core/serverSentEvents.gen.ts | 6 ++++ packages/sdk/js/src/v2/server.ts | 12 ++++--- 9 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 241dfb14d7d..433c9f661a8 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -252,7 +252,12 @@ export function applyDirectoryEvent(input: { const part = draft[result.index] const field = props.field as keyof typeof part const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + props.delta + const MAX_PART_STRING_LENGTH = 1_048_576 // 1 MB per part field + const combined = (existing ?? "") + props.delta + ;(part[field] as string) = + combined.length > MAX_PART_STRING_LENGTH + ? combined.slice(combined.length - MAX_PART_STRING_LENGTH) + : combined }), ) break diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 084ccf831ee..8f0adad97f0 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -48,6 +48,7 @@ export namespace LSPClient { new StreamMessageWriter(input.server.process.stdin as any), ) + const MAX_DIAGNOSTICS_FILES = 2_000 const diagnostics = new Map() connection.onNotification("textDocument/publishDiagnostics", (params) => { const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) @@ -56,6 +57,11 @@ export namespace LSPClient { count: params.diagnostics.length, }) const exists = diagnostics.has(filePath) + if (!exists && diagnostics.size >= MAX_DIAGNOSTICS_FILES) { + // Evict the oldest entry to stay within the size limit + const oldest = diagnostics.keys().next().value + if (oldest !== undefined) diagnostics.delete(oldest) + } diagnostics.set(filePath, params.diagnostics) if (!exists && input.serverID === "typescript") return Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) @@ -132,6 +138,7 @@ export namespace LSPClient { }) } + const MAX_OPEN_FILES = 1_000 const files: { [path: string]: number } = {} @@ -191,6 +198,11 @@ export namespace LSPClient { log.info("textDocument/didOpen", input) diagnostics.delete(input.path) + // Evict oldest tracked file if we're at the limit + if (Object.keys(files).length >= MAX_OPEN_FILES) { + const oldest = Object.keys(files)[0] + if (oldest) delete files[oldest] + } await connection.sendNotification("textDocument/didOpen", { textDocument: { uri: pathToFileURL(input.path).href, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c29fe03d30..92ea0c3b3ac 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -150,6 +150,27 @@ export namespace MCP { // Store transports for OAuth servers to allow finishing auth type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport const pendingOAuthTransports = new Map() + const pendingOAuthTimers = new Map>() + const OAUTH_TRANSPORT_TTL_MS = 5 * 60 * 1_000 // 5 minutes + + function setPendingOAuthTransport(key: string, transport: TransportWithAuth) { + const existing = pendingOAuthTimers.get(key) + if (existing) clearTimeout(existing) + pendingOAuthTransports.set(key, transport) + const timer = setTimeout(() => { + pendingOAuthTransports.delete(key) + pendingOAuthTimers.delete(key) + log.info("evicted stale pending OAuth transport", { key }) + }, OAUTH_TRANSPORT_TTL_MS) + pendingOAuthTimers.set(key, timer) + } + + function deletePendingOAuthTransport(key: string) { + const timer = pendingOAuthTimers.get(key) + if (timer) clearTimeout(timer) + pendingOAuthTimers.delete(key) + pendingOAuthTransports.delete(key) + } // Prompt cache types type PromptInfo = Awaited>["prompts"][number] @@ -205,6 +226,8 @@ export namespace MCP { }), ), ) + for (const timer of pendingOAuthTimers.values()) clearTimeout(timer) + pendingOAuthTimers.clear() pendingOAuthTransports.clear() }, ) @@ -378,7 +401,7 @@ export namespace MCP { }).catch((e) => log.debug("failed to show toast", { error: e })) } else { // Store transport for later finishAuth call - pendingOAuthTransports.set(key, transport) + setPendingOAuthTransport(key, transport) status = { status: "needs_auth" as const } // Show toast for needs_auth Bus.publish(TuiEvent.ToastShow, { @@ -772,7 +795,7 @@ export namespace MCP { } catch (error) { if (error instanceof UnauthorizedError && capturedUrl) { // Store transport for finishAuth - pendingOAuthTransports.set(mcpName, transport) + setPendingOAuthTransport(mcpName, transport) return { authorizationUrl: capturedUrl.toString() } } throw error @@ -879,7 +902,7 @@ export namespace MCP { } // Re-add the MCP server to establish connection - pendingOAuthTransports.delete(mcpName) + deletePendingOAuthTransport(mcpName) const result = await add(mcpName, mcpConfig) const statusRecord = result.status as Record @@ -899,7 +922,7 @@ export namespace MCP { export async function removeAuth(mcpName: string): Promise { await McpAuth.remove(mcpName) McpOAuthCallback.cancelPending(mcpName) - pendingOAuthTransports.delete(mcpName) + deletePendingOAuthTransport(mcpName) await McpAuth.clearOAuthState(mcpName) log.info("removed oauth credentials", { mcpName }) } diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce..67eb7d9415a 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -283,4 +283,18 @@ export namespace PermissionNext { const s = await state() return Object.values(s.pending).map((x) => x.info) } + + export async function clearSession(sessionID: string) { + const s = await state() + for (const [id, pending] of Object.entries(s.pending)) { + if (pending.info.sessionID !== sessionID) continue + delete s.pending[id] + pending.reject(new RejectedError()) + Bus.publish(Event.Replied, { + sessionID, + requestID: id, + reply: "reject", + }) + } + } } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 8454a9c3e97..99ad34c02e8 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -653,6 +653,7 @@ export namespace Session { await remove(child.id) } await unshare(sessionID).catch(() => {}) + await PermissionNext.clearSession(sessionID).catch(() => {}) // CASCADE delete handles messages and parts automatically Database.use((db) => { db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run() diff --git a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts index 8f7fac549d2..88cc283fafd 100644 --- a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts @@ -119,10 +119,16 @@ export const createSseClient = ({ signal.addEventListener("abort", abortHandler) + const MAX_BUFFER_SIZE = 10_485_760 // 10 MB try { while (true) { const { done, value } = await reader.read() if (done) break + if (buffer.length + value.length > MAX_BUFFER_SIZE) { + throw new Error( + `SSE buffer overflow: accumulated ${buffer.length + value.length} bytes (limit ${MAX_BUFFER_SIZE})`, + ) + } buffer += value const chunks = buffer.split("\n\n") diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts index 174131ccfd5..5006859c5c4 100644 --- a/packages/sdk/js/src/server.ts +++ b/packages/sdk/js/src/server.ts @@ -75,10 +75,14 @@ export async function createOpencodeServer(options?: ServerOptions) { reject(error) }) if (options.signal) { - options.signal.addEventListener("abort", () => { - clearTimeout(id) - reject(new Error("Aborted")) - }) + options.signal.addEventListener( + "abort", + () => { + clearTimeout(id) + reject(new Error("Aborted")) + }, + { once: true }, + ) } }) diff --git a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts index 056a8125932..c3eb5c3f388 100644 --- a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts @@ -146,10 +146,16 @@ export const createSseClient = ({ signal.addEventListener("abort", abortHandler) + const MAX_BUFFER_SIZE = 10_485_760 // 10 MB try { while (true) { const { done, value } = await reader.read() if (done) break + if (buffer.length + value.length > MAX_BUFFER_SIZE) { + throw new Error( + `SSE buffer overflow: accumulated ${buffer.length + value.length} bytes (limit ${MAX_BUFFER_SIZE})`, + ) + } buffer += value // Normalize line endings: CRLF -> LF, then CR -> LF buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts index 174131ccfd5..5006859c5c4 100644 --- a/packages/sdk/js/src/v2/server.ts +++ b/packages/sdk/js/src/v2/server.ts @@ -75,10 +75,14 @@ export async function createOpencodeServer(options?: ServerOptions) { reject(error) }) if (options.signal) { - options.signal.addEventListener("abort", () => { - clearTimeout(id) - reject(new Error("Aborted")) - }) + options.signal.addEventListener( + "abort", + () => { + clearTimeout(id) + reject(new Error("Aborted")) + }, + { once: true }, + ) } }) From fe65e5acc9456a4e3ae40248f4fe91405e7d91bb Mon Sep 17 00:00:00 2001 From: KryptoBasedDev Date: Sat, 21 Feb 2026 21:24:57 -0800 Subject: [PATCH 2/2] fix: subagent deallocation, compaction output retention, TUI listener leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the 4 remaining high-priority memory leak issues from the work plan (I-9385-A, I-7046-A, PR-14635, I-7046-C partial). I-9385-A (CRITICAL, Priority #1) — tool/task.ts: call Session.remove() after extracting subagent task output. This fires the session.deleted event, which triggers cleanupSessionCaches() in the event-reducer — freeing all in-memory messages, parts, diffs, permissions, and status for the subagent session. The task_id in the output becomes a dead reference; if the LLM tries to resume, Session.get() fails gracefully and a fresh session is created. Validated: the cleanup infrastructure already existed but was never invoked for subagent sessions. I-7046-A (CRITICAL, Priority #3) — session/compaction.ts: clear part.state.output and part.state.attachments when pruning compacted tool parts. Previously, prune() set time.compacted but left the full output string in both the DB row and the in-memory store. toModelMessages already substituted "[Old tool result content cleared]" for compacted parts — this change aligns stored data with that behavior, freeing the large strings from memory and disk. PR-14635 (HIGH, Priority #4) — TUI event listener cleanup: - app.tsx: save the unsubscribe functions returned by all 6 sdk.event.on() calls; call them in a single onCleanup() handler. Previously, onCleanup was not even imported. - routes/session/index.tsx: save and clean up the message.part.updated listener. This component mounts/unmounts during session navigation, so each navigation previously added a duplicate listener. - component/prompt/index.tsx: save and clean up the PromptAppend listener. Same mount/unmount pattern as the session component. I-7046-C (partial) — the TUI event listener fixes above cover the most impactful instances of the missing-dispose pattern. A full audit of all subscribe() call sites remains as follow-up work. Co-Authored-By: Claude Opus 4.6 --- packages/opencode/src/cli/cmd/tui/app.tsx | 23 +++++++++++++------ .../cli/cmd/tui/component/prompt/index.tsx | 3 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +++- packages/opencode/src/session/compaction.ts | 5 ++++ packages/opencode/src/tool/task.ts | 6 +++++ 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ab3d0968925..11d41f91ab4 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -3,7 +3,7 @@ import { Clipboard } from "@tui/util/clipboard" import { Selection } from "@tui/util/selection" import { MouseButton, TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" -import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" +import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, onCleanup, batch, Show, on } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Installation } from "@/installation" import { Flag } from "@/flag/flag" @@ -668,11 +668,11 @@ function App() { } }) - sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { + const unsub1 = sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) - sdk.event.on(TuiEvent.ToastShow.type, (evt) => { + const unsub2 = sdk.event.on(TuiEvent.ToastShow.type, (evt) => { toast.show({ title: evt.properties.title, message: evt.properties.message, @@ -681,14 +681,14 @@ function App() { }) }) - sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { + const unsub3 = sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { route.navigate({ type: "session", sessionID: evt.properties.sessionID, }) }) - sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { + const unsub4 = sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { route.navigate({ type: "home" }) toast.show({ @@ -698,7 +698,7 @@ function App() { } }) - sdk.event.on(SessionApi.Event.Error.type, (evt) => { + const unsub5 = sdk.event.on(SessionApi.Event.Error.type, (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = (() => { @@ -720,7 +720,7 @@ function App() { }) }) - sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { + const unsub6 = sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { toast.show({ variant: "info", title: "Update Available", @@ -729,6 +729,15 @@ function App() { }) }) + onCleanup(() => { + unsub1() + unsub2() + unsub3() + unsub4() + unsub5() + unsub6() + }) + return ( { + const unsubPromptAppend = sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) setTimeout(() => { @@ -107,6 +107,7 @@ export function Prompt(props: PromptProps) { renderer.requestRender() }, 0) }) + onCleanup(unsubPromptAppend) createEffect(() => { if (props.disabled) input.cursorColor = theme.backgroundElement diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f5a7f6f6ca4..a3e2f32b467 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onCleanup, Show, Switch, useContext, @@ -204,7 +205,7 @@ export function Session() { }) let lastSwitch: string | undefined = undefined - sdk.event.on("message.part.updated", (evt) => { + const unsubPartUpdated = sdk.event.on("message.part.updated", (evt) => { const part = evt.properties.part if (part.type !== "tool") return if (part.sessionID !== route.sessionID) return @@ -219,6 +220,7 @@ export function Session() { lastSwitch = part.id } }) + onCleanup(unsubPartUpdated) let scroll: ScrollBoxRenderable let prompt: PromptRef diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 9245426057c..bc1f7bde7fc 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -91,6 +91,11 @@ export namespace SessionCompaction { for (const part of toPrune) { if (part.state.status === "completed") { part.state.time.compacted = Date.now() + // Clear tool output and attachments from both DB and in-memory store. + // toModelMessages already substitutes "[Old tool result content cleared]" + // for compacted parts, so this aligns stored data with model behavior. + part.state.output = "[compacted]" + part.state.attachments = undefined await Session.updatePart(part) } } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8c8cf827aba..94cb91f6bf3 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -152,6 +152,12 @@ export const TaskTool = Tool.define("task", async (ctx) => { "", ].join("\n") + // Clean up subagent session to free in-memory state (messages, parts, + // event listeners). The task output has already been captured above. + // If the LLM later tries to resume via task_id, Session.get() will + // fail gracefully and a fresh session will be created instead. + Session.remove(session.id).catch(() => {}) + return { title: params.description, metadata: {