Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/core/process-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface IdleMonitor {
export interface IdleMonitorOptions {
timeoutMs: number;
onIdle: () => void;
isTransportAlive?: () => boolean;
}

export interface ParentMonitorOptions {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
147 changes: 147 additions & 0 deletions test/main/idle-timeout-spawn.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});