Skip to content

Commit c151ccd

Browse files
test(server): add regression for MCP subprocess lifetime (anomalyco#47727)
1 parent 95daf90 commit c151ccd

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { open } from "node:fs/promises"
2+
3+
const { Server } = await import("@modelcontextprotocol/sdk/server/index.js")
4+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
5+
6+
const file = process.env.PROCLIFETIME_PID_FILE
7+
if (file) {
8+
const handle = await open(file, "a")
9+
await handle.write(`${process.pid}\n`)
10+
await handle.close()
11+
}
12+
13+
const server = new Server({ name: "mcp-proc-lifetime", version: "1.0.0" }, { capabilities: {} })
14+
await server.connect(new StdioServerTransport())
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { readFileSync } from "node:fs"
2+
import os from "node:os"
3+
import path from "node:path"
4+
import { describe, expect } from "bun:test"
5+
import { Context, Effect, Layer } from "effect"
6+
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
7+
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
8+
import { resetDatabase } from "../fixture/db"
9+
import { TestInstance } from "../fixture/fixture"
10+
import { pollWithTimeout, testEffect } from "../lib/effect"
11+
12+
const stdioFixture = path.join(import.meta.dir, "../fixture/mcp-proc-lifetime.ts")
13+
const pidFile = path.join(os.tmpdir(), `mcp-proc-lifetime-${process.pid}.pid`)
14+
15+
const context = Context.empty() as Context.Context<unknown>
16+
const testStateLayer = Layer.effectDiscard(
17+
Effect.gen(function* () {
18+
yield* Effect.promise(() => resetDatabase())
19+
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()).pipe(Effect.ignore))
20+
}),
21+
)
22+
const it = testEffect(testStateLayer)
23+
24+
// Every process the fixture is asked to spawn appends its pid. A process we
25+
// spawn but never dispose stays alive and remains visible here — that is the
26+
// exact "never disposed" leak from #47727.
27+
const childPids = Effect.sync(() => {
28+
try {
29+
const values = readFileSync(pidFile, "utf8").trim().split("\n").filter(Boolean)
30+
return values.map((v) => Number.parseInt(v, 10)).filter(Number.isInteger)
31+
} catch {
32+
return [] as number[]
33+
}
34+
})
35+
36+
const alivePids = Effect.gen(function* () {
37+
const pids = yield* childPids
38+
return pids.filter((pid) => {
39+
try {
40+
process.kill(pid, 0)
41+
return true
42+
} catch {
43+
return false
44+
}
45+
})
46+
})
47+
48+
const aliveCount = Effect.map(alivePids, (pids) => pids.length)
49+
50+
function request(
51+
handler: ReturnType<typeof HttpApiApp.webHandler>,
52+
route: string,
53+
directory: string,
54+
init?: RequestInit,
55+
) {
56+
const headers = new Headers(init?.headers)
57+
headers.set("x-opencode-directory", directory)
58+
return Effect.promise(() =>
59+
Promise.resolve(
60+
handler.handler(new Request(`http://localhost${route}`, { ...init, headers }), context),
61+
),
62+
)
63+
}
64+
65+
describe("MCP subprocess lifetime under the instance HttpApi", () => {
66+
it.instance(
67+
"keeps one live MCP child per directory across repeated interactions and reaps it on disconnect",
68+
() =>
69+
Effect.gen(function* () {
70+
const tmp = yield* TestInstance
71+
const dir = tmp.directory
72+
const handler = HttpApiApp.webHandler()
73+
const routeFor = (route: string) => route.replace(":name", "demo")
74+
75+
const connected = yield* request(handler, routeFor(McpPaths.connect), dir, { method: "POST" })
76+
expect(connected.status).toBe(200)
77+
yield* pollWithTimeout(
78+
Effect.map(aliveCount, (n) => (n >= 1 ? true : undefined)),
79+
"MCP child did not come alive after connect",
80+
"10 seconds",
81+
)
82+
83+
// OpenChamber-style clients keep polling MCP state for the same
84+
// directory. Every interaction must reuse the connected child instead
85+
// of piling up fresh processes per request.
86+
// Ref: https://github.com/anomalyco/opencode/issues/47727
87+
for (let i = 0; i < 5; i++) {
88+
const status = yield* request(handler, McpPaths.status, dir)
89+
expect(status.status).toBe(200)
90+
const again = yield* request(handler, routeFor(McpPaths.connect), dir, { method: "POST" })
91+
expect(again.status).toBe(200)
92+
}
93+
yield* pollWithTimeout(
94+
Effect.map(aliveCount, (n) => (n === 1 ? true : undefined)),
95+
"Live MCP children accumulated across repeated interactions",
96+
)
97+
98+
const disconnected = yield* request(handler, routeFor(McpPaths.disconnect), dir, { method: "POST" })
99+
expect(disconnected.status).toBe(200)
100+
yield* pollWithTimeout(
101+
Effect.map(aliveCount, (n) => (n === 0 ? true : undefined)),
102+
"MCP child was not reaped after disconnect",
103+
)
104+
}),
105+
{
106+
config: {
107+
mcp: {
108+
demo: {
109+
type: "local",
110+
command: [process.execPath, stdioFixture],
111+
environment: { PROCLIFETIME_PID_FILE: pidFile },
112+
},
113+
},
114+
},
115+
},
116+
)
117+
})

0 commit comments

Comments
 (0)