Skip to content

shell: write one pipe slot per POLLOUT to a blocking stdout or stderr (Linux) - #46307

Open
robobun wants to merge 2 commits into
mainfrom
robobun/e6242f5e/blocking-pipe-write-cap
Open

robobun wants to merge 2 commits into
mainfrom
robobun/e6242f5e/blocking-pipe-write-cap

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

Background

Downsides

  • Builtin chunks of 64 KiB and more to a FIFO or pre-6.4 pipe are up to 2x slower. 60 x 1 MiB into cat: 1233 ms (main: 649 ms). Another process's output can split a chunk over one page.
  • The shell no longer paces an external command. 200 MB to a reader that stalls 3 s peaks at 777 MB RSS (main: 470 MB).
  • Still waits: console.log, Bun.write(Bun.stdout), FreeBSD, and a one-page write that loses a race.
Notes

No user report. This was found by reading write_to_blocking_pipe during work on #45565 (closed, superseded by #46021). The tracker has no issue for it. #23636 fixed the same symptom in the shell on macOS. In the #43868 review the shell was left alone after a macOS trace of a socket stdout showed each write capped by the kqueue count (discussion). epoll gives on_poll no count (src/io/posix_event_loop.rs:365), so on Linux the cap is usize::MAX.

History of the bound. #43868 first sized this write from F_GETPIPE_SZ and FIONREAD (62a5dfd). bb0e24a replaced that with one page after POLLOUT ("Only POLLOUT's guarantee is safe: one free page slot on Linux"), because queued bytes undercount used slots. b2ca90a then dropped the bound ("Drop the per-call nonblocking write machinery") when process.stdout went back to O_NONBLOCK, and 2c5f635 kept the per-call writes that cost nothing: pwritev2(RWF_NOWAIT) and send(MSG_DONTWAIT). The shell's writers got neither O_NONBLOCK nor the bound. This PR restores the bound for them, with one fstat per writer where bb0e24a had one per write. The socket arm is the send that 2c5f635 already uses.

Who is affected. The stall needs a shell output chunk larger than the free room of fd 1 or fd 2, a description without O_NONBLOCK, and:

  • a pipe(2) pipe on a kernel without FMODE_NOWAIT for pipes (upstream before 6.4), or with BUN_FEATURE_FLAG_DISABLE_RWF_NONBLOCK,
  • a socket (a child of Node child_process or of Bun.spawn with stdout: "pipe") on a kernel before 6.5,
  • a FIFO on every kernel. By the kernel source (fs/pipe.c, fifo_open) the same holds for a pipe opened by path (/dev/stdout, /proc/self/fd/N, bash > >(cmd)). Not run on a 6.4+ kernel.

Checked here on 6.1.155: pwritev2(RWF_NOWAIT) returns ENOTSUP for a pipe, a FIFO and a socketpair.

It covers builtin output, shell messages on stderr, the output of external commands that a non-quiet $ relays, and builtins under bun exec and .sh files. It does not happen with .quiet() or .text(), for a redirect to a FIFO path, or after the script read process.stdout or called Bun.stdout.writer(). Those set O_NONBLOCK on the description until the next spawn with inherited stdio clears it (checked with /proc/self/fdinfo/1).

Repro (Linux 6.1.155, release builds of the merge base 71bc818 and of this branch):

cd "$(mktemp -d)" && mkfifo f
cat > big.mjs <<'EOF'
import { $ } from "bun";
const t0 = performance.now();
setTimeout(() => console.error("timer fired after ms:", Math.round(performance.now() - t0)), 100);
const big = Buffer.alloc(200000, "x").toString();
await $`echo ${big}`;
EOF
(exec 3< f; sleep 2; cat <&3 | wc -c) &
bun big.mjs > f; wait
main this branch
timer, 3 runs 1932, 1952, 1967 ms 101, 101, 104 ms
main thread, 20 samples of /proc/PID/syscall during the stall 20 in write(2) 20 in epoll_pwait2
the same samples for bun big.sh (mini event loop) 20 in write(2) 20 in epoll_pwait2

