fix(windows): prevent terminal startup failures

## Bug 详情
- issues.md #46:Windows MSYS2/UCRT64 下执行 csc,欢迎信息输出后在 stdin raw mode/终端输入初始化阶段崩溃,出现 native std::bad_weak_ptr 和后续 Bun mutex corrupt。
- issues.md #47:cmd.exe 可启动,但 PowerShell 运行 csc 会走 npm 生成的 csc.ps1;截图显示 node.exe 报错 "Input must be provided either through stdin or as a prompt argument when using --print",说明裸 csc 被误判成 print/non-interactive 模式。

## 根因
- MSYS2/Cygwin/mintty 等 Windows 终端对 Node/Bun stdin raw mode 和终端扩展控制序列兼容性不稳定,启动早期强行 setRawMode/查询终端能力会触发崩溃风险。
- npm 的 PowerShell shim 解析优先级高于 csc.cmd,且可能让 stdout TTY 状态不可用;原 main.tsx 只要 stdout 非 TTY 就自动进入 non-interactive/--print 路径,最终在没有 prompt/stdin 时触发 #47 的报错。

## 修复方案
- 为 Ink stdin raw mode 增加 Windows 风险终端检测,在 MSYS2/Cygwin/mintty 或显式禁用开关下跳过 raw mode,使用 cooked input fallback。
- early input 捕获在启动早期复用同类风险判断,避免 REPL 渲染前就触发 raw mode 崩溃。
- postinstall 在 Windows 上清理属于本包的 csc.ps1,让 PowerShell 回落到与 cmd.exe 一致的 csc.cmd 启动路径。
- 新增 non-interactive 判定工具,保留显式 -p/--print、--init-only、--sdk-url 的 headless 行为,但要求 stdin/stdout 都不是交互 TTY 才自动进入 non-interactive,避免 PowerShell shim 下裸 csc 被误判。

## 变更要点
- 新增 packages/@ant/ink/src/core/raw-mode-support.ts 及单元测试,覆盖普通 Windows shell、MSYS2/Cygwin/mintty 和禁用开关
- 调整 Ink App raw mode 生命周期,只在真正启用 raw mode 后写入/清理 bracketed paste、focus reporting、extended key 等终端控制序列
- 调整 component unmount/exit 清理逻辑,按 rawModeEnabledCount/rawModeActive 关闭实际启用的输入监听和 raw mode
- 调整 src/utils/earlyInput.ts,避免不安全 Windows 终端在启动阶段启用 stdin raw mode
- 调整 scripts/postinstall.cjs,立即和延迟清理 npm 生成的 csc.ps1 shim
- 调整 src/main.tsx 使用 shouldUseNonInteractiveSession,并新增对应测试覆盖 PowerShell shim 场景

## 自测
- bun test packages/@ant/ink/src/core/__tests__/raw-mode-support.test.ts src/utils/__tests__/earlyInput.test.ts src/utils/__tests__/nonInteractiveSession.test.ts
- node --check scripts/postinstall.cjs
- bun run src/entrypoints/cli.tsx --version
- bun run lint
- git diff --check

## 已知限制
- bun run typecheck 仍失败,错误来自仓库既有缺失声明/缺失模块和无关类型问题,不来自本次改动。
This commit is contained in:
IronRookieCoder 2026-05-14 16:55:07 +08:00
parent 95a4430fa2
commit 223b694965
8 changed files with 418 additions and 53 deletions

View File

@ -40,6 +40,7 @@ import {
type ParsedMouse,
parseMultipleKeypresses,
} from '../core/parse-keypress.js';
import { supportsStdinRawMode } from '../core/raw-mode-support.js';
import reconciler from '../core/reconciler.js';
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js';
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js';
@ -157,6 +158,7 @@ export default class App extends PureComponent<Props, State> {
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
rawModeEnabledCount = 0;
rawModeActive = false;
internal_eventEmitter = new EventEmitter();
keyParseState = INITIAL_STATE;
@ -195,7 +197,11 @@ export default class App extends PureComponent<Props, State> {
// Determines if TTY is supported on the provided stdin
isRawModeSupported(): boolean {
return this.props.stdin.isTTY;
return supportsStdinRawMode(this.props.stdin);
}
isCookedModeFallbackSupported(): boolean {
return this.props.stdin.isTTY && !this.isRawModeSupported();
}
override render() {
@ -258,7 +264,7 @@ export default class App extends PureComponent<Props, State> {
this.pendingHyperlinkTimer = null;
}
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
if (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false);
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
@ -279,7 +285,8 @@ export default class App extends PureComponent<Props, State> {
handleSetRawMode = (isEnabled: boolean): void => {
const { stdin } = this.props;
if (!this.isRawModeSupported()) {
const rawModeSupported = this.isRawModeSupported();
if (!rawModeSupported && !this.isCookedModeFallbackSupported()) {
if (stdin === process.stdin) {
throw new Error(
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
@ -314,39 +321,50 @@ export default class App extends PureComponent<Props, State> {
}
stdin.ref();
stdin.setRawMode(true);
stdin.addListener('readable', this.handleReadable);
// Enable bracketed paste mode
this.props.stdout.write(EBP);
// Enable terminal focus reporting (DECSET 1004)
this.props.stdout.write(EFE);
// Enable extended key reporting so ctrl+shift+<letter> is
// distinguishable from ctrl+<letter>. We write both the kitty stack
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
// terminals honor whichever they implement (tmux only accepts the
// latter).
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
if (rawModeSupported) {
stdin.setRawMode(true);
this.rawModeActive = true;
} else {
this.rawModeActive = false;
defaultCallbacks.logForDebugging(
'[stdin] Raw mode disabled for this Windows terminal; using cooked input fallback',
{ level: 'warn' },
);
}
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
// detection when env vars are absent. Fire-and-forget: the DA1
// sentinel bounds the round-trip, and if the terminal ignores the
// query, flush() still resolves and name stays undefined.
// Deferred to next tick so it fires AFTER the current synchronous
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
if (r) {
setXtversionName(r.name);
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
} else {
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
}
stdin.addListener('readable', this.handleReadable);
if (rawModeSupported) {
// Enable bracketed paste mode
this.props.stdout.write(EBP);
// Enable terminal focus reporting (DECSET 1004)
this.props.stdout.write(EFE);
// Enable extended key reporting so ctrl+shift+<letter> is
// distinguishable from ctrl+<letter>. We write both the kitty stack
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
// terminals honor whichever they implement (tmux only accepts the
// latter).
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
}
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
// detection when env vars are absent. Fire-and-forget: the DA1
// sentinel bounds the round-trip, and if the terminal ignores the
// query, flush() still resolves and name stays undefined.
// Deferred to next tick so it fires AFTER the current synchronous
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
if (r) {
setXtversionName(r.name);
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
} else {
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
}
});
});
});
}
}
this.rawModeEnabledCount++;
@ -366,13 +384,16 @@ export default class App extends PureComponent<Props, State> {
return;
}
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
// Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE);
// Disable bracketed paste mode
this.props.stdout.write(DBP);
stdin.setRawMode(false);
if (this.rawModeActive) {
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
// Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE);
// Disable bracketed paste mode
this.props.stdout.write(DBP);
stdin.setRawMode(false);
this.rawModeActive = false;
}
stdin.removeListener('readable', this.handleReadable);
stdin.unref();
}
@ -480,7 +501,7 @@ export default class App extends PureComponent<Props, State> {
};
handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) {
if (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false);
}

View File

