import { spawnSync } from 'node:child_process'; // Windows 开发栈清理工具。 // // 背景:Windows 下所有长驻服务都经 `cmd.exe /d /s /c` 包装层启动(Node 的 // `shell: true`),而 Ctrl+C 会先让包装层退出。一旦中间层退出,按父进程链 // 遍历就再也到不了更深的服务进程,`taskkill /T` 也会因为 PID 已消失而失效。 // 因此这里同时提供两种定位方式: // 1. `selectProcessTreeIds`:按记录下来的根 PID 做父子链遍历(能处理根已退出、 // 但中间层仍留在快照里的情况)。 // 2. `selectWorktreeOwnedProcessIds`:按身份匹配(api-server.exe 的绝对路径、 // SpacetimeDB 的 --data-dir),不依赖任何仍然存活的包装层。 // 两者结合后,即使 `npm run agc` 的 Ctrl+C 只杀掉了 shell 包装层,也不会留下 // 属于本工作树的后端进程。 function normalizeWindowsPath(value) { const raw = String(value ?? '') .trim() .replace(/^\\\\\?\\/u, ''); if (!raw) { return ''; } return raw.replace(/[\\/]+$/u, '').toLowerCase(); } function parseWindowsProcessSnapshot(rawText) { const raw = String(rawText ?? '').trim(); if (!raw) { return []; } let parsed; try { parsed = JSON.parse(raw); } catch { return []; } if (!parsed) { return []; } return Array.isArray(parsed) ? parsed : [parsed]; } // 一次退出流程里会多次清理(每个服务的进程树 + 最后的身份兜底清扫), // PowerShell 全量进程快照约 1 秒,短时间内复用同一份快照即可,避免 Ctrl+C // 后清理被拖成十几秒。只在默认实现下缓存,注入实现(测试)始终重新读取。 const PROCESS_SNAPSHOT_TTL_MS = 1000; let cachedProcessSnapshot = null; let cachedProcessSnapshotAt = 0; function readWindowsProcessSnapshot({ spawnSyncImpl = spawnSync, env = process.env, now = Date.now, ttlMs = PROCESS_SNAPSHOT_TTL_MS, } = {}) { const cacheable = spawnSyncImpl === spawnSync && env === process.env; if ( cacheable && cachedProcessSnapshot && now() - cachedProcessSnapshotAt < ttlMs ) { return cachedProcessSnapshot; } const command = [ '$ErrorActionPreference = "SilentlyContinue"', 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', ].join('\n'); const result = spawnSyncImpl( 'powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], { encoding: 'utf8', env, maxBuffer: 32 * 1024 * 1024, }, ); if (result?.error || result?.status !== 0) { return []; } const snapshot = parseWindowsProcessSnapshot(result.stdout); if (cacheable) { cachedProcessSnapshot = snapshot; cachedProcessSnapshotAt = now(); } return snapshot; } function selectProcessTreeIds(processes, rootPid) { if (!Number.isInteger(rootPid)) { return []; } const childrenByParent = new Map(); for (const processEntry of processes ?? []) { const parentId = Number(processEntry?.ParentProcessId); const processId = Number(processEntry?.ProcessId); if (!Number.isInteger(parentId) || !Number.isInteger(processId)) { continue; } if (!childrenByParent.has(parentId)) { childrenByParent.set(parentId, []); } childrenByParent.get(parentId).push(processId); } const collected = new Set(); const queue = [rootPid]; while (queue.length > 0) { const current = queue.shift(); if (collected.has(current)) { continue; } collected.add(current); for (const childId of childrenByParent.get(current) ?? []) { queue.push(childId); } } return [...collected]; } function selectWorktreeOwnedProcessIds( processes, { apiServerExePath = '', spacetimeDataDir = '', selfPid = process.pid } = {}, ) { const expectedExePath = normalizeWindowsPath(apiServerExePath); const expectedDataDir = normalizeWindowsPath(spacetimeDataDir); if (!expectedExePath && !expectedDataDir) { return []; } const matched = []; for (const processEntry of processes ?? []) { const processId = Number(processEntry?.ProcessId); if (!Number.isInteger(processId) || processId === selfPid) { continue; } const executablePath = normalizeWindowsPath(processEntry?.ExecutablePath); if (expectedExePath && executablePath === expectedExePath) { matched.push(processId); continue; } if (!expectedDataDir) { continue; } const name = String(processEntry?.Name ?? '').toLowerCase(); if (!name.startsWith('spacetime')) { continue; } if ( normalizeWindowsPath(processEntry?.CommandLine).includes(expectedDataDir) ) { matched.push(processId); } } return matched; } function stopWindowsProcessIds( processIds, { spawnSyncImpl = spawnSync, env = process.env, waitForExitMs = 0 } = {}, ) { const uniqueIds = [ ...new Set((processIds ?? []).filter((value) => Number.isInteger(value))), ]; if (uniqueIds.length === 0) { return []; } const command = [ '$ErrorActionPreference = "SilentlyContinue"', '$ids = $env:GENARRATIVE_STOP_PIDS -split ","', 'foreach ($id in $ids) {', ' if ($id) { Stop-Process -Id ([int]$id) -Force -ErrorAction SilentlyContinue }', '}', ...(waitForExitMs > 0 ? [ // 启动前清理旧 api-server 时必须等它真正退出,否则 Windows 仍占用 // target\debug\api-server.exe,cargo 会报 failed to remove file。 `Wait-Process -Id $ids -Timeout ${Math.ceil(waitForExitMs / 1000)} -ErrorAction SilentlyContinue`, ] : []), ].join('\n'); spawnSyncImpl( 'powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], { env: { ...env, GENARRATIVE_STOP_PIDS: uniqueIds.join(',') }, stdio: 'ignore', }, ); return uniqueIds; } function stopWindowsProcessTree( rootPid, { snapshot = null, spawnSyncImpl = spawnSync, env = process.env } = {}, ) { if (!Number.isInteger(rootPid)) { return []; } const processes = snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env }); return stopWindowsProcessIds(selectProcessTreeIds(processes, rootPid), { spawnSyncImpl, env, }); } function stopWindowsWorktreeProcesses({ apiServerExePath = '', spacetimeDataDir = '', snapshot = null, spawnSyncImpl = spawnSync, env = process.env, } = {}) { const processes = snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env }); return stopWindowsProcessIds( selectWorktreeOwnedProcessIds(processes, { apiServerExePath, spacetimeDataDir, }), { spawnSyncImpl, env }, ); } export { normalizeWindowsPath, parseWindowsProcessSnapshot, readWindowsProcessSnapshot, selectProcessTreeIds, selectWorktreeOwnedProcessIds, stopWindowsProcessIds, stopWindowsProcessTree, stopWindowsWorktreeProcesses, };