mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+36
-5
@@ -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 {
|
||||
|
||||
@@ -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<number | null>((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: "" });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user