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: "" }); + }, + ); + }, +);