Skip to content

fix(io): end by SIGPIPE when stdout or stderr is a closed pipe - #1471

Closed
Yi-111-a wants to merge 1 commit into
bendlang:mainfrom
Yi-111-a:fix-epipe-1467
Closed

Yi-111-a wants to merge 1 commit into
bendlang:mainfrom
Yi-111-a:fix-epipe-1467

Conversation

@Yi-111-a

Copy link
Copy Markdown
Contributor

Fixes #1467.

A compiled program whose output is piped into head, less or grep -q
dies on EPIPE with

bend: a short write on a standard stream

and exits 1. Unix tools stop quietly in that spot instead, ended by
SIGPIPE: yes | head -1 writes nothing to stderr and reports 141.

bend2/comp.ts ignored SIGPIPE in io_loop (so a dead pipe cannot kill
the runtime mid-turn), which turned every broken pipe into that message.
io_out and io_sync now share one io_short helper: on EPIPE
against stdout or stderr the runtime restores the default SIGPIPE
disposition and raises it, so the program ends the way yes does —
status 141, nothing on stderr. Every other failed write, and every write
to a file or a socket, still answers err_fail exactly as before, since
only stdout and stderr reach io_out.

The JS lane gets the same treatment: EPIPE on fd 1 or 2 exits 141
instead of printing the message and exiting 1.

The reproducer from the report, built at 2.0.36 and after this change:

import Base

def flood(n: Nat) -> IO(Unit):
  match n:
    case 0n:
      IO.print("done")
    case 1n+p:
      IO.bind(Unit, Unit, IO.print("line of output for a pipe test"), _ => flood(p))

def main() -> IO(Unit):
  flood(200000n)
$ bend flood.bend -o flood && ./flood | head -1; echo "${pipestatus[@]}"
line of output for a pipe test
1 0                      # before: "bend: a short write on a standard stream"
$ bend flood.bend -o flood && ./flood | head -1; echo "${pipestatus[@]}"
line of output for a pipe test
141 0                    # after: nothing on stderr

Same under bun: bend flood.bend -o flood.js && bun flood.js | head -1
goes from bend: a short write on a standard stream / exit 1 to a silent
141.

Not covered by a tests/io case: the gate runs every test with its stdout
on a pipe it never closes, so a #| expectation cannot express a broken
pipe. Verified by hand in both lanes instead, plus the whole tests/io
and tests/halt suites interpreted against the unmodified baseline, which
moves nothing.

A compiled program whose output is piped into head, less or grep -q
dies on EPIPE with 'bend: a short write on a standard stream' and
exit 1. Unix tools stop quietly there instead, ended by SIGPIPE.

io_out and io_sync now share io_short: on EPIPE against stdout or
stderr the runtime restores the default SIGPIPE disposition and
raises it, so the program ends the way yes | head -1 does (status
141, nothing on stderr). Every other failed write, and every write
to a file or a socket, still answers err_fail as before. The JS
lane exits 141 on the same condition.
@viridale

Copy link
Copy Markdown
Contributor

Thanks for picking this up. #1470 fixes the same issue, and this PR has two gaps that it closes:

  1. No regression test. The description says a #| expectation can't express a broken pipe, but it can: the test only needs a child whose stdout or stderr is a pipe with its read end already closed. A closed stdout or stderr reader ends a program by SIGPIPE, not with an error (#1467) #1470 adds tests/io/stream_epipe.bend with .c and .js twins. The C twin forks a child that writes through io_out/io_sync. The JS twin spawns a child bun that runs the runtime's own io_out. Each prints the child's status: 141 for fd 1 and fd 2 on all three lanes (checkup, C, JS). On main it prints the message and 1. Closing the read end before the write keeps it deterministic.

  2. The JS lane doesn't end by SIGPIPE. process.exit(141) is a normal exit with status 141, so the two lanes disagree for any parent that checks how a child ended. waitpid reports WIFSIGNALED for the C binary and WIFEXITED for bun x.js; Python's subprocess, for example, reports -13 for one and 141 for the other. Bun ignores SIGPIPE, but adding and then removing a listener restores SIG_DFL (onDidChangeListeners in Bun's BunProcess.cpp, the same in 1.2 and 1.4). A closed stdout or stderr reader ends a program by SIGPIPE, not with an error (#1467) #1470's io_out does that and signals itself, so both lanes end the same way.

Smaller points: io_out only ever receives stdout/stderr (fd 1/2 in JS; file_write.js has its own loop), so the h == stdout || h == stderr and fd === 1 || fd === 2 checks are always true. The braceless one-line if also differs from the surrounding style.

#1470 covers the C lane, the JS lane and the in-process bend f.bend run, adds the test, and stays under comp.ts's cap (63,997 ttok), so I'd suggest closing this one in its favor.

@Yi-111-a

Copy link
Copy Markdown
Contributor Author

Agreed on every point — closing this in favour of #1470.

  • Regression test. You are right that a #| expectation can express it; my claim was wrong. I reasoned from the gate harness (which keeps its own read end open) rather than writing the case, and did not try the closed-read-end child before concluding it was impossible. tests/io/stream_epipe.bend with the .c/.js twins is the shape I should have produced.
  • The JS lane does not end by SIGPIPE. Correct, and it is the substantive gap. process.exit(141) is a normal exit, so waitpid reports WIFEXITED for bun and WIFSIGNALED for the C binary, and subprocess reports 141 vs -13. I had only compared the shell-visible $?, which is the same number in both cases and hides the difference — that is why my head/Python reproducer looked green. Restoring SIG_DFL and raising is the right fix, and your io_out already does it.
  • Redundant guards and style. Fair on both counts: io_out only ever sees stdout/stderr, and the braceless one-line if does not match the surrounding code. I added the guards defensively against a case that does not exist.

Thanks for taking the time to write this up — the SIG_DFL restore point (onDidChangeListeners in BunProcess.cpp) and the WIFEXITED/WIFSIGNALED split were both things I should have checked before opening.

Closing as superseded. Not adding a test-only PR on top of #1470; if the stream_epipe case ends up needing extra coverage once #1470 lands, I will follow up there.

@Yi-111-a

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #1470, per the review above.

@Yi-111-a Yi-111-a closed this Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A program whose stdout reader exits early prints bend: a short write on a standard stream and exits 1

3 participants