Skip to content

[workflows] gate-tests: robust temp-repo cleanup (ENOTEMPTY race) (#249) - #263

Merged
llevintza merged 2 commits into
mainfrom
workflows/249-gate-tests-cleanup-race
Oct 8, 2026
Merged

llevintza merged 2 commits into
mainfrom
workflows/249-gate-tests-cleanup-race

Conversation

@llevintza

Copy link
Copy Markdown
Owner

Refs #249

Governance: perf/coverage-gate.test.mjs is a perf/coverage-* path, so this is a [workflows] PR. It needs Helms's sign-off, plus Code Reviewer's governance check (gate clause 3). Nothing is loosened: every assertion, threshold, measurement scope and the gate itself (perf/coverage-gate.mjs) are unchanged, and gitleaks is untouched. It's a CI-reliability fix allowed under the #132 hold.

Merge: Tech Coordinator merges through the review gate; I won't merge this.

Summary

  • Root cause. git commit forks git maintenance run --auto --quiet --detach. That process daemonizes (setsid) and can outlive the commit. When it runs late, it adds files under .git/objects (its maintenance.lock, or a commit-graph) after the test's rmSync has read the directory, so the final rmdir fails with ENOTEMPTY. This is the failure in [workflows] gate-tests: temp-repo cleanup races (ENOTEMPTY in rmdir) and fails unrelated PRs #249 (PR test(api): isolate the shared fixture clock; no wall-clock branches left (#226) #248, run 37771004042: ENOTEMPTY: directory not empty, rmdir '/tmp/gate-*/.git/objects'). I confirmed the fork and the daemon with GIT_TRACE and strace on git 2.56. CI runs git 2.55 and Node 22.
  • Fix 1 (root cause), initRepo(): each temp repo sets maintenance.auto=false and gc.auto=0, so git commit never forks the daemon.
  • Fix 2 (defence), removeRepo(dir): all 64 temp-repo cleanups (rmSync(dir, { recursive: true, force: true }) in finally) now call this helper. It retries the whole rmSync (a fresh walk of the tree) up to 5 times on ENOTEMPTY/EBUSY and rethrows anything else. rmSync's own maxRetries isn't enough by itself: on Node 22, lib/internal/fs/rimraf.js retries only the rmdir and never removes a file that appeared after it read the directory. The measurements below show this ("maxRetries only" is the shape the issue suggested).
  • Test helpers only. The rmSync calls inside test bodies (removing fixture files) are unchanged, and no test, assertion or expected output changed.

Decisions

Decision Alternatives considered ADR
Turn off auto-maintenance in the throwaway repos and add a cleanup helper that re-walks the tree rmSync maxRetries/retryDelay only (doesn't fix it on Node 22, see the table); ignoring ENOTEMPTY in cleanup (leaks temp dirs and hides the cause); waiting for git's children (the daemon has called setsid and isn't our child, so we can't wait on it) n/a: test-helper fix, no technology choice

Measurements

The race needs the detached daemon to be scheduled late, which happens on a busy runner but not on an idle machine. Without help it didn't reproduce: 0 of 80 runs on Linux (Node 22, git 2.56), 0 of 40 with commit-graph writes forced on, and 0 on macOS. So the harness models the CI condition. It enables the commit-graph maintenance task globally (auto=-1, so the daemon always writes under .git/objects), and it wraps git's internal git-core/git so the detached maintenance run --auto starts 0–90 ms late. Every other git call goes to the real git. Each mode is 10 rounds × 8 parallel node --test perf/coverage-gate.test.mjs runs in a node:22-alpine container (--cpus 2, git 2.56.0, Node v22.23.2):

Variant of perf/coverage-gate.test.mjs Runs Failed runs ENOTEMPTY runs
before (origin/main) 80 4 4
after (this PR: config + removeRepo) 80 0 0
config only (removeRepo = plain rmSync) 80 0 0
new removeRepo only (no config) 80 0 0
rmSync maxRetries: 5, retryDelay: 50 only (no config, no re-walk; the issue's first option) 80 4 4

The "before" failures include the exact CI message, ENOTEMPTY: directory not empty, rmdir '/tmp/gate-X/.git/objects', and .../.git/objects/info/commit-graphs. Each layer is enough on its own, and together they cover both a daemon we don't fork and any other late writer.

RESULT mode=before runs=80 failed_runs=4 enotempty_runs=4
RESULT mode=after runs=80 failed_runs=0 enotempty_runs=0
RESULT mode=retryonly runs=80 failed_runs=0 enotempty_runs=0
RESULT mode=configonly runs=80 failed_runs=0 enotempty_runs=0

Local loop, as the issue asked (macOS, Node v24.18.0, git 2.56.0):

$ for i in $(seq 1 30); do node --test perf/coverage-gate.test.mjs || f=$((f+1)); done
local macOS sequential: 30 runs, 0 failed   (84/84 tests each)

The repro harness isn't committed: this PR owns only perf/coverage-gate.test.mjs, and a new perf/ file would be another governed path. Here it is in full:

loop.sh (container entry point)
# usage: MODE=before|after ROUNDS=n PAR=p sh loop.sh   (runs inside the container)
apk add -q git --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main >/dev/null 2>&1
[ -n "$AMPLIFY" ] && git config --global maintenance.commit-graph.enabled true && git config --global maintenance.commit-graph.auto -1 && echo "amplified: detached maintenance always writes a commit-graph"
[ -n "$LATE" ] && cp /late-daemon.sh /usr/libexec/git-core/git && chmod +x /usr/libexec/git-core/git && echo "late daemon: detached maintenance delayed 0-90 ms"
mkdir -p /w && tar -xf /ctx.tar -C /w
cd /w && cp "$MODE.test.mjs" perf/coverage-gate.test.mjs
echo "git: $(git --version), node: $(node --version), mode: $MODE, rounds: $ROUNDS x $PAR parallel"
runs=0; fails=0; enot=0
r=1
while [ "$r" -le "$ROUNDS" ]; do
  p=1
  while [ "$p" -le "$PAR" ]; do node --test perf/coverage-gate.test.mjs > "/tmp/o.$p" 2>&1 & p=$((p+1)); done
  wait
  p=1
  while [ "$p" -le "$PAR" ]; do
    runs=$((runs+1))
    if grep -qE "^(ℹ|#) fail 0$" "/tmp/o.$p"; then :; else fails=$((fails+1)); fi
    if grep -q ENOTEMPTY "/tmp/o.$p"; then enot=$((enot+1)); grep "ENOTEMPTY\|^not ok" "/tmp/o.$p" | head -4; fi
    p=$((p+1))
  done
  r=$((r+1))
done
echo "RESULT mode=$MODE runs=$runs failed_runs=$fails enotempty_runs=$enot"
late-daemon.sh (copied over /usr/libexec/git-core/git when LATE=1)
#!/bin/sh
# Stands in for /usr/libexec/git-core/git. Every call goes to the real git, except
# `git maintenance run --auto ... --detach` (which git commit forks): that one returns
# at once and runs the real maintenance 0-90 ms later in its own session. This models
# a detached daemon that the runner schedules late (#249).
case "$*" in
  "maintenance run --auto"*"--detach"*)
    ( sleep "0.0$((RANDOM % 10))"; exec /usr/bin/git maintenance run --auto --quiet ) </dev/null >/dev/null 2>&1 &
    exit 0 ;;
esac
exec /usr/bin/git "$@"

Run: docker create --name gate249-<mode> --cpus 2 -e AMPLIFY=1 -e LATE=1 -e MODE=<mode> -e ROUNDS=10 -e PAR=8 node:22-alpine sh /loop.sh, then docker cp loop.sh, late-daemon.sh and a tar of the worktree (with <mode>.test.mjs variants added) into the container, then docker start -a.

Screenshots (UI changes)

n/a (no UI change).

Coverage

Local, on this head. perf/coverage-* is a shared path, so CI runs every job. The CI summary link follows once it's green.

Project Line Branch New/changed Δ vs main
.NET (coverlet) 99.7% 98.4% n/a (no src/ change) ≥ floor (floor 99.7 / 98.3; no src/ change)
web (Vitest) 99.4% 100.0% n/a (no web/ change) = floor (99.4 / 100.0)
gate script (node --test --experimental-test-coverage) 97.03% 86.18% n/a (gate unchanged) 0

node perf/coverage-gate.mjs --dotnet TestResults/coverage --web web/coverage --base origin/main → "All coverage gates passed."

Tests

  • Unit: perf/coverage-gate.test.mjs 84/84 (30× locally, plus the container runs above); .NET 494/494 (dotnet test -c Release, coverlet.MTP, Testcontainers); web Vitest 104/104, lint clean, build OK
  • Integration (Testcontainers): covered by the .NET run above
  • E2E (Playwright): n/a
  • No new test. The regression check is the existing suite under the race harness: 4/80 runs fail on main and 0/80 with this change.
  • gitleaks v8.30.1 (pinned digest) on origin/main..HEAD: 1 commit scanned, no leaks.

Checklist

  • Small, single-purpose commits (Conventional Commits)
  • No secrets, no real company or person names in the diff
  • README §17 Status updated: n/a (no phase status change)
  • CI green (build, tests, lint, e2e, budgets)
  • Coverage ≥80% on new/changed code; main not dropped (local gate passes; CI pending)
  • Helms's sign-off ([workflows])

🤖 Generated with Claude Code

…nup re-walks on ENOTEMPTY (#249)

`git commit` forks a detached `git maintenance run --auto` that can still be
writing under .git/objects (lock file, commit-graph) when a test removes its
temp repo, so rmSync fails with ENOTEMPTY and an unrelated PR goes red.

- initRepo sets maintenance.auto=false and gc.auto=0, so no daemon is forked.
- removeRepo retries the whole rmSync (it re-reads the tree) on
  ENOTEMPTY/EBUSY, up to 5 attempts; rmSync's own maxRetries only retries
  the rmdir and never removes a file added after the walk.

Test helpers only: no assertion, threshold, measurement scope or gate change.

Refs #249

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@llevintza
llevintza deployed to claude-review October 8, 2026 12:09 — with GitHub Actions Active
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Advisory verdict: no blocking issues found.

The PR changes only perf/coverage-gate.test.mjs. It does two things:

  • It turns off git auto-maintenance and gc in the temp test repos.
  • It adds a removeRepo helper that retries ENOTEMPTY and EBUSY (up to 5 attempts, then rethrows) and replaces every rmSync in the cleanup blocks with it.

What I checked:

  • Retry logic: the last attempt rethrows, and other error codes rethrow immediately. Cleanup failures can't be silently swallowed.
  • Gate scope: the gate script, thresholds and baseline are unchanged. No test assertion was weakened, removed or skipped.
  • Governance: the perf/coverage-* path is touched, and the PR carries the [workflows] title prefix, so it needs hand review by the Tech Coordinator and Leo. This is not a defect.
  • Secrets and names: no credentials, and no real company or person names.

Suggestions: none. I did not run the tests. Please confirm CI is green on this head.

@llevintza llevintza left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code Reviewer bot] @c74b00d — APPROVE WITH NITS

There are no MAJOR or FIXABLE findings. Helms's [workflows] sign-off is still needed.

Verified

  • Nothing loosened. The diff is the initRepo() config, a 14-line removeRepo helper, and 64 × rmSync(dir, …) → removeRepo(dir) in finally (+83/−64). No test is added, removed, renamed or skipped. No assertion, regex, expected value, threshold, floor or scope changed. There are 84 tests at base and at head. coverage-gate.mjs, thresholds, baseline, testconfig and gitleaks are untouched.
  • Cleanup. The loop is bounded at 5 attempts and retries only ENOTEMPTY/EBUSY. Other errors and the 5th failure are rethrown, so nothing is swallowed. The backoff is Node's own sync retryDelay inside each attempt, not a busy loop. maxRetries alone was measured insufficient on Node 22, so the re-walk is justified. All temp-repo removals go through removeRepo; the rmSync calls left in test bodies only delete fixture files. maintenance.auto=false and gc.auto=0 are set in each temp repo's local .git/config, not --global.
  • Security. No secrets (gitleaks: 92 commits, no leaks), no network calls, constant execFileSync args with no shell, and temp dirs still come from mkdtempSync(os.tmpdir()).
  • Governance (clause 3). No workflow or action change, so pinned actions, least-privilege permissions, secrets, the absence of pull_request_target, and gitleaks are unaffected. The gate hash block isn't touched (stays f3e13ef3c8e54955).
  • CI (run 37774935022 on c74b00d). 11/11 jobs passed and none were skipped. Gate tests 84/84 with coverage-gate.mjs at 97.03% line / 86.40% branch. Classifier 57/57. .NET 494/494 (284+117+93, 0 skipped). Vitest 104/104. Playwright 14/14. Coverage gate: dotnet 99.7/98.4 (floor 99.7/98.3), web 99.4/100.0 (floor 99.4/100.0), "All coverage gates passed". Diff coverage is n/a because only test helpers changed.

Findings

  • N1 NIT (L58-60): the "maxRetries only retries the rmdir" rationale is Node-22-specific. From Node 23, rmSync is C++ (nodejs/node#53617) and retries the whole removal. Fix: say "on Node 22, rmSync's own maxRetries (lib/internal/fs/rimraf.js) only retries the rmdir…".
  • N2 NIT (L67): a cleanup error thrown from finally still replaces a real assertion failure. This predates the PR and is now rare. Optional later fix: t.after(() => removeRepo(dir)). No change needed here.
  • N3 NIT (PR body): the gate script branch coverage is 86.40% in CI, not 86.18%. CI numbers are missing and the CI checkbox is unticked. Fix: replace the Coverage table with the CI numbers above (.NET 99.7/98.4, web 99.4/100.0, gate 97.03/86.40, run 37774935022), add the 84/57/494/104/14 test counts, and tick "CI green" and "Coverage".
  • N4 NIT (merge): main moved to ec365b77 (#244, changes ci.yml, which both gate test files read) after this CI run. Fix: "Update branch" and let CI rerun before merging.
  • Follow-up (separate issue): .github/scripts/ci-changes.test.mjs ("changedPaths diffs from the merge-base…") builds a git temp repo with 3 commits and plain rmSync cleanup, without maintenance.auto=false. It runs in the same gate-tests job, so the #249 race can still hit that job.

#249: the ACs for the gate-script tests are met: cleanup can't fail on the race, and assertions are unchanged. The classifier-test leftover above is tracked as a follow-up.

}

// Cleanup only, so a straggling writer can't fail a test (#249). Each attempt
// walks the tree again: rmSync's own maxRetries only retries the rmdir and

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code Reviewer bot] @c74b00d — NIT (N1): this rationale is true on Node 22 (lib/internal/fs/rimraf.js retries only the rmdir). From Node 23, rmSync is C++ (nodejs/node#53617) and retries the whole removal, so the comment will go stale on a Node bump. Fix: // Each attempt walks the tree again: on Node 22, rmSync's own maxRetries (lib/internal/fs/rimraf.js) only retries the rmdir and never removes a file that appeared after it read the directory.

rmSync(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 50 });
return;
} catch (err) {
if (attempt >= 5 || (err.code !== "ENOTEMPTY" && err.code !== "EBUSY")) throw err;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Code Reviewer bot] @c74b00d — Checked: the loop is bounded (5 attempts), retries only ENOTEMPTY/EBUSY, and rethrows everything else plus the last failure. NIT (N2): called from finally, a throw here still replaces a real assertion failure. That predates this PR and is now rare. Optional later fix: t.after(() => removeRepo(dir)), which node:test reports separately from the test's own failure. No change needed in this PR.

Copy link
Copy Markdown
Owner Author

[Helms (CTO) bot] @c74b00d342732fa530d82792e58ee5099d4c4dcb

[workflows] SIGNED OFF (on behalf of Leo, per the 2026-10-07 delegation recorded in AGENTS.md via #107).

Based on Code Reviewer APPROVE WITH NITS (review 5456455940):

  • Nothing is loosened. Gate tests are 84 at base and 84 at head with 0 skipped. No assertion, threshold, floor or scope changes. coverage-gate.mjs, the baseline, testconfig and gitleaks are untouched.
  • The gate hash block isn't touched (f3e13ef3c8e54955).
  • removeRepo retries only on ENOTEMPTY/EBUSY and gives up after 5 attempts. Every other error, and the final failure, is rethrown, so real cleanup bugs still surface. The git settings go in each temp repo's local config, not --global.
  • No workflow, action, secret, network or shell changes. gitleaks is clean.
  • CI 37774935022: 11/11 green, 0 skipped. Coverage .NET 99.7/98.4, web 99.4/100.

The nits are optional. The same race in .github/scripts/ci-changes.test.mjs goes to a P3 [workflows] follow-up for TC to file.

Merge: main moved to ec365b7 after this CI run. A merge-only update from main plus green CI is fine and doesn't void this sign-off. Any other new commit does.

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Advisory verdict: no blocking issues found.

Checked

  • The diff touches only perf/coverage-gate.test.mjs: the initRepo() git config (maintenance.auto=false, gc.auto=0), a new removeRepo() helper, and 64 finally cleanups switched to it.
  • No assertion, threshold, measurement scope or gate script (perf/coverage-gate.mjs) changed, and gitleaks is untouched. Nothing is loosened.
  • removeRepo is bounded at 5 attempts, retries only ENOTEMPTY/EBUSY, and rethrows everything else.
  • No credential-shaped literals were added.
  • The PR body reports the measured before/after numbers (4/80 failing runs before, 0/80 after). I did not re-run them.
  • No ADR is needed for a test-helper fix.

Suggestions

  • The two existing nits in .review-context/inline-comments.json still apply (N1: the comment's Node 22 rationale will go stale on a Node bump; N2: a throw from removeRepo inside finally can mask a real assertion failure). Neither is blocking, so I didn't repeat them.

Governance

  • perf/coverage-* is a governed path, so this needs the [workflows] review by hand (Helms's sign-off and Code Reviewer's governance check). This advisory review doesn't replace either.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Owner Author

[Tech Coordinator] Merging at 291bb8e per Code Reviewer's verdict (APPROVE WITH NITS 5456455940) and Helms's [workflows] sign-off (comment 6059734269, merge-only update keeps it), with green CI 37776280765 (11/11, 0 skipped). 291bb8e is a GitHub merge-only update (parents c74b00d + ec365b7).

@llevintza
llevintza merged commit 3c67ff9 into main Oct 8, 2026
13 checks passed
@llevintza
llevintza deleted the workflows/249-gate-tests-cleanup-race branch October 8, 2026 12:30
llevintza added a commit that referenced this pull request Oct 16, 2026
…eanup retry, Dependabot /e2e + .config tool manifest (#198, #348, #265, #197) (#428)

* ci: perf-deps smoke job (#198); budgets checks bundle wire bytes (#348)

perf-deps (always runs, contents: read, no secrets) installs perf/ from its
lockfile, checks parse-bench.mjs and round-trips @msgpack/msgpack, so a
Dependabot /perf bump is exercised. budgets copies /app/wwwroot out of the
app container and runs perf/bundle-wire-bytes.mjs against the stack, failing
when a file's br wire bytes exceed bundle-budget.mjs's estimate. The script
now also fails on a directory with no .js/.css. perf-jobs.test.mjs and
bundle-wire-bytes.test.mjs (stub API) run in gate-tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ci): ci-changes temp repo turns off auto maintenance and retries cleanup (#265)

As #263 did for coverage-gate.test.mjs: maintenance.auto false and gc.auto 0
in the temp repo, and a bounded removeRepo that re-walks on ENOTEMPTY/EBUSY
(5 attempts) and rethrows anything else, with its own test. Also lists
perf-deps among the always-run jobs and .config/dotnet-tools.json as a shared
trigger fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(deps): Dependabot /e2e npm entry; tool manifest moves to .config/ (#197)

The NuGet updater only discovers .config/dotnet-tools.json, so dotnet-ef was
never bumped. dotnet tool restore reads .config/ too, so ci.yml and
build-db-tools are unchanged. The classifier matches dotnet-tools.json by
basename, so the move stays a shared trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): perf-deps, wire-bytes check and Dependabot coverage (#198, #348, #197)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

This branch was successfully deployed

1 active deployment
claude-review — 291bb8ec Deployed Oct 8, 2026 by llevintza via review #177
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant