fix: 修复 Windows Node.js 构建产物因 stdin.ref() 泄漏导致进程挂起 (#353)

startCapturingEarlyInput() 调用 stdin.ref() 后,如果 Ink 未能接管
(如 raw mode 不支持或 setup 阶段异常),unref() 永远不会被调用,
导致 Node.js 事件循环无法退出。修复包括:
- stopCapturingEarlyInput() 中补充 stdin.unref() 调用
- 新增 10s 安全阀定时器自动清理 leaked ref()
- Ink App.componentWillUnmount 兜底 unref() 非 TTY stdin

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-24 21:16:15 +08:00
committed by GitHub
parent e0c8e9dafc
commit e38d45460e
2 changed files with 48 additions and 3 deletions

View File

@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
// Windows Node.js), ensure stdin is unref'd so the process can
// exit. earlyInput may have called ref() before Ink mounted.
try {
this.props.stdin.unref()
} catch {
// stdin may already be destroyed
}
}
}

View File

@@ -19,6 +19,8 @@ let earlyInputBuffer = ''
let isCapturing = false
// Reference to the readable handler so we can remove it later
let readableHandler: (() => void) | null = null
// Safety valve: auto-cleanup after timeout so stdin.ref() never leaks
let safetyTimer: ReturnType<typeof setTimeout> | null = null
/**
* Start capturing stdin data early, before the REPL is initialized.
@@ -60,6 +62,20 @@ export function startCapturingEarlyInput(): void {
}
process.stdin.on('readable', readableHandler)
// Safety valve: if Ink never takes over within 10s (e.g. setup dialog
// stalls, or an error prevents Ink mount on Windows), unref stdin so
// the process doesn't hang forever. The REPL's Ink App normally calls
// consumeEarlyInput() → stopCapturingEarlyInput() long before this.
safetyTimer = setTimeout(() => {
if (isCapturing) {
stopCapturingEarlyInput()
}
}, 10_000)
// Don't let the timer itself keep the event loop alive
if (safetyTimer && typeof safetyTimer === 'object' && 'unref' in safetyTimer) {
safetyTimer.unref()
}
} catch {
// If we can't set raw mode, just silently continue without early capture
isCapturing = false
@@ -172,14 +188,34 @@ export function stopCapturingEarlyInput(): void {
isCapturing = false
// Clear safety timer
if (safetyTimer) {
clearTimeout(safetyTimer)
safetyTimer = null
}
if (readableHandler) {
process.stdin.removeListener('readable', readableHandler)
readableHandler = null
}
// Don't reset stdin state - the REPL's Ink App will manage stdin state.
// If we call setRawMode(false) here, it can interfere with the REPL's
// own stdin setup which happens around the same time.
// Undo the ref() from startCapturingEarlyInput so the event loop isn't
// kept alive if Ink never takes over (e.g. raw mode unsupported on
// Windows Node.js, or an error during setup). Ink's own
// handleSetRawMode(true) calls stdin.ref() again, and its
// handleSetRawMode(false) / unmount path calls stdin.unref(), so this
// unref is safe even when Ink does take over — the two ref/unref calls
// balance out.
try {
process.stdin.unref()
} catch {
// stdin may already be destroyed
}
// Don't reset setRawMode here — Ink's App.handleSetRawMode(true)
// calls stopCapturingEarlyInput() synchronously and then immediately
// calls setRawMode(true) + ref() on the same stdin, so toggling it
// off here would add a visible flicker on Windows.
}
/**