Skip to content

Commit b731912

Browse files
authored
Merge pull request #21 from overtimepog/fix/idle-timeout-transport-check
fix: check transport liveness before idle-timeout shutdown
2 parents d2f44d3 + f43a8a5 commit b731912

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)