From 605c3f38328cfdda467de11e4cdf448d623403ff Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:24:52 +0200 Subject: [PATCH] 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. --- .../src/diagnostics/console-streams.test.ts | 88 +++++++++++++++++++ .../src/diagnostics/console-streams.ts | 11 +++ packages/desktop/src/main.ts | 3 + 3 files changed, 102 insertions(+) create mode 100644 packages/desktop/src/diagnostics/console-streams.test.ts create mode 100644 packages/desktop/src/diagnostics/console-streams.ts diff --git a/packages/desktop/src/diagnostics/console-streams.test.ts b/packages/desktop/src/diagnostics/console-streams.test.ts new file mode 100644 index 0000000000..14ad337b7e --- /dev/null +++ b/packages/desktop/src/diagnostics/console-streams.test.ts @@ -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((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, + }); + }); +}); diff --git a/packages/desktop/src/diagnostics/console-streams.ts b/packages/desktop/src/diagnostics/console-streams.ts new file mode 100644 index 0000000000..686505bb40 --- /dev/null +++ b/packages/desktop/src/diagnostics/console-streams.ts @@ -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); +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 9acf329216..072f96a26d 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,3 +1,6 @@ +import { protectConsoleStreams } from "./diagnostics/console-streams.js"; +protectConsoleStreams(); + process.emitWarning = (() => {}) as typeof process.emitWarning; import log from "electron-log/main";