Bash on macOS Deadlocks on Any Heredoc Over 512 Bytes

I spent an evening convinced my Claude session was on drugs. Turns that normally take thirty seconds were taking five minutes, and turns that normally take five minutes were taking literal hours. Several Claude Code sessions, all crawling. Restarting the client did nothing. It had the exact texture of a bad night on somebody else’s infrastructure, so that is where I looked first, but that wasn’t it. The actual answer was as surprising as it was infuriating.

It was not the machine and it was not the API. It was a PreToolUse hook I had written the night before, deadlocked on a here-document, in a way that only happens on macOS, only under a bash newer than the one Apple ships, and only when the command being inspected is longer than 512 bytes.

The short version

Bash 5.1 changed how here-documents are implemented. They used to go through a temp file. Now bash writes the body into a pipe, and it does that write before forking the child process that is supposed to read the other end. Above 64KB it falls back to a temp file.

That is fine as long as a pipe can hold everything bash might shove into it before a reader exists. On Linux a pipe holds 64KB, which is exactly where bash gives up and uses a file, so the dangerous window is empty and nobody ever notices.

On macOS a fresh pipe holds 512 bytes.

So on a Mac, running a bash between 5.1 and 5.3.19, any here-document body between 513 bytes and 65535 bytes fills the pipe, blocks the write, and waits forever for a reader that has not been created yet. No error. No timeout. No partial output. The process just sits there.

It is an upstream bug, fixed in bash 5.3 patch 016. If you are on Homebrew bash, brew upgrade bash and get to 5.3.20 or later. Probably go do that now, then you can finish reading.

Do you have this?

Two commands. First, which bash are you actually running:

$ /usr/bin/env bash --version
GNU bash, version 5.3.9(1)-release (aarch64-apple-darwin25)

The binary that matters is the one your PATH resolves, not /bin/bash. Apple still ships 3.2.57 there and 3.2 is immune, so checking it will clear you incorrectly. What counts is what #!/usr/bin/env bash picks, which is how most of my scripts start.

Second, try to hang it:

/usr/bin/env bash -c '
  p=$(head -c 1000 /dev/zero | tr "\0" a)
  while IFS= read -r line; do :; done <<< "$p"
  echo survived
'

If that prints survived, you are fine. If it sits there, you have it. Control-C and go upgrade.

Here is the measured window on my machine, before the upgrade:

payloadresult
415 Bok
510 Bok
520 Bdeadlock
1 KBdeadlock
4 KBdeadlock
16 KBdeadlock
60 KBdeadlock
70 KBok
200 KBok

Both <<< here-strings and <<EOF here-documents. A control with no redirection at all passed at every size.

And the interpreter matrix, which is the part that turns this from “my hook is broken” into “my toolchain is broken”:

shellversion1 KB heredoc
/bin/bash3.2.57 (Apple)ok
/bin/sh3.2.57ok
/bin/zsh5.9ok
/opt/homebrew/bin/bash5.3.9deadlock

I later ran the same sweep on a Linux box running bash 5.3.9, the identical version that hangs on the Mac. Every size passes, including 60KB. The pipe on that machine reports a 65536-byte capacity. The variable is not the bash build, it is how much the platform lets you stuff into a pipe nobody is reading yet.

The worse symptom

The hang is annoying. The data loss is the actual reason I am writing this up.

Consider the single most common shape in this family:

cat > somefile.txt <<'EOF'
...1 KB of content...
EOF

The > redirect truncates somefile.txt to zero bytes. That happens first. Then bash tries to write the body into the pipe, blocks, and never returns. Your file is gone and the command that was going to rewrite it is wedged.

If you drive an agent that likes writing files with heredocs instead of a dedicated write tool, you are exposed to this on every single write in that size range, which is to say most of them. I swept my machine for damage afterward and found none, and I am aware that is luck rather than design.

One more thing nobody tells you: the wedged processes do not clean themselves up. I found 23 of them, the oldest sitting there for ten minutes, one per blocked tool call across several sessions. They have no timeout. Killing them lets the stuck calls return.

The fix

Three layers, in order of how much I would trust them.

