From 95646865590566799fc868ec51d84cbf127fcc88 Mon Sep 17 00:00:00 2001 From: Daniel Flanagan Date: Thu, 26 Feb 2026 22:48:09 -0600 Subject: [PATCH] fix(web): dispose idle MCP server instances to prevent process accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In `opencode web` mode, switching projects creates new Instance entries (each spawning MCP server child processes) that are never cleaned up. Unlike the TUI which calls shutdown()/disposeAll() on exit, the web command blocks forever with `await new Promise(() => {})` and never reaches its `server.stop()` call. This causes unbounded process growth — each project switch leaks ~200MB of MCP server processes (linear, notion, etc.) that persist for the lifetime of the web server. Fix: - Add signal handlers (SIGINT, SIGTERM, SIGHUP) to the web command that call Instance.disposeAll() with a 5s timeout before exiting - Add idle eviction to Instance: track last access time per directory, sweep every 60s, and dispose instances idle for >5 minutes - Clean up lastAccess bookkeeping in dispose() and disposeAll() --- packages/opencode/src/cli/cmd/web.ts | 17 ++++++- packages/opencode/src/project/instance.ts | 60 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f2..8f887c70f10 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,9 +3,13 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" +import { Instance } from "../../project/instance" +import { Log } from "../../util/log" import open from "open" import { networkInterfaces } from "os" +const log = Log.create({ service: "web" }) + function getNetworkIPs() { const nets = networkInterfaces() const results: string[] = [] @@ -75,7 +79,18 @@ export const WebCommand = cmd({ open(displayUrl).catch(() => {}) } + // Graceful shutdown: dispose all instances (and their MCP servers) before exiting + async function shutdown(signal: string) { + log.info("received signal, shutting down", { signal }) + await Promise.race([Instance.disposeAll(), new Promise((resolve) => setTimeout(resolve, 5000))]) + server.stop(true) + process.exit(0) + } + + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(signal, () => shutdown(signal)) + } + await new Promise(() => {}) - await server.stop() }, }) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f..fb1c2dd1811 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -13,6 +13,60 @@ interface Context { } const context = Context.create("instance") const cache = new Map>() +const lastAccess = new Map() + +/** How long an instance can be idle before it is eligible for eviction (ms). */ +const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + +/** How often the idle-eviction sweep runs (ms). */ +const SWEEP_INTERVAL_MS = 60 * 1000 // 1 minute + +const sweep = { + timer: undefined as ReturnType | undefined, + start() { + if (sweep.timer) return + sweep.timer = setInterval(async () => { + const now = Date.now() + for (const [directory, timestamp] of lastAccess) { + if (now - timestamp < IDLE_TIMEOUT_MS) continue + if (!cache.has(directory)) { + lastAccess.delete(directory) + continue + } + + Log.Default.info("evicting idle instance", { + directory, + idleMs: now - timestamp, + }) + + const entry = cache.get(directory) + if (!entry) continue + + const ctx = await entry.catch(() => undefined) + if (!ctx) { + cache.delete(directory) + lastAccess.delete(directory) + continue + } + + // re-check — may have been accessed while awaiting + const current = lastAccess.get(directory) + if (current && now - current < IDLE_TIMEOUT_MS) continue + + await context.provide(ctx, async () => { + await Instance.dispose() + }) + lastAccess.delete(directory) + } + }, SWEEP_INTERVAL_MS) + sweep.timer.unref() + }, + stop() { + if (!sweep.timer) return + clearInterval(sweep.timer) + sweep.timer = undefined + }, +} const disposal = { all: undefined as Promise | undefined, @@ -20,6 +74,9 @@ const disposal = { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + lastAccess.set(input.directory, Date.now()) + sweep.start() + let existing = cache.get(input.directory) if (!existing) { Log.Default.info("creating instance", { directory: input.directory }) @@ -70,6 +127,7 @@ export const Instance = { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) + lastAccess.delete(Instance.directory) GlobalBus.emit("event", { directory: Instance.directory, payload: { @@ -85,6 +143,7 @@ export const Instance = { disposal.all = iife(async () => { Log.Default.info("disposing all instances") + sweep.stop() const entries = [...cache.entries()] for (const [key, value] of entries) { if (cache.get(key) !== value) continue @@ -105,6 +164,7 @@ export const Instance = { await Instance.dispose() }) } + lastAccess.clear() }).finally(() => { disposal.all = undefined })