@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test';
import {
isWindowsRawModeUnsafe,
supportsStdinRawMode,
} from '../raw-mode-support.js';
describe('isWindowsRawModeUnsafe', () => {
test('does not disable raw mode for ordinary Windows shells', () => {
expect(
isWindowsRawModeUnsafe({
ComSpec: 'C:\\Windows\\System32\\cmd.exe',
}, 'win32'),
).toBe(false);
expect(
isWindowsRawModeUnsafe({
PSModulePath: 'C:\\Program Files\\PowerShell\\Modules',
}, 'win32'),
).toBe(false);
});
test('disables raw mode for MSYS2 and Cygwin terminals', () => {
expect(isWindowsRawModeUnsafe({ MSYSTEM: 'UCRT64' }, 'win32')).toBe(true);
expect(isWindowsRawModeUnsafe({ TERM: 'cygwin' }, 'win32')).toBe(true);
expect(isWindowsRawModeUnsafe({ TERM_PROGRAM: 'mintty' }, 'win32')).toBe(
true,
);
expect(isWindowsRawModeUnsafe({ SHELL: '/usr/bin/bash' }, 'win32')).toBe(
false,
);
expect(
isWindowsRawModeUnsafe({ SHELL: '/usr/bin/msys2_shell.cmd' }, 'win32'),
).toBe(true);
});
test('supports an explicit emergency disable switch', () => {
expect(
isWindowsRawModeUnsafe({
CLAUDE_CODE_DISABLE_STDIN_RAW_MODE: '1',
}, 'win32'),
).toBe(true);
});
});
describe('supportsStdinRawMode', () => {
test('requires a TTY stream with setRawMode', () => {
expect(supportsStdinRawMode({ isTTY: false } as NodeJS.ReadStream)).toBe(
false,
);
expect(supportsStdinRawMode({ isTTY: true } as NodeJS.ReadStream)).toBe(
false,
);
});
});

View File

@ -0,0 +1,43 @@
function isEnvTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true';
}
export function isWindowsRawModeUnsafe(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): boolean {
if (platform !== 'win32') {
return false;
}
if (isEnvTruthy(env.CLAUDE_CODE_DISABLE_STDIN_RAW_MODE)) {
return true;
}
const term = env.TERM?.toLowerCase() ?? '';
const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? '';
const msystem = env.MSYSTEM?.toLowerCase() ?? '';
const shell = env.SHELL?.toLowerCase() ?? '';
return (
term === 'cygwin' ||
term.includes('mintty') ||
termProgram.includes('mintty') ||
msystem.length > 0 ||
shell.includes('msys') ||
shell.includes('cygwin') ||
shell.includes('mingw')
);
}
export function supportsStdinRawMode(stdin: NodeJS.ReadStream): boolean {
if (!stdin.isTTY) {
return false;
}
if (isWindowsRawModeUnsafe(process.env, process.platform)) {
return false;
}
return typeof stdin.setRawMode === 'function';
}

View File