Upgrade bash. brew upgrade bash. The upstream fix landed as bash 5.3 patch 016; Homebrew 5.3.20 has it. Afterward, sweep for other bash binaries on the box, because a stale one on your PATH puts you right back. I found exactly two, Apple’s 3.2.57 and the Homebrew build, both clean.

Fix the shell pattern anyway. Wherever you loop over a variable’s lines:

# deadlocks
while IFS= read -r line; do
  ...
done <<< "$command"

# does not
while IFS= read -r line; do
  ...
done < <(printf '%s\n' "$command")

Process substitution forks a real writer, so there is always someone draining the pipe. And unlike printf ... | while, it keeps the loop out of a subshell, so variables you set inside it still exist afterward, which is usually the whole reason you wrote it that way in the first place.

I kept this change even though my bash is now correct. It costs nothing and it is what holds if a bad bash ships again.

Stop generating giant heredocs. If a tool you control has the option, write files with a write tool and pass scripts by path instead of inline. Running my repro script from a file took 2.0 seconds; running the same script as an inline heredoc hung forever. Same script, same hooks, same machine.

How I found it

This is the half I actually wanted to write about, because almost everything I tried was wrong in an instructive way.

What it looked like from the chair

A deadlocked hook and a slow API are indistinguishable. There is no error, no log line, no timeout, no partial output. The turn just sits there. So the first hour went into the network, the API, the context size, and whether the machine itself was sick, because that is what “everything is slow tonight” means.

The tell was right there: Read and Write tool calls were fine, only Bash calls hung. That is not what a slow machine looks like and it is not what a slow API looks like. It is what a slow hook looks like, and Bash is the only tool on this machine carrying 38 of them.

The agent could not see any of this, which is worth dwelling on if you are building this kind of tooling. The stall happened in the harness, before the model’s command ever ran. From inside the session there is nothing to observe: no output to read, no exit code, no elapsed time it can measure. Ask it what is wrong and it will reason plausibly about everything except the layer it cannot reach. It also could not reproduce the problem on demand, and neither could I.

The blind alleys

Context size. My first guess was prompt bloat. This machine loads a large always-on CLAUDE.md, a memory index, three convention files and about 300 deferred MCP tool definitions. Extremely plausible. Completely wrong. I never measured a thing.

Shell startup. Better reasoning: the Bash tool initializes a shell from the user profile, and time inside a command cannot see that, so a slow .zshrc would be invisible to every measurement I had taken. I measured time zsh -ic true at 2.4 seconds, which is genuinely slow and which I have since gone and fixed for unrelated reasons. It is also nowhere near five minutes.

Hooks doing network I/O. 38 PreToolUse hooks fire on every Bash call here. A grep found 20 of them containing gh, curl, git fetch, jira, or push-notification calls. This looked damning. I timed all 38: 2.4 seconds total, worst case 0.58 seconds. Cleared them and moved on.

That measurement was wrong, and how it was wrong is the most useful thing in this post. I fed the hooks the payload echo hi. These hooks exist to inspect command text. A trivial payload exits most of them on the first condition. I had built a test with a blind spot in precisely the shape of the bug, then used the result to rule out the guilty party.

Heredocs in the tool. The call that first hung was passing a script via python3 - <<'EOF', and I had a note in my own memory reading “heredoc hangs on a live fd”, which felt like a direct hit. I tested three shapes: plain heredoc, heredoc plus a subprocess given explicit stdin, heredoc plus a subprocess inheriting stdin. All three passed. Wrong again, though at least it was in the right family. A note that is almost right is worse than no note, because it terminates the search early and it does it while you feel clever.

Every attempt to reproduce it worked fine. The reflex with anything intermittent is to shrink it down to the smallest command that still fails. That reflex is what kept this bug alive, because shrinking a command is precisely the operation that fixes it. Every minimal repro I built passed, and passed cleanly. A bug that fires constantly during real work and never once in a test case reads as intermittent, or environmental, or as somebody else’s flaky service, so that is where I went back to looking. The failure to reproduce was the loudest piece of evidence I had and I read it as noise. If your test case is smaller than your real workload and the bug disappears, you have not failed to reproduce it, you have found the variable.

The two observations that cracked it

Both of these were mine rather than the agent’s, and both are the same kind of observation: notice something that should not be possible and refuse to let it go.

