Compare commits

...
1 Commits
Author SHA1 Message Date
paseo-bot[bot] 605c3f3832 fix(desktop): keep running when launcher console pipes close
Desktop logging reaches inherited stdout and stderr before the CLI guards run. A launcher that closes its output reader raised an uncaught EPIPE dialog. Handle closed console pipes at desktop startup while preserving console logging and surfacing other stream errors.
2026-10-14 14:43:06 +02:00
3 changed files with 102 additions and 0 deletions
@@ -0,0 +1,88 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url));
type Output = "stdout" | "stderr";
async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) {
const child = spawn(
process.execPath,
[
"--import",
"tsx",
"-e",
`
const { protectConsoleStreams } = require(${JSON.stringify(modulePath)});
protectConsoleStreams();
process.send("ready");
process.once("message", () => {
if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1);
console.info("desktop info");
console.warn("desktop warning");
setImmediate(() => { process.send("alive"); process.disconnect(); });
});
`,
],
{
cwd: fileURLToPath(new URL(".", import.meta.url)),
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")),
},
);
let stdout = "";
let stderr = "";
let alive = false;
child.stdout.setEncoding("utf8").on("data", (text: string) => {
stdout += text;
});
child.stderr.setEncoding("utf8").on("data", (text: string) => {
stderr += text;
});
child.on("message", (message) => {
if (message === "ready") {
if (input.closed) child[input.closed].destroy();
child.send("write");
}
if (message === "alive") alive = true;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
return { code, stdout, stderr, alive };
}
describe("desktop console streams", () => {
for (const stream of ["stdout", "stderr"] as const) {
it(`keeps running when the launcher closes ${stream}`, async () => {
const result = await writeToConsole({ closed: stream });
expect(result.code, result.stderr).toBe(0);
expect(result.alive).toBe(true);
expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe(
stream === "stdout" ? "desktop warning\n" : "desktop info\n",
);
});
}
// Windows stdout uses a native handle rather than the POSIX fd closed here.
it.skipIf(process.platform === "win32")(
"does not suppress a console failure other than a closed pipe",
async () => {
const result = await writeToConsole({ invalidStdout: true });
expect(result.code).toBe(1);
expect(result.alive).toBe(false);
expect(result.stderr).toContain("EBADF");
},
);
it("preserves logging while the console has readers", async () => {
expect(await writeToConsole()).toEqual({
code: 0,
stdout: "desktop info\n",
stderr: "desktop warning\n",
alive: true,
});
});
});
@@ -0,0 +1,11 @@
function ignoreClosedConsole(error: NodeJS.ErrnoException): void {
if (error.code !== "EPIPE") {
throw error;
}
}
/** Keep desktop logging independent of the program that launched its console. */
export function protectConsoleStreams(): void {
process.stdout.on("error", ignoreClosedConsole);
process.stderr.on("error", ignoreClosedConsole);
}
+3
View File
@@ -1,3 +1,6 @@
import { protectConsoleStreams } from "./diagnostics/console-streams.js";
protectConsoleStreams();
process.emitWarning = (() => {}) as typeof process.emitWarning;
import log from "electron-log/main";