diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 8a12dbab..a45342b3 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -42,7 +42,7 @@ jobs: "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke - run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_eight_session_test qt_terminal_modes_test qt_hidden_retention_test qt_history_navigation_test qt_selection_migration_test qt_find_test qt_scrollbar_size_test qt_clipboard_system_test --parallel 2 + run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_eight_session_test qt_terminal_modes_test qt_hidden_retention_test qt_history_navigation_test qt_selection_migration_test qt_find_test qt_scrollbar_size_test qt_clipboard_system_test qt_query_reply_test --parallel 2 - name: Stage the frozen Qt product and verify every file identity run: | python -m tools.stage_qt_product stage ` @@ -93,6 +93,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-clipboard-bin/Release --executable qt_clipboard_system_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-query-reply-bin/Release --executable qt_query_reply_test.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Preserve runtime file identities without binaries if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -112,8 +114,9 @@ jobs: build/qt-cpp/qt-history-bin/Release/runtime-manifest.json build/qt-cpp/qt-selection-bin/Release/runtime-manifest.json build/qt-cpp/qt-clipboard-bin/Release/runtime-manifest.json + build/qt-cpp/qt-query-reply-bin/Release/runtime-manifest.json - name: Run Qt offscreen smoke in its owned Job - run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_eight_session|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration|qt_find|qt_scrollbar_size)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_eight_session|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration|qt_find|qt_scrollbar_size|qt_query_reply)$' --no-tests=error --verbose - name: Run hosted-only Qt system clipboard copy run: python src/qt/run_smoke.py build/qt-cpp/qt-clipboard-bin/Release/qt_clipboard_system_test.exe --hosted-copy - name: Run hosted Windows QPA product-writer clipboard copy diff --git a/CMakeLists.txt b/CMakeLists.txt index 9929279d..ed925eb8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -882,6 +882,20 @@ if(TERMFORGE_BUILD_QT_SHELL) "$" "$/platforms/" VERBATIM) + add_executable(qt_query_reply_test src/qt/query_reply_test.cpp) + set_target_properties(qt_query_reply_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-query-reply-bin/$") + termforge_qt_entry(qt_query_reply_test) + target_link_libraries(qt_query_reply_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_query_reply_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) + add_test(NAME qt_query_reply COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_query_reply PROPERTIES TIMEOUT 45 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") if(TERMFORGE_ENABLE_RESIZE_REPRO) add_test(NAME qt_terminal_modes_resize_repro COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$" --resize) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index fd088142..9124d1e8 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -147,6 +147,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | More 尾部 ECH 与捕获时序(#42 限定修复) | [合同及来源](research/more-capture-ech.md)、[修复前后与同输入对照](../experiments/differential/results/2026-10-26-more-ech/validation.json)、[历史触发记录](../experiments/shell/results/2026-10-26-margin-state/validation.json) | Recovery ECH 缺失和旧捕获过早分开修正,同原件 whole/byte 红→绿;完整 More 提示、单 q/自然退出/EOF/Job0 独立核对。候选 pending-wrap 差异仍保留。 | | More 分页器轨迹冻结与产品配置冒烟(#28 首批应用切片) | [轨迹与独立预期](research/tui-more-trajectory-2026-11-02.md)、[冒烟记录](../experiments/shell/results/2026-11-02-tui-more/validation.json)、[固定进入样本](../experiments/core/fixtures/more-pager-fixed-411.bin)、[系统变体样本](../experiments/core/fixtures/more-pager-system-375.bin) | Windows 11 x64 固定 More.com 10.0.26100.1(SHA-256 dea4ed89…53,248 字节)的版本/入口/动作(进入→翻页→行滚→q 退出;搜索不适用)与逐步协议预期;固定 ConPTY 1.25 进入/退出 strict+recovery 双通且 4 次捕获确定,系统 ConPTY 进入非确定(ECH70/CUF79 两变体)且 strict 因 OSC 标题失败。DA1(ESC[c)在固定启动包出现(strict 回复 ESC[?6c、recovery 静默);6n、1049、滚动区未在 More 轨迹出现;ED2 仅系统路径进入出现、固定路径用空格+CR。只冻结轨迹不改产品行为,= 行号/F/P/S 交互键、二进制输入、其他语言提示未覆盖。 | | Vim for Windows 轨迹冻结与产品配置冒烟(#28 应用矩阵第 2 项) | [轨迹与独立预期](research/tui-vim-trajectory-2026-11-02.md)、[冒烟记录](../experiments/shell/results/2026-11-02-tui-vim/validation.json)、[80×24 冻结样本](../experiments/core/fixtures/vim-fixed-80x24-trajectory.bin)、[60×12 冻结样本](../experiments/core/fixtures/vim-fixed-60x12-trajectory.bin) | Windows 11 x64 固定 Vim 9.1 patches 1–532(Git for Windows,EXE SHA-256 2e0fda63…,msys-2.0.dll 3096d2ba…)的版本/入口/动作(进入→插入→保存退出→主屏恢复)与逐步协议预期;固定 ConPTY 1.25 全轨迹 3 次捕获逐字节一致,recovery 回放进入/插入/保存/退出/主屏恢复全程通过(exit 0),strict 在偏移 31 的 ESC[22;0;0t 以 too many CSI parameters 失败(terminal.hpp:81-82 参数门限早于 t 分支)。1049 进出(偏移 23/2385)、DA1(偏移 4)、DSR 6n(4 次)、DECSTBM(1;24r)、ED2 均在轨迹出现且核心已处理;DA2、括号粘贴、DCS 同步、多参数 XTWINOPS 标题未实现。60×12 为 ConPTY 60 列×12 行视口,非原版 60×3000 缓冲区,二者不合并。 | +| 正式 Qt / 固定 ConPTY 的 DSR 6n 回复分层证据(#28 回复合同调查) | [分层结果与限制](../experiments/shell/results/2026-11-09-qt-query-reply/README.md)、[固定验证身份](../experiments/shell/results/2026-11-09-qt-query-reply/validation.json)、[核心 reply 合同](../experiments/core/reply_test.cpp) | 80×24 主屏、单会话、home 起点写 `AB`+`ESC[6n`:既有核心合同预期 `ESC[1;3R`;固定 ConPTY 静默对照中 child 仅超时(exit 42,收不到字节),host 写入 `ESC[9;9R` 原样到达 child;正式 SessionModel 在 transport offset 23(固定源串 `AB`+`ESC[6n` 起点;`ESC[6n` 本身从零基字节 25 开始)解析查询并经有界输入队列回写,真实 child 恰收 `ESC[1;3R`,raw/metadata 一致、PID 绑定、cleanup_verified/active_after=0,两次运行字节一致。仅单场景、不 resize/备用屏/滚动区、不验证其它查询/TUI 消费/WindTerm 对齐,`presented_done=null`;child 启用 VT 输入模式;「conhost 代答」旧括注与该固定版本不符。 | ## 核心路线、参考与历史验证 diff --git a/docs/research/tui-vim-trajectory-2026-11-02.md b/docs/research/tui-vim-trajectory-2026-11-02.md index 1403be54..5d4080bc 100644 --- a/docs/research/tui-vim-trajectory-2026-11-02.md +++ b/docs/research/tui-vim-trajectory-2026-11-02.md @@ -116,7 +116,7 @@ - strict 拒绝多参数 XTWINOPS t、括号粘贴、DCS 同步与 DA2 是否应实现、以何种字节回复,未决定;本切片只记录失败位置,不猜原版回复。 - 原版 WindTerm 的 60×3000 缓冲区与 60×12 窗口(dwSize vs srWindow)与 ConPTY 60×12 的映射关系未验证。 - #52/#56 记录的 resize 中途丢输入(MSYS 运行时 SIGWINCH 竞态)未在 ConPTY 产品路径复现,本切片未做中途 resize,只做 60×12 固定起点变体。 -- DSR 6n 的回复字节(ESC[row;colR)在本切片只核对到核心有回复能力(reply_test),未核对 Vim 实际消费回复后继续的行为(本切片 Vim 在 ConPTY 下不等待回复即可进入编辑,ConPTY 由 conhost 代答)。 +- DSR 6n 的回复字节(ESC[row;colR)在本切片只核对到核心有回复能力(reply_test),未核对 Vim 实际消费回复后继续的行为(本切片 Vim 在 ConPTY 下不等待回复即可进入编辑,ConPTY 由 conhost 代答)。**后续修正(2026-11-09)**:固定 ConPTY 1.25 的静默/不同值对照显示 conhost 不代答 DSR,host 回复可原样到达启用 VT 输入模式的 child;见 [分层结果](../../experiments/shell/results/2026-11-09-qt-query-reply/README.md)。该括注保留为原假设。 - 其他终端类型(TERM 变量)、其他 vim 启动参数、多文件/多窗口、鼠标模式、宽字符正文均未覆盖。 - 未观测 DA1/DA2 的原版 WindTerm 回复字节(既有 issue28-probe.md 记录原版 DA/OSC 探针未能采集)。 diff --git a/experiments/shell/results/2026-11-09-qt-query-reply/README.md b/experiments/shell/results/2026-11-09-qt-query-reply/README.md new file mode 100644 index 00000000..193863b0 --- /dev/null +++ b/experiments/shell/results/2026-11-09-qt-query-reply/README.md @@ -0,0 +1,69 @@ +# 正式 Qt / 固定 ConPTY:DSR 6n 回复的分层证据(#28 回复合同调查切片) + +本切片在**正式 C++ / Qt Widgets / 随包固定 Microsoft ConPTY** 构建中,对一个有界、确定的单会话场景核对“核心回复 → Qt 回写 → 真实 child 收到字节”的完整证据链,并分别保留核心孤立合同、ConPTY 原始传输、Qt 产品回写和 child 实际接收四层观察。 + +**结论先行(限定场景)**:在 80×24 固定主屏、单会话、无 resize/备用屏/滚动区、child 已启用 VT 输入模式的场景下,正式 `SessionModel` 在 transport 的固定源串起点 23(`AB`+`ESC[6n` 的 `AB` 起点;`ESC[6n` 本身从零基字节 25 开始)解析到 child 的查询,按既有 reply 合同生成 `ESC[1;3R` 并经同一有界输入队列回写;真实 child 的独立接收文件恰好收到 `1b5b313b3352`(`ESC[1;3R`),PID 与 SessionModel 绑定,自然退出与 OwnedJob 回收均通过。固定 ConPTY 在本配置下**不会自行代答** DSR:host 静默对照中 child 只观察到自己的超时(exit 42),host 写入的另一个位置值也能原样到达 child。 + +不修改产品行为、默认入口或 query 支持;不新增 DA1/DA2/OSC 回复;不进入 #28 整体 TUI 验收,也不宣称 WindTerm 对齐或性能。 + +## 场景与独立预期 + +- 产品入口:正式 `SessionModel`(`src/qt/terminal_session.hpp`,`ParseMode::recovery`,80×24,随包固定 ConPTY 1.25.260930003),不是旧 candidate shell,也不是 strict replay。 +- 固定输入源(child 只写一次,6 B):`41421b5b366e` = `AB` + `ESC[6n`。child 位于主屏 home,先写两个可打印 ASCII 单元,再查询光标位置。 +- 独立字面预期**来自已确认的现有 reply 合同**(`experiments/core/reply_test.cpp`:`CSI 6n` 回复为 `ESC[row;colR`、行列均为 1 基;同一测试以 “ab”+`ESC[6n` 断言 `ESC[1;3R`),不是从被测产品输出反推:home 写 2 个单元后协议光标为 0 基 (0,2),故预期 `1b5b313b3352` = `ESC[1;3R`。 +- child(本测试可执行文件的 `--query-child` 重入入口)打开控制台标准句柄,设置 UTF-8、`ENABLE_VIRTUAL_TERMINAL_INPUT` 与 VT 输出模式,写入固定源后以有界 `ReadConsoleW` 收集实际输入单元并写原始字节文件 + metadata;不调用 `GetConsoleScreenBufferInfo` 等可能触发 conhost 光标同步的 API。 + +## 四层证据 + +| 层 | 入口/源码 | 本层实际观察 | 能支持到哪一步 | +|---|---|---|---| +| 1. 核心孤立合同 | 既有 `experiments/core/reply_test.cpp` + CTest `reply_core`(`CMakeLists.txt` 注册) | 本轮复跑通过(0.01 s);契约断言 “ab”+`ESC[6n` → `ESC[1;3R`,`5n` 负例不回复,DA1 仅 strict 回复 `ESC[?6c`、recovery 静默 | 只有核心 Parser 能生成该字节;不含 Qt/ConPTY/child | +| 2. ConPTY 原始传输(host 静默) | 本切片 `qt_query_reply_test` 的 raw-silent 阶段:`tf_conpty::Session` + 固定后端,观察到 query 后**不写任何回复** | transport = `1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e`(`ESC[1t`、`ESC[c`、`ESC[?1004h`、`ESC[?9001h` 启动包 + `AB` + `ESC[6n`);child 接收为空、exit 42(自身 4 s 超时),`cleanup_complete=true`、`active_after=0` | 固定 ConPTY 转发了 child 查询,但**没有自行产生回复**;此时 child 收不到任何 DSR 字节 | +| 3. ConPTY 原始传输(host 写入诊断值) | 同一 raw 阶段,host 在观察到 query 后写入 `1b5b393b3952`(`ESC[9;9R`,仅诊断用) | child 原始接收文件 = `1b5b393b3952`,exit 0;transport 与层 2 相同 | host→ConPTY→child 的写入路径确实送达真实 child,且未被 conhost 改写为实际位置 | +| 4. Qt 产品回写 + child 实际接收 | 正式 `SessionModel`:`poll()` 中 `parser_.feed()` → `take_replies()` → `send(QByteArray)` → 同一有界 `InputQueue` | transport 含启动包 + `AB` + `ESC[6n` 与关闭 `ESC[?1004l/ESC[?9001l`;`query_offset`=23 是固定源串 `AB`+`ESC[6n` 的起点(`AB` 占 23/24,`ESC[6n` 从零基字节 25 开始);`da1_startup_observed=true`;core 主屏行 0 = `AB`、协议光标 (0,2);child 原始接收 = `1b5b313b3352`,metadata 自报接收一致;child PID 与 `model.resize_state().child_pid` 一致;`natural_exit=true`、`cleanup_verified=true`、`output_eof=true`、`active_after=0` | 产品路径把合同回复送达真实 child;启动包中的 DA1 请求未产生回写(child 精确等于 DSR 回复,未收到 `ESC[?6c`) | + +两次完整运行(实现提交后的干净工作树)字节结果一致,仅 PID 不同: + +| run | product child PID | product 接收 hex | raw-silent 接收/exit | raw-distinct 接收/exit | supervisor job_after | +|---|---|---|---|---|---| +| 1 | 16284 | `1b5b313b3352` | 空 / 42 | `1b5b393b3952` / 0 | `{active:0,total:8,terminated:0}` | +| 2 | 13780 | `1b5b313b3352` | 空 / 42 | `1b5b393b3952` / 0 | `{active:0,total:8,terminated:0}` | + +`presented_done=null`;本切片不抓图、不比较像素,也不测量呈现时间。 + +## 与已有证据的关系(为什么需要新切片) + +- 核心层已有 `reply_test` / `reply_core`;本切片未复制核心测试,只引用其契约并在产品路径复用同一字面预期。 +- 既有 Vim 轨迹(`docs/research/tui-vim-trajectory-2026-11-02.md`)只证明固定 ConPTY transport 中出现过 4 次 `ESC[6n`、核心有回复能力;该文档同时明确“未核对 Vim 实际消费回复后继续的行为”。其第 119 行的括注“ConPTY 由 conhost 代答”与本轮固定配置的动态对照和 pinned 源码都不一致:pinned `outputStream.cpp` 的 `ConhostInternalGetSet::ReturnResponse` 在 VTIO(ConPTY)模式下直接返回,注释写明 “ConPTY should not respond to requests. That's the job of the terminal.”。因此该括注应视为未经核对的假设;本切片保留原记录,不重写历史结论,只以新证据修正该说法。 +- 旧 candidate shell 曾用 `shell_query_client.exe` 核对“产品侧回复 child”,但那是旧候选后端组合,不是正式 Qt / 固定 ConPTY;当前 Qt CTest 没有覆盖该链路。本切片改用正式 `SessionModel` 与同一可执行文件的独立 child 模式,补齐 Qt 回写与真实接收。 + +pinned 源码旁证(静态解释,不替代动态证据):child 的 VT 输出由 `WriteCharsVT` 先更新内部 state machine、再把输入文本转发给 host(`src/host/_stream.cpp`),所以 host 能看到 `ESC[6n`;host 写入的 `CSI R` 在 `InputStateMachineEngine` 中仅在 `IsVtInputEnabled()` 时作为未处理序列透传给 child(`ActionCsiDispatch` 的 `CSI_F3` 分支),与本次 child 启用 VT 输入模式的观察一致。 + +## 环境与身份 + +- Windows 11 x64 build 26200;MSVC 19.42.34433.0;Qt 6.10.3 win64_msvc2022_64 offscreen;随包固定 ConPTY 1.25.260930003(`conpty.dll` `feeef341…805a`、`OpenConsole.exe` `3d66b23d…0586`);app-local CRT 14.51.36247.0;80×24。 +- 后端身份:`reviewed-side-by-side-1.25.260930003`,creation flags = 24(`PSEUDOCONSOLE_GLYPH_WIDTH_CONSOLE` 0x18);产品配置身份 `cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24`。 +- 实现提交:`4806812`(测试 + CMake 注册 + 本次结果记录前置的实现提交;验证文件为后续文档提交)。完整机器可读身份、命令和两次运行原始记录见 [validation.json](validation.json)。 + +## 局限(保持开放) + +- 只覆盖**一个** 80×24 固定主屏、单会话、home 起点的确定位置;不含 resize、备用屏、滚动区、宽字符/结合字、历史、选择、Find。 +- 只覆盖已实现的 `DSR 6n`;DA1 产品路径仍 strict-only/recovery-silent,DA2、OSC 10/11、括号粘贴、鼠标/焦点模式等未实现或未验证,本切片不改变这些结论。 +- child 已启用 `ENABLE_VIRTUAL_TERMINAL_INPUT`。这是 pinned ConPTY 把未处理 CSI 透传给 child 的条件;未启用该模式的 child 在本轮未测试,不能据此推断其接收字节(例如是否被解释为按键事件)。 +- 未和 WindTerm 原版对照,未验证真实 TUI(Vim/More 等)消费回复后的继续行为;#28 的整体 TUI 矩阵、#27/#29/#30 联合验收仍开放。 +- 单次运行约 4.5 s,不是性能或呈现测量;`presented_done=null`。 + +## 复验 + +```powershell +python -m tools.prepare_qt_build # 或复用已准备且身份匹配的 local/qt-build-deps +cmake -S . -B build/query-reply -G "Visual Studio 17 2022" -A x64 ` + -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON ` + -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF ` + -DQt6_DIR="$PWD/local/qt-build-deps/qt/lib/cmake/Qt6" ` + -DTERMFORGE_CONPTY_BACKEND_DIR="$PWD/local/qt-build-deps/conpty" ` + -DTERMFORGE_MSVC_RUNTIME_DIR="$PWD/local/qt-build-deps/crt" +cmake --build build/query-reply --config Release --target qt_query_reply_test reply_test private_mode_test --parallel 2 +ctest --test-dir build/query-reply -C Release -R '^qt_query_reply$' --no-tests=error --output-on-failure +ctest --test-dir build/query-reply -C Release -R '^(reply_core|private_mode_core)$' --output-on-failure +``` diff --git a/experiments/shell/results/2026-11-09-qt-query-reply/validation.json b/experiments/shell/results/2026-11-09-qt-query-reply/validation.json new file mode 100644 index 00000000..30728a99 --- /dev/null +++ b/experiments/shell/results/2026-11-09-qt-query-reply/validation.json @@ -0,0 +1,169 @@ +{ + "schema": "termforge-qt-query-reply-validation-v1", + "source_commit": "480681263fdd270c73469cf2bab406f5dc6bb83a", + "source_tree": "826dd68dfea4a1ed774095c4a2ecb699d6aa2ce9", + "source_binding": "Implementation, test and CMake/CTest wiring were committed before the final two runs; this validation file is a documentation follow-up. The working tree was clean when both recorded runs started.", + "scope": "One bounded, deterministic DSR 6n scenario on the formal C++/Qt/fixed-ConPTY path: 80x24 primary screen, single session, no resize, alternate screen or scroll region; child writes AB + ESC[6n from home; independent expected reply ESC[1;3R comes from the existing core reply contract (reply_test.cpp), not from the product output.", + "passed": true, + "scoped_validation_passed": true, + "dsr6n_product_path_verified": true, + "core_reply_contract_verified": true, + "conpty_independent_reply_observed": false, + "host_written_bytes_reach_child_verified": true, + "product_behavior_changed": false, + "query_support_expanded": false, + "windterm_parity": false, + "full_tui_verified": false, + "presented_done": null, + "environment": { + "os": "Windows 11 x64 build 26200", + "compiler": "MSVC 19.42.34433.0", + "qt": "6.10.3 win64_msvc2022_64, offscreen platform", + "conpty": "1.25.260930003", + "conpty_files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586" + }, + "msvc_runtime": "14.51.36247.0", + "python_for_ctest": "3.12.10", + "backend_identity": "reviewed-side-by-side-1.25.260930003", + "creation_flags": 24, + "product_configuration_identity": "cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24", + "grid": [80, 24] + }, + "dependencies": { + "qt_archive_sha256": "4db84dee7fe3c558f242bef0a88852613af76580dc6d2b24596479f47004dad7", + "sdk_tree_sha256": "a35ab6fe76b62e4d9bd6d3c6b0d476c63583070cea5c9118ef5bb02042ffe266", + "conpty_package_sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "msvc_runtime_package_sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c", + "input_manifests": { + "qt": "2796294f254f7fe0370be99d2de8f7745734c7352ac2648b1f8d9a8fab5ad2cd", + "conpty": "e75e522a3355c0a66c6d60cafa02413a83d8757262747f650b77c959c09ed439", + "msvc_runtime": "154176c8b4ae6dd57fce453e2c2002ff484344b39451fa9880b07db4794f3758" + } + }, + "commands": { + "configure": "cmake -S . -B build/query-reply -G \"Visual Studio 17 2022\" -A x64 -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF -DQt6_DIR=/qt/lib/cmake/Qt6 -DTERMFORGE_CONPTY_BACKEND_DIR=/conpty -DTERMFORGE_MSVC_RUNTIME_DIR=/crt", + "build": "cmake --build build/query-reply --config Release --target qt_query_reply_test reply_test private_mode_test --parallel 2", + "new_test": "ctest --test-dir build/query-reply -C Release -R '^qt_query_reply$' --no-tests=error --output-on-failure", + "core_regression": "ctest --test-dir build/query-reply -C Release -R '^(reply_core|private_mode_core)$' --no-tests=error --output-on-failure" + }, + "inputs": { + "scenario": "dsr-6n-fixed-primary", + "child_source_hex": "41421b5b366e", + "child_source_bytes": 6, + "child_source_decode": "AB + ESC[6n written once from the primary-screen home position", + "expected_reply_hex": "1b5b313b3352", + "expected_reply_decode": "ESC[1;3R", + "expected_derivation": "Existing confirmed core reply contract in experiments/core/reply_test.cpp: CSI 6n replies ESC[row;colR with 1-based row and column; the same test asserts ab + ESC[6n => ESC[1;3R. After two printable cells from home the protocol cursor is (0,2), so the literal expectation is ESC[1;3R.", + "diagnostic_distinct_reply_hex": "1b5b393b3952", + "diagnostic_distinct_reply_scope": "Raw host control only; it is not product output and does not change query support." + }, + "results": { + "runs": [ + { + "label": "run1", + "supervisor_status": "passed", + "exit_code": 0, + "job_after": {"active": 0, "total": 8, "terminated": 0}, + "raw_silent": { + "child_pid": 15976, + "query_seen": true, + "timed_out": true, + "complete": false, + "received_hex": "", + "exit_code": 42, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "raw_distinct": { + "child_pid": 41008, + "query_seen": true, + "timed_out": false, + "complete": true, + "received_hex": "1b5b393b3952", + "exit_code": 0, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "product": { + "child_pid": 16284, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e1b5b3f313030346c1b5b3f393030316c", + "query_offset": 23, + "da1_startup_observed": true, + "core_cursor": [0, 2], + "core_row0_hex": "4142", + "expected_hex": "1b5b313b3352", + "received_hex": "1b5b313b3352", + "timed_out": false, + "complete": true, + "exit_code": 0, + "natural_exit": true, + "cleanup_verified": true, + "active_after": 0 + } + }, + { + "label": "run2", + "supervisor_status": "passed", + "exit_code": 0, + "job_after": {"active": 0, "total": 8, "terminated": 0}, + "raw_silent": { + "query_seen": true, + "timed_out": true, + "complete": false, + "received_hex": "", + "exit_code": 42, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "raw_distinct": { + "query_seen": true, + "timed_out": false, + "complete": true, + "received_hex": "1b5b393b3952", + "exit_code": 0, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e", + "cleanup_verified": true + }, + "product": { + "child_pid": 13780, + "transport_hex": "1b5b31741b5b631b5b3f31303034681b5b3f393030316841421b5b366e1b5b3f313030346c1b5b3f393030316c", + "query_offset": 23, + "da1_startup_observed": true, + "core_cursor": [0, 2], + "core_row0_hex": "4142", + "expected_hex": "1b5b313b3352", + "received_hex": "1b5b313b3352", + "timed_out": false, + "complete": true, + "exit_code": 0, + "natural_exit": true, + "cleanup_verified": true, + "active_after": 0 + } + } + ], + "core_regression": { + "reply_core": "passed 0.01 s", + "private_mode_core": "passed 0.02 s" + }, + "ctest_query_reply": { + "status": "passed", + "wall_seconds": 4.34 + } + }, + "recorded_observations": { + "transport_decode": "startup ESC[1t ESC[c ESC[?1004h ESC[?9001h (23 B) + AB + ESC[6n, then shutdown ESC[?1004l ESC[?9001l; the product capture includes shutdown, the raw stages were captured before close. Zero-based offsets: the fixed 6-byte source AB+ESC[6n starts at byte 23 (AB at 23/24; the ESC[6n sequence itself starts at byte 25).", + "silent_control": "With the host silent the real child received no bytes and exited through its own 4 s timeout (42); the fixed ConPTY therefore did not answer the DSR query itself in this configuration.", + "distinct_control": "The host-written diagnostic value ESC[9;9R reached the child unchanged, proving the host -> ConPTY -> child write path.", + "product_chain": "SessionModel parsed the exact fixed source at transport offset 23 (start of AB+ESC[6n; ESC[6n itself starts at zero-based byte 25), held the core cursor at (0,2) with primary row 0 = AB, and the real child received exactly ESC[1;3R; the child raw file and metadata cross-check, PID identity and cleanup all agree." + }, + "limitations": [ + "Single 80x24 primary-screen, single-session, home-start scenario; no resize, alternate screen, scroll region, wide/combining text, history, selection or Find.", + "Only the implemented DSR 6n query. DA1 stays strict-only/recovery-silent; DA2, OSC 10/11, bracketed paste and mouse/focus modes remain unimplemented or unverified; this slice adds no product query support.", + "The child enables ENABLE_VIRTUAL_TERMINAL_INPUT, which is the pinned ConPTY condition for passing unhandled CSI through to the child; non-VT-input children were not tested and their byte delivery is unknown.", + "No WindTerm original comparison and no real TUI consumption evidence (e.g. Vim continuing after the reply); #28 overall TUI matrix and #27/#29/#30 joint acceptance remain open.", + "Runs take about 4.5 s and are not performance or presentation measurements; presented_done is null." + ] +} diff --git a/src/qt/query_reply_test.cpp b/src/qt/query_reply_test.cpp new file mode 100644 index 00000000..b8b1a1c1 --- /dev/null +++ b/src/qt/query_reply_test.cpp @@ -0,0 +1,442 @@ +// Layered DSR 6n reply evidence for the formal C++/Qt/fixed-ConPTY path. +// +// The confirmed core contract (experiments/core/reply_test.cpp) answers a cursor +// position query with ESC[row;colR, both 1-based. This test fixes one bounded +// primary-screen scenario (write two printable ASCII cells from home, then ask +// ESC[6n) and separates four layers: +// +// 1. raw fixed-ConPTY transport with no host reply: does conhost answer by +// itself? (it must not; the pinned source's ConPTY path suppresses replies) +// 2. raw fixed-ConPTY transport with a distinct diagnostic reply: do bytes +// written by the host reach the real child unchanged? +// 3. the product SessionModel path: core reply -> Qt write-back -> ConPTY -> +// the real child receives the contract bytes ESC[1;3R. +// +// The child is this same executable re-entered through --query-child. It only +// enables the console's VT input/output modes, writes the fixed source once and +// records the exact UTF-16 input units it receives, so the observed bytes do not +// depend on the implementation under test. Test-only: no product behavior, +// default entry or query support is changed. +#include "terminal_session.hpp" +#include "../../experiments/conpty/session.hpp" +#include "../../experiments/core/text_projection.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using termforge::qt::SessionModel; + +// Fixed bounded scenario: a fresh 80x24 primary screen writes two printable +// ASCII cells from home and then asks for the cursor position. The independent +// literal expectation follows the existing reply contract, not this test's +// implementation: 1-based row 1, column 3 => ESC[1;3R. +constexpr std::string_view query_source = "AB\x1b[6n"; +constexpr std::string_view contract_reply = "\x1b[1;3R"; +// A deliberately wrong position used only by the raw diagnostic control; it +// proves the host-written bytes reach the child and are not produced by conhost. +constexpr std::string_view distinct_reply = "\x1b[9;9R"; +constexpr DWORD child_reply_timeout_ms = 4000; +constexpr ULONGLONG query_observe_timeout_ms = 5000; +constexpr ULONGLONG child_exit_timeout_ms = 8000; +constexpr ULONGLONG product_finish_timeout_ms = 12000; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +std::string hex(std::string_view bytes) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result; + for (const unsigned char byte : bytes) { + result += digits[byte >> 4]; + result += digits[byte & 15]; + } + return result; +} + +std::string read_binary(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + require(bool(input), "expected child result file is missing"); + return std::string(std::istreambuf_iterator(input), std::istreambuf_iterator()); +} + +void write_binary(const std::filesystem::path& path, std::string_view bytes) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + require(bool(output), "child result file could not be created"); + output.write(bytes.data(), static_cast(bytes.size())); + require(bool(output), "child result file write failed"); +} + +// Minimal field extraction for the child's own bounded metadata envelope. The +// raw received bytes are also written separately and cross-checked; this parser +// never feeds the expected reply value. +std::string metadata_field(const std::string& json, std::string_view key) { + const std::string quoted = "\"" + std::string(key) + "\":"; + const auto begin = json.find(quoted); + require(begin != std::string::npos, "child metadata field is missing"); + auto value = begin + quoted.size(); + auto end = json.find_first_of(",}", value); + require(end != std::string::npos, "child metadata field is unterminated"); + return json.substr(value, end - value); +} + +std::string metadata_hex(const std::string& json, std::string_view key) { + const auto field = metadata_field(json, key); + require(field.size() >= 2 && field.front() == '"' && field.back() == '"', + "child metadata hex field is not quoted"); + return field.substr(1, field.size() - 2); +} + +bool metadata_bool(const std::string& json, std::string_view key) { + const auto field = metadata_field(json, key); + require(field == "true" || field == "false", "child metadata boolean field is invalid"); + return field == "true"; +} + +unsigned long metadata_number(const std::string& json, std::string_view key) { + const auto field = metadata_field(json, key); + require(!field.empty() && field.find_first_not_of("0123456789") == std::string::npos, + "child metadata number field is invalid"); + return std::stoul(field); +} + +struct ChildObservation { + std::string raw; + std::string metadata; + unsigned long pid = 0; + bool timed_out = false; + bool complete = false; +}; + +ChildObservation read_child(const std::filesystem::path& result) { + ChildObservation observation; + observation.raw = read_binary(result); + observation.metadata = read_binary(result.string() + ".json"); + observation.pid = metadata_number(observation.metadata, "pid"); + observation.timed_out = metadata_bool(observation.metadata, "timed_out"); + observation.complete = metadata_bool(observation.metadata, "complete"); + // The independently written raw bytes and the metadata projection must agree. + require(metadata_hex(observation.metadata, "received_hex") == hex(observation.raw), + "child metadata disagrees with its raw received bytes"); + require(metadata_hex(observation.metadata, "source_hex") == hex(query_source), + "child metadata source differs from the fixed source"); + require(metadata_hex(observation.metadata, "contract_reply_hex") == hex(contract_reply), + "child metadata contract expectation differs from the confirmed reply contract"); + return observation; +} + +// Child-side receiver. It never reads the screen or console APIs beyond modes, +// so it cannot trigger conhost's cursor-sync DSR path; the only observed input +// is what the host actually delivered through ConPTY. +int query_child(const std::filesystem::path& result) { + try { + const HANDLE input = GetStdHandle(STD_INPUT_HANDLE); + const HANDLE output = GetStdHandle(STD_OUTPUT_HANDLE); + require(input && input != INVALID_HANDLE_VALUE, "query child console input unavailable"); + require(output && output != INVALID_HANDLE_VALUE, "query child console output unavailable"); + BOOL owned = FALSE; + require(IsProcessInJob(GetCurrentProcess(), nullptr, &owned) && owned, + "query child must run inside the owned Session Job"); + require(SetConsoleCP(CP_UTF8) && SetConsoleOutputCP(CP_UTF8), + "query child UTF-8 codepage setup failed"); + require(SetConsoleMode(input, ENABLE_VIRTUAL_TERMINAL_INPUT), + "query child VT input mode setup failed"); + require(SetConsoleMode(output, ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | + ENABLE_VIRTUAL_TERMINAL_PROCESSING | + DISABLE_NEWLINE_AUTO_RETURN), + "query child VT output mode setup failed"); + DWORD written = 0; + require(WriteFile(output, query_source.data(), static_cast(query_source.size()), &written, + nullptr) && + written == query_source.size(), + "query child source write failed"); + + std::string received; + bool timed_out = false; + bool complete = false; + const ULONGLONG deadline = GetTickCount64() + child_reply_timeout_ms; + while (!complete) { + const ULONGLONG now = GetTickCount64(); + if (now >= deadline) { + timed_out = true; + break; + } + const DWORD state = WaitForSingleObject(input, static_cast(deadline - now)); + if (state == WAIT_TIMEOUT) { + timed_out = true; + break; + } + require(state == WAIT_OBJECT_0, "query child console wait failed"); + wchar_t units[64]; + DWORD count = 0; + require(ReadConsoleW(input, units, 64, &count, nullptr) && count, + "query child console read failed"); + for (DWORD i = 0; i < count; ++i) { + require(units[i] >= 0 && units[i] <= 127, "query child reply is not ASCII"); + received.push_back(static_cast(units[i])); + require(received.size() <= 1024, "query child reply exceeds its fixed budget"); + if (units[i] == L'R') { + complete = true; + break; + } + } + } + + write_binary(result, received); + std::ofstream metadata(result.string() + ".json", std::ios::binary | std::ios::trunc); + require(bool(metadata), "query child metadata file could not be created"); + metadata << "{\"schema\":\"termforge-query-child-v1\",\"pid\":" << GetCurrentProcessId() + << ",\"source_hex\":\"" << hex(query_source) << "\",\"contract_reply_hex\":\"" + << hex(contract_reply) << "\",\"received_hex\":\"" << hex(received) + << "\",\"received_bytes\":" << received.size() + << ",\"timed_out\":" << (timed_out ? "true" : "false") + << ",\"complete\":" << (complete ? "true" : "false") << ",\"vt_input_mode\":true}\n"; + metadata.close(); + require(bool(metadata), "query child metadata write failed"); + return complete ? 0 : (timed_out ? 42 : 43); + } catch (const std::exception& error) { + std::fprintf(stderr, "query child: %s\n", error.what()); + return 5; + } +} + +std::wstring child_arguments(const std::filesystem::path& result) { + return L"--query-child \"" + result.wstring() + L"\""; +} + +struct RawStage { + std::string backend; + DWORD creation_flags = 0; + std::string transport_hex; + std::string received_hex; + bool query_seen = false; + bool timed_out = false; + bool complete = false; + DWORD exit_code = 0; + DWORD child_pid = 0; + bool cleanup_verified = false; +}; + +// Direct fixed-ConPTY run without SessionModel. Passing no reply keeps the host +// silent (does conhost answer the child itself?); passing distinct_reply proves +// the host->ConPTY->child byte path. Neither run uses the product reply code. +RawStage run_raw(const std::filesystem::path& executable, const std::filesystem::path& directory, + const std::filesystem::path& result, std::optional reply) { + RawStage stage; + tf_conpty::Session session(tf_conpty::Backend::configured()); + stage.backend = session.backend_identity(); + stage.creation_flags = session.backend_creation_flags(); + require(stage.backend != "system", "raw control must load the fixed bundled ConPTY backend"); + session.start(executable.wstring(), child_arguments(result), {}, directory.wstring(), {80, 24}); + stage.child_pid = session.pid; + const ULONGLONG observe_deadline = GetTickCount64() + query_observe_timeout_ms; + while (GetTickCount64() < observe_deadline) { + if (session.output().find(query_source) != std::string::npos) { + stage.query_seen = true; + break; + } + if (session.wait_exit(2)) break; + Sleep(2); + } + require(stage.query_seen, "raw control did not observe the child's fixed source on the ConPTY transport"); + if (reply) session.send(*reply); + bool exited = false; + const ULONGLONG exit_deadline = GetTickCount64() + child_exit_timeout_ms; + while (GetTickCount64() < exit_deadline) { + if (session.wait_exit(5)) { + exited = true; + break; + } + } + stage.transport_hex = hex(session.output()); + require(exited, "raw control child did not exit within its bounded deadline"); + stage.exit_code = session.exit_code; + session.close(); + session.check_reader(); + require(session.cleanup_complete && session.active_after == 0 && !session.capture_overflow, + "raw control did not reclaim its owned child, host and pipes"); + stage.cleanup_verified = true; + const auto child = read_child(result); + require(child.pid == session.pid, "raw control child identity mismatch"); + stage.received_hex = hex(child.raw); + stage.timed_out = child.timed_out; + stage.complete = child.complete; + return stage; +} + +struct ProductStage { + std::string backend; + std::string transport_hex; + size_t query_offset = 0; + bool da1_startup_observed = false; + int core_row = 0; + int core_column = 0; + std::string core_row0_hex; + std::string expected_hex = hex(contract_reply); + std::string received_hex; + bool timed_out = false; + bool complete = false; + DWORD exit_code = 0; + DWORD child_pid = 0; + bool natural_exit = false; + bool cleanup_verified = false; + bool output_eof = false; + DWORD active_after = 0; +}; + +// Product path: the real SessionModel feeds parsed transport into the recovery +// Parser and writes take_replies() back through its shared bounded input queue. +ProductStage run_product(const std::filesystem::path& executable, const std::filesystem::path& directory, + const std::filesystem::path& result) { + SessionModel model; + model.enable_transport_capture(); + require(model.start(executable.wstring(), child_arguments(result), directory.wstring()), + "product SessionModel did not start the fixed ConPTY child"); + QElapsedTimer lifecycle; + lifecycle.start(); + while (!model.finished() && lifecycle.elapsed() < static_cast(product_finish_timeout_ms)) { + QTest::qWait(5); + if (!model.error().isEmpty()) throw std::runtime_error(model.error().toStdString()); + } + require(model.finished(), "product SessionModel did not finish within its bounded deadline"); + + ProductStage stage; + stage.backend = model.configuration_identity().toStdString(); + const auto transport = model.transport_capture(); + stage.transport_hex = hex(transport); + stage.da1_startup_observed = transport.find("\x1b[c") != std::string::npos; + const auto query = transport.find(query_source); + require(query != std::string::npos, + "product transport did not carry the child's exact fixed source"); + stage.query_offset = query; + const auto& screen = model.screen(); + stage.core_row = screen.row; + stage.core_column = screen.column; + require(screen.width() == 80 && screen.height() == 24, + "product core grid is not the fixed 80x24 primary screen"); + const auto row0 = termforge::project_line(screen.visible_lines()[0]).text; + stage.core_row0_hex = hex(row0); + require(row0 == "AB", "product core primary row does not contain the fixed AB source"); + require(stage.core_row == 0 && stage.core_column == 2, + "product core protocol cursor after the fixed source is not row 1 column 3"); + stage.exit_code = model.exit_code(); + stage.child_pid = model.resize_state().child_pid; + stage.natural_exit = model.natural_exit(); + stage.cleanup_verified = model.cleanup_verified(); + stage.output_eof = model.output_eof(); + stage.active_after = model.active_processes_after(); + require(model.natural_exit() && model.cleanup_verified() && model.active_processes_after() == 0, + "product SessionModel natural exit or owned cleanup was not verified"); + + const auto child = read_child(result); + require(child.pid == stage.child_pid, "product child identity mismatch"); + stage.received_hex = hex(child.raw); + stage.timed_out = child.timed_out; + stage.complete = child.complete; + std::cerr << "{\"schema\":\"termforge-qt-query-reply-product-v1\",\"backend\":\"" + << stage.backend << "\",\"transport_hex\":\"" << stage.transport_hex + << "\",\"query_offset\":" << stage.query_offset << ",\"da1_startup_observed\":" + << (stage.da1_startup_observed ? "true" : "false") << ",\"core_cursor\":[" + << stage.core_row << ',' << stage.core_column << "],\"core_row0_hex\":\"" + << stage.core_row0_hex << "\",\"expected_hex\":\"" << stage.expected_hex + << "\",\"received_hex\":\"" << stage.received_hex << "\",\"timed_out\":" + << (stage.timed_out ? "true" : "false") << ",\"complete\":" + << (stage.complete ? "true" : "false") << ",\"child_pid\":" << stage.child_pid + << ",\"exit_code\":" << stage.exit_code << ",\"natural_exit\":" + << (stage.natural_exit ? "true" : "false") << ",\"cleanup_verified\":" + << (stage.cleanup_verified ? "true" : "false") << ",\"active_after\":" + << stage.active_after << ",\"presented_done\":null}\n"; + return stage; +} + +void emit_raw(const char* name, const RawStage& stage) { + std::cerr << "{\"schema\":\"termforge-qt-query-reply-raw-v1\",\"stage\":\"" << name + << "\",\"backend\":\"" << stage.backend << "\",\"creation_flags\":" << stage.creation_flags + << ",\"child_pid\":" << stage.child_pid << ",\"query_seen\":" + << (stage.query_seen ? "true" : "false") << ",\"timed_out\":" + << (stage.timed_out ? "true" : "false") << ",\"complete\":" + << (stage.complete ? "true" : "false") << ",\"exit_code\":" << stage.exit_code + << ",\"received_hex\":\"" << stage.received_hex << "\",\"transport_hex\":\"" + << stage.transport_hex << "\",\"cleanup_verified\":" + << (stage.cleanup_verified ? "true" : "false") << ",\"presented_done\":null}\n"; +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + if (argc == 3 && std::string_view(argv[1]) == "--query-child") { + return query_child(std::filesystem::path(argv[2])); + } + if (argc != 1) { + std::cerr << "usage: qt_query_reply_test\n"; + return 2; + } + char gate[3]{}; + DWORD count = 0; + BOOL owned = FALSE; + if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), gate, sizeof(gate), &count, nullptr) || count != 3 || + std::string_view(gate, count) != "GO\n" || !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || + !owned) + return 2; + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + try { + QTemporaryDir workspace; + require(workspace.isValid(), "query reply workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "query reply test executable path unavailable"); + const std::filesystem::path exe(executable); + const std::filesystem::path directory(workspace.path().toStdWString()); + + const auto silent = run_raw(exe, directory, directory / "raw-silent.bin", std::nullopt); + emit_raw("raw-silent", silent); + require(!silent.complete && silent.received_hex.empty(), + "fixed ConPTY answered the child's DSR itself instead of leaving it to the host"); + require(silent.exit_code == 42 && silent.timed_out, + "silent-host control child did not observe its bounded timeout"); + require(silent.cleanup_verified, "silent-host control did not verify cleanup"); + + const auto distinct = run_raw(exe, directory, directory / "raw-distinct.bin", + std::string(distinct_reply)); + emit_raw("raw-distinct", distinct); + require(distinct.complete && distinct.received_hex == hex(distinct_reply), + "host-written diagnostic bytes did not reach the real child unchanged"); + require(distinct.exit_code == 0, "distinct-reply control child did not complete"); + + const auto product = run_product(exe, directory, directory / "product.bin"); + require(product.complete, "product child did not complete its bounded reply wait"); + require(product.exit_code == 0, "product child exited before receiving its reply"); + require(product.received_hex == product.expected_hex, + "product path did not deliver the literal ESC[1;3R contract reply to the real child"); + std::cout << "{\"schema\":\"termforge-qt-query-reply-v1\",\"scenario\":" + "\"dsr-6n-fixed-primary\",\"source_hex\":\"" + << hex(query_source) << "\",\"contract_reply_hex\":\"" << hex(contract_reply) + << "\",\"raw_silent\":{\"received_hex\":\"" << silent.received_hex + << "\",\"timed_out\":true,\"exit_code\":" << silent.exit_code + << "},\"raw_distinct\":{\"received_hex\":\"" << distinct.received_hex + << "\",\"exit_code\":" << distinct.exit_code << "},\"product\":{\"received_hex\":\"" + << product.received_hex << "\",\"core_cursor\":[" << product.core_row << ',' + << product.core_column << "],\"query_offset\":" << product.query_offset + << ",\"child_pid\":" << product.child_pid << ",\"exit_code\":" << product.exit_code + << ",\"cleanup_verified\":true},\"child_count\":3,\"presented_done\":null}\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +}