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