fix(swarm): WindowsTerminalBackend pidFile health check + 5-state lifecycle

修 wt.exe split-pane fire-and-forget 导致 teammate 假死、TeamDelete 卡死、
kill-while-spawn race 等多个问题。

- 加 waitForPidFile() 在 wt.exe 返回后等 powershell.exe 真启动写 pidFile
  默认 8s timeout,env CLAUDE_WT_PANE_TIMEOUT_MS 覆盖,超时 throw 含完整诊断
- 加 5 态生命周期 (registered/spawning/ready/killing/dead),sendCommandToPane
  inner Promise 包装 spawnPromise,ready 态重 spawn 直接 throw
- killPane TOCTOU 修正:await spawnPromise 后重读 status;优先用缓存 pane.pid
  避免读盘,Stop-Process 失败也清缓存 + 标 dead 防 PID 复用误杀
- pid 解析严格化:/^\d+$/ + Number.isFinite + >0;移除 dead try/catch
- 构造函数 options 对象注入 pidFileDir(兼容原位置参数)
- 清启动前陈旧 pidFile,killPane fallback 3×500ms retry 兜底
This commit is contained in:
unraid 2026-05-18 18:31:07 +08:00
parent 2bca31e525
commit bde8f45bef

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'crypto'
import { readFile } from 'fs/promises'
import { readFile, unlink } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
@ -13,10 +13,15 @@ import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
type CommandResult = { stdout: string; stderr: string; code: number }
type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>
type PaneStatus = 'registered' | 'spawning' | 'ready' | 'killing' | 'dead'
type WindowsTerminalPane = {
title: string
mode: 'pane' | 'window'
pidFile: string
status: PaneStatus
pid?: number
spawnPromise?: Promise<void>
}
function quotePowerShellString(value: string): string {
@ -39,8 +44,42 @@ function wrapPowerShellCommand(command: string, pidFile: string): string {
].join('; ')
}
function makePidFile(paneId: string): string {
return join(tmpdir(), `${paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`)
const WT_PANE_TIMEOUT_DEFAULT_MS = 8000
const WT_PANE_POLL_INTERVAL_MS = 200
function getWtPaneTimeoutMs(): number {
const raw = process.env.CLAUDE_WT_PANE_TIMEOUT_MS
if (!raw) return WT_PANE_TIMEOUT_DEFAULT_MS
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0
? parsed
: WT_PANE_TIMEOUT_DEFAULT_MS
}
async function waitForPidFile(
pidFile: string,
timeoutMs: number,
): Promise<number> {
const deadline = Date.now() + timeoutMs
let lastErr: unknown
while (Date.now() < deadline) {
try {
const content = (await readFile(pidFile, 'utf-8')).trim()
if (!/^\d+$/.test(content)) {
lastErr = new Error(
`pidFile content not a valid pid: ${JSON.stringify(content)}`,
)
} else {
const pid = Number.parseInt(content, 10)
if (Number.isFinite(pid) && pid > 0) return pid
lastErr = new Error(`pidFile content parsed to invalid pid: ${pid}`)
}
} catch (err) {
lastErr = err
}
await new Promise(r => setTimeout(r, WT_PANE_POLL_INTERVAL_MS))
}
throw lastErr ?? new Error('pidFile never appeared')
}
/**
@ -58,10 +97,40 @@ export class WindowsTerminalBackend implements PaneBackend {
private panes = new Map<PaneId, WindowsTerminalPane>()
private readonly runCommand: CommandRunner
private readonly getPlatformValue: () => Platform
private readonly pidFileDir: string
constructor(
private readonly runCommand: CommandRunner = execFileNoThrow,
private readonly getPlatformValue: () => Platform = getPlatform,
) {}
runCommandOrOptions?:
| CommandRunner
| {
runCommand?: CommandRunner
getPlatform?: () => Platform
pidFileDir?: string
},
getPlatformValue?: () => Platform,
) {
if (
typeof runCommandOrOptions === 'function' ||
runCommandOrOptions === undefined
) {
this.runCommand = runCommandOrOptions ?? execFileNoThrow
this.getPlatformValue = getPlatformValue ?? getPlatform
this.pidFileDir = tmpdir()
} else {
this.runCommand = runCommandOrOptions.runCommand ?? execFileNoThrow
this.getPlatformValue = runCommandOrOptions.getPlatform ?? getPlatform
this.pidFileDir = runCommandOrOptions.pidFileDir ?? tmpdir()
}
}
private makePidFile(paneId: string): string {
return join(
this.pidFileDir,
`${paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`,
)
}
async isAvailable(): Promise<boolean> {
if (this.getPlatformValue() !== 'windows') {
@ -92,7 +161,8 @@ export class WindowsTerminalBackend implements PaneBackend {
this.panes.set(paneId, {
title: name,
mode: 'pane',
pidFile: makePidFile(paneId),
pidFile: this.makePidFile(paneId),
status: 'registered',
})
return { paneId, isFirstTeammate }
}
@ -106,7 +176,8 @@ export class WindowsTerminalBackend implements PaneBackend {
this.panes.set(paneId, {
title: name,
mode: 'window',
pidFile: makePidFile(paneId),
pidFile: this.makePidFile(paneId),
status: 'registered',
})
return { paneId, isFirstTeammate: false, windowName }
}
@ -121,6 +192,39 @@ export class WindowsTerminalBackend implements PaneBackend {
throw new Error(`Unknown Windows Terminal pane id: ${paneId}`)
}
// 拒绝 ready 态重 spawn避免同 pidFile 双进程竞争)
if (pane.status === 'ready' || pane.status === 'killing') {
throw new Error(
`Pane ${paneId} already spawned (status=${pane.status}); create a new pane to re-launch`,
)
}
if (pane.status === 'spawning') {
throw new Error(
`Pane ${paneId} is currently spawning; wait for the in-flight launch to complete`,
)
}
if (pane.status === 'dead') {
throw new Error(`Pane ${paneId} is dead; create a new pane`)
}
// pane.status === 'registered' → 继续
// 提前赋值 spawnPromise 在任何 await 前inner Promise 包装)
// Attach a no-op .catch() immediately to prevent unhandled rejection warnings
// in case killPane never awaits spawnPromise (e.g. sendCommandToPane fails
// before killPane is called).
let resolveSpawn!: () => void
let rejectSpawn!: (err: unknown) => void
const spawnPromise = new Promise<void>((res, rej) => {
resolveSpawn = res
rejectSpawn = rej
})
// Silence unhandled-rejection: killPane may .catch() this later, but if
// the pane dies before any kill is attempted, the rejection must not leak.
spawnPromise.catch(() => {})
pane.status = 'spawning'
pane.spawnPromise = spawnPromise
try {
const launcher = wrapPowerShellCommand(command, pane.pidFile)
// wt.exe treats ';' as its own command separator, which breaks
// multi-statement PowerShell commands passed via -Command. Encode the
@ -131,6 +235,8 @@ export class WindowsTerminalBackend implements PaneBackend {
? ['-w', '-1', 'new-tab', '--title', pane.title]
: ['-w', '0', 'split-pane', '--vertical', '--title', pane.title]
await unlink(pane.pidFile).catch(() => {})
const result = await this.runCommand('wt.exe', [
...args,
'powershell.exe',
@ -147,6 +253,34 @@ export class WindowsTerminalBackend implements PaneBackend {
`Failed to launch Windows Terminal teammate ${paneId}: ${result.stderr}`,
)
}
const timeoutMs = getWtPaneTimeoutMs()
let pid: number
try {
pid = await waitForPidFile(pane.pidFile, timeoutMs)
} catch (err) {
throw new Error(
`Windows Terminal pane failed to launch within ${timeoutMs}ms\n` +
` paneId: ${paneId}\n` +
` pidFile: ${pane.pidFile}\n` +
` wt.exe stdout: ${result.stdout || '(empty)'}\n` +
` wt.exe stderr: ${result.stderr || '(empty)'}\n` +
` underlying: ${err instanceof Error ? err.message : String(err)}\n` +
` override timeout via env CLAUDE_WT_PANE_TIMEOUT_MS`,
)
}
pane.pid = pid
pane.status = 'ready'
resolveSpawn()
} catch (err) {
pane.status = 'dead'
pane.pid = undefined
rejectSpawn(err)
throw err
} finally {
pane.spawnPromise = undefined
}
}
async setPaneBorderColor(
@ -189,26 +323,69 @@ export class WindowsTerminalBackend implements PaneBackend {
return false
}
let pid: number
// 1. 解 kill-while-spawn raceawait spawn 完成(不论成功失败)
if (pane.status === 'spawning' && pane.spawnPromise) {
await pane.spawnPromise.catch(() => {})
}
// 2. TOCTOU 修正:重读 status/pid
if (pane.status === 'dead') {
this.panes.delete(paneId)
return false
}
if (pane.status !== 'ready') {
// 还在其它非终态(理论不可达,保险)
return false
}
pane.status = 'killing'
// 3. 优先用缓存 pid
let pid: number | undefined = pane.pid
// 4. fallback缓存没有则读盘保留 retry 3×500ms
if (pid === undefined) {
let pidContent: string | null = null
for (let attempt = 0; attempt < 3; attempt++) {
try {
pid = Number.parseInt((await readFile(pane.pidFile, 'utf-8')).trim(), 10)
pidContent = (await readFile(pane.pidFile, 'utf-8')).trim()
break
} catch {
if (attempt === 2) {
pane.status = 'dead'
this.panes.delete(paneId)
return false
}
if (!Number.isFinite(pid)) {
await new Promise(r => setTimeout(r, 500))
}
}
if (!pidContent || !/^\d+$/.test(pidContent)) {
pane.status = 'dead'
this.panes.delete(paneId)
return false
}
const parsed = Number.parseInt(pidContent, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
pane.status = 'dead'
this.panes.delete(paneId)
return false
}
pid = parsed
}
// 5. 执行 Stop-Process
const result = await this.runCommand('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-Command',
`Stop-Process -Id ${pid} -Force -ErrorAction Stop`,
])
// 6. 不管成功失败都清缓存 + 标 dead + 从 map 删(防 PID 复用误杀)
pane.pid = undefined
pane.status = 'dead'
this.panes.delete(paneId)
logForDebugging(
`[WindowsTerminalBackend] killPane ${paneId} pid=${pid} code=${result.code}`,
)