Tests. Six new tests in the output that its reader does not drain block.

  • a blocking FIFO on stdout and on stderr (Linux): the fixture is the only reader of the FIFO and reads it on the thread that runs the shell. It checks a write of one slot, of one slot plus one byte and of the capacity plus one byte while exactly one slot is free. Then a small chunk before a chunk larger than the pipe, two shells at once, and 512 KiB from an external command (this reaches the changed branch of bump, checked with a breakpoint). It checks that the description is still blocking. Last, it makes the description nonblocking and checks that the shell then needs fewer write calls than the pipe has slots (syscw in /proc/thread-self/io).
  • two blocking FIFOs under a shell script (Linux): bun two.sh runs seq 1 30000 1>&2 | seq 1 30000 on the mini event loop. stdout must arrive while nothing reads stderr.
  • a socket and a socket without RWF_NOWAIT: a child whose stdout is a socketpair prints two small chunks and 4 MiB. It must report 100 event loop turns while that is pending. The second test sets BUN_FEATURE_FLAG_DISABLE_RWF_NONBLOCK so kernels from 6.5 reach the send arm.
  • a tty: 100 KiB of echo output arrives once and in order. It passes with and without the fix and guards the unchanged tty path.
  • On Linux 6.1 the first five time out without the fix. On a kernel from 6.5, RWF_NOWAIT works on a socket (by the kernel source, not run here), so a socket passes there without the fix.
  • Debug build: file-io, shell-blocking-pipe, exec, epipe, shell-pipe-read-fault and pipeline_stack pass.
  • Release builds of the merge base and this branch over 55 test files (the shell and its commands, terminal, spawn, spawn-maxbuf, filesink, process-stdio, bun-write): the only difference is the five new tests. Two failures are on both builds in this container, which has a slow disk: ls > recursive > node_modules times out, and 15 to 18 tests of bun-write.test.js time out.

Costs, measured. Release builds of 71bc818 and this branch, Linux 6.1.155. Syscalls are main-thread counts from a ptrace counter (strace and perf are not installed here), per await $echo ${p}`` with stdout a FIFO that cat drains. A `pipe(2)` pipe gives the same table on this kernel.

chunk main this branch
101 B poll 1, write 1 same
4096 B poll 1, write 1 same
4097 B poll 1, write 1 poll 2, write 2, fstat +1, fcntl +1
64 KiB poll 1, write 1 poll 16, write 16, fstat +1, fcntl +1
200001 B poll 1, write 1 poll 49, write 49, fstat +1, fcntl +1
1 MiB poll 1, write 1 poll 256, write 256, fstat +1, fcntl +1.2

The same, after the script read process.stdout (the description has O_NONBLOCK, main does not stall):

chunk main this branch
64 KiB poll 1, write 1 poll 1, write 1, fstat +1, fcntl +1
200001 B poll 5.4, write 4 poll 5.4, write 4, fstat +1, fcntl +2
1 MiB poll 22.6, write 16 poll 22.7, write 16, fstat +1, fcntl +7.7
  • The fstat is once per writer. The fcntl(F_GETFL) is once per try_write call of more than one page.
  • No allocation, queue entry or host function is added.
  • A regular file and .quiet(): the syscall tables are identical. A tty: one more fstat per writer for a chunk over one page.
  • A pipe(2) pipe on Linux 6.4 or later was not measured (no such kernel here). By the code, RWF_NOWAIT returns before any changed line.
  • Wall clock into cat, 11 interleaved runs, median (min to max):
main this branch
300 x 100 B 97 ms (81 to 242) 101 ms (78 to 119)
300 x 4 KiB 111 ms (95 to 157) 106 ms (96 to 148)
300 x 64 KiB 250 ms (231 to 318) 452 ms (424 to 552)
60 x 1 MiB 649 ms (574 to 698) 1233 ms (879 to 1771)
8 x 16 MiB 1363 ms (1092 to 1647) 2176 ms (1363 to 3028)
60 x 1 MiB, O_NONBLOCK 888 ms (777 to 1133) 915 ms (774 to 1061)
8 x 16 MiB, O_NONBLOCK 1745 ms (1119 to 2342) 1743 ms (1138 to 2015)
  • Output of an external command relayed by a non-quiet $ (head -c N /dev/zero into cat), 10 interleaved runs, median (min to max):
main this branch
50 MB 0.59 s (0.55 to 1.13) 0.40 s (0.29 to 0.52)
100 MB 1.33 s (1.23 to 1.55) 1.09 s (0.68 to 1.29)
200 MB 3.04 s (2.49 to 10.52) 1.89 s (0.71 to 2.41)
  • The bump change alone: 128 MiB relayed into cat after the script read process.stdout, so no build waits in write(2). Main: 5.3 to 6.1 s wall, 3.1 to 3.7 s user CPU. This branch: 1.2 to 1.6 s wall, 0.07 to 0.16 s user CPU (3 runs each).
  • Peak RSS for the relay, 7 runs, median (min to max). The non-quiet $ keeps the whole output for ShellOutput in both builds.