“Your timeouts don’t seem to matter.” I wrapped a diagnostic in timeout 15 and it still hung. That is impossible unless the stall happens before the timed process starts. Hooks run before the command. That one sentence moved the entire search out of the command and into the hook phase.

“The script runs fine, it’s something about how you invoked it.” Running my repro from a file took two seconds. Running the identical script inline hung. Same script. The difference was never the heredoc as such. It was that the inline version put 1030 bytes of script text into the command, and the file version put 60.

Re-run the hook timing with a realistic payload instead of echo hi and it falls out immediately:

   0.14s  jql-silent-zero-guard.sh
   0.05s  memory-grep-refs-reminder.sh
  25.00s  emergency-page-guard.sh   <<<<<< HUNG

Bisecting

From there it is mechanical, and this is the part where handing it to an agent pays. bash -x on the hook stopped dead at the top of a loop, with nothing from inside it:

+ command=$'cd /Users/cmyers && PAY=...'
+ publishes=0

The next construct is while IFS= read -r line; ... done <<< "$command", and it was blocking on setting up the redirection, before the first iteration ever ran.

Bisecting the payload said line 12 on its own was fine and the first twelve lines together hung, which ruled out content and pointed at size. Size sweep, then interpreter sweep, and the table above is what came back.

The guard at the center of all this was doing its job correctly, by the way. I wrote it to stop an agent from test-firing my wife’s emergency pager, which is a real thing that happened. The first version grepped for the topic name anywhere in the command, which would have blocked me from ever grepping or documenting my own notes. The fix was to require a single line to carry the client, the destination and a publish flag all together, and checking “one line” needs a per-line read. That is where the here-string came from. The precision that avoided a false positive is what introduced the deadlock.

Why none of my safety nets caught it

I have a hook test suite. 180 cases. It was green. Four independent reasons it could not have caught this, and I do not think any of them are stupid, which is why they are worth listing.

The suite ran the hooks under the wrong interpreter. It hardens its own PATH with export PATH=/usr/bin:/bin:... for good and obvious reasons, and then invokes bash "$HOOKS/$hook". That resolves to /bin/bash, which is 3.2, which is the immune one. Production uses the shebang and gets Homebrew 5.3. All 180 cases had been validating a binary the hooks never run under. No test written in that harness could ever have found a 5.3-only bug. The hardening that made the suite reproducible is exactly what made it unfaithful.

A deadlock hung the suite instead of failing a case. There was no per-hook timeout, so the failure mode was “the test run never finishes”, which reads as a hung terminal rather than a red result.

A dead hook looks exactly like a passing hook. Most cases assert that a hook produces no output. A hook that deadlocks and gets killed also produces no output. Even with a timeout, every no-fire case would have gone green.

The cases used no bulk payload. The suite already has 17KB padding fixtures, built for an earlier SIGPIPE bug that also needed bulk, and 17KB sits squarely inside the deadlock band. They would have caught this instantly. The cases for the new hook, written in a hurry the night before, used bare short commands.

The fixes were: keep the inherited PATH and exec the hook directly so its shebang chooses the interpreter, add a 15 second timeout per hook, and treat exit 124 as a failure in both the fires-and-does-not-fire modes so a dead hook cannot pass a silence assertion.

One last thing on that. My first attempt at the new test cases passed against a deliberately reverted, known-broken copy of the hooks, which means they were testing nothing at all. The only reason I know that is that I forced the broken path on purpose before believing the green. Do that. A new test that has never failed is not a test, it is a decoration.

What I took away

A guard does its job by doing nothing, so a broken guard and a working guard look identical from outside. That is a trap I have run into before. This time, the guard’s failure mode was a hang, and a hang does not present as a broken guard at all. It presents as a slow machine, and you will happily spend an hour on the network before you look at the thing you installed last night.

The portable lesson is smaller and I think more useful. A test payload that does not resemble production traffic is not a test. echo hi exercised all 38 hooks and proved nothing, because those hooks inspect the command and the command was the variable that mattered. I measured the right thing with the wrong input and concluded the exact opposite of the truth, then acted on it for an hour.

And check what your harness actually executes. Not what it means to execute. What it does.

References