fix(hermit): stop the git shim fork-bombing across sibling checkouts
`bin/git` resolves the system git by walking PATH with its own directory removed. Removing only its own directory is not enough. The agent guide mandates detached worktrees as the sanctioned isolation for parallel agents, so two checkouts of this repo routinely sit on one PATH — and each shim then stripped its own `bin/` and resolved `git` to the *other* checkout's identical shim, which resolved back. That is mutual recursion with no base case. Observed in practice: 5088 shim processes plus 2544 `awk` children, 85% of a 10666-process limit, after which every fork() on the machine failed with "Resource temporarily unavailable" and no build could start. Nothing in the output named git as the cause. Two changes, because one is the fix and the other is the blast radius: - PATH entries whose `git` carries the `hermit-git-shim-v1` marker are skipped, not just our own directory, so resolution reaches a real system git in one hop. The marker is deliberately repo-neutral — this shim and its CoCO descendant are the same script, and a machine carrying both must have each recognise the other. - `HERMIT_GIT_SHIM_ACTIVE` turns any residual loop into one loud error with the offending PATH, instead of a machine-wide outage. It is unset before the final exec, because hooks legitimately shell out to git and would otherwise fail as though they were the loop. `scripts/test-git-shim-recursion.sh` covers all four properties, including that no shim processes survive the sibling-on-PATH case. Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
This commit is contained in:
parent
995c740b87
commit
b3ce97b603
2 changed files with 180 additions and 0 deletions
27
bin/git
27
bin/git
|
|
@ -3,6 +3,25 @@ set -euo pipefail
|
|||
|
||||
readonly script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly minimum_version="2.46.0"
|
||||
# Sentinel identifying any copy of this shim, including a sibling checkout's.
|
||||
# Deliberately repo-neutral: this shim and its CoCO descendant are the same
|
||||
# script, and a machine carrying both must have each recognise the other.
|
||||
readonly shim_marker="hermit-git-shim-v1"
|
||||
|
||||
# Re-entry guard. Skipping only ${script_dir} below is not enough on its own:
|
||||
# two checkouts of this repo on one PATH each strip their own bin/ and resolve
|
||||
# git to the *other* shim, which resolves back — mutual recursion that forks
|
||||
# until the process table is exhausted and every fork() on the machine fails.
|
||||
# The marker scan below prevents that; this guard makes any residual loop a
|
||||
# single loud error instead of a machine-wide outage. It is unset before the
|
||||
# final exec so real git (and hooks that shell out to git) never see it.
|
||||
if [[ -n "${HERMIT_GIT_SHIM_ACTIVE:-}" ]]; then
|
||||
echo "error: the Hermit git shim re-entered itself resolving 'git'." >&2
|
||||
echo " PATH still contains another copy of this shim; no system Git was reached." >&2
|
||||
echo " PATH=${PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
export HERMIT_GIT_SHIM_ACTIVE=1
|
||||
|
||||
# Git is Hermit's own transport and therefore cannot safely be a Hermit package:
|
||||
# doing so recursively invokes Hermit while it holds its package lock. Keep this
|
||||
|
|
@ -12,6 +31,11 @@ path_without_hermit=""
|
|||
IFS=: read -r -a path_entries <<<"${PATH}"
|
||||
for entry in "${path_entries[@]}"; do
|
||||
[[ "${entry}" == "${script_dir}" ]] && continue
|
||||
# Drop sibling copies of this shim (other worktrees/checkouts on PATH), not
|
||||
# just our own directory — see the re-entry guard above for why.
|
||||
if [[ -f "${entry}/git" ]] && grep -qF "${shim_marker}" "${entry}/git" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
path_without_hermit="${path_without_hermit:+${path_without_hermit}:}${entry}"
|
||||
done
|
||||
|
||||
|
|
@ -44,4 +68,7 @@ if ! version_at_least "${installed_version}" "${minimum_version}"; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Real git must not inherit the guard: git hooks legitimately shell out to git,
|
||||
# and a leaked guard would fail them as though they were the recursion.
|
||||
unset HERMIT_GIT_SHIM_ACTIVE
|
||||
exec "${system_git}" "$@"
|
||||
|
|
|
|||
153
scripts/test-git-shim-recursion.sh
Executable file
153
scripts/test-git-shim-recursion.sh
Executable file
|
|
@ -0,0 +1,153 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression tests for the tracked Hermit git shim at bin/git.
|
||||
#
|
||||
# bin/git resolves the *system* git by walking PATH with its own directory
|
||||
# removed. Removing only its own directory is not enough: the repo agent guide mandates
|
||||
# detached worktrees as the sanctioned isolation for parallel agents, so two
|
||||
# checkouts of this repo routinely sit on one PATH. Each shim then stripped its
|
||||
# own bin/ and resolved `git` to the *other* checkout's identical shim, which
|
||||
# resolved back — mutual recursion that forks until the process table is
|
||||
# exhausted. Observed in practice: 5088 shim processes plus 2544 `awk` children,
|
||||
# 85% of a 10666-process limit, after which every fork() on the machine failed
|
||||
# with "Resource temporarily unavailable" and no build could start.
|
||||
#
|
||||
# The contract:
|
||||
# - a sibling copy of the shim on PATH is skipped, not execed, so resolution
|
||||
# reaches a real system git in one hop;
|
||||
# - re-entry is a single loud error, never an unbounded fork;
|
||||
# - the re-entry guard never leaks into the real git, because hooks
|
||||
# legitimately shell out to git and would fail as though they were the loop.
|
||||
#
|
||||
# Every shim invocation here is deadline-bounded: a regression must fail this
|
||||
# test, not take down the machine running it.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
shim="$repo_root/bin/git"
|
||||
tmp="$(mktemp -d)"
|
||||
# The recursion orphans its children, so reap by the unique tmp path rather
|
||||
# than by pid — killing the direct child alone would leave the chain running.
|
||||
cleanup() {
|
||||
pkill -9 -f "$tmp" 2>/dev/null || true
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
failures=0
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
pass() {
|
||||
printf 'ok: %s\n' "$1"
|
||||
}
|
||||
|
||||
if [[ ! -x "$shim" ]]; then
|
||||
fail "bin/git is missing or not executable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run a command with a hard deadline. Returns 124 on timeout, having reaped the
|
||||
# whole subtree under $tmp so a recursion regression cannot outlive the check.
|
||||
run_bounded() {
|
||||
local deadline="$1" outfile="$2"
|
||||
shift 2
|
||||
( "$@" >"$outfile" 2>&1 ) &
|
||||
local pid=$! waited=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
if ((waited >= deadline)); then
|
||||
pkill -9 -f "$tmp" 2>/dev/null || true
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
# A stand-in system git: new enough to clear the shim's version floor, and it
|
||||
# reports whether the re-entry guard leaked into it.
|
||||
mkdir -p "$tmp/system"
|
||||
cat >"$tmp/system/git" <<'FAKEGIT'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
echo "git version 99.0.0"
|
||||
else
|
||||
echo "guard=${HERMIT_GIT_SHIM_ACTIVE:-unset}"
|
||||
fi
|
||||
FAKEGIT
|
||||
chmod +x "$tmp/system/git"
|
||||
|
||||
# Two checkouts of this repo on one PATH — the configuration that fork-bombed.
|
||||
mkdir -p "$tmp/wtA/bin" "$tmp/wtB/bin"
|
||||
cp "$shim" "$tmp/wtA/bin/git"
|
||||
cp "$shim" "$tmp/wtB/bin/git"
|
||||
chmod +x "$tmp/wtA/bin/git" "$tmp/wtB/bin/git"
|
||||
|
||||
# The stand-in system git must precede the real one, but /usr/bin and /bin stay
|
||||
# on PATH: the shim itself calls dirname and awk, and dropping them would test a
|
||||
# broken environment rather than the resolution order under test.
|
||||
sibling_path="$tmp/wtA/bin:$tmp/wtB/bin:$tmp/system:/usr/bin:/bin"
|
||||
|
||||
# 1. Two shims on one PATH must resolve through to the system git, not recurse.
|
||||
if run_bounded 20 "$tmp/out1" env PATH="$sibling_path" "$tmp/wtA/bin/git" --version; then
|
||||
if [[ "$(cat "$tmp/out1")" == "git version 99.0.0" ]]; then
|
||||
pass "sibling shim on PATH is skipped; system git reached"
|
||||
else
|
||||
fail "expected 'git version 99.0.0', got: $(cat "$tmp/out1")"
|
||||
fi
|
||||
else
|
||||
status=$?
|
||||
if ((status == 124)); then
|
||||
fail "shim did not terminate with a sibling copy on PATH (recursion regression)"
|
||||
else
|
||||
fail "shim exited $status with a sibling copy on PATH: $(cat "$tmp/out1")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. The recursion must never be unbounded: assert the subtree stayed small.
|
||||
# A regression spawns thousands here; a correct shim spawns a handful.
|
||||
# Pass the path through the environment, not argv: with -v the pattern appears
|
||||
# in awk's own command line and the check matches itself.
|
||||
spawned="$(ps -axo args= |
|
||||
shim_glob="$tmp/wt" awk 'index($0, ENVIRON["shim_glob"]){n++} END{print n+0}')"
|
||||
if ((spawned == 0)); then
|
||||
pass "no shim processes left running"
|
||||
else
|
||||
fail "$spawned shim process(es) survived the resolution"
|
||||
fi
|
||||
|
||||
# 3. Re-entry is a single loud error rather than a fork.
|
||||
if run_bounded 20 "$tmp/out3" env PATH="$sibling_path" HERMIT_GIT_SHIM_ACTIVE=1 \
|
||||
"$tmp/wtA/bin/git" --version; then
|
||||
fail "re-entry guard did not trip when HERMIT_GIT_SHIM_ACTIVE was set"
|
||||
else
|
||||
status=$?
|
||||
if ((status == 124)); then
|
||||
fail "re-entry guard hung instead of failing fast"
|
||||
elif ((status == 1)) && grep -q "re-entered itself" "$tmp/out3"; then
|
||||
pass "re-entry guard fails fast with a diagnostic"
|
||||
else
|
||||
fail "re-entry guard exited $status: $(cat "$tmp/out3")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. The guard must not leak into the real git — hooks shell out to git and
|
||||
# would otherwise fail as though they were the recursion.
|
||||
if run_bounded 20 "$tmp/out4" env PATH="$sibling_path" "$tmp/wtA/bin/git" rev-parse; then
|
||||
if [[ "$(cat "$tmp/out4")" == "guard=unset" ]]; then
|
||||
pass "re-entry guard is cleared before exec'ing the system git"
|
||||
else
|
||||
fail "guard leaked into system git: $(cat "$tmp/out4")"
|
||||
fi
|
||||
else
|
||||
fail "shim failed invoking the system git: $(cat "$tmp/out4")"
|
||||
fi
|
||||
|
||||
if ((failures > 0)); then
|
||||
printf '\n%d check(s) failed\n' "$failures" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '\nall git shim recursion checks passed\n'
|
||||
Loading…
Add table
Add a link
Reference in a new issue