main this branch
50 MB, reader keeps up 154 MB (151 to 159) 186 MB (166 to 193)
50 MB, reader stalls 3 s 160 MB (159 to 186) 228 MB (198 to 233)
200 MB, reader keeps up 477 MB (464 to 624) 552 MB (519 to 624)
200 MB, reader stalls 3 s 470 MB (453 to 532) 777 MB (651 to 801)
200 MB, reader stalls 3 s, O_NONBLOCK 759 MB (756 to 770) 724 MB (655 to 765)

The last row shows that main has the same queue wherever its write does not wait.

  • The bump rule keeps a written prefix of up to the size of the unwritten tail before it drops it (main: 128 KB). The two stalled rows above do not show a higher peak from it. A reader at a steady lower speed can hold up to one more tail.
  • Chunk contiguity: a shell writes 200 chunks of 20,001 bytes, 5 ms apart, and a second process writes a 100-byte line per millisecond into the same pipe. Whole chunks in 5 runs: main 200, 199, 200, 200, 200. This branch 196, 197, 195, 196, 193. A chunk of at most one page still goes out in one write(2).
  • Binary: .text +1280 bytes (81,904,741 to 81,906,021), file size unchanged. PosixBufferedWriter (32 bytes) and PosixStreamingWriter (64 bytes) keep their size: the one-byte state replaces the one-byte rwf_unsupported flag.

Other visible changes.

  • While the pipe is full, console.log, a second $, or the stderr writer under 2>&1 can land inside a shell chunk. Before, the sleeping write(2) kept the chunk whole.
  • A process.exit() that races a shell write drops what the pipe has not taken. Before, the process could not exit until the reader drained the pipe.
  • After EPIPE in the middle of a chunk, ShellOutput.stdout holds the bytes of every write round before the one that failed (65536 here). Before it held 0. The exit code is the same. Pages written in the round that fails are not counted: try_write_with_write_fn drops its count on an error, as on main.
  • By the source, main does the same on a pipe where RWF_NOWAIT works. That was not run.

Not covered. stdout is a FIFO read after 2 s. The number is when a 100 ms timer fires, main then this branch.

  • console.log(200 KB): 1897 and 1941 ms. The console writer is synchronous by design (b2ca90a).
  • process.stdout.write(200 KB) after a spawnSync with inherited stdio: 1838 and 1935 ms. process.stdout/stderr: clear O_NONBLOCK for spawned children, don't drop console output on EAGAIN #43868 keeps Node's behavior there (discussion).
  • await Bun.write(Bun.stdout, 200 KB): 1845 and 1953 ms. It writes on the JS thread. It shares no code with the shell's writer, so it is not part of this PR.
  • FreeBSD: by the kernel source (sys_pipe.c, pipe_direct_write), a blocking pipe write of 8192 bytes or more waits until the reader took it, whatever the kevent count says. Not run. There is no FreeBSD test lane.
  • macOS: the write path is not changed and was not run. on_poll cuts a blocking write to the kqueue byte count.
  • Windows: the write path is not changed. The shell writes through libuv there. The bump change applies on every platform.

Alternatives.

  • O_NONBLOCK on the shell's dup, as process.stdout does. The description is shared with every child that inherits it. Measured: with the flag set, head -c 1000000 /dev/zero on that description fails with Resource temporarily unavailable after 65536 bytes, exit 1. A spawn clears the flag for the child, so the shell would have to set it again for each write while that child still runs.
  • The fd kind at the owner. is_pollable() in the shell already calls fstat on the dup. IOWriter::__start could label the poll from it, under cfg(linux, android), and try_write would lose its fstat and its set_flag. This PR keeps the test in the writer: Flags.is_socket also decides no_sigpipe, and Remove libuv on Windows #42819 rewrites that part of the shell.
  • A write of the pipe's capacity when ioctl(FIONREAD) is 0. In an emulation (a hook that changes the length of the write in a release build of this branch) it restores the speed of main: 60 x 1 MiB into cat takes 632 ms on main, 1097 ms with one slot and 603 ms with it, median of 9. Its costs: two more syscall wrappers, a lost race waits for up to the whole pipe and not one slot, and a faulting write by any writer leaves a slot in use while FIONREAD says 0 (seen here on 6.1). It can follow if the exposure is acceptable.
  • The relay. robobun/e6242f5e/pipe-splice-relay copies the bytes into a private pipe and moves them with splice(SPLICE_F_NONBLOCK), which waits for neither pipe. It removes the 2x cost and the race. It costs 2 fds per thread, 16 bytes per writer and 8.7 KB of .text.

