From 5e8f7c536ecda23abd88b03a836ef67e8a8f96ef Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 18:20:19 -0700 Subject: [PATCH] fix(cli): stop quietly when the stdout reader closes early (#3781) * fix(cli): stop quietly when the stdout reader closes early `rea ... | head` crashed once head closed the pipe. TOON and YAML output raised an unhandled EPIPE 'error' event with a Node stack trace. JSON output reported {"code":"UNKNOWN","message":"write EPIPE"} on stderr. Both exited 1, as if the analysis had failed. The CLI entry point owns process.stdout, so it now treats EPIPE as the reader declining the rest of the output. It stops writing, suppresses the failure report and exits 141 like a SIGPIPE-terminated writer, also when Incur exits through its own failure path. Other stdout errors are handled as before. Co-Authored-By: Claude Opus 5.5 * fix(cli): guard mcp doctor output and cover midstream reader closure The closed-reader guard was only installed for ordinary CLI commands. As a result `rea mcp doctor | head` still crashed on EPIPE. The guard now lives in one entry-point function shared by both CLI branches; MCP stdio keeps its own lifecycle. The tests now also close the reader after the first chunk of a 4 MiB streamed JSON result, so later writes in writeJsonOutput are covered. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/cli.md | 5 ++ scripts/rea.mjs | 41 ++++++++++-- tests/boundary/cli/closedStdout.test.ts | 84 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 tests/boundary/cli/closedStdout.test.ts diff --git a/docs/cli.md b/docs/cli.md index 736c0de377..e475964fd2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -352,6 +352,11 @@ command, objdump, Rizin and GDB. In these paths, a returned structured signal-derived status. This describes the current source routing; it is not a claim that every command or provider runtime has been independently exercised. +When the reader of piped output closes early, as `rea ... | head` does, the +CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a +SIGPIPE-terminated writer). The operation itself may have completed; only its +output was declined. + `inspect-artifact` also returns a structured cancelled result, but its direct analysis cancellation path does not set a signal-derived exit status, so it exits `1`. Setup has its own contract: cancellation exits `0`. Treat the diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 75ee552c5a..3db1cbf552 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -14,6 +14,10 @@ process.env.REA_PACKAGE_VERSION = packageJson.version; const isMcpMode = args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp"); const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor"; +/** Exit status of a writer terminated by SIGPIPE (128 + 13). */ +const SIGPIPE_EXIT_STATUS = 141; +// Set once the reader of CLI output has closed the pipe. +let stdoutClosed = false; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // Windows home lookup throws for an empty USERPROFILE. Dependencies resolve @@ -55,13 +59,14 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { const { runEntrypoint } = await import("../dist/mcp/main.js"); await runEntrypoint(); } else if (isMcpDoctorMode) { + guardClosedStdout(); const { runProductionMcpDoctorCli } = await import("../dist/cli-runtime/mcpDoctor.js"); const result = await runProductionMcpDoctorCli(args.slice(2), { dispatcherPath: fileURLToPath(import.meta.url), packageRoot, }); - process.stdout.write(result.output); + writeStdout(result.output); process.exitCode = result.exitCode; } else { const { cliConfigurationError, createCli } = @@ -73,13 +78,14 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { renderEmptyFilteredCliOutput, validateCliOutputArguments, } = await import("../dist/cli-runtime/cliOutput.js"); + guardClosedStdout(); const outputArguments = validateCliOutputArguments(args); const configurationError = cliConfigurationError(process.env); if (configurationError !== undefined) { process.stderr.write(`${configurationError}\n`); process.exitCode = 1; } else if (!outputArguments.ok) { - process.stdout.write(renderCliOutputArgumentError(outputArguments)); + writeStdout(renderCliOutputArgumentError(outputArguments)); process.exitCode = 1; } else { let wroteOutput = false; @@ -87,18 +93,43 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { await createCli(process.env, resultOutput).serve(args, { stdout: (output) => { if (resultOutput?.handled) { - if (resultOutput.failed) process.stderr.write(output); + if (resultOutput.failed && !stdoutClosed) + process.stderr.write(output); return; } if (output.length > 0) wroteOutput = true; - process.stdout.write(output); + writeStdout(output); }, + // A command failure caused by the closed pipe keeps the pipe's status. + exit: (code) => process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : code), }); if (!wroteOutput && !resultOutput?.handled) - process.stdout.write(renderEmptyFilteredCliOutput(args) ?? ""); + writeStdout(renderEmptyFilteredCliOutput(args) ?? ""); } } +/** + * A reader that closes the pipe early, as `rea ... | head` does, has declined + * the rest of the output. Stop writing without reporting a failure, and exit + * with the status a SIGPIPE-terminated writer reports. MCP stdio keeps its own + * client lifecycle. + */ +function guardClosedStdout() { + process.stdout.on("error", (cause) => { + if (cause?.code === "EPIPE") { + stdoutClosed = true; + process.exitCode = SIGPIPE_EXIT_STATUS; + return; + } + // Other failures stay unhandled unless a pending JSON write reports them. + if (process.stdout.listenerCount("error") === 1) throw cause; + }); +} + +function writeStdout(output) { + if (!stdoutClosed) process.stdout.write(output); +} + async function compiledRuntimeExists(paths) { for (const path of paths) { try { diff --git a/tests/boundary/cli/closedStdout.test.ts b/tests/boundary/cli/closedStdout.test.ts new file mode 100644 index 0000000000..8227d0b16a --- /dev/null +++ b/tests/boundary/cli/closedStdout.test.ts @@ -0,0 +1,84 @@ +import { join, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { describe, expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import type { TestProcesses } from "../../support/process/processFixture.js"; + +/** Run the CLI, closing its stdout reader after `bytes` (0: before any write). */ +const runWithClosedReader = async ( + processes: TestProcesses, + arguments_: readonly string[], + bytes: number, +) => { + const child = processes.spawn( + process.execPath, + [resolve("scripts/rea.mjs"), ...arguments_], + { env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" } }, + ); + let received = 0; + if (bytes === 0) child.stdout.destroy(); + else + child.stdout.on("data", (chunk: Buffer) => { + received += chunk.length; + if (received >= bytes) child.stdout.destroy(); + }); + child.stdin.end(); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => (stderr += chunk)); + const code = await new Promise((done) => + child.once("close", done), + ); + return { code, stderr, received }; +}; + +// Windows reports a closed pipe reader through a different error path. +describe.skipIf(process.platform === "win32")( + "CLI with a closed stdout", + () => { + cliTest.for([ + ["toon", ["capabilities", "--format", "toon"]], + ["json", ["capabilities", "--format", "json"]], + ["mcp doctor", ["mcp", "doctor", "--json"]], + ] as const)( + "stops quietly with the SIGPIPE status when the reader closes first ($0)", + async ([, arguments_], { processes }) => { + const result = await runWithClosedReader(processes, arguments_, 0); + expect(result).toMatchObject({ code: 141, stderr: "" }); + }, + ); + + cliTest( + "stops quietly when the reader closes during streamed JSON", + async ({ processes }) => { + const directory = await createTestTempDirectory("rea-closed-stdout-"); + const database = join(directory, "rows.db"); + const writer = new DatabaseSync(database); + writer.exec("CREATE TABLE t (v TEXT)"); + const insert = writer.prepare("INSERT INTO t VALUES (?)"); + // 4 MiB of rows outlasts any pipe buffer, so writes continue after close. + for (let row = 0; row < 64; row += 1) insert.run("x".repeat(65_536)); + writer.close(); + const result = await runWithClosedReader( + processes, + [ + "inspect-sqlite-database", + database, + "--table", + "t", + "--row-limit", + "64", + "--format", + "json", + ], + 1, + ); + expect(result.received).toBeGreaterThan(0); + expect(result).toMatchObject({ code: 141, stderr: "" }); + }, + ); + }, +);