@ -13,7 +13,7 @@
const { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, chmodSync } =
require("fs")
const { spawnSync } = require("child_process")
const { spawn, spawnSync } = require("child_process")
const { setDefaultResultOrder } = require("node:dns")
const path = require("path")
const os = require("os")
@ -97,6 +97,110 @@ function getBinaryPath() {
return path.resolve(dir, subdir, binary)
}
function safeResolve(...parts) {
const filtered = parts.filter(Boolean)
if (filtered.length === 0) return null
return path.resolve(...filtered)
}
function removePowerShellShimIfOwned(shimPath) {
if (!shimPath || !existsSync(shimPath)) return false
const cmdShimPath = shimPath.replace(/\.ps1$/i, ".cmd")
if (!existsSync(cmdShimPath)) return false
let content
try {
content = readFileSync(shimPath, "utf8")
} catch {
return false
}
// Only remove npm-generated shims for this package. PowerShell resolves
// csc.ps1 before csc.cmd, and enterprise ExecutionPolicy often blocks .ps1
// launchers while cmd.exe works. Removing this shim makes PowerShell fall
// through to the same .cmd launcher used by cmd.exe.
const normalized = content.replace(/\\/g, "/")
if (!normalized.includes("@costrict") && !normalized.includes("dist/cli-node.js")) {
return false
}
try {
rmSync(shimPath, { force: true })
console.log(`[postinstall] Removed PowerShell shim ${shimPath}; PowerShell will use csc.cmd`)
return true
} catch (error) {
console.warn(
`[postinstall] Could not remove PowerShell shim ${shimPath}: ${
error instanceof Error ? error.message : String(error)
}`,
)
return false
}
}
function cleanupWindowsPowerShellShim() {
if (process.platform !== "win32") return
const dirs = getWindowsPowerShellShimDirs()
const seen = new Set()
for (const dir of dirs) {
const shimPath = path.join(dir, "csc.ps1")
if (seen.has(shimPath)) continue
seen.add(shimPath)
removePowerShellShimIfOwned(shimPath)
}
}
function getWindowsPowerShellShimDirs() {
return [
safeResolve(process.env.npm_config_prefix),
safeResolve(process.env.npm_config_local_prefix, "node_modules", ".bin"),
safeResolve(process.env.INIT_CWD, "node_modules", ".bin"),
safeResolve(projectRoot, "node_modules", ".bin"),
].filter(Boolean)
}
function scheduleWindowsPowerShellShimCleanup() {
if (process.platform !== "win32") return
const dirs = getWindowsPowerShellShimDirs()
if (dirs.length === 0) return
const script = String.raw`
const { existsSync, readFileSync, rmSync } = require("fs");
const path = require("path");
const dirs = JSON.parse(process.argv[1] || "[]");
setTimeout(() => {
for (const dir of dirs) {
const shimPath = path.join(dir, "csc.ps1");
const cmdShimPath = path.join(dir, "csc.cmd");
if (!existsSync(shimPath) || !existsSync(cmdShimPath)) continue;
let content = "";
try {
content = readFileSync(shimPath, "utf8").replace(/\\/g, "/");
} catch {
continue;
}
if (!content.includes("@costrict") && !content.includes("dist/cli-node.js")) continue;
try {
rmSync(shimPath, { force: true });
} catch {}
}
}, 3000);
`
try {
const child = spawn(process.execPath, ["-e", script, JSON.stringify(dirs)], {
detached: true,
stdio: "ignore",
windowsHide: true,
})
child.unref()
} catch {
// Best effort only. Immediate cleanup above covers the normal npm flow.
}
}
// --- Download helpers ---
function proxyEnvSet() {
@ -341,6 +445,8 @@ async function downloadAndExtract() {
}
async function main() {
cleanupWindowsPowerShellShim()
scheduleWindowsPowerShellShimCleanup()
await downloadAndExtract()
}

View File

@ -233,6 +233,7 @@ import { getGhAuthStatus } from "./utils/github/ghAuthStatus.js";
import { safeParseJSON } from "./utils/json.js";
import { logError } from "./utils/log.js";
import { getModelDeprecationWarning } from "./utils/model/deprecation.js";
import { shouldUseNonInteractiveSession } from "./utils/nonInteractiveSession.js";
import {
getDefaultMainLoopModel,
getUserSpecifiedModelSetting,
@ -1134,15 +1135,16 @@ export async function main() {
}
}
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
// This is needed because telemetry initialization calls auth functions that need this flag
const cliArgs = process.argv.slice(2);
const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print");
const hasInitOnlyFlag = cliArgs.includes("--init-only");
const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url"));
const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE);
const isNonInteractive =
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY);
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
// This is needed because telemetry initialization calls auth functions that need this flag
const cliArgs = process.argv.slice(2);
const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE);
const isNonInteractive = shouldUseNonInteractiveSession({
args: cliArgs,
forceInteractive,
stdinIsTTY: process.stdin.isTTY,
stdoutIsTTY: process.stdout.isTTY,
});
// Stop capturing early input for non-interactive modes
if (isNonInteractive) {

View File

@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test'
import { shouldUseNonInteractiveSession } from '../nonInteractiveSession.js'
describe('shouldUseNonInteractiveSession', () => {
test('uses non-interactive mode for explicit headless flags', () => {
expect(
shouldUseNonInteractiveSession({
args: ['--print'],
forceInteractive: false,
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(true)
expect(
shouldUseNonInteractiveSession({
args: ['-p'],
forceInteractive: true,
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(true)
expect(
shouldUseNonInteractiveSession({
args: ['--init-only'],
forceInteractive: false,
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(true)
expect(
shouldUseNonInteractiveSession({
args: ['--sdk-url=http://localhost:1234'],
forceInteractive: false,
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(true)
})
test('does not enter print mode for PowerShell shims that preserve stdin TTY', () => {
expect(
shouldUseNonInteractiveSession({
args: [],
forceInteractive: false,
stdinIsTTY: true,
stdoutIsTTY: false,
}),
).toBe(false)
})
test('keeps automatic non-interactive mode when both stdio sides are non-TTY', () => {
expect(
shouldUseNonInteractiveSession({
args: [],
forceInteractive: false,
stdinIsTTY: false,
stdoutIsTTY: false,
}),
).toBe(true)
expect(
shouldUseNonInteractiveSession({
args: [],
forceInteractive: false,
stdinIsTTY: undefined,
stdoutIsTTY: undefined,
}),
).toBe(true)
})
test('forceInteractive only suppresses automatic non-interactive detection', () => {
expect(
shouldUseNonInteractiveSession({
args: [],
forceInteractive: true,
stdinIsTTY: false,
stdoutIsTTY: false,
}),
).toBe(false)
})
})

View File

@ -13,6 +13,39 @@
import { lastGrapheme } from './intl.js'
function isEnvTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true'
}
function supportsEarlyInputRawMode(): boolean {
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
return false
}
if (process.platform !== 'win32') {
return true
}
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_STDIN_RAW_MODE)) {
return false
}
const term = process.env.TERM?.toLowerCase() ?? ''
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? ''
const msystem = process.env.MSYSTEM?.toLowerCase() ?? ''
const shell = process.env.SHELL?.toLowerCase() ?? ''
return !(
term === 'cygwin' ||
term.includes('mintty') ||
termProgram.includes('mintty') ||
msystem.length > 0 ||
shell.includes('msys') ||
shell.includes('cygwin') ||
shell.includes('mingw')
)
}
// Buffer for early input characters
let earlyInputBuffer = ''
// Flag to track if we're currently capturing
@ -33,7 +66,7 @@ export function startCapturingEarlyInput(): void {
// be in print mode. Raw mode disables ISIG (terminal Ctrl+C → SIGINT),
// which would make -p uninterruptible.
if (
!process.stdin.isTTY ||
!supportsEarlyInputRawMode() ||
isCapturing ||
process.argv.includes('-p') ||
process.argv.includes('--print')

View File

@ -0,0 +1,27 @@
export type NonInteractiveSessionOptions = {
args: string[]
forceInteractive: boolean
stdinIsTTY: boolean | undefined
stdoutIsTTY: boolean | undefined
}
export function shouldUseNonInteractiveSession({
args,
forceInteractive,
stdinIsTTY,
stdoutIsTTY,
}: NonInteractiveSessionOptions): boolean {
if (args.includes('-p') || args.includes('--print')) return true
if (args.includes('--init-only')) return true
if (args.some(arg => arg.startsWith('--sdk-url'))) return true
if (forceInteractive) return false
const hasInteractiveStdin = stdinIsTTY === true
const hasInteractiveStdout = stdoutIsTTY === true
// Some Windows launchers, notably npm's generated PowerShell .ps1 shim,
// can hide stdout TTY status while stdin is still an interactive terminal.
// Treat that as interactive so a bare `csc` does not fall into --print mode.
return !hasInteractiveStdout && !hasInteractiveStdin
}