diff --git a/CMakeLists.txt b/CMakeLists.txt index d5608110..e864d422 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,6 +107,10 @@ if(WIN32) add_executable(conpty_ctrl_c_probe experiments/conpty/ctrl_c_probe.cpp) termforge_warnings(conpty_ctrl_c_probe) target_compile_definitions(conpty_ctrl_c_probe PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) + add_executable(conpty_margin_probe experiments/conpty/margin_probe.cpp) + termforge_warnings(conpty_margin_probe) + target_compile_definitions(conpty_margin_probe PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) + target_link_libraries(conpty_margin_probe PRIVATE user32 bcrypt) add_executable(presentation_probe experiments/presentation/presentation.cpp) termforge_warnings(presentation_probe) target_compile_definitions(presentation_probe PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN UNICODE _UNICODE) @@ -307,6 +311,7 @@ if(BUILD_TESTING) termforge_add_python_test(receive -m unittest experiments.receive.test_receive -v) termforge_add_python_test(conpty -m unittest experiments.conpty.test_conpty -v) termforge_add_python_test(conpty_ctrl_c -m unittest experiments.conpty.test_ctrl_c -v) + termforge_add_python_test(conpty_margin -m unittest experiments.conpty.test_margin_run -v) termforge_add_python_test(presentation -m unittest experiments.presentation.test_presentation -v) termforge_add_python_test(shell -m unittest experiments.shell.test_shell -v) termforge_add_python_test(selection_bytes -m unittest experiments.shell.test_selection_bytes -v) diff --git a/README.md b/README.md index 700833b0..114f744b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An independent, fully open-source terminal project targeting WindTerm 2.7.0 feature, interaction and performance parity. Not affiliated with WindTerm or KingToolbox. -当前是有限 Windows 本地终端原型,**R1 基础文字交互已按限定合同验收**:真实 cmd/ConPTY、两个区域共最多8个会话、有界历史/选择复制/粘贴/查找、固定Microsoft拼音,以及同次八会话完整文字流程和有限资源回收均有[重复运行与独立审计](experiments/shell/results/2026-10-24-r1-system-workflow/validation.json)。接下来进入R2的可靠本地终端准备;常用TUI、配置恢复、SSH/SFTP和日常开发包尚未完成,UI也尚未全面等价。没有与WindTerm性能等价的结论。`TermForge` 是暂名。 +当前是有限 Windows 本地终端原型,**R1 基础文字交互已按限定合同验收**:真实 cmd/ConPTY、两个区域共最多8个会话、有界历史/选择复制/粘贴/查找、固定Microsoft拼音,以及同次八会话完整文字流程和有限资源回收均有[重复运行与独立审计](experiments/shell/results/2026-10-24-r1-system-workflow/validation.json)。当前推进 R2,显式候选已验证固定 Vim 工作流,配置与工作区恢复已有受限验收;完整终端模式、复杂文字、真实不同 DPI 和日常开发包仍待完成,见 [R2 出口检查表](docs/milestones/R2-local-terminal.md)。SSH/SFTP 留在 R3,UI也尚未全面等价。没有与WindTerm性能等价的结论。`TermForge` 是暂名。 ## 开始使用 @@ -25,7 +25,7 @@ An independent, fully open-source terminal project targeting WindTerm 2.7.0 feat | 已经做了什么、还缺什么 | [当前能力与目标矩阵](docs/FEATURES.md) | | 最终目标与五个路标(从远到近) | [总体计划](docs/PLAN.md) | | 官方发行平台、架构与声明的最低系统边界 | [2.7.0 平台范围台账](research/windterm-2.7.0-platform-scope.md) | -| 下一路标的工作包与前置 | [R2 可靠本地终端](docs/PLAN.md#r2可靠本地终端持久化与可发布开发版) | +| 当前路标的验收、剩余工作与前置 | [R2 出口检查表](docs/milestones/R2-local-terminal.md) · [执行项](docs/TRACKING.md) | | 已验收的基础文字交互合同 | [R1 基础文字交互](docs/milestones/R1-local-text.md) | | GitHub milestones、issues 和执行顺序 | [任务跟踪](docs/TRACKING.md) | | 结论依据、复现命令和全部文档 | [证据索引](docs/EVIDENCE.md) · [文档导航](docs/README.md) | diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index fa7e8d69..e12ea691 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -8,11 +8,13 @@ | 结论 | 实现/命令入口 | 已有结果与限制 | |---|---|---| +| 原 10 秒纯夹具超时的阶段保留(#40 未关闭) | [限定合同](research/inventory-timeout-checkpoints.md)、[正常与受控超时对照](../research/ime/results/2026-10-26-inventory-timeout-checkpoints/validation.json) | GO 后即使最终 JSON 尚未返回,也可读取有界白名单阶段;实际同一 Try AST 在 ended 回调挂起仍抛原 TimeoutExpired,保留 progress-ended、回收与 Job0,不补造 failed/complete。旧父 runner 对同生产端丢观察的反事实成立;54 项原套件通过不回填历史 CI 的未知阶段或原因 | +| ConPTY 系统/固定官方后端的同源 margin A/B(#41、#47 未关闭) | [合同与第一方来源](research/conpty-margin-backends.md)、[固定 175B 与 16 case 结果](../experiments/conpty/results/2026-10-26-margin-backends/validation.json) | 系统后端末端 REGION_CIXED;官方固定 1.25 同包双文件保留 DECSTBM,候选末端恢复正确。禁用 VT processing 路线被合法窄屏反例拒绝;源 CHAR_INFO 仍错,末端正确不等于原生 API 正确。全部 child37/EOF109/Job0;这里只建立后端可行路径,默认产品后端未改,实际 GUI、resize、粘贴及 R1 整合另由 #47 验收 | | 拒绝负例的读取前停止(#46 夹具修正) | [原记录、反事实及 fresh 验证](../experiments/shell/results/2026-10-26-receiver-stop/validation.json)、[公开空数据夹具](../experiments/shell/fixtures/rejected-order-before-read.json) | F2 预期拒绝可先于首读完成停止;旧 oracle 误要求至少读过并成功取消一次。仅拒绝路径接受已证明的零读/空数据/停止并 join/无错误状态,正常读取门槛保留。原记录及 fresh 再现的同一零读字节在旧判定失败、新判定通过;12 项纯测试含字段反例,原模块14/14通过,native EXE身份未变且所有Job0。原完整回归红保留,#44 的退出根因仍未知 | | 候选 GUI 合法空标题(#45) | [真实 GUI 前后对照](../experiments/shell/results/2026-10-26-empty-title/validation.json)、[转换入口](../experiments/presentation/render.hpp)、[持续回归](../experiments/shell/candidate_title_test.cpp) | 自有 console 实际发出完整空 OSC 标题后,旧转换抛出 invalid text;空 UTF-8 合法转换为空 UTF-16 后,两 fresh GUI 存活且正常回收。混合 Unicode 与四类非法非空反例保留,旧二进制反事实仍失败。首次未观察到空 OSC 的尝试不算复现;旧候选 normal-1 的异常调用点和载荷未知,不能由新复现回填其根因;不是完整 R2 或 WindTerm 等价验收 | | 工作区恢复的运输完成端点(#38 夹具修正) | [限定合同](research/workspace-restore-transport-oracle.md)、[原失败及默认/候选验证](../experiments/shell/results/2026-10-26-workspace-transport-oracle/validation.json) | 完整 OSC 标题重复标记使旧 rfind 取错位置;真实 ConPTY 另有完整输出后以 column-one CUP 返回提示符的分支。独立判定保留 UTF-8、排除标题和命令回显,32 条断言及默认/候选原场景均通过;12 个样本的哈希、三会话关闭、Job0 与 desktop 删除另经复核。原期限/负载/恢复断言保留,原失败不回填;这是测试端点修正,不是产品恢复故障修复 | | 持续 cmd 输出与原始 Ctrl-C 回归(#36 未关闭) | [受监督入口](../experiments/conpty/README.md#持续-cmd-输出与-ctrlc-独立回归)、[三次 fresh 公开记录及本地字节复核](../experiments/conpty/results/2026-10-26-ctrl-c-retest/validation.json) | 完整一百万次循环、一次洪流 ETX、立即后续命令、原五秒期限及退出 37;三次原始输入/输出哈希、独立标记与 Job0 已本地重算。cmd 固定版本 9278、conhost 9444;旧失败与本次成功原因未知,未修改产品 Ctrl-C 路径。原始运输仅本地保留,公开哈希不能单独复核全部原始断言;未验原生控制处理函数、独立构建或性能,不能关闭 #36 | -| controller B 正常结束后 cmd 退出未验收(#44) | [原失败脱敏记录](../experiments/shell/results/2026-10-26-controller-exit/validation.json)、[待补诊断及关闭合同](https://github.com/JavaZeroo/termforge/issues/44) | child 精确接收32字节、正常退出0且已join,内外Job0;cmd仅记录close后的0xC000013A,wait_exit布尔和close前状态未知。随后同合同两轮通过不能覆盖原失败;probe未链接候选适配器,根因与候选关联均未证明,继续阻塞#38 | +| controller B 正常结束后 cmd 退出未验收(#44) | [原失败脱敏记录](../experiments/shell/results/2026-10-26-controller-exit/validation.json)、[新退出诊断及有界重放](../experiments/shell/results/2026-10-26-controller-exit-observation/validation.json)、[关闭合同](https://github.com/JavaZeroo/termforge/issues/44) | 原件中 child 精确接收32字节、正常退出0且已join,内外Job0;cmd仅记录close后的0xC000013A,原 wait/close前状态仍未知。新诊断分列实际写入、原生wait结果/错误、close前码及清理;失败先保留固定scalar白名单。基线10轮、新观察50轮未复现;最终两个配置的workflow各16项通过、各两fresh控制轮,不能覆盖原失败或关闭#44。probe不链接候选适配器,未证明候选关联或产品修复,继续阻塞#38 | | 候选文字观测与回显端点(#38 夹具修正) | [同源码前后对照及反事实](../experiments/shell/results/2026-10-26-native-observation/validation.json)、[标准文本投影](../experiments/core/text_projection.hpp) | 候选零宽spacer原被原生观测拼成空格,四类场景稳定卡在中文行;统一project_line后原四类各3fresh通过。运行观测另捕获写完但回显未到时先全选,迟到12字节使选区合法失效;原同一次五秒等待加严为完整实时回显后选择,5fresh/10个窗口通过,原assert保留。原no-echo对照的间歇红与后续绿均保留,因果解释按时序和选区合同推断;不宣称产品选择策略修复、全候选等价或完整R2验收 | | R1完整文字工作流与总出口(限定) | [同次系统组合及独立审计](../experiments/shell/results/2026-10-24-r1-system-workflow/validation.json)、[同源码既有回归](../experiments/shell/results/2026-10-24-r1-system-workflow/regression.json)、[协议与复现](../experiments/shell/WORKFLOW.md) | `ce9b24d`;[主CI 40045678550](https://github.com/JavaZeroo/termforge/actions/runs/40045678550) Windows38/Ubuntu24,本地38/38且内部8项skip另列;[显式verify 40045686776](https://github.com/JavaZeroo/termforge/actions/runs/40045686776)实际八会话两fresh,各26主点/13系统点/28动作、409/512观测、18实例回收。独立核191文件、58源码/5二进制身份、B原件51→57→58及目标隔离、两次actual revision绑定滚动、8夹测/19阶段窗/5短驻留,21真实产物反例全拒、两个最终帧已查看;同源码E2两轮与旧12组24轮/五组IME另审。结合R1-01–07满足限定总出口;无EXE独立重哈希/源到二进制证明,main内部skip未知,presented_done=null,不构成日常版、长期无泄漏或WindTerm性能等价 | | R1 八会话 E2 数据工作流与运行观测 | [协议/自动入口](../experiments/shell/WORKFLOW.md)、[固定源码审计](../experiments/shell/results/2026-10-24-r1-eight-sessions/validation.json)、[既有能力复验](../experiments/shell/results/2026-10-24-r1-eight-sessions/regression.json) | 固定源码与独立复核:`72fe231` / [主CI](https://github.com/JavaZeroo/termforge/actions/runs/40040980384),本地及同源码hosted各两轮。本地35/35(内部8项skip),主CI Windows35/Ubuntu21;[同源码真实IME复验](https://github.com/JavaZeroo/termforge/actions/runs/40040994121)为五模式28检查点。验收范围为每轮26检查点、18会话身份、235/256有界观测,覆盖五路洪流、阅读/Find/输入/缩放/淘汰、5次8→6→8和合成粘贴取消/关闭;实际窗口创建、字节/日志、worker/Job及非输入desktop回收分别复核。此历史data-only批次不等于同次真实CF/IME;完整组合见本表最新总验收 | diff --git a/docs/PLAN.md b/docs/PLAN.md index bfb2446d..ca1ccbcd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -115,7 +115,7 @@ **硬门槛:** [ADR-0003](adr/0003-core-reference-and-reuse.md) 的候选构建与核心路线重审不能因 UI 已投入开发而跳过。[#20](https://github.com/JavaZeroo/termforge/issues/20) 分两个节点推进:R1 期间可并行检查候选版本/许可、构建、共同子集和历史投影适配的可行性;R2 扩大终端模式与常用 TUI 的实现投入前,完成实际候选与共同轨迹比较并作路线决定。核心与 GUI 的产品路线决定须使用 [#19](https://github.com/JavaZeroo/termforge/issues/19) 的平台切片 [#23](https://github.com/JavaZeroo/termforge/issues/23) 已冻结的平台/架构/最低系统边界,明确候选的支持缺口;#19 的其余全量功能取证不阻塞候选构建或本决策。不要求先自研补齐候选的全部功能,也不等待正式 WindTerm 性能基线。产品架构需要后续明确决策;现有局部 ADR 和原型验证不自动成为完整产品架构的结论。 -**出口条件:** 固定本地真实任务、TUI、文字输入、配置恢复、真实 DPI 和干净环境发布全部通过;实际支持与已知差异进入功能矩阵。仅普通命令可运行不等同于可靠本地终端。 +**出口条件:** 固定本地真实任务、TUI、文字输入、配置恢复、真实 DPI 和干净环境发布全部通过;实际支持与已知差异进入功能矩阵。详细验收、证据边界与近期执行顺序见 [R2 出口检查表](milestones/R2-local-terminal.md)。仅普通命令可运行不等同于可靠本地终端。 ## R1:基础文字交互闭环 diff --git a/docs/README.md b/docs/README.md index bec5feab..1ed5942a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ |---|---| | 已经能做什么,离目标差在哪里? | [功能与差异矩阵](FEATURES.md) | | 最终目标是什么,如何分阶段完成? | [五个路标,远到近](PLAN.md) | -| 下一阶段具体做哪些事情? | [R2工作包与前置](PLAN.md)、[当前执行项](TRACKING.md) | +| R2 还需完成什么、怎样验收? | [R2 出口检查表](milestones/R2-local-terminal.md)、[当前执行项](TRACKING.md) | | 已验收的基础文字交互合同是什么? | [R1 基础文字交互](milestones/R1-local-text.md) | | 字体、网格和真实显示如何验收? | [R2 字体与显示合同](milestones/R2-font-display.md) | | 哪项可开工,谁阻塞谁? | [GitHub 跟踪索引](TRACKING.md) | diff --git a/docs/TRACKING.md b/docs/TRACKING.md index 578a6b81..e668cea0 100644 --- a/docs/TRACKING.md +++ b/docs/TRACKING.md @@ -35,7 +35,7 @@ R1已按[限定合同](milestones/R1-local-text.md)验收,[完整组合证据] ## R2执行切片 -以下覆盖[总项#7](https://github.com/JavaZeroo/termforge/issues/7)的全部工作包;详细范围、验收清单和实时状态只在对应 issue 维护。原生 sub-issue / blocked-by 是依赖关系的权威记录。 +以下覆盖[总项#7](https://github.com/JavaZeroo/termforge/issues/7)的全部工作包;出口与联合验收见 [R2 检查表](milestones/R2-local-terminal.md),单项范围和实时状态在对应 issue 维护。原生 sub-issue / blocked-by 是依赖关系的权威记录。 | 切片 | GitHub | 可验收交付 | 真实前置 | |---|---|---|---| @@ -56,6 +56,7 @@ R1已按[限定合同](milestones/R1-local-text.md)验收,[完整组合证据] | Windows 回归 | [#38](https://github.com/JavaZeroo/termforge/issues/38) | 定位工作区、交互及 CI 的真实失败 | 无;#35 的出口依赖,不能以局部几何结果代替修复 | | Windows CI 回归 | [#40](https://github.com/JavaZeroo/termforge/issues/40) | 复核 `ime_inventory` 原 10 秒超时根因,保留 AST/最终进度与 OwnedJob 回收证据 | 无;作为 #38 的子项阻塞 #38,不能用本机或单次 CI 通过冒充修复 | | ConPTY margin 差异 | [#41](https://github.com/JavaZeroo/termforge/issues/41) | 裁决宿主/传输的每缓冲区 margin,保留原场景和独立实现证据 | #26;作为 #27 的子项阻塞 #27,不能以模型切片或差异观察代替产品验收 | +| 显式官方 ConPTY 实验后端 | [#47](https://github.com/JavaZeroo/termforge/issues/47) | 固定同包 DLL/EXE 来源与身份,通过同一 Backend 验证实际 Session、GUI 和回收;不改变默认系统后端 | #41 的实现子项;仍需处理原生 API 与末端语义、resize、粘贴和完整工作流差异,不代表 #34 产品决定或 R2 出口 | | More 尾部 ECH 与捕获时序 | [#42](https://github.com/JavaZeroo/termforge/issues/42) | [限定合同](research/more-capture-ech.md):默认 ECH、完整 More 提示、q/退出/真实 EOF;候选 pending-wrap 差异保留给 #27 | 无;#38 的子项,与 margin 修复分开;完整原始输入和旧失败保留,不静默降级为 match | | controller 正常退出观测缺口 | [#44](https://github.com/JavaZeroo/termforge/issues/44) | 记录 wait_exit 与 close 前 cmd 状态,定位真实 child→cmd/close 转换 | 无;#38 的子项与出口阻塞;后续同合同重跑成功不覆盖原失败 | | 候选 GUI 合法空标题 | [#45](https://github.com/JavaZeroo/termforge/issues/45) | 修正空 UTF-8 转换,使用真实 console 标题事件验证 GUI 存活,并保留非法非空输入拒绝 | 无;#38 的子项;新最小复现与旧 normal-1 未定位异常分列 | diff --git a/docs/milestones/R2-local-terminal.md b/docs/milestones/R2-local-terminal.md new file mode 100644 index 00000000..3aa84fe1 --- /dev/null +++ b/docs/milestones/R2-local-terminal.md @@ -0,0 +1,38 @@ +# R2 本地终端与开发版出口检查表 + +本文件细化 [PLAN 的 R2 出口](../PLAN.md#r2可靠本地终端持久化与可发布开发版),不改变原范围。当前能力只在 [FEATURES](../FEATURES.md) 维护,执行状态与阻塞以 [总项 #7](https://github.com/JavaZeroo/termforge/issues/7) 及原生子项为准;这里不按关闭数量计算完成率,也不累计开发批次。 + +用户结果:在明确支持的 Windows 环境中,用固定本地 shell、分页器和编辑器完成连续工作;复杂文字、输入和会话互相一致;重启后恢复已声明的配置;最终下载包能从公开源码复建。 + +## 必须闭合的验收门槛 + +| 门槛与责任项 | 验收必须证明什么 | 不能代替验收的结果 | +|---|---|---| +| 回归可靠性:#36、#38(含 #40、#44) | 持续输出中断、controller 子进程到 shell 的正常退出、原 10 秒纯夹具失败路径均有明确阶段、字节、退出和自有 Job 回收证据;修复对应真实失败,最终声明配置的完整回归通过 | 多次重跑绿不能回填旧失败原因;close 后的退出码不能冒充自然退出;增加超时或跳过不能解除阻塞 | +| 终端模式:#27、#41 | 候选核心主/备用屏、各自滚动区域、origin/wrap、光标/样式、历史隔离及 resize 交叉符合固定合同;真实 ConPTY 路径保留实现这些语义所需的信息 | 独立核心 feed 正确不能补足宿主已经丢失的信息;不能猜测补发原运输中不存在的 VT,或用“已知平台差异”自行关闭功能缺口 | +| resize 与真实 TUI:#29、#28 | 逻辑/物理行、宽字符、历史与淘汰有明确策略;窗口、核心和真实子进程尺寸一致;固定应用逐一完成进入、操作、resize、退出、主屏恢复;回复、输入模式和 bracketed paste 字节经独立预期验证 | 离线回放、单独 Vim 成功或默认核心通过,不能代表整个候选产品矩阵;未声明应用不计完成 | +| 复杂文字与输入:#30、#39 | 首批 ZWJ/keycap 等样本有明确聚类、宽度、光标和选择合同,覆盖分块/样式/resize/历史边界;原始标量、实际 glyph、复制和子进程接收分别核对;固定 IME 与八会话回归保留 | 原文没丢、glyph 非零或逐标量宽度正确,不能证明字素聚类成立;未知 WindTerm 行为继续明确保留 | +| 真实显示:#31 | 按[字体/显示合同](R2-font-display.md)记录实际不同 DPI、真实跨屏、焦点、IME/Find/rename、命中、隐藏会话及子进程尺寸;环境身份和重复结果可核对 | 相同 96 DPI 的两台显示器、受控消息、DIB 或实际提交时刻不能代替不同 DPI 或实际上屏证据;不得修改个人显示设置制造验收 | +| 产品架构:#34 | 基于上述实际工作流与 #23/#26、已验收的 #32/#33,重审 ADR-0004;明确产品核心选择、GUI/平台边界、历史/快照、传输/呈现、背压/取消、依赖升级与维护策略 | 原型已采用某技术或候选已有接口,不构成最终产品选择;未测性能仍保持未知 | +| 开发版交付:#35 | 最终下载产物通过干净构建、依赖/字体许可和清单审计、解压运行、shell/TUI/文字/配置升级/真实显示工作流;按预先冻结的负载和资源合同完成重复会话与持续运行,逐项核对本表 | Alpha 1、源码构建成功、短驻留或某一 CI 通过,不代表 R2 日常开发包、长期稳定或 WindTerm 性能等价 | + +#32 本地 shell/分屏标签、#33 会话与配置恢复已有各自限定合同;最终集成仍须复验这些合同。R2 不扩大为 SSH/SFTP、任意嵌套分屏、完整 WindTerm UI 或跨平台客户端;这些任务继续按 PLAN 的对应路标推进。 + +## 最近的工作顺序 + +前三项不互相依赖,可在独立工作树并行诊断;每项形成独立可审查的 PR。遇到暂时不能复现的历史问题,提交可定位性改进时明确保留原问题,不阻塞其他独立工作。 + +1. **#44:区分自然退出与清理。** 在原退出期限内记录发送结果、wait_exit 布尔、close 前状态及 close 后结果;先用受监督原场景复现,再比较子进程结束、shell 返回与输入交接的时序。必要握手须证明作用于同一类失败,不能靠额外等待预算取绿。 +2. **#40:把超时落到具体阶段。** 分别记录进程启动、AST、被测失败路径和最终进度;即使原 10 秒到期也保留已完成阶段及回收。固定源码的本机与 CI 证据分列;仅诊断改进不能关闭历史根因。 +3. **#41:确认后端能否保留必要信息。** 先重放原 source/原生控制台/transport,再核对受支持的模式或后端;每种方案记录 API、版本、许可、部署边界和反例。若信息不可恢复,明确后端/上游工作,而不是在呈现层猜补状态。 +4. **合并终端行为验收。** #41 的可行路径明确后补齐 #27 的 origin/wrap、区域与 resize;与 #29 联合回归后,由 #28 验收固定 TUI、回复、应用键盘和粘贴。共同内容可提前做独立实现和测试,联合出口仍按依赖验收。 +5. **复杂文字与真实显示并行。** #39 先冻结可维护的流式分段合同及依赖,再实现 #30 的完整输入/选择流程。#31 独立准备实际不同 DPI 的受控测试环境;环境缺口保持开放。 +6. **完成架构与开发包。** #34 使用前述结果作决定;#35 在最终发行物上执行全部合同和持续运行。运行前冻结时长、会话数、负载、动作、采样与资源预算,不根据结果反填门槛。 + +## 证据与完成规则 + +- 每个结果保留源码/二进制、系统/后端、输入/负载身份,以及实际通过、失败和跳过;不同源码或补充运行不拼成一次“全绿”。 +- 控制台、模型、DWrite 布局、DIB、窗口提交和实际呈现各自留证;未观测端点保持 unknown/null。 +- 只公开经审核的合成输入和脱敏摘要;运输原件、用户路径、桌面身份等按现有来源规则处理。真实原生会话的宿主和子进程须在自有 Job 中启动、超时和回收;离线模型回放另列实际期限与监督范围,不能由进程 timeout 推断 Job 回收。 +- 单项完成更新其 issue、FEATURES 与 EVIDENCE;原失败仍保留。若真实平台能力迫使改变合同,先明确提出范围/架构决定,不能用新合同证明旧合同已完成。 +- #7 由 #35 对所有出口逐项核对后结项;正式 WindTerm 性能基线继续独立推进,不由本表推断已经等价。 diff --git a/docs/research/conpty-margin-backends.md b/docs/research/conpty-margin-backends.md new file mode 100644 index 00000000..4337de22 --- /dev/null +++ b/docs/research/conpty-margin-backends.md @@ -0,0 +1,49 @@ +# ConPTY 每屏 margin:源控制台与末端 VT 的边界 + +[#41](https://github.com/JavaZeroo/termforge/issues/41) 的固定源仍是 175 字节,SHA-256 `01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59`:主屏设置 `2;3r`,备用屏设置 `4;5r`,`1049l` 后在主屏滚动。**本机系统 ConPTY 的源控制台已没有恢复主屏 margin;固定官方 side-by-side ConPTY 则能把原 DECSTBM 交给末端核心,使末端恢复正确。** 这是继续整合的可行路径,不是已完成的产品修复。 + +## 三个不同检查点 + +- **应用写出的源 VT**:不修改原 fixture、不补发恢复命令、不改预期。 +- **源控制台 CHAR_INFO**:四阶段独立读取文字、属性、光标,保留原生 API 实际结果。 +- **ConPTY 输出与末端**:持续读到 EOF、正常退出后关闭;用固定候选核心处理实际 transport,再核对精确末屏。 + +旧系统后端的 `REGION_CIXED` 同时出现在源控制台和末端,候选仅忠实显示收到的内容。重新修复候选的每缓冲区 margin 无法还原 transport 已丢失的源命令。Microsoft [Scrolling Region 文档](https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#scrolling-region) 声明每个缓冲区维护自身区域;这一声明不能替代具体版本的运行结果。 + +## 同源 A/B 与证伪 + +同一探针、源字节和四阶段 capture,仅改变后端、输出 mode、合法网格大小。每个后端运行 `vt-on`/`vt-off` × `80×24`/`12×7`,两次 fresh 矩阵均完成;所有成功探针都要求 child 自然退出 37、管道 EOF 109、内外自有 Job 归零、无强制进程或读线程取消。结果与源码身份见[白名单记录](../../experiments/conpty/results/2026-10-26-margin-backends/validation.json)。 + +| 分支及可证伪预测 | 观察 | 裁决 | +|---|---|---| +| 系统标准后端,源控制台已丢 margin,末端也应错误 | 两尺寸 `vt-on` 均出现 `REGION_CIXED`,原 DECSTBM 未保留 | 不是末端绘制或候选状态恢复造成 | +| 关闭 `ENABLE_VIRTUAL_TERMINAL_PROCESSING` 可否当 raw 输出后端 | 80×24 的末端表面正确,但原生格已把 ESC 当字面内容;12×7 丢失 `BOTTOM_FIXED` 和完整结束标记 | 拒绝;不是可维护的 raw 路径 | +| 旧 passthrough flag 是否仍是受支持路径 | 第一方已删除该实现;当前头文件将 `0x08` 用作字素宽度策略 | 拒绝旧 flag,未在产品中试用或启用 | +| 固定官方新 ConPTY 可否保留源 DECSTBM | 两尺寸 `vt-on` 均保留 `2;3r`/`4;5r`;候选末端恢复 `REGION_C`、`REGION_D`、`BOTTOM_FIXED` | 继续实验整合,不能宣称 native API 状态也已修复 | + +`SetConsoleMode` 控制应用所在的 console buffer;它不是外部宿主的通用旁路协议。其[官方合同](https://learn.microsoft.com/en-us/windows/console/setconsolemode)还明确 mode 属于各 buffer。关闭处理的局部成功不能证明依赖原生 console API、readback 或会重新设置 mode 的 shell/TUI 也正确。 + +## 为什么新后端不同 + +Microsoft 在 [PR #16954](https://github.com/microsoft/terminal/pull/16954)(合入 `2050416997b237361e0c5979bb1cad88d0544673`)于 2024-03-28 删除旧 VT passthrough,原因包括 resize 不同步等不足。[固定 1.24 头文件](https://github.com/microsoft/terminal/blob/7ff11ee107c984e236960ec948b450ccbc10187b/src/winconpty/winconpty.h#L50-L59) 的 `0x08` 是 `PSEUDOCONSOLE_GLYPH_WIDTH_GRAPHEMES`。不要照搬过时的 `PSEUDOCONSOLE_PASSTHROUGH_MODE=0x8`。 + +新的 [PR #17510](https://github.com/microsoft/terminal/pull/17510)(`450eec48de252a3a8d270bade847755ecbeb5a75`)将 Console API 的输出同步转为 VT,并把应用 VT 交给末端;它也明确记录 `ENABLE_VIRTUAL_TERMINAL_PROCESSING` 开启、`DISABLE_NEWLINE_AUTO_RETURN` 关闭时的 LF→CRLF。实际 transport 符合这个限制,**不是源字节完全原样转发**。本轮不引用其性能数字作为 TermForge 性能结果。 + +固定包是 Microsoft 发布的 [Microsoft.Windows.Console.ConPTY 1.25.260930003](https://www.nuget.org/packages/Microsoft.Windows.Console.ConPTY/1.25.260930003),对应 [Terminal v1.25.2733.0](https://github.com/microsoft/terminal/releases/tag/v1.25.2733.0)、源码提交 `dc8ae365847d41f62d1ebca93815bb00959bc12f`,MIT。下载对象 1,750,533 字节,SHA-256 `6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03`;只提取同包 x64 `conpty.dll`/`OpenConsole.exe`,两个文件的 Authenticode 均为 `Valid`,文件身份在记录中保留。没有安装包、修改系统组件或替换默认后端。 + +## 复现与剩余工作 + +按根构建指南编译 `conpty_margin_probe`,使用已有显式候选 trace 路径及全新的输出目录: + +```powershell +cmake --build build --config Release --target conpty_margin_probe +python -m experiments.conpty.margin_run --candidate-trace --output local/margin-system --require-margins +python -m experiments.conpty.margin_run --backend /conpty.dll --candidate-trace --output local/margin-official --require-margins +ctest --test-dir build -C Release -R '^conpty_margin$' --output-on-failure +``` + +本机系统命令返回 1,官方固定后端返回 0;返回值只约束 `vt-on` 的两个固定末端结果。`vt-off` 是保留的反例,正常退出不等于终端语义正确。监督器将 transport、native capture 和错误原文保留在 `_private`,只发布 `summary.json`;它不下载或安装后端,固定 DLL/EXE 哈希不匹配立即拒绝。原生入口必须由监督器先加入自有 Job 再发送 GO。 + +原生探针及其控制台进程由上述自有 Job 监督;后续候选离线回放沿用 `run_trace` 的 20 秒进程期限,没有独立 Job 观测。这两个监督范围分列,原生 Job0 不代表回放进程树已验收。 + +下一步是显式实验 backend 接口、匹配包部署与来源、同 API 族 create/resize/close、模块生命期,以及实际 GUI 的查询/resize/cmd/PowerShell/More/Vim/关闭和 R1 关键回归。源控制台仍出现 margin 差异,依赖原生 API 的应用可能与末端不同步,必须单独处理。#41、#27、#28、#35 和 R2 保持开放;本轮没有截图、用户输入桌面、实际呈现时间、WindTerm 运行等价或性能结论。 diff --git a/docs/research/inventory-timeout-checkpoints.md b/docs/research/inventory-timeout-checkpoints.md new file mode 100644 index 00000000..5319a225 --- /dev/null +++ b/docs/research/inventory-timeout-checkpoints.md @@ -0,0 +1,46 @@ +# Inventory 纯夹具超时阶段观测(#40) + +## 已知与未知 + +[原 CI 40244057401](https://github.com/JavaZeroo/termforge/actions/runs/40244057401) 的 Windows Server 2025 `ime_inventory` 在 `WorkerContract.test_failed_final_progress_cannot_return_complete` 抛出 `TimeoutExpired`,保留原 10 秒。该作业镜像为 `windows-2025-vs2026 / 20261021.278.1`,整套件 45.07 秒。原日志没有子进程启动、AST 或 worker 失败路径的阶段原件,不能据后续快速通过判断原先卡住的调用点。 + +`b9abcab` 已为纯夹具增加 GO 前 OwnedJob、启动就绪及最终 AST/进度结果。但结果由最后一条 stdout JSON 返回;若在 GO 后、最终 JSON 前超时,`outcome=null`,此前的 AST/进度位置仍会丢失。这里补的是这个观测缺口,**没有确定原 CI 超时的根因,#40 继续开放**。 + +## 合同 + +只执行 AST 中选定的定义及 `powershell` 操作边界,系统 inventory worker 的环境守卫、IME、DISM、语言准备和输入桌面均不执行。worker 产品代码未修改。 + +- 原 10 秒总期限不变,包含 Python 启动 PowerShell 到完成;当前修订的所有清理等待共享另外 5 秒总期限。初版代码曾分别给予 communicate/drain 各 5 秒,不能将初版证据解释为已遵守此共享预算。 +- 仍先将自有 PowerShell 纳入 kill-on-close Job 再释放 GO。 +- 子进程以 CLR 文件 API 写固定阶段:就绪、GO、AST 前后、worker 进入、`started/ended/failed` 回调及 worker 返回。写入不加载模块、不查询环境。 +- 父进程清理后最多读取 4097 字节探测上限,只公开不超过 4096 字节、32 行的 ASCII 白名单阶段和非递减毫秒数。未知阶段、非法数值、截断、超限或读取失败不公开原文,保留 `missing/unavailable`。 +- 阶段表示已观察到边界,不能证明下一条具体调用正阻塞。子进程 stopwatch 与父进程启动计时各有起点,不能混为同一时间线。 +- 超时继续抛原 `TimeoutExpired`;观察和回收成功不转成业务通过,也不补造尚未发生的 `failed`、最终 JSON 或 `complete`。 + +## 验证 + +[初版脱敏结果](../../research/ime/results/2026-10-26-inventory-timeout-checkpoints/validation.json) 记录该次固定源码哈希和以下对照;它不覆盖下节新增的清理修订,原件与哈希不改写: + +1. 正常纯 AST 三个 fresh 子进程均返回 `status=failed`、`started/ended/failed`,阶段完整;退出 0 是抽取 AST 夹具的进程结果,不能当成 worker 业务成功。进程已 reap、stdout/stderr 已 drain、自有 Job 0。 +2. 在实际同一 Try AST 的 `ended` 回调中受控挂起,仍按原 10 秒超时,最后阶段为 `progress-ended`;`outcome=null`,没有伪造 `progress-failed`。清理后进程 reap、流 drain、Job 0。 +3. 相同带阶段记录的纯 AST 生产端换回 `65988f5` 的原父 runner,同样超时;已回收临时文件确实存在 `progress-ended`,旧父记录却不包含阶段观察。这是观测缺口的反事实,不是原 CI 原因的复现。 +4. 原完整 `ime_inventory` 命令 54/54 通过;额外反例覆盖丢失、权限错误、截断、非 ASCII、未知阶段、时间倒退、格式和大小上限。原 GO 前超时仍保留 `missing`,不能误报已进入 AST。 + +```powershell +python -m unittest research.ime.test_hosted_inventory research.ime.test_prepare_hosted research.ime.test_product_ime -v +``` + +这些结果不建立启动性能、永久无超时、真实 IME 或完整 R2 验收。下一次 hosted 失败若留下阶段,可据此构造更窄的复现,再决定产品或测试修复;历史没有的阶段仍为未知。 + +## 清理修订补充 + +PR 审查另发现初版父 runner 的 `job.close()` 异常会覆盖原 `TimeoutExpired`,并跳过检查点读取和最终摘要;accounting 与 terminate 在同一个 try 中也使计数查询失败时漏掉终止尝试。新增 mock 真实调用边界先确认这两个失败,再分别保护 accounting、terminate、kill、communicate、drain、close、最终 reap、检查点和摘要输出。清理只公开固定阶段及错误类型/数值码,异常消息不进入摘要;原异常对象继续抛出。 + +所有 communicate/drain 等待使用同一 5 秒清理截止时间;原 10 秒业务截止时间保持不变。首次计数查询失败保留 `null`,不能以随后回收补成零;计数的布尔值和非法结构也不作为真实 Job 观测。正常 AST 已输出最终报告时,只要清理失败、未知或进程/流未回收,runner 仍以 `fixture_cleanup_failed` 失败。摘要输出本身失败也不能替换原超时。 + +本修订只运行有针对性的 4 项清理反例、2 项检查点 parser 测试及一次真实正常 AST。真实 AST 保留 `started/ended/failed`,进程与流已回收、自有 Job 0、`cleanup_complete=true`。共享预算的受控计时反例确认 communicate 剩余 1 秒后,drain 只能得到 0 秒。没有将初版 54/54 记录重写成当前源码的完整验证;当前完整回归以本修订的 PR CI 为准,#40 历史超时根因仍未知。 + +```powershell +python -m unittest research.ime.test_hosted_inventory.LifecycleCleanupFailures research.ime.test_hosted_inventory.FixtureCheckpoints -v +python -m unittest research.ime.test_hosted_inventory.WorkerContract.test_failed_final_progress_cannot_return_complete -v +``` diff --git a/experiments/conpty/README.md b/experiments/conpty/README.md index cfdd0f3d..1ca6743e 100644 --- a/experiments/conpty/README.md +++ b/experiments/conpty/README.md @@ -64,3 +64,7 @@ python -m unittest experiments.conpty.test_ctrl_c -v 合同保留一百万次 `cmd for /L`,实际输出至少持续一秒后发送单个 ETX,立即发送后续命令,五秒内检查独立完整输出行;命令回显或循环自然结束不算中断。监督器检查退出与自有 Job 回收,分别记录输入/输出身份、探针/源码身份和本次自有 cmd/conhost 映像的系统文件身份。原始诊断只留在输出目录的 `_private` 子目录;公开文件不包含路径、PID 或原始异常文本。 此入口验收的是固定 cmd 场景,不能单独证明所有原生控制处理函数、真实物理按键或所有 Windows 版本通过。当前条件未复现旧失败,也不能据重复通过关闭 #36;环境对照与未决点见[输入机制研究](../../docs/research/conpty-ctrl-c-input.md)。 + +## 每屏 margin 与可替换后端诊断 + +`conpty_margin_probe` / `experiments.conpty.margin_run` 对同一固定175字节源检查四阶段真实 console capture、ConPTY EOF和候选末端。标准系统后端与固定官方 side-by-side ConPTY 的差异、关闭VT处理的窄网格反例、包身份及复现命令见[后端调查](../../docs/research/conpty-margin-backends.md)。这里只增加有界诊断,产品默认后端未改变;原生源控制台与末端结果分列,不能据末端正确关闭 #41 或宣布 R2 通过。 diff --git a/experiments/conpty/margin_probe.cpp b/experiments/conpty/margin_probe.cpp new file mode 100644 index 00000000..00bb91c6 --- /dev/null +++ b/experiments/conpty/margin_probe.cpp @@ -0,0 +1,114 @@ +// TermForge independent, bounded ConPTY information-loss probe. MIT. +#include +#include +#include +#include +#include +#include +#include +static decltype(&CreatePseudoConsole) create_pc = CreatePseudoConsole; +static decltype(&ResizePseudoConsole) resize_pc = ResizePseudoConsole; +static decltype(&ClosePseudoConsole) close_pc = ClosePseudoConsole; +static COORD selected_size{80,24}; +// Keep the selected API family together. HPCON is opaque: a DLL-created +// pseudoconsole must never be resized or closed by the operating system API. +static HRESULT WINAPI probe_create(COORD, HANDLE input, HANDLE output, DWORD flags, HPCON* console) { + return create_pc(selected_size, input, output, flags, console); +} +#define CreatePseudoConsole probe_create +#define ResizePseudoConsole resize_pc +#define ClosePseudoConsole close_pc +#include "session.hpp" +#undef CreatePseudoConsole +#undef ResizePseudoConsole +#undef ClosePseudoConsole +#include "../shell/workflow_fixture.hpp" +static void require(bool ok, const char* msg) { if (!ok) throw std::runtime_error(msg); } +static void save(const std::filesystem::path& p, const std::string& b) { + std::ofstream out(p, std::ios::binary); out.write(b.data(), b.size()); out.close(); + require(bool(out), "artifact write failed"); +} +static void write(const std::string& s) { + DWORD done = 0; + require(WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), s.data(), static_cast(s.size()), &done, nullptr) && done == s.size(), "client write failed"); +} +static void client(const std::wstring& mode, const std::filesystem::path& dir) { + const auto output = GetStdHandle(STD_OUTPUT_HANDLE); + DWORD requested = ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT; + if (mode == L"vt-on") requested |= ENABLE_VIRTUAL_TERMINAL_PROCESSING; + require(SetConsoleOutputCP(CP_UTF8) && SetConsoleMode(output, requested), "console mode failed"); + const std::vector> stages = { + {"before", "\x1b[2J\x1b[HTOP_FIXED\r\nREGION_A\r\nREGION_B\r\nBOTTOM_FIXED\r\n\x1b[2;3r"}, + {"alt", "\x1b[?1049hALT_OUTSIDE\r\n\x1b[4;1HALT_A\r\nALT_B\x1b[4;5r\x1b[5;1H\nALT_C\nALT_D"}, + {"restored", "\x1b[?1049l"}, + {"final", "\x1b[3;1H\nREGION_C\nREGION_D\x1b[6;1HTF_MARGIN_DONE\r\n"}, + }; + std::string source; + for (const auto& stage : stages) { + write(stage.second); source += stage.second; + tf_shell_test::WorkflowOptions options; + options.capture = dir / stage.first; options.generation = 1; + tf_shell_test::workflow_capture_console(options, stage.first); + } + save(dir / "source.bin", source); +} +int wmain(int argc, wchar_t** argv) { + try { + if (argc == 4 && std::wstring(argv[1]) == L"--client") { + BOOL in_job = FALSE; + require(IsProcessInJob(GetCurrentProcess(), nullptr, &in_job) && in_job, "owned Job required"); + require(std::wstring(argv[2]) == L"vt-on" || std::wstring(argv[2]) == L"vt-off", "fixed mode required"); + client(argv[2], argv[3]); return 37; + } + require(argc == 6, "arguments required"); + require(std::wstring(argv[4]) == L"80" || std::wstring(argv[4]) == L"12", "fixed width required"); + selected_size.X = static_cast(std::stoi(argv[4])); + require(std::wstring(argv[5]) == L"24" || std::wstring(argv[5]) == L"7", "fixed height required"); + selected_size.Y = static_cast(std::stoi(argv[5])); + require(std::wstring(argv[2]) == L"vt-on" || std::wstring(argv[2]) == L"vt-off", "fixed mode required"); + // No DLL, pseudoconsole, file, or child exists before the outer owner + // assigns this host to its Job and sends the bounded gate. + char go[4]{}; + size_t go_size = 0; + while (go_size < sizeof(go)) { + DWORD count = 0; + if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), go + go_size, 1, &count, nullptr) || count != 1) break; + if (go[go_size++] == '\n') break; + } + require(std::string(go,go_size) == "GO\n" || std::string(go,go_size) == "GO\r\n", "GO required"); + BOOL in_job = FALSE; + require(IsProcessInJob(GetCurrentProcess(), nullptr, &in_job) && in_job, "owned Job required"); + HMODULE backend = nullptr; + if (std::wstring(argv[1]) != L"system") { + const std::filesystem::path dll(argv[1]); + require(dll.is_absolute() && dll.filename() == L"conpty.dll", "absolute conpty.dll path required"); + backend = LoadLibraryExW(argv[1], nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); + require(backend != nullptr, "backend load failed"); + create_pc = reinterpret_cast(GetProcAddress(backend, "ConptyCreatePseudoConsole")); + resize_pc = reinterpret_cast(GetProcAddress(backend, "ConptyResizePseudoConsole")); + close_pc = reinterpret_cast(GetProcAddress(backend, "ConptyClosePseudoConsole")); + require(create_pc && resize_pc && close_pc, "backend API absent"); + } + wchar_t self[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, self, 32768); + require(length && length < 32768, "self path failed"); + const auto dir = std::filesystem::absolute(argv[3]); + std::filesystem::create_directories(dir); + tf_conpty::Session session; + session.start(self, L"--client " + std::wstring(argv[2]) + L" \"" + dir.wstring() + L"\""); + const bool waited = session.wait_exit(5000); + session.close(); + save(dir / "transport.bin", session.output()); + std::cout << std::boolalpha << "{\"waited\":" << waited + << ",\"exit_code\":" << session.exit_code << ",\"read_error\":" << session.read_error + << ",\"capture_overflow\":" << session.capture_overflow + << ",\"forced_reader_cancel\":" << session.forced_reader_cancel + << ",\"forced_process_cancel\":" << session.forced_process_cancel + << ",\"cleanup_complete\":" << session.cleanup_complete + << ",\"active_children_after\":" << session.active_after << "}\n"; + // The Session destructor must run before unloading its selected ABI. + // The short-lived diagnostic process retains the module until exit. + (void)backend; + return waited && session.exit_code == 37 && session.cleanup_complete ? 0 : 1; + } catch (const std::exception& e) { std::cerr << e.what() << '\n'; return 2; } +} diff --git a/experiments/conpty/margin_run.py b/experiments/conpty/margin_run.py new file mode 100644 index 00000000..afa2e64e --- /dev/null +++ b/experiments/conpty/margin_run.py @@ -0,0 +1,276 @@ +"""ConPTY margin information-loss diagnostic; never a full R2 acceptance gate.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import subprocess +import time + +from experiments.build_paths import binary +from experiments.candidates.alacritty.run import run_trace +from experiments.conpty.run import OwnedJob +from experiments.shell.workflow_checks import validate_console_window + +SOURCE = (b"\x1b[2J\x1b[HTOP_FIXED\r\nREGION_A\r\nREGION_B\r\nBOTTOM_FIXED\r\n\x1b[2;3r" + b"\x1b[?1049hALT_OUTSIDE\r\n\x1b[4;1HALT_A\r\nALT_B\x1b[4;5r\x1b[5;1H\nALT_C\nALT_D" + b"\x1b[?1049l\x1b[3;1H\nREGION_C\nREGION_D\x1b[6;1HTF_MARGIN_DONE\r\n") +SOURCE_SHA256 = "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59" +PACKAGE = { + "name": "Microsoft.Windows.Console.ConPTY", "version": "1.25.260930003", + "url": "https://github.com/microsoft/terminal/releases/download/v1.25.2733.0/" + "Microsoft.Windows.Console.ConPTY.1.25.260930003.nupkg", + "bytes": 1750533, + "sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586", + }, +} + + +def sha256(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def backend_path(value): + if value == "system": + return value + path = Path(value).resolve(strict=True) + if path.name != "conpty.dll": + raise ValueError("expected conpty.dll") + # Only the reviewed, matching x64 DLL/EXE pair is accepted. The tool does + # not download/install a backend or silently fall back to the system host. + for name, expected in PACKAGE["files"].items(): + if sha256(path.parent / name) != expected: + raise ValueError("fixed backend identity mismatch") + return str(path) + + +def validate_lifecycle(native): + expected = { + "waited": True, "exit_code": 37, "read_error": 109, + "capture_overflow": False, "forced_reader_cancel": False, + "forced_process_cancel": False, "cleanup_complete": True, + "active_children_after": 0, + } + if (type(native) is not dict or native.keys() != expected.keys() or + any(type(native[key]) is not type(value) or native[key] != value + for key, value in expected.items())): + raise ValueError("native lifecycle incomplete") + + +def margin_result(lines, columns, rows): + # This fixture's CR/LF framing and exact ASCII result are independent of + # the host's native CHAR_INFO. Width 12 wraps the fixed completion marker. + expected = ["TOP_FIXED", "REGION_C", "REGION_D", "BOTTOM_FIXED", ""] + marker = "TF_MARGIN_DONE" + expected += [marker[at:at + columns] for at in range(0, len(marker), columns)] + expected += [""] * (rows - len(expected)) + return lines == expected + + +def inspect_case(out, trace, mode, columns, rows): + source = (out / "source.bin").read_bytes() + transport = (out / "transport.bin").read_bytes() + if source != SOURCE or hashlib.sha256(source).hexdigest() != SOURCE_SHA256: + raise ValueError("original source changed") + if not 0 < len(transport) <= 8192: + raise ValueError("transport bounds") + stages = {stage: validate_console_window(out, expected_role=stage, + expected_generation=1, prefix=stage) + for stage in ("before", "alt", "restored", "final")} + report, records = run_trace(trace, transport, rows, columns, 100, + [len(transport)], out / "replay") + if not report["passed"]: + raise ValueError("candidate replay failed") + final = records[-1]["screen"] + lines = ["".join(cell["text"] for cell in row if cell.get("width", 1)).rstrip() + for row in final["cells"]] + return { + "mode": mode, "columns": columns, "rows": rows, + "source_bytes": len(source), "source_sha256": SOURCE_SHA256, + "transport_bytes": len(transport), "transport_sha256": hashlib.sha256(transport).hexdigest(), + "original_decstbm_forwarded": all(vt in transport for vt in (b"\x1b[2;3r", b"\x1b[4;5r")), + "source_equal_transport": source == transport, + "native_lines": {stage: data["lines"] for stage, data in stages.items()}, + "native_cursor": {stage: [data["cursor_row"], data["cursor_column"]] + for stage, data in stages.items()}, + "native_restore_equal_before": stages["restored"]["cells"] == stages["before"]["cells"], + "native_main_margins_restored": margin_result(stages["final"]["lines"], columns, rows), + "terminal_lines": lines, + "terminal_main_margins_restored": margin_result(lines, columns, rows), + "terminal_alternate": bool(records[-1]["mode_bits"] & (1 << 12)), + "replay_passed": report["passed"], "replay_core_sha256": sha256(trace), + "product_backend_changed": False, "full_r2_contract_verified": False, + "presented_done": None, + } + + +def execute(executable, backend, trace, out, mode, columns, rows, *, gate=b"GO\n", timeout=15): + if mode not in ("vt-on", "vt-off") or (columns, rows) not in ((80,24), (12,7)): + raise ValueError("fixed workload required") + out.mkdir(parents=True, exist_ok=False) + job = process = None + stdout = stderr = b"" + result = {"status": "failed", "failure_code": None, "outer_job_after": None, + "outer_job_after_forced_cleanup": None, "forced_outer_cleanup": False, + "job_assigned": False, "process_reaped": None, "streams_drained": False, + "cleanup_errors": []} + stage = "job_create" + + def cleanup_fault(code): + # Fixed codes only; exception text and private command paths stay out. + result["cleanup_errors"].append(code) + result["status"] = "failed" + + try: + job = OwnedJob() + stage = "process_start" + process = subprocess.Popen([str(executable), backend, mode, str(out), str(columns), str(rows)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW) + stage = "job_assign" + job.assign(process.pid) + result["job_assigned"] = True + stage = "process_communicate" + stdout, stderr = process.communicate(gate, timeout=timeout) + result["streams_drained"] = True + stage = "stream_evidence" + (out / "host.stdout.bin").write_bytes(stdout) + (out / "host.stderr.bin").write_bytes(stderr) + stage = "native_report" + if process.returncode != 0: + raise ValueError("native probe failed") + native = json.loads(stdout) + stage = "native_lifecycle" + validate_lifecycle(native) + stage = "case_inspection" + result.update(inspect_case(out, trace, mode, columns, rows), native=native, status="completed") + except BaseException: + result["failure_code"] = stage + raise + finally: + # One original five-second cleanup budget covers every bounded wait. + # Failures are independent: none can skip kill-on-close or diagnostics, + # nor replace the exception from assignment, timeout or native replay. + cleanup_deadline = time.monotonic() + 5 + + def job_observation(): + observed = job.drain(timeout=max(0, cleanup_deadline - time.monotonic())) + if (type(observed) is not dict or set(observed) != {"active", "total", "terminated"} + or any(type(value) is not int or value < 0 for value in observed.values())): + raise ValueError("invalid_job_accounting") + return observed + + try: + if process is not None: + try: + running = process.poll() is None + except Exception: + running = True + cleanup_fault("process_poll") + if running: + result["forced_outer_cleanup"] = True + result["status"] = "failed" + try: + if result["job_assigned"]: + job.terminate() + else: + process.kill() # This exact root is still waiting for GO. + except Exception: + cleanup_fault("process_terminate") + try: + stdout, stderr = process.communicate(timeout=max(0, cleanup_deadline - time.monotonic())) + result["streams_drained"] = True + except Exception: + cleanup_fault("process_communicate") + if job is not None and result["job_assigned"]: + try: + result["outer_job_after"] = job_observation() + except Exception: + cleanup_fault("job_accounting") + if result["outer_job_after"] is not None and result["outer_job_after"]["active"]: + result["forced_outer_cleanup"] = True + result["status"] = "failed" + try: + job.terminate() + except Exception: + cleanup_fault("job_terminate") + try: + result["outer_job_after_forced_cleanup"] = job_observation() + if result["outer_job_after_forced_cleanup"]["active"]: + cleanup_fault("job_not_empty") + except Exception: + cleanup_fault("job_accounting_after_forced_cleanup") + finally: + if job is not None: + try: + job.close() # Final containment fallback, even if terminate failed. + except Exception: + cleanup_fault("job_close") + if process is not None: + try: + if not result["streams_drained"]: + stdout, stderr = process.communicate(timeout=max(0, cleanup_deadline - time.monotonic())) + result["streams_drained"] = True + result["process_reaped"] = process.poll() is not None + if not result["process_reaped"]: + cleanup_fault("process_not_reaped") + except Exception: + cleanup_fault("process_reap_after_close") + for name, contents in (("host.stdout.bin", stdout), ("host.stderr.bin", stderr)): + try: + (out / name).write_bytes(contents) + except Exception: + cleanup_fault("stream_evidence_write") + if (result["cleanup_errors"] or result["forced_outer_cleanup"] or + result["outer_job_after"] is None or result["process_reaped"] is not True or + not result["streams_drained"]): + result["status"] = "failed" + if result["failure_code"] is None: + result["failure_code"] = "outer_cleanup_incomplete" + try: + (out / "diagnostic.json").write_text(json.dumps(result, indent=2), encoding="utf-8") + except Exception: + cleanup_fault("diagnostic_write") + if (result["cleanup_errors"] or result["forced_outer_cleanup"] or + result["outer_job_after"] is None or result["outer_job_after"]["active"] or + result["process_reaped"] is not True or not result["streams_drained"]): + raise ValueError("outer_cleanup_incomplete") + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=None) + parser.add_argument("--backend", default="system") + parser.add_argument("--candidate-trace", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--require-margins", action="store_true") + args = parser.parse_args() + executable = (args.binary or binary("conpty_margin_probe")).resolve(strict=True) + backend = backend_path(args.backend) + trace = args.candidate_trace.resolve(strict=True) + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=False) + samples = [execute(executable, backend, trace, out / "_private" / f"{columns}x{rows}-{mode}", + mode, columns, rows) + for columns, rows in ((80,24), (12,7)) for mode in ("vt-on", "vt-off")] + valid = all(sample["terminal_main_margins_restored"] for sample in samples if sample["mode"] == "vt-on") + summary = { + "schema": "termforge-conpty-margin-backend-v1", "generated_utc": datetime.now(timezone.utc).isoformat(), + "backend": "system" if backend == "system" else PACKAGE, + "probe_sha256": sha256(executable), "candidate_trace_sha256": sha256(trace), + "terminal_main_margins_restored_in_both_sizes": valid, "full_r2_contract_verified": False, + "samples": samples, + } + (out / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print(json.dumps({key: value for key, value in summary.items() if key != "samples"})) + return int(args.require_margins and not valid) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/conpty/results/2026-10-26-margin-backends/validation.json b/experiments/conpty/results/2026-10-26-margin-backends/validation.json new file mode 100644 index 00000000..8068024a --- /dev/null +++ b/experiments/conpty/results/2026-10-26-margin-backends/validation.json @@ -0,0 +1,2490 @@ +{ + "schema": "termforge-conpty-margin-backend-validation-v1", + "base_commit": "65988f5f20f62294ff52fcac5b5b81e563f93f3b", + "environment": { + "platform": "Windows-11-10.0.26200-SP0", + "python": "3.12.10", + "system_conhost": { + "sha256": "e449bce01f275cd08f3d4e64bb73b3b43ae845a0dbdb3e6131426e66537705e5", + "fixed_file_version": "10.0.26100.9444", + "fixed_product_version": "10.0.26100.9444", + "file_version_string": "10.0.26100.8875 (WinBuild.160101.0800)", + "product_version_string": "10.0.26100.8875" + } + }, + "package": { + "name": "Microsoft.Windows.Console.ConPTY", + "version": "1.25.260930003", + "url": "https://github.com/microsoft/terminal/releases/download/v1.25.2733.0/Microsoft.Windows.Console.ConPTY.1.25.260930003.nupkg", + "bytes": 1750533, + "sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586" + } + }, + "official_tag": "v1.25.2733.0", + "official_source_commit": "dc8ae365847d41f62d1ebca93815bb00959bc12f", + "official_native_files": { + "conpty.dll": { + "sha256": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "bytes": 119608, + "pe_machine": "0x8664", + "fixed_file_version": "1.25.2609.30003", + "fixed_product_version": "1.25.2609.30003", + "file_version_string": "1.25.2609.30003", + "product_version_string": "1.25.260930003" + }, + "OpenConsole.exe": { + "sha256": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586", + "bytes": 1089376, + "pe_machine": "0x8664", + "fixed_file_version": "1.25.2609.30003", + "fixed_product_version": "1.25.2609.30003", + "file_version_string": "1.25.2609.30003", + "product_version_string": "1.25.260930003" + } + }, + "authenticode_observation": { + "OpenConsole.exe": "Valid", + "conpty.dll": "Valid", + "method": "Get-AuthenticodeSignature; independent read-only observation" + }, + "source_sha256": { + "CMakeLists.txt": "f03543ecc465967750638fa51c84a4611c8415a7af362f094aa872259641d329", + "experiments/conpty/margin_probe.cpp": "4dc43c132f994280367d810e1066e8dbfcc2bb273897f7e6ceebae1e6780f713", + "experiments/conpty/margin_run.py": "5625c7d8dde798899eeb0e50dd528c3693051776f716802e5dee94baa6ae0aba", + "experiments/conpty/test_margin_run.py": "07dc4dc7c510e47d25bd2578c1519223fed0886efe504650f887967e636b4106", + "experiments/conpty/session.hpp": "f143a858df22302a2bc4a794263ff7769f4d0cfd9537307d916feb8ad638c6b6", + "experiments/shell/workflow_fixture.hpp": "332d73699320a6f503fc212417a4d3820b82cc2d3feb3d1fffe3930660d57453", + "experiments/shell/workflow_checks.py": "0d37c7625109cdcb8281b84179a13accd363a748f3b99fdcafee22fcd6ed6e19" + }, + "candidate_reused_from_commit": "b47a12a", + "candidate_source_files_same_as_base": { + "count": 29, + "all_equal": true, + "includes": [ + "Rust source", + "Cargo manifests", + "Cargo.lock" + ], + "excludes": [ + "build output", + "Python harness" + ] + }, + "fixed_source": { + "bytes": 175, + "sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "changed": false + }, + "fresh_matrices": 4, + "cases_per_matrix": 4, + "runs": [ + { + "repeat": 0, + "backend_name": "system", + "summary_sha256": "dbe1e9da58a0887147d8cf1bbc4d439708ca509a296a25ef514b845ce7ad2944", + "schema": "termforge-conpty-margin-backend-v1", + "generated_utc": "2026-10-26T07:13:02.867199+00:00", + "backend": "system", + "probe_sha256": "b531a400134fcdff6024d2a87767014562a4e246a2f55ff726d8e31ba6d08f88", + "candidate_trace_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "terminal_main_margins_restored_in_both_sizes": false, + "full_r2_contract_verified": false, + "samples": [ + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 631, + "transport_sha256": "c7eb5457195e16bf4d38b65078ed5430043e4a4952c92dffb91d72bdbd3b2cd3", + "original_decstbm_forwarded": false, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": false, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 320, + "transport_sha256": "033b589a8f5765d652a7d1085f157cb255d8f0d4956e8132df1894a032867fe4", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;1HTF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 4, + 6 + ], + "alt": [ + 8, + 5 + ], + "restored": [ + 8, + 13 + ], + "final": [ + 11, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 431, + "transport_sha256": "cc7d7fc4d76f2b43a2fc5b12994f5e4253582ce976e623428b7a6fb946472e1c", + "original_decstbm_forwarded": false, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DO", + "NE" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DO", + "NE" + ], + "terminal_main_margins_restored": false, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 470, + "transport_sha256": "b075d03b6e67aaa6eb7de2ad1e27af1108735f367e039e7d7332cc448b1980f3", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_F", + "IXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "\u001b[2;3r" + ], + "alt": [ + "9hALT_OUTSID", + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D" + ], + "restored": [ + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D\u001b[?1049", + "l" + ], + "final": [ + "ALT_D\u001b[?1049", + "l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;", + "1HTF_MARGIN_", + "DONE", + "" + ] + }, + "native_cursor": { + "before": [ + 6, + 6 + ], + "alt": [ + 6, + 5 + ], + "restored": [ + 6, + 1 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_C", + "", + "_", + "DONE" + ], + "terminal_main_margins_restored": false, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + } + ] + }, + { + "repeat": 0, + "backend_name": "official", + "summary_sha256": "99b5217bacd5d9384f8f4c2366fa24da33becfb4cd9299ca4d5ff974e69f1481", + "schema": "termforge-conpty-margin-backend-v1", + "generated_utc": "2026-10-26T07:13:03.511727+00:00", + "backend": { + "name": "Microsoft.Windows.Console.ConPTY", + "version": "1.25.260930003", + "url": "https://github.com/microsoft/terminal/releases/download/v1.25.2733.0/Microsoft.Windows.Console.ConPTY.1.25.260930003.nupkg", + "bytes": 1750533, + "sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586" + } + }, + "probe_sha256": "b531a400134fcdff6024d2a87767014562a4e246a2f55ff726d8e31ba6d08f88", + "candidate_trace_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "terminal_main_margins_restored_in_both_sizes": true, + "full_r2_contract_verified": false, + "samples": [ + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 218, + "transport_sha256": "d793c84c56624d6c647e562ef7ba35e21d8998baa43371cb36f95b2add684246", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 218, + "transport_sha256": "d793c84c56624d6c647e562ef7ba35e21d8998baa43371cb36f95b2add684246", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;1HTF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 4, + 6 + ], + "alt": [ + 8, + 5 + ], + "restored": [ + 8, + 13 + ], + "final": [ + 11, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 218, + "transport_sha256": "d793c84c56624d6c647e562ef7ba35e21d8998baa43371cb36f95b2add684246", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DO", + "NE" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DO", + "NE" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 222, + "transport_sha256": "01064e034cc1cd3c34eae65a2f656ae940936cf381e60371b8e17428ec00617d", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_F", + "IXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "\u001b[2;3r" + ], + "alt": [ + "9hALT_OUTSID", + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D" + ], + "restored": [ + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D\u001b[?1049", + "l" + ], + "final": [ + "ALT_D\u001b[?1049", + "l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;", + "1HTF_MARGIN_", + "DONE", + "" + ] + }, + "native_cursor": { + "before": [ + 6, + 6 + ], + "alt": [ + 6, + 5 + ], + "restored": [ + 6, + 1 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DO", + "NE" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + } + ] + }, + { + "repeat": 1, + "backend_name": "system", + "summary_sha256": "25520a2422d0297edc8fe67560e08e556f6aa8fd07a24600b14fd65e72ea7f40", + "schema": "termforge-conpty-margin-backend-v1", + "generated_utc": "2026-10-26T07:13:04.279560+00:00", + "backend": "system", + "probe_sha256": "b531a400134fcdff6024d2a87767014562a4e246a2f55ff726d8e31ba6d08f88", + "candidate_trace_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "terminal_main_margins_restored_in_both_sizes": false, + "full_r2_contract_verified": false, + "samples": [ + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 602, + "transport_sha256": "a2634b7dd4dd7841475b21e930953dbb69abb388e1138ee65ec1e4bbeeeca4f7", + "original_decstbm_forwarded": false, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": false, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 320, + "transport_sha256": "1564126a6dc616a68596b6dacd5bed829503de92388381a7ff5cce9d19213e31", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;1HTF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 4, + 6 + ], + "alt": [ + 8, + 5 + ], + "restored": [ + 8, + 13 + ], + "final": [ + 11, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 431, + "transport_sha256": "cc7d7fc4d76f2b43a2fc5b12994f5e4253582ce976e623428b7a6fb946472e1c", + "original_decstbm_forwarded": false, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DO", + "NE" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DO", + "NE" + ], + "terminal_main_margins_restored": false, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 495, + "transport_sha256": "f44453e70235b7463ab525977d835436d696884b1308b870c1831831288e6a2b", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_F", + "IXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "\u001b[2;3r" + ], + "alt": [ + "9hALT_OUTSID", + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D" + ], + "restored": [ + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D\u001b[?1049", + "l" + ], + "final": [ + "ALT_D\u001b[?1049", + "l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;", + "1HTF_MARGIN_", + "DONE", + "" + ] + }, + "native_cursor": { + "before": [ + 6, + 6 + ], + "alt": [ + 6, + 5 + ], + "restored": [ + 6, + 1 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_C", + "", + "_", + "DONE" + ], + "terminal_main_margins_restored": false, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + } + ] + }, + { + "repeat": 1, + "backend_name": "official", + "summary_sha256": "8d10dddd141d7f242cc8f567813123a24057195185d337330b71a244cb22a8b5", + "schema": "termforge-conpty-margin-backend-v1", + "generated_utc": "2026-10-26T07:13:04.903082+00:00", + "backend": { + "name": "Microsoft.Windows.Console.ConPTY", + "version": "1.25.260930003", + "url": "https://github.com/microsoft/terminal/releases/download/v1.25.2733.0/Microsoft.Windows.Console.ConPTY.1.25.260930003.nupkg", + "bytes": 1750533, + "sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586" + } + }, + "probe_sha256": "b531a400134fcdff6024d2a87767014562a4e246a2f55ff726d8e31ba6d08f88", + "candidate_trace_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "terminal_main_margins_restored_in_both_sizes": true, + "full_r2_contract_verified": false, + "samples": [ + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 218, + "transport_sha256": "d793c84c56624d6c647e562ef7ba35e21d8998baa43371cb36f95b2add684246", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 80, + "rows": 24, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 218, + "transport_sha256": "d793c84c56624d6c647e562ef7ba35e21d8998baa43371cb36f95b2add684246", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "alt": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "restored": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "final": [ + "\u001b[2J\u001b[HTOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "\u001b[2;3r\u001b[?1049hALT_OUTSIDE", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b[5;1H", + "ALT_C", + "ALT_D\u001b[?1049l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;1HTF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "native_cursor": { + "before": [ + 4, + 6 + ], + "alt": [ + 8, + 5 + ], + "restored": [ + 8, + 13 + ], + "final": [ + 11, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DONE", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-on", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 218, + "transport_sha256": "d793c84c56624d6c647e562ef7ba35e21d8998baa43371cb36f95b2add684246", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "alt": [ + "ALT_OUTSIDE", + "", + "", + "ALT_C", + "ALT_D", + "", + "" + ], + "restored": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "", + "" + ], + "final": [ + "TOP_FIXED", + "REGION_A", + "REGION_B", + "REGION_CIXED", + "REGION_D", + "TF_MARGIN_DO", + "NE" + ] + }, + "native_cursor": { + "before": [ + 0, + 0 + ], + "alt": [ + 4, + 5 + ], + "restored": [ + 0, + 0 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": true, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DO", + "NE" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + }, + { + "status": "completed", + "outer_job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "forced_outer_cleanup": false, + "mode": "vt-off", + "columns": 12, + "rows": 7, + "source_bytes": 175, + "source_sha256": "01878a5d54c343007309abb89faeb90c89262a8b55e420c5f267342ed9b27e59", + "transport_bytes": 222, + "transport_sha256": "01064e034cc1cd3c34eae65a2f656ae940936cf381e60371b8e17428ec00617d", + "original_decstbm_forwarded": true, + "source_equal_transport": false, + "native_lines": { + "before": [ + "\u001b[2J\u001b[HTOP_F", + "IXED", + "REGION_A", + "REGION_B", + "BOTTOM_FIXED", + "", + "\u001b[2;3r" + ], + "alt": [ + "9hALT_OUTSID", + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D" + ], + "restored": [ + "E", + "\u001b[4;1HALT_A", + "ALT_B\u001b[4;5r\u001b", + "[5;1H", + "ALT_C", + "ALT_D\u001b[?1049", + "l" + ], + "final": [ + "ALT_D\u001b[?1049", + "l\u001b[3;1H", + "REGION_C", + "REGION_D\u001b[6;", + "1HTF_MARGIN_", + "DONE", + "" + ] + }, + "native_cursor": { + "before": [ + 6, + 6 + ], + "alt": [ + 6, + 5 + ], + "restored": [ + 6, + 1 + ], + "final": [ + 6, + 0 + ] + }, + "native_restore_equal_before": false, + "native_main_margins_restored": false, + "terminal_lines": [ + "TOP_FIXED", + "REGION_C", + "REGION_D", + "BOTTOM_FIXED", + "", + "TF_MARGIN_DO", + "NE" + ], + "terminal_main_margins_restored": true, + "terminal_alternate": false, + "replay_passed": true, + "replay_core_sha256": "b96618ca2a06c3eda414a468e5b9479cae7da009089fa87e6c94b7deec7b1b66", + "product_backend_changed": false, + "full_r2_contract_verified": false, + "presented_done": null, + "native": { + "waited": true, + "exit_code": 37, + "read_error": 109, + "capture_overflow": false, + "forced_reader_cancel": false, + "forced_process_cancel": false, + "cleanup_complete": true, + "active_children_after": 0 + } + } + ] + } + ], + "focused_tests": { + "command": "python -m unittest experiments.conpty.test_margin_run -v", + "passed": 6, + "total": 6 + }, + "product_backend_changed": false, + "native_api_margin_gap_closed": false, + "full_r2_contract_verified": false, + "screenshots_or_user_desktop_input": false, + "raw_transport_committed": false, + "presented_done": null, + "remaining_gaps": [ + "Explicit product Backend API and same-family lifetime/deployment", + "Native CHAR_INFO still disagrees with terminal VT result", + "Actual GUI query, resize, cmd, PowerShell, More, Vim, close and R1 regressions", + "No performance, WindTerm runtime equivalence or full R2 acceptance claim" + ] +} diff --git a/experiments/conpty/session.hpp b/experiments/conpty/session.hpp index 4427bf20..190207fa 100644 --- a/experiments/conpty/session.hpp +++ b/experiments/conpty/session.hpp @@ -158,8 +158,12 @@ class Session { const HRESULT hr = ResizePseudoConsole(console_, {columns, rows}); if (FAILED(hr)) fail("ResizePseudoConsole", static_cast(hr)); } - bool wait_exit(unsigned timeout_ms) { - child_signaled = WaitForSingleObject(process_.get(), timeout_ms) == WAIT_OBJECT_0; + bool wait_exit(unsigned timeout_ms, DWORD* observed_wait_result = nullptr, DWORD* observed_wait_error = nullptr) { + const auto result = WaitForSingleObject(process_.get(), timeout_ms); + const DWORD error = result == WAIT_FAILED ? GetLastError() : ERROR_SUCCESS; + if (observed_wait_result) *observed_wait_result = result; + if (observed_wait_error) *observed_wait_error = error; + child_signaled = result == WAIT_OBJECT_0; if (child_signaled && !GetExitCodeProcess(process_.get(), &exit_code)) fail("GetExitCodeProcess"); return child_signaled; } diff --git a/experiments/conpty/test_margin_run.py b/experiments/conpty/test_margin_run.py new file mode 100644 index 00000000..6f5dd293 --- /dev/null +++ b/experiments/conpty/test_margin_run.py @@ -0,0 +1,196 @@ +import copy +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +from experiments.build_paths import binary +from experiments.conpty.margin_run import ( + PACKAGE, SOURCE, SOURCE_SHA256, backend_path, margin_result, sha256, validate_lifecycle, +) +from experiments.conpty.run import OwnedJob +from experiments.conpty import margin_run as runner + + +class MarginCleanupFailureTests(unittest.TestCase): + def mocks(self): + process, job = mock.Mock(), mock.Mock() + process.pid, process.returncode = 123, None + process.poll.return_value = None + job.drain.return_value = {"active": 0, "total": 1, "terminated": 0} + return process, job + + def execute(self, root, process, job): + with mock.patch.object(runner, "OwnedJob", return_value=job), \ + mock.patch.object(runner.subprocess, "Popen", return_value=process), \ + mock.patch.object(runner.subprocess, "CREATE_NO_WINDOW", 0, create=True), \ + mock.patch.object(runner, "inspect_case", return_value={"synthetic": True}): + return runner.execute(Path(root) / "never-executed", "system", Path(root) / "unused-trace", + Path(root) / "sample", "vt-on", 80, 24) + + def test_timeout_survives_independent_cleanup_failures_and_saves_unknowns(self): + process, job = self.mocks() + original = subprocess.TimeoutExpired("PRIVATE original", 15) + process.communicate.side_effect = [original, OSError("PRIVATE pipe failure"), + OSError("PRIVATE reap failure")] + job.terminate.side_effect = OSError("PRIVATE terminate failure") + job.drain.side_effect = OSError("PRIVATE accounting failure") + job.close.side_effect = OSError("PRIVATE close failure") + with tempfile.TemporaryDirectory() as root: + with self.assertRaises(subprocess.TimeoutExpired) as raised: + self.execute(root, process, job) + self.assertIs(raised.exception, original) + row = json.loads((Path(root) / "sample/diagnostic.json").read_text()) + self.assertEqual(row["status"], "failed") + self.assertIsNone(row["outer_job_after"]) + self.assertIsNone(row["process_reaped"]) + for stage in ("process_terminate", "process_communicate", "job_accounting", "job_close", + "process_reap_after_close"): + self.assertIn(stage, row["cleanup_errors"]) + self.assertNotIn("PRIVATE", json.dumps(row)) + job.close.assert_called_once() + + def test_assignment_failure_never_sends_go_and_only_kills_owned_root(self): + process, job = self.mocks() + original = OSError("PRIVATE assignment failure") + job.assign.side_effect = original + process.poll.side_effect = [None, 99] + process.communicate.return_value = (b"", b"") + with tempfile.TemporaryDirectory() as root: + with self.assertRaises(OSError) as raised: + self.execute(root, process, job) + self.assertIs(raised.exception, original) + process.kill.assert_called_once() + row = json.loads((Path(root) / "sample/diagnostic.json").read_text()) + self.assertFalse(row["job_assigned"]) + self.assertTrue(row["process_reaped"]) + self.assertTrue(row["streams_drained"]) + self.assertIsNone(row["outer_job_after"]) + self.assertNotIn("PRIVATE", json.dumps(row)) + process.kill.assert_called_once() + process.communicate.assert_called_once() + self.assertEqual(process.communicate.call_args.args, ()) + job.terminate.assert_not_called() + job.drain.assert_not_called() + job.close.assert_called_once() + + def test_normal_body_cannot_pass_forced_or_unknown_cleanup(self): + native = dict(waited=True, exit_code=37, read_error=109, capture_overflow=False, + forced_reader_cancel=False, forced_process_cancel=False, + cleanup_complete=True, active_children_after=0) + for observation, forced in (({"active": 2, "total": 3, "terminated": 0}, True), + (OSError("PRIVATE query"), False), + ({"active": False, "total": 1, "terminated": 0}, False)): + with self.subTest(observation=type(observation).__name__), tempfile.TemporaryDirectory() as root: + process, job = self.mocks() + process.returncode = 0 + process.poll.return_value = 0 + process.communicate.return_value = (json.dumps(native).encode(), b"") + job.drain.side_effect = [observation, {"active": 0, "total": 3, "terminated": 2}] + with self.assertRaises(ValueError): + self.execute(root, process, job) + row = json.loads((Path(root) / "sample/diagnostic.json").read_text()) + self.assertEqual(row["status"], "failed") + if forced: + self.assertEqual(row["outer_job_after"], observation) + self.assertEqual(row["outer_job_after_forced_cleanup"]["active"], 0) + self.assertTrue(row["forced_outer_cleanup"]) + else: + self.assertIsNone(row["outer_job_after"]) + self.assertIn("job_accounting", row["cleanup_errors"]) + self.assertNotIn("PRIVATE", json.dumps(row)) + job.close.assert_called_once() + + +class MarginDiagnosticTests(unittest.TestCase): + def test_original_workload_identity(self): + import hashlib + self.assertEqual(len(SOURCE), 175) + self.assertEqual(hashlib.sha256(SOURCE).hexdigest(), SOURCE_SHA256) + self.assertEqual(SOURCE.count(b"\x1b[2;3r"), 1) + self.assertEqual(SOURCE.count(b"\x1b[4;5r"), 1) + + def test_exact_margin_result_and_wrapped_marker(self): + for columns, rows in ((80, 24), (12, 7)): + lines = ["TOP_FIXED", "REGION_C", "REGION_D", "BOTTOM_FIXED", ""] + marker = "TF_MARGIN_DONE" + lines += [marker[at:at+columns] for at in range(0, len(marker), columns)] + lines += [""] * (rows - len(lines)) + self.assertTrue(margin_result(lines, columns, rows)) + wrong = lines.copy() + wrong[3] = "REGION_CIXED" + self.assertFalse(margin_result(wrong, columns, rows)) + wrong = lines.copy() + wrong[5] = "TF_MARGIN" + self.assertFalse(margin_result(wrong, columns, rows)) + self.assertFalse(margin_result(lines + [""], columns, rows)) + + def test_lifecycle_requires_exact_observed_types_and_values(self): + sample = dict(waited=True, exit_code=37, read_error=109, capture_overflow=False, + forced_reader_cancel=False, forced_process_cancel=False, + cleanup_complete=True, active_children_after=0) + validate_lifecycle(sample) + for key, value in sample.items(): + for replacement in (None, not value if type(value) is bool else value + 1, + int(value) if type(value) is bool else False): + wrong = copy.deepcopy(sample) + wrong[key] = replacement + with self.subTest(key=key, replacement=replacement), self.assertRaises(ValueError): + validate_lifecycle(wrong) + wrong = copy.deepcopy(sample) + del wrong[key] + with self.subTest(missing=key), self.assertRaises(ValueError): + validate_lifecycle(wrong) + + def test_unreviewed_backend_pair_is_rejected(self): + self.assertEqual(backend_path("system"), "system") + with tempfile.TemporaryDirectory() as root: + dll = Path(root) / "conpty.dll" + dll.write_bytes(b"unreviewed") + (dll.parent / "OpenConsole.exe").write_bytes(b"unreviewed") + self.assertNotEqual(sha256(dll), PACKAGE["files"]["conpty.dll"]) + with self.assertRaisesRegex(ValueError, "identity"): + backend_path(dll) + + @unittest.skipUnless(os.name == "nt", "Windows native diagnostic") + def test_native_eof_and_invalid_gate_never_start_probe(self): + executable = binary("conpty_margin_probe") + with tempfile.TemporaryDirectory() as root: + for index, gate in enumerate((b"", b"NO\n", b"GOXX")): + out = Path(root) / str(index) + result = subprocess.run([str(executable), "system", "vt-on", str(out), "80", "24"], + input=gate, capture_output=True, timeout=5, + creationflags=subprocess.CREATE_NO_WINDOW) + self.assertEqual(result.returncode, 2) + self.assertIn(b"GO required", result.stderr) + self.assertFalse(out.exists()) + + @unittest.skipUnless(os.name == "nt", "Windows native diagnostic") + def test_gate_wait_is_bounded_by_owned_job(self): + with tempfile.TemporaryDirectory() as root: + out = Path(root) / "absent" + job = OwnedJob() + process = subprocess.Popen([str(binary("conpty_margin_probe")), "system", "vt-on", str(out), "80", "24"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW) + try: + job.assign(process.pid) + with self.assertRaises(subprocess.TimeoutExpired): + process.wait(timeout=0.05) + self.assertFalse(out.exists()) + job.terminate() + process.communicate(timeout=5) + self.assertEqual(job.drain()["active"], 0) + self.assertFalse(out.exists()) + finally: + if process.poll() is None: + job.terminate() + process.communicate(timeout=5) + job.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/experiments/shell/WORKFLOW.md b/experiments/shell/WORKFLOW.md index c2a72d53..e0ccdf6e 100644 --- a/experiments/shell/WORKFLOW.md +++ b/experiments/shell/WORKFLOW.md @@ -49,6 +49,10 @@ python -m unittest experiments.shell.test_workflow_sessions -v 父进程先创建全新、未置位的九个 named manual-reset events,后缀为 `ready`、`output`、`output-done`、`append`、`append-done`、`evict`、`evict-done`、`stop`、`stopped`。子进程核对角色/代次/所有者进程、Job 和事件初态;控制来自事件,不能把控制字符混入接收字节。停止优先于下一条命令;父进程退出、超时、重复或乱序命令均失败。完成与停止事件不代表消费或呈现完成。 +Controller 的正常结束回归另外记录 `exit\r` 的实际写入字节、原五秒 `wait_exit` 布尔、原生 wait 返回值/错误以及 close 前退出码;尚未 signaled 的退出码保留 null。close 后退出码、强制取消与 Job 回收分别记录,不能用最终回收成功补成自然退出。发送前和 close 前的运输偏移/prompt 次数仅为输出观察,不证明 cmd 已读取输入,也不增加 fresh-prompt 等待、重发或期限。Python 在检查原生失败码之前保存固定 scalar 白名单,私有 PID/创建时间与 raw transport 不进入该摘要。 + +[#44 的原失败](results/2026-10-26-controller-exit/validation.json)仍未复现;[有界重放及新诊断](results/2026-10-26-controller-exit-observation/validation.json)不能覆盖原件或当作根因修复。当前正常返回的 fresh 重放同样可在只观察到初始 prompt 时完成 `exit\r`,因此不能仅由历史红推断必须增加 prompt 等待。两个 CMake 配置中的 controller probe 均不链接候选核心,相关通过不构成候选 GUI 或完整 R2 验收。 + 旧 `shell_text_client` 80行模式和 `shell_ime_client` 20行模式保持。后者的新 workflow 入口仍位于原严格 hosted IME 守卫之后;本机数据回归使用 text client,不伪造环境标志。 ## 证据分层 diff --git a/experiments/shell/results/2026-10-26-controller-exit-observation/validation.json b/experiments/shell/results/2026-10-26-controller-exit-observation/validation.json new file mode 100644 index 00000000..a3db5f86 --- /dev/null +++ b/experiments/shell/results/2026-10-26-controller-exit-observation/validation.json @@ -0,0 +1,694 @@ +{ + "schema": "termforge-controller-exit-observation-v1", + "date": "2026-10-26", + "base_commit": "65988f5f20f62294ff52fcac5b5b81e563f93f3b", + "platform": "Windows-11-10.0.26200-SP0", + "issue": 44, + "historical_red": "../2026-10-26-controller-exit/validation.json", + "historical_root_cause_established": false, + "historical_failure_reproduced": false, + "product_fix": false, + "closes_issue": false, + "observation_contract": { + "exit_input": "657869740d", + "graceful_exit_input_bytes": 5, + "cmd_exit_deadline_ms": 5000, + "host_outer_deadline_seconds": 25, + "load_changed": false, + "prompt_wait_added": false, + "input_retried": false, + "status_after_close_can_replace_preclose": false, + "unknown_preclose_code": null, + "native_wait_result_and_error_recorded": true, + "failed_host_observations_retained_before_status_assertion": true, + "observer_may_perturb_scheduling": true + }, + "bounded_replay": { + "baseline": { + "host_rounds": 10, + "controller_cases": 30, + "failed": 0, + "executable_sha256": "f803f4c8cfa5f3b332a8577a0c9600573b22050783d0797b698ffb2c4a72964f", + "client_sha256": "72caf22ad6836e5608a0d814b18ce1bc97a8aa6812fe6a000439e42d04b40f89", + "source_sha256": { + "experiments/shell/workflow_controller.hpp": "61005e442aa1729759dd1c1b8f0919826cb2e71f225fae53f0ea0c9cc28d38eb", + "experiments/shell/workflow_fixture_test.cpp": "29d35fd143ac215b6746f810e1915be17ad6c407623d0768121f1799e38c553c", + "experiments/shell/test_workflow.py": "63b224136c532420af97b0d87399819a403e73126ec0e7c803cfef838f27e87b", + "experiments/shell/workflow_checks.py": "0d37c7625109cdcb8281b84179a13accd363a748f3b99fdcafee22fcd6ed6e19" + } + }, + "intermediate_scalar_observer": { + "host_rounds": 50, + "controller_cases": 150, + "failed": 0, + "executable_sha256": "fc1c6aff62abd1dcd1546c78b808fed8158160b3101fe28c51e9c1a6147c24d9", + "client_sha256": "72caf22ad6836e5608a0d814b18ce1bc97a8aa6812fe6a000439e42d04b40f89", + "source_sha256": { + "experiments/shell/workflow_controller.hpp": "61005e442aa1729759dd1c1b8f0919826cb2e71f225fae53f0ea0c9cc28d38eb", + "experiments/shell/workflow_fixture_test.cpp": "a047d27717e7c0a7be8fa5320e0914c83a6b0db210b994208266173b6999a7fa", + "experiments/shell/test_workflow.py": "a9d6daca145ce1808400d8cb7f1230393843c9d909b7e9e962209801930cbeb3", + "experiments/shell/workflow_checks.py": "3594ff685240fff642d933db50c9a2206912e425c361125eec4258647ab45491" + }, + "native_wait_result_and_error_recorded": false, + "all_100_graceful_exit_inputs_before_second_prompt": true, + "note": "All graceful cases had exactly one observed TF_CONTROLLER> before exit input; this is an output observation, not proof of cmd read readiness." + } + }, + "final_validation": [ + { + "configuration": "default", + "command": "python -m unittest experiments.shell.test_workflow -v", + "module_tests_passed": 16, + "module_tests_total": 16, + "pure_tests": 14, + "real_test_methods": 2, + "controller_host_rounds": 2, + "direct_fixture_host_rounds": 2, + "candidate_adapter_linked_into_probe": false, + "runs": [ + { + "round": 0, + "executable_sha256": "984a673af6994a105cbdae809a35048f46abe5d8e72959183311975aa2e2d0dc", + "client_sha256": "38261757e5ca2346a5bfc50ad5aa49ae161549888c838221858b13010b348e81", + "source_sha256": { + "experiments/shell/workflow_controller.hpp": "61005e442aa1729759dd1c1b8f0919826cb2e71f225fae53f0ea0c9cc28d38eb", + "experiments/shell/workflow_fixture_test.cpp": "892155cc5f6b42a6fff500607a9523e69318c53034d0e2d2f2ccaacf97420aeb", + "experiments/shell/test_workflow.py": "f935800810cb498d73fc5ec601b541da6d45562341b5b83a4d7027aa46b00dbc", + "experiments/shell/workflow_checks.py": "f186ff0f5335c26fc5c4f0286961981dfd6af8fbaafc789e2bc50058486efa44", + "experiments/conpty/session.hpp": "d909c9999ca3fa3cd18e064eab76df67343f4986826bf84e3ab15b38156a5568", + "experiments/shell/workflow_fixture.hpp": "332d73699320a6f503fc212417a4d3820b82cc2d3feb3d1fffe3930660d57453", + "experiments/shell/raw_console_receiver.hpp": "9ea35252c33a938a7dc76d4ea7c6db72d04a3fd010c1ce6b0289c9747e50afcc", + "experiments/shell/text_client.cpp": "aea8ed527e6e79fbe61446afadbb25d4fa0b7ddef4966930583ac8fa04cadd15" + }, + "status": "passed", + "returncode": 0, + "outer_job_after": { + "active": 0, + "total": 11, + "terminated": 0 + }, + "forced_cleanup": false, + "native_case_observations": [ + { + "name": "B graceful", + "role": "B", + "generation": 21, + "observed_generation": 21, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 997, + "transport_bytes_before_close": 997, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 1, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "A phases", + "role": "A", + "generation": 22, + "observed_generation": 22, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 19439, + "transport_bytes_before_close": 19503, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 2, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": true, + "parent_order_rejected": true, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "B closed", + "role": "B", + "generation": 23, + "observed_generation": 23, + "child_exit_code": 91, + "cmd_exit_code": 91, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 0, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 978, + "transport_bytes_before_close": 978, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 1, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": true, + "abnormal_completion_rejected": true, + "stopped_event": false, + "child_finished": false, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 91 + } + ], + "transport": [ + { + "name": "B graceful", + "bytes": 1067, + "sha256": "2653f7abced710945f77aa5e34b2e72eaf1b39016b932885e1ca5e64f57c482f" + }, + { + "name": "A phases", + "bytes": 19552, + "sha256": "54e6eafc0ac2cdcf474ba1c96e902f3c1bcbc984a6014f367ec4ec9cd8d0c5b7" + }, + { + "name": "B closed", + "bytes": 994, + "sha256": "fb4a69f0862ab40271040ce3f9ff6d452faa2dac68faa1e285dac70607178fdc" + } + ] + }, + { + "round": 1, + "executable_sha256": "984a673af6994a105cbdae809a35048f46abe5d8e72959183311975aa2e2d0dc", + "client_sha256": "38261757e5ca2346a5bfc50ad5aa49ae161549888c838221858b13010b348e81", + "source_sha256": { + "experiments/shell/workflow_controller.hpp": "61005e442aa1729759dd1c1b8f0919826cb2e71f225fae53f0ea0c9cc28d38eb", + "experiments/shell/workflow_fixture_test.cpp": "892155cc5f6b42a6fff500607a9523e69318c53034d0e2d2f2ccaacf97420aeb", + "experiments/shell/test_workflow.py": "f935800810cb498d73fc5ec601b541da6d45562341b5b83a4d7027aa46b00dbc", + "experiments/shell/workflow_checks.py": "f186ff0f5335c26fc5c4f0286961981dfd6af8fbaafc789e2bc50058486efa44", + "experiments/conpty/session.hpp": "d909c9999ca3fa3cd18e064eab76df67343f4986826bf84e3ab15b38156a5568", + "experiments/shell/workflow_fixture.hpp": "332d73699320a6f503fc212417a4d3820b82cc2d3feb3d1fffe3930660d57453", + "experiments/shell/raw_console_receiver.hpp": "9ea35252c33a938a7dc76d4ea7c6db72d04a3fd010c1ce6b0289c9747e50afcc", + "experiments/shell/text_client.cpp": "aea8ed527e6e79fbe61446afadbb25d4fa0b7ddef4966930583ac8fa04cadd15" + }, + "status": "passed", + "returncode": 0, + "outer_job_after": { + "active": 0, + "total": 11, + "terminated": 0 + }, + "forced_cleanup": false, + "native_case_observations": [ + { + "name": "B graceful", + "role": "B", + "generation": 21, + "observed_generation": 21, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 16, + "transport_bytes_before_exit_input": 997, + "transport_bytes_before_close": 1051, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 2, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "A phases", + "role": "A", + "generation": 22, + "observed_generation": 22, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 16, + "transport_bytes_before_exit_input": 19413, + "transport_bytes_before_close": 19477, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 2, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": true, + "parent_order_rejected": true, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "B closed", + "role": "B", + "generation": 23, + "observed_generation": 23, + "child_exit_code": 91, + "cmd_exit_code": 91, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 0, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 978, + "transport_bytes_before_close": 978, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 1, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": true, + "abnormal_completion_rejected": true, + "stopped_event": false, + "child_finished": false, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 91 + } + ], + "transport": [ + { + "name": "B graceful", + "bytes": 1067, + "sha256": "2c97f68a01607c33a24b1699fa966dacc05a671c960089ecef04b37bd3cf0524" + }, + { + "name": "A phases", + "bytes": 19526, + "sha256": "0e54f834cb4184ee9ccbb4e64403e33297340ef90e23fa17ab2508cfecec5b8c" + }, + { + "name": "B closed", + "bytes": 994, + "sha256": "a8d74936a383ce2ab05474b92e7c5768a69ce49382016c23370519d03b471bb1" + } + ] + } + ] + }, + { + "configuration": "candidate-option-on", + "command": "python -m unittest experiments.shell.test_workflow -v", + "module_tests_passed": 16, + "module_tests_total": 16, + "pure_tests": 14, + "real_test_methods": 2, + "controller_host_rounds": 2, + "direct_fixture_host_rounds": 2, + "candidate_adapter_linked_into_probe": false, + "runs": [ + { + "round": 0, + "executable_sha256": "b5be61c04199a1333a45ee32302ac34269a2bdf1ee0cb07db0cf8d13899a9f83", + "client_sha256": "7cb195decd5e232070320fd1f8d6f3ad6b7ead55d1e32dcee5275101e1f2e4a7", + "source_sha256": { + "experiments/shell/workflow_controller.hpp": "61005e442aa1729759dd1c1b8f0919826cb2e71f225fae53f0ea0c9cc28d38eb", + "experiments/shell/workflow_fixture_test.cpp": "892155cc5f6b42a6fff500607a9523e69318c53034d0e2d2f2ccaacf97420aeb", + "experiments/shell/test_workflow.py": "f935800810cb498d73fc5ec601b541da6d45562341b5b83a4d7027aa46b00dbc", + "experiments/shell/workflow_checks.py": "f186ff0f5335c26fc5c4f0286961981dfd6af8fbaafc789e2bc50058486efa44", + "experiments/conpty/session.hpp": "d909c9999ca3fa3cd18e064eab76df67343f4986826bf84e3ab15b38156a5568", + "experiments/shell/workflow_fixture.hpp": "332d73699320a6f503fc212417a4d3820b82cc2d3feb3d1fffe3930660d57453", + "experiments/shell/raw_console_receiver.hpp": "9ea35252c33a938a7dc76d4ea7c6db72d04a3fd010c1ce6b0289c9747e50afcc", + "experiments/shell/text_client.cpp": "aea8ed527e6e79fbe61446afadbb25d4fa0b7ddef4966930583ac8fa04cadd15" + }, + "status": "passed", + "returncode": 0, + "outer_job_after": { + "active": 0, + "total": 11, + "terminated": 0 + }, + "forced_cleanup": false, + "native_case_observations": [ + { + "name": "B graceful", + "role": "B", + "generation": 21, + "observed_generation": 21, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 1021, + "transport_bytes_before_close": 1075, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 2, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "A phases", + "role": "A", + "generation": 22, + "observed_generation": 22, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 19459, + "transport_bytes_before_close": 19523, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 2, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": true, + "parent_order_rejected": true, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "B closed", + "role": "B", + "generation": 23, + "observed_generation": 23, + "child_exit_code": 91, + "cmd_exit_code": 91, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 0, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 998, + "transport_bytes_before_close": 998, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 1, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": true, + "abnormal_completion_rejected": true, + "stopped_event": false, + "child_finished": false, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 91 + } + ], + "transport": [ + { + "name": "B graceful", + "bytes": 1091, + "sha256": "643c9d33cacf68c47c2649351428e871476df8007fba020b45a5bf2a0d72f6c2" + }, + { + "name": "A phases", + "bytes": 19572, + "sha256": "65fcc17a222b4b85475a5ad3ce81c616f2545d36366a541b33e9262eeda58e19" + }, + { + "name": "B closed", + "bytes": 1014, + "sha256": "b1f7fd399d4801bf02eff3500a94587f01e32c42607a2f54897ef93839d33e0d" + } + ] + }, + { + "round": 1, + "executable_sha256": "b5be61c04199a1333a45ee32302ac34269a2bdf1ee0cb07db0cf8d13899a9f83", + "client_sha256": "7cb195decd5e232070320fd1f8d6f3ad6b7ead55d1e32dcee5275101e1f2e4a7", + "source_sha256": { + "experiments/shell/workflow_controller.hpp": "61005e442aa1729759dd1c1b8f0919826cb2e71f225fae53f0ea0c9cc28d38eb", + "experiments/shell/workflow_fixture_test.cpp": "892155cc5f6b42a6fff500607a9523e69318c53034d0e2d2f2ccaacf97420aeb", + "experiments/shell/test_workflow.py": "f935800810cb498d73fc5ec601b541da6d45562341b5b83a4d7027aa46b00dbc", + "experiments/shell/workflow_checks.py": "f186ff0f5335c26fc5c4f0286961981dfd6af8fbaafc789e2bc50058486efa44", + "experiments/conpty/session.hpp": "d909c9999ca3fa3cd18e064eab76df67343f4986826bf84e3ab15b38156a5568", + "experiments/shell/workflow_fixture.hpp": "332d73699320a6f503fc212417a4d3820b82cc2d3feb3d1fffe3930660d57453", + "experiments/shell/raw_console_receiver.hpp": "9ea35252c33a938a7dc76d4ea7c6db72d04a3fd010c1ce6b0289c9747e50afcc", + "experiments/shell/text_client.cpp": "aea8ed527e6e79fbe61446afadbb25d4fa0b7ddef4966930583ac8fa04cadd15" + }, + "status": "passed", + "returncode": 0, + "outer_job_after": { + "active": 0, + "total": 11, + "terminated": 0 + }, + "forced_cleanup": false, + "native_case_observations": [ + { + "name": "B graceful", + "role": "B", + "generation": 21, + "observed_generation": 21, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 1021, + "transport_bytes_before_close": 1075, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 2, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "A phases", + "role": "A", + "generation": 22, + "observed_generation": 22, + "child_exit_code": 0, + "cmd_exit_code": 0, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 5, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 19459, + "transport_bytes_before_close": 19523, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 2, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": true, + "parent_order_rejected": true, + "target_job_cancelled": false, + "abnormal_completion_rejected": false, + "stopped_event": true, + "child_finished": true, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 0 + }, + { + "name": "B closed", + "role": "B", + "generation": 23, + "observed_generation": 23, + "child_exit_code": 91, + "cmd_exit_code": 91, + "cmd_native_wait_result_before_exit_input": 258, + "cmd_native_wait_error_before_exit_input": 0, + "cmd_native_wait_result": 0, + "cmd_native_wait_error": 0, + "exit_input_bytes": 0, + "cmd_exit_wait_ms": 0, + "transport_bytes_before_exit_input": 998, + "transport_bytes_before_close": 998, + "prompt_count_before_exit_input": 1, + "prompt_count_before_close": 1, + "inner_job_active_after": 0, + "launched_through_existing_cmd": true, + "identity_verified": true, + "child_joined": true, + "phases_complete": false, + "parent_order_rejected": false, + "target_job_cancelled": true, + "abnormal_completion_rejected": true, + "stopped_event": false, + "child_finished": false, + "cmd_exited_before_exit_input": false, + "cmd_wait_exit_result": true, + "forced_process_cancel": false, + "forced_reader_cancel": false, + "cleanup_complete": true, + "passed": true, + "cmd_exit_code_before_exit_input": null, + "preclose_cmd_exit_code": 91 + } + ], + "transport": [ + { + "name": "B graceful", + "bytes": 1091, + "sha256": "184e40cbe394f012c09791f9587667e52f51484da5d331273d6da94115abf2db" + }, + { + "name": "A phases", + "bytes": 19572, + "sha256": "d2dd60e12715440523dc026836f091f0359b8ff85d75318b6b8f6d87a3b856f7" + }, + { + "name": "B closed", + "bytes": 1014, + "sha256": "e938ab3e0efc1c33926f89e4aade3f9014c4ac23a50eec799928b27cdd14d29d" + } + ] + } + ] + } + ], + "limitations": [ + "The historic red is retained unchanged; green replay cannot establish its cause or close #44.", + "Prompt occurrence counts describe captured transport only, not input acceptance or presentation.", + "workflow_fixture_test and shell_text_client do not link the candidate adapter in either configuration.", + "The two final CMake configurations validate this shared probe, not candidate GUI or complete R2 behavior.", + "Private transport and native process identity are not published; public SHA values alone cannot independently re-evaluate all original raw assertions.", + "Source and binary hashes bind recorded inputs; no independent source-to-binary build attestation is claimed.", + "No global input, clipboard, IME, or local WSL preparation was used." + ] +} diff --git a/experiments/shell/test_workflow.py b/experiments/shell/test_workflow.py index 80bebd16..718be7f0 100644 --- a/experiments/shell/test_workflow.py +++ b/experiments/shell/test_workflow.py @@ -11,7 +11,7 @@ from experiments.build_paths import ROOT, binary from experiments.shell.workflow_checks import (validate_fixture_case, validate_rejected_order, validate_fixture_host, - validate_console_window) + validate_console_window, controller_observations, validate_controller_exit) PREFIX = 'TF_R108_PASTE 中文\rNEXT\r' @@ -94,6 +94,55 @@ def captured_rejected_order(directory): class WorkflowOracleTest(unittest.TestCase): + def test_failed_controller_observation_retains_unknown_without_private_fields(self): + # Synthetic failed-row counterexample, not recovered historical data: + # a five-second wait times out and close produces STATUS_CONTROL_C_EXIT. + # This tests the sanitizer without claiming to reproduce the cause. + row = dict(generation=21, observed_generation=21, child_exit_code=0, + cmd_exit_code=0xC000013A, exit_input_bytes=5, cmd_exit_wait_ms=5000, + transport_bytes_before_exit_input=100, transport_bytes_before_close=140, + prompt_count_before_exit_input=1, prompt_count_before_close=1, + inner_job_active_after=0, launched_through_existing_cmd=True, + identity_verified=True, child_joined=True, phases_complete=False, + parent_order_rejected=False, target_job_cancelled=False, + abnormal_completion_rejected=False, stopped_event=True, child_finished=True, + cmd_exited_before_exit_input=False, cmd_wait_exit_result=False, + cmd_native_wait_result_before_exit_input=258, cmd_native_wait_error_before_exit_input=0, + cmd_native_wait_result=258, cmd_native_wait_error=0, + forced_process_cancel=False, forced_reader_cancel=False, + cleanup_complete=True, passed=False, cmd_exit_code_before_exit_input=None, + preclose_cmd_exit_code=None, identity_pid=1234, process_creation_time=999, + raw_transport='PRIVATE') + host = dict(schema='termforge-workflow-controller-test-v1', passed=False, + cases=[dict(row, name=name, role=role) for name, role in + [('B graceful', 'B'), ('A phases', 'A'), ('B closed', 'B')]]) + observed = controller_observations(host) + self.assertFalse(observed[0]['cmd_wait_exit_result']) + self.assertIsNone(observed[0]['preclose_cmd_exit_code']) + self.assertFalse(observed[0]['passed']) + for value in observed: + self.assertTrue({'identity_pid', 'process_creation_time', 'raw_transport'}.isdisjoint(value)) + with self.assertRaises(ValueError): + validate_controller_exit(observed[0], closed=False) + + def test_controller_preclose_exit_cannot_be_replaced_by_cleanup(self): + row = dict(cmd_wait_exit_result=True, cmd_native_wait_result=0, cmd_native_wait_error=0, + preclose_cmd_exit_code=0, exit_input_bytes=5, + forced_process_cancel=False, forced_reader_cancel=False, cmd_exit_code=0) + validate_controller_exit(row, closed=False) + closed = dict(row, preclose_cmd_exit_code=91, exit_input_bytes=0, cmd_exit_code=91) + validate_controller_exit(closed, closed=True) + # A plausible post-close code and successful cleanup cannot fill an + # unobserved pre-close result; preserve the original timed-out verdict. + for patch in ({'cmd_wait_exit_result': False, 'preclose_cmd_exit_code': None}, + {'preclose_cmd_exit_code': 0xC000013A}, {'preclose_cmd_exit_code': False}, + {'cmd_native_wait_result': 258}, {'cmd_native_wait_result': 0xFFFFFFFF, 'cmd_native_wait_error': 6}, + {'cmd_native_wait_result': False}, {'cmd_native_wait_error': False}, + {'exit_input_bytes': 4}, {'exit_input_bytes': True}, + {'forced_process_cancel': True}, {'forced_reader_cancel': True}): + with self.subTest(patch=patch), self.assertRaises(ValueError): + validate_controller_exit(dict(row, **patch), closed=False) + def test_captured_rejected_order_can_stop_before_first_read(self): with tempfile.TemporaryDirectory() as temporary: directory = Path(temporary) @@ -316,9 +365,11 @@ def test_two_fresh_controller_rounds(self): client_sha256=hashlib.sha256(client.read_bytes()).hexdigest(), source_sha256={name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in ( 'experiments/shell/workflow_controller.hpp', 'experiments/shell/workflow_fixture_test.cpp', - 'experiments/shell/test_workflow.py', 'experiments/shell/workflow_checks.py')}, + 'experiments/shell/test_workflow.py', 'experiments/shell/workflow_checks.py', + 'experiments/conpty/session.hpp', 'experiments/shell/workflow_fixture.hpp', + 'experiments/shell/raw_console_receiver.hpp', 'experiments/shell/text_client.cpp')}, returncode=None, outer_job_after=None, forced_cleanup=False, timed_out=False, - process_reaped=False, error_kind=None, status='failed', cases=[]) + process_reaped=False, error_kind=None, status='failed', cases=[], native_case_observations=[]) job = OwnedJob(); process = None; stdout = stderr = b'' try: process = subprocess.Popen([str(executable), '--controller-host', str(cases)], stdin=subprocess.PIPE, @@ -328,10 +379,15 @@ def test_two_fresh_controller_rounds(self): stdout, stderr = process.communicate(b'GO\n', timeout=25) record['returncode'] = process.returncode record['outer_job_after'] = job.drain(timeout=3) + # Parse before the return-code assertion so a native failed + # case retains its pre-close observation, independently of the + # verified-success list below. Raw identity-bearing stdout is + # still private and is never copied into this allowlist. + host = json.loads(stdout) + record['native_case_observations'] = controller_observations(host) self.assertEqual(process.returncode, 0, stderr.decode('utf-8', 'replace')) self.assertEqual(stderr, b'') self.assertEqual(record['outer_job_after']['active'], 0) - host = json.loads(stdout) self.assertEqual(host['schema'], 'termforge-workflow-controller-test-v1') self.assertIs(host['data_only'], True) self.assertIs(host['ime_or_global_input_used'], False) @@ -369,6 +425,7 @@ def test_two_fresh_controller_rounds(self): self.assertIs(type(row['cmd_exit_code']), int) self.assertEqual(row['child_exit_code'], 91 if closed else 0) self.assertEqual(row['cmd_exit_code'], 91 if closed else 0) + validate_controller_exit(row, closed=closed) expected = (PREFIX+'你好').encode('utf-8') if name == 'B graceful' else b'' if closed: # Job cancellation does not produce a normal receiver finish. diff --git a/experiments/shell/workflow_checks.py b/experiments/shell/workflow_checks.py index f7e46a1e..f9e1a2c8 100644 --- a/experiments/shell/workflow_checks.py +++ b/experiments/shell/workflow_checks.py @@ -246,6 +246,57 @@ def document(suffix): console_window=console) +def controller_observations(host): + """Retain fixed scalar observations even when native status is failed. + + Process identities and raw transport stay private. This does not validate + success: a timed-out pre-close exit remains false/null after cleanup. + """ + _require(type(host) is dict and host.get('schema') == 'termforge-workflow-controller-test-v1', + 'controller host identity') + rows = host.get('cases') + _require(type(rows) is list and len(rows) == 3, 'controller case count') + integers = ('generation', 'observed_generation', 'child_exit_code', 'cmd_exit_code', + 'cmd_native_wait_result_before_exit_input', 'cmd_native_wait_error_before_exit_input', + 'cmd_native_wait_result', 'cmd_native_wait_error', + 'exit_input_bytes', 'cmd_exit_wait_ms', 'transport_bytes_before_exit_input', + 'transport_bytes_before_close', 'prompt_count_before_exit_input', + 'prompt_count_before_close', 'inner_job_active_after') + booleans = ('launched_through_existing_cmd', 'identity_verified', 'child_joined', + 'phases_complete', 'parent_order_rejected', 'target_job_cancelled', + 'abnormal_completion_rejected', 'stopped_event', 'child_finished', + 'cmd_exited_before_exit_input', 'cmd_wait_exit_result', 'forced_process_cancel', + 'forced_reader_cancel', 'cleanup_complete', 'passed') + results = [] + for row, (name, role) in zip(rows, [('B graceful', 'B'), ('A phases', 'A'), ('B closed', 'B')]): + _require(type(row) is dict and row.get('name') == name and row.get('role') == role, + 'controller case identity') + value = dict(name=name, role=role) + for key in integers: + value[key] = _uint(row.get(key), f'controller {key}') + for key in booleans: + _require(type(row.get(key)) is bool, f'controller {key}') + value[key] = row[key] + for key in ('cmd_exit_code_before_exit_input', 'preclose_cmd_exit_code'): + item = row.get(key) + value[key] = None if item is None else _uint(item, f'controller {key}') + results.append(value) + return results + + +def validate_controller_exit(row, *, closed): + """A code read after closing ConPTY cannot stand in for natural cmd exit.""" + expected = 91 if closed else 0 + _require(row.get('cmd_wait_exit_result') is True and + type(row.get('cmd_native_wait_result')) is int and row['cmd_native_wait_result'] == 0 and + type(row.get('cmd_native_wait_error')) is int and row['cmd_native_wait_error'] == 0 and + type(row.get('preclose_cmd_exit_code')) is int and + row['preclose_cmd_exit_code'] == expected and + type(row.get('exit_input_bytes')) is int and row['exit_input_bytes'] == (0 if closed else 5) and + row.get('forced_process_cancel') is False and row.get('forced_reader_cancel') is False, + 'controller pre-close exit/input/cleanup') + + def validate_fixture_host(directory, host): """Validate five actual host cases, including transport; outer Job is caller-owned.""" directory = Path(directory) diff --git a/experiments/shell/workflow_fixture_test.cpp b/experiments/shell/workflow_fixture_test.cpp index b11de1d0..fa8dc237 100644 --- a/experiments/shell/workflow_fixture_test.cpp +++ b/experiments/shell/workflow_fixture_test.cpp @@ -23,6 +23,11 @@ void save(const std::filesystem::path& path,const std::string& bytes) { std::ofstream file(path,std::ios::binary); file.write(bytes.data(),static_cast(bytes.size())); file.close(); check(file.good(),"workflow transport save failed"); } +size_t occurrences(const std::string& value,const std::string& needle) { + size_t count=0,position=0; + while((position=value.find(needle,position))!=std::string::npos) { ++count;position+=needle.size(); } + return count; +} struct Events { static constexpr const wchar_t* names[]{L"ready",L"output",L"output-done",L"append",L"append-done",L"evict",L"evict-done",L"stop",L"stopped"}; std::array values; @@ -168,8 +173,22 @@ bool controller_case(const std::filesystem::path& directory,const std::string& r controller.stop(); // Safe after the actual target died; only its owned event is signaled. } else finished=controller.finished(); const DWORD child_exit=controller.exit_code(); - if(!close_target) session.send("exit\r"); - const bool cmd_exited=session.wait_exit(5000);session.close();save(directory/L"transport.bin",session.output()); + // Observation only: do not wait for a fresh prompt or change the original + // input, five-second exit deadline, or close/cleanup contract. In particular + // the code observed after close must not replace a missing pre-close exit. + const auto before_exit_input=session.output(); + DWORD before_input_wait_result=MAXDWORD,before_input_wait_error=0; + const bool cmd_exited_before_input=session.wait_exit(0,&before_input_wait_result,&before_input_wait_error); + const DWORD cmd_code_before_input=session.exit_code; + size_t exit_input_bytes=0; + if(!close_target) session.send("exit\r",[&](size_t written) { exit_input_bytes+=written; }); + const auto wait_begun=GetTickCount64(); + DWORD cmd_wait_result=MAXDWORD,cmd_wait_error=0; + const bool cmd_exited=session.wait_exit(5000,&cmd_wait_result,&cmd_wait_error); + const auto exit_wait_ms=GetTickCount64()-wait_begun; + const DWORD preclose_exit_code=session.exit_code; + const auto before_close=session.output(); + session.close();save(directory/L"transport.bin",session.output()); bool payload=true; if(role=="A") { const auto source=read(capture.wstring()+L".source.bin"); @@ -184,6 +203,17 @@ bool controller_case(const std::filesystem::path& directory,const std::string& r <<",\"phases_complete\":"<<(phases?"true":"false")<<",\"parent_order_rejected\":"<<(order_rejected?"true":"false")<<",\"target_job_cancelled\":"<<(close_target?"true":"false") <<",\"abnormal_completion_rejected\":"<<(abnormal_rejected?"true":"false")<<",\"stopped_event\":"<<(controller.stopped_event()?"true":"false") <<",\"child_finished\":"<<(finished?"true":"false")<<",\"child_exit_code\":"<") + <<",\"prompt_count_before_close\":"<") + <<",\"forced_process_cancel\":"<<(session.forced_process_cancel?"true":"false") + <<",\"forced_reader_cancel\":"<<(session.forced_reader_cancel?"true":"false") <<",\"inner_job_active_after\":"< 4096 or not contents.endswith(b"\n"): + raise ValueError("incomplete or oversized fixture checkpoint record") + rows = contents.decode("ascii").splitlines() + if not 1 <= len(rows) <= 32: + raise ValueError("invalid checkpoint count") + events = [] + previous = -1 + for row in rows: + milliseconds, stage = row.split("\t") + if (not milliseconds.isdecimal() or len(milliseconds) > 10 + or stage not in FIXTURE_CHECKPOINT_STAGES): + raise ValueError("invalid fixture checkpoint") + elapsed = int(milliseconds) + if elapsed < previous: + raise ValueError("nonmonotonic fixture checkpoint") + events.append({"elapsed_milliseconds": elapsed, "stage": stage}) + previous = elapsed + observation.update(status="observed", events=events) + except FileNotFoundError: + pass + except (OSError, UnicodeError, ValueError) as error: + observation.update(status="unavailable", error=inventory.error_identity(error)) + return observation + + +class FixtureCheckpoints(unittest.TestCase): + def test_only_fixed_complete_monotonic_bounded_records_are_public(self): + for contents in (b"", b"0\tgo-received", b"0\tPRIVATE-PATH\n", + b"1\tgo-received\n0\tast-started\n", b"x\tast-started\n", + b"0\tast-started\textra\n", b"\xff\tast-started\n", + b"0\tast-started\n" * 33, b"X" * 4097): + with self.subTest(contents=contents[:30]), tempfile.TemporaryDirectory() as directory: + (Path(directory) / "fixture-progress.tsv").write_bytes(contents) + result = fixture_checkpoints(Path(directory)) + self.assertEqual(result["status"], "unavailable") + self.assertEqual(result["events"], []) + self.assertNotIn("PRIVATE", json.dumps(result)) + + def test_absent_and_unreadable_records_remain_unknown(self): + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(fixture_checkpoints(Path(directory)), + {"status": "missing", "events": [], "error": None}) + with patch.object(Path, "open", side_effect=PermissionError("PRIVATE")): + result = fixture_checkpoints(Path(directory)) + self.assertEqual(result["status"], "unavailable") + self.assertEqual(result["events"], []) + self.assertNotIn("PRIVATE", json.dumps(result)) + + class InventoryReports(unittest.TestCase): def test_guard_rejects_without_creating_output(self): with tempfile.TemporaryDirectory() as directory: @@ -248,9 +309,19 @@ def pure_script(self, body, lifecycle=False): if lifecycle: prefix += r""" $fixtureStopwatch = [Diagnostics.Stopwatch]::StartNew() +$fixtureProgressPath = [IO.Path]::Combine($PSScriptRoot, 'fixture-progress.tsv') +function Save-FixtureCheckpoint([string]$Stage) { + # CLR-only fixed-stage metadata survives a timeout before final JSON output. + # Do not load a module or query the environment to produce test telemetry. + $line = $fixtureStopwatch.ElapsedMilliseconds.ToString([Globalization.CultureInfo]::InvariantCulture) + "`t" + $Stage + "`n" + [IO.File]::AppendAllText($fixtureProgressPath, $line, [Text.Encoding]::ASCII) +} +Save-FixtureCheckpoint 'fixture-ready' [IO.File]::WriteAllText([IO.Path]::Combine($PSScriptRoot, 'fixture-ready'), 'ready') if ([Console]::ReadLine() -cne 'GO') { throw 'Missing fixture GO' } +Save-FixtureCheckpoint 'go-received' $fixtureAstStarted = $fixtureStopwatch.ElapsedMilliseconds +Save-FixtureCheckpoint 'ast-started' """ prefix += r""" $tokens = $null; $errors = $null @@ -258,7 +329,7 @@ def pure_script(self, body, lifecycle=False): if ($errors.Count) { throw 'Worker syntax errors' } """ if lifecycle: - prefix += "\n$fixtureAstParseMilliseconds = $fixtureStopwatch.ElapsedMilliseconds - $fixtureAstStarted\n" + prefix += "\n$fixtureAstParseMilliseconds = $fixtureStopwatch.ElapsedMilliseconds - $fixtureAstStarted\nSave-FixtureCheckpoint 'ast-ended'\n" with tempfile.TemporaryDirectory() as directory: script = Path(directory) / "pure-fixture.ps1" script.write_text(prefix + body, encoding="utf-8") @@ -275,11 +346,23 @@ def run_lifecycle_fixture(self, command, directory): timeout = 10 started = time.monotonic() job = process = None + result = None stdout = stderr = b"" record = {"timeout_seconds": timeout, "elapsed_seconds": None, "ready_seconds": None, - "returncode": None, "reaped": False, "timed_out": False, + "returncode": None, "reaped": None, "streams_drained": False, "timed_out": False, "job_before_cleanup": None, "job_after": None, "cleanup_errors": [], "error": None, - "outcome": None} + "outcome": None, "checkpoint_observation": None, + "cleanup_timeout_seconds": 5, "cleanup_complete": False} + + def cleanup_fault(stage, error): + record["cleanup_errors"].append({"stage": stage, **inventory.error_identity(error)}) + + def checked_accounting(observed): + if (type(observed) is not dict or set(observed) != {"active", "total", "terminated"} + or any(type(value) is not int or value < 0 for value in observed.values())): + raise ValueError("invalid owned Job accounting") + return observed + try: job = inventory.OwnedJob() process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -299,46 +382,90 @@ def run_lifecycle_fixture(self, command, directory): if remaining <= 0: raise subprocess.TimeoutExpired(command, timeout) stdout, stderr = process.communicate(b"GO\n", timeout=remaining) + record["streams_drained"] = True record["returncode"] = process.returncode self.assertEqual(process.returncode, 0, stderr.decode("utf-8", errors="replace")) result = json.loads(stdout) result["process"] = record record["outcome"] = {key: result[key] for key in ("status", "states", "state_timeline", "ast_parse_milliseconds")} - return result - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired as error: record["timed_out"] = True + record["error"] = inventory.error_identity(error) raise - except Exception as error: + except BaseException as error: record["error"] = inventory.error_identity(error) raise finally: + cleanup_deadline = time.monotonic() + record["cleanup_timeout_seconds"] if job is not None: try: - record["job_before_cleanup"] = job.accounting() + record["job_before_cleanup"] = checked_accounting(job.accounting()) + except Exception as error: + cleanup_fault("job_accounting", error) + try: job.terminate() except Exception as error: - record["cleanup_errors"].append(inventory.error_identity(error)) + cleanup_fault("job_terminate", error) if process is not None: try: - if process.poll() is None: - process.kill() - stdout, stderr = process.communicate(timeout=5) - record["returncode"] = process.returncode - record["reaped"] = process.poll() is not None + running = process.poll() is None except Exception as error: - record["cleanup_errors"].append(inventory.error_identity(error)) + running = True + cleanup_fault("process_poll", error) + if running: + try: + process.kill() + except Exception as error: + cleanup_fault("process_kill", error) + if not record["streams_drained"]: + try: + stdout, stderr = process.communicate(timeout=max(0, cleanup_deadline - time.monotonic())) + record["returncode"] = process.returncode + record["streams_drained"] = True + except Exception as error: + cleanup_fault("process_communicate", error) if job is not None: try: - record["job_after"] = job.drain(timeout=5) + record["job_after"] = checked_accounting( + job.drain(timeout=max(0, cleanup_deadline - time.monotonic()))) if record["job_after"]["active"]: - record["cleanup_errors"].append({"kind": "owned-job-not-empty", "code": None}) + cleanup_fault("job_not_empty", ValueError("owned Job not empty")) except Exception as error: - record["cleanup_errors"].append(inventory.error_identity(error)) + cleanup_fault("job_drain", error) finally: - job.close() + try: + job.close() + except Exception as error: + cleanup_fault("job_close", error) + if process is not None: + try: + if not record["streams_drained"]: + stdout, stderr = process.communicate(timeout=max(0, cleanup_deadline - time.monotonic())) + record["returncode"] = process.returncode + record["streams_drained"] = True + record["reaped"] = process.poll() is not None + if not record["reaped"]: + cleanup_fault("process_not_reaped", ValueError("owned process not reaped")) + except Exception as error: + cleanup_fault("process_reap_after_close", error) record["elapsed_seconds"] = time.monotonic() - started - print(json.dumps({"fixture": "powershell-final-progress", "lifecycle": record}), flush=True) + try: + record["checkpoint_observation"] = fixture_checkpoints(directory) + except Exception as error: + cleanup_fault("checkpoint_read", error) + record["checkpoint_observation"] = { + "status": "unavailable", "events": [], "error": inventory.error_identity(error)} + record["cleanup_complete"] = (not record["cleanup_errors"] and record["job_after"] is not None + and record["job_after"]["active"] == 0 and record["reaped"] is True and record["streams_drained"]) + try: + print(json.dumps({"fixture": "powershell-final-progress", "lifecycle": record}), flush=True) + except Exception as error: + cleanup_fault("summary_emit", error) + record["cleanup_complete"] = False + if not record["cleanup_complete"]: + raise ValueError("fixture_cleanup_failed") + return result def test_worker_syntax_and_parameter_sets_are_checked_without_union(self): result = self.pure_script(r""" @@ -363,28 +490,37 @@ def test_worker_syntax_and_parameter_sets_are_checked_without_union(self): self.assertIsNone(result["offline"]["online_parameter_set"]) self.assertIn("Online", result["offline"]["missing_parameters"]) - def test_failed_final_progress_cannot_return_complete(self): - result = self.pure_script(r""" + @staticmethod + def failed_progress_body(pause_final=False): + body = r""" $Operation = 'powershell'; $phase = $Operation $report = [ordered]@{status = 'failed'; data = [ordered]@{}} $script:states = @() $script:stateTimeline = @() function Save-Progress([string]$Phase, [string]$State, $ErrorInfo = $null) { + Save-FixtureCheckpoint ('progress-' + $State) $script:states += $State $script:stateTimeline += [pscustomobject]@{state = $State; elapsed_milliseconds = $fixtureStopwatch.ElapsedMilliseconds} - if ($State -eq 'ended') { throw 'Synthetic final progress write failure' } + if ($State -eq 'ended') { __PAUSE_FINAL__ throw 'Synthetic final progress write failure' } } $body = @($ast.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] }) if ($body.Count -ne 1) { throw 'Expected one top-level worker operation boundary' } +Save-FixtureCheckpoint 'worker-entered' . ([scriptblock]::Create($body[0].Extent.Text)) +Save-FixtureCheckpoint 'worker-returned' @{status=$report.status; error=$report.data.error; states=$script:states; state_timeline=@($script:stateTimeline); ast_parse_milliseconds=$fixtureAstParseMilliseconds} | ConvertTo-Json -Depth 8 -Compress -""", lifecycle=True) +""" + return body.replace("__PAUSE_FINAL__", "[Threading.Thread]::Sleep(20000);" if pause_final else "") + + def test_failed_final_progress_cannot_return_complete(self): + result = self.pure_script(self.failed_progress_body(), lifecycle=True) self.assertEqual(result["status"], "failed") self.assertEqual(result["states"], ["started", "ended", "failed"]) self.assertIn("kind", result["error"]) self.assertEqual(result["process"]["returncode"], 0) self.assertTrue(result["process"]["reaped"]) + self.assertTrue(result["process"]["streams_drained"]) self.assertEqual(result["process"]["job_after"]["active"], 0) self.assertEqual(result["process"]["cleanup_errors"], []) self.assertEqual(result["process"]["timeout_seconds"], 10) @@ -392,6 +528,10 @@ def test_failed_final_progress_cannot_return_complete(self): self.assertGreaterEqual(result["process"]["ready_seconds"], 0) self.assertGreaterEqual(result["ast_parse_milliseconds"], 0) self.assertEqual([row["state"] for row in result["state_timeline"]], result["states"]) + self.assertEqual([row["stage"] for row in result["process"]["checkpoint_observation"]["events"]], + ["fixture-ready", "go-received", "ast-started", "ast-ended", "worker-entered", + "progress-started", "progress-ended", "progress-failed", "worker-returned"]) + self.assertEqual(result["process"]["checkpoint_observation"]["status"], "observed") def test_lifecycle_timeout_is_preserved_after_owned_cleanup(self): with tempfile.TemporaryDirectory() as directory: @@ -408,6 +548,24 @@ def test_lifecycle_timeout_is_preserved_after_owned_cleanup(self): self.assertNotEqual(record["returncode"], 0) self.assertEqual(record["job_after"]["active"], 0) self.assertEqual(record["cleanup_errors"], []) + self.assertEqual(record["checkpoint_observation"]["status"], "missing") + + def test_timeout_keeps_post_gate_checkpoint_without_a_final_report(self): + with patch("builtins.print") as output, self.assertRaises(subprocess.TimeoutExpired): + self.pure_script(self.failed_progress_body(pause_final=True), lifecycle=True) + output.assert_called_once() + record = json.loads(output.call_args.args[0])["lifecycle"] + self.assertTrue(record["timed_out"]) + self.assertIsNone(record["outcome"]) + self.assertEqual(record["checkpoint_observation"]["status"], "observed") + self.assertEqual([row["stage"] for row in record["checkpoint_observation"]["events"]], + ["fixture-ready", "go-received", "ast-started", "ast-ended", "worker-entered", + "progress-started", "progress-ended"]) + self.assertEqual(record["timeout_seconds"], 10) + self.assertTrue(record["reaped"]) + self.assertTrue(record["streams_drained"]) + self.assertEqual(record["job_after"]["active"], 0) + self.assertEqual(record["cleanup_errors"], []) def test_tip_inventory_enumerates_a_non_enumerated_cmdlet_collection(self): body = r""" @@ -485,5 +643,90 @@ def test_worker_rejects_eof_or_nonhosted_go_before_operations(self): self.assertFalse(out.exists()) +class LifecycleCleanupFailures(unittest.TestCase): + def fixture(self, directory, process, job): + (directory / "fixture-ready").touch() + (directory / "fixture-progress.tsv").write_text("0\tfixture-ready\n", encoding="ascii") + with patch.object(inventory, "OwnedJob", return_value=job), \ + patch.object(subprocess, "Popen", return_value=process), \ + patch.object(subprocess, "CREATE_NO_WINDOW", 0, create=True): + return WorkerContract().run_lifecycle_fixture(["PRIVATE command"], directory) + + def mocks(self): + process, job = Mock(), Mock() + process.pid, process.returncode = 123, None + process.poll.return_value = None + job.accounting.return_value = {"active": 0, "total": 1, "terminated": 0} + job.drain.return_value = {"active": 0, "total": 1, "terminated": 0} + return process, job + + def test_original_timeout_survives_accounting_terminate_reap_and_close_failures(self): + process, job = self.mocks() + original = subprocess.TimeoutExpired("PRIVATE original", 10) + process.communicate.side_effect = [original, OSError("PRIVATE communicate"), OSError("PRIVATE reap")] + for method in (job.accounting, job.terminate, job.drain, job.close): + method.side_effect = OSError("PRIVATE cleanup") + with tempfile.TemporaryDirectory() as directory, patch("builtins.print") as output: + with self.assertRaises(subprocess.TimeoutExpired) as raised: + self.fixture(Path(directory), process, job) + self.assertIs(raised.exception, original) + output.assert_called_once() + record = json.loads(output.call_args.args[0])["lifecycle"] + self.assertTrue(record["timed_out"]) + self.assertIsNone(record["job_before_cleanup"]) + self.assertIsNone(record["job_after"]) + self.assertIsNone(record["reaped"]) + self.assertEqual(record["checkpoint_observation"]["status"], "observed") + self.assertNotIn("PRIVATE", json.dumps(record)) + self.assertEqual({row["stage"] for row in record["cleanup_errors"]}, + {"job_accounting", "job_terminate", "process_communicate", "job_drain", "job_close", + "process_reap_after_close"}) + job.terminate.assert_called_once() + job.close.assert_called_once() + + def test_cleanup_waits_share_one_five_second_deadline(self): + process, job = self.mocks() + original = subprocess.TimeoutExpired("PRIVATE original", 10) + process.communicate.side_effect = [original, (b"", b"")] + process.poll.side_effect = [None, None, 99] + with tempfile.TemporaryDirectory() as directory, patch("builtins.print"), \ + patch.object(time, "monotonic", side_effect=[0, 1, 2, 3, 7, 8, 9]): + with self.assertRaises(subprocess.TimeoutExpired) as raised: + self.fixture(Path(directory), process, job) + self.assertIs(raised.exception, original) + self.assertEqual(process.communicate.call_args_list[-1].kwargs["timeout"], 1) + self.assertEqual(job.drain.call_args.kwargs["timeout"], 0) + + def test_cleanup_failure_after_complete_report_is_not_success(self): + process, job = self.mocks() + process.poll.return_value = 0 + process.returncode = 0 + report = {"status": "failed", "states": ["started", "ended", "failed"], + "state_timeline": [], "ast_parse_milliseconds": 1} + process.communicate.return_value = (json.dumps(report).encode(), b"") + job.close.side_effect = OSError("PRIVATE close") + with tempfile.TemporaryDirectory() as directory, patch("builtins.print") as output: + with self.assertRaisesRegex(ValueError, "^fixture_cleanup_failed$"): + self.fixture(Path(directory), process, job) + output.assert_called_once() + record = json.loads(output.call_args.args[0])["lifecycle"] + self.assertEqual(record["cleanup_errors"][0]["stage"], "job_close") + self.assertEqual(record["checkpoint_observation"]["status"], "observed") + self.assertNotIn("PRIVATE", json.dumps(record)) + + def test_summary_emit_failure_cannot_replace_original_timeout(self): + process, job = self.mocks() + original = subprocess.TimeoutExpired("PRIVATE original", 10) + process.communicate.side_effect = [original, (b"", b"")] + process.poll.side_effect = [None, None, 99] + with tempfile.TemporaryDirectory() as directory, \ + patch("builtins.print", side_effect=OSError("PRIVATE output failure")) as output: + with self.assertRaises(subprocess.TimeoutExpired) as raised: + self.fixture(Path(directory), process, job) + self.assertIs(raised.exception, original) + output.assert_called_once() + job.close.assert_called_once() + + if __name__ == "__main__": unittest.main()