Repository navigation
[workflows] gate-tests: robust temp-repo cleanup (ENOTEMPTY race) (#249) - #263
Conversation
…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>
|
Advisory verdict: no blocking issues found. The PR changes only
What I checked:
Suggestions: none. I did not run the tests. Please confirm CI is green on this head. |
llevintza
left a comment
There was a problem hiding this comment.
[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-lineremoveRepohelper, and 64 ×rmSync(dir, …)→removeRepo(dir)infinally(+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
retryDelayinside each attempt, not a busy loop.maxRetriesalone was measured insufficient on Node 22, so the re-walk is justified. All temp-repo removals go throughremoveRepo; thermSynccalls left in test bodies only delete fixture files.maintenance.auto=falseandgc.auto=0are set in each temp repo's local.git/config, not--global. - Security. No secrets (gitleaks: 92 commits, no leaks), no network calls, constant
execFileSyncargs with no shell, and temp dirs still come frommkdtempSync(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 (staysf3e13ef3c8e54955). - CI (run 37774935022 on c74b00d). 11/11 jobs passed and none were skipped. Gate tests 84/84 with
coverage-gate.mjsat 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,
rmSyncis 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
finallystill 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 plainrmSynccleanup, withoutmaintenance.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 |
There was a problem hiding this comment.
[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; |
There was a problem hiding this comment.
[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.
|
[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):
The nits are optional. The same race in 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. |
|
Advisory verdict: no blocking issues found. Checked
Suggestions
Governance
🤖 Generated with Claude Code |
…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>
Refs #249
Governance:
perf/coverage-gate.test.mjsis aperf/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
git commitforksgit maintenance run --auto --quiet --detach. That process daemonizes (setsid) and can outlive the commit. When it runs late, it adds files under.git/objects(itsmaintenance.lock, or a commit-graph) after the test'srmSynchas read the directory, so the finalrmdirfails withENOTEMPTY. 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 withGIT_TRACEandstraceon git 2.56. CI runs git 2.55 and Node 22.initRepo(): each temp repo setsmaintenance.auto=falseandgc.auto=0, sogit commitnever forks the daemon.removeRepo(dir): all 64 temp-repo cleanups (rmSync(dir, { recursive: true, force: true })infinally) now call this helper. It retries the wholermSync(a fresh walk of the tree) up to 5 times onENOTEMPTY/EBUSYand rethrows anything else.rmSync's ownmaxRetriesisn't enough by itself: on Node 22,lib/internal/fs/rimraf.jsretries only thermdirand never removes a file that appeared after it read the directory. The measurements below show this ("maxRetries only" is the shape the issue suggested).rmSynccalls inside test bodies (removing fixture files) are unchanged, and no test, assertion or expected output changed.Decisions
rmSyncmaxRetries/retryDelayonly (doesn't fix it on Node 22, see the table); ignoringENOTEMPTYin cleanup (leaks temp dirs and hides the cause); waiting for git's children (the daemon has calledsetsidand isn't our child, so we can't wait on it)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 internalgit-core/gitso the detachedmaintenance run --autostarts 0–90 ms late. Every other git call goes to the real git. Each mode is 10 rounds × 8 parallelnode --test perf/coverage-gate.test.mjsruns in anode:22-alpinecontainer (--cpus 2, git 2.56.0, Node v22.23.2):perf/coverage-gate.test.mjsremoveRepo)removeRepo= plainrmSync)removeRepoonly (no config)rmSyncmaxRetries: 5, retryDelay: 50only (no config, no re-walk; the issue's first option)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.Local loop, as the issue asked (macOS, Node v24.18.0, git 2.56.0):
The repro harness isn't committed: this PR owns only
perf/coverage-gate.test.mjs, and a newperf/file would be another governed path. Here it is in full:loop.sh(container entry point)late-daemon.sh(copied over/usr/libexec/git-core/gitwhenLATE=1)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, thendocker cploop.sh,late-daemon.shand a tar of the worktree (with<mode>.test.mjsvariants added) into the container, thendocker 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.src/change)src/change)web/change)node --test --experimental-test-coverage)node perf/coverage-gate.mjs --dotnet TestResults/coverage --web web/coverage --base origin/main→ "All coverage gates passed."Tests
perf/coverage-gate.test.mjs84/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 OKorigin/main..HEAD: 1 commit scanned, no leaks.Checklist
[workflows])🤖 Generated with Claude Code