From aff4ee2758f8357642bdb561de76b46481e76ad9 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 13 Jul 2026 09:26:23 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E5=88=A0=20workt?= =?UTF-8?q?ree=20=E5=89=8D=E6=89=AB=20/proc=20=E6=A3=80=E6=B5=8B=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=8D=A0=E7=94=A8=EF=BC=8C=E5=91=BD=E4=B8=AD=E5=88=99?= =?UTF-8?q?=E4=B8=AD=E6=AD=A2=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev server 常驻在 worktree 里持续写构建产物,递归删除与写入竞态导致 rmdir 撞 ENOTEMPTY。新增 findProcessesUsingWorktree 扫 /proc 找 cwd 或 fd 落在 worktree 下的进程,三处删除调用点(拖到完成列 / 手动删 / 删整工单) 命中即弹错误 toast 列出 pid+命令并中止(不删、不杀进程),用户停掉后重试。 非 Linux 无 /proc 返回空,降级为原直接删除。 --- vscode/src/git/worktree.test.ts | 37 +++++++++++++++ vscode/src/git/worktree.ts | 67 ++++++++++++++++++++++++++- vscode/src/panel/handlers/issues.ts | 41 +++++++++++++++- vscode/src/panel/handlers/worktree.ts | 13 +++++- 4 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 vscode/src/git/worktree.test.ts diff --git a/vscode/src/git/worktree.test.ts b/vscode/src/git/worktree.test.ts new file mode 100644 index 0000000..b9ef57d --- /dev/null +++ b/vscode/src/git/worktree.test.ts @@ -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((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 }) + } + }) +}) diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index 1e98f5b..86407fc 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -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//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 +} diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index 975304f..0071ddc 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 { 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) } diff --git a/vscode/src/panel/handlers/worktree.ts b/vscode/src/panel/handlers/worktree.ts index 679075f..4a7c2dd 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, 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) }