fix(server): keep daemon workers alive across host sleep (#3235)
The worker watchdog treated wall-clock time spent in host sleep as evidence that the worker was unresponsive. Count consecutive observed heartbeat misses instead so sleep does not destroy live terminal sessions while genuinely stalled workers still restart after the same active observation window.
This commit is contained in:
@@ -10,9 +10,15 @@ import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
|||||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||||
|
|
||||||
|
interface SupervisorSuspension {
|
||||||
|
afterMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function runSupervisorFixture(options: {
|
async function runSupervisorFixture(options: {
|
||||||
workerSource: string;
|
workerSource: string;
|
||||||
restartOnCrash?: boolean;
|
restartOnCrash?: boolean;
|
||||||
|
suspension?: SupervisorSuspension;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
code: number | null;
|
code: number | null;
|
||||||
@@ -52,10 +58,23 @@ async function runSupervisorFixture(options: {
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
|
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
|
detached: options.suspension !== undefined,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.suspension) {
|
||||||
|
const supervisorPid = child.pid;
|
||||||
|
if (supervisorPid === undefined) {
|
||||||
|
throw new Error("Supervisor fixture did not start");
|
||||||
|
}
|
||||||
|
const { afterMs, durationMs } = options.suspension;
|
||||||
|
setTimeout(() => {
|
||||||
|
process.kill(-supervisorPid, "SIGSTOP");
|
||||||
|
setTimeout(() => process.kill(-supervisorPid, "SIGCONT"), durationMs);
|
||||||
|
}, afterMs);
|
||||||
|
}
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stdout.setEncoding("utf8");
|
child.stdout.setEncoding("utf8");
|
||||||
@@ -241,6 +260,40 @@ describe("supervisor durable logging", () => {
|
|||||||
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"keeps a healthy worker alive after the host resumes from sleep",
|
||||||
|
async () => {
|
||||||
|
const result = await runSupervisorFixture({
|
||||||
|
suspension: { afterMs: 1_000, durationMs: 16_000 },
|
||||||
|
timeoutMs: 25_000,
|
||||||
|
workerSource: `
|
||||||
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const marker = process.argv[1] + ".started";
|
||||||
|
if (!existsSync(marker)) {
|
||||||
|
writeFileSync(marker, "started");
|
||||||
|
setInterval(() => {
|
||||||
|
process.send?.({ type: "paseo:worker-heartbeat" });
|
||||||
|
}, 250);
|
||||||
|
setTimeout(() => {
|
||||||
|
process.send?.({ type: "paseo:shutdown", reason: "sleep_resume_test_complete" });
|
||||||
|
}, 17_000);
|
||||||
|
} else {
|
||||||
|
process.send?.({ type: "paseo:shutdown", reason: "unexpected_sleep_restart" });
|
||||||
|
}
|
||||||
|
setInterval(() => {}, 1_000);
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.code).toBe(0);
|
||||||
|
expect(result.signal).toBeNull();
|
||||||
|
expect(result.log).toContain('"reason":"sleep_resume_test_complete"');
|
||||||
|
expect(result.log).not.toContain('"reason":"unexpected_sleep_restart"');
|
||||||
|
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
||||||
|
},
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
|
||||||
test.skipIf(isPlatform("win32"))(
|
test.skipIf(isPlatform("win32"))(
|
||||||
"forces shutdown when a worker ignores SIGTERM",
|
"forces shutdown when a worker ignores SIGTERM",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createStream as createRotatingFileStream } from "rotating-file-stream";
|
|||||||
import { signalProcessTree } from "../src/utils/tree-kill.js";
|
import { signalProcessTree } from "../src/utils/tree-kill.js";
|
||||||
|
|
||||||
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
|
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
|
||||||
const WORKER_HEARTBEAT_TIMEOUT_MS = 15_000;
|
const WORKER_HEARTBEAT_MISSED_CHECK_LIMIT = 15;
|
||||||
const WORKER_TERMINATION_GRACE_MS = 10_000;
|
const WORKER_TERMINATION_GRACE_MS = 10_000;
|
||||||
|
|
||||||
interface SupervisorLogFileOptions {
|
interface SupervisorLogFileOptions {
|
||||||
@@ -250,7 +250,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentChild = child;
|
const currentChild = child;
|
||||||
let lastWorkerHeartbeatAt = Date.now();
|
let missedWorkerHeartbeatChecks = 0;
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
|
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
|
||||||
if (currentChild.connected) {
|
if (currentChild.connected) {
|
||||||
@@ -271,12 +271,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
|||||||
if (child !== currentChild || restarting || shuttingDown) {
|
if (child !== currentChild || restarting || shuttingDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const heartbeatAgeMs = Date.now() - lastWorkerHeartbeatAt;
|
missedWorkerHeartbeatChecks += 1;
|
||||||
if (heartbeatAgeMs < WORKER_HEARTBEAT_TIMEOUT_MS) {
|
if (missedWorkerHeartbeatChecks < WORKER_HEARTBEAT_MISSED_CHECK_LIMIT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeLifecycleLog("Worker heartbeat timed out; restarting worker", {
|
writeLifecycleLog("Worker heartbeat timed out; restarting worker", {
|
||||||
heartbeatAgeMs,
|
missedHeartbeatChecks: missedWorkerHeartbeatChecks,
|
||||||
supervisorPid: process.pid,
|
supervisorPid: process.pid,
|
||||||
workerPid: currentChild.pid ?? null,
|
workerPid: currentChild.pid ?? null,
|
||||||
});
|
});
|
||||||
@@ -300,7 +300,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
|||||||
|
|
||||||
child.on("message", (msg: unknown) => {
|
child.on("message", (msg: unknown) => {
|
||||||
if (isWorkerHeartbeatMessage(msg)) {
|
if (isWorkerHeartbeatMessage(msg)) {
|
||||||
lastWorkerHeartbeatAt = Date.now();
|
missedWorkerHeartbeatChecks = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lifecycleMessage = parseLifecycleMessage(msg);
|
const lifecycleMessage = parseLifecycleMessage(msg);
|
||||||
|
|||||||
Reference in New Issue
Block a user