Skip to content

fix(core): 对齐 strict 与 recovery 的接受集,DA1 回复 VT102 - #272

Merged
JavaZeroo merged 5 commits into
mainfrom
fix/core-real-conpty-modes
Nov 1, 2026
Merged

JavaZeroo merged 5 commits into
mainfrom
fix/core-real-conpty-modes

Conversation

@JavaZeroo

@JavaZeroo JavaZeroo commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

问题与最终行为

main 的 feed_strict 路径(replay 与测试在用)对真实 ConPTY 流里的若干序列直接抛错,而产品的 recovery 路径本就吞掉它们并计数。两者对同一份字节给出不同结论,测试路径因此无法回放真实捕获。

本 PR 让 strict 在这几类序列上与 recovery 对齐。

序列 修改前(strict) 修改后(strict) recovery(产品,未变)
ESC[?1004h/l 焦点上报 throw 接受,忽略 已吞掉
ESC[?9001h/l Win32 输入模式 throw 接受,忽略 已吞掉
ESC[1/2/3t 窗口命令 throw 接受,忽略 已吞掉
ESC[c DA1 throw 回复 ESC[?6c 已吞掉(不回复)
ESC[?2004h bracketed paste throw 仍 throw 已吞掉
ESC[?1000h 鼠标 throw 仍 throw 已吞掉
ESC[4t/ESC[11t 查询 throw 仍 throw 已吞掉

为什么 DA1 回 ESC[?6c

  • 候选实现对同一查询回的正是 ESC[?6c(vendor/alacritty_terminal/src/term/mod.rs:1214),差分对照因此一致
  • VT102 是核心实际能支撑的最小声明:光标定位、擦除、插删行/字符、SGR、DECAWM、备用屏均在实现范围内;更高的版本号属于过度声明
  • 回复通过既有的 pending_replies 通道,与 CSI 6n 的 DSR 回复同一机制

为什么有四项仍抛错

接受 ?2004/?1000 会让应用以为终端支持括号粘贴或鼠标上报,属于用"不报错"制造虚假能力;CSI 4t/11t 是需要回复的查询,静默吞掉会让应用一直等;OSC 标题在 strict 路径尚未支持。这些保持显式失败,并在测试里断言仍然抛错。

验证

新增 experiments/core/private_mode_test.cpp,注册为 CTest private_mode_core:

private mode checks passed
68/68 tests passed, 0 failed out of 68   134.99s

用例覆盖:

  1. ?1004h/l、?9001h/l 前后屏幕 JSON 与纯文本输入完全一致(接受但无副作用)
  2. ESC[1/2/3t 同样无副作用
  3. ESC[c 只产生一条 ESC[?6c 回复(take_replies() 断言)
  4. ?2004h、?1000h、ESC[4t、ESC[11t 仍然抛错

真实捕获的推进(2026-10-27-native-viewport-resize/case-0/transport.bin,104 字节):

修复前: 停在第 0 字节(ESC[1t)
修复后: 通过 ESC[1t、ESC[c、?1004、?9001,停在该流的 OSC 0 标题处

OSC 是 strict 路径的下一个缺口,本 PR 不处理。

性能影响(如适用)

不适用。分派分支增加,无运行期成本。

来源与限制

  • 缺口清单与实测见 #28 的四条评论
  • 限制:本 PR 只对齐 strict 与 recovery 的接受集,不新增产品能力;recovery 路径对 ?9001 等仍是丢弃而非支持,Win32 输入模式与括号粘贴的实际语义仍未实现

真实捕获 experiments/conpty/results/2026-10-27-native-viewport-resize/case-0/
transport.bin 在 strict 下第一个控制序列就失败,核心无法回放任何真实 ConPTY 传输。

按「记录型接受、不改变行为」的最小范围补三条:

- ?1004 焦点上报:接受并忽略。核心不绘制焦点状态,宿主也没有可作用的对象
- ?9001 Win32 输入模式:接受并忽略,同上
- CSI 1t 窗口操作(XTWOL/XTMOD):接受并忽略。这是 ConPTY 在原生 resize 附近
  发出的通知,按它改变几何会与宿主自身的尺寸管理冲突

有意不做的:
- ESC[c(DA1)仍抛错。回复内容等于声明终端能力,核心实现的是很有限的子集,
  回复哪一种都需要先定策略,本提交不预设
- ?2004 bracketed paste 与 ?1000 鼠标模式仍抛错,避免用「接受」制造已支持的假象

效果:上面那份捕获的解析从字节 0 推进到字节 4(即 ESC[c 处),前三项的接受
不再阻塞回放。
决定 DA1 策略:回复 ESC[?6c(VT102)。依据是候选实现
(vendor/alacritty_terminal/src/term/mod.rs:1214)对 CSI c 回同一条,
且 VT102 是核心实际能支撑的最小声明——光标定位、擦除、插删行/字符、
SGR、DECAWM、备用屏都在实现范围内,而更高版本号会过度声明。

同时澄清一处此前的误读:核心有两条解析路径。feed_strict(旧路径,
replay 与测试在用)遇到 OSC 等序列直接抛错;recovery(产品路径)本就
会吞掉这些并计数。本提交让 strict 在 ?1004/?9001/CSI 1-3t/DA1 上与
recovery 对齐,而不是新增产品能力。

保留仍抛错的项(并有测试断言):?2004 bracketed paste、?1000 鼠标、
CSI 4t/11t 等查询、OSC(strict 路径尚未支持标题)。
@JavaZeroo JavaZeroo changed the title fix(core): 接受真实 ConPTY 会发出的 ?1004/?9001 与 CSI 1t fix(core): 对齐 strict 与 recovery 的接受集,DA1 回复 VT102 Oct 31, 2026
ConPTY emits DA1 exactly once in each session's startup packet. On the product recovery path that now produces a pending reply, which poll() sends back through the same bounded input path as keystrokes. When a session is closed before its first poll (resize_lifecycle_boundaries mode 0), send() hits !running() and records a fatal 'Terminal input is unavailable' that the await helper surfaces. A reply only matters while the child is still attached, so skip it once the session is no longer running. This also removes the same latent race for DSR cursor-position replies.
The product parser runs in recovery mode, but the shared dispatch()
added in #272 made recovery answer the fixed ConPTY startup packet's DA1
query with ESC[?6c too. The Qt entry drains that reply and sends it back
through the bounded input path as if it were a keystroke at session
startup, which races with the session lifecycle: when the child has
exited or is closing, the input queue is already stopping and send()
records "Terminal input queue is full or unavailable" (qt_cpp_shell
PR-event failures).

Answer DA1 only in strict mode (replay/tests), matching the #272 design
table where strict is for replay/testing and recovery is the product
path that stays silent.
@JavaZeroo

Copy link
Copy Markdown
Owner Author

诊断结论:不是 DA1 回路,而是 DA1 回复与快速关闭会话的竞态

已定位根因并提交修复 96f11c9(src/qt/terminal_session.hpp)。

证据链(实测)

  1. ConPTY 每次会话启动都向宿主发一次 DA1 \x1b[c。已有原始捕获可证:experiments/conpty/results/2026-10-27-native-viewport-resize/case-*/transport.bin,每个文件都在 \x1b[1t 之后紧跟一次 \x1b[c。
  2. 本 PR 的 case 'c'(experiments/core/terminal.hpp:108-115)把 \x1b[?6c 写入 pending_replies。
  3. SessionModel::poll()(src/qt/terminal_session.hpp)每条回复都经 send() 写回输入通道。
  4. send() 在 !running() 时记录致命错误 Terminal input is unavailable(src/qt/terminal_session.hpp:247)。
  5. resize_smoke.cpp 的 resize_lifecycle_boundaries mode 0(stage 900)在 start() 后立即 terminal.close(),早于首个 poll 定时器(10ms)处理启动包;此时 DA1 回复被 send() 命中 !running(),await helper(resize_smoke.cpp:133-141)把任何非空 model.error() 当失败 → qt_cpp_resize 在 stage 900 失败。

CI 实测(两个失败运行的日志完全一致)

  • push 41187449450 与 PR 41187453220:qt_cpp_shell Passed、qt_unicode_input Passed,仅 qt_cpp_resize 在 stage 900 以 Terminal input is unavailable 失败。

对「查询→回复→再查询」回路假设的判定:证伪

  • 主循环 stage 0–8 的 size_child 在 DA1 回复写入后仍正常应答 ~15 次 Q 命令,说明该回复被 ConPTY 自己消费、并未转发给子进程(否则 fixture 读到 0x1B 会直接 throw 退出)。这属于推断(由 size_child 存活反推),ConPTY 内部精确处理未直接取证。
  • 我未能在任何日志中观察到输入队列灌满(Terminal input queue is full or unavailable);这两次运行里 shell 均通过。「queue full」不成立。

修复

src/qt/terminal_session.hpp 的 poll() 回复循环加 if (!running()) break;:查询回复只在子进程仍连接时有意义,会话进入关闭后直接丢弃,既不再产生虚假 Terminal input is unavailable,也顺带消除 DSR 光标位置回复同样的潜在竞态。

red→green:

  • red:de55d8f(两次 CI 均 resize stage 900 失败,见 41187449450 / 41187453220)。
  • green:96f11c9(等待 qt-cpp-windows 重跑验证)。

(任务描述中的 windeployqt 硬杀是另一次资源类失败,非本代码路径;未在此处理。)

@JavaZeroo

Copy link
Copy Markdown
Owner Author

根因:recovery(产品路径)不该回复 DA1

结论:de55d8f 把 case 'c'(DA1)加进共享 dispatch(),strict 与 recovery 都回 ESC[?6c。产品解析器是 recovery(src/qt/terminal_session.hpp:36),于是产品路径第一次会回复 ConPTY 启动包里的 DA1,并把这条回复经有界输入队列当作击键回写——这违反了 #272 自述「recovery 未变」。

实测(固定 ConPTY 1.25 + 逐字节探针,区分实测/推断):

  • 固定 ConPTY 启动包逐会话发出一次 ESC[1t ESC[c ESC[?1004h ESC[?9001h ESC[?7l ESC[?7h …,其中 DA1 = ESC[c 恰好一次(与 experiments/conpty/results/2026-10-27-native-viewport-resize/case-*/transport.bin 字节证据一致)。
  • recovery 解析器对它只回复一次 ESC[?6c(reply_count=1,6s 内不再增加)。
  • 没有无限回路:回写后 cmd 的 cooked 回显不会把 ESC[?6c 原样送回输出(输出流中 ESC[?6c 出现 0 次),解析器不会再次看到 DA1 查询。「回显→再回复→洪泛→16KB 满」假设被推翻。

推断(CI 日志 41288373162): 这条唯一的启动期回复经 src/qt/terminal_session.hpp:163-165 的 take_replies() 走 SessionModel::send() → InputQueue::send(),在会话启动瞬间与子进程/输入队列生命周期竞态,队列拒绝该次发送,send() 返回 false → 记 Terminal input queue is full or unavailable(terminal_session.hpp:252),qt_cpp_shell 在首个 prompt await(~1.44s)失败。96f11c9 的 if (!running()) break 只覆盖 close_requested/finished,未覆盖「input_ 已 stop」的窗口。这是 PR 事件才会稳定命中的时序竞态。

修复

  • experiments/core/terminal.hpp:108-121:case 'c' 仅在 mode == ParseMode::strict 时回 ESC[?6c;recovery(产品路径)保持吞掉不回复——与 fix(core): 对齐 strict 与 recovery 的接受集,DA1 回复 VT102 #272 设计表一致(strict 用于 replay/测试,recovery 是产品路径)。?1004/?9001/CSI 1-3t 无副作用,保持共享不动。
  • experiments/core/reply_test.cpp:29-40:补「recovery 不回复 DA1、strict 回复 DA1」的断言。

CI

commit: b05e273 fix(core): answer DA1 only on the strict parser path

…modes

Bring in main's DECOM origin-mode work (#301) so the DA1 fix can be
CI-tested against current main. Resolves the private-CSI block and test
registration conflicts; both additions are kept.
@JavaZeroo

Copy link
Copy Markdown
Owner Author

跟进:CI 已转绿。

推送修复后 main 又前进了(91a7214 DECOM origin-mode #301),与本分支在 experiments/core/terminal.hpp(private-CSI 块)和 CMakeLists.txt(测试注册)产生冲突,导致 PR 一度 unmergeable 且 PR 事件 CI 无法触发。已用 merge commit 2d3e583 合并 main 并保留双方新增(DECOM ?6 + ?1004/?9001),无 force push。

核心合并后本地验证:reply_test(recovery 不回复 DA1 / strict 回复)、private_mode_test、decom_origin_test(11/11)均通过。

@JavaZeroo
JavaZeroo merged commit c3e825e into main Nov 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant