diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index b51f292c29c6..c051869d4fdf 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -1,13 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listRegisteredPtysMock } = vi.hoisted(() => ({ - listRegisteredPtysMock: vi.fn() -})) +const { listRegisteredPtysMock, terminateWindowsSetupRunnersForWorktreeIdMock } = vi.hoisted( + () => ({ + listRegisteredPtysMock: vi.fn(), + terminateWindowsSetupRunnersForWorktreeIdMock: vi.fn() + }) +) vi.mock('../memory/pty-registry', () => ({ listRegisteredPtys: listRegisteredPtysMock })) +vi.mock('../windows-worktree-setup-runner-kill', () => ({ + terminateWindowsSetupRunnersForWorktreeId: terminateWindowsSetupRunnersForWorktreeIdMock +})) + import { killAllProcessesForWorktree, WORKTREE_PROCESS_SWEEP_TIMEOUT_MS } from './worktree-teardown' import type { IPtyProvider, PtyProcessInfo } from '../providers/types' import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' @@ -40,6 +47,8 @@ function createProviderStub(listProcesses: () => Promise): IPt describe('killAllProcessesForWorktree', () => { beforeEach(() => { listRegisteredPtysMock.mockReset() + terminateWindowsSetupRunnersForWorktreeIdMock.mockReset() + terminateWindowsSetupRunnersForWorktreeIdMock.mockResolvedValue(0) }) it('reaches daemon sessions and registry entries without a runtime', async () => { @@ -887,4 +896,28 @@ describe('killAllProcessesForWorktree', () => { vi.useRealTimers() } }) + + it('sweeps Windows setup-runner.cmd trees after PTY stop (#10629)', async () => { + const worktreeId = 'repo-1::D:\\orca\\workspaces\\repo\\feature' + const localProvider = createProviderStub(async () => [ + { id: `${worktreeId}@@pty-1`, cwd: 'D:\\orca\\workspaces\\repo\\feature', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([]) + + await killAllProcessesForWorktree(worktreeId, { localProvider, timeoutMs: 5_000 }) + + expect(localProvider.shutdown).toHaveBeenCalledWith( + `${worktreeId}@@pty-1`, + expect.objectContaining({ immediate: true }) + ) + expect(terminateWindowsSetupRunnersForWorktreeIdMock).toHaveBeenCalledWith( + worktreeId, + expect.objectContaining({ deadlineMs: expect.any(Number) }) + ) + const shutdownCall = (localProvider.shutdown as unknown as ReturnType).mock + .invocationCallOrder[0] + const setupRunnerSweepCall = + terminateWindowsSetupRunnersForWorktreeIdMock.mock.invocationCallOrder[0] + expect(setupRunnerSweepCall).toBeGreaterThan(shutdownCall) + }) }) diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 993a263d348f..7e73c952cc53 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -18,6 +18,7 @@ import { verifyUnstoppedPtys, type UnstoppedPtyVerdict } from './unstopped-pty-verification' +import { terminateWindowsSetupRunnersForWorktreeId } from '../windows-worktree-setup-runner-kill' // Why: normal inventories still coalesce into one process scan, while a stale // or pathological inventory cannot fan out unbounded provider/RPC shutdowns. @@ -252,6 +253,14 @@ export async function killAllProcessesForWorktree( } } + // Why: reparented setup-runner.cmd keeps Windows worktree dirs locked after PTY stop (#10629). + // Bound the sweep so a stalled process enumeration cannot escape the teardown deadline. + await settleBeforeDeadline( + () => terminateWindowsSetupRunnersForWorktreeId(worktreeId, { deadlineMs: deadline }), + 0, + deadline + ) + return { runtimeStopped: runtimeResult.stopped, providerStopped, registryStopped } } diff --git a/src/main/windows-worktree-setup-runner-kill.test.ts b/src/main/windows-worktree-setup-runner-kill.test.ts new file mode 100644 index 000000000000..fc1fb0d7f486 --- /dev/null +++ b/src/main/windows-worktree-setup-runner-kill.test.ts @@ -0,0 +1,380 @@ +import { join, resolve } from 'node:path' +import type * as fsPromises from 'node:fs/promises' +import { describe, expect, it, vi } from 'vitest' +import type { WindowsProcessRow } from './providers/windows-foreground-process-rows' +import { + commandLineTargetsWorktreeSetupRunner, + extractSetupRunnerPathsFromCommandLine, + resolveWorktreeGitDirPath, + terminateWindowsSetupRunnersForWorktree, + terminateWindowsSetupRunnersForWorktreeId +} from './windows-worktree-setup-runner-kill' + +function row( + partial: Partial & Pick +): WindowsProcessRow { + return { + ppid: 1, + name: 'cmd.exe', + executablePath: 'C:\\Windows\\System32\\cmd.exe', + ...partial + } +} + +describe('extractSetupRunnerPathsFromCommandLine', () => { + it('extracts drive-qualified setup-runner paths from cmd.exe lines', () => { + expect( + extractSetupRunnerPathsFromCommandLine( + 'C:\\Windows\\system32\\cmd.exe /c "D:/repo/.git/worktrees/feature/orca/setup-runner.cmd"' + ) + ).toEqual(['D:/repo/.git/worktrees/feature/orca/setup-runner.cmd']) + }) + + it('extracts backslash Windows paths', () => { + expect( + extractSetupRunnerPathsFromCommandLine( + 'cmd.exe /c C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd' + ) + ).toEqual(['C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd']) + }) +}) + +describe('commandLineTargetsWorktreeSetupRunner', () => { + const worktreePath = 'D:\\orca\\workspaces\\repo\\feature' + + it('matches setup-runner under a resolved linked-worktree gitdir', () => { + expect( + commandLineTargetsWorktreeSetupRunner( + 'C:\\Windows\\system32\\cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd', + worktreePath, + ['D:/repo/.git/worktrees/feature'] + ) + ).toBe(true) + }) + + it('matches linked-worktree setup-runner when the command references the gitdir anchor', () => { + expect( + commandLineTargetsWorktreeSetupRunner( + 'C:\\Windows\\system32\\cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd', + worktreePath, + ['D:/repo/.git/worktrees/feature'] + ) + ).toBe(true) + }) + + it('does not match a same-named worktree under a different repo root', () => { + // Why: basename-only `.git/worktrees/feature/...` must not kill another repo's runner. + expect( + commandLineTargetsWorktreeSetupRunner( + 'C:\\Windows\\system32\\cmd.exe /c D:/other/repo/.git/worktrees/feature/orca/setup-runner.cmd', + worktreePath + ) + ).toBe(false) + }) + + it('matches setup-runner living under the worktree path itself', () => { + expect( + commandLineTargetsWorktreeSetupRunner( + `cmd.exe /c ${worktreePath}\\.git\\orca\\setup-runner.cmd`, + worktreePath + ) + ).toBe(true) + }) + + it('ignores setup-runners for a different worktree name', () => { + expect( + commandLineTargetsWorktreeSetupRunner( + 'cmd.exe /c D:/repo/.git/worktrees/other-feature/orca/setup-runner.cmd', + worktreePath + ) + ).toBe(false) + }) + + it('ignores unrelated cmd.exe processes', () => { + expect( + commandLineTargetsWorktreeSetupRunner('C:\\Windows\\system32\\cmd.exe /c dir', worktreePath) + ).toBe(false) + }) +}) + +describe('resolveWorktreeGitDirPath', () => { + it('returns an absolute gitdir path as-is', async () => { + const readFileImpl = vi.fn( + async () => 'gitdir: D:/repo/.git/worktrees/feature\n' + ) as unknown as typeof fsPromises.readFile + await expect( + resolveWorktreeGitDirPath('D:\\orca\\workspaces\\repo\\feature', { readFileImpl }) + ).resolves.toBe('D:/repo/.git/worktrees/feature') + }) + + it('resolves a relative gitdir path from the worktree directory', async () => { + const readFileImpl = vi.fn( + async () => 'gitdir: ../repo/.git/worktrees/feature\n' + ) as unknown as typeof fsPromises.readFile + await expect(resolveWorktreeGitDirPath('/repo/worktree', { readFileImpl })).resolves.toBe( + resolve('/repo/worktree', '../repo/.git/worktrees/feature') + ) + }) + + it('returns null when .git is missing', async () => { + const readFileImpl = vi.fn(async () => { + throw Object.assign(new Error('enoent'), { code: 'ENOENT' }) + }) + await expect(resolveWorktreeGitDirPath('D:\\missing', { readFileImpl })).resolves.toBeNull() + }) + + it('returns the .git directory when EISDIR', async () => { + const readFileImpl = vi.fn(async () => { + throw Object.assign(new Error('eisdir'), { code: 'EISDIR' }) + }) + await expect(resolveWorktreeGitDirPath('/repo/main', { readFileImpl })).resolves.toBe( + join('/repo/main', '.git') + ) + }) +}) + +describe('terminateWindowsSetupRunnersForWorktree', () => { + it('no-ops on non-Windows platforms', async () => { + const listProcessRows = vi.fn(async () => [ + row({ + pid: 42, + command: 'cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd' + }) + ]) + const killTree = vi.fn(async () => {}) + await expect( + terminateWindowsSetupRunnersForWorktree('D:\\orca\\workspaces\\repo\\feature', { + platform: 'darwin', + listProcessRows, + killTree + }) + ).resolves.toBe(0) + expect(listProcessRows).not.toHaveBeenCalled() + expect(killTree).not.toHaveBeenCalled() + }) + + it('no-ops for non-Windows worktree paths even on win32', async () => { + const listProcessRows = vi.fn(async () => []) + await expect( + terminateWindowsSetupRunnersForWorktree('/home/me/repo/feature', { + platform: 'win32', + listProcessRows + }) + ).resolves.toBe(0) + expect(listProcessRows).not.toHaveBeenCalled() + }) + + it('taskkills matching setup-runner trees before Git removal (#10629)', async () => { + const listProcessRows = vi.fn(async () => [ + row({ + pid: 5936, + command: + 'C:\\Windows\\system32\\cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd' + }), + row({ + pid: 7000, + command: 'cmd.exe /c D:/repo/.git/worktrees/other/orca/setup-runner.cmd' + }), + row({ + pid: 8000, + command: 'powershell.exe -NoProfile' + }) + ]) + const killTree = vi.fn(async () => {}) + + const killed = await terminateWindowsSetupRunnersForWorktree( + 'D:\\orca\\workspaces\\repo\\feature', + { + platform: 'win32', + listProcessRows, + killTree, + pathAnchors: ['D:/repo/.git/worktrees/feature'] + } + ) + + expect(killed).toBe(1) + expect(killTree).toHaveBeenCalledTimes(1) + expect(killTree).toHaveBeenCalledWith(5936) + }) + + it('dedupes PIDs and swallows kill failures', async () => { + const listProcessRows = vi.fn(async () => [ + row({ + pid: 11, + command: 'cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd' + }), + row({ + pid: 11, + command: 'cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd' + }) + ]) + const killTree = vi.fn(async () => { + throw new Error('access denied') + }) + + await expect( + terminateWindowsSetupRunnersForWorktree('D:\\orca\\workspaces\\repo\\feature', { + platform: 'win32', + listProcessRows, + killTree, + pathAnchors: ['D:/repo/.git/worktrees/feature'] + }) + ).resolves.toBe(1) + expect(killTree).toHaveBeenCalledTimes(1) + }) + + it('returns 0 when process enumeration fails', async () => { + const listProcessRows = vi.fn(async () => { + throw new Error('powershell missing') + }) + const killTree = vi.fn(async () => {}) + await expect( + terminateWindowsSetupRunnersForWorktree('D:\\orca\\workspaces\\repo\\feature', { + platform: 'win32', + listProcessRows, + killTree + }) + ).resolves.toBe(0) + expect(killTree).not.toHaveBeenCalled() + }) + + it('bounds gitdir resolution by the teardown deadline', async () => { + vi.useFakeTimers() + try { + let resolveGitDir: (value: string | null) => void = () => {} + const listProcessRows = vi.fn(async () => []) + const deadlineMs = Date.now() + 10 + const sweep = terminateWindowsSetupRunnersForWorktree('D:\\orca\\workspaces\\repo\\feature', { + platform: 'win32', + resolveGitDirPath: () => + new Promise((resolve) => { + resolveGitDir = resolve + }), + listProcessRows, + deadlineMs, + now: Date.now + }) + + await vi.advanceTimersByTimeAsync(10) + await expect(sweep).resolves.toBe(0) + expect(listProcessRows).not.toHaveBeenCalled() + resolveGitDir(null) + } finally { + vi.useRealTimers() + } + }) + + it('bounds process enumeration by the teardown deadline', async () => { + vi.useFakeTimers() + try { + let resolveRows: (value: WindowsProcessRow[]) => void = () => {} + const killTree = vi.fn(async () => {}) + const deadlineMs = Date.now() + 10 + const sweep = terminateWindowsSetupRunnersForWorktree('D:\\orca\\workspaces\\repo\\feature', { + platform: 'win32', + pathAnchors: ['D:/repo/.git/worktrees/feature'], + listProcessRows: () => + new Promise((resolve) => { + resolveRows = resolve + }), + killTree, + deadlineMs, + now: Date.now + }) + + await vi.advanceTimersByTimeAsync(10) + await expect(sweep).resolves.toBe(0) + expect(killTree).not.toHaveBeenCalled() + resolveRows([]) + } finally { + vi.useRealTimers() + } + }) + + it('bounds process-tree termination aggregation by the teardown deadline', async () => { + vi.useFakeTimers() + try { + let resolveKill: () => void = () => {} + const listProcessRows = vi.fn(async () => [ + row({ + pid: 42, + command: 'cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd' + }) + ]) + const killTree = vi.fn( + () => + new Promise((resolve) => { + resolveKill = resolve + }) + ) + const deadlineMs = Date.now() + 10 + const sweep = terminateWindowsSetupRunnersForWorktree('D:\\orca\\workspaces\\repo\\feature', { + platform: 'win32', + pathAnchors: ['D:/repo/.git/worktrees/feature'], + listProcessRows, + killTree, + deadlineMs, + now: Date.now + }) + + await vi.advanceTimersByTimeAsync(0) + expect(killTree).toHaveBeenCalledWith(42) + await vi.advanceTimersByTimeAsync(10) + await expect(sweep).resolves.toBe(0) + resolveKill() + } finally { + vi.useRealTimers() + } + }) +}) + +describe('terminateWindowsSetupRunnersForWorktreeId', () => { + it('resolves the worktree path and kills matching runners', async () => { + const listProcessRows = vi.fn(async () => [ + row({ + pid: 42, + command: 'cmd.exe /c D:/repo/.git/worktrees/feature/orca/setup-runner.cmd' + }) + ]) + const killTree = vi.fn(async () => {}) + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + + await expect( + terminateWindowsSetupRunnersForWorktreeId('repo-1::D:\\orca\\workspaces\\repo\\feature', { + platform: 'win32', + listProcessRows, + killTree, + pathAnchors: ['D:/repo/.git/worktrees/feature'] + }) + ).resolves.toBe(1) + expect(killTree).toHaveBeenCalledWith(42) + expect(info).toHaveBeenCalledWith( + expect.stringContaining('killed 1 Windows setup-runner process tree(s)') + ) + info.mockRestore() + }) + + it('skips when the teardown deadline has already elapsed', async () => { + const listProcessRows = vi.fn(async () => []) + await expect( + terminateWindowsSetupRunnersForWorktreeId('repo-1::D:\\orca\\workspaces\\repo\\feature', { + platform: 'win32', + listProcessRows, + deadlineMs: 10, + now: () => 20 + }) + ).resolves.toBe(0) + expect(listProcessRows).not.toHaveBeenCalled() + }) + + it('skips POSIX worktree paths on win32', async () => { + const listProcessRows = vi.fn(async () => []) + await expect( + terminateWindowsSetupRunnersForWorktreeId('repo-1::/home/me/repo/feature', { + platform: 'win32', + listProcessRows + }) + ).resolves.toBe(0) + expect(listProcessRows).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/windows-worktree-setup-runner-kill.ts b/src/main/windows-worktree-setup-runner-kill.ts new file mode 100644 index 000000000000..1e79070c575b --- /dev/null +++ b/src/main/windows-worktree-setup-runner-kill.ts @@ -0,0 +1,258 @@ +import { readFile } from 'node:fs/promises' +import { isAbsolute, join, resolve } from 'node:path' +import { + isPathInsideOrEqual, + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../shared/cross-platform-path' +import { splitWorktreeIdForFilesystem } from '../shared/worktree-id' +import { + queryWindowsProcessRowsFresh, + type WindowsProcessRow +} from './providers/windows-foreground-process-rows' +import { terminateWindowsProcessTree, type WindowsTreeKiller } from './windows-process-tree-kill' + +export type WindowsSetupRunnerKillDeps = { + platform?: NodeJS.Platform + listProcessRows?: () => Promise + killTree?: WindowsTreeKiller + /** Extra roots (e.g. resolved gitdir) that own the worktree's setup-runner.cmd. */ + pathAnchors?: readonly string[] + resolveGitDirPath?: (worktreePath: string) => Promise + /** When set, skip the sweep once the worktree teardown budget has elapsed. */ + deadlineMs?: number + now?: () => number +} + +async function settleWindowsSweepBeforeDeadline( + run: () => Promise, + fallback: T, + deadlineMs: number | undefined, + now: () => number +): Promise { + if (deadlineMs === undefined) { + return run() + } + const remaining = deadlineMs - now() + if (remaining <= 0) { + return fallback + } + return new Promise((resolve) => { + let settled = false + let timer: ReturnType | undefined + const finish = (value: T): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + resolve(value) + } + timer = setTimeout(() => finish(fallback), remaining) + timer.unref?.() + void Promise.resolve() + .then(run) + .then(finish, () => finish(fallback)) + }) +} + +/** Best-effort linked-worktree gitdir (or bare `.git` dir) for path anchoring. */ +export async function resolveWorktreeGitDirPath( + worktreePath: string, + deps: { readFileImpl?: typeof readFile } = {} +): Promise { + const trimmed = worktreePath.trim() + if (!trimmed) { + return null + } + const read = deps.readFileImpl ?? readFile + try { + const content = await read(join(trimmed, '.git'), 'utf8') + const match = content.match(/^gitdir:\s*(.+?)\s*$/im) + if (!match) { + return null + } + const raw = match[1].trim() + if (!raw) { + return null + } + if (isAbsolute(raw) || isWindowsAbsolutePathLike(raw)) { + return raw + } + return resolve(trimmed, raw) + } catch (error) { + // Why: main checkouts store objects under a `.git` directory, not a gitdir file. + if ((error as NodeJS.ErrnoException | undefined)?.code === 'EISDIR') { + return join(trimmed, '.git') + } + return null + } +} + +/** Paths of `setup-runner.cmd` embedded in a Windows process CommandLine. */ +export function extractSetupRunnerPathsFromCommandLine(commandLine: string): string[] { + const matches = commandLine.matchAll(/(?:[A-Za-z]:[\\/]|[\\/]{1,2})[^\s"']*?setup-runner\.cmd/gi) + const paths: string[] = [] + for (const match of matches) { + if (match[0]) { + paths.push(match[0]) + } + } + return paths +} + +/** + * True when a process CommandLine is running (or embedding) this worktree's + * `setup-runner.cmd` — including the common linked-worktree gitdir location + * under `.git/worktrees//orca/`. + * + * Identity is path-anchor based only (worktree path and optional gitdir). A + * basename-only `.git/worktrees//…` match would force-kill another + * repository's same-named linked worktree (#10629 review). + */ +export function commandLineTargetsWorktreeSetupRunner( + commandLine: string, + worktreePath: string, + pathAnchors: readonly string[] = [] +): boolean { + if (!commandLine || !/setup-runner\.cmd/i.test(commandLine)) { + return false + } + + const normalizedCommand = normalizeRuntimePathForComparison(commandLine) + const anchors = new Set() + const addAnchor = (value: string): void => { + const normalized = normalizeRuntimePathForComparison(value.trim()) + // Why: single-segment anchors collide across hosts; require a real root path. + if (normalized.length >= 2) { + anchors.add(normalized) + } + } + addAnchor(worktreePath) + for (const anchor of pathAnchors) { + addAnchor(anchor) + } + + const runnerPaths = extractSetupRunnerPathsFromCommandLine(commandLine) + for (const runnerPath of runnerPaths) { + for (const anchor of anchors) { + if (isPathInsideOrEqual(anchor, runnerPath)) { + return true + } + } + } + + // Why: some listings omit drive-qualified paths; still match when the full + // worktree/gitdir string appears alongside setup-runner.cmd. + for (const anchor of anchors) { + if (normalizedCommand.includes(anchor)) { + return true + } + } + + return false +} + +/** + * Worktree-id entry used by destructive teardown: resolves the filesystem path + * and skips non-Windows / non-Windows-path worktrees. + */ +export async function terminateWindowsSetupRunnersForWorktreeId( + worktreeId: string, + deps: WindowsSetupRunnerKillDeps = {} +): Promise { + const now = deps.now ?? Date.now + if (deps.deadlineMs !== undefined && now() >= deps.deadlineMs) { + return 0 + } + const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + if (!worktreePath) { + return 0 + } + const killed = await terminateWindowsSetupRunnersForWorktree(worktreePath, deps).catch(() => 0) + if (killed > 0) { + console.info( + `[worktree-teardown] ${worktreeId} killed ${killed} Windows setup-runner process tree(s)` + ) + } + return killed +} + +/** + * Force-kill Windows `setup-runner.cmd` process trees tied to `worktreePath`. + * Best-effort: enumeration/kill failures resolve to 0 so callers can still + * attempt Git removal (and surface a real filesystem error if handles remain). + */ +export async function terminateWindowsSetupRunnersForWorktree( + worktreePath: string, + deps: WindowsSetupRunnerKillDeps = {} +): Promise { + const now = deps.now ?? Date.now + if (deps.deadlineMs !== undefined && now() >= deps.deadlineMs) { + return 0 + } + const platform = deps.platform ?? process.platform + if (platform !== 'win32') { + return 0 + } + const trimmed = worktreePath.trim() + if (!trimmed || !isWindowsAbsolutePathLike(trimmed)) { + return 0 + } + + const pathAnchors = [...(deps.pathAnchors ?? [])] + if (deps.pathAnchors === undefined) { + const resolveGitDir = deps.resolveGitDirPath ?? resolveWorktreeGitDirPath + const gitDir = await settleWindowsSweepBeforeDeadline( + () => resolveGitDir(trimmed).catch(() => null), + null, + deps.deadlineMs, + now + ) + if (gitDir) { + pathAnchors.push(gitDir) + } + } + + const list = deps.listProcessRows ?? queryWindowsProcessRowsFresh + const rows = await settleWindowsSweepBeforeDeadline( + () => list().catch(() => []), + [], + deps.deadlineMs, + now + ) + + const killTree = deps.killTree ?? terminateWindowsProcessTree + const targetPids = new Set() + for (const row of rows) { + if (!Number.isInteger(row.pid) || row.pid <= 0 || row.pid === process.pid) { + continue + } + if (commandLineTargetsWorktreeSetupRunner(row.command, trimmed, pathAnchors)) { + targetPids.add(row.pid) + } + } + + if (targetPids.size === 0) { + return 0 + } + + const killedPids = await settleWindowsSweepBeforeDeadline( + async () => { + await Promise.all( + [...targetPids].map((pid) => + killTree(pid).catch(() => { + /* already dead or access denied — Git removal will surface leftovers */ + }) + ) + ) + return [...targetPids] + }, + [], + deps.deadlineMs, + now + ) + return killedPids.length +}