Windows/Git Bash: 85 per-function base64 evals make every Bash call pay ~5-9s; snapshot-creation 10s timeout silently forks sessions into fast/slow regimes
Summary
On Windows + Git for Windows (MSYS bash), the Bash tool's shell-snapshot mechanism adds ~5–9 seconds to every Bash tool call, and the snapshot-creation path has a timeout asymmetry that silently splits sessions into two performance regimes. Two coupled problems, both measured:
- Per-call cost: the snapshot stores every captured shell function as its own
eval "$(echo '<base64>' | base64 -d)" > /dev/null 2>&1line. On a machine whose login shell defines 145 functions (stock Git-for-Windowsgit-completion.bash— 144 of the 145), that is 85 base64-eval lines, each paying an MSYS subshell fork (~16 ms) plus an externalbase64exec (~65 ms with real-time AV on). Re-sourced on every Bash tool call. - Creation-timeout roulette: snapshot creation runs the login shell under a hard 10,000 ms timeout, while creation itself takes ~6 s idle on this class of machine. Under load it times out → the session permanently falls back to per-call
bash -c -l(the-lis added only when no snapshot was captured) — which is faster (~1.3 s/call) than snapshot mode (~6.5 s/call). Sessions randomly land in one regime at their first Bash call and stay there. A timed-out creation can still leave the snapshot file on disk, which makes forensics confusing (file present, never sourced).
Environment
- Windows 11 Pro, Git for Windows (stock
/etc/profile.d/git-prompt.sh→ git-completion), Windows Defender real-time on - Claude Code 2.1.237 (desktop app and CLI; behavior identical), snapshot files
~/.claude/shell-snapshots/snapshot-bash-*.sh, 88,199 bytes / 85 evals each
Measurements
Sourcing cost isolated (same snapshot, same machine, idle):
source full snapshot (85 evals) : 5,703 – 6,510 ms
same file, eval lines stripped : 88 ms <- 98.5% of cost is the eval lines
85 × $(:) fork-only control : 1,393 ms <- pure MSYS fork floor, AV-independent
85 × $(echo|base64 -d) : 6,853 ms (~81 ms/eval)
Corpus evidence (stream-parsed 9,473 clean serial Bash tool_use→tool_result deltas across 110 session transcripts / 35 days, one machine):
- Sessions in snapshot mode: floors 6.2–8.4 s, medians 8–15 s under load
- Sessions in
-lfallback mode: floors 972–1,300 ms - 39 of 83 sessions (n≥5) sat under a 2 s floor — i.e. the busier the machine, the more sessions accidentally escape the tax, which makes the cost look nondeterministic and very hard for users to attribute
- First call of a session ≈ 2× median (creation) — consistent with the 10 s deadline being marginal, not comfortable
Suggested fixes (either helps; both are small)
- Batch the decode: store all function bodies in one base64 blob (or plain
declare -ftext — which #50733 already requests for auditability) and decode once. Turns 85 forks+execs into ~1. Expected: ~6 s → well under 200 ms per call on this machine class. - Rationalize the creation timeout: either raise/scale it, or make the outcome consistent — right now timing out yields a faster session than succeeding, which is exactly backwards; and the partially-written snapshot file should be removed on timeout so file-presence matches behavior.
Workaround for affected users (validated)
Git for Windows' /etc/profile.d/git-prompt.sh sources ~/.config/git/git-prompt.sh instead of its completion-loading block when that file exists. A user-level override that replicates the stock prompt but skips git-completion.bash when CLAUDECODE=1 is set (Claude Code sets it on the snapshot-creation shell) drops the snapshot to ~1 eval / 4.9 KB with the interactive prompt byte-identical. After deploying it: per-call floors converge to ~1.0–1.3 s in every new session. Happy to share the override file in a comment if useful.