🐛 fix(vscode): 删 worktree 前扫 /proc 检测进程占用,命中则中止清理
dev server 常驻在 worktree 里持续写构建产物,递归删除与写入竞态导致 rmdir 撞 ENOTEMPTY。新增 findProcessesUsingWorktree 扫 /proc 找 cwd 或 fd 落在 worktree 下的进程,三处删除调用点(拖到完成列 / 手动删 / 删整工单) 命中即弹错误 toast 列出 pid+命令并中止(不删、不杀进程),用户停掉后重试。 非 Linux 无 /proc 返回空,降级为原直接删除。
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
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 } from './worktree'
|
||||
|
||||
// 检测靠扫 /proc,只有 Linux 有;别的平台整组跳过(函数本身也会返回空)。
|
||||
describe.runIf(existsSync('/proc'))('findProcessesUsingWorktree', () => {
|
||||
it('抓到 cwd 落在 worktree 目录下的常驻进程', async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'wt-proc-'))
|
||||
// cwd 设进 dir 的 sleep,模拟仍在 worktree 里跑的 dev server
|
||||
const child = spawn('sleep', ['30'], { cwd: dir, stdio: 'ignore' })
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('spawn', () => resolve())
|
||||
child.on('error', reject)
|
||||
})
|
||||
const hits = findProcessesUsingWorktree(dir)
|
||||
expect(hits.some(h => h.pid === String(child.pid))).toBe(true)
|
||||
}
|
||||
finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('没进程占用的目录返回空数组', () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'wt-empty-'))
|
||||
try {
|
||||
expect(findProcessesUsingWorktree(dir)).toEqual([])
|
||||
}
|
||||
finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
@@ -101,3 +101,68 @@ export async function removeWorktreeDir(workspaceRoot: string, absWorktreePath:
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface WorktreeProcHit {
|
||||
pid: string
|
||||
cmd: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫 /proc 找出 cwd 或打开的 fd 落在 worktree 目录(含子目录)下的进程。
|
||||
*
|
||||
* 删 worktree 前的守门:dev server(pnpm dev / vite / next dev)常驻在 worktree
|
||||
* 里持续往构建产物目录写文件,此时递归删除会与写入竞态——rm 删完子项后 rmdir
|
||||
* 父目录,进程刚好又塞进新文件 → ENOTEMPTY。force:true 只免疫「文件不存在」,
|
||||
* 救不了「删除期间目录被重新填充」。所以占用就别删,交给用户先停进程。
|
||||
*
|
||||
* 非 Linux(无 /proc)返回空数组 → 调用方降级为直接删除。
|
||||
*/
|
||||
export function findProcessesUsingWorktree(absWorktreePath: string): WorktreeProcHit[] {
|
||||
const hits: WorktreeProcHit[] = []
|
||||
const target = path.resolve(absWorktreePath)
|
||||
const prefix = target.endsWith(path.sep) ? target : target + path.sep
|
||||
|
||||
let pids: string[]
|
||||
try {
|
||||
pids = readdirSync('/proc').filter(n => /^\d+$/.test(n))
|
||||
}
|
||||
catch {
|
||||
return hits // 无 /proc → 无从扫描
|
||||
}
|
||||
|
||||
for (const pid of pids) {
|
||||
let used = false
|
||||
// ① cwd 落在 worktree 下(dev server 常把工作目录设在里面)
|
||||
try {
|
||||
const cwd = readlinkSync(`/proc/${pid}/cwd`)
|
||||
if (cwd === target || cwd.startsWith(prefix)) // (deleted) 后缀也照样命中前缀
|
||||
used = true
|
||||
}
|
||||
catch {} // 进程已退出 / 无权限 → 跳过
|
||||
// ② 否则查打开的 fd 是否指向 worktree 下的文件(构建产物写句柄)
|
||||
if (!used) {
|
||||
try {
|
||||
for (const fd of readdirSync(`/proc/${pid}/fd`)) {
|
||||
try {
|
||||
const p = readlinkSync(`/proc/${pid}/fd/${fd}`)
|
||||
if (p === target || p.startsWith(prefix)) {
|
||||
used = true
|
||||
break
|
||||
}
|
||||
}
|
||||
catch {} // 单个 fd 竞态关闭 → 跳过
|
||||
}
|
||||
}
|
||||
catch {} // /proc/<pid>/fd 读不到 → 跳过
|
||||
}
|
||||
if (used) {
|
||||
let cmd = ''
|
||||
try {
|
||||
cmd = readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim()
|
||||
}
|
||||
catch {} // cmdline 读不到就留空,pid 仍报给用户
|
||||
hits.push({ pid, cmd })
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
@@ -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 { removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||
import { findProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||
import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode'
|
||||
import { getToken } from '../../auth/secrets'
|
||||
import { buildCcCommand } from '../../cc/ccCommand'
|
||||
@@ -475,6 +475,29 @@ 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
|
||||
.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})`,
|
||||
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)
|
||||
logger.add({
|
||||
@@ -896,6 +919,22 @@ 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
|
||||
.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,
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await removeWorktreeDir(workspaceRoot, absWorktree)
|
||||
}
|
||||
|
||||
@@ -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, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||
import { expandTilde, findProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||
import {
|
||||
runImplTabPostCloseHook,
|
||||
runImplTabPreCreateHook,
|
||||
@@ -97,6 +97,17 @@ 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
|
||||
.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
|
||||
}
|
||||
|
||||
try {
|
||||
await removeWorktreeDir(workspaceRoot, abs)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user