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>
This commit is contained in:
paseo-bot[bot]
2026-10-05 15:57:59 +02:00
committed by GitHub
parent dfb8a1a2bc
commit 9da676d709
2 changed files with 52 additions and 1 deletions
@@ -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<string> {
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: `
+11
View File
@@ -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);