Skip to content

--format json still truncated through a pipe when the exit code is non-zero (#184) #279

Description

@williamthorsen

Summary

--format json output is still truncated when stdout is a pipe, on @arethetypeswrong/cli 0.18.4. This is #184, which was closed but remains reproducible.

The truncation is governed by the exit code, not the payload size: an identically-sized payload from a passing package traverses the same pipe intact. Only a run that reports problems (exit code ≠ 0) truncates.

That detail matters for why this was believed fixed: the streaming write() helper appears to have been introduced to address #184 — its comment says as much — but it doesn't achieve the flush it was written for, and verifying it against a clean package would have shown it working.

Reproduction

Two packages, 8 entry points each, near-identical JSON payloads. The only difference: one has findings.

  • passing: each entry point is valid ESM
  • failing: same shape, but each entry point's .js body is module.exports.value = 1; (CJS syntax in an ESM package)
attw pkg.tgz --profile esm-only --format json | cat   # through a pipe
attw pkg.tgz --profile esm-only --format json > out   # to a file
exit to file through pipe
PASSING 0 152,795 B 152,795 B parses
FAILING 1 157,642 B 65,536 B parse fails

65,536 is exactly the pipe buffer capacity (the reporter of #184 saw 98,304 — the figure varies by platform, the mechanism doesn't).

Root cause

write() resolves on the source Readable's end event, not on the destination draining:

export async function write(data, out) {
  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);   // <- return value ignored; may merely buffer
    });
    stream.on("end", () => {
      resolve();          // <- resolves when the SOURCE is exhausted
    });
    out.on("error", (err) => reject(err));
  });
}

out.write() on a pipe is asynchronous — it returns false once past the high-water mark, leaving the data queued. end fires when the Readable has been fully consumed, which says nothing about whether stdout has flushed. So write() resolves with bytes still pending. Functionally this is equivalent to a bare out.write(data); the stream indirection doesn't add a flush guarantee.

Then, in the JSON branch of index.ts:

await write(JSON.stringify(result, undefined, 2), out);
// ...
const exitCode = getExitCode(analysis, opts);
if (exitCode) {
  process.exit(exitCode);   // <- discards the still-buffered tail
}
return;

process.exit() terminates without flushing pending async writes.

This accounts for every observed behavior:

  • Non-zero exitprocess.exit() → truncated.
  • Zero exit → falls through to return → Node drains stdout on natural exit → intact.
  • Non-JSON formats → set process.exitCode rather than calling process.exit() → also drain. Which is why only --format json is affected.

Impact

Any consumer reading attw --format json through a pipe gets invalid JSON precisely for the packages that have findings and more than a handful of entry points. This includes child_process.spawnSync/execSync, which capture output via pipes — so it hits programmatic consumers, not just interactive | jq.

The failure mode is unkind: it strikes only when there are problems and the package is large, so fixtures that are small or clean will pass while real-world usage breaks.

Suggested fix

Either resolves it; they're complementary.

1. Don't process.exit() on the JSON path — mirror what the human-readable path already does:

process.exitCode = exitCode;
return;

Smallest change, consistent with the existing branch, and sufficient on its own.

2. Make write() actually await the flush — repairs the primitive for every caller:

export function write(data, out) {
  return new Promise((resolve, reject) => {
    out.write(data + "\n", (err) => (err ? reject(err) : resolve()));
  });
}

stream.write(chunk, cb) invokes cb once the chunk has been flushed, so awaiting it before exiting is sufficient.

Environment

  • @arethetypeswrong/cli 0.18.4
  • Node v24.14.1
  • macOS (Darwin 25.5.0)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions