Skip to content

Commit f43a8a5

Browse files
committed
fix: check transport liveness before idle-timeout shutdown
The idle monitor fires after 15 minutes of no tool calls and triggers process.exit(). This kills the MCP server even when the agent connection (stdio transport) is still alive - the agent may just be idle (thinking, waiting for user input, etc). The abrupt exit breaks the SSE stream for the calling agent. The fix adds an optional isTransportAlive callback to createIdleMonitor. When the idle timer fires, it checks whether the transport (stdin) is still readable. If so, the timer reschedules instead of shutting down. When the transport is actually dead, shutdown proceeds normally. In index.ts, the callback checks process.stdin.readable && !destroyed. Backward compatible: when isTransportAlive is not provided, the original behavior (unconditional shutdown) is preserved. Includes 7 new tests: - 5 unit tests for createIdleMonitor with isTransportAlive - 2 spawn-level integration tests proving the bug (without fix) and the fix (with isTransportAlive) at the process level
1 parent d2f44d3 commit f43a8a5

3 files changed

Lines changed: 153 additions & 0 deletions

File tree

src/core/process-lifecycle.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface IdleMonitor {
2727
export interface IdleMonitorOptions {
2828
timeoutMs: number;
2929
onIdle: () => void;
30+
isTransportAlive?: () => boolean;
3031
}
3132

3233
export interface ParentMonitorOptions {
@@ -89,6 +90,10 @@ export function createIdleMonitor(options: IdleMonitorOptions): IdleMonitor {
8990
if (timer) clearTimeout(timer);
9091
timer = setTimeout(() => {
9192
timer = null;
93+
if (options.isTransportAlive && options.isTransportAlive()) {
94+
schedule();
95+
return;
96+
}
9297
options.onIdle();
9398
}, options.timeoutMs);
9499
unrefHandle(timer);

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ async function main() {
549549
const idleMonitor = createIdleMonitor({
550550
timeoutMs: getIdleShutdownMs(process.env.CONTEXTPLUS_IDLE_TIMEOUT_MS),
551551
onIdle: () => requestShutdown("idle-timeout", 0),
552+
isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed,
552553
});
553554

554555
noteServerActivity = idleMonitor.touch;
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { spawn } from "node:child_process";
4+
import { resolve, dirname, join } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
import { mkdtempSync, writeFileSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import {
9+
createIdleMonitor,
10+
} from "../../build/core/process-lifecycle.js";
11+
12+
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
13+
14+
function wait(ms) {
15+
return new Promise((resolve) => setTimeout(resolve, ms));
16+
}
17+
18+
function createTestScript(withFix) {
19+
const buildPath = join(PROJECT_ROOT, "build/core/process-lifecycle.js").replace(/\\/g, "/");
20+
return `
21+
import { createIdleMonitor } from "file://${buildPath}";
22+
23+
const idleMonitor = createIdleMonitor({
24+
timeoutMs: 200,
25+
onIdle: () => {
26+
process.stderr.write("IDLE_SHUTDOWN\\n");
27+
process.exit(0);
28+
},
29+
${withFix ? 'isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed,' : ''}
30+
});
31+
32+
process.stderr.write("STARTED\\n");
33+
const keepAlive = setInterval(() => {}, 1000);
34+
setTimeout(() => {
35+
idleMonitor.stop();
36+
clearInterval(keepAlive);
37+
process.stderr.write("SURVIVED\\n");
38+
process.exit(0);
39+
}, 1500);
40+
`;
41+
}
42+
43+
function runHarness(withFix) {
44+
return new Promise((resolve) => {
45+
const tmpDir = mkdtempSync(join(tmpdir(), "cp-test-"));
46+
const scriptPath = join(tmpDir, "harness.mjs");
47+
writeFileSync(scriptPath, createTestScript(withFix));
48+
49+
const child = spawn("node", [scriptPath], {
50+
stdio: ["pipe", "pipe", "pipe"],
51+
});
52+
53+
let stderr = "";
54+
child.stderr.on("data", (d) => { stderr += d.toString(); });
55+
56+
child.on("exit", (code) => {
57+
resolve({ code, stderr });
58+
});
59+
});
60+
}
61+
62+
describe("idle-timeout transport-aware fix", () => {
63+
it("does NOT fire onIdle when isTransportAlive returns true", async () => {
64+
let idleFired = 0;
65+
const monitor = createIdleMonitor({
66+
timeoutMs: 30,
67+
onIdle: () => { idleFired += 1; },
68+
isTransportAlive: () => true,
69+
});
70+
await wait(80);
71+
assert.equal(idleFired, 0, "onIdle should not fire when transport is alive");
72+
monitor.stop();
73+
});
74+
75+
it("fires onIdle when isTransportAlive returns false", async () => {
76+
let idleFired = 0;
77+
const monitor = createIdleMonitor({
78+
timeoutMs: 30,
79+
onIdle: () => { idleFired += 1; },
80+
isTransportAlive: () => false,
81+
});
82+
await wait(80);
83+
assert.equal(idleFired, 1, "onIdle should fire when transport is dead");
84+
monitor.stop();
85+
});
86+
87+
it("fires onIdle normally when no isTransportAlive provided (backward compat)", async () => {
88+
let idleFired = 0;
89+
const monitor = createIdleMonitor({
90+
timeoutMs: 30,
91+
onIdle: () => { idleFired += 1; },
92+
});
93+
await wait(80);
94+
assert.equal(idleFired, 1, "onIdle should fire with no transport check");
95+
monitor.stop();
96+
});
97+
98+
it("reschedules then fires when transport dies after initial alive check", async () => {
99+
let transportAlive = true;
100+
let idleFired = 0;
101+
const monitor = createIdleMonitor({
102+
timeoutMs: 30,
103+
onIdle: () => { idleFired += 1; },
104+
isTransportAlive: () => transportAlive,
105+
});
106+
await wait(50);
107+
assert.equal(idleFired, 0, "should not fire while transport alive");
108+
transportAlive = false;
109+
await wait(50);
110+
assert.equal(idleFired, 1, "should fire after transport dies");
111+
monitor.stop();
112+
});
113+
114+
it("touch resets the idle timer even with transport check", async () => {
115+
let idleFired = 0;
116+
const monitor = createIdleMonitor({
117+
timeoutMs: 40,
118+
onIdle: () => { idleFired += 1; },
119+
isTransportAlive: () => false,
120+
});
121+
await wait(20);
122+
monitor.touch();
123+
await wait(20);
124+
assert.equal(idleFired, 0, "touch should reset timer");
125+
await wait(30);
126+
assert.equal(idleFired, 1, "should fire after full timeout post-touch");
127+
monitor.stop();
128+
});
129+
130+
it("spawn: without isTransportAlive, server exits on idle with stdin open", async () => {
131+
const result = await runHarness(false);
132+
assert.equal(result.code, 0);
133+
assert.ok(result.stderr.includes("IDLE_SHUTDOWN"),
134+
"server idle-shutdown with stdin open (no transport check)");
135+
assert.ok(!result.stderr.includes("SURVIVED"),
136+
"server died before survival window");
137+
});
138+
139+
it("spawn: with isTransportAlive, server survives idle when stdin is open", async () => {
140+
const result = await runHarness(true);
141+
assert.equal(result.code, 0);
142+
assert.ok(!result.stderr.includes("IDLE_SHUTDOWN"),
143+
"server should NOT idle-shutdown when transport alive");
144+
assert.ok(result.stderr.includes("SURVIVED"),
145+
"server should survive past idle timeout");
146+
});
147+
});

0 commit comments

Comments
 (0)