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 { randomUUID } from 'crypto'
import { readFile } from 'fs/promises' import { readFile, unlink } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' 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 CommandResult = { stdout: string; stderr: string; code: number }
type CommandRunner = (command: string, args: string[]) => Promise<CommandResult> type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>
type PaneStatus = 'registered' | 'spawning' | 'ready' | 'killing' | 'dead'
type WindowsTerminalPane = { type WindowsTerminalPane = {
title: string title: string
mode: 'pane' | 'window' mode: 'pane' | 'window'
pidFile: string pidFile: string
status: PaneStatus
pid?: number
spawnPromise?: Promise<void>
} }
function quotePowerShellString(value: string): string { function quotePowerShellString(value: string): string {
@ -39,8 +44,42 @@ function wrapPowerShellCommand(command: string, pidFile: string): string {
].join('; ') ].join('; ')
} }
function makePidFile(paneId: string): string { const WT_PANE_TIMEOUT_DEFAULT_MS = 8000
return join(tmpdir(), `${paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`) 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 panes = new Map<PaneId, WindowsTerminalPane>()
private readonly runCommand: CommandRunner
private readonly getPlatformValue: () => Platform
private readonly pidFileDir: string
constructor( constructor(
private readonly runCommand: CommandRunner = execFileNoThrow, runCommandOrOptions?:
private readonly getPlatformValue: () => Platform = getPlatform, | 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> { async isAvailable(): Promise<boolean> {
if (this.getPlatformValue() !== 'windows') { if (this.getPlatformValue() !== 'windows') {
@ -92,7 +161,8 @@ export class WindowsTerminalBackend implements PaneBackend {
this.panes.set(paneId, { this.panes.set(paneId, {
title: name, title: name,
mode: 'pane', mode: 'pane',
pidFile: makePidFile(paneId), pidFile: this.makePidFile(paneId),
status: 'registered',
}) })
return { paneId, isFirstTeammate } return { paneId, isFirstTeammate }
} }
@ -106,7 +176,8 @@ export class WindowsTerminalBackend implements PaneBackend {
this.panes.set(paneId, { this.panes.set(paneId, {
title: name, title: name,
mode: 'window', mode: 'window',
pidFile: makePidFile(paneId), pidFile: this.makePidFile(paneId),
status: 'registered',
}) })
return { paneId, isFirstTeammate: false, windowName } return { paneId, isFirstTeammate: false, windowName }
} }
@ -121,32 +192,95 @@ export class WindowsTerminalBackend implements PaneBackend {
throw new Error(`Unknown Windows Terminal pane id: ${paneId}`) throw new Error(`Unknown Windows Terminal pane id: ${paneId}`)
} }
const launcher = wrapPowerShellCommand(command, pane.pidFile) // 拒绝 ready 态重 spawn避免同 pidFile 双进程竞争)
// wt.exe treats ';' as its own command separator, which breaks if (pane.status === 'ready' || pane.status === 'killing') {
// multi-statement PowerShell commands passed via -Command. Encode the
// entire script as Base64 UTF-16LE and use -EncodedCommand instead.
const encoded = Buffer.from(launcher, 'utf16le').toString('base64')
const args =
pane.mode === 'window'
? ['-w', '-1', 'new-tab', '--title', pane.title]
: ['-w', '0', 'split-pane', '--vertical', '--title', pane.title]
const result = await this.runCommand('wt.exe', [
...args,
'powershell.exe',
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded,
])
if (result.code !== 0) {
throw new Error( throw new Error(
`Failed to launch Windows Terminal teammate ${paneId}: ${result.stderr}`, `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
// entire script as Base64 UTF-16LE and use -EncodedCommand instead.
const encoded = Buffer.from(launcher, 'utf16le').toString('base64')
const args =
pane.mode === 'window'
? ['-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',
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded,
])
if (result.code !== 0) {
throw new Error(
`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( async setPaneBorderColor(
@ -189,26 +323,69 @@ export class WindowsTerminalBackend implements PaneBackend {
return false return false
} }
let pid: number // 1. 解 kill-while-spawn raceawait spawn 完成(不论成功失败)
try { if (pane.status === 'spawning' && pane.spawnPromise) {
pid = Number.parseInt((await readFile(pane.pidFile, 'utf-8')).trim(), 10) await pane.spawnPromise.catch(() => {})
} catch { }
// 2. TOCTOU 修正:重读 status/pid
if (pane.status === 'dead') {
this.panes.delete(paneId) this.panes.delete(paneId)
return false return false
} }
if (pane.status !== 'ready') {
if (!Number.isFinite(pid)) { // 还在其它非终态(理论不可达,保险)
this.panes.delete(paneId)
return false 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 {
pidContent = (await readFile(pane.pidFile, 'utf-8')).trim()
break
} catch {
if (attempt === 2) {
pane.status = 'dead'
this.panes.delete(paneId)
return false
}
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', [ const result = await this.runCommand('powershell.exe', [
'-NoLogo', '-NoLogo',
'-NoProfile', '-NoProfile',
'-Command', '-Command',
`Stop-Process -Id ${pid} -Force -ErrorAction Stop`, `Stop-Process -Id ${pid} -Force -ErrorAction Stop`,
]) ])
// 6. 不管成功失败都清缓存 + 标 dead + 从 map 删(防 PID 复用误杀)
pane.pid = undefined
pane.status = 'dead'
this.panes.delete(paneId) this.panes.delete(paneId)
logForDebugging( logForDebugging(
`[WindowsTerminalBackend] killPane ${paneId} pid=${pid} code=${result.code}`, `[WindowsTerminalBackend] killPane ${paneId} pid=${pid} code=${result.code}`,
) )