test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user