Skip to content

Commit 9564686

Browse files
committed
fix(web): dispose idle MCP server instances to prevent process accumulation
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()
1 parent 1cd77b1 commit 9564686

2 files changed

Lines changed: 76 additions & 1 deletion

File tree

packages/opencode/src/cli/cmd/web.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { UI } from "../ui"
33
import { cmd } from "./cmd"
44
import { withNetworkOptions, resolveNetworkOptions } from "../network"
55
import { Flag } from "../../flag/flag"
6+
import { Instance } from "../../project/instance"
7+
import { Log } from "../../util/log"
68
import open from "open"
79
import { networkInterfaces } from "os"
810

11+
const log = Log.create({ service: "web" })
12+
913
function getNetworkIPs() {
1014
const nets = networkInterfaces()
1115
const results: string[] = []
@@ -75,7 +79,18 @@ export const WebCommand = cmd({
7579
open(displayUrl).catch(() => {})
7680
}
7781

82+
// Graceful shutdown: dispose all instances (and their MCP servers) before exiting
83+
async function shutdown(signal: string) {
84+
log.info("received signal, shutting down", { signal })
85+
await Promise.race([Instance.disposeAll(), new Promise((resolve) => setTimeout(resolve, 5000))])
86+
server.stop(true)
87+
process.exit(0)
88+
}
89+
90+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
91+
process.on(signal, () => shutdown(signal))
92+
}
93+
7894
await new Promise(() => {})
79-
await server.stop()
8095
},
8196
})

packages/opencode/src/project/instance.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,70 @@ interface Context {
1313
}
1414
const context = Context.create<Context>("instance")
1515
const cache = new Map<string, Promise<Context>>()
16+
const lastAccess = new Map<string, number>()
17+
18+
/** How long an instance can be idle before it is eligible for eviction (ms). */
19+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
20+
21+
/** How often the idle-eviction sweep runs (ms). */
22+
const SWEEP_INTERVAL_MS = 60 * 1000 // 1 minute
23+
24+
const sweep = {
25+
timer: undefined as ReturnType<typeof setInterval> | undefined,
26+
start() {
27+
if (sweep.timer) return
28+
sweep.timer = setInterval(async () => {
29+
const now = Date.now()
30+
for (const [directory, timestamp] of lastAccess) {
31+
if (now - timestamp < IDLE_TIMEOUT_MS) continue
32+
if (!cache.has(directory)) {
33+
lastAccess.delete(directory)
34+
continue
35+
}
36+
37+
Log.Default.info("evicting idle instance", {
38+
directory,
39+
idleMs: now - timestamp,
40+
})
41+
42+
const entry = cache.get(directory)
43+
if (!entry) continue
44+
45+
const ctx = await entry.catch(() => undefined)
46+
if (!ctx) {
47+
cache.delete(directory)
48+
lastAccess.delete(directory)
49+
continue
50+
}
51+
52+
// re-check — may have been accessed while awaiting
53+
const current = lastAccess.get(directory)
54+
if (current && now - current < IDLE_TIMEOUT_MS) continue
55+
56+
await context.provide(ctx, async () => {
57+
await Instance.dispose()
58+
})
59+
lastAccess.delete(directory)
60+
}
61+
}, SWEEP_INTERVAL_MS)
62+
sweep.timer.unref()
63+
},
64+
stop() {
65+
if (!sweep.timer) return
66+
clearInterval(sweep.timer)
67+
sweep.timer = undefined
68+
},
69+
}
1670

1771
const disposal = {
1872
all: undefined as Promise<void> | undefined,
1973
}
2074

2175
export const Instance = {
2276
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
77+
lastAccess.set(input.directory, Date.now())
78+
sweep.start()
79+
2380
let existing = cache.get(input.directory)
2481
if (!existing) {
2582
Log.Default.info("creating instance", { directory: input.directory })
@@ -70,6 +127,7 @@ export const Instance = {
70127
Log.Default.info("disposing instance", { directory: Instance.directory })
71128
await State.dispose(Instance.directory)
72129
cache.delete(Instance.directory)
130+
lastAccess.delete(Instance.directory)
73131
GlobalBus.emit("event", {
74132
directory: Instance.directory,
75133
payload: {
@@ -85,6 +143,7 @@ export const Instance = {
85143

86144
disposal.all = iife(async () => {
87145
Log.Default.info("disposing all instances")
146+
sweep.stop()
88147
const entries = [...cache.entries()]
89148
for (const [key, value] of entries) {
90149
if (cache.get(key) !== value) continue
@@ -105,6 +164,7 @@ export const Instance = {
105164
await Instance.dispose()
106165
})
107166
}
167+
lastAccess.clear()
108168
}).finally(() => {
109169
disposal.all = undefined
110170
})

0 commit comments

Comments
 (0)