Skip to content

Commit 6efcd20

Browse files
kryptobaseddevroot
authored andcommitted
fix: resolve 10 memory leak sources across multiple subsystems
Fixes subagent session deallocation, delta string cap, tool output on compact, SSE buffer guard, MCP OAuth transport TTL, pending permissions cleanup, TUI event listener cleanup, LSP diagnostics cap, and AbortSignal listener. Original work from PR anomalyco#14650 by @kryptobaseddev Co-authored-by: Keaton Hoskins <[email protected]>
1 parent 2a20822 commit 6efcd20

14 files changed

Lines changed: 120 additions & 22 deletions

File tree

packages/app/src/context/global-sync/event-reducer.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,12 @@ export function applyDirectoryEvent(input: {
252252
const part = draft[result.index]
253253
const field = props.field as keyof typeof part
254254
const existing = part[field] as string | undefined
255-
;(part[field] as string) = (existing ?? "") + props.delta
255+
const MAX_PART_STRING_LENGTH = 1_048_576 // 1 MB per part field
256+
const combined = (existing ?? "") + props.delta
257+
;(part[field] as string) =
258+
combined.length > MAX_PART_STRING_LENGTH
259+
? combined.slice(combined.length - MAX_PART_STRING_LENGTH)
260+
: combined
256261
}),
257262
)
258263
break

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
33
import { Selection } from "@tui/util/selection"
44
import { MouseButton, TextAttributes } from "@opentui/core"
55
import { RouteProvider, useRoute } from "@tui/context/route"
6-
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
6+
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, onCleanup, batch, Show, on } from "solid-js"
77
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
88
import { Installation } from "@/installation"
99
import { Flag } from "@/flag/flag"
@@ -673,11 +673,11 @@ function App() {
673673
}
674674
})
675675

676-
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
676+
const unsub1 = sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
677677
command.trigger(evt.properties.command)
678678
})
679679

680-
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
680+
const unsub2 = sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
681681
toast.show({
682682
title: evt.properties.title,
683683
message: evt.properties.message,
@@ -686,14 +686,14 @@ function App() {
686686
})
687687
})
688688

689-
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
689+
const unsub3 = sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
690690
route.navigate({
691691
type: "session",
692692
sessionID: evt.properties.sessionID,
693693
})
694694
})
695695

696-
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
696+
const unsub4 = sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
697697
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
698698
route.navigate({ type: "home" })
699699
toast.show({
@@ -703,7 +703,7 @@ function App() {
703703
}
704704
})
705705

706-
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
706+
const unsub5 = sdk.event.on(SessionApi.Event.Error.type, (evt) => {
707707
const error = evt.properties.error
708708
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
709709
const message = (() => {
@@ -725,7 +725,7 @@ function App() {
725725
})
726726
})
727727

728-
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
728+
const unsub6 = sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
729729
toast.show({
730730
variant: "info",
731731
title: "Update Available",
@@ -734,6 +734,15 @@ function App() {
734734
})
735735
})
736736

