Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-json-pipe-truncation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@arethetypeswrong/cli": patch
---

Fix `--format json` output being truncated when piped and the exit code is non-zero. `write` now waits for stdout to actually flush, and the JSON branch sets `process.exitCode` instead of calling `process.exit`, which could terminate before a buffered pipe drained.
7 changes: 3 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,9 @@ particularly ESM-related module resolution issues.`,
await unlink(deleteTgz);
}

const exitCode = getExitCode(analysis, opts);
if (exitCode) {
process.exit(exitCode);
}
// Set the exit code and return rather than process.exit(), which would
// terminate before a buffered stdout pipe finishes flushing (see #279).
process.exitCode = getExitCode(analysis, opts);

return;
}
Expand Down
28 changes: 7 additions & 21 deletions packages/cli/src/write.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
import { Readable, Writable } from "node:stream";
import type { Writable } from "node:stream";

// JSON output is often longer than 64 kb, so we need to use streams to write it to stdout
// in order to avoid truncation when piping to other commands.
// JSON output is often longer than 64 kb. Wait for the write to actually flush
// (the callback fires once the chunk has been handled, not merely buffered) so
// the output isn't truncated when stdout is a pipe.
export async function write(data: string, out: Writable): Promise<void> {
return new Promise((resolve, reject) => {
const stream = new Readable({
read() {
this.push(data);
this.push("\n");
this.push(null);
},
});

stream.on("data", (chunk) => {
out.write(chunk);
});

stream.on("end", () => {
resolve();
});

out.on("error", (err) => {
reject(err);
out.write(data + "\n", (err) => {
if (err) reject(err);
else resolve();
});
});
}
37 changes: 37 additions & 0 deletions packages/cli/test/write.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert";
import path from "node:path";
import { Writable } from "node:stream";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

const directoryPath = path.dirname(fileURLToPath(import.meta.url));

// Regression test for #279: `write` must resolve only once the writable has
// actually handled the data, not merely when the source string is exhausted —
// otherwise a buffered stdout pipe can be truncated on exit.
test("write resolves only after the writable has flushed the data", async () => {
const { write } = await import(path.resolve(directoryPath, "../../dist/write.js"));

const chunks: string[] = [];
let release: (() => void) | undefined;
const out = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
// Defer completion to simulate a pipe that hasn't drained yet.
release = () => callback();
},
});

let resolved = false;
const promise = write("hello", out).then(() => {
resolved = true;
});

await new Promise((r) => setImmediate(r));
assert.equal(resolved, false, "write() resolved before the writable flushed");

release!();
await promise;
assert.equal(resolved, true);
assert.equal(chunks.join(""), "hello\n");
});