From fcfb70dce33f981cb4bb089e7b7b511332980442 Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:19:46 -0800 Subject: [PATCH 1/8] 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 --- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 5 +++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 32 +++++++++++++++++-- packages/opencode/src/lsp/client.ts | 26 +++++++++++++++ packages/opencode/src/session/prompt.ts | 5 +++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 2403a4e938be..b603dc6153ed 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -38,6 +38,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ [key in Event["type"]]: Extract }>() + const MAX_EVENT_QUEUE = 1000 let queue: Event[] = [] let timer: Timer | undefined let last = 0 @@ -57,6 +58,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ } const handleEvent = (event: Event) => { + // Drop oldest events if queue is too large to prevent unbounded memory growth + if (queue.length >= MAX_EVENT_QUEUE) { + queue.splice(0, queue.length - MAX_EVENT_QUEUE + 1) + } queue.push(event) const elapsed = Date.now() - last diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927aa4..361c15310aff 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -254,19 +254,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) const updated = store.message[event.properties.info.sessionID] if (updated.length > 100) { - const oldest = updated[0] + // Remove excess messages beyond the limit, cleaning up their parts too + const excess = updated.length - 100 + const removedMessages = updated.slice(0, excess) batch(() => { setStore( "message", event.properties.info.sessionID, produce((draft) => { - draft.shift() + draft.splice(0, excess) }), ) setStore( "part", produce((draft) => { - delete draft[oldest.id] + for (const msg of removedMessages) { + delete draft[msg.id] + } }), ) }) @@ -442,6 +446,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const fullSyncedSessions = new Set() + let currentSessionID: string | undefined const result = { data: store, set: setStore, @@ -468,6 +473,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return last.time.completed ? "idle" : "working" }, async sync(sessionID: string) { + // Clean up previous session's data from memory when switching sessions + if (currentSessionID && currentSessionID !== sessionID) { + const oldMessages = store.message[currentSessionID] + if (oldMessages) { + setStore( + produce((draft) => { + // Clean up parts for old session's messages + for (const msg of oldMessages) { + delete draft.part[msg.id] + } + // Clean up old session's messages + delete draft.message[currentSessionID!] + // Clean up old session's diff + delete draft.session_diff[currentSessionID!] + }), + ) + } + fullSyncedSessions.delete(currentSessionID) + } + currentSessionID = sessionID + if (fullSyncedSessions.has(sessionID)) return const [session, messages, todo, diff] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 084ccf831eec..ad3fbc73c3bf 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -48,7 +48,9 @@ export namespace LSPClient { new StreamMessageWriter(input.server.process.stdin as any), ) + const MAX_DIAGNOSTIC_FILES = 200 const diagnostics = new Map() + const diagnosticOrder: string[] = [] // track insertion order for eviction connection.onNotification("textDocument/publishDiagnostics", (params) => { const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) l.info("textDocument/publishDiagnostics", { @@ -56,7 +58,29 @@ export namespace LSPClient { count: params.diagnostics.length, }) const exists = diagnostics.has(filePath) + + // If empty diagnostics, just remove the entry to free memory + if (params.diagnostics.length === 0) { + diagnostics.delete(filePath) + const idx = diagnosticOrder.indexOf(filePath) + if (idx !== -1) diagnosticOrder.splice(idx, 1) + if (exists) Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + return + } + diagnostics.set(filePath, params.diagnostics) + + // Update insertion order (move to end) + const idx = diagnosticOrder.indexOf(filePath) + if (idx !== -1) diagnosticOrder.splice(idx, 1) + diagnosticOrder.push(filePath) + + // Evict oldest entries if we exceed the limit + while (diagnosticOrder.length > MAX_DIAGNOSTIC_FILES) { + const oldest = diagnosticOrder.shift()! + diagnostics.delete(oldest) + } + if (!exists && input.serverID === "typescript") return Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) }) @@ -237,6 +261,8 @@ export namespace LSPClient { }, async shutdown() { l.info("shutting down") + diagnostics.clear() + diagnosticOrder.length = 0 connection.end() connection.dispose() input.server.process.kill() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 55b95fffed65..529db3932db8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -264,6 +264,11 @@ export namespace SessionPrompt { return } match.abort.abort() + // Reject any pending callbacks to prevent promise/closure leaks + for (const cb of match.callbacks) { + cb.reject(new Error("Session cancelled")) + } + match.callbacks.length = 0 delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) return From f8b738cf78d2ad93399494d06c488468380e36e3 Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Mon, 9 Mar 2026 02:56:24 -0800 Subject: [PATCH 2/8] fix: additional memory leak fixes across TUI, bus, PTY, and core - Add onCleanup for all sdk.event.on() listeners in app.tsx, session route, and prompt component to prevent listener accumulation on re-render - Add onCleanup for process SIGUSR2 handler in theme provider - Add onCleanup for leader timeout in keybind provider - Clear event queue on SDK context cleanup - Remove empty subscription arrays in Bus and RPC listener maps - Clear warning timeout in state disposal after completion - Clear models refresh interval on process exit - Add dispose() to ShareNext to clear pending sync timeouts - Clear PTY session buffer on removal to free memory immediately Co-Authored-By: Claude Opus 4.6 --- packages/opencode/src/bus/index.ts | 1 + packages/opencode/src/cli/cmd/tui/app.tsx | 119 ++++++++++-------- .../cli/cmd/tui/component/prompt/index.tsx | 3 +- .../src/cli/cmd/tui/context/keybind.tsx | 3 +- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 1 + .../src/cli/cmd/tui/context/theme.tsx | 8 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- packages/opencode/src/project/state.ts | 6 +- packages/opencode/src/provider/models.ts | 6 +- packages/opencode/src/pty/index.ts | 1 + packages/opencode/src/share/share-next.ts | 7 ++ packages/opencode/src/util/rpc.ts | 1 + 12 files changed, 99 insertions(+), 61 deletions(-) diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index edb093f19747..540a9319a23a 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -100,6 +100,7 @@ export namespace Bus { const index = match.indexOf(callback) if (index === -1) return match.splice(index, 1) + if (match.length === 0) subscriptions.delete(type) } } } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8bb17ff13362..9f601cb04680 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" @@ -677,66 +677,83 @@ function App() { }, ]) - sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { - command.trigger(evt.properties.command) - }) - - sdk.event.on(TuiEvent.ToastShow.type, (evt) => { - toast.show({ - title: evt.properties.title, - message: evt.properties.message, - variant: evt.properties.variant, - duration: evt.properties.duration, - }) + createEffect(() => { + const currentModel = local.model.current() + if (!currentModel) return + if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) { + untrack(() => { + DialogAlert.show( + dialog, + "Warning", + "While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen", + ).then(() => kv.set("openrouter_warning", true)) + }) + } }) - sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { - route.navigate({ - type: "session", - sessionID: evt.properties.sessionID, - }) - }) + const unsubs = [ + sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { + command.trigger(evt.properties.command) + }), - sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { - if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { - route.navigate({ type: "home" }) + sdk.event.on(TuiEvent.ToastShow.type, (evt) => { toast.show({ - variant: "info", - message: "The current session was deleted", + title: evt.properties.title, + message: evt.properties.message, + variant: evt.properties.variant, + duration: evt.properties.duration, }) - } - }) + }), - sdk.event.on(SessionApi.Event.Error.type, (evt) => { - const error = evt.properties.error - if (error && typeof error === "object" && error.name === "MessageAbortedError") return - const message = (() => { - if (!error) return "An error occurred" + sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { + route.navigate({ + type: "session", + sessionID: evt.properties.sessionID, + }) + }), - if (typeof error === "object") { - const data = error.data - if ("message" in data && typeof data.message === "string") { - return data.message - } + 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({ + variant: "info", + message: "The current session was deleted", + }) } - return String(error) - })() + }), + + sdk.event.on(SessionApi.Event.Error.type, (evt) => { + const error = evt.properties.error + if (error && typeof error === "object" && error.name === "MessageAbortedError") return + const message = (() => { + if (!error) return "An error occurred" + + if (typeof error === "object") { + const data = error.data + if ("message" in data && typeof data.message === "string") { + return data.message + } + } + return String(error) + })() - toast.show({ - variant: "error", - message, - duration: 5000, - }) - }) + toast.show({ + variant: "error", + message, + duration: 5000, + }) + }), - sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { - toast.show({ - variant: "info", - title: "Update Available", - message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`, - duration: 10000, - }) - }) + sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { + toast.show({ + variant: "info", + title: "Update Available", + message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`, + duration: 10000, + }) + }), + ] + onCleanup(() => unsubs.forEach((fn) => fn())) return ( { + const unsubPromptAppend = sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) setTimeout(() => { @@ -108,6 +108,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/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 566d66ade508..9a47d5b30b8f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,4 +1,4 @@ -import { createMemo } from "solid-js" +import { createMemo, onCleanup } from "solid-js" import { Keybind } from "@/util/keybind" import { pipe, mapValues } from "remeda" import type { TuiConfig } from "@/config/tui" @@ -27,6 +27,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex let focus: Renderable | null let timeout: NodeJS.Timeout + onCleanup(() => { if (timeout) clearTimeout(timeout) }) function leader(active: boolean) { if (active) { setStore("leader", true) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index b603dc6153ed..5eada5afae80 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -108,6 +108,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ abort.abort() sse?.abort() if (timer) clearTimeout(timer) + queue.length = 0 }) return { diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 2320c08ccc6e..7a68b03f5f15 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,6 +1,6 @@ import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import path from "path" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "./helper" import { Glob } from "../../../../util/glob" import aura from "./theme/aura.json" with { type: "json" } @@ -347,10 +347,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } const renderer = useRenderer() - process.on("SIGUSR2", async () => { + const sigusr2Handler = async () => { renderer.clearPaletteCache() init() - }) + } + process.on("SIGUSR2", sigusr2Handler) + onCleanup(() => process.off("SIGUSR2", sigusr2Handler)) const values = createMemo(() => { return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode) 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 7456742cdf36..072e78f635a9 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, onMount, Show, Switch, @@ -215,7 +216,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 @@ -230,6 +231,7 @@ export function Session() { lastSwitch = part.id } }) + onCleanup(unsubPartUpdated) let scroll: ScrollBoxRenderable let prompt: PromptRef diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5eb..e5f8b2dc7fe5 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -36,14 +36,15 @@ export namespace State { let disposalFinished = false - setTimeout(() => { + const warnTimeout = setTimeout(() => { if (!disposalFinished) { log.warn( "state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug", { key }, ) } - }, 10000).unref() + }, 10000) + warnTimeout.unref() const tasks: Promise[] = [] for (const [init, entry] of entries) { @@ -64,6 +65,7 @@ export namespace State { entries.clear() recordsByKey.delete(key) + clearTimeout(warnTimeout) disposalFinished = true log.info("state disposal completed", { key }) } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index bae33178467e..717fdae3154d 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -123,10 +123,12 @@ export namespace ModelsDev { if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { ModelsDev.refresh() - setInterval( + const modelsRefreshInterval = setInterval( async () => { await ModelsDev.refresh() }, 60 * 1000 * 60, - ).unref() + ) + modelsRefreshInterval.unref() + process.on("exit", () => clearInterval(modelsRefreshInterval)) } diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index d6bc4973a062..f9939828e497 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -234,6 +234,7 @@ export namespace Pty { } } session.subscribers.clear() + session.buffer = "" Bus.publish(Event.Deleted, { id: session.info.id }) } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index e911656c900f..d301f0c51ed1 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -63,6 +63,13 @@ export namespace ShareNext { const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" + export function dispose() { + for (const [, entry] of queue) { + clearTimeout(entry.timeout) + } + queue.clear() + } + export async function init() { if (disabled) return Bus.subscribe(Session.Event.Updated, async (evt) => { diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index ebd8be40e455..e8650d112a0b 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -59,6 +59,7 @@ export namespace Rpc { handlers.add(handler) return () => { handlers!.delete(handler) + if (handlers!.size === 0) listeners.delete(event) } }, } From 5124d2ab66a11aff4efe6a47ff7058bfe4c24812 Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:32:12 -0800 Subject: [PATCH 3/8] fix: consolidate memory leak fixes from 23+ community PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the remaining memory leaks identified in #16697 by consolidating the best fixes from 23+ open community PRs into a single coherent changeset. Fixes consolidated from PRs: #16695, #16346, #14650, #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 --- packages/opencode/src/cli/cmd/serve.ts | 9 ++- .../control-plane/workspace-server/routes.ts | 28 +++++--- packages/opencode/src/format/index.ts | 6 +- packages/opencode/src/index.ts | 4 ++ packages/opencode/src/lsp/client.ts | 8 +++ packages/opencode/src/lsp/index.ts | 3 + packages/opencode/src/mcp/index.ts | 6 +- packages/opencode/src/permission/next.ts | 15 ++++ packages/opencode/src/plugin/index.ts | 6 +- packages/opencode/src/question/index.ts | 14 ++++ packages/opencode/src/server/routes/global.ts | 61 +++++++++------- packages/opencode/src/server/server.ts | 70 +++++++++++-------- packages/opencode/src/session/compaction.ts | 4 ++ packages/opencode/src/session/index.ts | 7 ++ packages/opencode/src/share/share-next.ts | 23 +++--- packages/opencode/src/tool/bash.ts | 20 +++++- packages/opencode/src/tool/task.ts | 6 ++ 17 files changed, 213 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index ab51fe8c3e3b..5925d9797d36 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,6 +5,7 @@ import { Flag } from "../../flag/flag" import { Workspace } from "../../control-plane/workspace" import { Project } from "../../project/project" import { Installation } from "../../installation" +import { Instance } from "../../project/instance" export const ServeCommand = cmd({ command: "serve", @@ -18,7 +19,13 @@ export const ServeCommand = cmd({ const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) + // Wait for termination signal instead of blocking forever + await new Promise((resolve) => { + const shutdown = () => resolve() + process.on("SIGTERM", shutdown) + process.on("SIGINT", shutdown) + }) + await Instance.disposeAll() await server.stop() }, }) diff --git a/packages/opencode/src/control-plane/workspace-server/routes.ts b/packages/opencode/src/control-plane/workspace-server/routes.ts index 353e5d50af09..ce143270703c 100644 --- a/packages/opencode/src/control-plane/workspace-server/routes.ts +++ b/packages/opencode/src/control-plane/workspace-server/routes.ts @@ -7,10 +7,25 @@ export function WorkspaceServerRoutes() { c.header("X-Accel-Buffering", "no") c.header("X-Content-Type-Options", "nosniff") return streamSSE(c, async (stream) => { + let done = false + let resolveStream: (() => void) | undefined + + const cleanup = () => { + if (done) return + done = true + clearInterval(heartbeat) + GlobalBus.off("event", handler) + resolveStream?.() + } + const send = async (event: unknown) => { - await stream.writeSSE({ - data: JSON.stringify(event), - }) + try { + await stream.writeSSE({ + data: JSON.stringify(event), + }) + } catch { + cleanup() + } } const handler = async (event: { directory?: string; payload: unknown }) => { await send(event.payload) @@ -22,11 +37,8 @@ export function WorkspaceServerRoutes() { }, 10_000) await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - GlobalBus.off("event", handler) - resolve() - }) + resolveStream = resolve + stream.onAbort(cleanup) }) }) }) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index b849f778ecef..7055d55f6c78 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -101,9 +101,13 @@ export namespace Format { return result } + let unsubFormatted: (() => void) | undefined + export function init() { log.info("init") - Bus.subscribe(File.Event.Edited, async (payload) => { + // Unsubscribe previous subscription to prevent stacking on re-init + unsubFormatted?.() + unsubFormatted = Bus.subscribe(File.Event.Edited, async (payload) => { const file = payload.properties.file log.info("formatting", { file }) const ext = path.extname(file) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index fe7fd63ef12a..c588ae151166 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -210,6 +210,10 @@ try { } process.exitCode = 1 } finally { + // Dispose all instances (LSP, MCP, PTY child processes) to prevent zombies. + // Race with a 5-second timeout so we don't hang on unresponsive subprocesses. + const { Instance } = await import("./project/instance") + await Promise.race([Instance.disposeAll(), new Promise((r) => setTimeout(r, 5000))]).catch(() => {}) // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index ad3fbc73c3bf..09113ec62a83 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -156,6 +156,7 @@ export namespace LSPClient { }) } + const MAX_OPEN_FILES = 1000 const files: { [path: string]: number } = {} @@ -224,6 +225,12 @@ export namespace LSPClient { }, }) files[input.path] = 0 + // Evict oldest file if we exceed the limit + const keys = Object.keys(files) + if (keys.length > MAX_OPEN_FILES) { + const oldest = keys[0] + delete files[oldest] + } return }, }, @@ -263,6 +270,7 @@ export namespace LSPClient { l.info("shutting down") diagnostics.clear() diagnosticOrder.length = 0 + for (const key of Object.keys(files)) delete files[key] connection.end() connection.dispose() input.server.process.kill() diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 6ea7554c0968..d9724e95dd9b 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -141,6 +141,9 @@ export namespace LSP { }, async (state) => { await Promise.all(state.clients.map((client) => client.shutdown())) + state.clients.length = 0 + state.broken.clear() + state.spawning.clear() }, ) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e48a42a8b345..d29a000b0ba8 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -420,7 +420,9 @@ export namespace MCP { duration: 8000, }).catch((e) => log.debug("failed to show toast", { error: e })) } else { - // Store transport for later finishAuth call + // Close any existing pending transport before storing the new one + const existing = pendingOAuthTransports.get(key) + if (existing) existing.close?.().catch(() => {}) pendingOAuthTransports.set(key, transport) status = { status: "needs_auth" as const } // Show toast for needs_auth @@ -942,6 +944,8 @@ export namespace MCP { export async function removeAuth(mcpName: string): Promise { await McpAuth.remove(mcpName) McpOAuthCallback.cancelPending(mcpName) + const transport = pendingOAuthTransports.get(mcpName) + if (transport) transport.close?.().catch(() => {}) pendingOAuthTransports.delete(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 3ef3a02304dc..0d50ec79246c 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -278,6 +278,21 @@ export namespace PermissionNext { } } + export async function clearSession(sessionID: string) { + const s = await state() + for (const [id, pending] of Object.entries(s.pending)) { + if (pending.info.sessionID === sessionID) { + delete s.pending[id] + Bus.publish(Event.Replied, { + sessionID: pending.info.sessionID, + requestID: pending.info.id, + reply: "reject", + }) + pending.reject(new RejectedError()) + } + } + } + export async function list() { const s = await state() return Array.from(s.pending.values(), (x) => x.info) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8790efac49be..e81231338353 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -130,6 +130,8 @@ export namespace Plugin { return state().then((x) => x.hooks) } + let unsub: (() => void) | undefined + export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() @@ -137,7 +139,9 @@ export namespace Plugin { // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) } - Bus.subscribeAll(async (input) => { + // Unsubscribe previous wildcard subscriber to prevent stacking on re-init + unsub?.() + unsub = Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) for (const hook of hooks) { hook["event"]?.({ diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index cf52979fc881..062bf979ce41 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -161,6 +161,20 @@ export namespace Question { } } + export async function clearSession(sessionID: string) { + const s = await state() + for (const [id, pending] of Object.entries(s.pending)) { + if (pending.info.sessionID === sessionID) { + delete s.pending[id] + Bus.publish(Event.Rejected, { + sessionID: pending.info.sessionID, + requestID: pending.info.id, + }) + pending.reject(new RejectedError()) + } + } + } + export async function list() { return state().then((x) => Array.from(x.pending.values(), (x) => x.info)) } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 4d019f6a7eeb..c5aa5365c7b8 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -69,40 +69,51 @@ export const GlobalRoutes = lazy(() => c.header("X-Accel-Buffering", "no") c.header("X-Content-Type-Options", "nosniff") return streamSSE(c, async (stream) => { - stream.writeSSE({ - data: JSON.stringify({ - payload: { - type: "server.connected", - properties: {}, - }, - }), - }) + let done = false + let resolveStream: (() => void) | undefined + + const cleanup = () => { + if (done) return + done = true + clearInterval(heartbeat) + GlobalBus.off("event", handler) + resolveStream?.() + log.info("global event disconnected") + } + + const writeSSE = async (data: string) => { + try { + await stream.writeSSE({ data }) + } catch { + cleanup() + } + } + + await writeSSE(JSON.stringify({ + payload: { + type: "server.connected", + properties: {}, + }, + })) + async function handler(event: any) { - await stream.writeSSE({ - data: JSON.stringify(event), - }) + await writeSSE(JSON.stringify(event)) } GlobalBus.on("event", handler) // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { - stream.writeSSE({ - data: JSON.stringify({ - payload: { - type: "server.heartbeat", - properties: {}, - }, - }), - }) + void writeSSE(JSON.stringify({ + payload: { + type: "server.heartbeat", + properties: {}, + }, + })) }, 10_000) await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - GlobalBus.off("event", handler) - resolve() - log.info("global event disconnected") - }) + resolveStream = resolve + stream.onAbort(cleanup) }) }) }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 55bcf2dfce16..3273d6b16b59 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -513,42 +513,54 @@ export namespace Server { }, }, }), - async (c) => { - log.info("event connected") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { - stream.writeSSE({ - data: JSON.stringify({ + async (c) => { + log.info("event connected") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamSSE(c, async (stream) => { + let done = false + let resolveStream: (() => void) | undefined + + const cleanup = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + resolveStream?.() + log.info("event disconnected") + } + + const writeSSE = async (data: string) => { + try { + await stream.writeSSE({ data }) + } catch { + cleanup() + } + } + + await writeSSE(JSON.stringify({ type: "server.connected", properties: {}, - }), - }) - const unsub = Bus.subscribeAll(async (event) => { - await stream.writeSSE({ - data: JSON.stringify(event), + })) + + const unsub = Bus.subscribeAll(async (event) => { + await writeSSE(JSON.stringify(event)) + if (event.type === Bus.InstanceDisposed.type) { + stream.close() + } }) - if (event.type === Bus.InstanceDisposed.type) { - stream.close() - } - }) - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - stream.writeSSE({ - data: JSON.stringify({ + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + void writeSSE(JSON.stringify({ type: "server.heartbeat", properties: {}, - }), - }) - }, 10_000) + })) + }, 10_000) - await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - unsub() - resolve() - log.info("event disconnected") + await new Promise((resolve) => { + resolveStream = resolve + stream.onAbort(cleanup) }) }) }) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 8d934c05dab5..37988361a062 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -92,6 +92,10 @@ export namespace SessionCompaction { for (const part of toPrune) { if (part.state.status === "completed") { part.state.time.compacted = Date.now() + // Clear output and attachments to free memory — compacted parts + // are already replaced with placeholder text in toModelMessages + part.state.output = "" + part.state.attachments = undefined await Session.updatePart(part) } } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0879fe87fd3b..49481711570d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -29,6 +29,8 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { PermissionNext } from "@/permission/next" +import { Question } from "@/question" +import { FileTime } from "@/file/time" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { iife } from "@/util/iife" @@ -668,6 +670,11 @@ export namespace Session { for (const child of await children(sessionID)) { await remove(child.id) } + // Clean up per-session state before deleting + await PermissionNext.clearSession(sessionID) + await Question.clearSession(sessionID) + const ft = FileTime.state() + delete ft.read[sessionID] await unshare(sessionID).catch(() => {}) // CASCADE delete handles messages and parts automatically Database.use((db) => { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index d301f0c51ed1..ecc67214ce16 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -63,7 +63,11 @@ export namespace ShareNext { const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" + const unsubs: (() => void)[] = [] + export function dispose() { + for (const fn of unsubs) fn() + unsubs.length = 0 for (const [, entry] of queue) { clearTimeout(entry.timeout) } @@ -72,15 +76,18 @@ export namespace ShareNext { export async function init() { if (disabled) return - Bus.subscribe(Session.Event.Updated, async (evt) => { + // Unsubscribe previous subscriptions to prevent stacking on re-init + for (const fn of unsubs) fn() + unsubs.length = 0 + unsubs.push(Bus.subscribe(Session.Event.Updated, async (evt) => { await sync(evt.properties.info.id, [ { type: "session", data: evt.properties.info, }, ]) - }) - Bus.subscribe(MessageV2.Event.Updated, async (evt) => { + })) + unsubs.push(Bus.subscribe(MessageV2.Event.Updated, async (evt) => { await sync(evt.properties.info.sessionID, [ { type: "message", @@ -99,23 +106,23 @@ export namespace ShareNext { }, ]) } - }) - Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + })) + unsubs.push(Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { await sync(evt.properties.part.sessionID, [ { type: "part", data: evt.properties.part, }, ]) - }) - Bus.subscribe(Session.Event.Diff, async (evt) => { + })) + unsubs.push(Bus.subscribe(Session.Event.Diff, async (evt) => { await sync(evt.properties.sessionID, [ { type: "session_diff", data: evt.properties.diff, }, ]) - }) + })) } export async function create(sessionID: SessionID) { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 109a665363b1..70c34afa179b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -176,7 +176,9 @@ export const BashTool = Tool.define("bash", async () => { windowsHide: process.platform === "win32", }) - let output = "" + const MAX_OUTPUT_BYTES = 10 * 1024 * 1024 // 10 MB cap + const outputChunks: Buffer[] = [] + let outputLen = 0 // Initialize metadata with empty output ctx.metadata({ @@ -187,11 +189,18 @@ export const BashTool = Tool.define("bash", async () => { }) const append = (chunk: Buffer) => { - output += chunk.toString() + outputChunks.push(chunk) + outputLen += chunk.length + // Evict oldest chunks if we exceed the cap + while (outputLen > MAX_OUTPUT_BYTES && outputChunks.length > 1) { + const removed = outputChunks.shift()! + outputLen -= removed.length + } + const preview = Buffer.concat(outputChunks).toString() ctx.metadata({ metadata: { // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access) - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, + output: preview.length > MAX_METADATA_LENGTH ? preview.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : preview, description: params.description, }, }) @@ -242,6 +251,11 @@ export const BashTool = Tool.define("bash", async () => { }) }) + let output = Buffer.concat(outputChunks).toString() + // Free the chunks array + outputChunks.length = 0 + outputLen = 0 + const resultMetadata: string[] = [] if (timedOut) { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 68e44eb97e48..6d3140385f23 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -153,6 +153,12 @@ export const TaskTool = Tool.define("task", async (ctx) => { "", ].join("\n") + // Deallocate subagent session to free in-memory state (messages, parts, listeners) + // If the LLM later tries to resume via task_id, Session.get() will fail gracefully + if (!params.task_id) { + Session.remove(session.id).catch(() => {}) + } + return { title: params.description, metadata: { From bcf80c69bcf75e998253ca64190c0bbb3bc84ea3 Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:23:24 -0800 Subject: [PATCH 4/8] fix: add robust process exit detection for child processes Port robust process exit detection from PR #15757 to fix zombie/stuck child processes in containers where Bun fails to deliver exit events. - Add polling watchdog to bash tool and Process.spawn that detects process exit via kill(pid, 0) when event-loop events are missed - Add process registry (active map) with stale/reap exports for server-level watchdog to detect and clean up stuck bash processes - Improve Shell.killTree with alive() helper and proper SIGKILL escalation after SIGTERM timeout - Add session-level watchdog interval in prompt loop to periodically reap stale bash processes Based on the work in anomalyco/opencode#15757. Co-Authored-By: Nacho F. Lizaur --- packages/opencode/src/session/prompt.ts | 8 ++ packages/opencode/src/shell/shell.ts | 34 ++++++--- packages/opencode/src/tool/bash.ts | 97 ++++++++++++++++++++++++- packages/opencode/src/util/process.ts | 46 ++++++++++-- 4 files changed, 166 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 529db3932db8..0864f256f101 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,6 +47,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { stale, reap } from "@/tool/bash" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -291,6 +292,13 @@ export namespace SessionPrompt { using _ = defer(() => cancel(sessionID)) + const watchdog = setInterval(() => { + for (const id of stale()) { + reap(id) + } + }, 5000) + using _watchdog = defer(() => clearInterval(watchdog)) + // Structured output state // Note: On session resumption, state is reset but outputFormat is preserved // on the user message and will be retrieved from lastUser below diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index a30889d699ad..c08e6fce7a26 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -9,6 +9,15 @@ import { setTimeout as sleep } from "node:timers/promises" const SIGKILL_TIMEOUT_MS = 200 export namespace Shell { + function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + } + export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise { const pid = proc.pid if (!pid || opts?.exited?.()) return @@ -27,17 +36,24 @@ export namespace Shell { try { process.kill(-pid, "SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) - if (!opts?.exited?.()) { - process.kill(-pid, "SIGKILL") - } - } catch (_e) { - proc.kill("SIGTERM") - await sleep(SIGKILL_TIMEOUT_MS) - if (!opts?.exited?.()) { + } catch { + try { + proc.kill("SIGTERM") + } catch {} + } + + await sleep(SIGKILL_TIMEOUT_MS) + + if (opts?.exited?.() || !alive(pid)) return + try { + process.kill(-pid, "SIGKILL") + } catch { + try { proc.kill("SIGKILL") - } + } catch {} } + + await sleep(SIGKILL_TIMEOUT_MS) } const BLACKLIST = new Set(["fish", "nu"]) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 70c34afa179b..f27af0c9aa5b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -23,6 +23,40 @@ const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 export const log = Log.create({ service: "bash-tool" }) +// Registry for active bash processes — enables server-level watchdog +const active = new Map< + string, + { + pid: number + timeout: number + started: number + kill: () => void + done: () => void + } +>() + +export function stale() { + const result: string[] = [] + const now = Date.now() + for (const [id, entry] of active) { + if (now - entry.started > entry.timeout + 5000) result.push(id) + } + return result +} + +export function reap(id: string) { + const entry = active.get(id) + if (!entry) return + log.info("reaping stuck process", { + callID: id, + pid: entry.pid, + age: Date.now() - entry.started, + }) + entry.kill() + entry.done() + active.delete(id) +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -176,6 +210,14 @@ export const BashTool = Tool.define("bash", async () => { windowsHide: process.platform === "win32", }) + if (!proc.pid) { + if (proc.exitCode !== null) { + log.info("process exited before pid could be read", { exitCode: proc.exitCode }) + } else { + throw new Error(`Failed to spawn process: pid is undefined for command "${params.command}"`) + } + } + const MAX_OUTPUT_BYTES = 10 * 1024 * 1024 // 10 MB cap const outputChunks: Buffer[] = [] let outputLen = 0 @@ -232,25 +274,72 @@ export const BashTool = Tool.define("bash", async () => { void kill() }, timeout + 100) + const callID = ctx.callID + if (callID) { + active.set(callID, { + pid: proc.pid!, + timeout, + started: Date.now(), + kill: () => Shell.killTree(proc, { exited: () => exited }), + done: () => {}, + }) + } + await new Promise((resolve, reject) => { + let resolved = false + const cleanup = () => { + if (resolved) return + resolved = true clearTimeout(timeoutTimer) + clearInterval(poll) ctx.abort.removeEventListener("abort", abortHandler) } - proc.once("exit", () => { + const done = () => { + if (resolved) return exited = true cleanup() resolve() - }) + } - proc.once("error", (error) => { + // Update the active entry with the real done callback + if (callID) { + const entry = active.get(callID) + if (entry) entry.done = done + } + + const fail = (error: Error) => { + if (resolved) return exited = true cleanup() reject(error) - }) + } + + proc.once("exit", done) + proc.once("close", done) + proc.once("error", fail) + + // Polling watchdog: detect process exit when Bun's event loop + // fails to deliver the "exit" event (confirmed Bun bug in containers) + const poll = setInterval(() => { + if (proc.exitCode !== null || proc.signalCode !== null) { + done() + return + } + if (proc.pid && process.platform !== "win32") { + try { + process.kill(proc.pid, 0) + } catch { + done() + return + } + } + }, 1000) }) + if (callID) active.delete(callID) + let output = Buffer.concat(outputChunks).toString() // Free the chunks array outputChunks.length = 0 diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 049096937099..e8924e3f1c8b 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -79,20 +79,54 @@ export namespace Process { } const exited = new Promise((resolve, reject) => { - const done = () => { + let resolved = false + + const cleanup = () => { + if (resolved) return + resolved = true opts.abort?.removeEventListener("abort", abort) if (timer) clearTimeout(timer) + clearInterval(poll) + } + + const finish = (code: number) => { + if (resolved) return + cleanup() + resolve(code) + } + + const fail = (error: Error) => { + if (resolved) return + cleanup() + reject(error) } proc.once("exit", (code, signal) => { - done() - resolve(code ?? (signal ? 1 : 0)) + finish(code ?? (signal ? 1 : 0)) }) - proc.once("error", (error) => { - done() - reject(error) + proc.once("close", (code, signal) => { + finish(code ?? (signal ? 1 : 0)) }) + + proc.once("error", fail) + + // Polling watchdog: detect process exit when Bun's event loop + // fails to deliver the "exit" event (confirmed Bun bug in containers) + const poll = setInterval(() => { + if (proc.exitCode !== null || proc.signalCode !== null) { + finish(proc.exitCode ?? (proc.signalCode ? 1 : 0)) + return + } + if (proc.pid && process.platform !== "win32") { + try { + process.kill(proc.pid, 0) + } catch { + finish(proc.exitCode ?? 1) + return + } + } + }, 1000) }) if (opts.abort) { From 4c1e9723d074925cf40d940b32ba9067f0fe7d22 Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:31:00 -0800 Subject: [PATCH 5/8] fix: add stdio end event fallback, diagnostic logging, and tests Complete the port of PR #15757 with remaining pieces: - Add stdio end event redundancy as third fallback for exit detection (fires when pipe file descriptors close, independent of exit events) - Add diagnostic log.info calls at spawn, abort, timeout, and each exit detection path for debugging container issues - Add comprehensive tests: defensive patterns, polling watchdog isolation, Shell.killTree, server-level watchdog (stale/reap), stdio end events, and Process.spawn defensive patterns - Skip truncation tests on Windows (matching upstream) Co-Authored-By: Nacho F. Lizaur --- packages/opencode/src/tool/bash.ts | 68 +++- packages/opencode/test/tool/bash.test.ts | 405 +++++++++++++++++++- packages/opencode/test/util/process.test.ts | 36 ++ 3 files changed, 504 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f27af0c9aa5b..875b4792d3c9 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -218,6 +218,13 @@ export const BashTool = Tool.define("bash", async () => { } } + log.info("spawned process", { + pid: proc.pid, + command: params.command.slice(0, 100), + cwd, + timeout, + }) + const MAX_OUTPUT_BYTES = 10 * 1024 * 1024 // 10 MB cap const outputChunks: Buffer[] = [] let outputLen = 0 @@ -263,6 +270,7 @@ export const BashTool = Tool.define("bash", async () => { } const abortHandler = () => { + log.info("process abort triggered", { pid: proc.pid }) aborted = true void kill() } @@ -270,16 +278,19 @@ export const BashTool = Tool.define("bash", async () => { ctx.abort.addEventListener("abort", abortHandler, { once: true }) const timeoutTimer = setTimeout(() => { + log.info("process timeout triggered", { pid: proc.pid, timeout }) timedOut = true void kill() }, timeout + 100) + const started = Date.now() + const callID = ctx.callID if (callID) { active.set(callID, { pid: proc.pid!, timeout, - started: Date.now(), + started, kill: () => Shell.killTree(proc, { exited: () => exited }), done: () => {}, }) @@ -294,6 +305,8 @@ export const BashTool = Tool.define("bash", async () => { clearTimeout(timeoutTimer) clearInterval(poll) ctx.abort.removeEventListener("abort", abortHandler) + proc.stdout?.removeListener("end", check) + proc.stderr?.removeListener("end", check) } const done = () => { @@ -316,21 +329,62 @@ export const BashTool = Tool.define("bash", async () => { reject(error) } - proc.once("exit", done) - proc.once("close", done) + proc.once("exit", () => { + log.info("process exit detected via 'exit' event", { pid: proc.pid, exitCode: proc.exitCode }) + done() + }) + proc.once("close", () => { + log.info("process exit detected via 'close' event", { pid: proc.pid, exitCode: proc.exitCode }) + done() + }) proc.once("error", fail) + // Redundancy: stdio end events fire when pipe file descriptors close + // independent of process exit monitoring — catches missed exit events + let streams = 0 + const total = (proc.stdout ? 1 : 0) + (proc.stderr ? 1 : 0) + const check = () => { + streams++ + if (streams < total) return + if (proc.exitCode !== null || proc.signalCode !== null) { + log.info("stdio end detected exit (exitCode already set)", { + pid: proc.pid, + exitCode: proc.exitCode, + }) + done() + return + } + setTimeout(() => { + log.info("stdio end deferred check", { + pid: proc.pid, + exitCode: proc.exitCode, + }) + done() + }, 50) + } + proc.stdout?.once("end", check) + proc.stderr?.once("end", check) + // Polling watchdog: detect process exit when Bun's event loop // fails to deliver the "exit" event (confirmed Bun bug in containers) const poll = setInterval(() => { if (proc.exitCode !== null || proc.signalCode !== null) { + log.info("polling watchdog detected exit via exitCode/signalCode", { + exitCode: proc.exitCode, + signalCode: proc.signalCode, + }) done() return } + + // Check 2: process.kill(pid, 0) throws ESRCH if process is dead if (proc.pid && process.platform !== "win32") { try { process.kill(proc.pid, 0) } catch { + log.info("polling watchdog detected exit via kill(0) ESRCH", { + pid: proc.pid, + }) done() return } @@ -340,6 +394,14 @@ export const BashTool = Tool.define("bash", async () => { if (callID) active.delete(callID) + log.info("process completed", { + pid: proc.pid, + exitCode: proc.exitCode, + duration: Date.now() - started, + timedOut, + aborted, + }) + let output = Buffer.concat(outputChunks).toString() // Free the chunks array outputChunks.length = 0 diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index f947398b37e1..993047aef7bb 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" -import { BashTool } from "../../src/tool/bash" +import { BashTool, stale, reap } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission/next" import { Truncate } from "../../src/tool/truncation" import { SessionID, MessageID } from "../../src/session/schema" +import { Shell } from "../../src/shell/shell" +import { spawn } from "child_process" const ctx = { sessionID: SessionID.make("ses_test"), @@ -314,7 +316,7 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash truncation", () => { +describe.skipIf(process.platform === "win32")("tool.bash truncation", () => { test("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, @@ -401,3 +403,402 @@ describe("tool.bash truncation", () => { }) }) }) + +describe("tool.bash defensive patterns", () => { + test("completes normally with polling active", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "echo 'quick'", description: "Quick echo" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("quick") + }, + }) + }) + + test("resolves within polling interval for fast commands", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const start = Date.now() + const result = await bash.execute( + { command: "echo 'fast'", description: "Fast echo" }, + ctx, + ) + const elapsed = Date.now() - start + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("fast") + expect(elapsed).toBeLessThan(3000) + }, + }) + }) + + test.skipIf(process.platform === "win32")("handles long-running command that completes", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "sleep 2 && echo done", description: "Sleep then echo" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("done") + }, + }) + }) + + test("resolves when process exits normally (exit event path)", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "echo 'test'", description: "Exit event test" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + }, + }) + }) + + test("does not double-resolve for normal execution", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + let count = 0 + const result = await bash.execute( + { command: "echo 'once'", description: "Single resolve test" }, + ctx, + ) + count++ + expect(count).toBe(1) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("once") + }, + }) + }) + + test("spawns process with valid pid", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "echo 'pid-test'", description: "Pid validation test" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + }, + }) + }) + + test("handles invalid command gracefully", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "/nonexistent/binary/xyz", description: "Invalid command" }, + ctx, + ) + expect(result.metadata.exit).not.toBe(0) + }, + }) + }) + + test.skipIf(process.platform === "win32")("times out long-running command", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "sleep 60", timeout: 1000, description: "Long sleep" }, + ctx, + ) + expect(result.output).toContain("timeout") + }, + }) + }) + + test.skipIf(process.platform === "win32")("abort signal kills process", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const controller = new AbortController() + setTimeout(() => controller.abort(), 500) + const result = await bash.execute( + { command: "sleep 60", description: "Abortable sleep" }, + { ...ctx, abort: controller.signal }, + ) + expect(result.output).toContain("abort") + }, + }) + }) + + test("cleanup clears both timeout and polling interval", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "echo 'cleanup'", description: "Cleanup test" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + // If cleanup failed, lingering timers would keep the process alive + // and this test would time out. Completing is the assertion. + }, + }) + }) +}) + +// Prove polling watchdog detects exit without exit/close events +// (simulates Bun bug where events are dropped in containers) +describe.skipIf(process.platform === "win32")("polling watchdog isolation", () => { + test("resolves via polling when exit/close events are suppressed", async () => { + const proc = spawn("echo", ["hello"], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + let output = "" + proc.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString() + }) + + // Wait for process to finish — but deliberately do NOT use exit/close events + await Bun.sleep(500) + + const detected = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("polling watchdog failed to detect exit within 3s")), + 3000, + ) + + const poll = setInterval(() => { + if (proc.exitCode !== null || proc.signalCode !== null) { + clearInterval(poll) + clearTimeout(timer) + resolve("exitCode") + return + } + if (proc.pid) { + try { + process.kill(proc.pid, 0) + } catch { + clearInterval(poll) + clearTimeout(timer) + resolve("kill-esrch") + return + } + } + }, 200) + }) + + expect(["exitCode", "kill-esrch"]).toContain(detected) + expect(output.trim()).toBe("hello") + }) + + test("resolves via polling for process that exits with non-zero code", async () => { + const proc = spawn("exit 1", [], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + await Bun.sleep(500) + + const detected = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("polling watchdog failed to detect exit within 3s")), + 3000, + ) + + const poll = setInterval(() => { + if (proc.exitCode !== null || proc.signalCode !== null) { + clearInterval(poll) + clearTimeout(timer) + resolve("exitCode") + return + } + if (proc.pid) { + try { + process.kill(proc.pid, 0) + } catch { + clearInterval(poll) + clearTimeout(timer) + resolve("kill-esrch") + return + } + } + }, 200) + }) + + expect(["exitCode", "kill-esrch"]).toContain(detected) + }) + + test("resolves via polling for killed process (simulates timeout kill)", async () => { + const proc = spawn("sleep 60", [], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + expect(proc.pid).toBeDefined() + + // Kill the process (simulates what timeout/abort does) + try { + process.kill(-proc.pid!, "SIGKILL") + } catch { + proc.kill("SIGKILL") + } + + await Bun.sleep(500) + + const detected = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("polling watchdog failed to detect killed process within 3s")), + 3000, + ) + + const poll = setInterval(() => { + if (proc.exitCode !== null || proc.signalCode !== null) { + clearInterval(poll) + clearTimeout(timer) + resolve("exitCode") + return + } + if (proc.pid) { + try { + process.kill(proc.pid, 0) + } catch { + clearInterval(poll) + clearTimeout(timer) + resolve("kill-esrch") + return + } + } + }, 200) + }) + + expect(["exitCode", "kill-esrch"]).toContain(detected) + }) +}) + +describe.skipIf(process.platform === "win32")("shell.killTree", () => { + test("terminates a running process", async () => { + const proc = spawn("sleep", ["60"], { detached: true }) + expect(proc.pid).toBeDefined() + await Shell.killTree(proc) + await Bun.sleep(100) + expect(() => process.kill(proc.pid!, 0)).toThrow() + }) + + test("handles already-dead process", async () => { + const proc = spawn("echo", ["done"]) + await new Promise((resolve) => proc.once("exit", () => resolve())) + await Shell.killTree(proc, { exited: () => true }) + }) + + test("escalates to SIGKILL when SIGTERM ignored", async () => { + const proc = spawn("bash", ["-c", "trap '' TERM; sleep 60"], { detached: true }) + expect(proc.pid).toBeDefined() + await Shell.killTree(proc) + await Bun.sleep(100) + expect(() => process.kill(proc.pid!, 0)).toThrow() + }) +}) + +describe("tool.bash diagnostic logging", () => { + test("bash tool works with diagnostic logging", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "echo 'log-test'", description: "Logging test" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("log-test") + }, + }) + }) +}) + +describe.skipIf(process.platform === "win32")("server-level watchdog", () => { + test("stale returns empty when no processes are registered", () => { + const ids = stale() + expect(ids).toEqual([]) + }) + + test("reap force-completes a stuck bash process", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const id = "test-reap-" + Date.now() + const promise = bash.execute( + { command: "sleep 60", description: "Stuck process for reap test" }, + { ...ctx, callID: id }, + ) + + await Bun.sleep(300) + + reap(id) + + // The promise should now resolve (not hang forever) + const result = await promise + expect(result).toBeDefined() + expect(result.output).toBeDefined() + }, + }) + }) + + test("reap is a no-op for unknown callID", () => { + reap("nonexistent-id-" + Date.now()) + }) +}) + +describe.skipIf(process.platform === "win32")("stdio end events", () => { + test("command with stdout output completes via stdio path", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "seq 1 100", description: "Generate numbered output" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("1") + expect(result.metadata.output).toContain("100") + }, + }) + }) + + test("command with both stdout and stderr completes", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { command: "echo out && echo err >&2", description: "Both streams" }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("out") + expect(result.metadata.output).toContain("err") + }, + }) + }) +}) diff --git a/packages/opencode/test/util/process.test.ts b/packages/opencode/test/util/process.test.ts index 758469fe3ea1..dcb14763cfc1 100644 --- a/packages/opencode/test/util/process.test.ts +++ b/packages/opencode/test/util/process.test.ts @@ -75,3 +75,39 @@ describe("util.process", () => { expect(out.stdout.toString()).toBe("set") }) }) + +describe("util.process defensive patterns", () => { + test("Process.run completes normally", async () => { + const result = await Process.run(node('process.stdout.write("hello")')) + expect(result.code).toBe(0) + expect(result.stdout.toString()).toContain("hello") + }) + + test("Process.run handles failing command", async () => { + expect(Process.run(node("process.exit(1)"))).rejects.toThrow() + }) + + test("Process.run with nothrow returns non-zero code", async () => { + const result = await Process.run(node("process.exit(1)"), { nothrow: true }) + expect(result.code).not.toBe(0) + }) + + test("Process.spawn returns valid exited promise", async () => { + const proc = Process.spawn(node('process.stdout.write("test")'), { stdout: "pipe" }) + const code = await proc.exited + expect(code).toBe(0) + }) + + test("Process.spawn abort kills process", async () => { + const controller = new AbortController() + const proc = Process.spawn(node("setInterval(() => {}, 60000)"), { abort: controller.signal }) + setTimeout(() => controller.abort(), 200) + const code = await proc.exited + expect(typeof code).toBe("number") + }) + + test("Process.run completes for fast commands", async () => { + const result = await Process.run(node("process.exit(0)")) + expect(result.code).toBe(0) + }) +}) From 3dee417d123ece50eb0c695987be42ad6e46232f Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:37:15 -0800 Subject: [PATCH 6/8] fix: handle null exitCode when stdio end events resolve first On Windows, stdio pipe end events can fire before the exit event populates proc.exitCode, causing it to be null in the result metadata. Fall back to 0 (or 1 if signalCode is set) when exitCode is null, matching the same pattern used in Process.spawn. Co-Authored-By: Nacho F. Lizaur --- packages/opencode/src/tool/bash.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 875b4792d3c9..9b5426494738 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -425,7 +425,7 @@ export const BashTool = Tool.define("bash", async () => { title: params.description, metadata: { output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - exit: proc.exitCode, + exit: proc.exitCode ?? (proc.signalCode ? 1 : 0), description: params.description, }, output, From 89cadd29363abece094396b94ef2a1c66b39f769 Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:40:55 -0800 Subject: [PATCH 7/8] fix: correct SSE handler indentation after rebase conflict resolution Co-Authored-By: Claude Opus 4.6 --- packages/opencode/src/server/server.ts | 81 +++++++++++++------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 3273d6b16b59..ec8fd6e19f21 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -513,55 +513,54 @@ export namespace Server { }, }, }), - async (c) => { - log.info("event connected") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { - let done = false - let resolveStream: (() => void) | undefined + async (c) => { + log.info("event connected") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamSSE(c, async (stream) => { + let done = false + let resolveStream: (() => void) | undefined + + const cleanup = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + resolveStream?.() + log.info("event disconnected") + } - const cleanup = () => { - if (done) return - done = true - clearInterval(heartbeat) - unsub() - resolveStream?.() - log.info("event disconnected") + const writeSSE = async (data: string) => { + try { + await stream.writeSSE({ data }) + } catch { + cleanup() } + } - const writeSSE = async (data: string) => { - try { - await stream.writeSSE({ data }) - } catch { - cleanup() - } + await writeSSE(JSON.stringify({ + type: "server.connected", + properties: {}, + })) + + const unsub = Bus.subscribeAll(async (event) => { + await writeSSE(JSON.stringify(event)) + if (event.type === Bus.InstanceDisposed.type) { + stream.close() } + }) - await writeSSE(JSON.stringify({ - type: "server.connected", + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + void writeSSE(JSON.stringify({ + type: "server.heartbeat", properties: {}, })) + }, 10_000) - const unsub = Bus.subscribeAll(async (event) => { - await writeSSE(JSON.stringify(event)) - if (event.type === Bus.InstanceDisposed.type) { - stream.close() - } - }) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - void writeSSE(JSON.stringify({ - type: "server.heartbeat", - properties: {}, - })) - }, 10_000) - - await new Promise((resolve) => { - resolveStream = resolve - stream.onAbort(cleanup) - }) + await new Promise((resolve) => { + resolveStream = resolve + stream.onAbort(cleanup) }) }) }, From a37674c4c3292a9a98cac6d0324a920719faf688 Mon Sep 17 00:00:00 2001 From: binarydoubling <47013933+binarydoubling@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:43:04 -0800 Subject: [PATCH 8/8] fix: use Map methods for pending entries after upstream type change Upstream changed pending from plain object to Map. Update clearSession() in permission and question modules to use Map iteration and .delete() instead of Object.entries() and delete. Co-Authored-By: Claude Opus 4.6 --- packages/opencode/src/permission/next.ts | 4 ++-- packages/opencode/src/question/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 0d50ec79246c..48a00904b132 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -280,9 +280,9 @@ export namespace PermissionNext { export async function clearSession(sessionID: string) { const s = await state() - for (const [id, pending] of Object.entries(s.pending)) { + for (const [id, pending] of s.pending) { if (pending.info.sessionID === sessionID) { - delete s.pending[id] + s.pending.delete(id) Bus.publish(Event.Replied, { sessionID: pending.info.sessionID, requestID: pending.info.id, diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 062bf979ce41..d675d379b538 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -163,9 +163,9 @@ export namespace Question { export async function clearSession(sessionID: string) { const s = await state() - for (const [id, pending] of Object.entries(s.pending)) { + for (const [id, pending] of s.pending) { if (pending.info.sessionID === sessionID) { - delete s.pending[id] + s.pending.delete(id) Bus.publish(Event.Rejected, { sessionID: pending.info.sessionID, requestID: pending.info.id,