🐛 fix(vscode): 完成列/删 worktree 时自动杀掉占用进程再删目录
This commit is contained in:
@@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { findProcessesUsingWorktree, resolveWorktreePath } from './worktree'
|
import { findProcessesUsingWorktree, killProcessesUsingWorktree, resolveWorktreePath } from './worktree'
|
||||||
|
|
||||||
describe('resolveWorktreePath', () => {
|
describe('resolveWorktreePath', () => {
|
||||||
const workspaceRoot = '/workspace/project'
|
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<void>((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<void>((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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -110,10 +110,8 @@ export interface WorktreeProcHit {
|
|||||||
/**
|
/**
|
||||||
* 扫 /proc 找出 cwd 或打开的 fd 落在 worktree 目录(含子目录)下的进程。
|
* 扫 /proc 找出 cwd 或打开的 fd 落在 worktree 目录(含子目录)下的进程。
|
||||||
*
|
*
|
||||||
* 删 worktree 前的守门:dev server(pnpm dev / vite / next dev)常驻在 worktree
|
* 扫占用 worktree 的进程。dev server 等常驻进程会让递归删除撞 ENOTEMPTY
|
||||||
* 里持续往构建产物目录写文件,此时递归删除会与写入竞态——rm 删完子项后 rmdir
|
* (删期间目录被重新填充);调用方应先 killProcessesUsingWorktree 再删。
|
||||||
* 父目录,进程刚好又塞进新文件 → ENOTEMPTY。force:true 只免疫「文件不存在」,
|
|
||||||
* 救不了「删除期间目录被重新填充」。所以占用就别删,交给用户先停进程。
|
|
||||||
*
|
*
|
||||||
* 非 Linux(无 /proc)返回空数组 → 调用方降级为直接删除。
|
* 非 Linux(无 /proc)返回空数组 → 调用方降级为直接删除。
|
||||||
*/
|
*/
|
||||||
@@ -166,3 +164,50 @@ export function findProcessesUsingWorktree(absWorktreePath: string): WorktreePro
|
|||||||
}
|
}
|
||||||
return hits
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as fs from 'node:fs'
|
|||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
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 { commands, env, ThemeColor, Uri, window, workspace } from 'vscode'
|
||||||
import { getToken } from '../../auth/secrets'
|
import { getToken } from '../../auth/secrets'
|
||||||
import { buildCcCommand } from '../../cc/ccCommand'
|
import { buildCcCommand } from '../../cc/ccCommand'
|
||||||
@@ -474,28 +474,18 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
mainBranch: settingsForHook.devBranch || 'main',
|
mainBranch: settingsForHook.devBranch || 'main',
|
||||||
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
||||||
})
|
})
|
||||||
// 删前守门:dev server 等仍在写 worktree 的进程会让递归删除撞 ENOTEMPTY。
|
// dispose 终端后仍可能有 dev server 等残留;杀掉再删,占用不再回滚拖拽。
|
||||||
// 命中就别删、别杀进程,只提示并回滚拖拽,让用户停掉后自己重试。
|
const killed = killProcessesUsingWorktree(abs)
|
||||||
const procHits = findProcessesUsingWorktree(abs)
|
if (killed.length > 0) {
|
||||||
if (procHits.length > 0) {
|
const list = killed
|
||||||
const list = procHits
|
|
||||||
.map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
.map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
source: 'panel',
|
source: 'panel',
|
||||||
message: `worktree 仍被进程占用,已中止清理 (issue #${issueNumber})`,
|
message: `清理 worktree 前已杀掉占用进程 (issue #${issueNumber})`,
|
||||||
details: list,
|
details: list,
|
||||||
})
|
})
|
||||||
panel.postMessage({
|
|
||||||
type: 'toast/show',
|
|
||||||
id: makeNonce(),
|
|
||||||
level: 'error',
|
|
||||||
message: `工单 #${issueNumber} 有进程仍在占用 worktree,已中止清理。请先停止后重试:\n${list}`,
|
|
||||||
dismissOnTimer: 10000,
|
|
||||||
})
|
|
||||||
rollback(fromColumn)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await removeWorktreeDir(workspaceRoot, abs)
|
await removeWorktreeDir(workspaceRoot, abs)
|
||||||
@@ -933,21 +923,18 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
mainBranch: settingsForHook.devBranch || 'main',
|
mainBranch: settingsForHook.devBranch || 'main',
|
||||||
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
||||||
})
|
})
|
||||||
// 删整工单也先守门:占用进程会让删除撞 ENOTEMPTY,且底层进程还在跑更不该
|
// 先杀占用再删,与拖到完成 / 删 worktree 同一路径。
|
||||||
// 强删。中止并提示,用户停掉后重删(保持与拖拽清理一致:占用就别删)。
|
const killed = killProcessesUsingWorktree(absWorktree)
|
||||||
const procHits = findProcessesUsingWorktree(absWorktree)
|
if (killed.length > 0) {
|
||||||
if (procHits.length > 0) {
|
const list = killed
|
||||||
const list = procHits
|
|
||||||
.map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
.map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
panel.postMessage({
|
logger.add({
|
||||||
type: 'toast/show',
|
level: 'warn',
|
||||||
id: makeNonce(),
|
source: 'panel',
|
||||||
level: 'error',
|
message: `删除工单前已杀掉占用 worktree 的进程 (issue #${issueNumber})`,
|
||||||
message: `工单 #${issueNumber} 有进程仍在占用 worktree,已中止删除。请先停止后重试:\n${list}`,
|
details: list,
|
||||||
dismissOnTimer: 10000,
|
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await removeWorktreeDir(workspaceRoot, absWorktree)
|
await removeWorktreeDir(workspaceRoot, absWorktree)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { execFile } from 'node:child_process'
|
|||||||
import { commands, Uri, window, workspace } from 'vscode'
|
import { commands, Uri, window, workspace } from 'vscode'
|
||||||
import { getToken } from '../../auth/secrets'
|
import { getToken } from '../../auth/secrets'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
import { expandTilde, findProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
import { expandTilde, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||||
import {
|
import {
|
||||||
runImplTabPostCloseHook,
|
runImplTabPostCloseHook,
|
||||||
runImplTabPreCreateHook,
|
runImplTabPreCreateHook,
|
||||||
@@ -97,15 +97,18 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe
|
|||||||
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 删前守门:dev server 等仍在写 worktree 的进程会让递归删除撞 ENOTEMPTY。
|
// 先杀占用进程(dev server 等),再删目录,避免 ENOTEMPTY 竞态。
|
||||||
// 命中就别删、别杀进程,只提示,让用户停掉后重试。
|
const killed = killProcessesUsingWorktree(abs)
|
||||||
const procHits = findProcessesUsingWorktree(abs)
|
if (killed.length > 0) {
|
||||||
if (procHits.length > 0) {
|
const list = killed
|
||||||
const list = procHits
|
|
||||||
.map(h => `pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
.map(h => `pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
void window.showErrorMessage(`有进程仍在占用 worktree,已中止删除。请先停止后重试:\n${list}`)
|
logger.add({
|
||||||
return
|
level: 'warn',
|
||||||
|
source: 'panel',
|
||||||
|
message: `删除 worktree 前已杀掉占用进程 (issue #${issueNumber})`,
|
||||||
|
details: list,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user