Skip to content

[Hackathon] fix(cli): EPIPE guard forces exit 0 and overrides a failing verdict (pipefail CI goes green) #489

Description

@crypticsaiyan

Verified against (commit SHA or released version)

commit 988c035 (v0.14.0)

Discord Username / User ID

uisupersaiyan3

What does this improvement do?

When stdout's reader closes early, installBrokenPipeGuard calls exit(0) (interrupt.ts:361-370); in an armed lifetime it aborts with BrokenPipeInterruptError, whose exitCode is also 0 (interrupt.ts:167-168). The real exit code is only set later, after async telemetry (index.ts:409), so the forced exit(0) wins.

Result: a failing test wait / test run --wait / test rerun --wait exits 0 whenever its stdout exceeds the pipe buffer (~64 KiB) and the reader stops early: | head, | head -c, | grep -m1, | jq that errors out. GitHub Actions shell: bash runs with -eo pipefail, so the step goes green although tests failed. Large --output json results (big suites, long failure text) reach the threshold easily.

The comment calls 0 the "conventional SIGPIPE-equivalent", but a process killed by SIGPIPE exits 141, and before PR #185 this path exited 1. Swallowing the stack trace is right; erasing the command's verdict is not.

Details / implementation notes

End-to-end repro with the built CLI against a local mock API whose GET /runs/run_1 returns a failed run with a 300 KB error string:

$ testsprite test wait run_1 --output json > /dev/null                         ; exit=1
$ set -o pipefail; testsprite test wait run_1 --output json | cat > /dev/null       ; exit=1
$ set -o pipefail; testsprite test wait run_1 --output json | head -c 100 >/dev/null ; exit=0  PIPESTATUS=0 0

stderr even prints {"error":{"code":"CLI_ERROR","message":"Run run_1 finished with status: failed",...}} in the last case, yet the exit code is 0. With small output there is no EPIPE and the exit stays 1, confirming the buffer threshold.

Why tests miss it: interrupt.test.ts:324 ("exits 0 on stdout EPIPE") and :336 (armed scope) exercise the guard in isolation; none has a non-zero verdict pending, so the override is never observed. The fix keeps the :324 success case green.

Proposed fix (no new dependency, no new flags):

  • On stdout EPIPE, stop writing (destroy/neutralise stdout) and let the command's own exit code stand, or exit with process.exitCode when it is already non-zero, else 0 (or 141 if maintainers prefer SIGPIPE semantics).
  • Give the armed BrokenPipeInterruptError path the same treatment so a tunnel run's failure verdict also survives.

Tests: disarmed EPIPE with a pending failure keeps exit 1; plain success plus EPIPE still exits 0; armed EPIPE during a failing run keeps the failure code after cleanup.

Not a duplicate: grepped issues/PRs for EPIPE, broken pipe, SIGPIPE, 141, pipefail, | head. Only PR #185 (which introduced the guard) matches. PR to follow once assigned.

Confirmations

  • I have searched existing issues and this is not a duplicate.
  • I have provided my Discord identity above for reward coordination.

Activity

  1. zeshi-du commented on Oct 20, 2026

    @zeshi-du
    Contributor

    Thanks @crypticsaiyan — real bug, and your analysis matches v0.15.0: the disarmed guard calls exit(0) (interrupt.ts:369), and the armed path aborts with an exitCode-0 BrokenPipeInterruptError, while the real exit code is applied later in index.ts after an async step. Accepted.

    Scope:

    • Keep changes to interrupt.ts and the exit-code assignment in index.ts; don't edit command implementations. The guard can't see the verdict, so the exit probably has to be deferred to where the final code is set. Describe your design in the PR.
    • A determined failing verdict must survive EPIPE on armed and disarmed paths. Plain success plus EPIPE still exits 0 (not 141). A command still polling must stop promptly when the reader closes, via the existing shutdown mechanism.
    • No exit-code value changes, new flags or dependencies.
    • Tests: mock-based Vitest only, including one that fails before your fix (non-zero verdict plus EPIPE, both paths) and the existing success case. Run lint, format:check, typecheck and tests first.

    Comment /assign to claim it.

  2. added
    acceptedTriaged and accepted — a PR for this will be reviewed
    and removed
    needs-triageNew issue/PR awaiting first triage
    on Oct 20, 2026
  3. Lexiie commented on Oct 20, 2026

    @Lexiie
    Contributor

    /assign

  4. testsprite-hob commented on Oct 20, 2026

    @testsprite-hob

    Assigned to @Lexiie. Thanks for taking this on. (0 more slots available.)

    Your open assigned issues (3/3):

  5. crypticsaiyan commented on Oct 21, 2026

    @crypticsaiyan
    ContributorAuthor

    /assign

  6. testsprite-hob commented on Oct 21, 2026

    @testsprite-hob

    Assigned to @crypticsaiyan. Thanks for taking this on. (0 more slots available.)

    Your open assigned issues (3/3):

  7. crypticsaiyan commented on Oct 21, 2026

    @crypticsaiyan
    ContributorAuthor

    PR #631 is up for this, within the scope above: changes only in interrupt.ts, a fails-before test for the non-zero verdict + EPIPE case, the success case kept at exit 0, and the design described in the PR body. End-to-end, test wait <failed-run> --output json | head -c 100 now exits 1 (0 on main). Details and a side-by-side check are in the PR.

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

Metadata

Metadata

Labels

acceptedTriaged and accepted — a PR for this will be reviewedhackathonCLI hackathon submissionsin-progressAssigned and actively being worked on

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions