Skip to content

(test): drain a capped git instead of killing it, so its repository can be removed on Windows - #336

Merged
devsuitup merged 6 commits into
mainfrom
fix/wait-for-child-exit
Sep 29, 2026
Merged

devsuitup merged 6 commits into
mainfrom
fix/wait-for-child-exit

Conversation

@jbr-sekoia

@jbr-sekoia jbr-sekoia commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #332.

Defect

defaultRunGit (git-changes-file.js) and defaultLocalExec (git-changes-runner.js) ran git through execFile with a maxBuffer. On overrun, execFile kills the child. On Windows, a killed git leaves its working directory busy for a moment after execFile has reported its exit, so a caller that removes the repository right after the call gets EBUSY on rmdir. The subtest "a blob over the cap is refused too…" is the only real-git test that overruns the cap (4096-byte blob, 1025-byte cap), and it is the one that failed its cleanup.

The test's rmSync(…, { maxRetries: 10, retryDelay: 100 }) could not absorb it. On Node 20 and 22, fs.rmSync recursive goes through lib/internal/fs/rimraf.js, whose _rmdirSync retries only ENOTEMPTY/EEXIST/EPERM after emptying the directory. An EBUSY from the directory's first rmdir is thrown at once. The failing stack shows exactly that frame: _rmdirSync (node:internal/fs/rimraf:236:5), which is the first rmdirSync(path).

Evidence (windows-2022, temporary jobs on this branch, since removed)

  • Signature reproduced under pwsh, the shell the Test workflow uses, where git resolves to C:\Program Files\Git\bin\git.exe: the same EBUSY: resource busy or locked, rmdir '…\switchboard-gcf-real-XXXX\repo' as in run 36548459342.
  • Before/after in the same workflow run: the "before" jobs restored git-changes-file.js and git-changes-runner.js from main. Each job ran the subtest node --test --test-name-pattern="blob over the cap", 4 processes at a time, on node 20 and 22.
runs EBUSY
before (main) 8400 24
after (this branch) 8400 0

If the rate were unchanged (24/8400), the chance of seeing 0 in 8400 runs is about e^-24.

  • Direct probe: an execFile git killed by its timeout, then fs.rmdirSync(repo) inside the callback. It got EBUSY in 5 of 5 tries. The process tree shows bin\git.exe (the PID execFile holds) with mingw64\bin\git.exe as its child. At callback time process.kill(pid, 0) reports neither of them alive. The same probe with the drained read got ENOTEMPTY (the directory is free) in 5 of 5 tries.
  • Linux cannot reproduce it: removing a directory that another process is using succeeds there.

Change

  • run-to-exit.js: a spawn that settles on close. Past the stdout cap it keeps reading and drops the bytes, so git exits on its own. The result carries overflow: true and the same stdout maxBuffer length exceeded message as before, so isStdoutCapFailure and the too-large refusal are unchanged. A timeout still kills, after destroying the pipes on this side the way execFile does. Without that, a grandchild that still holds them would delay close (covered by a test that fails without it).
  • git-changes-file.js, git-changes-runner.js: both local runners go through it. Their result shapes (code, stdout, stderr, tooLarge) are the same as before.
  • test/run-to-exit.test.js: overrun (the child runs to its own end and has exited when the call settles; this fails against execFile, which kills it), timeout, a timeout with a grandchild holding the pipes, spawn failure, clean exit, non-zero exit.
  • The two real-git test files: the cleanup comments described a mechanism that was not the one at work. Each is now a pointer to the doc.
  • .ai/contexts/changes-view.md: new section "A capped read waits for git to exit", plus the references to execFile updated.

Not verified

  • Why a killed git keeps the directory busy after both processes are reported gone: whether the launcher's child is terminated asynchronously or the handles are released late. Only the observable was measured: busy after a kill, free after a natural exit.
  • A git killed by the 10 s timeout still leaves its directory busy for a moment on Windows. This PR does not change that. Only the cap path stopped killing.
  • Draining means an overrun now costs the time to read git's whole output rather than stopping at the cap: a very large blob for the editor, or a status -uall over 20 MB for the runner. The 10 s timeout still bounds it. Not measured on a large repository.
  • The full npm run coverage suite on windows-2022 passed on this branch. That is one run per node version, not a flake-rate measurement of the whole suite.

Removes the CI loop and the diagnostic scripts used to measure the EBUSY
on windows-2022, and rewrites the changes-view section to what the runs
showed: a killed git leaves its working directory busy for a moment
after its exit is reported, although neither the PATH launcher nor the
mingw64 git it starts is still reported alive; a git drained to its end
does not (24 of 8400 runs before, 0 of 8400 after).
@jbr-sekoia jbr-sekoia changed the title (test): wait for a capped git child to exit before resolving (test): drain a capped git instead of killing it, so its repository can be removed on Windows Sep 29, 2026
@jbr-sekoia
jbr-sekoia marked this pull request as ready for review September 29, 2026 13:42

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Adversarial review at 59103e4 — approved. CI all green on this head.

Equivalence with execFile, checked for every caller: cwd, env: localGitEnv(), windowsHide: true carried over, shell false, same argv; a missing git/cwd gives code -1 with the spawn message, a signal/overflow/timeout -1, a non-zero exit keeps its code; callers read only code, stderr, tooLarge (fed from overflow); defaultRunGit still returns raw Buffers (binary safe), defaultLocalExec decodes the concatenated Buffer (split multibyte safe); stderr is capped at maxBuffer, so no unbounded growth; finish settles once; the timer is always armed on these paths (10 s local, 20 s remote). run-to-exit, git-changes-file-real-git, git-changes-runner-real-git: 79 pass, 2 skipped.

Non-blocking:

  • Nothing pins the callers to runToExit. With origin/main's execFile versions of git-changes-file.js and git-changes-runner.js put back, the two real-git files still pass (73/73) — the EBUSY is 24 in 8400. test/run-to-exit.test.js proves "drain, don't kill" for the module, not that the runners use it; a revert would go green. One test through defaultRunGit/defaultLocalExec with an injected spawn (or a source pin, the house pattern for wiring) would close that. The CI figures in the body are strong evidence for the fix itself; job links for the before/after runs would make them checkable.
  • Latency on overrun (run-to-exit.js:42-51): a capped read used to fail at the cap; it now waits for git to exit, up to the 10 s timeout — a huge status -uall delays the collapsed retry, a big blob delays "too large", by as much. The body says so; worth a line in changes-view.md, and a test for timeout + overflow together (overflow is checked first in finish, so the retry still triggers — by reading).
  • Timeout path (:59-66): child.kill() reaches the bin\git.exe wrapper only; if the kill itself fails, no close and the promise never settles. Same weakness as execFile, not a regression; a fallback settle would make it total.
  • Pre-existing, unchanged: isStdoutCapFailure reads stderr || message, so git stderr before an overrun hides the maxBuffer message and skips the retry. Carrying overflow through would be more direct.

Nits: the title says (test): but the change is in the Changes panel's product path — (changes): let a capped git child run to exit would say what lands. Stale mentions of execFile's maxBuffer remain at .ai/contexts/changes-view.md:42 and git-changes-runner.js:212. The cap now counts bytes where the old string-mode local exec counted characters — harmless.

@devsuitup
devsuitup merged commit 61a228a into main Sep 29, 2026
10 checks passed
@devsuitup
devsuitup deleted the fix/wait-for-child-exit branch September 29, 2026 13:55
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.

(ci): git-changes-file real-git test fails on windows-2022 with EBUSY while removing its temp directory

2 participants