From f43a8a5f751c5a554a121d7c877ec038cec3df83 Mon Sep 17 00:00:00 2001 From: Overtime Date: Thu, 26 Mar 2026 20:12:38 -0400 Subject: [PATCH] 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 --- src/core/process-lifecycle.ts | 5 + src/index.ts | 1 + test/main/idle-timeout-spawn.test.mjs | 147 ++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 test/main/idle-timeout-spawn.test.mjs diff --git a/src/core/process-lifecycle.ts b/src/core/process-lifecycle.ts index f9256f8..f7949e2 100644 --- a/src/core/process-lifecycle.ts +++ b/src/core/process-lifecycle.ts @@ -27,6 +27,7 @@ export interface IdleMonitor { export interface IdleMonitorOptions { timeoutMs: number; onIdle: () => void; + isTransportAlive?: () => boolean; } export interface ParentMonitorOptions { @@ -89,6 +90,10 @@ export function createIdleMonitor(options: IdleMonitorOptions): IdleMonitor { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; + if (options.isTransportAlive && options.isTransportAlive()) { + schedule(); + return; + } options.onIdle(); }, options.timeoutMs); unrefHandle(timer); diff --git a/src/index.ts b/src/index.ts index dc4a514..e688e3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -549,6 +549,7 @@ async function main() { const idleMonitor = createIdleMonitor({ timeoutMs: getIdleShutdownMs(process.env.CONTEXTPLUS_IDLE_TIMEOUT_MS), onIdle: () => requestShutdown("idle-timeout", 0), + isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed, }); noteServerActivity = idleMonitor.touch; diff --git a/test/main/idle-timeout-spawn.test.mjs b/test/main/idle-timeout-spawn.test.mjs new file mode 100644 index 0000000..3085f7a --- /dev/null +++ b/test/main/idle-timeout-spawn.test.mjs @@ -0,0 +1,147 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + createIdleMonitor, +} from "../../build/core/process-lifecycle.js"; + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createTestScript(withFix) { + const buildPath = join(PROJECT_ROOT, "build/core/process-lifecycle.js").replace(/\\/g, "/"); + return ` + import { createIdleMonitor } from "file://${buildPath}"; + + const idleMonitor = createIdleMonitor({ + timeoutMs: 200, + onIdle: () => { + process.stderr.write("IDLE_SHUTDOWN\\n"); + process.exit(0); + }, + ${withFix ? 'isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed,' : ''} + }); + + process.stderr.write("STARTED\\n"); + const keepAlive = setInterval(() => {}, 1000); + setTimeout(() => { + idleMonitor.stop(); + clearInterval(keepAlive); + process.stderr.write("SURVIVED\\n"); + process.exit(0); + }, 1500); + `; +} + +function runHarness(withFix) { + return new Promise((resolve) => { + const tmpDir = mkdtempSync(join(tmpdir(), "cp-test-")); + const scriptPath = join(tmpDir, "harness.mjs"); + writeFileSync(scriptPath, createTestScript(withFix)); + + const child = spawn("node", [scriptPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", (d) => { stderr += d.toString(); }); + + child.on("exit", (code) => { + resolve({ code, stderr }); + }); + }); +} + +describe("idle-timeout transport-aware fix", () => { + it("does NOT fire onIdle when isTransportAlive returns true", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => true, + }); + await wait(80); + assert.equal(idleFired, 0, "onIdle should not fire when transport is alive"); + monitor.stop(); + }); + + it("fires onIdle when isTransportAlive returns false", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => false, + }); + await wait(80); + assert.equal(idleFired, 1, "onIdle should fire when transport is dead"); + monitor.stop(); + }); + + it("fires onIdle normally when no isTransportAlive provided (backward compat)", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + }); + await wait(80); + assert.equal(idleFired, 1, "onIdle should fire with no transport check"); + monitor.stop(); + }); + + it("reschedules then fires when transport dies after initial alive check", async () => { + let transportAlive = true; + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => transportAlive, + }); + await wait(50); + assert.equal(idleFired, 0, "should not fire while transport alive"); + transportAlive = false; + await wait(50); + assert.equal(idleFired, 1, "should fire after transport dies"); + monitor.stop(); + }); + + it("touch resets the idle timer even with transport check", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 40, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => false, + }); + await wait(20); + monitor.touch(); + await wait(20); + assert.equal(idleFired, 0, "touch should reset timer"); + await wait(30); + assert.equal(idleFired, 1, "should fire after full timeout post-touch"); + monitor.stop(); + }); + + it("spawn: without isTransportAlive, server exits on idle with stdin open", async () => { + const result = await runHarness(false); + assert.equal(result.code, 0); + assert.ok(result.stderr.includes("IDLE_SHUTDOWN"), + "server idle-shutdown with stdin open (no transport check)"); + assert.ok(!result.stderr.includes("SURVIVED"), + "server died before survival window"); + }); + + it("spawn: with isTransportAlive, server survives idle when stdin is open", async () => { + const result = await runHarness(true); + assert.equal(result.code, 0); + assert.ok(!result.stderr.includes("IDLE_SHUTDOWN"), + "server should NOT idle-shutdown when transport alive"); + assert.ok(result.stderr.includes("SURVIVED"), + "server should survive past idle timeout"); + }); +});