Files
paseo/packages/server/scripts/supervisor.logging.test.ts
Mohamed Boudra e63a971968 Make daemon shutdowns easier to diagnose (#1790)
* fix(daemon): log stop reasons and client identity

Record websocket client identity, process memory/uptime, and shutdown reasons across CLI, desktop, supervisor, and worker paths so daemon drops can be traced from the triggering client to worker termination.

* fix(daemon): keep shutdown diagnostics in sync

Test drift: supervisor and relay tests still asserted the old log text and metadata shape after shutdown diagnostics started logging structured reasons and relay connection ids. Update those assertions to the new diagnostic contract.

Also centralize client lifecycle reason normalization and derive desktop daemon stop reasons from one tuple so future changes cannot silently drift.
2026-06-29 23:31:11 +08:00

207 lines
6.4 KiB
TypeScript

import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
log: string;
stdout: string;
stderr: string;
}> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-log-"));
const logPath = path.join(tempDir, "daemon.log");
const workerPath = path.join(tempDir, "worker.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(workerPath, options.workerSource);
await writeFile(
runnerPath,
`
import { runSupervisor } from ${JSON.stringify(pathToFileURL(supervisorPath).href)};
runSupervisor({
name: "TestSupervisor",
startupMessage: "starting fixture",
resolveWorkerEntry: () => ${JSON.stringify(workerPath)},
workerArgs: [],
workerEnv: process.env,
workerExecArgv: [],
restartOnCrash: ${JSON.stringify(options.restartOnCrash ?? false)},
logFile: {
path: ${JSON.stringify(logPath)},
rotate: { maxSize: "1m", maxFiles: 2 },
},
});
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const { code, signal } = await new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("supervisor fixture timed out"));
}, 10000);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (exitCode, exitSignal) => {
clearTimeout(timeout);
resolve({ code: exitCode, signal: exitSignal });
});
});
const log = await readFile(logPath, "utf8");
return { code, signal, log, stdout, stderr };
}
describe("supervisor durable logging", () => {
test("resolves rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(paseoHome, {}, {});
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "10m", maxFiles: 3 },
});
});
test("lets persisted rotation override env rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{
log: {
file: {
path: "logs/daemon.log",
rotate: { maxSize: "25m", maxFiles: 4 },
},
},
},
{
PASEO_LOG_ROTATE_SIZE: "200m",
PASEO_LOG_ROTATE_COUNT: "12",
},
);
expect(logFile).toEqual({
path: path.resolve(paseoHome, "logs", "daemon.log"),
rotate: { maxSize: "25m", maxFiles: 4 },
});
});
test("uses env rotation when persisted rotation is absent", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{},
{
PASEO_LOG_ROTATE_SIZE: "50m",
PASEO_LOG_ROTATE_COUNT: "8",
},
);
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "50m", maxFiles: 8 },
});
});
test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('{"level":30,"msg":"worker-json-stdout"}\\n');
process.stderr.write('{"level":50,"msg":"worker-json-stderr"}\\n');
process.exit(0);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"worker-json-stdout"');
expect(result.log).toContain('"worker-json-stderr"');
expect(result.stdout).toContain('"worker-json-stdout"');
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('raw stdout line\\n');
process.stderr.write('raw stderr line\\n');
process.exit(0);
`,
});
expect(result.log).toContain("raw stdout line\n");
expect(result.log).toContain("raw stderr line\n");
});
test("logs the worker shutdown reason before signaling the worker", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.send?.({ type: "paseo:shutdown", reason: "client_shutdown_rpc" });
setInterval(() => {}, 1000);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker requested shutdown"');
expect(result.log).toContain('"reason":"client_shutdown_rpc"');
expect(result.log).toContain('"msg":"Supervisor sending signal to worker"');
expect(result.log).toContain('"signal":"SIGTERM"');
expect(result.log).toContain('"workerPid":');
});
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
test.skipIf(isPlatform("win32"))(
"logs worker signal exits even when the worker cannot log",
async () => {
const result = await runSupervisorFixture({
workerSource: `
process.kill(process.pid, "SIGKILL");
`,
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
},
);
});