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:
parent
2bca31e525
commit
bde8f45bef
|
|
@ -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 race:await 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}`,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user