From 197aa9a9248e4db3f48325a53e59c098352c9cb5 Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:34:02 +0800 Subject: [PATCH 1/2] feat(qt): verify eight-session input target isolation (#30) Add qt_eight_session: eight real children are created only through the public +/tab-switch/close entries, each with its own PID, process-local nonce and incremental UTF-8 receive file. Hard-coded literal expectations (ASCII, CJK, supplementary plane, combining mark, and the unmodified Home key) must reach only the active target, including through a non-creation-order switch sequence. A controlled 120-line-per-child background block proves inactive children keep draining and never take the input target. Closing a non-active and then the active tab must leave surviving targets receiving exactly their literal bytes; a replacement tab must not inherit a closed tab's pending UTF-16 unit; a parent window close must complete every remaining session with cleanup_verified and active_processes_after=0 under the OwnedJob supervisor. The session matrix, PID/nonce/file identities, per-step expected/actual hex and timing, bounded background sequences, close timings and parent-close states are recorded for review. This is an offscreen synthetic-event check: presented_done is null, and no physical keyboard, native IME, system clipboard or ninth-session policy is claimed. SessionTabs currently has no session cap; the approved eight session basis is verified, not extended. #30/#155 stay open. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- .github/workflows/qt-cpp.yml | 7 +- CMakeLists.txt | 20 + docs/EVIDENCE.md | 1 + docs/FEATURES.md | 2 +- experiments/shell/eight_session_client.cpp | 229 ++++++ .../README.md | 70 ++ src/qt/README.md | 10 +- src/qt/eight_session_test.cpp | 696 ++++++++++++++++++ 8 files changed, 1028 insertions(+), 7 deletions(-) create mode 100644 experiments/shell/eight_session_client.cpp create mode 100644 experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md create mode 100644 src/qt/eight_session_test.cpp diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 1ba19d1..0ad6c6e 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_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 --parallel 2 - name: Record complete path-free runtime manifests run: | python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-bin/Release --executable termforge_qt_shell.exe @@ -55,6 +55,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-unicode-render-bin/Release --executable qt_unicode_render_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-eight-session-bin/Release --executable qt_eight_session_test.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-modes-bin/Release --executable qt_terminal_modes_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-hidden-retention-bin/Release --executable qt_hidden_retention_test.exe @@ -82,13 +84,14 @@ jobs: build/qt-cpp/qt-resize-bin/Release/runtime-manifest.json build/qt-cpp/qt-unicode-bin/Release/runtime-manifest.json build/qt-cpp/qt-unicode-render-bin/Release/runtime-manifest.json + build/qt-cpp/qt-eight-session-bin/Release/runtime-manifest.json build/qt-cpp/qt-modes-bin/Release/runtime-manifest.json build/qt-cpp/qt-hidden-retention-bin/Release/runtime-manifest.json 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 - 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_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)$' --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 b20848f..cd178ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -308,6 +308,9 @@ if(WIN32) add_executable(shell_unicode_echo_client experiments/shell/unicode_echo_client.cpp) termforge_warnings(shell_unicode_echo_client) target_compile_definitions(shell_unicode_echo_client PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN UNICODE _UNICODE) + add_executable(shell_eight_session_client experiments/shell/eight_session_client.cpp) + termforge_warnings(shell_eight_session_client) + target_compile_definitions(shell_eight_session_client PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN UNICODE _UNICODE) add_executable(shell_text_client experiments/shell/text_client.cpp) termforge_warnings(shell_text_client) target_compile_definitions(shell_text_client PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN UNICODE _UNICODE) @@ -744,6 +747,23 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_unicode_render PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") + add_executable(qt_eight_session_test src/qt/eight_session_test.cpp) + set_target_properties(qt_eight_session_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-eight-session-bin/$") + termforge_qt_entry(qt_eight_session_test) + target_link_libraries(qt_eight_session_test PRIVATE Qt6::Test) + add_dependencies(qt_eight_session_test shell_eight_session_client) + add_custom_command(TARGET qt_eight_session_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" "$/" + VERBATIM) + add_test(NAME qt_eight_session COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_eight_session PROPERTIES TIMEOUT 60 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") add_executable(qt_cpp_resize_smoke src/qt/resize_smoke.cpp) set_target_properties(qt_cpp_resize_smoke PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-resize-bin/$") diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 0b3b86e..fd08814 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -27,6 +27,7 @@ | Qt 有界系统复制入口(#29/#30 切片) | [实现、fake 入口回归与 hosted 阻塞](../experiments/shell/results/2026-11-06-qt-system-copy/README.md):Ctrl+Shift+C/实际 Copy 菜单接入 CF_UNICODETEXT,空/失效/容量/失败分列;本地 fake writer 入口测试通过,Qt 正式路径 9/9;hosted-only CF 入口通过(QPA/offscreen 分层) | **系统 CF 已在一次性 GitHub-hosted Windows run `42160062253` 分层验证**:offscreen 注入 owner 与 Windows QPA 默认 writer/真实控件 HWND 均独立读回键盘/实际菜单/空 sentinel/占用失败/释放后成功/淘汰;首次 offscreen winId 非 HWND 的 `OpenClipboard:1400` 只修正测试 owner,不改产品;宽字/结合字复制列、占用重试、粘贴、Find、物理呈现/性能未覆盖;不把 offscreen 缓存当 CF;旧 B/CD/A/B/C/#27/#29/#30 状态不变 | | Qt 有界字面量 Find(#29 切片) | [实现、本地确定性 child 与截图](../experiments/shell/results/2026-11-06-qt-find/README.md):Ctrl+Shift+F/查询/Previous/Next/Close,复用 TextSearch 的 4096 字节/1024 结果/字面量/ASCII case folding;源位置经 ViewMapping 在正常主屏与历史页高亮导航;本地 Qt 正式路径 10/10(qt_find 1/1) | 固定 ASCII 样例、离屏 Qt;中文/NFD 只声明字面标量匹配,不宣称字素/glyph 等价;不含选词/选行、滚动条/翻页键历史、TUI/备用屏 Find、物理呈现/DPI/性能阈值;不代表 #29/#30/R2 完成 | | 正式 Qt / 固定 ConPTY 固定 Unicode 样本贯通呈现与独立接收(#30 切片) | [链路、样本与实测](../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md):新增 `qt_unicode_render`,5 条固定样本经公开 Qt 事件→真实 child `ReadConsoleW` 精确接收→同字节 CR→CRLF 回显→核心解析→`TerminalWidget` 离屏抓图;receiver/echo SHA-256 与源字节逐字节一致,CJK 实测 fallback `Alibaba PuHuiTi`、emoji `Segoe UI Emoji`(advance 21>span 18 但无裁剪参考墨迹 3..17 未越格),结合标 `marks=1`,第二条空会话 0 字节且无样本文字;Qt 正式路径 6/6 | U+200D/U+FE0F 仍被 recovery parser 替换为 U+FFFD(只记录,不批准新聚类/宽度策略);offscreen + 宿主 fallback 依赖、`presented_done=null`,不覆盖物理呈现/原生 IME/真实 DPI/完整字素或 #30/#155 验收 | +| 正式 Qt / 固定 ConPTY 八会话文本输入目标隔离与生命周期(#30 切片) | [入口、矩阵、时序与实测](../experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md):新增 `qt_eight_session`,通过公开 `+`/标签切换/关闭入口创建 8 个真实固定 ConPTY child(+1 替代页),每会话独立 PID/nonce/接收文件;硬编码字面预期核对 ASCII/CJK/补充平面/结合标与无修饰 Home 只进入活动目标,非创建顺序 `[4,0,7,2,5,1,6,3]` 切换,8×120 行受控后台输出全部排空(queued 0),关闭非活动/活动标签后存活目标继续精确接收,替代页首输入无待发单元泄漏,父窗关闭 6 会话 `cleanup_verified/active_processes_after=0`、status 0;Qt 正式路径 CTest 12/12,核心 42/42 | 仅 offscreen 合成 Qt 事件、固定 8 会话/80×24/1 替代页,`presented_done=null`;`SessionTabs` 当前无会话上限,未定义/未测试第 9 个会话行为;不覆盖真实键盘、原生 IME、系统剪贴板、真实 DPI、完整 Copy→Paste→IME、八会话以上或性能;#30/#155/#7 不关闭 | | 隐藏主屏纯高度变化的正文保留(#27 工程合同) | [RED/GREEN、命令与边界](../experiments/core/results/2026-11-04-hidden-height-retention/README.md):修复前 `resize_hidden_height_test` 证明隐藏 live/历史被按未变宽度裁剪并丢失保留尾;`resize_preserving` 改为只在宽度实际变化时适配隐藏帧后,三场景通过并注册 `retained_resize_hidden_height_core`;组合回放 20/20、事务检查不变,正式 Qt CTest 5/5 | 工程不变量;原版对照见下一行。2026-11-04 用户确认保留完整正文、接受原版返回裁剪差异,工程身份不变;固定七阶段正式 Qt 路径已离屏核对(见下方正式路径行),物理呈现/DPI 未覆盖。未覆盖备用屏后续输出、保存光标交叉、Unicode、历史淘汰、Qt 物理呈现/DPI 或性能;#27/#29 不关闭 | | 正式 Qt / 固定 ConPTY 隐藏帧七阶段完整正文(#27/#29 正式路径切片) | [场景、阶段与显示/命中](../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md):真实 child 写入 91×20 固定 75 B 正文→公开 Qt 缩窄 61→`1049h`→仅高度 20→14→20→`1049l`→恢复宽度 91;58 检查 0 失败,2 次运行除 `child_pid` 外一致;阶段 1/2/6/7 逻辑正文 SHA `0e683ff0…` 相同,保留行 id `12`、91-Cell 源行与历史 5 行前缀保持,显示页/光标/命中均映射同一源身份;Qt 正式路径 7/7(8.99 s)、核心 41/41(23.05 s) | 仅 offscreen 固定样本;非物理呈现(`presented_done=null`)、DPI、IME 或性能验收;child 原生行单独记录,不作为保留文本 oracle;原严格失败、补充审计与 `semantic_acceptance=false` 不改写;#27/#29/#30/#7 不关闭 | | 原版隐藏主屏高度的录制恢复与离线补充审计(#27/#29) | [补充审计、阶段观察与差异](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md):七阶段原版录制的严格审计失败保留;从仍可用的取证环境只读恢复完整输入后,离线补充审计通过(计划 `938992c4…`、参考 EXE `7b1053…`/4047 清单 `e459adf2…`、执行源清单 `51e80b93…`,0 次重跑),补充件 `4c0f28f2…` | 审计显示返回主屏后宽行按当前宽度裁剪为 61 空格 + `w`(62 字符)、DSR `19;7→14;7`,与 #311 的保留合同存在差异;2026-11-04 用户已确认保留完整正文、接受该原版观察为产品有意差异([决定评论](https://github.com/JavaZeroo/termforge/issues/29#issuecomment-6627911863));本记录不修改实现、不重跑、不关闭 #27/#29;语义验收 false,备用屏阶段 API 文本不等于活动备用屏网格 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 589dbd1..d40c5f7 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -23,7 +23,7 @@ | 新路线迁移入口 | 当前状态与边界 | 目标差距 | 路标 | |---|---|---|---| -| 自主 C++ / Qt / 固定微软 ConPTY | **实现中,未验收**:[显式 Qt 入口](../src/qt/README.md)提供默认 80×24、随可用区域变化的 cmd 标签、基本 ASCII 与[限定 Unicode 文本字节](../experiments/shell/results/2026-10-29-qt-unicode-input/README.md)、16 色及各会话独立有界队列、[输入法组合/提交/取消(事件级 + 固定拼音验证)](../research/ui/results/2026-10-30-qt-ime-fixed-pinyin/README.md),并接入[无修饰导航键与基础鼠标正反拖选高亮](../experiments/shell/results/2026-11-01-r2-core-input/README.md);[基础动态尺寸验证](../experiments/shell/results/2026-10-29-qt-resize/README.md)分离窗口/核心/transport 与真实 child,覆盖极小、重复、隐藏页和关闭交叉,并复验原标签算术及事件循环回收。678 原生极窄失败保留;0ff 仅修订字面子区域/顶层实际区域夹具,另有[冻结包一次隔离原生成功](../experiments/shell/results/2026-10-29-qt-resize/native-validation.json):实际顶层区域、core/transport/child 一致,窗口仅 DPI 96。旧尺寸结果保留各自源码;[Unicode 联合源码的一次完整61项](../experiments/shell/results/2026-10-29-qt-unicode-input/validation.json)分开 Qt3项与其余58项既有/独立诊断,13条内部skip保留。正式尺寸事务已改走显式保留入口并绘制独立显示投影/显示光标:[只缩宽的保留行契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md)在真实 child/固定 ConPTY 核对 12/12 检查,Qt 正式路径 CTest 5/5;旧六阶段 native-sync 失败保留并归类为原生重排网格与保留行合同的坐标差。Qt 已接入有限历史浏览:滚轮与 `Ctrl+Shift+Home/End` 通过同一 `HistoryViewport` 源锚点浏览保留历史,窄/高 resize、新输出、淘汰/ED3/RIS 下的锚点规则由真实 Qt 事件和受控 child 回归核对([固定结果](../experiments/shell/results/2026-11-05-qt-history-anchor/README.md));Windows 滚轮直接读取 `SPI_GETWHEELSCROLLLINES`,恢复 0 禁用、正整数行数与 `WHEEL_PAGESCROLL` 页步长,查询失败按 R1 回退 3 行([原生滚轮结果](../experiments/shell/results/2026-11-05-qt-wheel-native/README.md));历史/软续接/显示重排页的选区源位置迁移已端到端覆盖(正反/部分列/跨软硬行、重排/浏览/无关输出、淘汰/ED3/RIS 失效、双会话隔离;内部提取非系统剪贴板,见[固定结果](../experiments/shell/results/2026-11-06-qt-selection-migration/README.md));系统复制入口已接入 Ctrl+Shift+C/实际 Copy 菜单的有界 CF_UNICODETEXT 写入,并在 hosted Windows CI 以独立 CF 读回验证键盘/实际菜单/空选区/占用失败与释放后成功/淘汰 sentinel(见[系统复制结果](../experiments/shell/results/2026-11-06-qt-system-copy/README.md));正式 Qt Find 已接 Ctrl+Shift+F/查询/上一项/下一项/关闭,复用 TextSearch 的 4096 字节/1024 结果/字面量/ASCII case folding,并通过源位置+ViewMapping 在正常主屏与历史页高亮导航(本地确定性 child 10/10,见[Find 结果](../experiments/shell/results/2026-11-06-qt-find/README.md);不含选词/选行/物理呈现/性能阈值);历史滚动条已接真实 QScrollBar/源阅读锚点(见[滚动条结果](../experiments/shell/results/2026-11-07-qt-history-scrollbar/README.md)),翻页键历史与粘贴回底未迁移 | 分屏、其他 shell、工作区恢复、同时缩宽+缩高的原版视口/滚动与后续 CUP 证据(已有固定**写后端点**样本:一次动作 91×20→61×14 后执行 CUP 7;2+DQ,观测 scroll 5、DSR 7;4、62 字符行;无 pre-write 原版正文,裁剪归因未知;见[双轴结果](../research/ui/results/2026-11-04-resize-dual-axis-reference/README.md))、完整 reflow、Qt 滚动条/翻页键历史与自动滚动、选词/选行与系统复制/粘贴、Find、完整 Unicode 字素/绘制、候选窗与真实 IME 交叉(已有固定拼音;[固定时间点对照](research/qt-ime-candidate-panel-reference-2026-11-01.md)中输出后两侧都保持旧位置、resize 后延后点都更新;字体阶段原版未触发,面板身份未证明,且对照 Qt 源为历史 5fab30b)、真实 DPI、应用兼容与性能仍需实现或迁移;完整 WindTerm 标签/尺寸行为对照未完成;当前不是可发布开发包 | R2 | +| 自主 C++ / Qt / 固定微软 ConPTY | **实现中,未验收**:[显式 Qt 入口](../src/qt/README.md)提供默认 80×24、随可用区域变化的 cmd 标签、基本 ASCII 与[限定 Unicode 文本字节](../experiments/shell/results/2026-10-29-qt-unicode-input/README.md)、16 色及各会话独立有界队列、[输入法组合/提交/取消(事件级 + 固定拼音验证)](../research/ui/results/2026-10-30-qt-ime-fixed-pinyin/README.md),并接入[无修饰导航键与基础鼠标正反拖选高亮](../experiments/shell/results/2026-11-01-r2-core-input/README.md);[基础动态尺寸验证](../experiments/shell/results/2026-10-29-qt-resize/README.md)分离窗口/核心/transport 与真实 child,覆盖极小、重复、隐藏页和关闭交叉,并复验原标签算术及事件循环回收。678 原生极窄失败保留;0ff 仅修订字面子区域/顶层实际区域夹具,另有[冻结包一次隔离原生成功](../experiments/shell/results/2026-10-29-qt-resize/native-validation.json):实际顶层区域、core/transport/child 一致,窗口仅 DPI 96。旧尺寸结果保留各自源码;[Unicode 联合源码的一次完整61项](../experiments/shell/results/2026-10-29-qt-unicode-input/validation.json)分开 Qt3项与其余58项既有/独立诊断,13条内部skip保留。正式尺寸事务已改走显式保留入口并绘制独立显示投影/显示光标:[只缩宽的保留行契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md)在真实 child/固定 ConPTY 核对 12/12 检查,Qt 正式路径 CTest 5/5;旧六阶段 native-sync 失败保留并归类为原生重排网格与保留行合同的坐标差。Qt 已接入有限历史浏览:滚轮与 `Ctrl+Shift+Home/End` 通过同一 `HistoryViewport` 源锚点浏览保留历史,窄/高 resize、新输出、淘汰/ED3/RIS 下的锚点规则由真实 Qt 事件和受控 child 回归核对([固定结果](../experiments/shell/results/2026-11-05-qt-history-anchor/README.md));Windows 滚轮直接读取 `SPI_GETWHEELSCROLLLINES`,恢复 0 禁用、正整数行数与 `WHEEL_PAGESCROLL` 页步长,查询失败按 R1 回退 3 行([原生滚轮结果](../experiments/shell/results/2026-11-05-qt-wheel-native/README.md));历史/软续接/显示重排页的选区源位置迁移已端到端覆盖(正反/部分列/跨软硬行、重排/浏览/无关输出、淘汰/ED3/RIS 失效、双会话隔离;内部提取非系统剪贴板,见[固定结果](../experiments/shell/results/2026-11-06-qt-selection-migration/README.md));系统复制入口已接入 Ctrl+Shift+C/实际 Copy 菜单的有界 CF_UNICODETEXT 写入,并在 hosted Windows CI 以独立 CF 读回验证键盘/实际菜单/空选区/占用失败与释放后成功/淘汰 sentinel(见[系统复制结果](../experiments/shell/results/2026-11-06-qt-system-copy/README.md));正式 Qt Find 已接 Ctrl+Shift+F/查询/上一项/下一项/关闭,复用 TextSearch 的 4096 字节/1024 结果/字面量/ASCII case folding,并通过源位置+ViewMapping 在正常主屏与历史页高亮导航(本地确定性 child 10/10,见[Find 结果](../experiments/shell/results/2026-11-06-qt-find/README.md);不含选词/选行/物理呈现/性能阈值);历史滚动条已接真实 QScrollBar/源阅读锚点(见[滚动条结果](../experiments/shell/results/2026-11-07-qt-history-scrollbar/README.md)),翻页键历史与粘贴回底未迁移;八会话目标隔离与生命周期已用公开 `+`/标签切换/关闭入口和每会话独立 PID/nonce/接收文件有限验证([固定结果](../experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md)):8 个真实固定 ConPTY child、非创建顺序切换、固定 120 行后台输出排空、关闭非活动/活动标签后存活目标输入、替代页不继承待发单元、父窗关闭回收 | 分屏、其他 shell、工作区恢复、同时缩宽+缩高的原版视口/滚动与后续 CUP 证据(已有固定**写后端点**样本:一次动作 91×20→61×14 后执行 CUP 7;2+DQ,观测 scroll 5、DSR 7;4、62 字符行;无 pre-write 原版正文,裁剪归因未知;见[双轴结果](../research/ui/results/2026-11-04-resize-dual-axis-reference/README.md))、完整 reflow、Qt 滚动条/翻页键历史与自动滚动、选词/选行与系统复制/粘贴、Find、完整 Unicode 字素/绘制、候选窗与真实 IME 交叉(已有固定拼音;[固定时间点对照](research/qt-ime-candidate-panel-reference-2026-11-01.md)中输出后两侧都保持旧位置、resize 后延后点都更新;字体阶段原版未触发,面板身份未证明,且对照 Qt 源为历史 5fab30b)、真实 DPI、应用兼容与性能仍需实现或迁移;完整 WindTerm 标签/尺寸行为对照未完成;八会话仅固定 8 会话/80×24/离屏合成事件/有界后台输出,未定义或测试第 9 个会话行为,真实键盘/原生 IME/系统剪贴板未验收;当前不是可发布开发包 | R2 | 自主 C++ 核心已具备双缓冲与限定 `?1049`、DECAWM、DECOM、DECCKM/DECTCEM、每屏 DECSTBM 边距、字符/行插删、ED3/RIS、DSR 6n 和部分 SGR 状态。依据分别见 [1049](../experiments/core/results/2026-10-29-cpp-1049/README.md)、[DECAWM](../experiments/core/results/2026-10-29-cpp-decawm/README.md)及[支持表](../experiments/core/README.md)。[原版每屏 origin 对照](../research/ui/results/2026-11-02-mode-state-reference/README.md)确认备用屏进入时关闭 origin、主屏返回恢复自身状态及固定样本的 margin 下界;核心已改为[每屏保存 origin](../experiments/core/results/2026-11-02-origin-frame/README.md)。区域 LF、IL/DL 后的精确行序,以及[区域自动换行续接、RIS/ED3 历史结构发布](../experiments/core/results/2026-11-02-r27-mode-cross/README.md)已有修正。完整网格/样式、resize 和运输交叉仍由 #27/#29 验收,不能据此声明完整 TUI 通过。 diff --git a/experiments/shell/eight_session_client.cpp b/experiments/shell/eight_session_client.cpp new file mode 100644 index 0000000..c68d2b2 --- /dev/null +++ b/experiments/shell/eight_session_client.cpp @@ -0,0 +1,229 @@ +// One independent console receiver for the formal Qt eight-session matrix. +// +// Each child owns its own identity (PID plus a process-local nonce), receiver file +// and bounded background output. The test creates it only through the public tab +// entry, reads the READY identity the child prints, and verifies the received UTF-8 +// against literal expectations. Control protocol (not product behavior): +// *BG* starts the single bounded background block; the marker bytes are not +// recorded in the receiver file. +// EOT stops the child naturally after flushing the receiver file. +// Everything else is appended to /.bin as received UTF-8. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr size_t maximum_received = 64 * 1024; +constexpr unsigned background_lines = 120; +constexpr char background_marker[] = "*BG*"; +constexpr size_t background_marker_size = sizeof(background_marker) - 1; + +class Handle { + HANDLE value_ = nullptr; +public: + explicit Handle(HANDLE value) : value_(value) { + if (!value_ || value_ == INVALID_HANDLE_VALUE) + throw std::runtime_error("create/open handle failed: " + std::to_string(GetLastError())); + } + ~Handle() { CloseHandle(value_); } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + HANDLE get() const { return value_; } +}; + +std::string utf8(const std::wstring& units) { + if (units.empty()) return {}; + const int bytes = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), + static_cast(units.size()), nullptr, 0, nullptr, nullptr); + if (bytes <= 0) throw std::runtime_error("console input is not valid UTF-16"); + std::string encoded(static_cast(bytes), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), static_cast(units.size()), + encoded.data(), bytes, nullptr, nullptr) != bytes) + throw std::runtime_error("console input UTF-8 conversion failed"); + return encoded; +} + +std::string hex64(uint64_t value) { + static constexpr char digits[] = "0123456789abcdef"; + std::string text(16, '0'); + for (int index = 15; index >= 0; --index) { + text[static_cast(index)] = digits[value & 0xf]; + value >>= 4; + } + return text; +} + +bool write_bytes(HANDLE handle, const char* data, size_t size) { + size_t offset = 0; + while (offset < size) { + DWORD written = 0; + if (!WriteFile(handle, data + offset, static_cast(size - offset), &written, nullptr) || !written) + return false; + offset += written; + } + return true; +} + +class Receiver { + Handle output_; + Handle file_; + std::string pending_; + std::wstring pending_high_; + unsigned long long saved_ = 0; + std::atomic background_started_{false}; + Handle stop_{CreateEventW(nullptr, FALSE, FALSE, nullptr)}; + std::thread background_; + std::atomic failed_{false}; +public: + Receiver(HANDLE output, const std::wstring& path) : output_(output), file_(CreateFileW( + path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)) {} + ~Receiver() { stop_background(); } + void start_background(const std::string& line_prefix) { + bool expected = false; + if (!background_started_.compare_exchange_strong(expected, true)) return; + background_ = std::thread([this, line_prefix] { + for (unsigned sequence = 1; sequence <= background_lines; ++sequence) { + if (WaitForSingleObject(stop_.get(), 10) != WAIT_TIMEOUT) return; + char line[160]; + const int length = std::snprintf(line, sizeof(line), "%s seq=%03u\r\n", + line_prefix.c_str(), sequence); + if (length <= 0 || static_cast(length) >= sizeof(line) || + !write_bytes(output_.get(), line, static_cast(length))) { + failed_ = true; + return; + } + } + }); + } + void stop_background() noexcept { + if (!background_.joinable()) return; + SetEvent(stop_.get()); + background_.join(); + } + void write_received(const std::string& bytes) { + if (bytes.empty()) return; + if (saved_ + bytes.size() > maximum_received) { + failed_ = true; + throw std::runtime_error("received text exceeds the fixed bound"); + } + if (!write_bytes(file_.get(), bytes.data(), bytes.size()) || !FlushFileBuffers(file_.get())) + throw std::runtime_error("receiver write failed"); + saved_ += bytes.size(); + } + // Extract the control marker from the byte stream; hold a possible marker prefix + // at the tail until the next chunk so a split write cannot record it as text. + void feed(std::string bytes) { + pending_ += bytes; + for (;;) { + const auto position = pending_.find(background_marker); + if (position == std::string::npos) break; + pending_.erase(position, background_marker_size); + start_background(background_prefix_); + } + size_t keep = 0; + const size_t limit = (std::min)(pending_.size(), background_marker_size - 1); + for (size_t length = limit; length > 0; --length) { + if (pending_.compare(pending_.size() - length, length, background_marker, 0, length) == 0) { + keep = length; + break; + } + } + const std::string deliver = pending_.substr(0, pending_.size() - keep); + pending_.erase(0, pending_.size() - keep); + write_received(deliver); + } + void finish() { + stop_background(); + write_received(pending_); + pending_.clear(); + if (failed_) throw std::runtime_error("background output failed"); + FlushFileBuffers(file_.get()); + } + void set_prefix(const std::string& prefix) { background_prefix_ = prefix; } + bool background_started() const { return background_started_.load(); } + bool failed() const { return failed_; } +private: + std::string background_prefix_; +}; + +int run(const std::wstring& root) { + BOOL owned = FALSE; + if (!IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) + throw std::runtime_error("eight-session receiver requires its owned Session Job"); + const DWORD pid = GetCurrentProcessId(); + LARGE_INTEGER counter{}; + if (!QueryPerformanceCounter(&counter)) throw std::runtime_error("QueryPerformanceCounter failed"); + std::string nonce = hex64((static_cast(counter.QuadPart) << 16) ^ + (static_cast(pid) << 32) ^ GetTickCount64()); + nonce = nonce.substr(nonce.size() - 16); + Handle input(CreateFileW(L"CONIN$", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, OPEN_EXISTING, 0, nullptr)); + Handle output(CreateFileW(L"CONOUT$", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, + OPEN_EXISTING, 0, nullptr)); + if (!SetConsoleCP(CP_UTF8) || !SetConsoleOutputCP(CP_UTF8) || + !SetConsoleMode(input.get(), ENABLE_VIRTUAL_TERMINAL_INPUT) || + !SetConsoleMode(output.get(), ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | + ENABLE_VIRTUAL_TERMINAL_PROCESSING | + DISABLE_NEWLINE_AUTO_RETURN)) + throw std::runtime_error("console mode/encoding setup failed"); + const std::wstring path = root + L"\\" + std::wstring(nonce.begin(), nonce.end()) + L".bin"; + Receiver receiver(output.get(), path); + const std::string prefix = "TF8_BG pid=" + std::to_string(pid) + " nonce=" + nonce; + receiver.set_prefix(prefix); + { + const std::string ready = "TF8_READY pid=" + std::to_string(pid) + " nonce=" + nonce + "\r\n"; + if (!write_bytes(output.get(), ready.data(), ready.size())) + throw std::runtime_error("ready marker write failed"); + } + std::wstring pending_high; + for (;;) { + wchar_t units[256]; + DWORD count = 0; + if (!ReadConsoleW(input.get(), units, static_cast(std::size(units)), &count, nullptr) || count == 0) + throw std::runtime_error("console input read failed"); + std::wstring chunk; + bool finished = false; + for (DWORD index = 0; index < count; ++index) { + if (units[index] == 4) { + if (index + 1 != count) throw std::runtime_error("input after EOT"); + finished = true; + break; + } + chunk += units[index]; + } + if (!pending_high.empty()) chunk.insert(chunk.begin(), pending_high.front()); + pending_high.clear(); + if (!chunk.empty() && chunk.back() >= 0xd800 && chunk.back() <= 0xdbff) { + pending_high.push_back(chunk.back()); + chunk.pop_back(); + } + receiver.feed(utf8(chunk)); + if (receiver.failed()) throw std::runtime_error("bounded background output failed"); + if (finished) { + if (!pending_high.empty()) receiver.feed(utf8(std::wstring(1, 0xfffd))); + receiver.finish(); + const std::string done = "TF8_DONE pid=" + std::to_string(pid) + " nonce=" + nonce + "\r\n"; + if (!write_bytes(output.get(), done.data(), done.size())) + throw std::runtime_error("done marker write failed"); + return 0; + } + } +} +} // namespace + +int wmain(int argc, wchar_t** argv) { + if (argc != 2) return 2; + try { + return run(argv[1]); + } catch (const std::exception& error) { + std::fprintf(stderr, "TermForge eight-session receiver: %s\n", error.what()); + return 11; + } +} diff --git a/experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md b/experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md new file mode 100644 index 0000000..7f1da66 --- /dev/null +++ b/experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md @@ -0,0 +1,70 @@ +# 正式 Qt / 固定 ConPTY:八会话文本输入目标隔离与生命周期(#30 切片) + +本切片在**正式 C++ / Qt Widgets / 随包固定 Microsoft ConPTY** 构建中,用现有公开 `+` / 标签切换 / 关闭入口创建 **8 个真实 child**,逐个会话用独立 PID、nonce 与接收文件核对:文本与既有无修饰导航键只进入活动目标;可控有界后台输出期间后台 child 继续排空且不夺取输入目标;按非创建顺序切换仍保持目标隔离;关闭非活动/活动标签后存活目标继续接收;替代页不继承已关闭会话的待发 UTF-16 单元;父窗口关闭后所有 child 完成回收。 + +不修改产品行为,不新增会话上限、Ctrl 字母、应用模式或字素宽度策略;不重复 #313 的 Unicode 呈现/glyph 实验。 + +## 入口与协议 + +- 创建:`SessionTabs` 构造创建首个会话,其余 7 个通过公开 `QToolButton`(`newCmdSession`)点击创建;切换/关闭使用公开 `QTabBar` 点击与关闭按钮。没有直接拼装 8 个 model 参与验证。 +- 每个 child(`experiments/shell/eight_session_client.cpp`)打开 `CONIN$`/`CONOUT$`,设置 UTF-8 与 VT 输入/输出模式,生成进程内 **nonce**,在 `TF8_READY pid= nonce=<16hex>` 中打印身份,并创建 `/.bin` 接收文件;测试同时用 `model.resize_state().child_pid` 绑定 PID。 +- 接收:child 按批把 `ReadConsoleW` 得到的完整标量转成 UTF-8 追加写接收文件并 flush;测试只比较**硬编码字面预期**(ASCII/CJK/补充平面/结合标 + Home `1b5b48`),不从产品输出生成 expected。 +- 受控后台:测试向活动目标发送字面标记 `*BG*`(夹具控制协议,不写入接收文件),child 启动一次**固定 120 行**、每行约 10 ms 的有界后台输出 `TF8_BG pid=... nonce=... seq=NNN`。 +- 不提权、不访问个人剪贴板、不使用 OS IME;全部为合成 Qt 事件 + offscreen 平台。 + +## 实测结果(一次完整运行;连跑两次均通过) + +环境:Windows 11 x64 build 26200,MSVC 19.42.34433.0,Qt 6.10.3 offscreen,随包固定 ConPTY 1.25.260930003,app-local CRT 14.51.36247.0;`cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24`。 + +创建矩阵(每项独立 PID/nonce/接收文件): + +| creation | PID | nonce | 接收文件 | 结果 | +|---|---|---|---|---| +| 0 | 33032 | `08e96ee51af4e742` | `08e96ee51af4e742.bin` | 输入后关闭(pending 目标) | +| 1 | 13452 | `08e9db79916fe771` | `08e9db79916fe771.bin` | 存活至父窗关闭 | +| 2 | 41320 | `08e94e974a48e7b0` | `08e94e974a48e7b0.bin` | 非活动标签关闭 | +| 3 | 8140 | `08e9efc45289e7ee` | `08e9efc45289e7ee.bin` | 活动标签关闭 | +| 4 | 25012 | `08e991ac900ae85c` | `08e991ac900ae85c.bin` | 存活 | +| 5 | 28748 | `08e98065e628e8c9` | `08e98065e628e8c9.bin` | 存活 | +| 6 | 19408 | `08e9bb946b76e975` | `08e9bb946b76e975.bin` | 存活 | +| 7 | 3212 | `08e9fcdd3e73e9d3` | `08e9fcdd3e73e9d3.bin` | 存活 | +| 8(替代页) | 23648 | `08e9ad8826e5f433` | `08e9ad8826e5f433.bin` | 首个输入仅 `NEW`+Home | + +逐会话隔离与字面预期(节选,均为实际 receiver hex=预期 hex): + +- 创建顺序切换 8 次(t0=705 ms … t7=1169 ms 内完成):每次只向当前活动目标发送 `*BG*`+样本+Home;ASCII `TF8-A0 ASCII`、CJK `汉字`、补充平面 `🙂`、结合标 `e`+U+0301、`é`、`终端`、`😀`、`plain` 的接收字节与字面预期逐字节一致。 +- 非创建顺序切换 `[4, 0, 7, 2, 5, 1, 6, 3]`:每步追加 `B`+Home,8 个 receiver 均精确匹配,且每次仅目标文件变化(`isolation_checked=true`)。 +- 后台有界输出:8 个 child 全部到达 `seq=120`、`queued_bytes=0`(high-water 96–98 B),即后台输出被排空;期间输入只进入活动目标。 +- 非活动关闭(creation 2, PID 41320):63 ms 内标签 8→7、页面释放;存活目标(creation 3)继续收到 `+CLOSE`+Home 且字节精确。 +- 活动关闭(creation 3, PID 8140):55 ms 内标签 7→6、页面释放;新活动目标(creation 4)继续收到 `+ACTIVE`+Home 且字节精确。 +- 待发文字隔离:对 creation 0 发送孤立高代理项(未产生字节,文件不变),关闭该标签(39 ms,6→5)后点击 `+` 创建替代页;替代页首个输入接收为 `4e45571b5b48`(`NEW`+Home),**无 U+FFFD 前缀或旧待发单元泄漏**,旧文件保持不变。 +- 父窗口关闭:6 个存活会话全部 `finished=true`、`cleanup_verified=true`、`active_processes_after=0`、`output_eof=true`;`app.exec()` 经 `aboutToQuit`/`on_all_exited` 完成,status=0、completions=1,关闭时刻与完成时刻两处清理检查均为 true。外层 OwnedJob 由 supervisor 断言 `active=0`。 +- 总运行约 3.38 s(CTest `qt_eight_session` 3.22–3.28 s,连跑两次)。 + +字体/呈现与 glyph 不在本切片范围;`presented_done=null`,测试不抓图、不比较像素。 + +## 现有 8 会话基础与限制位置 + +`SessionTabs`(`src/qt/session_tabs.hpp`)当前**没有新增会话计数上限或资源阈值**,公开 `+` 直接调用 `new_session`;因此本切片只验证“现有契约允许的 8 个并发会话”,不把第 9 个拒绝行为、最大会话数或资源政策当作已批准用户决定,也未测试第 9 个。Win32 R1 的 8 会话/拒绝第 9 个是另一配置的历史合同,不转记为 Qt 结论。若后续要定义上限,属新行为,需要单独确认。 + +## 复验 + +```powershell +python -m tools.prepare_qt_build +cmake -S . -B build/qt-cpp -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/qt-cpp --config Release --target qt_eight_session_test --parallel 2 +ctest --test-dir build/qt-cpp -C Release -R '^qt_eight_session$' --no-tests=error --output-on-failure +``` + +同源码回归:Qt 正式路径 CTest 12/12(含新测试,去除需人工剪贴板的 hosted clipboard 两项);默认/核心 CTest 与 Python Unicode 回归见 [validation.json](validation.json)。完整源码/二进制/运行文件身份见 `validation.json`。 + +## 边界 + +- 仅 offscreen + 合成 Qt 事件,不是真实物理键盘、原生 IME、真实系统剪贴板或物理呈现;不宣称完整 Copy→Paste→IME、R2、#30 或 #155 通过。 +- 只覆盖固定 8 会话 + 1 替代页、单窗口、单显示器、`80×24`,不含分屏、八会话以上、长期运行、其他 shell、真实 DPI 或性能;后台输出为固定 120 行合成负载,不是吞吐指标。 +- 接收 oracle 是每个 child 自己写入的 UTF-8 文件 + READY 身份;测试不使用内部 model 文本自洽替代独立 receiver。`*BG*` 是夹具控制协议,不是产品文本/按键行为。 diff --git a/src/qt/README.md b/src/qt/README.md index ab2597c..3b80a65 100644 --- a/src/qt/README.md +++ b/src/qt/README.md @@ -25,12 +25,12 @@ cmake -S . -B build/qt-cpp -G "Visual Studio 17 2022" -A x64 ` -DQt6_DIR="/lib/cmake/Qt6" ` -DTERMFORGE_CONPTY_BACKEND_DIR="" ` -DTERMFORGE_MSVC_RUNTIME_DIR="" -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_terminal_modes_test qt_hidden_retention_test +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 build/qt-cpp/qt-bin/Release/termforge_qt_shell.exe -ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?|qt_hidden_retention)$' --output-on-failure +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)$' --output-on-failure ``` -当前主线注册的 Qt CTest 包括上述五项与 `qt_terminal_modes_retained`(只缩宽的保留/投影契约)、`qt_unicode_render`(固定 Unicode 样本的独立接收 + 离屏绘制/fallback 记录);IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。旧六阶段 `--resize` 仍以显式诊断运行并保留失败,不注册进默认 CI。 +当前主线注册的 Qt CTest 包括上述六项与 `qt_terminal_modes_retained`(只缩宽的保留/投影契约)、`qt_unicode_render`(固定 Unicode 样本的独立接收 + 离屏绘制/fallback 记录)、`qt_eight_session`(八会话目标隔离与生命周期);IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。旧六阶段 `--resize` 仍以显式诊断运行并保留失败,不注册进默认 CI。 固定 ConPTY/CRT 输入缺失或篡改时构建明确失败;实际工具集更新到固定 CRT 之外时须重新审查依赖。产品中的 shell 启动失败保留可关闭的错误窗口。随目录保留 Qt 动态运行库、平台插件、`conpty-backend/`、`assets/fonts/`、十个固定 CRT DLL 及其原许可/NOTICE;每次构建重新校验并部署 CRT,不运行安装器、不修改 System32。最终目录用 `tools/msvc_runtime.py manifest` 记录全部文件。当前只是本地构建输出,Qt/第三方的完整分发审计及产品打包尚未完成。 @@ -52,11 +52,13 @@ Parser/Screen 仅在 Qt 线程修改;ConPTY reader 向有界输出队列写入 `qt_unicode_render` 补上同一正式路径此前未覆盖的“解析后实际绘制”合同:固定 ASCII/CJK/补充平面/结合标样本经公开 Qt 事件进入真实 child,独立 `ReadConsoleW` 接收端与 child 回显文件分别核对接收字节和呈现源;核心解析为既有 `Cell`/width/marks 后由 `TerminalWidget` 离屏抓图,并逐 cell 记录实际 glyph faces、missing glyph、advance 与无裁剪参考墨迹,同时用第二条空会话观察目标隔离。实测 CJK 使用宿主 fallback 字体、emoji 的 advance 超出两格但参考墨迹未越格;U+200D/U+FE0F 被 recovery parser 替换为 U+FFFD(既有缺口,只记录)。该检查只证明离屏局部链,`presented_done=null`,不是物理呈现、原生 IME 或字素/宽度策略的批准,详见[固定结果](../../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md)。 +`qt_eight_session` 用公开 `+`/标签切换/关闭入口创建 8 个真实固定 ConPTY child,每个 child 通过 `TF8_READY pid=… nonce=…` 打印身份并写独立 `.bin` 接收文件;测试以硬编码字面预期核对 ASCII/CJK/补充平面/结合标文本与无修饰 Home 只到达活动目标,按非创建顺序切换,用固定 120 行受控后台输出核对后台排空/不夺输入,关闭非活动与活动标签后核对存活目标,替代页不继承已关闭会话的待发单元,父窗口关闭经 `aboutToQuit`/`on_all_exited` 与模型 `cleanup_verified/active_processes_after` 核对 6 个存活会话回收(外层 Job active=0)。只覆盖固定 8 会话/80×24/离屏合成事件;未定义或测试第 9 个会话行为,不是真实键盘、原生 IME、系统剪贴板或完整 R2 验收,详见[固定结果](../../experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md)。 + IME 实现通过 `QInputMethodEvent` 绘制未提交的 preedit,仅把 commit 的 UTF-8 交给会话;空 preedit 取消预览,失焦/隐藏清理预览,光标格通过 `ImCursorRectangle` 查询。预览按字体度量计算宽度,失败提交保留错误反馈。`qt_cpp_shell` 的合成事件检查覆盖 preedit 不进入终端正文、commit 到达、cancel 不写入。独立的[固定微软拼音记录](../../research/ui/results/2026-10-30-qt-ime-fixed-pinyin/README.md)已有隔离 Windows VM 中 `ni'hao` 组合、取消及「你好」提交的限定证据;完整会话交叉、候选窗身份、其他 DPI 和实际呈现仍由 [#155](https://github.com/JavaZeroo/termforge/issues/155) / [#30](https://github.com/JavaZeroo/termforge/issues/30) 接续。 [N11 候选框几何对照](../../docs/research/qt-ime-candidate-panel-reference-2026-11-01.md)在冻结源 `5fab30b` 与官方 WindTerm 2.7.0 上各采集三次:输出后两侧面板均保持旧位置,resize 后均在固定延后采集点前更新;原版字体放大未触发,字体阶段仍不可比。这份历史冻结包证据不等于当前主线已复验;亮度带几何不证明候选窗身份,原版三次收尾关闭均失败,也不能把几何片段记成整次原生验收通过。 -上述三项自动测试使用 Qt offscreen 平台和本项目独立取得的 OFL Roboto Mono 字体;没有物理键盘、实际不同 DPI、Windows 原生窗口上屏或用户呈现时间验收,`presented_done=null`。隔离 VM 原生观察保留自己的源码与环境身份。两会话隔离不代表八会话、完整 IME 交叉或工作区验收,测试耗时不是终端性能指标。 +上述自动测试使用 Qt offscreen 平台和本项目独立取得的 OFL Roboto Mono 字体;没有物理键盘、实际不同 DPI、Windows 原生窗口上屏或用户呈现时间验收,`presented_done=null`。隔离 VM 原生观察保留自己的源码与环境身份。八会话目标隔离已有本切片的限定证据(合成 Qt 事件/离屏/固定 120 行后台输出),但仍不代表真实物理键盘、原生 IME、系统剪贴板或完整 Copy→Paste→IME 与工作区验收;测试耗时不是终端性能指标。 构建开关默认关闭只是从旧原型向新入口迁移的状态;正式路线已确认,旧构建保留为回归对照。Qt 文字与图形资源均独立取得,不使用 WindTerm 的专有字体、图标或主题。 diff --git a/src/qt/eight_session_test.cpp b/src/qt/eight_session_test.cpp new file mode 100644 index 0000000..5f124e6 --- /dev/null +++ b/src/qt/eight_session_test.cpp @@ -0,0 +1,696 @@ +// Formal Qt / fixed ConPTY eight-session input-target isolation and lifecycle. +// +// Eight real children are created only through the public "+"/tab/close entries of +// SessionTabs. Each child owns a PID and process-local nonce, writes its receive +// file incrementally and prints a READY identity; the test then checks literal +// text and Home bytes reach only the active target, bounded background output keeps +// draining while another target receives input, closing non-active/active tabs +// preserves surviving targets, a replacement page inherits no pending text, and a +// parent-window close completes every owned Job. Offscreen synthetic Qt events: +// presented_done stays null and no physical keyboard, native IME or clipboard is used. +#include "session_tabs.hpp" +#include "terminal_widget.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using termforge::qt::SessionModel; +using termforge::qt::SessionPage; +using termforge::qt::SessionTabs; + +namespace { +constexpr int session_count = 8; +constexpr int background_total = 120; + +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +template +void await(Predicate predicate, const std::string& message, int timeout_ms = 5000) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < timeout_ms) { + QApplication::processEvents(QEventLoop::AllEvents, 10); + QTest::qWait(2); + } + require(predicate(), message); +} +std::string row_text(const termforge::Screen& screen, size_t row) { + std::string line; + for (const auto& cell : screen.visible_lines().at(row)) + if (cell.width) line += cell.text; + while (!line.empty() && line.back() == ' ') line.pop_back(); + return line; +} +std::optional find_row(const termforge::Screen& screen, std::string_view text) { + for (size_t row = 0; row < screen.visible_lines().size(); ++row) + if (row_text(screen, row).find(text) != std::string::npos) return row; + return std::nullopt; +} +bool has_text(const termforge::Screen& screen, std::string_view text) { + return find_row(screen, text).has_value(); +} +std::string hex(std::string_view bytes) { + static constexpr char digits[] = "0123456789abcdef"; + std::string encoded; + encoded.reserve(bytes.size() * 2); + for (const char byte : bytes) { + encoded += digits[static_cast(byte) >> 4]; + encoded += digits[static_cast(byte) & 0x0f]; + } + return encoded; +} +std::string sha256_hex(std::string_view bytes) { + return QCryptographicHash::hash(QByteArray(bytes.data(), static_cast(bytes.size())), + QCryptographicHash::Sha256).toHex().toStdString(); +} +bool text_event(QWidget& target, const QString& text) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_unknown, Qt::NoModifier, text); + event.setAccepted(false); + QApplication::sendEvent(&target, &event); + return event.isAccepted(); +} +bool file_exists(const QString& path) { return QFileInfo::exists(path); } +std::string read_file(const QString& path) { + QFile file(path); + require(file.open(QIODevice::ReadOnly), "receiver file is unreadable: " + path.toStdString()); + const QByteArray bytes = file.readAll(); + return {bytes.constData(), static_cast(bytes.size())}; +} +struct Identity { + unsigned long pid = 0; + std::string nonce; + std::string ready_text; +}; +// The child's READY line is the only product-visible identity source; the test then +// binds it to the model's child PID and to the receiver file name. +Identity parse_identity(const termforge::Screen& screen) { + const auto row = find_row(screen, "TF8_READY pid="); + require(row.has_value(), "child READY identity is missing"); + const std::string text = row_text(screen, *row); + const std::string prefix = "TF8_READY pid="; + require(text.rfind(prefix, 0) == 0, "child READY identity has an unexpected shape"); + size_t cursor = prefix.size(); + unsigned long pid = 0; + require(cursor < text.size() && text[cursor] >= '0' && text[cursor] <= '9', "child READY PID is missing"); + while (cursor < text.size() && text[cursor] >= '0' && text[cursor] <= '9') { + pid = pid * 10 + static_cast(text[cursor] - '0'); + ++cursor; + } + const std::string nonce_marker = " nonce="; + require(text.compare(cursor, nonce_marker.size(), nonce_marker) == 0, "child READY nonce is missing"); + cursor += nonce_marker.size(); + std::string nonce; + for (; cursor < text.size(); ++cursor) { + const char ch = text[cursor]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) break; + nonce += ch; + } + require(pid != 0 && nonce.size() == 16, "child READY identity is malformed"); + return {pid, nonce, text}; +} +unsigned background_sequence(const termforge::Screen& screen) { + unsigned last = 0; + for (size_t row = 0; row < screen.visible_lines().size(); ++row) { + const std::string text = row_text(screen, row); + if (text.rfind("TF8_BG pid=", 0) != 0) continue; + const auto position = text.find(" seq="); + if (position == std::string::npos) continue; + unsigned value = 0; + for (size_t index = position + 5; index < text.size() && text[index] >= '0' && text[index] <= '9'; ++index) + value = value * 10 + static_cast(text[index] - '0'); + last = (std::max)(last, value); + } + return last; +} +// Literal expectations are hard-coded in the test, never derived from product output. +const char* payload_a(int index) { + static const char* const payloads[] = { + "TF8-A0 ASCII", "TF8-A1 \xe6\xb1\x89\xe5\xad\x97", "TF8-A2 \xf0\x9f\x99\x82", + "TF8-A3 e\xcc\x81", "TF8-A4 \xc3\xa9", "TF8-A5 \xe7\xbb\x88\xe7\xab\xaf", + "TF8-A6 \xf0\x9f\x98\x80", "TF8-A7 plain"}; + return payloads[index]; +} +const char* payload_b(int index) { + static const char* const payloads[] = { + "B0", "B1\xe6\xb1\x89", "B2\xf0\x9f\x99\x82", "B3\xcc\x81", + "B4", "B5", "B6", "B7"}; + return payloads[index]; +} +struct SessionRecord { + int creation = -1; + QPointer page; + unsigned long pid = 0; + std::string nonce; + QString path; + std::string ready_text; + std::string received; + quint64 ready_ms = 0; + bool closed = false; + int tab_index = -1; +}; +struct StepRecord { + std::string phase; + int creation = -1; + int tab = -1; + unsigned long pid = 0; + std::string nonce; + std::string expected_hex; + std::string actual_hex; + std::string actual_sha256; + quint64 started_ms = 0; + quint64 duration_ms = 0; + bool isolation_checked = false; +}; +struct CloseRecord { + std::string phase; + int creation = -1; + unsigned long pid = 0; + std::string nonce; + quint64 elapsed_ms = 0; + int tabs_after = 0; + bool page_released = false; +}; +class Harness { +public: + SessionTabs window_; + QTabBar* bar_ = nullptr; + QTabWidget* pages_view_ = nullptr; + QToolButton* create_ = nullptr; + QElapsedTimer clock_; + std::vector sessions_; + std::vector steps_; + std::vector closures_; + QString root_; + + Harness(const QString& executable, const QString& root, QWidget* parent = nullptr) + : window_(executable.toStdWString(), (QStringLiteral("\"") + root + QLatin1Char('"')).toStdWString(), + root.toStdWString(), parent), + root_(root) { + clock_.start(); + bar_ = window_.findChild(); + pages_view_ = window_.findChild(); + create_ = window_.findChild(QStringLiteral("newCmdSession")); + require(bar_ != nullptr, "tab bar is missing"); + require(pages_view_ != nullptr, "page view is missing"); + require(create_ != nullptr, "public + create action is missing"); + } + quint64 elapsed() const { return static_cast(clock_.elapsed()); } + void show() { + window_.show(); + QApplication::setActiveWindow(&window_); + QApplication::processEvents(); + } + SessionPage* current() const { return window_.current_session(); } + void switch_to(int creation) { + auto& session = sessions_.at(static_cast(creation)); + require(session.page != nullptr, "session page is gone"); + const int tab = pages_view_->indexOf(session.page); + require(tab >= 0, "session has no tab"); + QTest::mouseClick(bar_, Qt::LeftButton, Qt::NoModifier, bar_->tabRect(tab).center()); + QApplication::processEvents(); + require(bar_->currentIndex() == tab, "tab click did not select the target tab"); + require(window_.current_session() == session.page, "tab click did not select the target session"); + require(QApplication::focusWidget() == &session.page->terminal(), "selected session terminal has no focus"); + session.tab_index = tab; + } + Identity await_ready(int creation) { + auto& session = sessions_.at(static_cast(creation)); + require(session.page != nullptr, "cannot await READY for a released page"); + await([&] { return has_text(session.page->model().screen(), "TF8_READY pid="); }, + "session " + std::to_string(creation) + " did not print its READY identity"); + const Identity identity = parse_identity(session.page->model().screen()); + require(identity.pid == session.page->model().resize_state().child_pid, + "child READY PID does not match the model child identity"); + session.pid = identity.pid; + session.nonce = identity.nonce; + session.ready_text = identity.ready_text; + session.path = root_ + QStringLiteral("/") + QString::fromStdString(identity.nonce) + QStringLiteral(".bin"); + require(file_exists(session.path), "session " + std::to_string(creation) + " has no receiver file"); + require(read_file(session.path).empty(), "session " + std::to_string(creation) + " receiver file is not clean"); + session.ready_ms = elapsed(); + return identity; + } + void send_text(const std::string& bytes) { + auto* page = current(); + require(page != nullptr, "no active session"); + require(QApplication::focusWidget() == &page->terminal(), "active session terminal has no focus"); + require(text_event(page->terminal(), QString::fromUtf8(bytes.data(), static_cast(bytes.size()))), + "active target rejected literal text: " + hex(bytes)); + } + void send_home() { + auto* page = current(); + require(page != nullptr, "no active session"); + require(QApplication::focusWidget() == &page->terminal(), "active session terminal has no focus"); + QTest::keyClick(&page->terminal(), Qt::Key_Home); + } + std::string active_expected(const std::string& addition) { + auto* page = current(); + require(page != nullptr, "no active session"); + const auto found = std::find_if(sessions_.begin(), sessions_.end(), + [&](const SessionRecord& session) { return session.page == page; }); + require(found != sessions_.end(), "active session is not in the matrix"); + return found->received + addition + "\x1b[H"; + } + StepRecord& begin_step(const std::string& phase, int creation) { + auto& session = sessions_.at(static_cast(creation)); + require(session.page != nullptr, "step target is released"); + StepRecord step; + step.phase = phase; + step.creation = creation; + step.tab = pages_view_->indexOf(session.page); + step.pid = session.pid; + step.nonce = session.nonce; + step.started_ms = elapsed(); + steps_.push_back(std::move(step)); + return steps_.back(); + } + void finish_step(StepRecord& step, const std::string& expected) { + auto& session = sessions_.at(static_cast(step.creation)); + await([&] { return read_file(session.path) == expected; }, + "session " + std::to_string(step.creation) + " receiver did not get its exact literal bytes"); + session.received = expected; + step.duration_ms = elapsed() - step.started_ms; + step.expected_hex = hex(expected); + step.actual_hex = hex(read_file(session.path)); + step.actual_sha256 = sha256_hex(read_file(session.path)); + } + void require_other_receivers_unchanged(int except_creation, const char* phase) { + for (const auto& session : sessions_) { + if (session.creation == except_creation || session.page == nullptr) continue; + require(read_file(session.path) == session.received, + std::string(phase) + ": session " + std::to_string(session.creation) + + " receiver changed without being the input target"); + } + } + void require_background_bounded(const char* phase) { + for (const auto& session : sessions_) { + if (session.page == nullptr) continue; + const auto snapshot = session.page->model().output_state(); + require(snapshot.queued_bytes <= 1024 * 1024, + std::string(phase) + ": session " + std::to_string(session.creation) + + " output queue exceeded its bounded expectation"); + } + } + void close_tab(int creation, const std::string& phase) { + auto& session = sessions_.at(static_cast(creation)); + require(session.page != nullptr, "close target is already released"); + const int tab = pages_view_->indexOf(session.page); + require(tab >= 0, "close target has no tab"); + auto* close = bar_->tabButton(tab, QTabBar::RightSide); + if (!close) close = bar_->tabButton(tab, QTabBar::LeftSide); + require(close != nullptr, "tab close action is missing"); + CloseRecord record; + record.phase = phase; + record.creation = creation; + record.pid = session.pid; + record.nonce = session.nonce; + const int expected_tabs = bar_->count() - 1; + QPointer closing = session.page; + const quint64 started = elapsed(); + QTest::mouseClick(close, Qt::LeftButton); + await([&] { return bar_->count() == expected_tabs && !closing; }, + "closing a tab did not finish and release its owned session", 8000); + record.elapsed_ms = elapsed() - started; + record.tabs_after = bar_->count(); + record.page_released = !closing; + session.closed = true; + closures_.push_back(record); + require(window_.current_session() != nullptr && window_.current_session()->model().running(), + "closing a tab affected the surviving window state"); + require(QApplication::focusWidget() == &window_.current_session()->terminal(), + "tab close did not restore terminal focus to a survivor"); + } +}; + +QJsonObject session_record_json(const SessionRecord& session) { + return QJsonObject{{"creation", session.creation}, + {"pid", static_cast(session.pid)}, + {"nonce", QString::fromStdString(session.nonce)}, + {"ready_text", QString::fromStdString(session.ready_text)}, + {"receiver_file", QFileInfo(session.path).fileName()}, + {"ready_ms", static_cast(session.ready_ms)}, + {"received_hex", QString::fromStdString(hex(session.received))}, + {"received_sha256", QString::fromStdString(sha256_hex(session.received))}, + {"closed", session.closed}, + {"tab_index_last", session.tab_index}}; +} +int close_parent_and_verify(QApplication& app, Harness& harness, QJsonObject* record) { + std::vector> pages; + for (const auto& session : harness.sessions_) + if (session.page != nullptr) pages.push_back(session.page); + require(!pages.empty(), "no live session remains before the parent close"); + require(!QApplication::quitOnLastWindowClosed(), "the parent-close case does not use the product exit policy"); + std::vector verified(pages.size(), false); + std::vector captured(pages.size()); + const auto cleanup_ready = [&] { + bool ready = true; + for (size_t index = 0; index < pages.size(); ++index) { + if (const auto& page = pages[index]) { + verified[index] = page->model().finished() && page->model().cleanup_verified() && + page->model().active_processes_after() == 0; + captured[index] = QJsonObject{ + {"finished", page->model().finished()}, + {"natural_exit", page->model().natural_exit()}, + {"exit_code", static_cast(page->model().exit_code())}, + {"cleanup_verified", page->model().cleanup_verified()}, + {"active_processes_after", static_cast(page->model().active_processes_after())}, + {"output_eof", page->model().output_eof()}, + {"error", page->model().error()}}; + } + ready = ready && verified[index]; + } + return ready; + }; + bool quit_seen = false, cleanup_at_quit = false, cleanup_at_completion = false; + int completions = 0; + std::optional completion_status; + const auto quitting = QObject::connect(&app, &QCoreApplication::aboutToQuit, &harness.window_, [&] { + quit_seen = true; + cleanup_at_quit = cleanup_ready(); + }); + harness.window_.on_all_exited = [&](int status) { + ++completions; + completion_status = status; + cleanup_at_completion = cleanup_ready(); + app.exit(status); + }; + QTimer deadline; + deadline.setSingleShot(true); + QObject::connect(&deadline, &QTimer::timeout, &harness.window_, [&] { app.exit(99); }); + QTimer::singleShot(0, &harness.window_, [&] { harness.window_.close(); }); + deadline.start(15000); + const int status = app.exec(); + deadline.stop(); + QObject::disconnect(quitting); + harness.window_.on_all_exited = {}; + require(quit_seen && cleanup_at_completion && cleanup_at_quit && cleanup_ready(), + "the application event loop quit before every owned session completed cleanup"); + require(status == 0 && completions == 1 && completion_status.value_or(-1) == 0, + "parent window close did not end app.exec through verified session completion"); + QJsonArray sessions; + for (size_t index = 0; index < pages.size(); ++index) { + auto json = captured[index]; + json["verified"] = static_cast(verified[index]); + sessions.append(json); + } + *record = QJsonObject{{"status", status}, {"completions", completions}, + {"cleanup_at_quit", cleanup_at_quit}, + {"cleanup_at_completion", cleanup_at_completion}, + {"sessions", sessions}}; + return status; +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + char gate[3]{}; + DWORD count = 0; + BOOL owned = FALSE; + if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), gate, sizeof(gate), &count, nullptr) || count != sizeof(gate) || + std::string_view(gate, count) != "GO\n" || !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) { + std::cerr << "Qt eight-session test requires its owned Job supervisor\n"; + return 2; + } + wchar_t executable_path[32768]{}; + const DWORD path_length = GetModuleFileNameW(nullptr, executable_path, 32768); + const char* selected_platform = std::getenv("QT_QPA_PLATFORM"); + if (selected_platform && std::string_view(selected_platform) == "offscreen" && + (!path_length || path_length >= 32768 || + !std::filesystem::is_regular_file(std::filesystem::path(executable_path).parent_path() / "platforms/qoffscreen.dll"))) { + std::cerr << "Qt eight-session test requires its deployed offscreen platform plugin\n"; + return 2; + } + qInstallMessageHandler([](QtMsgType, const QMessageLogContext&, const QString& message) { + std::cerr << message.toStdString() << '\n' << std::flush; + }); + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + try { + const auto artifacts = qEnvironmentVariable("TERMFORGE_TEST_ARTIFACTS"); + if (!artifacts.isEmpty()) require(QDir().mkpath(artifacts), "artifact directory could not be created"); + QTemporaryDir workspace(artifacts.isEmpty() ? QString() : artifacts + QStringLiteral("/qt-eight-session-XXXXXX")); + require(workspace.isValid(), "owned session workspace could not be created"); + if (!artifacts.isEmpty()) workspace.setAutoRemove(false); + const QString receiver = QCoreApplication::applicationDirPath() + QStringLiteral("/shell_eight_session_client.exe"); + require(QFile::exists(receiver), "independent eight-session receiver is missing"); + + Harness harness(receiver, workspace.path()); + harness.show(); + harness.sessions_.resize(session_count); + for (int creation = 0; creation < session_count; ++creation) { + if (creation != 0) { + const int before = harness.bar_->count(); + QTest::mouseClick(harness.create_, Qt::LeftButton); + await([&] { return harness.bar_->count() == before + 1; }, + "public + action did not create an independent tab"); + } + auto* page = harness.current(); + require(page != nullptr, "public + action did not select the new session"); + auto& session = harness.sessions_[static_cast(creation)]; + session.creation = creation; + session.page = page; + harness.await_ready(creation); + require(page->model().running() && page->model().error().isEmpty(), + "session " + std::to_string(creation) + " is not running cleanly"); + } + require(harness.bar_->count() == session_count, "eight sessions were not created through the public entry"); + for (const auto& session : harness.sessions_) + require(session.page->launched() && session.page->model().running(), "a session failed to launch"); + std::vector pids; + std::vector nonces; + for (const auto& session : harness.sessions_) { + pids.push_back(session.pid); + nonces.push_back(session.nonce); + } + std::sort(pids.begin(), pids.end()); + std::sort(nonces.begin(), nonces.end()); + require(std::adjacent_find(pids.begin(), pids.end()) == pids.end(), "two sessions share one child PID"); + require(std::adjacent_find(nonces.begin(), nonces.end()) == nonces.end(), "two sessions share one nonce"); + const std::string backend = harness.sessions_[0].page->model().configuration_identity().toStdString(); + const int grid_columns = harness.sessions_[0].page->model().screen().width(); + const int grid_rows = harness.sessions_[0].page->model().screen().height(); + + // Creation-order switch: start the bounded background block and send the first + // literal text plus Home through the public key path of each selected target. + for (int creation = 0; creation < session_count; ++creation) { + harness.switch_to(creation); + auto& step = harness.begin_step("creation-order-trigger-text-home", creation); + harness.send_text("*BG*"); + harness.send_text(payload_a(creation)); + harness.send_home(); + const std::string expected = std::string(payload_a(creation)) + "\x1b[H"; + harness.finish_step(step, expected); + harness.require_other_receivers_unchanged(creation, "creation-order input"); + step.isolation_checked = true; + harness.require_background_bounded("creation-order input"); + await([&] { return background_sequence(harness.sessions_[creation].page->model().screen()) >= 1; }, + "bounded background output did not start for session " + std::to_string(creation)); + } + + // Non-creation-order switch: each target receives only its own literal bytes. + const int order[] = {4, 0, 7, 2, 5, 1, 6, 3}; + for (const int creation : order) { + harness.switch_to(creation); + auto& session = harness.sessions_[static_cast(creation)]; + auto& step = harness.begin_step("non-creation-order-text-home", creation); + harness.send_text(payload_b(creation)); + harness.send_home(); + const std::string expected = session.received + payload_b(creation) + "\x1b[H"; + harness.finish_step(step, expected); + harness.require_other_receivers_unchanged(creation, "non-creation-order input"); + step.isolation_checked = true; + } + // While the targets receive input, every bounded background block keeps + // draining; the inactive children must not steal the active input target. + await([&] { + for (const auto& session : harness.sessions_) + if (background_sequence(session.page->model().screen()) < background_total) return false; + return true; + }, "bounded background output did not drain for every session", 8000); + QJsonArray background_records; + for (const auto& session : harness.sessions_) { + const auto snapshot = session.page->model().output_state(); + QJsonObject background; + background["creation"] = session.creation; + background["sequence"] = static_cast(background_sequence(session.page->model().screen())); + background["queued_bytes"] = static_cast(snapshot.queued_bytes); + background["high_water_bytes"] = static_cast(snapshot.high_water_bytes); + background["received_hex"] = QString::fromStdString(hex(session.received)); + background_records.append(background); + } + + // Close a non-active tab; the surviving target still receives input. + const int inactive_creation = 2; + require(harness.bar_->currentIndex() != harness.pages_view_->indexOf(harness.sessions_[inactive_creation].page), + "the non-active close target is the current tab"); + harness.close_tab(inactive_creation, "non-active-tab"); + { + auto* survivor = harness.current(); + require(survivor != nullptr, "no surviving session after the non-active close"); + const auto found = std::find_if(harness.sessions_.begin(), harness.sessions_.end(), + [&](const SessionRecord& session) { return session.page == survivor; }); + require(found != harness.sessions_.end(), "surviving session is not in the matrix"); + auto& step = harness.begin_step("survivor-input-after-non-active-close", found->creation); + harness.send_text("+CLOSE"); + harness.send_home(); + harness.finish_step(step, found->received + "+CLOSE\x1b[H"); + harness.require_other_receivers_unchanged(found->creation, "non-active close survivor input"); + step.isolation_checked = true; + } + + // Close the active tab; the next target still receives input. + { + auto* active = harness.current(); + require(active != nullptr, "no active session before the active close"); + const auto found = std::find_if(harness.sessions_.begin(), harness.sessions_.end(), + [&](const SessionRecord& session) { return session.page == active; }); + require(found != harness.sessions_.end(), "active session is not in the matrix"); + const int creation = found->creation; + harness.close_tab(creation, "active-tab"); + auto* survivor = harness.current(); + require(survivor != nullptr, "no surviving session after the active close"); + const auto next = std::find_if(harness.sessions_.begin(), harness.sessions_.end(), + [&](const SessionRecord& session) { return session.page == survivor; }); + require(next != harness.sessions_.end(), "post-close surviving session is not in the matrix"); + auto& step = harness.begin_step("survivor-input-after-active-close", next->creation); + harness.send_text("+ACTIVE"); + harness.send_home(); + harness.finish_step(step, next->received + "+ACTIVE\x1b[H"); + harness.require_other_receivers_unchanged(next->creation, "active close survivor input"); + step.isolation_checked = true; + } + + // A replacement tab must not inherit the closed tab's pending UTF-16 unit. + int pending_creation = -1; + for (const auto& session : harness.sessions_) { + if (session.page != nullptr && session.page != harness.current() && !session.closed) { + pending_creation = session.creation; + break; + } + } + require(pending_creation >= 0, "no session is available to carry the pending unit"); + harness.switch_to(pending_creation); + { + auto& pending = harness.sessions_[static_cast(pending_creation)]; + require(text_event(pending.page->terminal(), QString(QChar(0xd83d))), + "the active target rejected the pending high surrogate"); + require(read_file(pending.path) == pending.received, "a pending high surrogate wrote receiver bytes"); + } + harness.close_tab(pending_creation, "pending-tab"); + { + const auto& pending = harness.sessions_[static_cast(pending_creation)]; + require(read_file(pending.path) == pending.received, + "closing a tab with a pending unit wrote receiver bytes"); + } + { + const int before = harness.bar_->count(); + QTest::mouseClick(harness.create_, Qt::LeftButton); + await([&] { return harness.bar_->count() == before + 1; }, "public + did not create the replacement tab"); + } + auto& replacement = harness.sessions_.emplace_back(); + replacement.creation = session_count; + replacement.page = harness.current(); + require(replacement.page != nullptr, "replacement tab has no session"); + harness.await_ready(session_count); + for (const auto& session : harness.sessions_) { + if (&session == &replacement) continue; + require(session.pid != replacement.pid && session.nonce != replacement.nonce, + "the replacement session reused an old identity"); + } + require(read_file(replacement.path).empty(), "the replacement receiver file is not clean"); + { + auto& step = harness.begin_step("replacement-first-input", session_count); + harness.send_text("NEW"); + harness.send_home(); + harness.finish_step(step, "NEW\x1b[H"); + require(read_file(replacement.path) == "NEW\x1b[H", + "the replacement session inherited bytes from the closed tab's pending unit"); + } + + QJsonArray step_records; + for (const auto& step : harness.steps_) { + step_records.append(QJsonObject{{"phase", QString::fromStdString(step.phase)}, + {"creation", step.creation}, + {"tab", step.tab}, + {"pid", static_cast(step.pid)}, + {"nonce", QString::fromStdString(step.nonce)}, + {"started_ms", static_cast(step.started_ms)}, + {"duration_ms", static_cast(step.duration_ms)}, + {"expected_hex", QString::fromStdString(step.expected_hex)}, + {"actual_hex", QString::fromStdString(step.actual_hex)}, + {"actual_sha256", QString::fromStdString(step.actual_sha256)}, + {"isolation_checked", step.isolation_checked}}); + } + QJsonArray close_records; + for (const auto& close : harness.closures_) { + close_records.append(QJsonObject{{"phase", QString::fromStdString(close.phase)}, + {"creation", close.creation}, + {"pid", static_cast(close.pid)}, + {"nonce", QString::fromStdString(close.nonce)}, + {"elapsed_ms", static_cast(close.elapsed_ms)}, + {"tabs_after", close.tabs_after}, + {"page_released", close.page_released}}); + } + QJsonArray session_matrix; + int live_sessions = 0; + for (const auto& session : harness.sessions_) { + session_matrix.append(session_record_json(session)); + if (!session.closed) ++live_sessions; + } + + const quint64 total_elapsed_ms = harness.elapsed(); + QJsonObject parent_close; + const int parent_status = close_parent_and_verify(app, harness, &parent_close); + require(parent_status == 0, "parent window close returned an unexpected status"); + + QJsonObject result{ + {"schema", "termforge-qt-eight-session-v1"}, + {"backend", QString::fromStdString(backend)}, + {"grid", QJsonArray{grid_columns, grid_rows}}, + {"sessions_created", session_count}, + {"live_sessions_at_parent_close", live_sessions}, + {"replacement_created", true}, + {"switch_order", QJsonArray{4, 0, 7, 2, 5, 1, 6, 3}}, + {"session_matrix", session_matrix}, + {"steps", step_records}, + {"background", background_records}, + {"closures", close_records}, + {"parent_close", parent_close}, + {"total_elapsed_ms", static_cast(total_elapsed_ms)}, + {"presented_done", QJsonValue()}, + {"offscreen_only", true}, + {"physical_keyboard", false}, + {"native_ime", false}, + {"clipboard_used", false}}; + QFile saved(workspace.path() + QStringLiteral("/sample.json")); + require(saved.open(QIODevice::WriteOnly) && saved.write(QJsonDocument(result).toJson()) > 0, + "session sample could not be retained"); + std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << "Qt eight-session matrix: " << error.what() << '\n'; + return 1; + } +} From 7826aec9f0038f125e6580000b251b115ba7d444 Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:35:45 +0800 Subject: [PATCH 2/2] docs(evidence): record the Qt eight-session isolation validation (#30) Record the final two-run and regression results for qt_eight_session in validation.json: per-session PID/nonce/receiver-file matrix, per-step literal expected/actual bytes and timing, non-creation-order switch coverage, bounded background sequences and queue drain, close timings, replacement pending-unit isolation, parent-close cleanup states, and the fixed dependency/source/binary identities. Align the report's measured values with the final runs. The recorded implementation sources still match the implementation commit; this follow-up changes only documentation. #30/#155 stay open. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- .../README.md | 30 +- .../validation.json | 1186 +++++++++++++++++ 2 files changed, 1201 insertions(+), 15 deletions(-) create mode 100644 experiments/shell/results/2026-11-08-qt-eight-session-isolation/validation.json diff --git a/experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md b/experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md index 7f1da66..a3cdab1 100644 --- a/experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md +++ b/experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md @@ -20,26 +20,26 @@ | creation | PID | nonce | 接收文件 | 结果 | |---|---|---|---|---| -| 0 | 33032 | `08e96ee51af4e742` | `08e96ee51af4e742.bin` | 输入后关闭(pending 目标) | -| 1 | 13452 | `08e9db79916fe771` | `08e9db79916fe771.bin` | 存活至父窗关闭 | -| 2 | 41320 | `08e94e974a48e7b0` | `08e94e974a48e7b0.bin` | 非活动标签关闭 | -| 3 | 8140 | `08e9efc45289e7ee` | `08e9efc45289e7ee.bin` | 活动标签关闭 | -| 4 | 25012 | `08e991ac900ae85c` | `08e991ac900ae85c.bin` | 存活 | -| 5 | 28748 | `08e98065e628e8c9` | `08e98065e628e8c9.bin` | 存活 | -| 6 | 19408 | `08e9bb946b76e975` | `08e9bb946b76e975.bin` | 存活 | -| 7 | 3212 | `08e9fcdd3e73e9d3` | `08e9fcdd3e73e9d3.bin` | 存活 | -| 8(替代页) | 23648 | `08e9ad8826e5f433` | `08e9ad8826e5f433.bin` | 首个输入仅 `NEW`+Home | +| 0 | 43348 | `08eaee5cc32f221f` | `08eaee5cc32f221f.bin` | 输入后关闭(pending 目标),READY≈41 ms | +| 1 | 20808 | `08ea16595fcd224e` | `08ea16595fcd224e.bin` | 存活至父窗关闭,READY≈98 ms | +| 2 | 39768 | `08eadc42011f228c` | `08eadc42011f228c.bin` | 非活动标签关闭,READY≈151 ms | +| 3 | 8276 | `08ea677636ff22bb` | `08ea677636ff22bb.bin` | 活动标签关闭,READY≈205 ms | +| 4 | 13248 | `08ea74ea6ead22fa` | `08ea74ea6ead22fa.bin` | 存活,READY≈259 ms | +| 5 | 26452 | `08ea206652522329` | `08ea206652522329.bin` | 存活,READY≈312 ms | +| 6 | 27920 | `08ea2a2aa56b2367` | `08ea2a2aa56b2367.bin` | 存活,READY≈367 ms | +| 7 | 10396 | `08ea6fdec4a22396` | `08ea6fdec4a22396.bin` | 存活,READY≈421 ms | +| 8(替代页) | 19492 | `08ea04e4b4c22d5a` | `08ea04e4b4c22d5a.bin` | 首个输入仅 `NEW`+Home,READY≈2922 ms | 逐会话隔离与字面预期(节选,均为实际 receiver hex=预期 hex): -- 创建顺序切换 8 次(t0=705 ms … t7=1169 ms 内完成):每次只向当前活动目标发送 `*BG*`+样本+Home;ASCII `TF8-A0 ASCII`、CJK `汉字`、补充平面 `🙂`、结合标 `e`+U+0301、`é`、`终端`、`😀`、`plain` 的接收字节与字面预期逐字节一致。 +- 创建顺序切换 8 次(首个样本步起始≈434 ms,末个≈845 ms;每步接收校验 2–5 ms):每次只向当前活动目标发送 `*BG*`+样本+Home;ASCII `TF8-A0 ASCII`、CJK `汉字`、补充平面 `🙂`、结合标 `e`+U+0301、`é`、`终端`、`😀`、`plain` 的接收字节与字面预期逐字节一致。 - 非创建顺序切换 `[4, 0, 7, 2, 5, 1, 6, 3]`:每步追加 `B`+Home,8 个 receiver 均精确匹配,且每次仅目标文件变化(`isolation_checked=true`)。 - 后台有界输出:8 个 child 全部到达 `seq=120`、`queued_bytes=0`(high-water 96–98 B),即后台输出被排空;期间输入只进入活动目标。 -- 非活动关闭(creation 2, PID 41320):63 ms 内标签 8→7、页面释放;存活目标(creation 3)继续收到 `+CLOSE`+Home 且字节精确。 -- 活动关闭(creation 3, PID 8140):55 ms 内标签 7→6、页面释放;新活动目标(creation 4)继续收到 `+ACTIVE`+Home 且字节精确。 -- 待发文字隔离:对 creation 0 发送孤立高代理项(未产生字节,文件不变),关闭该标签(39 ms,6→5)后点击 `+` 创建替代页;替代页首个输入接收为 `4e45571b5b48`(`NEW`+Home),**无 U+FFFD 前缀或旧待发单元泄漏**,旧文件保持不变。 -- 父窗口关闭:6 个存活会话全部 `finished=true`、`cleanup_verified=true`、`active_processes_after=0`、`output_eof=true`;`app.exec()` 经 `aboutToQuit`/`on_all_exited` 完成,status=0、completions=1,关闭时刻与完成时刻两处清理检查均为 true。外层 OwnedJob 由 supervisor 断言 `active=0`。 -- 总运行约 3.38 s(CTest `qt_eight_session` 3.22–3.28 s,连跑两次)。 +- 非活动关闭(creation 2, PID 39768):43 ms 内标签 8→7、页面释放;存活目标(creation 3)继续收到 `+CLOSE`+Home 且字节精确。 +- 活动关闭(creation 3, PID 8276):30 ms 内标签 7→6、页面释放;新活动目标(creation 4)继续收到 `+ACTIVE`+Home 且字节精确。 +- 待发文字隔离:对 creation 0 发送孤立高代理项(未产生字节,文件不变),关闭该标签(38 ms,6→5)后点击 `+` 创建替代页;替代页首个输入接收为 `4e45571b5b48`(`NEW`+Home),**无 U+FFFD 前缀或旧待发单元泄漏**,旧文件保持不变。 +- 父窗口关闭:6 个存活会话全部 `finished=true`、`cleanup_verified=true`、`active_processes_after=0`、`output_eof=true`;`app.exec()` 经 `aboutToQuit`/`on_all_exited` 完成,status=0、completions=1,关闭时刻与完成时刻两处清理检查均为 true。外层 OwnedJob supervisor 记录 `job_after={active:0,total:20,terminated:0}`。 +- 总运行约 2.93 s(CTest `qt_eight_session` 3.20 s,连跑两次均通过)。 字体/呈现与 glyph 不在本切片范围;`presented_done=null`,测试不抓图、不比较像素。 diff --git a/experiments/shell/results/2026-11-08-qt-eight-session-isolation/validation.json b/experiments/shell/results/2026-11-08-qt-eight-session-isolation/validation.json new file mode 100644 index 0000000..9e93f2f --- /dev/null +++ b/experiments/shell/results/2026-11-08-qt-eight-session-isolation/validation.json @@ -0,0 +1,1186 @@ +{ + "schema": "termforge-qt-eight-session-validation-v1", + "source_commit": "197aa9a9248e4db3f48325a53e59c098352c9cb5", + "source_tree": "7862b48eaedfe977b6d768735be92e3cf84965e9", + "source_binding": "Implementation, tests, CI wiring and report were committed before the final runs; this validation file is a documentation follow-up. The recorded implementation sources match the committed blobs, and the working tree was clean before the final runs started.", + "scope": "Eight real fixed-ConPTY children created only through the public +/tab/close entries: per-session PID/nonce/receiver file, literal ASCII/CJK/supplementary/combining text and unmodified Home isolation, non-creation-order switching, bounded background drain, non-active/active close survival, replacement pending-unit isolation and parent-window cleanup.", + "passed": true, + "scoped_validation_passed": true, + "eight_session_isolation_verified": true, + "ninth_session_policy_approved": false, + "physical_keyboard_verified": false, + "native_ime_verified": false, + "system_clipboard_verified": false, + "full_copy_paste_ime_workflow_verified": false, + "r2_complete": false, + "windterm_parity": 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": "3.12.10", + "backend_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/qt-cpp -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/qt-cpp --config Release --target qt_eight_session_test --parallel 2", + "new_test": "ctest --test-dir build/qt-cpp -C Release -R '^qt_eight_session$' --no-tests=error --output-on-failure", + "qt_regression": "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)$' --output-on-failure", + "core_regression": "ctest --test-dir build -C Release -R '^(.*_core|core|resize_replay_contract|recovery|keyboard)$' --output-on-failure", + "python_core": "python -m unittest experiments.core.test_unicode experiments.core.test_core -v" + }, + "results": { + "eight_session_runs": [ + { + "label": "run1", + "supervisor_status": "passed", + "exit_code": 0, + "job_after": { + "active": 0, + "total": 20, + "terminated": 0 + }, + "presented_done_null": true, + "backend": "cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24", + "grid": [ + 80, + 24 + ], + "sessions_created": 8, + "replacement_created": true, + "live_sessions_at_parent_close": 6, + "switch_order": [ + 4, + 0, + 7, + 2, + 5, + 1, + 6, + 3 + ], + "total_elapsed_ms": 2928, + "session_matrix": [ + { + "creation": 0, + "pid": 43348, + "nonce": "08eaee5cc32f221f", + "receiver_file": "08eaee5cc32f221f.bin", + "closed": true, + "received_bytes": 20, + "ready_ms": 41 + }, + { + "creation": 1, + "pid": 20808, + "nonce": "08ea16595fcd224e", + "receiver_file": "08ea16595fcd224e.bin", + "closed": false, + "received_bytes": 24, + "ready_ms": 98 + }, + { + "creation": 2, + "pid": 39768, + "nonce": "08eadc42011f228c", + "receiver_file": "08eadc42011f228c.bin", + "closed": true, + "received_bytes": 23, + "ready_ms": 151 + }, + { + "creation": 3, + "pid": 8276, + "nonce": "08ea677636ff22bb", + "receiver_file": "08ea677636ff22bb.bin", + "closed": true, + "received_bytes": 29, + "ready_ms": 205 + }, + { + "creation": 4, + "pid": 13248, + "nonce": "08ea74ea6ead22fa", + "receiver_file": "08ea74ea6ead22fa.bin", + "closed": false, + "received_bytes": 27, + "ready_ms": 259 + }, + { + "creation": 5, + "pid": 26452, + "nonce": "08ea206652522329", + "receiver_file": "08ea206652522329.bin", + "closed": false, + "received_bytes": 21, + "ready_ms": 312 + }, + { + "creation": 6, + "pid": 27920, + "nonce": "08ea2a2aa56b2367", + "receiver_file": "08ea2a2aa56b2367.bin", + "closed": false, + "received_bytes": 19, + "ready_ms": 367 + }, + { + "creation": 7, + "pid": 10396, + "nonce": "08ea6fdec4a22396", + "receiver_file": "08ea6fdec4a22396.bin", + "closed": false, + "received_bytes": 20, + "ready_ms": 421 + }, + { + "creation": 8, + "pid": 19492, + "nonce": "08ea04e4b4c22d5a", + "receiver_file": "08ea04e4b4c22d5a.bin", + "closed": false, + "received_bytes": 6, + "ready_ms": 2922 + } + ], + "step_counts": { + "creation-order-trigger-text-home": 8, + "non-creation-order-text-home": 8, + "survivor-input-after-non-active-close": 1, + "survivor-input-after-active-close": 1, + "replacement-first-input": 1 + }, + "steps": [ + { + "actual_hex": "5446382d41302041534349491b5b48", + "actual_sha256": "027467573e90a7ffb5de8787ab44e915a00f0ae260d48e0d264d8ac52fec4f26", + "creation": 0, + "duration_ms": 5, + "expected_hex": "5446382d41302041534349491b5b48", + "isolation_checked": true, + "nonce": "08eaee5cc32f221f", + "phase": "creation-order-trigger-text-home", + "pid": 43348, + "started_ms": 434, + "tab": 0 + }, + { + "actual_hex": "5446382d413120e6b189e5ad971b5b48", + "actual_sha256": "39888f6392d86c661b4c0427f140e24d006009d3c65bcac8706ee1ee60940ea2", + "creation": 1, + "duration_ms": 3, + "expected_hex": "5446382d413120e6b189e5ad971b5b48", + "isolation_checked": true, + "nonce": "08ea16595fcd224e", + "phase": "creation-order-trigger-text-home", + "pid": 20808, + "started_ms": 496, + "tab": 1 + }, + { + "actual_hex": "5446382d413220f09f99821b5b48", + "actual_sha256": "027cbb6c4972fdd916a06e6cd08a9efc1c53d5bedf28ca9db1a054ba3456ca5a", + "creation": 2, + "duration_ms": 4, + "expected_hex": "5446382d413220f09f99821b5b48", + "isolation_checked": true, + "nonce": "08eadc42011f228c", + "phase": "creation-order-trigger-text-home", + "pid": 39768, + "started_ms": 562, + "tab": 2 + }, + { + "actual_hex": "5446382d41332065cc811b5b48", + "actual_sha256": "1c8843663e10273c736f12f0b8d8417cad8ac0bd1efba103b8f8bb448ecf0309", + "creation": 3, + "duration_ms": 2, + "expected_hex": "5446382d41332065cc811b5b48", + "isolation_checked": true, + "nonce": "08ea677636ff22bb", + "phase": "creation-order-trigger-text-home", + "pid": 8276, + "started_ms": 612, + "tab": 3 + }, + { + "actual_hex": "5446382d413420c3a91b5b48", + "actual_sha256": "573d8b22b581424a4ab6a391b48a5b0776a95893b08e89c91b3b3c2af844ec16", + "creation": 4, + "duration_ms": 3, + "expected_hex": "5446382d413420c3a91b5b48", + "isolation_checked": true, + "nonce": "08ea74ea6ead22fa", + "phase": "creation-order-trigger-text-home", + "pid": 13248, + "started_ms": 673, + "tab": 4 + }, + { + "actual_hex": "5446382d413520e7bb88e7abaf1b5b48", + "actual_sha256": "453cfe23f21237feda6af7e619fd726d53837b5cc623837bf4f6e0287131c628", + "creation": 5, + "duration_ms": 3, + "expected_hex": "5446382d413520e7bb88e7abaf1b5b48", + "isolation_checked": true, + "nonce": "08ea206652522329", + "phase": "creation-order-trigger-text-home", + "pid": 26452, + "started_ms": 737, + "tab": 5 + }, + { + "actual_hex": "5446382d413620f09f98801b5b48", + "actual_sha256": "1320e092025f4d0916fc65db4573a32a234e6f03dd30fc70fc870d97c3c57160", + "creation": 6, + "duration_ms": 4, + "expected_hex": "5446382d413620f09f98801b5b48", + "isolation_checked": true, + "nonce": "08ea2a2aa56b2367", + "phase": "creation-order-trigger-text-home", + "pid": 27920, + "started_ms": 780, + "tab": 6 + }, + { + "actual_hex": "5446382d413720706c61696e1b5b48", + "actual_sha256": "3dbd0a9640f8d1297044af10d4cf14b5a4826f1bd3caa62bc261a12b7bdf2062", + "creation": 7, + "duration_ms": 3, + "expected_hex": "5446382d413720706c61696e1b5b48", + "isolation_checked": true, + "nonce": "08ea6fdec4a22396", + "phase": "creation-order-trigger-text-home", + "pid": 10396, + "started_ms": 845, + "tab": 7 + }, + { + "actual_hex": "5446382d413420c3a91b5b4842341b5b48", + "actual_sha256": "d51f7927ddfa10f0b00c64a2b96c00012924951421cd6e36b1fbb30f79951e11", + "creation": 4, + "duration_ms": 33, + "expected_hex": "5446382d413420c3a91b5b4842341b5b48", + "isolation_checked": true, + "nonce": "08ea74ea6ead22fa", + "phase": "non-creation-order-text-home", + "pid": 13248, + "started_ms": 893, + "tab": 4 + }, + { + "actual_hex": "5446382d41302041534349491b5b4842301b5b48", + "actual_sha256": "09efa4e3a70c8873195ba6e471e1be545f27114a61b2228ebcc9963a30217fc5", + "creation": 0, + "duration_ms": 28, + "expected_hex": "5446382d41302041534349491b5b4842301b5b48", + "isolation_checked": true, + "nonce": "08eaee5cc32f221f", + "phase": "non-creation-order-text-home", + "pid": 43348, + "started_ms": 941, + "tab": 0 + }, + { + "actual_hex": "5446382d413720706c61696e1b5b4842371b5b48", + "actual_sha256": "7c73c27d8e810e17e65c7067683b9970f43f23a66017ef5f8839ab5a81389a48", + "creation": 7, + "duration_ms": 26, + "expected_hex": "5446382d413720706c61696e1b5b4842371b5b48", + "isolation_checked": true, + "nonce": "08ea6fdec4a22396", + "phase": "non-creation-order-text-home", + "pid": 10396, + "started_ms": 981, + "tab": 7 + }, + { + "actual_hex": "5446382d413220f09f99821b5b484232f09f99821b5b48", + "actual_sha256": "a21a1cfb2e7542e1fe2b784df1f0ecea69443cdf740664b9aca729886d92c169", + "creation": 2, + "duration_ms": 32, + "expected_hex": "5446382d413220f09f99821b5b484232f09f99821b5b48", + "isolation_checked": true, + "nonce": "08eadc42011f228c", + "phase": "non-creation-order-text-home", + "pid": 39768, + "started_ms": 1022, + "tab": 2 + }, + { + "actual_hex": "5446382d413520e7bb88e7abaf1b5b4842351b5b48", + "actual_sha256": "10eb9f3682aac873596f4523395da5a5961845c13193eeb3e5941f78196a8049", + "creation": 5, + "duration_ms": 31, + "expected_hex": "5446382d413520e7bb88e7abaf1b5b4842351b5b48", + "isolation_checked": true, + "nonce": "08ea206652522329", + "phase": "non-creation-order-text-home", + "pid": 26452, + "started_ms": 1069, + "tab": 5 + }, + { + "actual_hex": "5446382d413120e6b189e5ad971b5b484231e6b1891b5b48", + "actual_sha256": "325f9dcd529fd60d49fdc3838de5c34e10f0ddb76d0603dc501575d836abc3ef", + "creation": 1, + "duration_ms": 28, + "expected_hex": "5446382d413120e6b189e5ad971b5b484231e6b1891b5b48", + "isolation_checked": true, + "nonce": "08ea16595fcd224e", + "phase": "non-creation-order-text-home", + "pid": 20808, + "started_ms": 1114, + "tab": 1 + }, + { + "actual_hex": "5446382d413620f09f98801b5b4842361b5b48", + "actual_sha256": "4a376e3035044ee47acf735343b93f1b8e674ecb7891ba0575670ce48c0014bf", + "creation": 6, + "duration_ms": 18, + "expected_hex": "5446382d413620f09f98801b5b4842361b5b48", + "isolation_checked": true, + "nonce": "08ea2a2aa56b2367", + "phase": "non-creation-order-text-home", + "pid": 27920, + "started_ms": 1157, + "tab": 6 + }, + { + "actual_hex": "5446382d41332065cc811b5b484233cc811b5b48", + "actual_sha256": "29c5f5c2cc5bebc12a0d379a435c56b8c8c0bc9613127f3c548aa833f85ca437", + "creation": 3, + "duration_ms": 26, + "expected_hex": "5446382d41332065cc811b5b484233cc811b5b48", + "isolation_checked": true, + "nonce": "08ea677636ff22bb", + "phase": "non-creation-order-text-home", + "pid": 8276, + "started_ms": 1189, + "tab": 3 + }, + { + "actual_hex": "5446382d41332065cc811b5b484233cc811b5b482b434c4f53451b5b48", + "actual_sha256": "39fbb1c5d57e608456124b8c952c152b13aa1d6dae6e8ef678a73fb70426dbc5", + "creation": 3, + "duration_ms": 7, + "expected_hex": "5446382d41332065cc811b5b484233cc811b5b482b434c4f53451b5b48", + "isolation_checked": true, + "nonce": "08ea677636ff22bb", + "phase": "survivor-input-after-non-active-close", + "pid": 8276, + "started_ms": 2771, + "tab": 2 + }, + { + "actual_hex": "5446382d413420c3a91b5b4842341b5b482b4143544956451b5b48", + "actual_sha256": "5b690abd0dcd7b13feff473402c4e56ee5029bb7f69adf7a226976c70abf4c86", + "creation": 4, + "duration_ms": 5, + "expected_hex": "5446382d413420c3a91b5b4842341b5b482b4143544956451b5b48", + "isolation_checked": true, + "nonce": "08ea74ea6ead22fa", + "phase": "survivor-input-after-active-close", + "pid": 13248, + "started_ms": 2808, + "tab": 2 + }, + { + "actual_hex": "4e45571b5b48", + "actual_sha256": "38fd3633bdf05071f3fa8b52dd5d99f315aac4a32c68415f56225944d4d3fe1c", + "creation": 8, + "duration_ms": 5, + "expected_hex": "4e45571b5b48", + "isolation_checked": false, + "nonce": "08ea04e4b4c22d5a", + "phase": "replacement-first-input", + "pid": 19492, + "started_ms": 2922, + "tab": 5 + } + ], + "steps_expected_equal_actual": true, + "background": [ + { + "creation": 0, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d41302041534349491b5b4842301b5b48", + "sequence": 120 + }, + { + "creation": 1, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413120e6b189e5ad971b5b484231e6b1891b5b48", + "sequence": 120 + }, + { + "creation": 2, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413220f09f99821b5b484232f09f99821b5b48", + "sequence": 120 + }, + { + "creation": 3, + "high_water_bytes": 96, + "queued_bytes": 0, + "received_hex": "5446382d41332065cc811b5b484233cc811b5b48", + "sequence": 120 + }, + { + "creation": 4, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413420c3a91b5b4842341b5b48", + "sequence": 120 + }, + { + "creation": 5, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413520e7bb88e7abaf1b5b4842351b5b48", + "sequence": 120 + }, + { + "creation": 6, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413620f09f98801b5b4842361b5b48", + "sequence": 120 + }, + { + "creation": 7, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413720706c61696e1b5b4842371b5b48", + "sequence": 120 + } + ], + "closures": [ + { + "creation": 2, + "elapsed_ms": 43, + "nonce": "08eadc42011f228c", + "page_released": true, + "phase": "non-active-tab", + "pid": 39768, + "tabs_after": 7 + }, + { + "creation": 3, + "elapsed_ms": 30, + "nonce": "08ea677636ff22bb", + "page_released": true, + "phase": "active-tab", + "pid": 8276, + "tabs_after": 6 + }, + { + "creation": 0, + "elapsed_ms": 38, + "nonce": "08eaee5cc32f221f", + "page_released": true, + "phase": "pending-tab", + "pid": 43348, + "tabs_after": 5 + } + ], + "parent_close": { + "cleanup_at_completion": true, + "cleanup_at_quit": true, + "completions": 1, + "sessions": [ + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + } + ], + "status": 0 + }, + "flags": { + "offscreen_only": true, + "physical_keyboard": false, + "native_ime": false, + "clipboard_used": false, + "presented_done": null + } + }, + { + "label": "run2", + "supervisor_status": "passed", + "exit_code": 0, + "job_after": { + "active": 0, + "total": 20, + "terminated": 0 + }, + "presented_done_null": true, + "backend": "cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24", + "grid": [ + 80, + 24 + ], + "sessions_created": 8, + "replacement_created": true, + "live_sessions_at_parent_close": 6, + "switch_order": [ + 4, + 0, + 7, + 2, + 5, + 1, + 6, + 3 + ], + "total_elapsed_ms": 2937, + "session_matrix": [ + { + "creation": 0, + "pid": 16184, + "nonce": "08ea77c02b3b2ed1", + "receiver_file": "08ea77c02b3b2ed1.bin", + "closed": true, + "received_bytes": 20, + "ready_ms": 43 + }, + { + "creation": 1, + "pid": 44756, + "nonce": "08eae7d5595c2f00", + "receiver_file": "08eae7d5595c2f00.bin", + "closed": false, + "received_bytes": 24, + "ready_ms": 102 + }, + { + "creation": 2, + "pid": 32112, + "nonce": "08ea3479c3a12f3e", + "receiver_file": "08ea3479c3a12f3e.bin", + "closed": true, + "received_bytes": 23, + "ready_ms": 160 + }, + { + "creation": 3, + "pid": 42104, + "nonce": "08eaed6b2e0c2f7d", + "receiver_file": "08eaed6b2e0c2f7d.bin", + "closed": true, + "received_bytes": 29, + "ready_ms": 219 + }, + { + "creation": 4, + "pid": 35688, + "nonce": "08eac2749df02fbb", + "receiver_file": "08eac2749df02fbb.bin", + "closed": false, + "received_bytes": 27, + "ready_ms": 283 + }, + { + "creation": 5, + "pid": 27736, + "nonce": "08ea257e38722ffa", + "receiver_file": "08ea257e38722ffa.bin", + "closed": false, + "received_bytes": 21, + "ready_ms": 343 + }, + { + "creation": 6, + "pid": 44244, + "nonce": "08eae5fac8623038", + "receiver_file": "08eae5fac8623038.bin", + "closed": false, + "received_bytes": 19, + "ready_ms": 403 + }, + { + "creation": 7, + "pid": 26552, + "nonce": "08ea2e8048e33077", + "receiver_file": "08ea2e8048e33077.bin", + "closed": false, + "received_bytes": 20, + "ready_ms": 464 + }, + { + "creation": 8, + "pid": 20004, + "nonce": "08ea048b94ed3a0c", + "receiver_file": "08ea048b94ed3a0c.bin", + "closed": false, + "received_bytes": 6, + "ready_ms": 2925 + } + ], + "step_counts": { + "creation-order-trigger-text-home": 8, + "non-creation-order-text-home": 8, + "survivor-input-after-non-active-close": 1, + "survivor-input-after-active-close": 1, + "replacement-first-input": 1 + }, + "steps": [ + { + "actual_hex": "5446382d41302041534349491b5b48", + "actual_sha256": "027467573e90a7ffb5de8787ab44e915a00f0ae260d48e0d264d8ac52fec4f26", + "creation": 0, + "duration_ms": 3, + "expected_hex": "5446382d41302041534349491b5b48", + "isolation_checked": true, + "nonce": "08ea77c02b3b2ed1", + "phase": "creation-order-trigger-text-home", + "pid": 16184, + "started_ms": 477, + "tab": 0 + }, + { + "actual_hex": "5446382d413120e6b189e5ad971b5b48", + "actual_sha256": "39888f6392d86c661b4c0427f140e24d006009d3c65bcac8706ee1ee60940ea2", + "creation": 1, + "duration_ms": 3, + "expected_hex": "5446382d413120e6b189e5ad971b5b48", + "isolation_checked": true, + "nonce": "08eae7d5595c2f00", + "phase": "creation-order-trigger-text-home", + "pid": 44756, + "started_ms": 540, + "tab": 1 + }, + { + "actual_hex": "5446382d413220f09f99821b5b48", + "actual_sha256": "027cbb6c4972fdd916a06e6cd08a9efc1c53d5bedf28ca9db1a054ba3456ca5a", + "creation": 2, + "duration_ms": 3, + "expected_hex": "5446382d413220f09f99821b5b48", + "isolation_checked": true, + "nonce": "08ea3479c3a12f3e", + "phase": "creation-order-trigger-text-home", + "pid": 32112, + "started_ms": 587, + "tab": 2 + }, + { + "actual_hex": "5446382d41332065cc811b5b48", + "actual_sha256": "1c8843663e10273c736f12f0b8d8417cad8ac0bd1efba103b8f8bb448ecf0309", + "creation": 3, + "duration_ms": 4, + "expected_hex": "5446382d41332065cc811b5b48", + "isolation_checked": true, + "nonce": "08eaed6b2e0c2f7d", + "phase": "creation-order-trigger-text-home", + "pid": 42104, + "started_ms": 634, + "tab": 3 + }, + { + "actual_hex": "5446382d413420c3a91b5b48", + "actual_sha256": "573d8b22b581424a4ab6a391b48a5b0776a95893b08e89c91b3b3c2af844ec16", + "creation": 4, + "duration_ms": 4, + "expected_hex": "5446382d413420c3a91b5b48", + "isolation_checked": true, + "nonce": "08eac2749df02fbb", + "phase": "creation-order-trigger-text-home", + "pid": 35688, + "started_ms": 691, + "tab": 4 + }, + { + "actual_hex": "5446382d413520e7bb88e7abaf1b5b48", + "actual_sha256": "453cfe23f21237feda6af7e619fd726d53837b5cc623837bf4f6e0287131c628", + "creation": 5, + "duration_ms": 3, + "expected_hex": "5446382d413520e7bb88e7abaf1b5b48", + "isolation_checked": true, + "nonce": "08ea257e38722ffa", + "phase": "creation-order-trigger-text-home", + "pid": 27736, + "started_ms": 754, + "tab": 5 + }, + { + "actual_hex": "5446382d413620f09f98801b5b48", + "actual_sha256": "1320e092025f4d0916fc65db4573a32a234e6f03dd30fc70fc870d97c3c57160", + "creation": 6, + "duration_ms": 4, + "expected_hex": "5446382d413620f09f98801b5b48", + "isolation_checked": true, + "nonce": "08eae5fac8623038", + "phase": "creation-order-trigger-text-home", + "pid": 44244, + "started_ms": 806, + "tab": 6 + }, + { + "actual_hex": "5446382d413720706c61696e1b5b48", + "actual_sha256": "3dbd0a9640f8d1297044af10d4cf14b5a4826f1bd3caa62bc261a12b7bdf2062", + "creation": 7, + "duration_ms": 4, + "expected_hex": "5446382d413720706c61696e1b5b48", + "isolation_checked": true, + "nonce": "08ea2e8048e33077", + "phase": "creation-order-trigger-text-home", + "pid": 26552, + "started_ms": 867, + "tab": 7 + }, + { + "actual_hex": "5446382d413420c3a91b5b4842341b5b48", + "actual_sha256": "d51f7927ddfa10f0b00c64a2b96c00012924951421cd6e36b1fbb30f79951e11", + "creation": 4, + "duration_ms": 17, + "expected_hex": "5446382d413420c3a91b5b4842341b5b48", + "isolation_checked": true, + "nonce": "08eac2749df02fbb", + "phase": "non-creation-order-text-home", + "pid": 35688, + "started_ms": 931, + "tab": 4 + }, + { + "actual_hex": "5446382d41302041534349491b5b4842301b5b48", + "actual_sha256": "09efa4e3a70c8873195ba6e471e1be545f27114a61b2228ebcc9963a30217fc5", + "creation": 0, + "duration_ms": 28, + "expected_hex": "5446382d41302041534349491b5b4842301b5b48", + "isolation_checked": true, + "nonce": "08ea77c02b3b2ed1", + "phase": "non-creation-order-text-home", + "pid": 16184, + "started_ms": 964, + "tab": 0 + }, + { + "actual_hex": "5446382d413720706c61696e1b5b4842371b5b48", + "actual_sha256": "7c73c27d8e810e17e65c7067683b9970f43f23a66017ef5f8839ab5a81389a48", + "creation": 7, + "duration_ms": 28, + "expected_hex": "5446382d413720706c61696e1b5b4842371b5b48", + "isolation_checked": true, + "nonce": "08ea2e8048e33077", + "phase": "non-creation-order-text-home", + "pid": 26552, + "started_ms": 1005, + "tab": 7 + }, + { + "actual_hex": "5446382d413220f09f99821b5b484232f09f99821b5b48", + "actual_sha256": "a21a1cfb2e7542e1fe2b784df1f0ecea69443cdf740664b9aca729886d92c169", + "creation": 2, + "duration_ms": 30, + "expected_hex": "5446382d413220f09f99821b5b484232f09f99821b5b48", + "isolation_checked": true, + "nonce": "08ea3479c3a12f3e", + "phase": "non-creation-order-text-home", + "pid": 32112, + "started_ms": 1049, + "tab": 2 + }, + { + "actual_hex": "5446382d413520e7bb88e7abaf1b5b4842351b5b48", + "actual_sha256": "10eb9f3682aac873596f4523395da5a5961845c13193eeb3e5941f78196a8049", + "creation": 5, + "duration_ms": 28, + "expected_hex": "5446382d413520e7bb88e7abaf1b5b4842351b5b48", + "isolation_checked": true, + "nonce": "08ea257e38722ffa", + "phase": "non-creation-order-text-home", + "pid": 27736, + "started_ms": 1093, + "tab": 5 + }, + { + "actual_hex": "5446382d413120e6b189e5ad971b5b484231e6b1891b5b48", + "actual_sha256": "325f9dcd529fd60d49fdc3838de5c34e10f0ddb76d0603dc501575d836abc3ef", + "creation": 1, + "duration_ms": 31, + "expected_hex": "5446382d413120e6b189e5ad971b5b484231e6b1891b5b48", + "isolation_checked": true, + "nonce": "08eae7d5595c2f00", + "phase": "non-creation-order-text-home", + "pid": 44756, + "started_ms": 1136, + "tab": 1 + }, + { + "actual_hex": "5446382d413620f09f98801b5b4842361b5b48", + "actual_sha256": "4a376e3035044ee47acf735343b93f1b8e674ecb7891ba0575670ce48c0014bf", + "creation": 6, + "duration_ms": 29, + "expected_hex": "5446382d413620f09f98801b5b4842361b5b48", + "isolation_checked": true, + "nonce": "08eae5fac8623038", + "phase": "non-creation-order-text-home", + "pid": 44244, + "started_ms": 1183, + "tab": 6 + }, + { + "actual_hex": "5446382d41332065cc811b5b484233cc811b5b48", + "actual_sha256": "29c5f5c2cc5bebc12a0d379a435c56b8c8c0bc9613127f3c548aa833f85ca437", + "creation": 3, + "duration_ms": 35, + "expected_hex": "5446382d41332065cc811b5b484233cc811b5b48", + "isolation_checked": true, + "nonce": "08eaed6b2e0c2f7d", + "phase": "non-creation-order-text-home", + "pid": 42104, + "started_ms": 1226, + "tab": 3 + }, + { + "actual_hex": "5446382d41332065cc811b5b484233cc811b5b482b434c4f53451b5b48", + "actual_sha256": "39fbb1c5d57e608456124b8c952c152b13aa1d6dae6e8ef678a73fb70426dbc5", + "creation": 3, + "duration_ms": 4, + "expected_hex": "5446382d41332065cc811b5b484233cc811b5b482b434c4f53451b5b48", + "isolation_checked": true, + "nonce": "08eaed6b2e0c2f7d", + "phase": "survivor-input-after-non-active-close", + "pid": 42104, + "started_ms": 2787, + "tab": 2 + }, + { + "actual_hex": "5446382d413420c3a91b5b4842341b5b482b4143544956451b5b48", + "actual_sha256": "5b690abd0dcd7b13feff473402c4e56ee5029bb7f69adf7a226976c70abf4c86", + "creation": 4, + "duration_ms": 6, + "expected_hex": "5446382d413420c3a91b5b4842341b5b482b4143544956451b5b48", + "isolation_checked": true, + "nonce": "08eac2749df02fbb", + "phase": "survivor-input-after-active-close", + "pid": 35688, + "started_ms": 2819, + "tab": 2 + }, + { + "actual_hex": "4e45571b5b48", + "actual_sha256": "38fd3633bdf05071f3fa8b52dd5d99f315aac4a32c68415f56225944d4d3fe1c", + "creation": 8, + "duration_ms": 12, + "expected_hex": "4e45571b5b48", + "isolation_checked": false, + "nonce": "08ea048b94ed3a0c", + "phase": "replacement-first-input", + "pid": 20004, + "started_ms": 2925, + "tab": 5 + } + ], + "steps_expected_equal_actual": true, + "background": [ + { + "creation": 0, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d41302041534349491b5b4842301b5b48", + "sequence": 120 + }, + { + "creation": 1, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413120e6b189e5ad971b5b484231e6b1891b5b48", + "sequence": 120 + }, + { + "creation": 2, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413220f09f99821b5b484232f09f99821b5b48", + "sequence": 120 + }, + { + "creation": 3, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d41332065cc811b5b484233cc811b5b48", + "sequence": 120 + }, + { + "creation": 4, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413420c3a91b5b4842341b5b48", + "sequence": 120 + }, + { + "creation": 5, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413520e7bb88e7abaf1b5b4842351b5b48", + "sequence": 120 + }, + { + "creation": 6, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413620f09f98801b5b4842361b5b48", + "sequence": 120 + }, + { + "creation": 7, + "high_water_bytes": 98, + "queued_bytes": 0, + "received_hex": "5446382d413720706c61696e1b5b4842371b5b48", + "sequence": 120 + } + ], + "closures": [ + { + "creation": 2, + "elapsed_ms": 43, + "nonce": "08ea3479c3a12f3e", + "page_released": true, + "phase": "non-active-tab", + "pid": 32112, + "tabs_after": 7 + }, + { + "creation": 3, + "elapsed_ms": 27, + "nonce": "08eaed6b2e0c2f7d", + "page_released": true, + "phase": "active-tab", + "pid": 42104, + "tabs_after": 6 + }, + { + "creation": 0, + "elapsed_ms": 27, + "nonce": "08ea77c02b3b2ed1", + "page_released": true, + "phase": "pending-tab", + "pid": 16184, + "tabs_after": 5 + } + ], + "parent_close": { + "cleanup_at_completion": true, + "cleanup_at_quit": true, + "completions": 1, + "sessions": [ + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + }, + { + "active_processes_after": 0, + "cleanup_verified": true, + "error": "", + "exit_code": 91, + "finished": true, + "natural_exit": false, + "output_eof": true, + "verified": true + } + ], + "status": 0 + }, + "flags": { + "offscreen_only": true, + "physical_keyboard": false, + "native_ime": false, + "clipboard_used": false, + "presented_done": null + } + } + ], + "qt_formal_path_ctest": { + "total": 12, + "passed": 12, + "failed": 0, + "seconds": 23.23 + }, + "core_ctest": { + "total": 42, + "passed": 42, + "failed": 0, + "seconds": 23.4 + }, + "python_core": { + "unicode_tests": 15, + "core_tests": 16, + "log": "Ran 31 tests in 7.450s" + }, + "determinism": { + "both_runs_passed": true, + "same_phase_counts": true, + "same_session_count_and_states": true, + "pid_values_differ_between_runs": true, + "nonce_values_differ_between_runs": true + } + }, + "source_hashes": { + "CMakeLists.txt": "997d314a92de0b08b052ac946433545d980c746e9004acece02496e4aa527eb3", + ".github/workflows/qt-cpp.yml": "039802fc64f72a973479b84a6bc24ee03b1cccdfa21ac70024753cd8df22c183", + "src/qt/eight_session_test.cpp": "60b475983259fd6d73d819973519912cfa09e2ad54874cdd4b594415102c2d6c", + "experiments/shell/eight_session_client.cpp": "922bb556bb51213740a70f2ff65434a174dcd41e579d301abfbadcf9b35bf5dc", + "src/qt/README.md": "5e604f8e3f393f2b6f8a0d72a4ca5e59a5e7cb699b787bb460cd7bc9f82c7f0b", + "docs/EVIDENCE.md": "f8c26d42dde23ab3497dee91375b77104e28972d859ff32b56987706389acc89", + "docs/FEATURES.md": "db84f1ec837739af1e6bab48370fa81abe5359a14491a2896405a9d9752e70e4", + "experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md": "d751422601d036b1b46d446b3d9a7146e498ddbdae2ece9f307207c8d274dcae" + }, + "artifact_hashes": { + "build/qt-cpp/qt-eight-session-bin/Release/qt_eight_session_test.exe": { + "bytes": 372736, + "sha256": "e3a28487917174c3443080c45c4cc27302c40317085dceb7e4d4168d9bf1ab9d" + }, + "build/qt-cpp/bin/Release/shell_eight_session_client.exe": { + "bytes": 34816, + "sha256": "ba8907216ad88f6b1ddf26e99edff61e4802c6fb5ff1374e429e68847c37d53f" + }, + "build/qt-cpp/qt-bin/Release/termforge_qt_shell.exe": { + "bytes": 284672, + "sha256": "2b70a76059ed168fccdd7412df5fde1779887a346458df99de22ef56811e734d" + }, + "build/qt-cpp/qt-eight-session-bin/Release/assets/fonts/RobotoMono-Regular.ttf": { + "bytes": 125748, + "sha256": "af0bff7599c3df3831755c16e39b3c496df74b8c8d8a1161b14dc8461be17cb4" + }, + "build/qt-cpp/qt-eight-session-bin/Release/conpty-backend/ConPTY.dll": { + "bytes": 119608, + "sha256": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a" + } + }, + "implementation_sources_match_recorded_commit": true, + "limitations": [ + "Offscreen synthetic Qt events only: not a physical keyboard, native IME, real system clipboard, real DPI or presented frame (presented_done=null).", + "Fixed eight sessions plus one replacement at 80x24, one window, one monitor; no split panes, more than eight sessions, other shells, long-running or performance workload.", + "SessionTabs has no session cap today; the ninth-session behavior, a maximum session count and any resource policy are not defined, tested or approved by this slice.", + "The bounded background block is a synthetic 120-line fixture protocol, not a throughput or latency measurement.", + "The Win32 R1 eight-session workflow, real Copy->Paste->IME combination and #155 IME acceptance remain separate; #30/#155/#7 stay open." + ] +}