Thanks for the fast and detailed reply, and for the pointer to msg00126, I
really appreciate it! That explains it completely, and corrects my
diagnosis. You're right that PIPE_BUF is about write atomicity, not
capacity; I conflated the two because the numbers coincide.
I now agree this is a duplicate of msg00126. I can confirm the maxpipekva
theory directly on the affected machine. Measuring the actual capacity of a
fresh pipe with no reader:
python3 -c 'import os;r,w=os.pipe();os.set_blocking(w,False);t=0
try:
while True: t+=os.write(w,b"x"*64)
except BlockingIOError: print(t)'
-> 512
So this machine is sitting above the 16 MiB maxpipekva high-water mark and
the kernel is handing out minimum-size (512-byte) pipes, while the Homebrew
bash was built with HEREDOC_PIPESIZE=65536.
That also explains what previously looked like nondeterministic behaviour
to me: the hang only happens when the whole-system pipe memory is above the
threshold, so it's not just the here-document size. A reboot drops pipe kva
back under the mark and the same script runs fine (that explains why it
does not reproduce in your machine). In my case, I'm running a lot of
long-lived pipes (tmus, editors, claude-code, bats test suites, etc). I now
have a python script that reproduces this by first creating several pipes
(attached only in case it helps), providing the path to the bash binary as
the first argument can be used to deterministically verify if the issue is
solved.
Happy to test the devel-branch fix on macOS 26 when it lands.
System details, in case they help:
macOS 26.5.1 (Darwin 25.5.0, arm64, xnu-12377.121.6)
bash 5.3.15, Homebrew, poured from a prebuilt bottle (not built locally)
On Fri, 10 Jul 2026 at 14:06, Chet Ramey <[email protected]> wrote:
> On 7/10/26 6:28 AM, Julián Mateu wrote:
>
> > Bash Version: 5.3
> > Patch Level: 15
> > Release Status: release (installed via Homebrew; per the mechanism
> below, a
> > from-source build should also reproduce)
>
> This appears to be very system-specific and the result of changes in recent
> versions of macOS; see
>
> https://lists.gnu.org/archive/html/bug-bash/2026-06/msg00126.html
>
> for a detailed explanation.
>
> Bash computes the pipe size at build time and assumes it doesn't change,
> and, that if it does, there is a way to determine it at runtime.
>
> I've never hit this on macOS 15 or 26, and the bash test suite uses here-
> documents with sizes 512 <= doc <= 4096, 4096 <= doc <= 65536, and larger
> than 65536. I would be shocked if the person who packaged bash for
> homebrew didn't run the test suite, so we can assume they didn't encounter
> this, either.
>
> > Description:
> > On macOS, a here-document whose body exceeds PIPE_BUF (512 bytes on
> Darwin)
> > makes bash 5.3 hang forever.
>
> As above, the issue is a little bit more dynamic than that. Plus PIPE_BUF
> is about write atomicity on pipes, not pipe capacity.
>
>
> > bash on Linux (64 KB pipe buffers) are
> > unaffected.
>
> One nice feature Linux has is the ability to dyamically determine the pipe
> buffer size using F_GETPIPE_SZ. It would be nice if macOS offered something
> similar.
>
> >
> > Repeat-By (self-contained, no external tools; run under bash 5.3 on
> macOS):
> >
> > printf -v body '%*s' 600 '' # 600-byte body
> > body=${body// /x}
> > cat <<EOF
> > $body
> > EOF
> > echo done # never printed on bash 5.3 / macOS
>
> This is bash-5.3 on macOS: the first is a locally-built bash-5.3.15; the
> second is from Macports.
>
> $ uname -a
> Darwin Mac.lan 24.6.0 Darwin Kernel Version 24.6.0: Tue Apr 21 20:15:34
> PDT
> 2026; root:xnu-11417.140.69.710.16~1/RELEASE_ARM64_T6031 arm64
> $ cat x2
> printf -v body '%*s' 600 '' # 600-byte body
> body=${body// /x}
> cat <<EOF
> $body
> EOF
> echo done # never printed on bash 5.3 / macOS
> $ ../bash-5.3-patched/bash ./x2
>
> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
> done
> $ /opt/local/bin/bash ./x2
>
> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
> done
>
> There will be a fix derived from the bug-bash message above in the next
> devel branch push.
>
> Chet
> --
> ``The lyf so short, the craft so long to lerne.'' - Chaucer
> ``Ars longa, vita brevis'' - Hippocrates
> Chet Ramey, UTech, CWRU [email protected] http://tiswww.cwru.edu/~chet/
>
#!/usr/bin/env python3
"""
Deterministic reproducer for the bash 5.2 macOS here-document hang.
Mechanism (per bug-bash 2026-02/msg00126): recent XNU sizes pies dynamically
(512 B .. 64 KiB) but only *grows* them while total system pipe memory is under
the 16 MiB `maxpipekva` high-water mark. Homebrew bash was built believing a
pipe holds 64 KiB (HEREDOC_PIPESIZE), so it writes a medium heredoc into a pipe
BEFORE forking the reader. If the machine is over the mark, that pipe is only
521 B -> the write blocks with no reader -> deadlock.
This script forces the machine over the mark (256 pipes x 64 KiB = 16 MiB, held
open) and shows the before/after: on a freshly-booted machine you see
65536 -> 512 and OK -> HANG. On an already-over-budget machine both are 512/HANG.
"""
import os, subprocess, sys, resource
# 256 grown pipes need ~512 fds; macOS defaults the soft limit to 256. Raise it.
_, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
for target in (4096, 2048, 1024):
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
break
except (ValueError, OSError):
continue
FD_SOFT = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
BASH = "/opt/homebrew/bin/bash" if len(sys.argv) == 1 else sys.argv[1]
# BASH = "/bin/bash"
# 513-byte here-string body: the minimal case (>512, <= 64 KiB).
TEST = [BASH, '-c', 'cat <<< "$(printf %0513d 0)" > /dev/null && echo OK']
def pipe_capacity():
"""Capacity of a brand-new pipe with no reader (grows to 64 KiB if the kernel can)."""
r, w = os.pipe(); os.set_blocking(w, False); n = 0
try:
while True: n += os.write(w, b"x" * 4096)
except (BlockingIOError, OSError): pass
os.close(r); os.close(w)
return n
def bash_heredoc(label):
try:
subprocess.run(TEST, timeout=5, capture_output=True)
print(f" {label}: bash 513-byte heredoc -> OK (completed)")
except subprocess.TimeoutExpired:
print(f" {label}: bash 513-byte heredoc -> HANG (deadlocked, killed at 5s)")
print(f"fd soft limit is now {FD_SOFT}")
base = pipe_capacity()
print(f"baseline fresh-pipe capacity: {base} bytes (65536=healthy, 512=over-mark)")
bash_heredoc("BEFORE")
if base <= 1024:
print(f">>> ALREADY over the mark. The hand is already deterministic here; reboot to reset.")
raise SystemExit
held, crossed = [], False
try:
for i in range(600):
r, w = os.pipe(); os.set_blocking(w, False)
try:
while True: os.write(w, b"x" * 4096) # grow to ~64 KiB, hold the data
except (BlockingIOError, OSError): pass
held.append((r, w))
if pipe_capacity() <= 1024: # a new pipe can no longer grow
print(f"crossed the 16 MiB maxpipekva mark after allocating {i+1} full pipes")
crossed = True
break
except OSError as e:
print(f"ran out of fds after {len(held)} pipes ({e}); run 'ulimit -n 4096' first")
if not crossed:
print(f"did not cross the mark within the pipe/fd budget available")
print(f"after-fill fresh-pipe capacity: {pipe_capacity()} bytes (65536=healthy, 512=over-mark)")
bash_heredoc("AFTER ")
print(f"(holding {len(held)} pipes; they free when this process exits)")