737+
onCleanup(() => {
738+
unsub1()
739+
unsub2()
740+
unsub3()
741+
unsub4()
742+
unsub5()
743+
unsub6()
744+
})
745+
737746
return (
738747
<box
739748
width={dimensions().width}

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export function Prompt(props: PromptProps) {
9696
const pasteStyleId = syntax().getStyleId("extmark.paste")!
9797
let promptPartTypeId = 0
9898

99-
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
99+
const unsubPromptAppend = sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
100100
if (!input || input.isDestroyed) return
101101
input.insertText(evt.properties.text)
102102
setTimeout(() => {
@@ -107,6 +107,7 @@ export function Prompt(props: PromptProps) {
107107
renderer.requestRender()
108108
}, 0)
109109
})
110+
onCleanup(unsubPromptAppend)
110111

111112
createEffect(() => {
112113
if (props.disabled) input.cursorColor = theme.backgroundElement

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
For,
88
Match,
99
on,
10+
onCleanup,
1011
Show,
1112
Switch,
1213
useContext,
@@ -208,7 +209,7 @@ export function Session() {
208209
})
209210

210211
let lastSwitch: string | undefined = undefined
211-
sdk.event.on("message.part.updated", (evt) => {
212+
const unsubPartUpdated = sdk.event.on("message.part.updated", (evt) => {
212213
const part = evt.properties.part
213214
if (part.type !== "tool") return
214215
if (part.sessionID !== route.sessionID) return
@@ -223,6 +224,7 @@ export function Session() {
223224
lastSwitch = part.id
224225
}
225226
})
227+
onCleanup(unsubPartUpdated)
226228

227229
let scroll: ScrollBoxRenderable
228230
let prompt: PromptRef

packages/opencode/src/lsp/client.ts

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

51+
const MAX_DIAGNOSTICS_FILES = 2_000
5152
const diagnostics = new Map<string, Diagnostic[]>()
5253
connection.onNotification("textDocument/publishDiagnostics", (params) => {
5354
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
@@ -56,6 +57,11 @@ export namespace LSPClient {
5657
count: params.diagnostics.length,
5758
})
5859
const exists = diagnostics.has(filePath)
60+
if (!exists && diagnostics.size >= MAX_DIAGNOSTICS_FILES) {
61+
// Evict the oldest entry to stay within the size limit
62+
const oldest = diagnostics.keys().next().value
63+
if (oldest !== undefined) diagnostics.delete(oldest)
64+
}
5965
diagnostics.set(filePath, params.diagnostics)
6066
if (!exists && input.serverID === "typescript") return
6167
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
@@ -132,6 +138,7 @@ export namespace LSPClient {
132138
})
133139
}
134140

141+
const MAX_OPEN_FILES = 1_000
135142
const files: {
136143
[path: string]: number
137144
} = {}
@@ -191,6 +198,11 @@ export namespace LSPClient {
191198

192199
log.info("textDocument/didOpen", input)
193200
diagnostics.delete(input.path)
201+
// Evict oldest tracked file if we're at the limit
202+
if (Object.keys(files).length >= MAX_OPEN_FILES) {
203+
const oldest = Object.keys(files)[0]
204+
if (oldest) delete files[oldest]
205+
}
194206
await connection.sendNotification("textDocument/didOpen", {
195207
textDocument: {
196208
uri: pathToFileURL(input.path).href,

packages/opencode/src/mcp/index.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,27 @@ export namespace MCP {
150150
// Store transports for OAuth servers to allow finishing auth
151151
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
152152
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
153+
const pendingOAuthTimers = new Map<string, ReturnType<typeof setTimeout>>()
154+
const OAUTH_TRANSPORT_TTL_MS = 5 * 60 * 1_000 // 5 minutes
155+
156+
function setPendingOAuthTransport(key: string, transport: TransportWithAuth) {
157+
const existing = pendingOAuthTimers.get(key)
158+
if (existing) clearTimeout(existing)
159+
pendingOAuthTransports.set(key, transport)
160+
const timer = setTimeout(() => {
161+
pendingOAuthTransports.delete(key)
162+
pendingOAuthTimers.delete(key)
163+
log.info("evicted stale pending OAuth transport", { key })
164+
}, OAUTH_TRANSPORT_TTL_MS)
165+
pendingOAuthTimers.set(key, timer)
166+
}
167+
168+
function deletePendingOAuthTransport(key: string) {
169+
const timer = pendingOAuthTimers.get(key)
170+
if (timer) clearTimeout(timer)
171+
pendingOAuthTimers.delete(key)
172+
pendingOAuthTransports.delete(key)
173+
}
153174

154175
// Prompt cache types
155176
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
@@ -205,6 +226,8 @@ export namespace MCP {
205226
}),
206227
),
207228
)
229+
for (const timer of pendingOAuthTimers.values()) clearTimeout(timer)
230+
pendingOAuthTimers.clear()
208231
pendingOAuthTransports.clear()
209232
},
210233
)
@@ -378,7 +401,7 @@ export namespace MCP {
378401
}).catch((e) => log.debug("failed to show toast", { error: e }))
379402
} else {
380403
// Store transport for later finishAuth call
381-
pendingOAuthTransports.set(key, transport)
404+
setPendingOAuthTransport(key, transport)
382405
status = { status: "needs_auth" as const }
383406
// Show toast for needs_auth
384407
Bus.publish(TuiEvent.ToastShow, {
@@ -772,7 +795,7 @@ export namespace MCP {
772795
} catch (error) {
773796
if (error instanceof UnauthorizedError && capturedUrl) {
774797
// Store transport for finishAuth
775-
pendingOAuthTransports.set(mcpName, transport)
798+
setPendingOAuthTransport(mcpName, transport)
776799
return { authorizationUrl: capturedUrl.toString() }
777800
}
778801
throw error
@@ -879,7 +902,7 @@ export namespace MCP {
879902
}
880903

881904
// Re-add the MCP server to establish connection
882-
pendingOAuthTransports.delete(mcpName)
905+
deletePendingOAuthTransport(mcpName)
883906
const result = await add(mcpName, mcpConfig)
884907

885908
const statusRecord = result.status as Record<string, Status>
@@ -899,7 +922,7 @@ export namespace MCP {
899922
export async function removeAuth(mcpName: string): Promise<void> {
900923
await McpAuth.remove(mcpName)
901924
McpOAuthCallback.cancelPending(mcpName)
902-
pendingOAuthTransports.delete(mcpName)
925+
deletePendingOAuthTransport(mcpName)
903926
await McpAuth.clearOAuthState(mcpName)
904927
log.info("removed oauth credentials", { mcpName })
905928
}

packages/opencode/src/permission/next.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,18 @@ export namespace PermissionNext {
283283
const s = await state()
284284
return Object.values(s.pending).map((x) => x.info)
285285
}
286+
287+
export async function clearSession(sessionID: string) {
288+
const s = await state()
289+
for (const [id, pending] of Object.entries(s.pending)) {
290+
if (pending.info.sessionID !== sessionID) continue
291+
delete s.pending[id]
292+
pending.reject(new RejectedError())
293+
Bus.publish(Event.Replied, {
294+
sessionID,
295+
requestID: id,
296+
reply: "reject",
297+
})
298+
}
299+
}
286300
}

packages/opencode/src/session/compaction.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export namespace SessionCompaction {
9191
for (const part of toPrune) {
9292
if (part.state.status === "completed") {
9393
part.state.time.compacted = Date.now()
94+
// Clear tool output and attachments from both DB and in-memory store.
95+
// toModelMessages already substitutes "[Old tool result content cleared]"
96+
// for compacted parts, so this aligns stored data with model behavior.
97+
part.state.output = "[compacted]"
98+
part.state.attachments = undefined
9499
await Session.updatePart(part)
95100
}
96101
}

packages/opencode/src/session/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ export namespace Session {
653653
await remove(child.id)
654654
}
655655
await unshare(sessionID).catch(() => {})
656+
await PermissionNext.clearSession(sessionID).catch(() => {})
656657
// CASCADE delete handles messages and parts automatically
657658
Database.use((db) => {
658659
db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()

packages/opencode/src/tool/task.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ export const TaskTool = Tool.define("task", async (ctx) => {
152152
"</task_result>",
153153
].join("\n")
154154

155+
// Clean up subagent session to free in-memory state (messages, parts,
156+
// event listeners). The task output has already been captured above.
157+
// If the LLM later tries to resume via task_id, Session.get() will
158+
// fail gracefully and a fresh session will be created instead.
159+
Session.remove(session.id).catch(() => {})
160+
155161
return {
156162
title: params.description,
157163
metadata: {

0 commit comments

Comments
 (0)