Skip to content

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

Open
robobun wants to merge 1 commit into
mainfrom
robobun/e6242f5e/blocking-pipe-write-cap
Open

robobun wants to merge 1 commit 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.

  • 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 that reached the pipe (65536 here). Before it held 0. The exit code is the same.
  • 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 6:49 AM PT - Oct 21st, 2026

❌ @robobun, your commit f4d814f has some failures in Build #129985 (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.
Description check Passed The description is complete and relevant. It explains the problem, fix, trade-offs, limitations, and verification results, including detailed test coverage. The Problem/Fix and Tests sections provide …
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.
Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, fix, trade-offs, limitations, and verification results, including detailed test coverage. The Problem/Fix and Tests sections provide the information requested by the template, although the exact template headings are not used.

  • 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 on lines +308 to +311
if state.get() == PipeWrite::OneSlot
&& !sys::get_fcntl_flags(fd).is_ok_and(|flags| (flags as i32 & sys::O::NONBLOCK) != 0)
{
return write_one_slot_to_pipe;

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.

🟡 (optional) Users relaying a large external command's output through a non-quiet $ to a slow FIFO, socket or pre-6.4 pipe can now see Bun's memory grow to the whole output size. Once the one-slot write at src/io/PipeWriter.rs:311 stops blocking, nothing pauses the child reader, so every unwritten byte accumulates in IOWriter.buf (src/runtime/shell/IOWriter.rs:639 only compacts, never caps). On the base branch the blocked write(2) also stopped the reader, so the child was paced by its 64 KiB pipe. Fix: add backpressure in the shell writer path, e.g. stop watching the subprocess stdout/stderr read poll while the queue of unwritten bytes exceeds a bound and resume when it drains.

Why this was flagged

A script runs await $\cat 4GB.log`(not quiet) with fd 1 a FIFO, a socketpair from a parentBun.spawn`, or a pipe on a kernel without FMODE_NOWAIT, and the reader drains slowly. try_write (src/io/PipeWriter.rs:86-93) now returns write_one_slot_to_pipe (src/io/PipeWriter.rs:311) or write_to_socket (src/io/PipeWriter.rs:302), which return Pending instead of sleeping in write(2). The JS thread keeps running, so the shell's subprocess output reader keeps reading the child's pipe and enqueuing into IOWriter; bump (src/runtime/shell/IOWriter.rs:635-647) only drains the written prefix and there is no cap or pause on the reader anywhere in src/runtime/shell. RSS grows to the size of the command's output until the reader catches up, and a container memory limit kills the process. On the base branch write_to_blocking_pipe (src/io/PipeWriter.rs:257) blocked the thread, the reader did not run, the child blocked on its 64 KiB pipe, and memory stayed bounded.

Verification: nit — acknowledged in diff: the PR description's "Downsides" states "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)". src/runtime/shell/subproc.rs:1852-1876 on_read_chunk never pauses on IOWriter backpressure. src/runtime/shell/IOWriter.rs:1011 does s.buf.extend_from_slice(buf) with no cap.

} else if s.total_bytes_written >= SHRINK_THRESHOLD {
} else if s.total_bytes_written >= SHRINK_THRESHOLD
// The drain moves every unwritten byte. A queue that grows faster than the fd takes it would pay that per chunk.
&& s.total_bytes_written >= s.buf.len() / 2

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.

🟡 (optional) Users relaying a large command's output through a non-quiet $ to a slow reader now hold up to twice the unwritten bytes in the shell queue, on top of the growth already reported. src/runtime/shell/IOWriter.rs:635-637 only compacts buf once the written prefix is at least half of buf.len(), so with a reader that drains slowly the already-written prefix is retained until it equals the unwritten tail. On the base the prefix was dropped every 128 KiB of progress. Fix: bound retained written bytes independently of the queue size (e.g. a ring or VecDeque so compaction is O(1), or compact when the prefix exceeds max(SHRINK_THRESHOLD, unwritten/k) with a hard cap).

Why this was flagged

Trigger: a non-quiet $ relays an external command whose output outruns a blocking FIFO, socket or pre-6.4 pipe reader, through subproc.rs PipeReader chunks enqueued into IOWriter and drained by bump (src/runtime/shell/IOWriter.rs:611). The old condition at the base's IOWriter.rs:635 compacted whenever total_bytes_written >= SHRINK_THRESHOLD (128 KiB), keeping buf at roughly unwritten + 128 KiB. The new clause at src/runtime/shell/IOWriter.rs:637 waits until total_bytes_written >= buf.len() / 2, so with 300 MB queued and 100 MB written nothing is freed; buf can reach 2x the unwritten tail. The confirmed finding at src/io/PipeWriter.rs:311 covers the loss of backpressure; its remedy (pacing or capping the relay) does not change this compaction rule, which doubles whatever is retained for any multi-chunk queue, so the fix and the site are distinct. The PR's own 777 MB vs 470 MB RSS measurement includes this factor but the dismissal treated it as 'only the constant'. Population: every slow-reader relay on Linux FIFOs, sockets on pre-6.5 kernels and pipes on pre-6.4 kernels.

Verification: Triggered when a non-quiet $ relays external-command output into an IOWriter whose fd drains slower than chunks are enqueued. At src/runtime/shell/IOWriter.rs:635-643 the written prefix W is only dropped once W >= U, where U is the unwritten tail, so buf.len() reaches up to 2U, whereas the base condition dropped the prefix at every bump once W >= 128 KiB, holding buf.len() at roughly U + 128 KiB.

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