test: harden daemon lifecycle and e2e reliability across app/cli/server

This commit is contained in:
Mohamed Boudra
2026-02-25 10:21:46 +07:00
parent 094ce49d4f
commit a060ae1256
98 changed files with 5716 additions and 1771 deletions
+56 -14
View File
@@ -1,5 +1,8 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { loadConfig } from "../src/server/config.js";
import { acquirePidLock, PidLockError, releasePidLock } from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
@@ -52,20 +55,59 @@ function resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerEnv: NodeJS.ProcessEnv = {
...process.env,
PASEO_PID_LOCK_MODE: "external",
};
applySherpaLoaderEnv(process.env);
applySherpaLoaderEnv(workerEnv);
runSupervisor({
name: "DaemonRunner",
startupMessage: config.devMode
? "Starting daemon worker (dev mode, crash restarts enabled)"
: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv: process.env,
workerExecArgv: resolveWorkerExecArgv(workerEntry),
restartOnCrash: config.devMode,
shutdownReasons: ["cli_shutdown"],
const paseoHome = resolvePaseoHome(workerEnv);
const daemonConfig = loadConfig(paseoHome, { env: workerEnv });
try {
await acquirePidLock(paseoHome, daemonConfig.listen, {
ownerPid: process.pid,
});
} catch (error) {
if (error instanceof PidLockError) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
return;
}
throw error;
}
let lockReleased = false;
const releaseLock = async (): Promise<void> => {
if (lockReleased) {
return;
}
lockReleased = true;
await releasePidLock(paseoHome, {
ownerPid: process.pid,
});
};
runSupervisor({
name: "DaemonRunner",
startupMessage: config.devMode
? "Starting daemon worker (dev mode, crash restarts enabled)"
: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv,
workerExecArgv: resolveWorkerExecArgv(workerEntry),
restartOnCrash: config.devMode,
onSupervisorExit: releaseLock,
});
}
void main().catch((error) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
+14 -16
View File
@@ -1,26 +1,24 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import dotenv from "dotenv";
import { runSupervisor } from "./supervisor.js";
dotenv.config({
path: fileURLToPath(new URL("../.env", import.meta.url)),
quiet: true,
});
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
if (!existsSync(WORKER_ENTRY)) {
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
const result = spawnSync(
process.execPath,
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
{
stdio: "inherit",
env: process.env,
}
);
if (result.error) {
throw result.error;
}
runSupervisor({
name: "DevRunner",
startupMessage: "Starting server worker (crash restarts enabled)",
resolveWorkerEntry: () => WORKER_ENTRY,
workerArgs: process.argv.slice(2),
workerEnv: process.env,
// Always run worker with tsx so dev server uses TypeScript sources directly.
workerExecArgv: ["--import", "tsx"],
restartOnCrash: true,
shutdownReasons: ["cli_shutdown"],
});
process.exit(result.status ?? 1);
@@ -5,7 +5,7 @@ import { z } from "zod";
const server = new McpServer({ name: "paseo-test-mcp", version: "1.0.0" });
server.tool(
"echo",
"paseo_roundtrip_text",
{ text: z.string() },
async ({ text }) => ({
content: [{ type: "text", text: `ECHO:${text}` }],
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import { describe, expect, test } from 'vitest'
describe('supervision parity', () => {
test('has exactly one runtime callsite for runSupervisor', () => {
const daemonRunner = readFileSync(new URL('./daemon-runner.ts', import.meta.url), 'utf8')
const devRunner = readFileSync(new URL('./dev-runner.ts', import.meta.url), 'utf8')
const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length
const devRunnerCalls = (devRunner.match(/\brunSupervisor\s*\(/g) ?? []).length
expect(daemonRunnerCalls + devRunnerCalls).toBe(1)
})
})
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { describe, expect, test } from 'vitest'
describe('supervisor lifecycle intents', () => {
test('uses explicit shutdown and restart IPC intents', () => {
const source = readFileSync(new URL('./supervisor.ts', import.meta.url), 'utf8')
const legacyShutdownReason = ['cli', 'shutdown'].join('_')
expect(source).toContain('"paseo:shutdown"')
expect(source).toContain('"paseo:restart"')
expect(source).not.toContain(legacyShutdownReason)
})
})
+50 -20
View File
@@ -1,9 +1,13 @@
import { fork, type ChildProcess } from "child_process";
type RestartMessage = {
type: "paseo:restart";
reason?: string;
};
type WorkerLifecycleMessage =
| {
type: "paseo:shutdown";
}
| {
type: "paseo:restart";
reason?: string;
};
type SupervisorOptions = {
name: string;
@@ -13,24 +17,32 @@ type SupervisorOptions = {
workerEnv?: NodeJS.ProcessEnv;
workerExecArgv?: string[];
restartOnCrash?: boolean;
shutdownReasons?: string[];
onSupervisorExit?: () => Promise<void> | void;
};
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
return signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
}
function isRestartMessage(msg: unknown): msg is RestartMessage {
return (
typeof msg === "object" &&
msg !== null &&
"type" in msg &&
(msg as { type?: unknown }).type === "paseo:restart"
);
function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
if (typeof msg !== "object" || msg === null || !("type" in msg)) {
return null;
}
const type = (msg as { type?: unknown }).type;
if (type === "paseo:shutdown") {
return { type: "paseo:shutdown" };
}
if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason;
return {
type: "paseo:restart",
...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}),
};
}
return null;
}
export function runSupervisor(options: SupervisorOptions): void {
const shutdownReasons = new Set(options.shutdownReasons ?? ["cli_shutdown"]);
const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2);
const workerEnv = options.workerEnv ?? process.env;
@@ -39,11 +51,27 @@ export function runSupervisor(options: SupervisorOptions): void {
let child: ChildProcess | null = null;
let restarting = false;
let shuttingDown = false;
let exiting = false;
const log = (message: string): void => {
process.stderr.write(`[${options.name}] ${message}\n`);
};
const exitSupervisor = (code: number): void => {
if (exiting) {
return;
}
exiting = true;
Promise.resolve(options.onSupervisorExit?.())
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
log(`Supervisor exit cleanup failed: ${message}`);
})
.finally(() => {
process.exit(code);
});
};
const spawnWorker = () => {
let workerEntry: string;
try {
@@ -52,7 +80,7 @@ export function runSupervisor(options: SupervisorOptions): void {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Failed to resolve worker entry: ${message}`);
process.exit(1);
exitSupervisor(1);
return;
}
@@ -63,12 +91,13 @@ export function runSupervisor(options: SupervisorOptions): void {
});
child.on("message", (msg: unknown) => {
if (!isRestartMessage(msg)) {
const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) {
return;
}
if (msg.reason && shutdownReasons.has(msg.reason)) {
requestShutdown(`Shutdown requested by worker (${msg.reason})`);
if (lifecycleMessage.type === "paseo:shutdown") {
requestShutdown("Shutdown requested by worker");
return;
}
@@ -80,7 +109,8 @@ export function runSupervisor(options: SupervisorOptions): void {
if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
process.exit(0);
exitSupervisor(0);
return;
}
if (restarting || (restartOnCrash && code !== 0 && code !== null)) {
@@ -91,7 +121,7 @@ export function runSupervisor(options: SupervisorOptions): void {
}
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
process.exit(typeof code === "number" ? code : 0);
exitSupervisor(typeof code === "number" ? code : 0);
});
};
@@ -112,7 +142,7 @@ export function runSupervisor(options: SupervisorOptions): void {
restarting = false;
log(`${reason}. Stopping worker...`);
if (!child) {
process.exit(0);
exitSupervisor(0);
return;
}
child.kill("SIGTERM");