From 0dada59e8393d3c3d5dd4656213174f36ad8b825 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Sun, 26 Jul 2026 09:54:40 +0900 Subject: [PATCH 1/4] fix(win): kill orphan setup-runner.cmd before worktree delete (#10629) On Windows, cmd.exe processes left running setup-runner.cmd from a worktree's private gitdir keep directory handles open so git worktree remove / rmdir fail with Permission denied. After PTY teardown, scan process CommandLines and taskkill /T /F matching setup-runner trees. --- src/main/runtime/worktree-teardown.test.ts | 28 +- src/main/runtime/worktree-teardown.ts | 4 + ...windows-worktree-setup-runner-kill.test.ts | 268 ++++++++++++++++++ .../windows-worktree-setup-runner-kill.ts | 226 +++++++++++++++ 4 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 src/main/windows-worktree-setup-runner-kill.test.ts create mode 100644 src/main/windows-worktree-setup-runner-kill.ts diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index b51f292c29c6..669437e78638 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,17 @@ 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 () => []) + listRegisteredPtysMock.mockReturnValue([]) + + await killAllProcessesForWorktree(worktreeId, { localProvider, timeoutMs: 5_000 }) + + expect(terminateWindowsSetupRunnersForWorktreeIdMock).toHaveBeenCalledWith( + worktreeId, + expect.objectContaining({ deadlineMs: expect.any(Number) }) + ) + }) }) diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 993a263d348f..20731573042d 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,9 @@ export async function killAllProcessesForWorktree( } } + // Why: reparented setup-runner.cmd keeps Windows worktree dirs locked after PTY stop (#10629). + await terminateWindowsSetupRunnersForWorktreeId(worktreeId, { deadlineMs: 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..67639bddc806 --- /dev/null +++ b/src/main/windows-worktree-setup-runner-kill.test.ts @@ -0,0 +1,268 @@ +import { join } from 'node:path' +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 the linked-worktree basename heuristic without gitdir', () => { + expect( + commandLineTargetsWorktreeSetupRunner( + 'C:\\Windows\\system32\\cmd.exe /c D:/other/repo/.git/worktrees/feature/orca/setup-runner.cmd', + worktreePath + ) + ).toBe(true) + }) + + 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') + await expect( + resolveWorktreeGitDirPath('D:\\orca\\workspaces\\repo\\feature', { readFileImpl }) + ).resolves.toBe('D:/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() + }) +}) + +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..524902296dee --- /dev/null +++ b/src/main/windows-worktree-setup-runner-kill.ts @@ -0,0 +1,226 @@ +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 +} + +/** 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 +} + +function worktreePathBasename(worktreePath: string): string | null { + const normalized = worktreePath.trim().replace(/[\\/]+$/g, '') + if (!normalized) { + return null + } + return normalized.split(/[\\/]/).findLast(Boolean)?.trim() || null +} + +/** + * 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/`. + */ +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 + } + } + + // Why: when `.git` is already gone we still recognize the linked-worktree + // gitdir shape that Orca wrote into the CommandLine at launch (#10629). + const basename = worktreePathBasename(worktreePath) + if (basename) { + const linkedMarker = `/.git/worktrees/${normalizeRuntimePathForComparison(basename)}/orca/setup-runner.cmd` + if (normalizedCommand.includes(linkedMarker)) { + return true + } + for (const runnerPath of runnerPaths) { + if (normalizeRuntimePathForComparison(runnerPath).includes(linkedMarker)) { + 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 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 resolveGitDir(trimmed).catch(() => null) + if (gitDir) { + pathAnchors.push(gitDir) + } + } + + let rows: WindowsProcessRow[] + try { + const list = deps.listProcessRows ?? queryWindowsProcessRowsFresh + rows = await list() + } catch { + return 0 + } + + 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 + } + + await Promise.all( + [...targetPids].map((pid) => + killTree(pid).catch(() => { + /* already dead or access denied — Git removal will surface leftovers */ + }) + ) + ) + return targetPids.size +} From 3def0b6561072706c110dcd25e13268de131fd36 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Sun, 26 Jul 2026 12:09:48 +0900 Subject: [PATCH 2/4] fix(win): bound setup-runner sweep and require path anchors (#10629) Address CodeRabbit: race the setup-runner kill with the teardown deadline via settleBeforeDeadline, and drop basename-only linked-worktree matching so same-named worktrees under other repos are not force-killed. --- src/main/runtime/worktree-teardown.ts | 7 ++++- ...windows-worktree-setup-runner-kill.test.ts | 15 +++++++++-- .../windows-worktree-setup-runner-kill.ts | 27 +++---------------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 20731573042d..7e73c952cc53 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -254,7 +254,12 @@ export async function killAllProcessesForWorktree( } // Why: reparented setup-runner.cmd keeps Windows worktree dirs locked after PTY stop (#10629). - await terminateWindowsSetupRunnersForWorktreeId(worktreeId, { deadlineMs: deadline }) + // 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 index 67639bddc806..c31fa1b8da4b 100644 --- a/src/main/windows-worktree-setup-runner-kill.test.ts +++ b/src/main/windows-worktree-setup-runner-kill.test.ts @@ -51,13 +51,24 @@ describe('commandLineTargetsWorktreeSetupRunner', () => { ).toBe(true) }) - it('matches the linked-worktree basename heuristic without gitdir', () => { + 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(true) + ).toBe(false) }) it('matches setup-runner living under the worktree path itself', () => { diff --git a/src/main/windows-worktree-setup-runner-kill.ts b/src/main/windows-worktree-setup-runner-kill.ts index 524902296dee..3e955e9adc47 100644 --- a/src/main/windows-worktree-setup-runner-kill.ts +++ b/src/main/windows-worktree-setup-runner-kill.ts @@ -69,18 +69,14 @@ export function extractSetupRunnerPathsFromCommandLine(commandLine: string): str return paths } -function worktreePathBasename(worktreePath: string): string | null { - const normalized = worktreePath.trim().replace(/[\\/]+$/g, '') - if (!normalized) { - return null - } - return normalized.split(/[\\/]/).findLast(Boolean)?.trim() || null -} - /** * 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, @@ -122,21 +118,6 @@ export function commandLineTargetsWorktreeSetupRunner( } } - // Why: when `.git` is already gone we still recognize the linked-worktree - // gitdir shape that Orca wrote into the CommandLine at launch (#10629). - const basename = worktreePathBasename(worktreePath) - if (basename) { - const linkedMarker = `/.git/worktrees/${normalizeRuntimePathForComparison(basename)}/orca/setup-runner.cmd` - if (normalizedCommand.includes(linkedMarker)) { - return true - } - for (const runnerPath of runnerPaths) { - if (normalizeRuntimePathForComparison(runnerPath).includes(linkedMarker)) { - return true - } - } - } - return false } From 911968d97f0b20e96a8300af7a6c79bbacef4c68 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 6 Aug 2026 12:50:37 +0900 Subject: [PATCH 3/4] fix(win): enforce setup-runner teardown deadline --- ...windows-worktree-setup-runner-kill.test.ts | 94 ++++++++++++++++++- .../windows-worktree-setup-runner-kill.ts | 81 +++++++++++++--- 2 files changed, 159 insertions(+), 16 deletions(-) diff --git a/src/main/windows-worktree-setup-runner-kill.test.ts b/src/main/windows-worktree-setup-runner-kill.test.ts index c31fa1b8da4b..f3ffa3c38529 100644 --- a/src/main/windows-worktree-setup-runner-kill.test.ts +++ b/src/main/windows-worktree-setup-runner-kill.test.ts @@ -1,4 +1,5 @@ import { join } 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 { @@ -98,7 +99,9 @@ describe('commandLineTargetsWorktreeSetupRunner', () => { describe('resolveWorktreeGitDirPath', () => { it('returns an absolute gitdir path as-is', async () => { - const readFileImpl = vi.fn(async () => 'gitdir: D:/repo/.git/worktrees/feature\n') + 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') @@ -225,6 +228,95 @@ describe('terminateWindowsSetupRunnersForWorktree', () => { ).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', () => { diff --git a/src/main/windows-worktree-setup-runner-kill.ts b/src/main/windows-worktree-setup-runner-kill.ts index 3e955e9adc47..1e79070c575b 100644 --- a/src/main/windows-worktree-setup-runner-kill.ts +++ b/src/main/windows-worktree-setup-runner-kill.ts @@ -24,6 +24,40 @@ export type WindowsSetupRunnerKillDeps = { 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, @@ -155,6 +189,10 @@ 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 @@ -167,19 +205,24 @@ export async function terminateWindowsSetupRunnersForWorktree( const pathAnchors = [...(deps.pathAnchors ?? [])] if (deps.pathAnchors === undefined) { const resolveGitDir = deps.resolveGitDirPath ?? resolveWorktreeGitDirPath - const gitDir = await resolveGitDir(trimmed).catch(() => null) + const gitDir = await settleWindowsSweepBeforeDeadline( + () => resolveGitDir(trimmed).catch(() => null), + null, + deps.deadlineMs, + now + ) if (gitDir) { pathAnchors.push(gitDir) } } - let rows: WindowsProcessRow[] - try { - const list = deps.listProcessRows ?? queryWindowsProcessRowsFresh - rows = await list() - } catch { - return 0 - } + const list = deps.listProcessRows ?? queryWindowsProcessRowsFresh + const rows = await settleWindowsSweepBeforeDeadline( + () => list().catch(() => []), + [], + deps.deadlineMs, + now + ) const killTree = deps.killTree ?? terminateWindowsProcessTree const targetPids = new Set() @@ -196,12 +239,20 @@ export async function terminateWindowsSetupRunnersForWorktree( return 0 } - await Promise.all( - [...targetPids].map((pid) => - killTree(pid).catch(() => { - /* already dead or access denied — Git removal will surface leftovers */ - }) - ) + 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 targetPids.size + return killedPids.length } From adacfa6bd3550fd6d9174b3f2d2ee76e60a6730b Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 6 Aug 2026 12:54:58 +0900 Subject: [PATCH 4/4] test(win): cover setup-runner teardown contracts --- src/main/runtime/worktree-teardown.test.ts | 13 ++++++++++++- src/main/windows-worktree-setup-runner-kill.test.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 669437e78638..c051869d4fdf 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -899,14 +899,25 @@ describe('killAllProcessesForWorktree', () => { 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 () => []) + 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/windows-worktree-setup-runner-kill.test.ts b/src/main/windows-worktree-setup-runner-kill.test.ts index f3ffa3c38529..fc1fb0d7f486 100644 --- a/src/main/windows-worktree-setup-runner-kill.test.ts +++ b/src/main/windows-worktree-setup-runner-kill.test.ts @@ -1,4 +1,4 @@ -import { join } from 'node:path' +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' @@ -107,6 +107,15 @@ describe('resolveWorktreeGitDirPath', () => { ).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' })