From 7c4c75f40a5e8023e850238fd8a0d01b173450f0 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Tue, 29 Sep 2026 14:42:40 +0200 Subject: [PATCH 1/6] ci: loop the blob-over-the-cap subtest on windows-2022 (temporary) --- .github/workflows/flake-loop.yml | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/flake-loop.yml diff --git a/.github/workflows/flake-loop.yml b/.github/workflows/flake-loop.yml new file mode 100644 index 00000000..088f063e --- /dev/null +++ b/.github/workflows/flake-loop.yml @@ -0,0 +1,33 @@ +name: Flake loop (temporary) + +on: + pull_request: + branches: [main] + +jobs: + loop: + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + node-version: [20, 22] + shard: [1, 2, 3] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - name: Run the blob-over-the-cap subtest repeatedly + shell: bash + run: | + runs=200; fail=0; ebusy=0 + for i in $(seq 1 $runs); do + if ! node --test --test-name-pattern="blob over the cap" test/git-changes-file-real-git.test.js > out.txt 2>&1; then + fail=$((fail+1)) + if grep -q EBUSY out.txt; then ebusy=$((ebusy+1)); fi + grep -E "not ok|EBUSY" out.txt | head -5 + fi + done + echo "RESULT node=${{ matrix.node-version }} shard=${{ matrix.shard }} runs=$runs failed=$fail ebusy=$ebusy" From 9491a9865a8bc8ce4822aab99ee9bc0a4d3f54b4 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Tue, 29 Sep 2026 14:46:08 +0200 Subject: [PATCH 2/6] ci: observe git processes after a capped read on windows-2022 (temporary) --- .github/workflows/flake-loop.yml | 18 ++++++++++ scripts/diag-capped-read.js | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 scripts/diag-capped-read.js diff --git a/.github/workflows/flake-loop.yml b/.github/workflows/flake-loop.yml index 088f063e..4f23d106 100644 --- a/.github/workflows/flake-loop.yml +++ b/.github/workflows/flake-loop.yml @@ -31,3 +31,21 @@ jobs: fi done echo "RESULT node=${{ matrix.node-version }} shard=${{ matrix.shard }} runs=$runs failed=$fail ebusy=$ebusy" + + diag: + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + node-version: [22] + blob: [4096, 8388608] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - name: Observe git processes after a capped read + shell: bash + run: node scripts/diag-capped-read.js 100 ${{ matrix.blob }} diff --git a/scripts/diag-capped-read.js b/scripts/diag-capped-read.js new file mode 100644 index 00000000..566bf625 --- /dev/null +++ b/scripts/diag-capped-read.js @@ -0,0 +1,60 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { readChangesFile } = require('../git-changes-file'); + +function git(cwd, args) { + return execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8' }); +} + +function gitProcesses() { + if (process.platform !== 'win32') return ''; + return execFileSync('powershell', ['-NoProfile', '-Command', + "Get-CimInstance Win32_Process -Filter \"Name='git.exe'\" | ForEach-Object { \"$($_.ProcessId) parent=$($_.ParentProcessId) $($_.ExecutablePath) :: $($_.CommandLine)\" }"], + { encoding: 'utf8' }); +} + +async function once(i, blobSize) { + const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'diag-cap-'))); + const repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + git(repo, ['init', '-q']); + git(repo, ['config', 'user.email', 'a@b.c']); + git(repo, ['config', 'user.name', 'a']); + fs.writeFileSync(path.join(repo, 'shrunk.txt'), 'x'.repeat(blobSize)); + git(repo, ['add', 'shrunk.txt']); + fs.writeFileSync(path.join(repo, 'shrunk.txt'), 'tiny\n'); + + const result = await readChangesFile({ cwd: repo, relPath: 'shrunk.txt', staged: false, maxBytes: 1024 }); + let rm = 'ok'; + try { fs.rmdirSync(repo); } catch (err) { rm = err.code; } + const procs = rm === 'EBUSY' ? gitProcesses() : ''; + let later = ''; + if (rm === 'EBUSY') { + const t0 = Date.now(); + for (;;) { + try { fs.rmSync(repo, { recursive: true, force: true }); later = `released after ${Date.now() - t0}ms`; break; } catch (err) { + if (Date.now() - t0 > 10000) { later = `still ${err.code} after 10s`; break; } + } + } + } + console.log(`#${i} blob=${blobSize} reason=${result.reason} first-rmdir=${rm} ${later}`); + if (procs) console.log(procs.trim()); + fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); + return rm; +} + +(async () => { + console.log('git on PATH:', process.platform === 'win32' ? execFileSync('where', ['git'], { encoding: 'utf8' }).trim() : ''); + const counts = {}; + const n = Number(process.argv[2] || 50); + const blobSize = Number(process.argv[3] || 4096); + for (let i = 1; i <= n; i++) { + const rm = await once(i, blobSize); + counts[rm] = (counts[rm] || 0) + 1; + } + console.log('DIAG-RESULT', JSON.stringify(counts)); +})(); From bf3cd3d503c1c936f02c93976f233959fcfecf84 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Tue, 29 Sep 2026 14:54:07 +0200 Subject: [PATCH 3/6] ci: run the loop under pwsh, the shell the test step uses (temporary) --- .github/workflows/flake-loop.yml | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/flake-loop.yml b/.github/workflows/flake-loop.yml index 4f23d106..ae9abc4a 100644 --- a/.github/workflows/flake-loop.yml +++ b/.github/workflows/flake-loop.yml @@ -20,17 +20,19 @@ jobs: cache: npm - run: npm ci - name: Run the blob-over-the-cap subtest repeatedly - shell: bash + shell: pwsh run: | - runs=200; fail=0; ebusy=0 - for i in $(seq 1 $runs); do - if ! node --test --test-name-pattern="blob over the cap" test/git-changes-file-real-git.test.js > out.txt 2>&1; then - fail=$((fail+1)) - if grep -q EBUSY out.txt; then ebusy=$((ebusy+1)); fi - grep -E "not ok|EBUSY" out.txt | head -5 - fi - done - echo "RESULT node=${{ matrix.node-version }} shard=${{ matrix.shard }} runs=$runs failed=$fail ebusy=$ebusy" + Get-Command git -All | ForEach-Object { "git candidate: $($_.Source)" } + $runs = 200; $fail = 0; $ebusy = 0 + for ($i = 1; $i -le $runs; $i++) { + $out = node --test --test-name-pattern="blob over the cap" test/git-changes-file-real-git.test.js 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + $fail++ + if ($out -match 'EBUSY') { $ebusy++ } + ($out -split "`n") | Select-String -Pattern 'not ok|EBUSY' | Select-Object -First 5 | ForEach-Object { $_.Line } + } + } + "RESULT node=${{ matrix.node-version }} shard=${{ matrix.shard }} runs=$runs failed=$fail ebusy=$ebusy" diag: runs-on: windows-2022 @@ -47,5 +49,5 @@ jobs: cache: npm - run: npm ci - name: Observe git processes after a capped read - shell: bash + shell: pwsh run: node scripts/diag-capped-read.js 100 ${{ matrix.blob }} From be7d0ea9c73fd459e280ff9d37290a3e9f9a5027 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Tue, 29 Sep 2026 15:06:53 +0200 Subject: [PATCH 4/6] fix(changes): let a capped git run to its end instead of killing it --- .ai/contexts/changes-view.md | 39 ++++++++++-- .github/workflows/flake-loop.yml | 36 +++++------- git-changes-file.js | 23 ++------ git-changes-runner.js | 18 ++---- run-to-exit.js | 74 +++++++++++++++++++++++ scripts/diag-loop.js | 34 +++++++++++ scripts/diag-orphan.js | 28 +++++++++ test/git-changes-file-real-git.test.js | 5 +- test/git-changes-runner-real-git.test.js | 4 +- test/run-to-exit.test.js | 75 ++++++++++++++++++++++++ 10 files changed, 273 insertions(+), 63 deletions(-) create mode 100644 run-to-exit.js create mode 100644 scripts/diag-loop.js create mode 100644 scripts/diag-orphan.js create mode 100644 test/run-to-exit.test.js diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 259386b9..eb8d570b 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -14,6 +14,7 @@ integration: `.ai/contexts/viewer-panel.md` ("Changes mode"). |---|---| | `git-changes.js` | Pure parser — no electron, no DOM, no fs. `require()`-d from `main.js` and from tests, same pattern as `remote-hosts.js` / `derive-project-path.js`. | | `git-changes-runner.js` | Runs the git commands, local or remote, behind one interface. | +| `run-to-exit.js` | The local spawn both runners share: settles on `close`, drains past the stdout cap instead of killing — see "A capped read waits for git to exit". | | `git-changes-target.js` | cwd resolution for the panel's IPCs, extracted out of `main.js` for testability (same rationale as `delete-session-target.js`). | | `git-changes-file.js` | The content pair and the write target behind the editable diff: the `:` guard, the repository-containment check, the read and the write. | | `git-changes-watch.js` | The registry behind `git-changes-watch`: arms `fs.watch`, debounces, re-arms after a rename, and reports the repo-relative path. | @@ -36,7 +37,7 @@ integration: `.ai/contexts/viewer-panel.md` ("Changes mode"). `createGitChangesRunner({kind, cwd, alias, exec, timeoutMs, fsOps})` → `{status(), diff(path, {staged, untracked}), isWorkTree()}`. `status()` runs three commands in parallel (`git status --porcelain=v2 --branch -uall -z`, `git diff --numstat -z`, `git diff --cached --numstat -z`), merges them, and reports `untrackedCollapsed` (see "Untracked files"). `diff()` runs `git diff [--cached] -- ` — or, with `untracked: true`, `git diff --no-index -- /dev/null ` (see "Untracked files") — capped at 512 KB (`MAX_DIFF_BYTES`) measured in UTF-8 bytes and cut on a line boundary, with a `truncated` flag. -- **Local** (`kind: 'local'`): `child_process.execFile('git', args, {cwd, timeout, maxBuffer})` — cwd is `execFile`'s own option, never a `-C` argument. No shell is invoked, so argument content cannot be interpreted as a command regardless of what it contains; timeout 10s. +- **Local** (`kind: 'local'`): `runToExit('git', args, {cwd, timeoutMs, maxBuffer})` (`run-to-exit.js`, a `child_process.spawn` with no shell) — cwd is the spawn's own option, never a `-C` argument. No shell is invoked, so argument content cannot be interpreted as a command regardless of what it contains; timeout 10s. See "A capped read waits for git to exit". - **Remote** (`kind: 'remote'`): the same ssh transport `remote-attach.js` already uses for the tmux probe/restore calls (`buildRemoteCommandArgs`, `defaultRunRemoteCommand`) — `ssh -o BatchMode=yes -o ConnectTimeout=5 -n "git -C '' '--literal-pathspecs' 'diff' '--' '' ..."`. Timeout 20s. This command string DOES run through a shell on the far end. - **`invoke(args, remoteOpts)`** is the single choke point both `status()` and `diff()` go through: it prepends `--literal-pathspecs` (`buildGitArgs`, see "Quoting rule") to every argv/command, and threads `remoteOpts.maxStdoutBytes` to the remote transport only (the local path's `execFile` `maxBuffer` already bounds it). @@ -90,9 +91,9 @@ git's default untracked mode; if the retry succeeds the result comes back `? dir/` rows, and a note saying the untracked listing is coarse. The retry is gated on the failure signature (`isStdoutCapFailure`: the remote -transport's own `stdout exceeded bytes`, or `execFile`'s -`stdout maxBuffer length exceeded` — both non-localized, one ours and one -Node's). Any other failure returns its own error untouched: retrying on every +transport's own `stdout exceeded bytes`, or the local runner's +`stdout maxBuffer length exceeded`, worded as Node's `execFile` words it — both +non-localized). Any other failure returns its own error untouched: retrying on every non-zero exit would tell a user whose repository is unreadable (`could not read directory: Permission denied`) that they have too many untracked files, and discard the real message on the way. If the retry itself @@ -351,7 +352,7 @@ of them produces `reason: 'not-a-repo'`. **A cwd that is gone is ruled out before the corroboration is trusted.** The walk below answers "no `.git` anywhere" for a path that does not exist, so a deleted worktree outside a repository would otherwise be reported as "not a git -repository". `execFile` happens to fail to spawn for such a cwd — code `-1`, not 128 — but the local and +repository". The local spawn happens to fail for such a cwd — code `-1`, not 128 — but the local and remote transports differ here (`git -C ` exits 128), so the check is an outcome of its own rather than something left to a code that happens not to match. It also decides the wording: `spawn git ENOENT` reads as "git is not @@ -723,3 +724,31 @@ hook), the scratch-repo test wrote `tracked.txt` into the outer repository's index and rewrote its local `user.email`; the test helper now drops `GIT_*` / `HUSKY*` for the scratch repo and disables its hooks, and the runner no longer trusts them either. + +## A capped read waits for git to exit + +Both local runners (`defaultRunGit` in `git-changes-file.js`, `defaultLocalExec` +in `git-changes-runner.js`) go through `runToExit` (`run-to-exit.js`), which +settles on the child's `close` and never kills a child for overrunning its +stdout cap. Past the cap it keeps reading and drops the bytes, so git runs to +its own end; the result carries `overflow: true` and the same +`stdout maxBuffer length exceeded` message `execFile` would give. + +The reason is Windows. The `git.exe` on `PATH` there (`C:\Program Files\Git\bin` +or `\cmd`) is a launcher that starts `mingw64\bin\git.exe` as its own child. +`execFile` kills an overrunning child, which on Windows terminates the launcher +only: the real git is left running, with the repository as its working +directory, after the call has returned. A process holding a directory as its +cwd makes that directory impossible to remove (`EBUSY`), which is how the +over-the-cap test in `test/git-changes-file-real-git.test.js` failed its cleanup +on `windows-2022`. Letting git finish means the launcher exits only after the +real git has, and `close` is the moment both are gone. + +A timeout still kills: a git that hangs cannot be waited for. On Windows that +kill has the same launcher-only reach, so a timed-out git can outlive the call. + +`fs.rmSync`'s `maxRetries` does not cover this failure on Node 20 and 22: their +recursive removal retries only after emptying a directory (`ENOTEMPTY`, +`EPERM`); an `EBUSY` on the first `rmdir` is thrown at once. The retries the +real-git tests pass cover Windows' delayed deletion of the files inside, not a +live process. diff --git a/.github/workflows/flake-loop.yml b/.github/workflows/flake-loop.yml index ae9abc4a..9e946d26 100644 --- a/.github/workflows/flake-loop.yml +++ b/.github/workflows/flake-loop.yml @@ -11,7 +11,8 @@ jobs: fail-fast: false matrix: node-version: [20, 22] - shard: [1, 2, 3] + variant: [before, after] + shard: [1, 2] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -19,35 +20,28 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - - name: Run the blob-over-the-cap subtest repeatedly + - name: Restore the runners from main + if: matrix.variant == 'before' shell: pwsh run: | - Get-Command git -All | ForEach-Object { "git candidate: $($_.Source)" } - $runs = 200; $fail = 0; $ebusy = 0 - for ($i = 1; $i -le $runs; $i++) { - $out = node --test --test-name-pattern="blob over the cap" test/git-changes-file-real-git.test.js 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - $fail++ - if ($out -match 'EBUSY') { $ebusy++ } - ($out -split "`n") | Select-String -Pattern 'not ok|EBUSY' | Select-Object -First 5 | ForEach-Object { $_.Line } - } - } - "RESULT node=${{ matrix.node-version }} shard=${{ matrix.shard }} runs=$runs failed=$fail ebusy=$ebusy" + git fetch origin main --depth=1 + git checkout FETCH_HEAD -- git-changes-file.js git-changes-runner.js + git status --short + - name: Run the blob-over-the-cap subtest repeatedly, four at a time + shell: pwsh + run: | + (Get-Command git).Source + node scripts/diag-loop.js 600 4 diag: runs-on: windows-2022 - strategy: - fail-fast: false - matrix: - node-version: [22] - blob: [4096, 8388608] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 22 cache: npm - run: npm ci - - name: Observe git processes after a capped read + - name: Does a killed git leave a git.exe behind shell: pwsh - run: node scripts/diag-capped-read.js 100 ${{ matrix.blob }} + run: node scripts/diag-orphan.js diff --git a/git-changes-file.js b/git-changes-file.js index d2b67783..8f3f11e3 100644 --- a/git-changes-file.js +++ b/git-changes-file.js @@ -5,7 +5,7 @@ const realFs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { execFile } = require('child_process'); +const { runToExit } = require('./run-to-exit'); const { localGitEnv } = require('./git-changes-runner'); const { parseStatusPorcelainV2 } = require('./git-changes'); const { resolveOnDisk, isInsideDir } = require('./resolve-path-on-disk'); @@ -51,23 +51,10 @@ function requireLocalTarget(target) { return target; } -function defaultRunGit(args, { cwd, timeoutMs, maxBuffer }) { - return new Promise((resolve) => { - execFile('git', args, { cwd, env: localGitEnv(), timeout: timeoutMs, maxBuffer, encoding: 'buffer', windowsHide: true }, - (err, stdout, stderr) => { - const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout || ''); - if (err) { - resolve({ - code: typeof err.code === 'number' ? err.code : -1, - stdout: out, - stderr: String(stderr || err.message || ''), - tooLarge: err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', - }); - return; - } - resolve({ code: 0, stdout: out, stderr: '', tooLarge: false }); - }); - }); +async function defaultRunGit(args, { cwd, timeoutMs, maxBuffer }) { + const result = await runToExit('git', args, { cwd, env: localGitEnv(), timeoutMs, maxBuffer }); + if (result.code === 0) return { code: 0, stdout: result.stdout, stderr: '', tooLarge: false }; + return { code: result.code, stdout: result.stdout, stderr: result.stderr || result.message, tooLarge: result.overflow }; } // see .ai/contexts/changes-view.md ("Containment, and which path the write runs on") diff --git a/git-changes-runner.js b/git-changes-runner.js index 1474e6d2..4e1b5e44 100644 --- a/git-changes-runner.js +++ b/git-changes-runner.js @@ -2,7 +2,7 @@ 'use strict'; -const { execFile } = require('child_process'); +const { runToExit } = require('./run-to-exit'); const fs = require('fs'); const path = require('path'); const { defaultRunRemoteCommand } = require('./remote-attach'); @@ -184,17 +184,11 @@ function localGitEnv() { return env; } -function defaultLocalExec(args, { cwd, timeoutMs }) { - return new Promise((resolve) => { - execFile('git', args, { cwd, env: localGitEnv(), timeout: timeoutMs, maxBuffer: LOCAL_MAX_BUFFER, windowsHide: true }, - (err, stdout, stderr) => { - if (err) { - resolve({ code: typeof err.code === 'number' ? err.code : -1, stdout: stdout || '', stderr: stderr || err.message || String(err) }); - return; - } - resolve({ code: 0, stdout: stdout || '', stderr: stderr || '' }); - }); - }); +async function defaultLocalExec(args, { cwd, timeoutMs }) { + const result = await runToExit('git', args, { cwd, env: localGitEnv(), timeoutMs, maxBuffer: LOCAL_MAX_BUFFER }); + const stdout = result.stdout.toString('utf8'); + if (result.code === 0) return { code: 0, stdout, stderr: result.stderr }; + return { code: result.code, stdout, stderr: result.stderr || result.message }; } // Bounds what git wrote — see .ai/contexts/changes-view.md ("Bounded error messages") diff --git a/run-to-exit.js b/run-to-exit.js new file mode 100644 index 00000000..20950d25 --- /dev/null +++ b/run-to-exit.js @@ -0,0 +1,74 @@ +'use strict'; + +const { spawn } = require('child_process'); + +// Settles on 'close' and never kills over a stdout cap — see .ai/contexts/changes-view.md ("A capped read waits for git to exit") +function runToExit(file, args, { cwd, env, timeoutMs, maxBuffer }, spawnFn = spawn) { + return new Promise((resolve) => { + const command = [file, ...args].join(' '); + const stdoutChunks = []; + const stderrChunks = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let overflow = false; + let timedOut = false; + let spawnError = null; + let settled = false; + let timer = null; + + const finish = (exitCode) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks).toString('utf8'); + let message = ''; + if (spawnError) message = spawnError.message; + else if (overflow) message = 'stdout maxBuffer length exceeded'; + else if (exitCode !== 0) message = `Command failed: ${command}`; + const code = spawnError || overflow || timedOut || typeof exitCode !== 'number' ? -1 : exitCode; + resolve({ code, stdout, stderr, overflow, timedOut, message }); + }; + + let child; + try { + child = spawnFn(file, args, { cwd, env, windowsHide: true }); + } catch (err) { + spawnError = err; + finish(null); + return; + } + + child.stdout.on('data', (chunk) => { + if (overflow) return; + if (stdoutBytes + chunk.length > maxBuffer) { + stdoutChunks.push(chunk.subarray(0, maxBuffer - stdoutBytes)); + stdoutBytes = maxBuffer; + overflow = true; + return; + } + stdoutChunks.push(chunk); + stdoutBytes += chunk.length; + }); + child.stderr.on('data', (chunk) => { + if (stderrBytes >= maxBuffer) return; + const kept = chunk.subarray(0, maxBuffer - stderrBytes); + stderrChunks.push(kept); + stderrBytes += kept.length; + }); + if (timeoutMs > 0) { + timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + } + child.on('error', (err) => { + if (child.pid !== undefined) return; + spawnError = err; + finish(null); + }); + child.on('close', (exitCode) => finish(exitCode)); + }); +} + +module.exports = { runToExit }; diff --git a/scripts/diag-loop.js b/scripts/diag-loop.js new file mode 100644 index 00000000..6cbe59a6 --- /dev/null +++ b/scripts/diag-loop.js @@ -0,0 +1,34 @@ +'use strict'; + +const { spawn } = require('child_process'); + +const total = Number(process.argv[2] || 400); +const concurrency = Number(process.argv[3] || 4); +const pattern = process.argv[4] || 'blob over the cap'; +const file = process.argv[5] || 'test/git-changes-file-real-git.test.js'; + +let started = 0; +let done = 0; +let failed = 0; +let ebusy = 0; + +function one() { + if (started >= total) return; + started++; + const child = spawn(process.execPath, ['--test', `--test-name-pattern=${pattern}`, file], { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (d) => { out += d; }); + child.stderr.on('data', (d) => { out += d; }); + child.on('close', (code) => { + done++; + if (code !== 0) { + failed++; + if (/EBUSY/.test(out)) ebusy++; + console.log(out.split('\n').filter((l) => /not ok|EBUSY/.test(l)).slice(0, 4).join('\n')); + } + if (done === total) console.log(`RESULT runs=${total} concurrency=${concurrency} failed=${failed} ebusy=${ebusy}`); + else one(); + }); +} + +for (let i = 0; i < concurrency; i++) one(); diff --git a/scripts/diag-orphan.js b/scripts/diag-orphan.js new file mode 100644 index 00000000..c0f90c74 --- /dev/null +++ b/scripts/diag-orphan.js @@ -0,0 +1,28 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFile, execFileSync } = require('child_process'); + +function gitProcesses() { + return execFileSync('powershell', ['-NoProfile', '-Command', + "Get-CimInstance Win32_Process -Filter \"Name='git.exe'\" | ForEach-Object { \"$($_.ProcessId) parent=$($_.ParentProcessId) $($_.ExecutablePath) :: $($_.CommandLine)\" }"], + { encoding: 'utf8' }).trim(); +} + +const repo = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'diag-orphan-'))); +execFileSync('git', ['init', '-q'], { cwd: repo }); + +const child = execFile('git', ['cat-file', '--batch'], { cwd: repo, timeout: 1000, windowsHide: true }, (err) => { + console.log(`callback: killed=${err && err.killed} signal=${err && err.signal} wrapper pid=${child.pid} exitCode=${child.exitCode} signalCode=${child.signalCode}`); + console.log('git.exe processes after the callback:'); + console.log(gitProcesses() || '(none)'); + let rm = 'ok'; + try { fs.rmdirSync(repo); } catch (e) { rm = e.code; } + console.log(`first rmdir of the repo: ${rm}`); +}); +setTimeout(() => { + console.log('git.exe processes while running:'); + console.log(gitProcesses() || '(none)'); +}, 200); diff --git a/test/git-changes-file-real-git.test.js b/test/git-changes-file-real-git.test.js index 843f5a63..1b910c1a 100644 --- a/test/git-changes-file-real-git.test.js +++ b/test/git-changes-file-real-git.test.js @@ -24,10 +24,7 @@ function mkTmp() { return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-gcf-real-'))); } -// The maxBuffer cap SIGTERMs an overrunning `git cat-file`, and execFile's -// callback runs before that child has been reaped (measured: exitCode null, -// killed true). On Windows a live process holds a handle on its working -// directory, so removing the scratch repo can race it — hence the retries. +// see .ai/contexts/changes-view.md ("A capped read waits for git to exit") function cleanup(dir) { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } diff --git a/test/git-changes-runner-real-git.test.js b/test/git-changes-runner-real-git.test.js index ea6d502a..9e6713bc 100644 --- a/test/git-changes-runner-real-git.test.js +++ b/test/git-changes-runner-real-git.test.js @@ -42,9 +42,7 @@ function mkTmp() { return fs.realpathSync.native(dir); } -// Same race as test/git-changes-file-real-git.test.js: a git child killed by a -// cap is still terminating when the assertion returns, and on Windows it holds -// its working directory until it dies. +// see .ai/contexts/changes-view.md ("A capped read waits for git to exit") function cleanup(dir) { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } diff --git a/test/run-to-exit.test.js b/test/run-to-exit.test.js new file mode 100644 index 00000000..26fdfc67 --- /dev/null +++ b/test/run-to-exit.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const { runToExit } = require('../run-to-exit'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-rte-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +} + +function node(script, opts = {}) { + let child = null; + const spawnFn = (file, args, options) => { + child = spawn(file, args, options); + return child; + }; + const promise = runToExit(process.execPath, ['-e', script], { cwd: opts.cwd || process.cwd(), env: process.env, timeoutMs: opts.timeoutMs || 10_000, maxBuffer: opts.maxBuffer || 1024 * 1024 }, spawnFn); + return { promise, child: () => child }; +} + +test('runToExit: a child that overruns the stdout cap is left to finish, and the call settles only once it has exited', async () => { + const tmp = mkTmp(); + try { + const marker = path.join(tmp, 'finished'); + const script = `process.stdout.write('x'.repeat(8192), () => setTimeout(() => { require('fs').writeFileSync(${JSON.stringify(marker)}, 'done'); }, 300));`; + const run = node(script, { maxBuffer: 1024 }); + const result = await run.promise; + assert.equal(result.overflow, true); + assert.equal(result.code, -1); + assert.equal(result.message, 'stdout maxBuffer length exceeded'); + assert.equal(result.stdout.length, 1024, 'what fits under the cap is kept, the rest is drained and dropped'); + assert.ok(fs.existsSync(marker), 'the child ran to its own end instead of being killed by the cap'); + assert.notEqual(run.child().exitCode, null, 'the child has exited by the time the call settles'); + } finally { cleanup(tmp); } +}); + +test('runToExit: a timeout kills the child and settles only after it has exited', async () => { + const run = node('setInterval(() => {}, 1000);', { timeoutMs: 200 }); + const result = await run.promise; + assert.equal(result.timedOut, true); + assert.equal(result.code, -1); + const child = run.child(); + assert.ok(child.exitCode !== null || child.signalCode !== null, 'the killed child has exited by the time the call settles'); +}); + +test('runToExit: a spawn that never ran settles with code -1 and the spawn error', async () => { + const result = await runToExit(process.execPath, ['-e', ''], { cwd: path.join(os.tmpdir(), 'switchboard-rte-missing-dir-does-not-exist'), env: process.env, timeoutMs: 10_000, maxBuffer: 1024 }); + assert.equal(result.code, -1); + assert.match(result.message, /ENOENT/); +}); + +test('runToExit: a clean exit returns stdout and stderr with code 0', async () => { + const result = await node("process.stdout.write('out'); process.stderr.write('warn');").promise; + assert.equal(result.code, 0); + assert.equal(result.stdout.toString('utf8'), 'out'); + assert.equal(result.stderr, 'warn'); + assert.equal(result.overflow, false); + assert.equal(result.message, ''); +}); + +test('runToExit: a non-zero exit keeps its code and stderr', async () => { + const result = await node("process.stderr.write('fatal: nope'); process.exitCode = 3;").promise; + assert.equal(result.code, 3); + assert.equal(result.stderr, 'fatal: nope'); + assert.match(result.message, /^Command failed: /); +}); From 83cd586e151002c157becb9439f6aaa5baf7d4b6 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Tue, 29 Sep 2026 15:16:43 +0200 Subject: [PATCH 5/6] fix(changes): close the pipes before a timeout kill, so a grandchild cannot hold the call --- .ai/contexts/changes-view.md | 6 ++-- .github/workflows/flake-loop.yml | 2 +- run-to-exit.js | 2 ++ scripts/diag-orphan.js | 59 ++++++++++++++++++++++++-------- test/run-to-exit.test.js | 8 +++++ 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index eb8d570b..20be4718 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -744,8 +744,10 @@ over-the-cap test in `test/git-changes-file-real-git.test.js` failed its cleanup on `windows-2022`. Letting git finish means the launcher exits only after the real git has, and `close` is the moment both are gone. -A timeout still kills: a git that hangs cannot be waited for. On Windows that -kill has the same launcher-only reach, so a timed-out git can outlive the call. +A timeout still kills: a git that hangs cannot be waited for. The pipes are +closed on this side first, as `execFile` does, so a surviving grandchild that +still holds them cannot delay `close`. On Windows that kill has the same +launcher-only reach, so a timed-out git can outlive the call. `fs.rmSync`'s `maxRetries` does not cover this failure on Node 20 and 22: their recursive removal retries only after emptying a directory (`ENOTEMPTY`, diff --git a/.github/workflows/flake-loop.yml b/.github/workflows/flake-loop.yml index 9e946d26..79e79d3a 100644 --- a/.github/workflows/flake-loop.yml +++ b/.github/workflows/flake-loop.yml @@ -31,7 +31,7 @@ jobs: shell: pwsh run: | (Get-Command git).Source - node scripts/diag-loop.js 600 4 + node scripts/diag-loop.js 1500 4 diag: runs-on: windows-2022 diff --git a/run-to-exit.js b/run-to-exit.js index 20950d25..bc1538f7 100644 --- a/run-to-exit.js +++ b/run-to-exit.js @@ -59,6 +59,8 @@ function runToExit(file, args, { cwd, env, timeoutMs, maxBuffer }, spawnFn = spa if (timeoutMs > 0) { timer = setTimeout(() => { timedOut = true; + child.stdout.destroy(); + child.stderr.destroy(); child.kill(); }, timeoutMs); } diff --git a/scripts/diag-orphan.js b/scripts/diag-orphan.js index c0f90c74..a52e0bc5 100644 --- a/scripts/diag-orphan.js +++ b/scripts/diag-orphan.js @@ -4,25 +4,54 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFile, execFileSync } = require('child_process'); +const { runToExit } = require('../run-to-exit'); -function gitProcesses() { - return execFileSync('powershell', ['-NoProfile', '-Command', - "Get-CimInstance Win32_Process -Filter \"Name='git.exe'\" | ForEach-Object { \"$($_.ProcessId) parent=$($_.ParentProcessId) $($_.ExecutablePath) :: $($_.CommandLine)\" }"], +function realGitChildOf(pid) { + const out = execFileSync('powershell', ['-NoProfile', '-Command', + `Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}" | ForEach-Object { "$($_.ProcessId) $($_.ExecutablePath)" }`], { encoding: 'utf8' }).trim(); + const line = out.split('\n').find((l) => /git\.exe/i.test(l)); + return line ? Number(line.split(' ')[0]) : null; +} + +function alive(pid) { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function waitGone(pid) { + const t0 = process.hrtime.bigint(); + while (alive(pid)) { /* spin */ } + return Number(process.hrtime.bigint() - t0) / 1e6; } const repo = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'diag-orphan-'))); execFileSync('git', ['init', '-q'], { cwd: repo }); +execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=a', 'commit', '-q', '--allow-empty', '-m', 'x'], { cwd: repo }); +fs.writeFileSync(path.join(repo, 'big.txt'), 'x'.repeat(64 * 1024 * 1024)); +execFileSync('git', ['add', 'big.txt'], { cwd: repo }); + +function execFileCase(label, args, opts) { + return new Promise((resolve) => { + let real = null; + const child = execFile('git', args, { cwd: repo, windowsHide: true, ...opts }, (err) => { + const at = alive(real); + const ms = at ? waitGone(real).toFixed(1) : '0'; + let rm = 'ok'; + try { fs.rmdirSync(repo); } catch (e) { rm = e.code; } + console.log(`${label}: err=${err && (err.code || err.signal)} launcher=${child.pid} real=${real} real-alive-at-callback=${at} real-gone-after=${ms}ms rmdir-at-callback=${rm}`); + resolve(); + }); + setTimeout(() => { real = realGitChildOf(child.pid); }, 50); + }); +} -const child = execFile('git', ['cat-file', '--batch'], { cwd: repo, timeout: 1000, windowsHide: true }, (err) => { - console.log(`callback: killed=${err && err.killed} signal=${err && err.signal} wrapper pid=${child.pid} exitCode=${child.exitCode} signalCode=${child.signalCode}`); - console.log('git.exe processes after the callback:'); - console.log(gitProcesses() || '(none)'); - let rm = 'ok'; - try { fs.rmdirSync(repo); } catch (e) { rm = e.code; } - console.log(`first rmdir of the repo: ${rm}`); -}); -setTimeout(() => { - console.log('git.exe processes while running:'); - console.log(gitProcesses() || '(none)'); -}, 200); +(async () => { + for (let i = 0; i < 5; i++) await execFileCase(`execFile timeout #${i}`, ['cat-file', '--batch'], { timeout: 2000 }); + for (let i = 0; i < 5; i++) await execFileCase(`execFile maxBuffer #${i}`, ['cat-file', 'blob', ':big.txt'], { maxBuffer: 1024, encoding: 'buffer' }); + for (let i = 0; i < 5; i++) { + const r = await runToExit('git', ['cat-file', 'blob', ':big.txt'], { cwd: repo, env: process.env, timeoutMs: 10000, maxBuffer: 1024 }); + let rm = 'ok'; + try { fs.rmdirSync(repo); } catch (e) { rm = e.code; } + console.log(`runToExit maxBuffer #${i}: overflow=${r.overflow} rmdir-at-settle=${rm}`); + } +})(); diff --git a/test/run-to-exit.test.js b/test/run-to-exit.test.js index 26fdfc67..7a128f53 100644 --- a/test/run-to-exit.test.js +++ b/test/run-to-exit.test.js @@ -52,6 +52,14 @@ test('runToExit: a timeout kills the child and settles only after it has exited' assert.ok(child.exitCode !== null || child.signalCode !== null, 'the killed child has exited by the time the call settles'); }); +test('runToExit: a timeout settles even when a grandchild still holds the output pipes', async () => { + const script = "require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 3000)'], { stdio: 'inherit' }); setInterval(() => {}, 1000);"; + const started = Date.now(); + const result = await node(script, { timeoutMs: 300 }).promise; + assert.equal(result.timedOut, true); + assert.ok(Date.now() - started < 2500, `settled after ${Date.now() - started} ms, not when the grandchild let go of the pipes`); +}); + test('runToExit: a spawn that never ran settles with code -1 and the spawn error', async () => { const result = await runToExit(process.execPath, ['-e', ''], { cwd: path.join(os.tmpdir(), 'switchboard-rte-missing-dir-does-not-exist'), env: process.env, timeoutMs: 10_000, maxBuffer: 1024 }); assert.equal(result.code, -1); From 59103e421c70983818653b6d628015b6944d99a5 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Tue, 29 Sep 2026 15:34:06 +0200 Subject: [PATCH 6/6] fix(changes): drop the temporary Windows loop, state what was measured 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). --- .ai/contexts/changes-view.md | 27 +++++++------- .github/workflows/flake-loop.yml | 47 ------------------------- run-to-exit.js | 2 +- scripts/diag-capped-read.js | 60 -------------------------------- scripts/diag-loop.js | 34 ------------------ scripts/diag-orphan.js | 57 ------------------------------ 6 files changed, 13 insertions(+), 214 deletions(-) delete mode 100644 .github/workflows/flake-loop.yml delete mode 100644 scripts/diag-capped-read.js delete mode 100644 scripts/diag-loop.js delete mode 100644 scripts/diag-orphan.js diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 20be4718..3f388f1c 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -734,23 +734,20 @@ stdout cap. Past the cap it keeps reading and drops the bytes, so git runs to its own end; the result carries `overflow: true` and the same `stdout maxBuffer length exceeded` message `execFile` would give. -The reason is Windows. The `git.exe` on `PATH` there (`C:\Program Files\Git\bin` -or `\cmd`) is a launcher that starts `mingw64\bin\git.exe` as its own child. -`execFile` kills an overrunning child, which on Windows terminates the launcher -only: the real git is left running, with the repository as its working -directory, after the call has returned. A process holding a directory as its -cwd makes that directory impossible to remove (`EBUSY`), which is how the -over-the-cap test in `test/git-changes-file-real-git.test.js` failed its cleanup -on `windows-2022`. Letting git finish means the launcher exits only after the -real git has, and `close` is the moment both are gone. +The reason is Windows. There, a git that was killed leaves its working +directory busy for a moment after `execFile` has reported its exit: the +repository cannot be removed (`EBUSY` on `rmdir`) although neither the `git.exe` +on `PATH` nor the `mingw64\bin\git.exe` it starts as its own child is still +reported alive. A git that runs to its own end does not. On `windows-2022` the +over-the-cap subtest of `test/git-changes-file-real-git.test.js` hit `EBUSY` in +its cleanup in 24 of 8400 runs while the cap killed git, and in none of 8400 +once git was drained instead. A timeout still kills: a git that hangs cannot be waited for. The pipes are -closed on this side first, as `execFile` does, so a surviving grandchild that -still holds them cannot delay `close`. On Windows that kill has the same -launcher-only reach, so a timed-out git can outlive the call. +closed on this side first, as `execFile` does, so a grandchild that still holds +them cannot delay `close`. On Windows a timed-out git therefore leaves its +working directory busy for a moment after the call has returned, as above. `fs.rmSync`'s `maxRetries` does not cover this failure on Node 20 and 22: their recursive removal retries only after emptying a directory (`ENOTEMPTY`, -`EPERM`); an `EBUSY` on the first `rmdir` is thrown at once. The retries the -real-git tests pass cover Windows' delayed deletion of the files inside, not a -live process. +`EPERM`); an `EBUSY` on the first `rmdir` of a directory is thrown at once. diff --git a/.github/workflows/flake-loop.yml b/.github/workflows/flake-loop.yml deleted file mode 100644 index 79e79d3a..00000000 --- a/.github/workflows/flake-loop.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Flake loop (temporary) - -on: - pull_request: - branches: [main] - -jobs: - loop: - runs-on: windows-2022 - strategy: - fail-fast: false - matrix: - node-version: [20, 22] - variant: [before, after] - shard: [1, 2] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: npm - - run: npm ci - - name: Restore the runners from main - if: matrix.variant == 'before' - shell: pwsh - run: | - git fetch origin main --depth=1 - git checkout FETCH_HEAD -- git-changes-file.js git-changes-runner.js - git status --short - - name: Run the blob-over-the-cap subtest repeatedly, four at a time - shell: pwsh - run: | - (Get-Command git).Source - node scripts/diag-loop.js 1500 4 - - diag: - runs-on: windows-2022 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - name: Does a killed git leave a git.exe behind - shell: pwsh - run: node scripts/diag-orphan.js diff --git a/run-to-exit.js b/run-to-exit.js index bc1538f7..e674e0e1 100644 --- a/run-to-exit.js +++ b/run-to-exit.js @@ -2,7 +2,7 @@ const { spawn } = require('child_process'); -// Settles on 'close' and never kills over a stdout cap — see .ai/contexts/changes-view.md ("A capped read waits for git to exit") +// see .ai/contexts/changes-view.md ("A capped read waits for git to exit") function runToExit(file, args, { cwd, env, timeoutMs, maxBuffer }, spawnFn = spawn) { return new Promise((resolve) => { const command = [file, ...args].join(' '); diff --git a/scripts/diag-capped-read.js b/scripts/diag-capped-read.js deleted file mode 100644 index 566bf625..00000000 --- a/scripts/diag-capped-read.js +++ /dev/null @@ -1,60 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { execFileSync } = require('child_process'); -const { readChangesFile } = require('../git-changes-file'); - -function git(cwd, args) { - return execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8' }); -} - -function gitProcesses() { - if (process.platform !== 'win32') return ''; - return execFileSync('powershell', ['-NoProfile', '-Command', - "Get-CimInstance Win32_Process -Filter \"Name='git.exe'\" | ForEach-Object { \"$($_.ProcessId) parent=$($_.ParentProcessId) $($_.ExecutablePath) :: $($_.CommandLine)\" }"], - { encoding: 'utf8' }); -} - -async function once(i, blobSize) { - const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'diag-cap-'))); - const repo = path.join(tmp, 'repo'); - fs.mkdirSync(repo); - git(repo, ['init', '-q']); - git(repo, ['config', 'user.email', 'a@b.c']); - git(repo, ['config', 'user.name', 'a']); - fs.writeFileSync(path.join(repo, 'shrunk.txt'), 'x'.repeat(blobSize)); - git(repo, ['add', 'shrunk.txt']); - fs.writeFileSync(path.join(repo, 'shrunk.txt'), 'tiny\n'); - - const result = await readChangesFile({ cwd: repo, relPath: 'shrunk.txt', staged: false, maxBytes: 1024 }); - let rm = 'ok'; - try { fs.rmdirSync(repo); } catch (err) { rm = err.code; } - const procs = rm === 'EBUSY' ? gitProcesses() : ''; - let later = ''; - if (rm === 'EBUSY') { - const t0 = Date.now(); - for (;;) { - try { fs.rmSync(repo, { recursive: true, force: true }); later = `released after ${Date.now() - t0}ms`; break; } catch (err) { - if (Date.now() - t0 > 10000) { later = `still ${err.code} after 10s`; break; } - } - } - } - console.log(`#${i} blob=${blobSize} reason=${result.reason} first-rmdir=${rm} ${later}`); - if (procs) console.log(procs.trim()); - fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); - return rm; -} - -(async () => { - console.log('git on PATH:', process.platform === 'win32' ? execFileSync('where', ['git'], { encoding: 'utf8' }).trim() : ''); - const counts = {}; - const n = Number(process.argv[2] || 50); - const blobSize = Number(process.argv[3] || 4096); - for (let i = 1; i <= n; i++) { - const rm = await once(i, blobSize); - counts[rm] = (counts[rm] || 0) + 1; - } - console.log('DIAG-RESULT', JSON.stringify(counts)); -})(); diff --git a/scripts/diag-loop.js b/scripts/diag-loop.js deleted file mode 100644 index 6cbe59a6..00000000 --- a/scripts/diag-loop.js +++ /dev/null @@ -1,34 +0,0 @@ -'use strict'; - -const { spawn } = require('child_process'); - -const total = Number(process.argv[2] || 400); -const concurrency = Number(process.argv[3] || 4); -const pattern = process.argv[4] || 'blob over the cap'; -const file = process.argv[5] || 'test/git-changes-file-real-git.test.js'; - -let started = 0; -let done = 0; -let failed = 0; -let ebusy = 0; - -function one() { - if (started >= total) return; - started++; - const child = spawn(process.execPath, ['--test', `--test-name-pattern=${pattern}`, file], { stdio: ['ignore', 'pipe', 'pipe'] }); - let out = ''; - child.stdout.on('data', (d) => { out += d; }); - child.stderr.on('data', (d) => { out += d; }); - child.on('close', (code) => { - done++; - if (code !== 0) { - failed++; - if (/EBUSY/.test(out)) ebusy++; - console.log(out.split('\n').filter((l) => /not ok|EBUSY/.test(l)).slice(0, 4).join('\n')); - } - if (done === total) console.log(`RESULT runs=${total} concurrency=${concurrency} failed=${failed} ebusy=${ebusy}`); - else one(); - }); -} - -for (let i = 0; i < concurrency; i++) one(); diff --git a/scripts/diag-orphan.js b/scripts/diag-orphan.js deleted file mode 100644 index a52e0bc5..00000000 --- a/scripts/diag-orphan.js +++ /dev/null @@ -1,57 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { execFile, execFileSync } = require('child_process'); -const { runToExit } = require('../run-to-exit'); - -function realGitChildOf(pid) { - const out = execFileSync('powershell', ['-NoProfile', '-Command', - `Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}" | ForEach-Object { "$($_.ProcessId) $($_.ExecutablePath)" }`], - { encoding: 'utf8' }).trim(); - const line = out.split('\n').find((l) => /git\.exe/i.test(l)); - return line ? Number(line.split(' ')[0]) : null; -} - -function alive(pid) { - try { process.kill(pid, 0); return true; } catch { return false; } -} - -function waitGone(pid) { - const t0 = process.hrtime.bigint(); - while (alive(pid)) { /* spin */ } - return Number(process.hrtime.bigint() - t0) / 1e6; -} - -const repo = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'diag-orphan-'))); -execFileSync('git', ['init', '-q'], { cwd: repo }); -execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=a', 'commit', '-q', '--allow-empty', '-m', 'x'], { cwd: repo }); -fs.writeFileSync(path.join(repo, 'big.txt'), 'x'.repeat(64 * 1024 * 1024)); -execFileSync('git', ['add', 'big.txt'], { cwd: repo }); - -function execFileCase(label, args, opts) { - return new Promise((resolve) => { - let real = null; - const child = execFile('git', args, { cwd: repo, windowsHide: true, ...opts }, (err) => { - const at = alive(real); - const ms = at ? waitGone(real).toFixed(1) : '0'; - let rm = 'ok'; - try { fs.rmdirSync(repo); } catch (e) { rm = e.code; } - console.log(`${label}: err=${err && (err.code || err.signal)} launcher=${child.pid} real=${real} real-alive-at-callback=${at} real-gone-after=${ms}ms rmdir-at-callback=${rm}`); - resolve(); - }); - setTimeout(() => { real = realGitChildOf(child.pid); }, 50); - }); -} - -(async () => { - for (let i = 0; i < 5; i++) await execFileCase(`execFile timeout #${i}`, ['cat-file', '--batch'], { timeout: 2000 }); - for (let i = 0; i < 5; i++) await execFileCase(`execFile maxBuffer #${i}`, ['cat-file', 'blob', ':big.txt'], { maxBuffer: 1024, encoding: 'buffer' }); - for (let i = 0; i < 5; i++) { - const r = await runToExit('git', ['cat-file', 'blob', ':big.txt'], { cwd: repo, env: process.env, timeoutMs: 10000, maxBuffer: 1024 }); - let rm = 'ok'; - try { fs.rmdirSync(repo); } catch (e) { rm = e.code; } - console.log(`runToExit maxBuffer #${i}: overflow=${r.overflow} rmdir-at-settle=${rm}`); - } -})();