Self-review. Two rounds, 64 concerns, 53 addressed. The first round moved the PR from the relay to this bound, because #43868 chose "where a per-call nonblocking write costs nothing" (discussion). The second round added the O_NONBLOCK check, the mini event loop test and the cost rows above. Not done:

  • The capacity write and the owner label (see Alternatives).
  • The three paths and FreeBSD under "Not covered". No issue was filed for Bun.write.
  • Pausing the reader of an external command while the shell's queue is long.
  • A test for the EPIPE byte count.
  • A run on a kernel from 6.4 and on macOS.

Related. #43091 makes pipeline commands write into a blocking pipe(2). It does not change the root stdout and stderr writers. #42819 removes the compaction that the bump hunk changes, so the two conflict.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/file-io.test.ts

… (Linux)

The shell writes `$` output to dups of fd 1 and fd 2. For a poll that its
owner did not label, the pipe writer waited for POLLOUT and then called
write(2) with the whole buffer. POLLOUT on a Linux pipe proves one free
slot, and a slot holds one page. write(2) of more than that on a
description without O_NONBLOCK returns only when every byte is in, so the
event loop thread slept until the reader drained the pipe.

The shell cannot set O_NONBLOCK on that description. Children inherit it,
and a plain tool fails with EAGAIN on it. pwritev2(RWF_NOWAIT) covers a
pipe(2) pipe from Linux 6.4, a socket from 6.5, and a FIFO never.

Once RWF_NOWAIT is refused, try_write looks at the fd on its first write
of more than one page (Linux and Android):

- A pipe or FIFO: poll, then write(2) of at most one slot, while the
  description has no O_NONBLOCK. #43868 had this bound in bb0e24a and
  removed it in b2ca90a, when process.stdout went back to O_NONBLOCK.
  On macOS the byte count from kqueue already bounds this write.
- A socket that its owner labelled as a pipe: it becomes a socket, and
  send(MSG_DONTWAIT) writes it.
- A tty: unchanged.

IOWriter::bump moved every unwritten byte to the front each time 128 KB
were written. That is quadratic in the queue wherever writes do not wait,
on every platform. It now waits until the written prefix is at least half
of the buffer.
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:36 PM PT - Oct 26th, 2026

❌ @robobun, your commit 584ed1b has 4 failures in Build #133191 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46307

That installs a local version of the PR into your bun-46307 executable, so you can run:

bun-46307 --bun

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Linux 6.1.155 with release builds of main (71bc818) and of this branch.

  • Script: bun big.mjs > fifo. big.mjs awaits a $ that prints 200 KB with echo, and the reader of the FIFO starts after 2 s. The full script is in the PR body.
  • Main: a 100 ms timer fires after 1932 to 1967 ms. The main thread is in write(2) in 20 of 20 samples of /proc/PID/syscall.
  • This branch: the timer fires after 101 to 104 ms. The main thread is in epoll_pwait2 in 20 of 20 samples.

PR: #46307

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: c674c744-b8a5-4cb8-9fda-aa6e5886755a
📥 Commits

Reviewing files that changed from the base of the PR and between 59c1ac4 and f4d814f.

📒 Files selected for processing: 3
  • src/io/PipeWriter.rs
  • src/runtime/shell/IOWriter.rs
  • test/js/bun/shell/file-io.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

POSIX pipe writers now track per-descriptor write strategies and select write paths based on descriptor type and flags. Shell output buffer draining also requires the written prefix to cover at least half the buffer. New tests cover FIFO, socket, and TTY output.

Changes

Shell Output Handling

Layer / File(s) Summary
Per-descriptor pipe write strategy
src/io/PipeWriter.rs
Pipe writers track strategy state and select write behavior based on descriptor classification and flags.
Shell output buffer draining
src/runtime/shell/IOWriter.rs
Buffer draining requires the written prefix to be at least 128 KiB and at least half the buffer.
FIFO, socket, and TTY output coverage
test/js/bun/shell/file-io.test.ts
Tests cover output completeness, ordering, and descriptor behavior for blocking FIFOs, sockets, shell scripts, and TTYs.

