The Newer Bash Hung. The Fix Was to Stop Generating Shell Code.

I expected the old system Bash to be the suspicious interpreter.
This machine produced the opposite result.
This time, macOS Bash 3.2 finished the command in about ten milliseconds.
Homebrew Bash 5.3.15 did not return before the timeout.
The command was not a build, an install, or a network request.
It printed a zsh hook.
node-snapshot init
That one command sat inside my login startup:
source <(node-snapshot init)
When the producer never finished, the shell never reached a prompt.
The eventual fix was pleasantly unambitious.
I stopped asking Bash to generate the zsh program from a heredoc.
I put the zsh program in a .zsh file and made Bash do this:
exec cat "${script_dir}/init.zsh"
Same command surface.
Same emitted hook.
One fewer parser boundary.
Start with shell stages, not suspects
The symptom was “login zsh is very slow.”
That sentence invites a long suspect list:
- Oh My Zsh
- completion initialization
nvmdirenv- a network check
- an oversized plugin list
Several of those were plausible.
Plausible is not the same as isolated.
The diagnostic on August 14, 2026 started with three shells under timeouts:
zsh -dfi -c exit
zsh -i -c exit
zsh -li -c exit
The exact flags matter less than the contrast.
The minimal interactive shell finished in roughly 5–33ms.
The non-login interactive shell finished in roughly 175–787ms.
The login interactive shell crossed a 15-second timeout three times.
The problem was not “zsh is slow.”
It lived on the path added by login startup.
That reduced the search area before I changed a single file.
Source startup files one boundary at a time
The next step was not commenting out half the dotfiles.
It was running each boundary in isolation.
The .zprofile path finished in roughly 339–558ms.
Profiling attributed about 433ms to nvm and about 26ms to compinit in that run.
Those were real costs.
They were not a 15-second hang.
The login-only path led to this line in .zlogin:
source <(node-snapshot init)
Running the producer directly also failed to return inside repeated 2–15 second windows.
direnv hook zsh, by comparison, completed in roughly 14–69ms during the same diagnosis.
Now the problem had a command, not a theme.
That distinction kept the investigation from becoming a generic “optimize my shell” project.
Process substitution makes the producer part of startup
The line looks like a declaration.
Operationally it is a pipeline between two interpreters.
login zsh
-> runs node-snapshot init
-> waits for emitted zsh source
-> parses and sources that output
-> reaches the prompt
If the producer blocks, no amount of profiling the generated hook helps.
The hook has not started yet.
That was easy to miss because the emitted zsh includes a background update check:
(node-snapshot upgrade --check 2>/dev/null &)
A network explanation felt natural.
But the isolated init command blocked while emitting its payload, before the shell could source that background command.
The network path remained a potential startup cost after sourcing.
It was not the direct cause of this hang.
Execution order beat static resemblance.
Follow the dispatcher to the interpreter that actually runs
node-snapshot is a Bash dispatcher.
The installed entry point used this shebang:
#!/usr/bin/env bash
On this machine, bash resolved to Homebrew Bash 5.3.15.
The dispatcher then used exec to hand init to commands/init.sh.
The historical init.sh was small.
Its main job was a quoted heredoc:
cat <<'SHELL'
_node_snapshot_chpwd() {
# zsh hook body
}
autoload -Uz add-zsh-hook
add-zsh-hook chpwd _node_snapshot_chpwd
SHELL
Static reading did not reveal a network request in that path.
The next useful variable was the interpreter.
The August 14 measurements were stark:
Homebrew Bash 5.3.15: timed out while emitting the heredoc
macOS /bin/bash 3.2: completed in about 10ms
That evidence supports a narrow conclusion.
The installed node-snapshot 0.5.1 init script and Homebrew Bash 5.3.15 were incompatible on this machine at that time.
It does not establish that Bash 5.3 generally hangs on heredocs.
It does not establish a version matrix.
It does not make Bash 3.2 “more compatible” in general.
The interpreter is part of the input, but the construct is too.
The local configuration was already fixed before the investigation ended
There was another trap waiting after the root cause was found.
The current .zlogin no longer contained the source line.
An existing commit, b25b350, had already removed it.
After that removal, the August 14 login probes completed in roughly 0.66–1.09 seconds.
The diagnostic agent did not edit the file again.
That matters because “I found the bad line” is not proof that the bad line still needs to be removed.
Repository state can move while a symptom report, a terminal session, and a diagnostic overlap.
The correct response was:
- confirm the historical call site;
- confirm the current file no longer had it;
- measure the post-removal shell;
- fix the distributed tool separately.
Reapplying the configuration fix would have changed nothing.
Leaving the tool broken would have made the next user hit the same problem.
Preserve the command contract and remove the generation step
The public fix landed in homebrew-tap commit 08baa42.
Before the fix, init.sh owned about 53 lines of zsh payload inside its Bash heredoc.
After the fix, the payload moved verbatim into init.zsh.
init.sh became a tiny adapter:
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec cat "${script_dir}/init.zsh"
Users still run:
source <(node-snapshot init)
The dispatcher still has an init subcommand.
The formula still installs every file under the command directory.
The zsh hook still registers _node_snapshot_chpwd, add-zsh-hook, and the background update check.
The public API did not change.
Only the responsibility moved.
Bash locates and streams a file.
Zsh owns the zsh program.
A static payload is not merely an escaped heredoc
I could have tried to repair the heredoc itself.
That would preserve an unnecessary representation.
The payload is not dynamic Bash output.
It is a static zsh program containing its own quoting, command substitutions, parameter expansions, and comments.
Embedding that program inside Bash creates two readers for the same text:
- Bash must safely carry it through the heredoc.
- Zsh must later parse it as executable source.
Moving it to init.zsh gives the payload one native representation.
The adapter becomes boring enough to inspect at a glance.
There is also a packaging benefit.
The Homebrew formula already installs the complete command directory:
(libexec/"node-snapshot"/"commands").install Dir["libexec/node-snapshot/commands/*"]
Adding init.zsh required no new runtime dependency and no bespoke install step.
The existing package boundary already supported the simpler design.
Turn “does not hang” into a test with a deadline
The old unit assertion only captured output after the command returned.
That is useless when the regression is “never returns.”
The fix changed the test shape.
It starts init in the background, checks the process up to twenty times at 100ms intervals, and fails if the process is still alive.
In outline:
"${BIN}" init > "${init_output}" &
init_pid=$!
while kill -0 "${init_pid}" 2>/dev/null && [[ "${init_attempt}" -lt 20 ]]; do
sleep 0.1
init_attempt=$((init_attempt + 1))
done
if kill -0 "${init_pid}" 2>/dev/null; then
_fail "init: does not exit promptly"
fi
Only after the liveness assertion passes does the test inspect the payload markers.
That order matters.
“The output contains the function name” cannot prove a producer terminates.
A partial buffer may contain every expected string while the process remains stuck.
The timeout property needs its own reader.
Recheck the current release instead of replaying the old conclusion
The historical diagnosis belongs to 0.5.1 on August 14.
The installed formula directory on August 26 is 0.5.2, and the current public source contains the static-file fix.
I reran the current path rather than assuming the old result persisted.
node-snapshot init, three attempts: 0.01s each
current init.sh via /bin/bash 3.2: 0.00s
current init.sh via Homebrew Bash 5.3.15: 0.00s
node-snapshot unit suite: PASS
The emitted payload still contained the three markers the integration contract expects:
_node_snapshot_chpwd
add-zsh-hook
node-snapshot upgrade --check
I also made the ad-hoc marker check fail first by looking for a deliberately absent marker.
Only after that exit code was non-zero did I trust the checks for the real markers.
The current .zlogin still does not source node-snapshot init.
Three fresh non-TTY zsh -li -c exit probes completed in 0.47–0.78 seconds.
Those probes emitted zle option warnings because they were not attached to a terminal, so I treat the numbers as comparative startup evidence, not as a polished measure of time-to-interactive-prompt.
The tool is fixed.
The local opt-in remains removed.
Those are two separate current facts.
The older interpreter was not the villain here
The tempting takeaway from macOS automation is “never trust Bash 3.2.”
That lesson is too large for the evidence.
In this incident, a shell-integration generator finished under /bin/bash 3.2 and hung under Homebrew Bash 5.3.15.
The useful rule is not “newer wins” or “system wins.”
It is:
run the exact construct through the exact interpreter on the exact execution path.
For shell startup work, my baseline is now:
- compare minimal, interactive, and login startup modes under a deadline;
- source startup boundaries individually;
- execute the suspected producer directly;
- resolve the interpreter from its real shebang and
PATH; - compare interpreters only after the call site is isolated;
- inspect current Git and file state before editing;
- make termination, output shape, and actual startup three separate checks.
That sequence found a 53-line heredoc hiding behind one innocent dotfiles line.
The best fix was not a more clever heredoc.
It was admitting that static zsh belongs in a zsh file.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.