From 9da676d70944e754947536b3aa4e7eec0262dca7 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:57:59 +0200 Subject: [PATCH] Keep the daemon running when nobody reads its output (#6117) * fix(server): keep the daemon running when its stdout reader is gone `paseo daemon run` in the foreground hands its stdout and stderr to the supervisor. When the program that started it closed its end, as `paseo daemon run | head -1` does, the supervisor's next write failed with EPIPE and nothing handled the error. The supervisor exited 1, the worker lost its parent and exited too, and the CLI reported "Daemon failed to start". The supervisor now drops output nobody reads and keeps supervising the worker. daemon.log still records everything. Other stdout and stderr errors surface as before. Closes #6114 * test(server): fail on unexpected daemon.log read errors and check stderr reaches the log --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/scripts/supervisor.logging.test.ts | 42 ++++++++++++++++++- packages/server/scripts/supervisor.ts | 11 +++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index bdee5f6f7d..7589cb92a1 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -27,6 +27,8 @@ async function runSupervisorFixture(options: { fileSizeLimitBlocks?: number; /** Occupy the log path with a directory so the supervisor cannot open daemon.log. */ blockLogPath?: boolean; + /** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */ + closeOutput?: boolean; }): Promise<{ code: number | null; signal: NodeJS.Signals | null; @@ -86,6 +88,11 @@ async function runSupervisorFixture(options: { stdio: ["ignore", "pipe", "pipe"], }); + if (options.closeOutput) { + child.stdout.destroy(); + child.stderr.destroy(); + } + let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -116,10 +123,19 @@ async function runSupervisorFixture(options: { }); }); - const log = await readFile(logPath, "utf8"); + const log = await readLogIfWritten(logPath); return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr }; } +async function readLogIfWritten(logPath: string): Promise { + try { + return await readFile(logPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + describe("supervisor durable logging", () => { test("resolves rotation defaults", () => { const paseoHome = path.join(path.sep, "tmp", "paseo-home"); @@ -189,6 +205,30 @@ describe("supervisor durable logging", () => { expect(result.stderr).toContain('"worker-json-stderr"'); }); + test("keeps supervising the worker when nobody reads its stdout or stderr", async () => { + const result = await runSupervisorFixture({ + closeOutput: true, + workerSource: ` + process.on("message", (message) => { + if (message?.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.stdout.write("first stdout line\\n"); + process.stderr.write("first stderr line\\n"); + setTimeout(() => { + process.stdout.write("later stdout line\\n"); + process.send?.({ type: "paseo:shutdown", reason: "closed_output_probe" }); + }, 500); + setInterval(() => {}, 1000); + `, + }); + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.log).toContain("first stderr line\n"); + expect(result.log).toContain("later stdout line\n"); + expect(result.log).toContain('"reason":"closed_output_probe"'); + }); + test("preserves raw non-JSON stdout and stderr lines", async () => { const result = await runSupervisorFixture({ workerSource: ` diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 1f8c1374c6..ec56822619 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -166,6 +166,15 @@ function createDurableLog( }; } +// The program that started the daemon can close its end of stdout or stderr, as +// `paseo daemon run | head -1` does. Drop the output nobody reads and keep supervising +// the worker; daemon.log still records it. +function ignoreClosedOutput(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export function runSupervisor(options: SupervisorOptions): SupervisorController { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); @@ -482,6 +491,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController process.on("SIGINT", () => forwardSignal("SIGINT")); process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.stdout.on("error", ignoreClosedOutput); + process.stderr.on("error", ignoreClosedOutput); process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage);