Skip to content

Commit 11afb64

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 8c739b4 commit 11afb64

File tree

2 files changed

+74
-1
lines changed

2 files changed

+74
-1
lines changed

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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,65 @@ 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+
let sweepTimer: ReturnType<typeof setInterval> | undefined
25+
26+
function startIdleSweep() {
27+
if (sweepTimer) return
28+
sweepTimer = setInterval(async () => {
29+
const now = Date.now()
30+
for (const [directory, timestamp] of lastAccess) {
31+
// Only evict if idle and there are other cached instances (keep at least the most recent)
32+
if (now - timestamp < IDLE_TIMEOUT_MS) continue
33+
if (!cache.has(directory)) {
34+
lastAccess.delete(directory)
35+
continue
36+
}
37+
38+
Log.Default.info("evicting idle instance", {
39+
directory,
40+
idleMs: now - timestamp,
41+
})
42+
43+
const entry = cache.get(directory)
44+
if (!entry) continue
45+
46+
const ctx = await entry.catch(() => undefined)
47+
if (!ctx) {
48+
cache.delete(directory)
49+
lastAccess.delete(directory)
50+
continue
51+
}
52+
53+
// Double-check it hasn't been accessed while we were awaiting
54+
const currentTimestamp = lastAccess.get(directory)
55+
if (currentTimestamp && now - currentTimestamp < IDLE_TIMEOUT_MS) continue
56+
57+
await context.provide(ctx, async () => {
58+
await Instance.dispose()
59+
})
60+
lastAccess.delete(directory)
61+
}
62+
}, SWEEP_INTERVAL_MS)
63+
sweepTimer.unref()
64+
}
1665

1766
const disposal = {
1867
all: undefined as Promise<void> | undefined,
1968
}
2069

2170
export const Instance = {
2271
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
72+
lastAccess.set(input.directory, Date.now())
73+
startIdleSweep()
74+
2375
let existing = cache.get(input.directory)
2476
if (!existing) {
2577
Log.Default.info("creating instance", { directory: input.directory })
@@ -70,6 +122,7 @@ export const Instance = {
70122
Log.Default.info("disposing instance", { directory: Instance.directory })
71123
await State.dispose(Instance.directory)
72124
cache.delete(Instance.directory)
125+
lastAccess.delete(Instance.directory)
73126
GlobalBus.emit("event", {
74127
directory: Instance.directory,
75128
payload: {
@@ -85,6 +138,10 @@ export const Instance = {
85138

86139
disposal.all = iife(async () => {
87140
Log.Default.info("disposing all instances")
141+
if (sweepTimer) {
142+
clearInterval(sweepTimer)
143+
sweepTimer = undefined
144+
}
88145
const entries = [...cache.entries()]
89146
for (const [key, value] of entries) {
90147
if (cache.get(key) !== value) continue
@@ -105,6 +162,7 @@ export const Instance = {
105162
await Instance.dispose()
106163
})
107164
}
165+
lastAccess.clear()
108166
}).finally(() => {
109167
disposal.all = undefined
110168
})

0 commit comments

Comments
 (0)