diff --git a/vscode/src/git/worktree.test.ts b/vscode/src/git/worktree.test.ts index b1bfa1e..cac7613 100644 --- a/vscode/src/git/worktree.test.ts +++ b/vscode/src/git/worktree.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { describe, expect, it } from 'vitest' -import { findProcessesUsingWorktree, resolveWorktreePath } from './worktree' +import { findProcessesUsingWorktree, killProcessesUsingWorktree, resolveWorktreePath } from './worktree' describe('resolveWorktreePath', () => { const workspaceRoot = '/workspace/project' @@ -55,3 +55,60 @@ describe.runIf(existsSync('/proc'))('findProcessesUsingWorktree', () => { } }) }) + +// 杀占用进程:与 find 同依赖 /proc,非 Linux 跳过。 +describe.runIf(existsSync('/proc'))('killProcessesUsingWorktree', () => { + it('杀掉 cwd 落在 worktree 下的进程,find 结果变空', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'wt-kill-')) + const child = spawn('sleep', ['30'], { cwd: dir, stdio: 'ignore' }) + try { + await new Promise((resolve, reject) => { + child.on('spawn', () => resolve()) + child.on('error', reject) + }) + expect(findProcessesUsingWorktree(dir).some(h => h.pid === String(child.pid))).toBe(true) + + const killed = killProcessesUsingWorktree(dir) + expect(killed.some(h => h.pid === String(child.pid))).toBe(true) + + // 等子进程真正退出(kill 函数已等 300–500ms,这里再兜底) + await new Promise((resolve) => { + if (child.killed || child.exitCode !== null) + resolve() + else + child.on('exit', () => resolve()) + setTimeout(resolve, 1000) + }) + expect(findProcessesUsingWorktree(dir).some(h => h.pid === String(child.pid))).toBe(false) + } + finally { + try { + child.kill('SIGKILL') + } + catch {} + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('不杀 process.pid / process.ppid(命中时跳过)', () => { + // chdir 到独占临时目录,让 find 能命中本进程 cwd,又不会扫到 vitest/项目其它进程。 + const dir = mkdtempSync(path.join(os.tmpdir(), 'wt-self-')) + const prev = process.cwd() + const selfPid = String(process.pid) + const parentPid = String(process.ppid) + try { + process.chdir(dir) + // find 应能看到自身(cwd=dir);kill 必须跳过 self/ppid + expect(findProcessesUsingWorktree(dir).some(h => h.pid === selfPid)).toBe(true) + + const killed = killProcessesUsingWorktree(dir) + expect(killed.some(h => h.pid === selfPid)).toBe(false) + expect(killed.some(h => h.pid === parentPid)).toBe(false) + expect(process.kill(process.pid, 0)).toBe(true) + } + finally { + process.chdir(prev) + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index 86407fc..2fda7e2 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -110,10 +110,8 @@ export interface WorktreeProcHit { /** * 扫 /proc 找出 cwd 或打开的 fd 落在 worktree 目录(含子目录)下的进程。 * - * 删 worktree 前的守门:dev server(pnpm dev / vite / next dev)常驻在 worktree - * 里持续往构建产物目录写文件,此时递归删除会与写入竞态——rm 删完子项后 rmdir - * 父目录,进程刚好又塞进新文件 → ENOTEMPTY。force:true 只免疫「文件不存在」, - * 救不了「删除期间目录被重新填充」。所以占用就别删,交给用户先停进程。 + * 扫占用 worktree 的进程。dev server 等常驻进程会让递归删除撞 ENOTEMPTY + * (删期间目录被重新填充);调用方应先 killProcessesUsingWorktree 再删。 * * 非 Linux(无 /proc)返回空数组 → 调用方降级为直接删除。 */ @@ -166,3 +164,50 @@ export function findProcessesUsingWorktree(absWorktreePath: string): WorktreePro } return hits } + +/** + * 删 worktree 前杀掉占用目录的进程(SIGTERM → 短暂等待 → SIGKILL)。 + * + * 跳过 process.pid / process.ppid,避免误杀扩展宿主或父 VS Code。 + * 非 Linux(find 返回空)为 no-op。返回被尝试杀掉的 hits,供调用方记日志。 + */ +export function killProcessesUsingWorktree(absWorktreePath: string): WorktreeProcHit[] { + const selfPid = String(process.pid) + const parentPid = String(process.ppid) + // ① 扫占用 → 过滤掉自身/父进程 + const hits = findProcessesUsingWorktree(absWorktreePath) + .filter(h => h.pid !== selfPid && h.pid !== parentPid) + if (hits.length === 0) + return hits + + // ② SIGTERM 温和退出(ESRCH=已死 → 忽略) + for (const h of hits) { + try { + process.kill(Number(h.pid), 'SIGTERM') + } + catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ESRCH') + console.warn(`[superpowers] SIGTERM pid ${h.pid} 失败:`, err) + } + } + + // ③ 同步等 ~400ms,给 dev server 关句柄的时间 + const sab = new SharedArrayBuffer(4) + const ia = new Int32Array(sab) + Atomics.wait(ia, 0, 0, 400) + + // ④ 仍存活的 SIGKILL 兜底 + for (const h of hits) { + try { + process.kill(Number(h.pid), 0) // 探测是否仍在 + process.kill(Number(h.pid), 'SIGKILL') + } + catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ESRCH') + console.warn(`[superpowers] SIGKILL pid ${h.pid} 失败:`, err) + } + } + return hits +} diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index f4a7558..794f641 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { findProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' +import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode' import { getToken } from '../../auth/secrets' import { buildCcCommand } from '../../cc/ccCommand' @@ -474,28 +474,18 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: mainBranch: settingsForHook.devBranch || 'main', customScriptPath: settingsForHook.worktreePreRemoveScript, }) - // 删前守门:dev server 等仍在写 worktree 的进程会让递归删除撞 ENOTEMPTY。 - // 命中就别删、别杀进程,只提示并回滚拖拽,让用户停掉后自己重试。 - const procHits = findProcessesUsingWorktree(abs) - if (procHits.length > 0) { - const list = procHits + // dispose 终端后仍可能有 dev server 等残留;杀掉再删,占用不再回滚拖拽。 + const killed = killProcessesUsingWorktree(abs) + if (killed.length > 0) { + const list = killed .map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) .join('\n') logger.add({ level: 'warn', source: 'panel', - message: `worktree 仍被进程占用,已中止清理 (issue #${issueNumber})`, + message: `清理 worktree 前已杀掉占用进程 (issue #${issueNumber})`, details: list, }) - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: `工单 #${issueNumber} 有进程仍在占用 worktree,已中止清理。请先停止后重试:\n${list}`, - dismissOnTimer: 10000, - }) - rollback(fromColumn) - return } try { await removeWorktreeDir(workspaceRoot, abs) @@ -933,21 +923,18 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: mainBranch: settingsForHook.devBranch || 'main', customScriptPath: settingsForHook.worktreePreRemoveScript, }) - // 删整工单也先守门:占用进程会让删除撞 ENOTEMPTY,且底层进程还在跑更不该 - // 强删。中止并提示,用户停掉后重删(保持与拖拽清理一致:占用就别删)。 - const procHits = findProcessesUsingWorktree(absWorktree) - if (procHits.length > 0) { - const list = procHits + // 先杀占用再删,与拖到完成 / 删 worktree 同一路径。 + const killed = killProcessesUsingWorktree(absWorktree) + if (killed.length > 0) { + const list = killed .map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) .join('\n') - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: `工单 #${issueNumber} 有进程仍在占用 worktree,已中止删除。请先停止后重试:\n${list}`, - dismissOnTimer: 10000, + logger.add({ + level: 'warn', + source: 'panel', + message: `删除工单前已杀掉占用 worktree 的进程 (issue #${issueNumber})`, + details: list, }) - return } try { await removeWorktreeDir(workspaceRoot, absWorktree) diff --git a/vscode/src/panel/handlers/worktree.ts b/vscode/src/panel/handlers/worktree.ts index 4a7c2dd..affeafb 100644 --- a/vscode/src/panel/handlers/worktree.ts +++ b/vscode/src/panel/handlers/worktree.ts @@ -4,7 +4,7 @@ import { execFile } from 'node:child_process' import { commands, Uri, window, workspace } from 'vscode' import { getToken } from '../../auth/secrets' import { detectRepo } from '../../git/remote' -import { expandTilde, findProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' +import { expandTilde, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { runImplTabPostCloseHook, runImplTabPreCreateHook, @@ -97,15 +97,18 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe customScriptPath: settingsForHook.worktreePreRemoveScript, }) - // 删前守门:dev server 等仍在写 worktree 的进程会让递归删除撞 ENOTEMPTY。 - // 命中就别删、别杀进程,只提示,让用户停掉后重试。 - const procHits = findProcessesUsingWorktree(abs) - if (procHits.length > 0) { - const list = procHits + // 先杀占用进程(dev server 等),再删目录,避免 ENOTEMPTY 竞态。 + const killed = killProcessesUsingWorktree(abs) + if (killed.length > 0) { + const list = killed .map(h => `pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) .join('\n') - void window.showErrorMessage(`有进程仍在占用 worktree,已中止删除。请先停止后重试:\n${list}`) - return + logger.add({ + level: 'warn', + source: 'panel', + message: `删除 worktree 前已杀掉占用进程 (issue #${issueNumber})`, + details: list, + }) } try {