Suggested reviewers: jarred-sumner

Priority: Normal

Merge Risk: Minimal · up to f4d81

The change keeps Linux shell output to blocking pipes from stalling the event loop. No actionable merge-blocking issue remains after review.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes the primary Linux change: limiting blocking pipe and FIFO writes to one slot per POLLOUT for stdout and stderr.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The PR description is detailed and directly related to the changeset. It describes the Linux/POSIX pipe write-path change in src/io/PipeWriter.rs (one-page writes per POLLOUT for blocking pipes/FIFOs,…
Full details: Description check

Explanation

The PR description is detailed and directly related to the changeset. It describes the Linux/POSIX pipe write-path change in src/io/PipeWriter.rs (one-page writes per POLLOUT for blocking pipes/FIFOs, send(MSG_DONTWAIT) for sockets, tty unchanged), the IOWriter::bump compaction change, and the new tests in test/js/bun/shell/file-io.test.ts. It also gives reproduction steps, benchmarks, downsides, and alternatives considered. The description is relevant and specific, so the check passes.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/io/PipeWriter.rs — Shell callers whose reader closes mid-chunk now get written: 0 and lose the capture of pages that were already delivered, on every chunk over one page rather than only on a signal-interrupted write. src/io/PipeWriter.rs:132-143 loops write_one_slot_to_pipe once per page; after N pages succeed, the next page's poll reports Hup and sys::write returns EPIPE, and the Err at src/io/PipeWriter.rs:143 discards offset. The contract at src/io/PipeWriter.rs:215-218 ('an error here means nothing was written this round') is no longer true for the Pipe arm. Fix: report the pages already written before the error (e.g. return Pending(offset) and surface the error on the next round) so on_write tees them and parents see the true count.

    Why this was flagged

    Trigger: a non-quiet $ writes a chunk larger than bun_core::page_size() to a blocking pipe or FIFO, and the reader exits after consuming part of it. Pages 1..N succeed and offset advances at src/io/PipeWriter.rs:147; page N+1 polls Hup, sys::write gets EPIPE, and line 143 returns WriteResult::Err(err) with offset dropped. on_poll then calls on_error (src/io/PipeWriter.rs:207) without any on_write, so IOWriter::on_error (src/runtime/shell/IOWriter.rs:862) reports written: 0 for every pending writer and the bytes already in the pipe are never teed into the captured stdout buffer. On the base this needed a partial blocking write(2) (reader close mid-copy); write_to_blocking_pipe wrote the whole chunk in one call, so in the common case either all bytes were counted or none were written. The PR makes the multi-call loop the normal path for every chunk over one page, so the lost count is now the ordinary EPIPE outcome. The doc comment at src/io/PipeWriter.rs:215-218 still asserts the old invariant.

    Verification: pre-existing. Trigger: a non-quiet $ whose stdout/stderr fd is a blocking pipe/FIFO and whose reader closes after some of a chunk has been written. try_write_with_write_fn (src/io/PipeWriter.rs:132-143, untouched by the diff) advances offset on each Ok(wrote) and then return WriteResult::Err(err) without offset. The base already fails the same way by the same route.

Comment thread src/io/PipeWriter.rs
Comment thread src/runtime/shell/IOWriter.rs
robobun added a commit that referenced this pull request Oct 24, 2026
A nonblocking descriptor takes the whole write while a reader on the
same thread drains the pipe. The blocking descriptor is a todo row:
on Linux that write waits in write(2), and the one-slot write of
#46307 ends the wait.
@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

About the count after EPIPE (try_write_with_write_fn returns Err and drops offset, src/io/PipeWriter.rs:143): this branch loses less than main, not more.

  • Main: the blocking write(2) returns the bytes it copied when the reader closes. The loop calls write again, gets EPIPE, and drops that whole count. I measured echo of 300 KB into a pipe whose reader closes without a read. ShellOutput.stdout holds 0 bytes, and the pipe took 65536.
  • This branch: each round ends in Pending(offset), so the caller counts it. The same run gives 65536 bytes. Only the pages of the round that fails are not counted, and that is less than one pipe.

The loss that remains is in try_write_with_write_fn, which this PR does not change. A report of the progress before the error changes the error timing of every pipe writer (process.stdout, the stdin of Bun.spawn). So it is not part of this PR. The Notes said that the capture holds every byte that reached the pipe. I corrected that sentence.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant