Skip to content

fix(gateway): use cooked-mode readline for approval prompts on Windows#77

Open
knqiufan wants to merge 2 commits into
stepfun-ai:mainfrom
knqiufan:fix/76-windows-approval-readline-leak
Open

fix(gateway): use cooked-mode readline for approval prompts on Windows#77
knqiufan wants to merge 2 commits into
stepfun-ai:mainfrom
knqiufan:fix/76-windows-approval-readline-leak

Conversation

@knqiufan

@knqiufan knqiufan commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

关联 Issue

Closes #76

变更类型

  • feat 新功能
  • fix bug 修复
  • docs 文档
  • refactor 重构(无行为变更)
  • test 仅测试
  • chore 构建 / 脚手架 / CI
  • perf 性能优化

影响范围

  • 改动了 packages/protocol/(跨边界协议,需要同步 SDK)
  • 改动了 packages/core/ 的公开 API
  • 改动了 src/gateway/(session authority 行为变更)
  • 改动了分层依赖(同步更新了 AGENTS.md.dependency-cruiser.cjs
  • 仅 docs / 注释 / 测试

变更说明(why,而非 what)

Windows console 下,terminal: true 创建的 readline 接口在 rl.close() 时无法可靠移除 stdin 上的 keypress listener。每次 promptForToolApproval / promptForUserClarification 进出都给 stdin 累加一个泄漏的 listener。到第 N 个 prompt 时,累积的 N 个 listener 既会从 Windows focus / IME / 终端模式切换事件合成出空行提交(rl.question() 提前 resolve 成 "",被解释为 deny,agent-loop 顺序推进到下一个 tool call —— 即「30 秒静默后 prompt 自动跳过」),又会把单次 y 通过每个 listener 的 _ttyWrite 各 echo 一次(终端显示 yyy)。yy/yyy 无法匹配决策解析,会话死锁。

详见 #76 的根因分析。

关键决策:把 readline 接口从 terminal: true 切到 terminal: false(cooked mode)。OS 接管行编辑,只在用户按 Enter 时交付完整一行 —— 没有 raw mode、没有 keypress listener、没有伪空行事件。两个症状从源头一起消除。

接受的折衷:prompt 内失去行内编辑(退格 / 方向键 / 历史)。对 y/n 审批场景可接受,用户只需输入一个字符 + Enter。

实现结构:抽出 src/gateway/interactive-readline.ts 作为工厂(per-call 创建 + dispose),两个 prompt 函数在 try/finally 里使用。工厂在创建时快照已有的 keypress listener;dispose 只清理此后新增的差集,不再调用 input.pause()setRawMode(false),因此不会影响主 REPL 的长驻 readline。

测试计划

  • 单测:src/gateway/interactive-readline.test.ts 共 7 个用例,覆盖每次调用返回独立 interface、terminal: false 不增长 keypress listener、dispose 安全可重入、循环 create/dispose 不累积 listener、debug 日志开启时不抛错,以及 dispose 不影响既存 listener。pnpm vitest run src/gateway/interactive-readline.test.ts 全绿。
  • 集成:不涉及(readline 改动属于交互层,无对应 integration 套件)。
  • 手工验证(Windows 11 10.0.26200,Windows Terminal):
    • pnpm step "list all .md files in the current directory" —— 修复前 ~30s 静默后会自动刷出下一条 prompt,且按一次 y 显示 yyy;修复后只出现一条 prompt,静默 30s+ 不自动跳过,按一次 y + Enter 单字符显示并正常推进到下一个 tool call。
    • pnpm step(无参数,REPL 模式)—— 主输入框与 approval 流程互不干扰。
  • vitest.config.tscoverage.include 已加入 src/gateway/interactive-readline.ts

自检清单

  • 本地 pnpm check 已通过(test / lint / dep-guard / deadcode / tsc / format)
  • 新加文件配套加了 .test.ts
  • 平台相关代码用了 vi.skipIf / describe.runIf,未硬编码 platform 判断 —— 本改动不引入新的平台分支,行为对所有平台一致(只是 Windows 上更需要)
  • 新增被覆盖统计的模块已同步加入 vitest.config.tscoverage.include
  • 不包含二进制、构建产物、密钥
  • 未使用 --no-verify 绕过钩子

@github-actions github-actions Bot added area/build scripts, .github, build/config files area/gateway src/gateway labels Jun 21, 2026
@ZouR-Ma

ZouR-Ma commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

根因分析很扎实——terminal: true 在 Windows 下 rl.close() 清不掉 keypress 监听、累积后合成空行提交 + 单键多次回显,这条链路和 #76 对得上;选择 cooked mode 从源头消掉 raw mode 依赖、并把折衷(prompt 内无行内编辑)明文写出来,这个决策方式我们很认可。per-call 工厂 + try/finally dispose 的结构也对。

合入前有两个必须处理的点:

  1. (必须)dispose 的防御性清理会误伤主 REPL 的 readline。 disposeInteractiveReadline 把 stdin 上所有 keypress 监听一次移除,还调用了 input.pause()——但文本 REPL 的主循环(runtime.ts:2145)用的是全会话长驻的 terminal: true readline,它的 keypress 监听要活到会话结束。REPL 里触发一次审批 → dispose → 主 readline 的监听被摘,下一次 rl.question 的输入行为就不可预期了。你在 Windows Terminal 验证过这条路径没出事,但摘不属于自己的监听本质上是碰运气,mac/Linux 终端上没验证过。推荐修法(约 5 行):create 时快照 input.listeners("keypress"),dispose 时只移除快照之后新增的那部分;input.pause() 建议一并去掉或同样按差集原则处理。验收标准:差集清理 + 现有 6 例不回归 + 补一条「dispose 不动既存监听」的用例。(如果你确认现状在三平台都无害,贴出「REPL → 审批 → 继续正常输入」的三平台验证记录也可以,但差集修法更省心。)
  2. (必须)rebase 到最新 main,让 CI 重跑。 现在的绿是 6 月 21 日旧基线跑的。顺带:#75 已经修掉了你 --no-verify 说明里那 10 个 symlink 失败——rebase 后本地 pnpm check 应能全绿,那段坦白说明可以删了(当时写清楚根因这个做法本身很好)。

一个留档的点:cooked mode 是全平台生效的,macOS/Linux 的审批 prompt 同样失去行内编辑——y/n 场景没问题;将来若有多字符输入的 prompt 复用这个工厂,需要重新评估。两点改完 @ 我们,这个和 #79 我们都想尽快合。

knqiufan and others added 2 commits July 18, 2026 02:21
Windows console + `terminal: true` readline leaks keypress listeners
across prompts (`rl.close()` does not reliably remove the listener on
Windows). At the Nth prompt stdin has N accumulated listeners, which
(a) synthesize spurious empty-line submits from focus/IME/mode-switch
events and (b) echo a single keystroke N times via `_ttyWrite`.

Result: prompts auto-advance after ~30s idle, and a single `y` becomes
`yy`/`yyy`, which fails the decision parser and deadlocks the session.

Switch both `promptForToolApproval` and `promptForUserClarification`
to a factory that creates the readline interface with `terminal: false`
(cooked mode). The OS handles line editing and delivers complete lines
only on Enter — no raw mode, no keypress listener, no spurious events.

Trade-off: no in-line editing (backspace, arrows) inside the prompt;
acceptable for y/n approval where the user types one char + Enter.

`disposeInteractiveReadline` also runs `input.pause()`,
`setRawMode(false)`, and removes any stray `keypress` listeners as
defensive cleanup for Windows hosts where residue was observed even
with `terminal: false`.

Closes stepfun-ai#76

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@knqiufan
knqiufan force-pushed the fix/76-windows-approval-readline-leak branch from b2fa820 to 7beb47d Compare July 17, 2026 18:26
@knqiufan

knqiufan commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

@ZouR-Ma 感谢细致审阅,两个必须项都已处理:

  • 已 rebase 到最新 main(包含 test(utils): align path and shell tests with TESTING.md on Windows #75),并安全 force-push 更新 PR;描述中已删除过期的 --no-verify / symlink 失败说明。
  • createInteractiveReadline 现在会快照创建前已有的 keypress listeners;dispose 在关闭临时 readline 后只移除本次创建后出现的差集(按计数处理重复 listener),不再调用 input.pause()setRawMode(false),不会影响主 REPL 的长驻 readline。
  • 新增「dispose 不移除既存 listener」用例,当前 interactive-readline 测试共 7 项。

已本地运行 pnpm check:90 个测试文件通过,1514 passed / 24 skipped;PR 的 macOS CI / Ubuntu CI / Windows CI 已全绿。请再帮忙确认,感谢!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build scripts, .github, build/config files area/gateway src/gateway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Windows TTY: tool-approval prompts auto-advance after ~30s idle and a single y is echoed N times

2 participants