From c2eebcaab4edc44a678b2205382a86ab65b52251 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:18:28 -0400 Subject: [PATCH] fix(cli): exit cleanly instead of crashing on EPIPE (#864) Piping quicktype's output into a program that closes the pipe early (e.g. `... | head -n 4`) left an unhandled 'error' event on process.stdout/stderr. Node treated the resulting EPIPE as an uncaught exception, printing a raw stack trace and exiting with code 1 instead of exiting quietly like well-behaved Unix CLIs do. Add EPIPE error handlers on process.stdout/stderr in the CLI entry point that exit(0) on EPIPE and rethrow any other stream error. Co-Authored-By: gpt-5.6-sol via pi --- src/index.ts | 11 +++++++ test/unit/cli-epipe.test.ts | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 test/unit/cli-epipe.test.ts diff --git a/src/index.ts b/src/index.ts index 6ae510e25b..7383ef03bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1272,6 +1272,17 @@ export async function main( } if (require.main === module) { + const exitOnEPIPE = (error: NodeJS.ErrnoException): void => { + if (error.code === "EPIPE") { + process.exit(0); + } + + throw error; + }; + + process.stdout.on("error", exitOnEPIPE); + process.stderr.on("error", exitOnEPIPE); + main(process.argv.slice(2)).catch((e) => { if (e instanceof Error) { console.error(`Error: ${e.message}.`); diff --git a/test/unit/cli-epipe.test.ts b/test/unit/cli-epipe.test.ts new file mode 100644 index 0000000000..460dbe36eb --- /dev/null +++ b/test/unit/cli-epipe.test.ts @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; +import * as path from "node:path"; + +import { describe, expect, test } from "vitest"; + +const repositoryRoot = process.cwd(); + +function runWithClosedStdout(): Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + stderr: string; +}> { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + path.join(repositoryRoot, "dist", "index.js"), + "--src-lang", + "schema", + "--lang", + "swift", + "--just-types", + path.join( + repositoryRoot, + "test", + "inputs", + "schema", + "vega-lite.schema", + ), + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let stderr = ""; + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code, signal) => { + resolve({ code, signal, stderr }); + }); + + child.stdout.destroy(); + }); +} + +describe("CLI output", () => { + test("exits successfully when stdout is closed early", async () => { + const result = await runWithClosedStdout(); + + expect(result.signal).toBeNull(); + expect(result.code).toBe(0); + expect(result.stderr).not.toContain("Error: write EPIPE"); + expect(result.stderr).not.toContain("Unhandled 'error' event"); + }); +});