diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 0c6fb2a3..015f836d 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -10,11 +10,11 @@ |---|---| | main 默认:自研核心 + 系统 ConPTY | R1 限定总验收和有限 VT/Unicode 子集;默认构建不需要 Rust | | main 显式候选:`termforge.4` + 系统 ConPTY | [固定来源/补丁台账](../experiments/candidates/alacritty/PROVENANCE.md)、[适配边界](../experiments/core/CANDIDATE.md)、[#39 字素合同](research/streaming-graphemes.md);仅按已有合同验收 | -| PR #50 实验:`termforge.4` + 固定官方 ConPTY | [Draft PR](https://github.com/JavaZeroo/termforge/pull/50) / [#47](https://github.com/JavaZeroo/termforge/issues/47);[联合基线](research/joint-conpty-baseline.md)已固定,产品回归未全绿;[resize #51](https://github.com/JavaZeroo/termforge/issues/51)、[Console-width Vim #52](https://github.com/JavaZeroo/termforge/issues/52)、[确定性 sender fence #53](https://github.com/JavaZeroo/termforge/issues/53)仍开放,已有 completed 观察不构成部分发送取消验收 | +| PR #50 实验:`termforge.4` + 固定官方 ConPTY | [Draft PR](https://github.com/JavaZeroo/termforge/pull/50) / [#47](https://github.com/JavaZeroo/termforge/issues/47);[联合基线](research/joint-conpty-baseline.md)已固定,产品回归未全绿;[resize #51](https://github.com/JavaZeroo/termforge/issues/51)、[GUI Vim #52](https://github.com/JavaZeroo/termforge/issues/52)、[工作区 #63](https://github.com/JavaZeroo/termforge/issues/63)仍开放。[#53 的未完成粘贴、负例及原八会话证据](https://github.com/JavaZeroo/termforge/blob/848cb0e621a291f732cc0e046d0b2fab6a77d962/docs/research/resize-replay-cost.md)已按限定合同整合到实验父分支;不包含同次真实 CF/IME 或完整官方矩阵验收 | R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分别只代表原记录。源 VT、原生控制台 CHAR_INFO、transport、末端状态、自有 DIB、绘制提交和真实呈现是不同检查点;未知端点保持未知。 -显式官方后端的旧 `.3` 实现及独立生命期/双 SHA 拒绝记录见[实验合同](research/experimental-conpty-backend.md)与[冻结验证](../experiments/conpty/results/2026-10-26-reviewed-backend/validation.json)。联合源码 `3e5b96d` 的[三配置清单](../experiments/conpty/results/2026-10-26-joint-backend/validation.json)分别记录默认43/45、候选/system 48/48、显式官方46/48(shell套件超时,未完成项仍未知);原resize/Vim各一红一绿、粘贴两红。新恢复/worker/工作区失败由 #61–#63 承接,旧失败不因其他轮成功变成通过。 +显式官方后端的旧 `.3` 实现及独立生命期/双 SHA 拒绝记录见[实验合同](https://github.com/JavaZeroo/termforge/blob/e0b4d67e50bda6fe08cc02b43a475137133310b3/docs/research/experimental-conpty-backend.md)与[冻结验证](https://github.com/JavaZeroo/termforge/blob/e0b4d67e50bda6fe08cc02b43a475137133310b3/experiments/conpty/results/2026-10-26-reviewed-backend/validation.json)。联合源码 `3e5b96d` 的[三配置清单](../experiments/conpty/results/2026-10-26-joint-backend/validation.json)分别记录默认43/45、候选/system 48/48、显式官方46/48(shell套件超时,未完成项仍未知);原resize/Vim各一红一绿、粘贴两红。新恢复/worker/工作区失败由 #61–#63 承接,旧失败不因其他轮成功变成通过。 后续合并推送的两个独立失败见[原件审计](../experiments/shell/results/2026-10-26-postmerge-ci/validation.json):main `098f9ab` 工作区第二轮保存 stage2 [#72](https://github.com/JavaZeroo/termforge/issues/72),实验分支源码 `af563ba` 的 681 高度选择场景 [#73](https://github.com/JavaZeroo/termforge/issues/73)。两次 CI 实际均为默认自研核心/system;return2、Job0、desktop 已删除,原生退出/EOF 与逐运行 EXE 绑定仍未知。它们不等同已修 #61/#66,另一批 PR 成功不能覆盖这些失败。 @@ -60,6 +60,8 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 工作区恢复的命令编辑前缀(#61) | [BS 因果反例及复验](../experiments/shell/results/2026-10-26-workspace-restore-bs/validation.json)、[运输完成合同](research/workspace-restore-transport-oracle.md) | 原失败中的 PowerShell 命令重绘 BS 污染当前记录;真实 LF 后仍须完整输出与新提示符,回显/拼接/非法封装拒绝保留。仅修完成 oracle,原 30/40 秒与产品断言不变。 | | 工作区保存的空记录重绘(#72) | [原 122 B 因果回归与三配置结果](../experiments/shell/results/2026-10-27-workspace-empty-cr/validation.json)、[运输完成合同](research/workspace-restore-transport-oracle.md) | 两次 CI 失败记录逐字节相同;空且未编辑的记录在输出前允许裸 CR,仍须完整独立标记与新提示符。默认/system、候选/system、候选/官方各两轮保存/恢复/钳制共 18 次通过;最终完整回归、跳过和未闭合项独立记录。 | | WSL 原生尺寸程序返回边界(#80) | [机制、反例与复验](research/wsl-size-return.md)、[各阶段固定记录](../experiments/shell/results/2026-10-27-wsl-size-return/validation.json) | 尺寸输出后提前发送 exit 的受控 stage6 红例;实际事件握手修前红、修后与原两轮通过。默认/system 完整 46/46,12 个内部跳过单列;全 shell 提示符方案的 cmd stage5 失败保留。原 hosted 样本同因未知,其他配置、实际呈现及 #81 准备超时未由此验收。 | +| hosted 准备子命令边界(#81) | [白名单与观测合同](research/hosted-shell-command-boundaries.md)、[原失败及回归身份](../experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json) | 固定命令的调用/返回/超时和 worker 期限后的部分记录;写记录失败不替代实际命令结果。23 项准备/清理回归通过,主要过程使用模拟,真实 Job 用隔离 Python 子树;原 hosted 具体超时命令与根因仍未知。 | +| 选择复制夹具错误边界(#85) | [观测、控制与保护合同](research/selection-clipboard-boundaries.md)、[原失败及固定回归](../experiments/shell/results/2026-10-27-selection-clipboard-boundaries/validation.json) | 失败后记录固定阶段、读写目的、原错误及打开窗口/数据所有者类别,明确非原子;清理异常不覆盖主错误。默认/system 本地 46/46,13 个内部跳过;真实 CF 控制需 hosted 验证,原 OpenClipboard 5 根因仍未知,不作修复宣称。 | | 候选文字观测与回显端点(#38 夹具修正) | [同源码前后对照及反事实](../experiments/shell/results/2026-10-26-native-observation/validation.json)、[标准文本投影](../experiments/core/text_projection.hpp) | 观测改用标准文本投影,回显完成后才全选;原断言和负例保留。修正夹具时序/投影,不宣称产品选择策略已变或 R2 全绿。 | | 拒绝负例的读取前停止(#46 夹具修正) | [原记录、反事实及 fresh 验证](../experiments/shell/results/2026-10-26-receiver-stop/validation.json)、[公开空数据夹具](../experiments/shell/fixtures/rejected-order-before-read.json) | 旧 oracle 错误拒绝“首读前已停止”;同一空数据/零读原件红→绿,正常读取约束保留。限定夹具修正,不解释 #44 的退出。 | | 候选 GUI 合法空标题(#45) | [真实 GUI 前后对照](../experiments/shell/results/2026-10-26-empty-title/validation.json)、[转换入口](../experiments/presentation/render.hpp)、[持续回归](../experiments/shell/candidate_title_test.cpp) | 真实空 OSC 标题使旧 UTF 转换抛异常;修后两 fresh GUI 正常,非法非空反例保留。不回填旧 normal-1 未知异常的根因。 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ba83f865..aa28f292 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -14,7 +14,7 @@ |---|---|---| | 默认 main:自研核心 + 系统 ConPTY | 默认构建与已发布 Alpha 的基础路线,不需要 Rust | 已有 R1 限定验收;VT/Unicode 能力仍为有限子集,不能引用候选核心的字素或 TUI 结果 | | 显式候选 main:`termforge.4` + 系统 ConPTY | `TERMFORGE_BUILD_CANDIDATE_ADAPTER=ON`;按 [ADR-0003](adr/0003-core-reference-and-reuse.md)保留为可评估实验,正式核心选型待用户确认 | [固定来源](../experiments/candidates/alacritty/PROVENANCE.md)、[适配边界](../experiments/core/CANDIDATE.md)与 [#39 字素合同](research/streaming-graphemes.md);候选正确性通过不关闭系统 ConPTY 或 R2 父项 | -| 实验分支:`termforge.4` + 固定官方 ConPTY | [PR #50(Draft)](https://github.com/JavaZeroo/termforge/pull/50),联合源码 `3e5b96d` 已重建并[冻结真实基线](research/joint-conpty-baseline.md),尚未合并或通过产品整体验收 | [#47](https://github.com/JavaZeroo/termforge/issues/47) 跟踪后端整合;终端 margin 与原生状态分列。[resize 残尾 #51](https://github.com/JavaZeroo/termforge/issues/51)、[Console-width Vim #52](https://github.com/JavaZeroo/termforge/issues/52)、[未完成粘贴的确定性取消夹具 #53](https://github.com/JavaZeroo/termforge/issues/53)仍开放,现有 completed 观察不能证明部分发送取消通过或产品 Cancel 故障;worker gate [#62](https://github.com/JavaZeroo/termforge/issues/62) 已有[独立夹具修正与双后端实际 worker 验证](research/sender-fence.md),默认产品路径不启用 fence,也不替代 #53 的 UI 取消/关闭;工作区 [#63](https://github.com/JavaZeroo/termforge/issues/63) 回归仍开放 | +| 实验分支:`termforge.4` + 固定官方 ConPTY | [PR #50(Draft)](https://github.com/JavaZeroo/termforge/pull/50),联合源码 `3e5b96d` 的[原基线](research/joint-conpty-baseline.md)保留;后续限定修复已整合,尚未合入 main 或通过产品整体验收 | [#47](https://github.com/JavaZeroo/termforge/issues/47) 跟踪后端整合;终端 margin 与原生状态分列。[resize #51](https://github.com/JavaZeroo/termforge/issues/51)、[GUI Vim #52](https://github.com/JavaZeroo/termforge/issues/52)、[工作区 #63](https://github.com/JavaZeroo/termforge/issues/63) 仍开放。[#53](https://github.com/JavaZeroo/termforge/issues/53) 的原 3MB/768B/16KiB 未完成粘贴取消/关闭、负例及八会话 data-only 两轮已限定验证并整合到实验父分支;同次真实 CF/IME 与完整官方矩阵未通过。fence 默认关闭,不由实验结果选择产品后端 | 以下能力默认指已合并实现;某个配置的通过结果不能转用于另一个配置。R1 的真实系统 CF/拼音验收属于固定自研核心配置,不外推到候选 `.4` 或实验后端联合配置。 @@ -68,7 +68,7 @@ | 组合验收基础 | **限定验收**:[固定源码审计](../experiments/shell/results/2026-10-24-r1-workflow-foundation/validation.json)覆盖基础数据与快照;[受控夹具](../experiments/shell/WORKFLOW.md)分离编号日志、ConPTY传输、原始接收与阶段日志;固定输入前缀保持4096 UTF-16容量,旧IME模式仍要求空前缀 | 该基础证据不单独验收八会话或真实Copy→Paste→IME;组合出口见 #18 独立验收 | R1 | | 八会话文字工作流 | **限定验收**:[R1 固定源码整体验收](../experiments/shell/results/2026-10-24-r1-system-workflow/validation.json)覆盖同次 8 会话/拒绝第 9 个、后台有限洪流、历史/Find/实际选择与 CF 复制/确认粘贴/真实拼音、切换缩放、淘汰、取消关闭和重复回收;实际字节、目标隔离、动作与 Job/desktop 回收有独立审计 | 限定 Windows 固定拼音、本地 cmd 与有界负载;这是固定 R1 配置的组合验收,不能替代 R2 候选/新后端组合。实际呈现、常用 TUI、长期稳定及 UI/性能等价仍未验收 | R1 | | 活体资源与滚动端点 | **限定测量**:普通启动默认关闭;[R1 组合审计](../experiments/shell/results/2026-10-24-r1-system-workflow/validation.json)绑定实际接收、state 与同 revision 的 WM_PAINT/BitBlt/GdiFlush 提交,保留跨时钟校准与阶段资源,可离线重算 | launcher-tree 不等于 UI 归因;观察进程成本不等于增量采样成本,短命进程及退出 CPU 可能漏采。短驻留不证明长期无泄漏,`presented_done=null`,不构成性能通过 | R1;扩展 R4 / R5 | -| 自动正确性与构建 | **限定验收**:Windows shell/ConPTY/呈现及 Windows/Linux 核心 CI、差分/恢复/队列/生命周期场景 | 产品打包、干净机器使用、依赖升级、长期回归和更广平台矩阵;#40 的 CI 纯夹具超时、窄窗口选择 #73、四 shell 原事件 #80 及 hosted 准备 #81 等独立失败仍未闭合;#72 保存判据与 #80 同步条件的限定修正不代替其他配置或未知根因验收 | R2 / R4 | +| 自动正确性与构建 | **限定验收**:Windows shell/ConPTY/呈现及 Windows/Linux 核心 CI、差分/恢复/队列/生命周期场景 | 产品打包、干净机器使用、依赖升级、长期回归和更广平台矩阵;#40 的 CI 纯夹具超时、窄窗口选择 #73、四 shell 原事件 #80、hosted 准备 #81 和[复制夹具 OpenClipboard 5 #85](https://github.com/JavaZeroo/termforge/issues/85) 等独立失败仍未闭合;#72 保存判据与 #80 同步条件的限定修正不代替其他配置或未知根因验收 | R2 / R4 | | Windows 产品交付 | **实现中,未验收**:首个 [v0.1.0-alpha.1 预发布](releases/v0.1.0-alpha.1.md)提供 Windows x64、静态 MSVC 运行库、附资源许可和构建身份的免安装 ZIP;未签名,已验环境限 Windows 11 本机与 Server 2025 CI,布局仍写入 LOCALAPPDATA | 这是 R1 体验包,尚非 R2 日常开发版;干净机器与更广平台矩阵、签名/安装、配置升级及持续运行仍待完成。Windows 10 未验收,Server 2022 已知资源门槛未过 | R2 / R3 | | Linux/macOS 与其他架构 | **未实现**客户端;Linux 核心 CI 已有。[官方平台范围台账](../research/windterm-2.7.0-platform-scope.md)已固定 2.7.0 的 Windows x86/x64、macOS x64、Linux x64 资产及声明下限;这不是 TermForge 支持或实测结论 | 最终需真实 Linux/macOS 客户端;平台调查可提前进行,整合及完整验收排在 Windows 11 x64 完整版本之后。其他系统版本、最低版本和架构由用户后续明确;旧系统及 ARM 等保持调查记录,不自动承诺或排除,也不能由核心 CI 推断产品支持 | 持续调查;R5 全平台出口 | | 吞吐、响应、内存与稳定性 | **实现中,未验收**:已有机制实验、连续 CPU 区间校准与合成回归;候选字素改动的受限诊断中 project/interactive 吞吐中位数约下降 16%/9%,由 [#54](https://github.com/JavaZeroo/termforge/issues/54) 跟踪 | 共享宿主、allocator 包装与缺少真实呈现/输入时延端点使该诊断不构成正式预算。仍需同负载/同正确性、无插桩对照、观测开销和长期/多会话公平性;可靠原版基线后提出逐项预算及允许波动范围,**由用户明确确认**。与 WindTerm 的实际差距**待取证** | 当前优先;贯穿 R1–R5 | diff --git a/docs/TRACKING.md b/docs/TRACKING.md index 10e239ea..d122c9ce 100644 --- a/docs/TRACKING.md +++ b/docs/TRACKING.md @@ -24,7 +24,7 @@ | 原生 API / VT 末端 margin 的处理路径 | [平台语义与方案 #56](https://github.com/JavaZeroo/termforge/issues/56) | 可用固定探针独立推进;不能以策略说明豁免 #41 原合同 | | 实验后端连续 resize | [残尾与 Unicode 重放 #51](https://github.com/JavaZeroo/termforge/issues/51) | 原冻结配置可诊断;最终修复验收使用 #55 联合基线 | | 实验后端 Vim | [Console-width Vim #52](https://github.com/JavaZeroo/termforge/issues/52) | 同上;GUI stage59 与 native new-buffer 分列,早期 flags0 成功不替代最终配置 | -| 实验后端未完成粘贴取消 | [确定性 sender fence #53](https://github.com/JavaZeroo/termforge/issues/53) | 保留 3MB/768 checkpoint/16KiB 门槛;最终验收使用 #55,completed 不算 cancelled | +| 实验后端未完成粘贴取消 | [确定性 sender fence #53](https://github.com/JavaZeroo/termforge/issues/53) | 原 3MB/768B/16KiB 未完成期 cancel/close、负例与八会话 data-only 已限定闭合,PR #76 已合入实验父分支;完整官方矩阵与同次 CF/IME 仍由 #47 接续 | | 终端模式、备用屏与滚动区 | [终端行为 #27](https://github.com/JavaZeroo/termforge/issues/27) | 核心前置 #26 已验;整体验收由 #41 阻塞 | | resize、逻辑行与历史 | [resize 合同 #29](https://github.com/JavaZeroo/termforge/issues/29) | 核心前置 #26 已验;与 #27/#28 联合核对模式、区域、历史和实际 child 尺寸 | | 查询回复、应用模式与真实 TUI | [固定应用矩阵 #28](https://github.com/JavaZeroo/termforge/issues/28) | #27;完成时核对 #29 的 resize 合同及目标配置 | @@ -37,13 +37,13 @@ | 工作 | 责任任务 | 完成条件的归属 | |---|---|---| | 持续输出中的 Ctrl-C 历史失败 | [原始 ETX 回归 #36](https://github.com/JavaZeroo/termforge/issues/36) | 有界定位历史因果与原生交付;后续通过不回填旧因 | -| 新增实际回归 | [恢复 oracle 修正 #61](https://github.com/JavaZeroo/termforge/issues/61) · [InputPump gate #62](https://github.com/JavaZeroo/termforge/issues/62) · [工作区 stage52 #63](https://github.com/JavaZeroo/termforge/issues/63) · [hosted reverse #66](https://github.com/JavaZeroo/termforge/issues/66) | #61 恢复 oracle、#66 双会话 tick 门槛已按原合同限定闭合;#62 实验夹具已限定闭合,#63 继续开放;#53 的 UI fence 尚未接入,不放宽期限、负载或几何断言 | -| 后续合并推送的新失败 | [保存 stage2 #72](https://github.com/JavaZeroo/termforge/issues/72) · [681 高度选择 #73](https://github.com/JavaZeroo/termforge/issues/73) | 两次均为默认/system;原件已审计,精确阶段/字节与因果另查,不重开不同触发的 #61/#66 | +| 新增实际回归 | [恢复 oracle 修正 #61](https://github.com/JavaZeroo/termforge/issues/61) · [InputPump gate #62](https://github.com/JavaZeroo/termforge/issues/62) · [工作区 stage52 #63](https://github.com/JavaZeroo/termforge/issues/63) · [hosted reverse #66](https://github.com/JavaZeroo/termforge/issues/66) | #61/#66 与实验 #62/#53 已按各自原合同限定闭合,#63 继续开放;不放宽期限、负载或几何断言 | +| 工作区保存判据与选择失败 | [保存 stage2 #72](https://github.com/JavaZeroo/termforge/issues/72) · [681 高度选择 #73](https://github.com/JavaZeroo/termforge/issues/73) | #72 的原字节因果修正已合入主线;#73 精确阶段与因果仍待定位,不重开不同触发的 #61/#66 | | 原 10 秒纯夹具超时 | [IME inventory 定位 #40](https://github.com/JavaZeroo/termforge/issues/40) | 阶段与清理已补齐;下一步是固定源码/宿主的有界原因定位 | | controller 自然退出 | [close 前退出 #44](https://github.com/JavaZeroo/termforge/issues/44) | 保留 send/wait/pre-close 与清理差异;不能以 close 后结果当自然退出 | | 旧 normal-1 未知异常 | [事件转换边界 #57](https://github.com/JavaZeroo/termforge/issues/57) | 独立于已修合法空标题 #45;原事件 kind/载荷未知,不能回填根因 | -| Windows 工作流可靠性 | [回归汇总 #38](https://github.com/JavaZeroo/termforge/issues/38) | #40/#44/#57/#72/#73;已闭合切片和旧失败保留在该总项,不再排入当前队列 | -| 显式官方 ConPTY 整合 | [后端汇总 #47](https://github.com/JavaZeroo/termforge/issues/47) | #51/#52(GUI)/#53/#63;#62 worker 夹具与 #52 native 捕获已独立修正,#55 已固定实验联合基线,实验可选不等于产品默认或日常验收 | +| Windows 工作流可靠性 | [回归汇总 #38](https://github.com/JavaZeroo/termforge/issues/38) | #40/#44/#57/#73/#80/#81/[#85](https://github.com/JavaZeroo/termforge/issues/85);已闭合切片和旧失败保留在该总项,不再排入当前队列 | +| 显式官方 ConPTY 整合 | [后端汇总 #47](https://github.com/JavaZeroo/termforge/issues/47) | #51/#52(GUI)/#63;#62/#53 与 #52 native 捕获已独立修正,#55 已固定实验联合基线,实验可选不等于产品默认或日常验收 | | 每缓冲区 margin 端到端合同 | [margin 汇总 #41](https://github.com/JavaZeroo/termforge/issues/41) | #47/#56;原生 API、transport 和末端分别验证 | | 产品核心、GUI/平台与呈现边界 | [选型提案与用户确认 #34](https://github.com/JavaZeroo/termforge/issues/34) | #28–#31/#54,结合 #23/#26/#32/#33 和原版对标证据;重审 ADR-0003/0004,记录用户确认 | | 最终 Windows 开发版 | [产物与持续运行 #35](https://github.com/JavaZeroo/termforge/issues/35) | #27–#34/#36/#38;在最终产物上执行完整合同后核对 #7 出口 | diff --git a/docs/milestones/R2-local-terminal.md b/docs/milestones/R2-local-terminal.md index 905f4a1f..23b00d7b 100644 --- a/docs/milestones/R2-local-terminal.md +++ b/docs/milestones/R2-local-terminal.md @@ -28,7 +28,7 @@ R2 的前期平台调查/候选比较、有限会话与恢复、首批流式字 ```mermaid flowchart LR baseline["55 联合基线"] --> backend["47 后端整合"] - fixes["51–53 后端缺口"] --> backend + fixes["51 / 52 / 63 后端缺口"] --> backend baseline -. "最终修复复验" .-> fixes native["56 原生语义路径"] --> margin["41 margin 合同"] backend --> margin --> modes["27 终端模式"] --> tui["28 固定 TUI"] @@ -44,7 +44,7 @@ flowchart LR ## 下一批的任务边界 -1. **在已冻结基线上逐项闭合后端回归。** #55 已交付含红的真实基线;接续 #51–#53 与 #63 的独立失败。#62 的[真实 writer 持块、取消与停止夹具](../research/sender-fence.md)不降低原 4MiB/16KiB/期限,下一步把默认关闭的 fence 接到 #53 原 UI/3MB/八会话入口;这不代表这些 UI 路径已通过。#51–#53 可以用原冻结输入独立定位,最终修复验收须在联合配置重建;一次未复现不自动构成因果修复。 +1. **在已冻结基线上逐项闭合后端回归。** #55 已交付含红的真实基线;接续 #51/#52/#63 的独立失败。#62 的[真实 writer 持块、取消与停止夹具](../research/sender-fence.md)及 #53 原 UI/3MB/八会话 data-only 合同已限定验证并整合到实验父分支,期限与负载保持;同次真实 CF/IME 和完整官方矩阵仍未通过。未完成项可以用原冻结输入独立定位,最终修复验收须在联合配置重建;一次未复现不自动构成因果修复。 2. **并行处理原版对照、平台语义与性能。** [原生语义处理路径 #56](https://github.com/JavaZeroo/termforge/issues/56)先明确 #41 原合同需要什么、哪些路径可支持;保留必要的上游或独立实现工作,不用“平台不同”豁免缺口。#3/#4 优先建立原版 Windows 本地终端基线;#54 现在冻结重复方法与完成端点并定位吞吐成本,数值预算作为提案交用户确认,不等到 #34 才补性能。 3. **在主线推进独立行为合同。** #29 核对极窄/极短、软硬行、历史/淘汰、选择/Find、连续 resize 和 child ack;#30 核对 IME 组合/提交/取消与输出/resize/切换、应用键盘/粘贴接收、实际复制及同次八会话。已验收的 #39 只提供首批字素基础,最终配置的交叉验收仍需执行。 4. **补终端模式和应用矩阵。** #27 明确 DECOM/DECAWM、pending-wrap、保存恢复、异常序列与 margin×resize;#28 先固定应用版本/入口/动作及协议字节预期,再逐应用完成进入、操作、resize、退出、主屏恢复。Vim 的一个修复不替代完整 TUI 验收。 @@ -63,7 +63,7 @@ flowchart LR | 门槛与责任项 | 验收必须证明什么 | 不能代替验收的结果 | |---|---|---| -| 回归可靠性:#36、#38(含 #40、#44、#57、#72、#73、#80、#81) | 持续输出中断、controller 子进程到 shell 的正常退出、原 10 秒纯夹具及保存/选择、四 shell 交接、hosted 准备失败路径均有明确阶段、字节、退出和自有 Job 回收证据;修复对应真实失败,最终声明配置的完整回归通过 | 多次重跑绿不能回填旧失败原因;close 后的退出码不能冒充自然退出;增加超时或跳过不能解除阻塞 | +| 回归可靠性:#36、#38(含 #40、#44、#57、#73、#80、#81、#85) | 持续输出中断、controller 子进程到 shell 的正常退出、原 10 秒纯夹具及保存/选择、四 shell 交接、hosted 准备和复制夹具失败路径均有明确阶段、字节、退出和自有 Job 回收证据;修复对应真实失败,最终声明配置的完整回归通过 | 多次重跑绿不能回填旧失败原因;close 后的退出码不能冒充自然退出;增加超时或跳过不能解除阻塞 | | 终端模式:#27、#41 | 候选核心主/备用屏、各自滚动区域、origin/wrap、光标/样式、历史隔离及 resize 交叉符合固定合同;真实 ConPTY 路径保留实现这些语义所需的信息 | 独立核心 feed 正确不能补足宿主已经丢失的信息;不能猜测补发原运输中不存在的 VT,或用“已知平台差异”自行关闭功能缺口 | | resize 与真实 TUI:#29、#28 | 逻辑/物理行、宽字符、历史与淘汰有明确策略;窗口、核心和真实子进程尺寸一致;固定应用逐一完成进入、操作、resize、退出、主屏恢复;回复、输入模式和 bracketed paste 字节经独立预期验证 | 离线回放、单独 Vim 成功或默认核心通过,不能代表整个候选产品矩阵;未声明应用不计完成 | | 复杂文字与输入:#30(复用已验 #39) | 保留首批字素合同,在最终配置核对原始标量、宽度/光标、实际 glyph、复制与子进程接收;完成固定 IME 与同次八会话交叉回归 | #39 的字素与单列 resize 成功不代表完整 IME、应用模式或 R1 系统输入组合在另一配置已通过;未知 WindTerm 行为仍保留 | diff --git a/docs/research/hosted-shell-command-boundaries.md b/docs/research/hosted-shell-command-boundaries.md new file mode 100644 index 00000000..1dd74a95 --- /dev/null +++ b/docs/research/hosted-shell-command-boundaries.md @@ -0,0 +1,23 @@ +# hosted shell 准备的子命令观测边界 + +[#81](https://github.com/JavaZeroo/termforge/issues/81) 保存 [PR #76 的 preflight 失败](https://github.com/JavaZeroo/termforge/actions/runs/40366292708/job/121724790152):`command_deadline`,产品测试未运行。原摘要只知道 worker 退出 1、Job active0/total6/terminated0,不能识别 cmd、PowerShell 5.1、PowerShell 7 或 wsl-list 中哪条命令超时,也不能从进程总数推定。 + +准备程序现在在实际 `run_command` 调用处写一个有界原子记录,父进程确认 worker 已回收后才读取,并把通过严格白名单验证的字段放入公开摘要。原命令参数、工作目录、stdout/stderr、环境和异常文本仍保留在私有目录,不进入记录。 + +| 阶段 | 能证明什么 | 不能证明什么 | +|---|---|---| +| `invoking` | 即将进入 `subprocess.run`,固定命令 ID 已明确 | OS 已经创建子进程、命令已成功启动 | +| `returned` | `subprocess.run` 返回及其实际 returncode;记录该调用的单调时钟耗时 | shell 标记正确、WSL 已可用、整个准备或 Job 清理通过 | +| `timed_out` | 实际调用抛出 `TimeoutExpired`;退出码保持 null | 子进程自然退出,或已知其最终退出码 | + +记录只允许各 step 的固定命令顺序,最多 8 个边界、8192 B;未知字段、枚举、类型、乱序或过大数据整体不公开。`observed` 可以只含最后一条 `invoking`,不表示命令完成。缺记录、无子命令的 download、无效记录及 worker 尚未回收分别报告;这些诊断状态不替代原准备结果与清理判据。任一边界写入失败只留下缺失或部分记录,实际命令仍按原返回码、输出大小和 marker 判定;超时仍报告 `command_deadline`。 + +单命令 10 秒、preflight 45 秒、import 80/90 秒及 startup 20/25 秒等既有预算不变;不增加 sleep、重试或跳过。hosted、工作目录与自有 Job 保护保持原位,不在个人 Windows 环境运行准备流程或改动 WSL。 + +验证使用现有 `run_command` 和 worker→parent 摘要入口:模拟超时后,旧实现因缺记录出现红例;新实现保留固定命令身份。故障注入另证明记录失败不能覆盖原超时;开始/返回记录写失败与命令返回 0/7 的四种组合也分别保留真实结果及 marker 判定。外层 worker 期限留下的部分记录、退出/回收区分、未知数据不泄漏均有回归。准备与清理两模块共 23 项通过,其中自有 Job API 用真实隔离 Python 子树,其余受控过程使用模拟,未据此声称重现原 hosted 超时。 + +```powershell +python -m unittest tools.test_prepare_hosted_shells tools.test_owned_job_cleanup -v +``` + +原 artifact 与本次代码/验证身份见[限定记录](../../experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json)。后续 hosted 运行继续沿用原 workflow;即使不再超时,也只证明该次运行通过,#81 原根因仍待定位。#80 的四 shell 交接与此准备阶段没有同因证据。 diff --git a/docs/research/selection-clipboard-boundaries.md b/docs/research/selection-clipboard-boundaries.md new file mode 100644 index 00000000..24744154 --- /dev/null +++ b/docs/research/selection-clipboard-boundaries.md @@ -0,0 +1,33 @@ +# 选择复制夹具的原生错误边界(#85) + +## 问题与来源 + +PR83 的 [Windows CI 40377249027](https://github.com/JavaZeroo/termforge/actions/runs/40377249027/job/121763560184) 使用默认自研核心/system,源码 `79352f2b36f9f2a8828368ced089f6e2a6fda078`、合并 checkout `939866f1cfd7ccc96fb9fdee0c6a493b6ebd3c07`。完整 CTest 45/46、265.77s;选择字节场景的第一轮真实系统复制返回 `OpenClipboard: 5`。准备五步和四 shell 已通过,不将此失败归入 #81。 + +原 artifact12677990045(4714691B,SHA256 `7575a5d82fec271625affe62a19a5f21edf97ae2a6bcd6b005f070070bcfa28f`)已本地核对并保留。system-0 只有 forward/reverse 两条完整进度,均为预期的合成文字;没有第三条 dpi-96 或最终结果,system-1 未运行。退出2、非超时、Job active0/total11/terminated0,桌面已删除。 + +**原根因未知。** 现有证据无法区分 stage6 的 sentinel 写入和 stage7 的独立读回,不能由错误5推断具体占用者或访问上下文。后续控制成功或正常复制通过均不能补写原事件原因。[#85](https://github.com/JavaZeroo/termforge/issues/85) 持续追踪;本次交付是诊断和控制验证。 + +## 失败后的观测合同 + +`scenario_selection_bytes` 只在原实际 CF 调用抛出 `ClipboardError` 后记录:固定 stage、`sentinel-write`/`readback`、`OpenClipboard`/其他剪贴板操作、原错误码,以及打开窗口与数据所有者的独立类别。类别仅为未报告、无法判断、工作区窗口、同进程其他窗口、其他进程窗口,不输出 HWND/PID、标题、路径、其他窗口文本或剪贴板内容。 + +记录标为 `after-error-nonatomic`。两次 Win32 查询不是同一时刻的快照,也不是失败瞬间的归因;未报告打开窗口不证明没有占用。诊断采用临时文件再替换,写入失败不替换原错误。 + +原错误处理还可能遇到自有 owner 清理异常。`rethrow_clipboard_failure` 保留主异常的类型和内容,清理异常只输出固定诊断;即使诊断流再次失败,仍重抛主异常。实际 owner 的 release/finish 合同不放宽:控制用例必须取得成功释放记录、线程退出和所有原生/worker错误为0,否则测试失败。 + +## 受控占用与个人环境保护 + +默认关闭的 `TERMFORGE_TEST_SELECTION_CLIPBOARD_HOLD` 只有 `sentinel-write`/`readback` 两个值,且仅在原 hosted system 场景允许。它复用已有 `ClipboardScenarioOwner(false)`,由自有线程/窗口通过事件确认真实占用,在 stage6 写或 stage7 读时产生错误。测试通过原 `execute`、真实窗口和原 CF 调用入口执行,要求保留错误5/退出2、对应阶段、同进程类别、释放记录及 Job/桌面回收。 + +个人环境仍由 Python 与 native 双重 guard 拒绝真实系统 CF;不伪造 hosted 标志,不修改个人剪贴板。原 queued Ctrl+Shift+C、反向选择、受控 DPI、空选区、输入隔离、九阶段与两轮、30s/40s 期限均不变。没有增加自动重试、sleep 或改变产品占用反馈。 + +## 验证与限制 + +固定源码、构建身份、原失败、纯清理反例及本地回归见[验证清单](../../experiments/shell/results/2026-10-27-selection-clipboard-boundaries/validation.json)。纯反例通过抽取原无保护清理顺序验证主错误覆盖;这是异常处理边界测试,不是对原 hosted 占用原因的复现。最终测试校验异常类型、值和文本,不以异常对象地址作为合同。 + +新工作树首次完整回归缺少已声明的 Python 依赖,41/46、234.70s,五个失败保留;后续使用独立 venv 安装仓库既有固定依赖。初次抽取测试曾使用异常对象地址,不能作为修复证据,已另存并排除;有效反例与最终测试使用相同类型/值合同。 + +固定 `0774d33b82745444e5629d14c758a09e2dd562bf` 的默认/system 本地定向检查 2/2、3.57s;完整 CTest 46/46、285.53s,13 个内部 skip 单列。选择模块 10 项中 8 项通过、2 项真实 CF 跳过。JUnit 的成功输出有截断,内部 skip 和模块细节由完整 LastTest 日志核对,不能从截断摘要猜测。 + +本地真实系统 CF 按保护规则跳过。真实占用控制与原两轮系统复制须在一次性 hosted VM 验证;结果由 #85 对应 PR 分列,不把本地跳过计为验收。候选/官方后端、实际呈现、WindTerm 等价及性能均未由此验收。 diff --git a/experiments/shell/clipboard_failure.hpp b/experiments/shell/clipboard_failure.hpp new file mode 100644 index 00000000..6be154d6 --- /dev/null +++ b/experiments/shell/clipboard_failure.hpp @@ -0,0 +1,19 @@ +// Exception handling for owned clipboard scenarios, independent of Win32 APIs. +#pragma once +#include +#include + +namespace tf_shell_test { +// Call from the primary clipboard exception handler. Cleanup failure remains +// secondary; its fixed diagnostic must not disclose an exception's contents. +template +[[noreturn]] void rethrow_clipboard_failure(Cleanup&& cleanup, std::ostream& diagnostic) { + const auto primary = std::current_exception(); + try { cleanup(); } + catch(...) { + try { diagnostic << "[TF-SELECTION-CLIPBOARD] owned cleanup failed\n"; } + catch(...) {} // Even a failed diagnostic stream cannot replace primary. + } + std::rethrow_exception(primary); +} +} // namespace tf_shell_test diff --git a/experiments/shell/clipboard_policy_test.cpp b/experiments/shell/clipboard_policy_test.cpp index 173a746b..f5d947b0 100644 --- a/experiments/shell/clipboard_policy_test.cpp +++ b/experiments/shell/clipboard_policy_test.cpp @@ -1,6 +1,8 @@ // Independent decision-table checks for the fixture's narrow requester-exit case. #include "clipboard_owner_policy.hpp" +#include "clipboard_failure.hpp" #include +#include #include using namespace tf_shell_test; static void check(bool value, const char* why) { if(!value) throw std::runtime_error(why); } @@ -70,7 +72,37 @@ static void exact_native_error_matrix() { ClipboardOwnerObservation complete; complete.release_event = complete.thread_exited = true; check(occupied.decide(complete) == ClipboardOwnerDecision::accepted, "ordinary occupied owner needs no publication"); } +static void cleanup_cannot_replace_primary_failure() { + struct PrimaryFailure : std::runtime_error { + int code = 5; + PrimaryFailure() : std::runtime_error("synthetic primary clipboard failure") {} + }; + for(const bool cleanup_fails : {false,true}) for(const bool diagnostic_fails : {false,true}) { + std::ostringstream diagnostic; + if(diagnostic_fails) { diagnostic.setstate(std::ios::badbit); } + unsigned calls = 0; + try { + try { throw PrimaryFailure(); } + catch(const PrimaryFailure&) { + if(diagnostic_fails) { + // Arm the stream while preserving the primary handler. + try { diagnostic.exceptions(std::ios::badbit); } catch(const std::ios_base::failure&) {} + } + rethrow_clipboard_failure([&] { + ++calls; + if(cleanup_fails) throw std::runtime_error("synthetic private cleanup detail"); + },diagnostic); + } + } catch(const PrimaryFailure& error) { + check(error.code == 5,"primary failure code changed"); + check(std::string_view(error.what()) == "synthetic primary clipboard failure","primary failure text changed"); + } + check(calls == 1,"cleanup must run exactly once even when it fails"); + check(diagnostic.str() == (cleanup_fails && !diagnostic_fails ? + "[TF-SELECTION-CLIPBOARD] owned cleanup failed\n" : ""),"secondary failure must have only a fixed safe diagnostic"); + } +} int main() { - try { release_proofs_and_pending_states(); exact_native_error_matrix(); std::cout << "clipboard owner policy checks passed\n"; } + try { release_proofs_and_pending_states(); exact_native_error_matrix(); cleanup_cannot_replace_primary_failure(); std::cout << "clipboard owner policy checks passed\n"; } catch(const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } } diff --git a/experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json b/experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json new file mode 100644 index 00000000..afa369b1 --- /dev/null +++ b/experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json @@ -0,0 +1,299 @@ +{ + "schema": "termforge-hosted-command-boundaries-validation-v1", + "date": "2026-10-27", + "issue": "https://github.com/JavaZeroo/termforge/issues/81", + "scope": "bounded command observation and worker summary; no original timeout root-cause claim", + "code_commit": "f6db464e80973050f0c83f265c2227b04b128741", + "review_base": "54ed4b56eac026c32988dbba24b2015bbc37a80c", + "sources": [ + { + "path": "tools/prepare_hosted_shells.py", + "git_blob_sha256": "d7de1cae740b288516a8a271e92c40840bad96bc8bd7230055cbf7c45c74133a", + "working_bytes_sha256": "d7de1cae740b288516a8a271e92c40840bad96bc8bd7230055cbf7c45c74133a" + }, + { + "path": "tools/test_prepare_hosted_shells.py", + "git_blob_sha256": "40bf84fe14eb7cb29e97670924f1aa6d0411770a11888e4173280028dd4dee1a", + "working_bytes_sha256": "40bf84fe14eb7cb29e97670924f1aa6d0411770a11888e4173280028dd4dee1a" + } + ], + "environment": { + "os": "Windows", + "release": "11", + "version": "10.0.26200", + "machine": "AMD64", + "python": "3.12.10" + }, + "original_hosted_failure": { + "pr": 76, + "head": "97ac1420801c072ec318604fb2193957c0c57e69", + "checkout": "1a1afd2152af3776e95a41c7f5fa1a8c3709d681", + "run_id": 40366292708, + "job_id": 121724790152, + "artifact_id": 12671066881, + "artifact": { + "path": "local/r76-ci-prep/hosted-shell-preparation.zip", + "bytes": 668, + "sha256": "b69abc82963067e5c7d406776f2dbf3cb2e0c31cfb65b5ab9a74e15b4dfb7a40" + }, + "summary_identity": { + "path": "local/r76-ci-prep/summary.json", + "bytes": 1350, + "sha256": "132c9ec4fc3b7c34bb3f66465842d306287c809e409b9c5bf0a29f927d8f020c" + }, + "summary": { + "schema": "termforge-hosted-shell-preparation-v1", + "status": "failed", + "fixed_import_rootfs_version": "noble-20240423-amd64-wsl1", + "fixed_import_rootfs_sha256": "8251e27ffff381a4af5f41dcb94d867de3e0d9774a9241908ab34555d99315ea", + "rootfs_verified": false, + "hosted_provenance": { + "image_os": "win25-vs2026", + "image_version": "20261021.278.1", + "github_run_id": "40366292708" + }, + "steps": [ + { + "schema": "termforge-hosted-shell-preparation-v1", + "step": "preflight", + "status": "failed", + "failure_code": "command_deadline", + "cleanup": { + "root_natural_exit": true, + "root_reaped": true, + "root_exit_code": 1, + "root_direct_kill_requested": false, + "job_termination_requests": 0, + "job_before": { + "active": 0, + "total": 6, + "terminated": 0 + }, + "job_after": { + "active": 0, + "total": 6, + "terminated": 0 + }, + "job_after_pid_count": 0, + "membership_stable": true, + "members": [], + "member_cleanup_requested_count": 0, + "verified_wsl_helper_count": 0, + "helper_cleanup_policy": "natural-empty-job", + "audit_status": "observed" + } + } + ], + "failure_code": "command_deadline" + }, + "specific_command": null, + "product_tests_run": false, + "root_cause": null + }, + "red_controls": [ + { + "case": "new boundary test against implementation without command events", + "methods": 1, + "errors": 1, + "seconds": 0.006, + "observed_failure": "missing command-events.json", + "source_snapshot_available": false, + "log": "local/r81-red/test.log" + }, + { + "case": "timeout terminal event write failure during implementation", + "methods": 1, + "failures": 1, + "seconds": 0.007, + "observed_failure": "command_observation replaced command_deadline", + "source_snapshot_available": false, + "log": "local/r81-write-fault-red/test.log" + }, + { + "case": "invoking/returned write failures with mocked return code 0/7", + "methods": 1, + "errors": 4, + "seconds": 0.036, + "observed_failure": "command_observation replaced actual result", + "source_snapshot_available": true, + "same_test_source_as_green": true, + "log": "local/r81-return-fault-red/test.log" + } + ], + "green_validation": { + "command": "python -m unittest tools.test_prepare_hosted_shells tools.test_owned_job_cleanup -v", + "returncode": 0, + "methods": 23, + "passed": 23, + "skipped": 0, + "seconds": 0.354, + "log": "local/r81-final-observer-guards/test.log", + "test_results": [ + { + "method": "test_parent_retains_command_or_worker_deadline_after_cleanup", + "class": "tools.test_prepare_hosted_shells.CommandBoundaryTests.test_parent_retains_command_or_worker_deadline_after_cleanup", + "result": "ok" + }, + { + "method": "test_returned_command_and_partial_invocation_are_reported_independently", + "class": "tools.test_prepare_hosted_shells.CommandBoundaryTests.test_returned_command_and_partial_invocation_are_reported_independently", + "result": "ok" + }, + { + "method": "test_timeout_keeps_actual_command_boundary_without_private_arguments", + "class": "tools.test_prepare_hosted_shells.CommandBoundaryTests.test_timeout_keeps_actual_command_boundary_without_private_arguments", + "result": "ok" + }, + { + "method": "test_trace_write_failure_cannot_replace_observed_timeout", + "class": "tools.test_prepare_hosted_shells.CommandBoundaryTests.test_trace_write_failure_cannot_replace_observed_timeout", + "result": "ok" + }, + { + "method": "test_trace_write_failure_preserves_command_and_marker_result", + "class": "tools.test_prepare_hosted_shells.CommandBoundaryTests.test_trace_write_failure_preserves_command_and_marker_result", + "result": "ok" + }, + { + "method": "test_unknown_or_unordered_data_is_rejected_without_echoing_it", + "class": "tools.test_prepare_hosted_shells.CommandBoundaryTests.test_unknown_or_unordered_data_is_rejected_without_echoing_it", + "result": "ok" + }, + { + "method": "test_a_successful_download_cannot_stand_in_for_import_or_shell_checks", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_a_successful_download_cannot_stand_in_for_import_or_shell_checks", + "result": "ok" + }, + { + "method": "test_absent_ubuntu_requires_verified_download_before_import_and_both_checks", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_absent_ubuntu_requires_verified_download_before_import_and_both_checks", + "result": "ok" + }, + { + "method": "test_distribution_list_and_marker_oracles_reject_missing_or_wrong_identity", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_distribution_list_and_marker_oracles_reject_missing_or_wrong_identity", + "result": "ok" + }, + { + "method": "test_empty_list_error_does_not_replace_a_registered_distribution", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_empty_list_error_does_not_replace_a_registered_distribution", + "result": "ok" + }, + { + "method": "test_exact_hosted_guard_rejects_other_hosts_and_repositories", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_exact_hosted_guard_rejects_other_hosts_and_repositories", + "result": "ok" + }, + { + "method": "test_existing_unusable_ubuntu_is_never_replaced", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_existing_unusable_ubuntu_is_never_replaced", + "result": "ok" + }, + { + "method": "test_fixed_product_launch_and_import_have_no_global_mutation_flags", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_fixed_product_launch_and_import_have_no_global_mutation_flags", + "result": "ok" + }, + { + "method": "test_local_runtime_is_rejected_before_any_subprocess_or_download", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_local_runtime_is_rejected_before_any_subprocess_or_download", + "result": "ok" + }, + { + "method": "test_path_guard_rejects_workspace_escape_and_requires_local_ownership", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_path_guard_rejects_workspace_escape_and_requires_local_ownership", + "result": "ok" + }, + { + "method": "test_public_provenance_is_guarded_and_contains_only_safe_runner_identity", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_public_provenance_is_guarded_and_contains_only_safe_runner_identity", + "result": "ok" + }, + { + "method": "test_stream_download_requires_exact_hash_size_and_deadline", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_stream_download_requires_exact_hash_size_and_deadline", + "result": "ok" + }, + { + "method": "test_worker_job_guard_precedes_host_paths_or_commands", + "class": "tools.test_prepare_hosted_shells.HostedShellPreparationTests.test_worker_job_guard_precedes_host_paths_or_commands", + "result": "ok" + }, + { + "method": "test_image_name_alone_never_qualifies_an_unknown_helper", + "class": "tools.test_owned_job_cleanup.CleanupPolicyTests.test_image_name_alone_never_qualifies_an_unknown_helper", + "result": "ok" + }, + { + "method": "test_image_query_failure_is_not_failure_to_reap_an_owned_fixture_tree", + "class": "tools.test_owned_job_cleanup.CleanupPolicyTests.test_image_query_failure_is_not_failure_to_reap_an_owned_fixture_tree", + "result": "ok" + }, + { + "method": "test_only_owned_and_reaped_check_trees_can_use_fixture_cleanup_policy", + "class": "tools.test_owned_job_cleanup.CleanupPolicyTests.test_only_owned_and_reaped_check_trees_can_use_fixture_cleanup_policy", + "result": "ok" + }, + { + "method": "test_real_run_step_seam_distinguishes_worker_success_and_cleanup", + "class": "tools.test_owned_job_cleanup.CleanupPolicyTests.test_real_run_step_seam_distinguishes_worker_success_and_cleanup", + "result": "ok" + }, + { + "method": "test_ordinary_owned_python_helper_is_audited_unknown_and_reaped", + "class": "tools.test_owned_job_cleanup.OwnJobApiTests.test_ordinary_owned_python_helper_is_audited_unknown_and_reaped", + "result": "ok" + } + ], + "real_process_test": "OwnJobApiTests.test_ordinary_owned_python_helper_is_audited_unknown_and_reaped", + "other_command_and_worker_tests": "subprocess/worker/clock/Job boundary fault injection; file read/write and existing entry points exercised", + "hosted_preparation_run_locally": false, + "local_wsl_configuration_changed": false + }, + "reviews": { + "standards": "initial finding about observation masking result fixed; focused rereview passed", + "spec": "focused rereview passed; original hosted diagnosis remains incomplete", + "reviewers_reran_tests": false + }, + "local_evidence": [ + { + "path": "local/r81-red/test.log", + "bytes": 1673, + "sha256": "eb215b17b995709e1f6d1bf7d8652f0629a3a24e47217a2898f8075330f0f368" + }, + { + "path": "local/r81-write-fault-red/test.log", + "bytes": 2191, + "sha256": "eb86e90507019c1ac7582839d349eb6f8cbba2cb8a8bdfeac92fbcc735f05890" + }, + { + "path": "local/r81-return-fault-red/test.log", + "bytes": 5841, + "sha256": "81263ba133387c3d87d36946c2132f2c65e1e072de5fd247849a0c87d9e5817e" + }, + { + "path": "local/r81-return-fault-red/prepare_hosted_shells.py", + "bytes": 25986, + "sha256": "a9e6af2c3e11092593ba78f36d0cd8ef9ff6d9f183021a888d2fa43100cbdb4e" + }, + { + "path": "local/r81-return-fault-red/test_prepare_hosted_shells.py", + "bytes": 20777, + "sha256": "40bf84fe14eb7cb29e97670924f1aa6d0411770a11888e4173280028dd4dee1a" + }, + { + "path": "local/r81-final-observer-guards/test.log", + "bytes": 4716, + "sha256": "e18931c3771d5a8defb9226efd4fd3a692a1a784b5fc99afeb67241942b4157a" + } + ], + "limitations": [ + "No reenactment of original hosted timeout; its command and root cause remain unknown.", + "Fault-injected subprocess and worker results do not establish hosted behavior.", + "Invoking is a pre-call observation, not proof of OS process creation.", + "Observed can be a partial prefix; missing or malformed trace is not fabricated into completion.", + "Command return does not establish valid markers, ready WSL, cleanup success or product correctness.", + "Preparation budgets, no-retry policy and hosted/Job/path guards are unchanged.", + "No WindTerm performance equivalence, presentation or R2 acceptance claim." + ] +} diff --git a/experiments/shell/results/2026-10-27-selection-clipboard-boundaries/validation.json b/experiments/shell/results/2026-10-27-selection-clipboard-boundaries/validation.json new file mode 100644 index 00000000..ed81fe06 --- /dev/null +++ b/experiments/shell/results/2026-10-27-selection-clipboard-boundaries/validation.json @@ -0,0 +1,357 @@ +{ + "schema": "termforge-selection-clipboard-boundaries-v1", + "source": { + "commit": "0774d33b82745444e5629d14c758a09e2dd562bf", + "base": "79352f2b36f9f2a8828368ced089f6e2a6fda078", + "configuration": "default-core/system-ConPTY", + "files": [ + { + "path": "experiments/shell/workspace_selection_bytes_scenario.hpp", + "file_sha256": "ed2c2131fc6ef7634dec8d81fc7d4d22287f989cc75b7d5658f3bfa4f8057d35", + "git_blob_content_sha256": "ed2c2131fc6ef7634dec8d81fc7d4d22287f989cc75b7d5658f3bfa4f8057d35" + }, + { + "path": "experiments/shell/test_selection_bytes.py", + "file_sha256": "03705399c8802475b83fc3ef22fce78133a7f87f01754b4d70338e0c135f8d14", + "git_blob_content_sha256": "03705399c8802475b83fc3ef22fce78133a7f87f01754b4d70338e0c135f8d14" + }, + { + "path": "experiments/shell/clipboard_failure.hpp", + "file_sha256": "f37a9867e83b95eaa084211c9ab47bb4bf400682c2c02c59b941bbafbfac0bca", + "git_blob_content_sha256": "f37a9867e83b95eaa084211c9ab47bb4bf400682c2c02c59b941bbafbfac0bca" + }, + { + "path": "experiments/shell/clipboard_policy_test.cpp", + "file_sha256": "780567c70cb5028c9ace2ed50944177808f62fc42365572f9d48ff7fda5f26a3", + "git_blob_content_sha256": "780567c70cb5028c9ace2ed50944177808f62fc42365572f9d48ff7fda5f26a3" + }, + { + "path": "experiments/shell/clipboard_scenario_state.hpp", + "file_sha256": "0824d44ff250ff9f4d62468aa710f07833ac4b48e5c496b5c750cc39a15aa408", + "git_blob_content_sha256": "0824d44ff250ff9f4d62468aa710f07833ac4b48e5c496b5c750cc39a15aa408" + } + ] + }, + "environment": { + "os": "Windows", + "version": "10.0.26200", + "architecture": "AMD64", + "python": "3.12.10", + "dependencies": { + "psutil": "7.2.2", + "pyte": "0.8.2", + "wcwidth": "0.2.13" + }, + "private_venv": true, + "real_system_clipboard_executed_locally": false + }, + "binaries": [ + { + "identity": "termforge_shell.exe", + "bytes": 1229312, + "sha256": "1a72cef854d61b7c3fe31187336531d9c3ed377fed7a2d6f80d83dc183a637ca" + }, + { + "identity": "clipboard_policy_test.exe", + "bytes": 31232, + "sha256": "2bb88bce7d48001865148d30331a9ee1e55cd7a8fbe187b97a18e820d1c9bf3a" + }, + { + "identity": "shell_test_launcher.exe", + "bytes": 47616, + "sha256": "1ec7cc313dbbebc8c6fbfae670036bd02ffd5d001572900df06b81de8e332a90" + }, + { + "identity": "shell_clipboard_reader.exe", + "bytes": 22528, + "sha256": "5c25fc64a1b02c4c1007e86392008f3b3e7309011d94cecd9a44111f7cb1083f" + } + ], + "original_hosted_failure": { + "head": "79352f2b36f9f2a8828368ced089f6e2a6fda078", + "checkout": "939866f1cfd7ccc96fb9fdee0c6a493b6ebd3c07", + "run_id": 40377249027, + "job_id": 121763560184, + "artifact_id": 12677990045, + "artifact": { + "identity": "original-hosted-failure.zip", + "bytes": 4714691, + "sha256": "7575a5d82fec271625affe62a19a5f21edf97ae2a6bcd6b005f070070bcfa28f" + }, + "ctest": { + "passed": 45, + "total": 46, + "seconds": 265.77 + }, + "configuration": "default-core/system-ConPTY", + "completed_phases": [ + "forward", + "reverse" + ], + "initial_main_columns": 74, + "completed_clipboard_bytes_match_fixed_width_fixture": true, + "system_repeat_1_started": false, + "final_result_present": false, + "sample": { + "status": "failed", + "timed_out": false, + "exit_code": 2, + "job_after": { + "active": 0, + "total": 11, + "terminated": 0 + }, + "desktop_gone": true, + "job_after_cleanup": { + "active": 0, + "total": 11, + "terminated": 0 + } + }, + "native_error": "OpenClipboard: 5", + "failed_operation": null, + "cause": null, + "raw_evidence": [ + { + "identity": "original-sample.json", + "bytes": 481, + "sha256": "f84883962d70b5e0aace2e5d574ca1a9ac365752fe3fe94f877e9d11a87bb70d" + }, + { + "identity": "original-selection-bytes-progress.jsonl", + "bytes": 2894, + "sha256": "f4a716f536ed72f08291ffeca4b994046ff099950b7b2e0a8634f6cadd2d4e01" + } + ] + }, + "cleanup_error_regression": { + "scope": "pure exception-handling seam extracted from the original unprotected cleanup order; no Win32 clipboard calls", + "red_exit_code": 1, + "red_output": "synthetic private cleanup detail", + "same_test_and_callers": true, + "red_evidence": [ + { + "identity": "valid-cleanup-red/clipboard_failure.hpp", + "bytes": 565, + "sha256": "959e77e0dd29f8990d70d7d0e8284118819886130052fe1c5c8bc0bfc1a99806" + }, + { + "identity": "valid-cleanup-red/clipboard_policy_test.cpp", + "bytes": 7485, + "sha256": "780567c70cb5028c9ace2ed50944177808f62fc42365572f9d48ff7fda5f26a3" + }, + { + "identity": "valid-cleanup-red/workspace_selection_bytes_scenario.hpp", + "bytes": 19512, + "sha256": "ed2c2131fc6ef7634dec8d81fc7d4d22287f989cc75b7d5658f3bfa4f8057d35" + }, + { + "identity": "valid-cleanup-red/build.log", + "bytes": 1122, + "sha256": "1965de11fc8061bba42682cbc068cfbbf0815d2ffd79c2f80a00d261cf5eaa18" + }, + { + "identity": "valid-cleanup-red/test.log", + "bytes": 34, + "sha256": "ecfdfa0cd38f96c5230de1918adf8a2a9f401a3347ca36a10eb965110fea9b70" + } + ], + "green_combinations": 4, + "checks": [ + "primary exception type/code/text", + "cleanup exactly once", + "fixed safe secondary diagnostic", + "failed diagnostic stream preserves primary" + ], + "excluded_experiment": "Earlier object-address assertions failed with both helpers; retained locally and not counted as causal evidence." + }, + "validation": { + "targeted": { + "passed": 2, + "total": 2, + "seconds": 3.57 + }, + "full": { + "passed": 46, + "total": 46, + "seconds": 285.53 + }, + "selection_module": { + "run": 10, + "passed": 8, + "skipped": 2 + }, + "internal_skips": [ + { + "ctest": "differential", + "unittest": "setUpClass (test_candidate_compare.CandidateComparisonTests)", + "reason": "optional Alacritty binary is not built; core-candidate workflow runs these checks" + }, + { + "ctest": "differential", + "unittest": "setUpClass (test_capability_matrix.CapabilityMatrixTests)", + "reason": "optional Alacritty binary is not built; core-candidate workflow runs these checks" + }, + { + "ctest": "differential", + "unittest": "setUpClass (test_fuzz_matrix.FuzzMatrixTests)", + "reason": "optional Alacritty binary is not built; core-candidate workflow runs these checks" + }, + { + "ctest": "differential", + "unittest": "setUpClass (test_real_app_matrix.RealApplicationMatrixTests)", + "reason": "optional real application capture dependencies are not built" + }, + { + "ctest": "text_actions", + "unittest": "test_actual_system_copy_paste_and_shortcuts_twice (experiments.shell.test_actions.ActionWindowTest.test_actual_system_copy_paste_and_shortcuts_twice)", + "reason": "actual system Copy/Paste requires a disposable hosted VM" + }, + { + "ctest": "research_guards", + "unittest": "test_suspended_target_is_reclaimed_if_job_assignment_fails (test_ui_r1.EvidenceBoundaryTests.test_suspended_target_is_reclaimed_if_job_assignment_fails)", + "reason": "opt-in pinned reference failure injection" + }, + { + "ctest": "shell", + "unittest": "test_mouse_selection_to_real_system_clipboard (experiments.shell.test_shell.ShellTests.test_mouse_selection_to_real_system_clipboard)", + "reason": "system copy UI gate requires a disposable hosted VM" + }, + { + "ctest": "shell", + "unittest": "test_system_clipboard_product_read_errors_cancel_and_close (experiments.shell.test_shell.ShellTests.test_system_clipboard_product_read_errors_cancel_and_close)", + "reason": "product clipboard read gate requires a disposable hosted VM" + }, + { + "ctest": "shell", + "unittest": "test_system_clipboard_shortcut_to_window_confirmation_and_receiver (experiments.shell.test_shell.ShellTests.test_system_clipboard_shortcut_to_window_confirmation_and_receiver)", + "reason": "real system clipboard UI gate requires a disposable hosted VM" + }, + { + "ctest": "selection_bytes", + "unittest": "test_owned_clipboard_contention_preserves_write_and_read_failures (experiments.shell.test_selection_bytes.SelectionBytesWindowTest.test_owned_clipboard_contention_preserves_write_and_read_failures)", + "reason": "system CF requires a disposable hosted VM" + }, + { + "ctest": "selection_bytes", + "unittest": "test_real_system_copy_shortcuts_for_styled_fixture_twice (experiments.shell.test_selection_bytes.SelectionBytesWindowTest.test_real_system_copy_shortcuts_for_styled_fixture_twice)", + "reason": "system CF requires a disposable hosted VM" + }, + { + "ctest": "selection_context", + "unittest": "test_real_system_copy_and_edit_context_twice (experiments.shell.test_selection_context.SelectionContextWindowTest.test_real_system_copy_and_edit_context_twice)", + "reason": "real CF/EDIT Copy requires a disposable hosted VM" + }, + { + "ctest": "paste_system", + "unittest": "test_real_system_clipboard (experiments.shell.test_paste.PasteTests.test_real_system_clipboard)", + "reason": "Private window station OpenClipboard denied (5); system API gate remains unverified. Evidence: retained locally" + } + ], + "logs": [ + { + "identity": "configure-venv.log", + "bytes": 386, + "sha256": "3c69d2ff8721653805b03dc61816afdd7805bbed37d70518274d9ec2ceccd0e7" + }, + { + "identity": "build-value-error.log", + "bytes": 4759, + "sha256": "1dba038c383273dee6494bb03a18afa41f25441af71af972e5f16e707546ca62" + }, + { + "identity": "targeted-final.log", + "bytes": 421, + "sha256": "4877e6197902f940067ac07acfd4b2a8a3ff4efa48536eec15b2753cab5e438c" + }, + { + "identity": "targeted-final-detail.log", + "bytes": 3555, + "sha256": "9740a7c330949e5af29e1760d0d36e0ed22db5dd825a368a6f008b29c9a43c04" + }, + { + "identity": "full-final.log", + "bytes": 5043, + "sha256": "8688e5c64460bd7961d81c2e111da9e9d78809ffec6949b560c291ae690a19a1" + } + ], + "junit": { + "identity": "local-r85-final.xml", + "bytes": 36835, + "sha256": "1c17f563005adfaf23272382b6634c66a8025afcf87430840f71453da432e457" + }, + "untruncated_test_detail": { + "identity": "full-final-LastTest.log", + "bytes": 125694, + "sha256": "eaee290733c350b1147540f7c90fa280385f10246331fa6b710f0d48196a2511" + }, + "initial_missing_dependencies": { + "passed": 41, + "total": 46, + "seconds": 234.7, + "failures": [ + { + "test": "benchmark_tools", + "missing_modules": [ + "psutil" + ] + }, + { + "test": "differential", + "missing_modules": [ + "pyte" + ] + }, + { + "test": "workflow_sessions", + "missing_modules": [ + "psutil", + "pyte" + ] + }, + { + "test": "workflow_resources", + "missing_modules": [ + "psutil" + ] + }, + { + "test": "workflow_resource_phases", + "missing_modules": [ + "psutil" + ] + } + ], + "junit": { + "identity": "initial-local-r85.xml", + "bytes": 57973, + "sha256": "8b4d5eb4f9677e31d89da148adc3e0e9e507d287a5d657a47851f4412c68b5cc" + }, + "log": { + "identity": "full-missing-deps.log", + "bytes": 31688, + "sha256": "f8bef464a366a08874fd7016d712011ed66f6cad47db38b835fc03723e7018a1" + } + } + }, + "hosted_controls": { + "status": "not-run-in-this-local-proof", + "operations": [ + { + "stage": 6, + "purpose": "sentinel-write" + }, + { + "stage": 7, + "purpose": "readback" + } + ], + "guard": "existing disposable GitHub-hosted system-clipboard guard; never impersonated locally" + }, + "limits": [ + "Original hosted operation and cause remain unknown; no original-cause fix claimed.", + "After-error ownership queries are separate and nonatomic; null open window does not prove unlocked.", + "Original nine phases, two normal rounds, queued shortcut, exact bytes and deadlines unchanged.", + "No product retry/feedback changes; no candidate/official, actual presentation, performance or WindTerm-equivalence acceptance." + ] +} diff --git a/experiments/shell/test_selection_bytes.py b/experiments/shell/test_selection_bytes.py index cf3eb4a3..a3950559 100644 --- a/experiments/shell/test_selection_bytes.py +++ b/experiments/shell/test_selection_bytes.py @@ -8,6 +8,7 @@ import subprocess import tempfile import unittest +from unittest import mock from experiments.shell.selection_bytes_checks import ( PHASES, DPIS, HARD, CONT, READY, intended_output, expected_physical_lines, validate_selection_bytes, @@ -191,6 +192,29 @@ def test_real_styled_conpty_copy_projection_and_dpi_twice(self): self.assertTrue(summary["dpi_selection_preserved"]) self.assertTrue(summary["cleanup_verified"]) + @unittest.skipUnless(os.environ.get("GITHUB_ACTIONS") == "true" and + os.environ.get("RUNNER_ENVIRONMENT") == "github-hosted", "system CF requires a disposable hosted VM") + def test_owned_clipboard_contention_preserves_write_and_read_failures(self): + for operation, stage in (("sentinel-write", 6), ("readback", 7)): + with self.subTest(operation=operation): + out = self.out / ("occupied-" + operation) + with mock.patch.dict(os.environ, {"TERMFORGE_TEST_SELECTION_CLIPBOARD_HOLD": operation}): + with self.assertRaisesRegex(ValueError, "shell probe failed: OpenClipboard: 5"): + execute(self.exe, "ui-copy-bytes-system", out) + sample = json.loads((out / "sample.json").read_text(encoding="utf-8")) + self.assertEqual(sample["exit_code"], 2) + self.assertFalse(sample["timed_out"]) + self.assertTrue(sample["desktop_gone"]) + self.assertEqual(sample["job_after"]["active"], 0) + evidence = json.loads((out / "selection-clipboard-failure.json").read_text(encoding="utf-8")) + self.assertEqual(evidence, {"schema": "termforge-selection-clipboard-failure-v1", "stage": stage, + "operation": operation, "native_operation": "OpenClipboard", "native_error": 5, + "observation": "after-error-nonatomic", "open_window": "same-process-window", + "data_owner": "same-process-window"}) + released = json.loads((out / "selection-clipboard-owner-release.json").read_text(encoding="utf-8")) + self.assertEqual(released, {"release_event": True, "thread_exited": True, "native_error": 0, + "worker_error": 0, "secondary_native_error": 0, "secondary_worker_error": 0}) + @unittest.skipUnless(os.environ.get("GITHUB_ACTIONS") == "true" and os.environ.get("RUNNER_ENVIRONMENT") == "github-hosted", "system CF requires a disposable hosted VM") def test_real_system_copy_shortcuts_for_styled_fixture_twice(self): diff --git a/experiments/shell/workspace_selection_bytes_scenario.hpp b/experiments/shell/workspace_selection_bytes_scenario.hpp index d4365a26..49f79270 100644 --- a/experiments/shell/workspace_selection_bytes_scenario.hpp +++ b/experiments/shell/workspace_selection_bytes_scenario.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include "clipboard_failure.hpp" #include "scenario_tick_gate.hpp" inline void Workspace::scenario_selection_bytes() { @@ -17,6 +19,7 @@ inline void Workspace::scenario_selection_bytes() { static std::array,2> initial_grids{}; static std::array saved_keys{}; static bool restore_keys = false; + static unsigned hold_clipboard = 0; static tf_shell_test::TwoSessionTickGate phase_ticks; const auto begin_phase = [&]() { phase_ticks.begin({bottom->observed_tick_count(),main ? main->observed_tick_count() : 0}); @@ -87,16 +90,85 @@ inline void Workspace::scenario_selection_bytes() { require(false, "selection bytes key could not enter the real Workspace message loop"); } }; + const auto observe_clipboard_error = [&](bool writing, const tf_shell::ClipboardError& error) noexcept { + // Observe only after the failure. These separate API queries do not + // identify who held the clipboard when OpenClipboard failed; a null + // open window also does not prove that the clipboard was unlocked. + try { + const auto relation = [&](HWND candidate) { + if (!candidate) return "not-reported"; + DWORD process = 0; + if (!GetWindowThreadProcessId(candidate,&process) || !process) return "unresolved"; + if (candidate == window_) return "workspace-window"; + return process == GetCurrentProcessId() ? "same-process-window" : "other-process-window"; + }; + const auto open_window = relation(GetOpenClipboardWindow()); + const auto data_owner = relation(GetClipboardOwner()); + const bool opening = std::string_view(error.what()).rfind("OpenClipboard: ",0) == 0; + const auto path = directory / "selection-clipboard-failure.json"; + const auto temporary = directory / "selection-clipboard-failure.json.tmp"; + std::ofstream out(temporary,std::ios::binary | std::ios::trunc); + out << "{\"schema\":\"termforge-selection-clipboard-failure-v1\",\"stage\":" << stage_ + << ",\"operation\":\"" << (writing ? "sentinel-write" : "readback") + << "\",\"native_operation\":\"" << (opening ? "OpenClipboard" : "other-clipboard-operation") + << "\",\"native_error\":" << error.code + << ",\"observation\":\"after-error-nonatomic\",\"open_window\":\"" << open_window + << "\",\"data_owner\":\"" << data_owner << "\"}\n"; + out.close(); + if (out.good()) MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING); + } catch (...) { + // Missing diagnostics cannot replace the original clipboard error. + } + }; + const auto held_clipboard = [&](bool writing) { + std::unique_ptr owner; + if ((writing && stage_ == 6 && hold_clipboard == 1) || + (!writing && stage_ == 7 && hold_clipboard == 2)) { + owner = std::make_unique(false); + require(owner->occupied(),"selection clipboard fault owner did not acquire the actual clipboard"); + } + return owner; + }; + const auto release_held_clipboard = [&](auto& owner) { + if (!owner) return; + owner->release(); + const auto released = owner->finish(); owner.reset(); + require(released.reason == tf_shell_test::ClipboardReleaseReason::ordinary && !released.expected_requester_gone, + "selection clipboard fault owner did not finish an ordinary release"); + const auto& value = released.observation; + std::ostringstream out; + out << "{\"release_event\":" << (value.release_event ? "true" : "false") + << ",\"thread_exited\":" << (value.thread_exited ? "true" : "false") + << ",\"native_error\":" << value.native_error << ",\"worker_error\":" << value.worker_error + << ",\"secondary_native_error\":" << value.secondary_native_error + << ",\"secondary_worker_error\":" << value.secondary_worker_error << "}\n"; + try { save(directory / "selection-clipboard-owner-release.json",out.str()); } + catch (...) {} // Keep the native failure even if its cleanup record cannot be saved. + }; const auto sentinel = [&]() { require(system, "local selection bytes mode must not write the system clipboard"); tf_shell_test::require_hosted_clipboard(); - tf_shell::write_unicode_clipboard(window_,u"TF_BYTES_SENTINEL 中文 e\u0301"); + auto owner = held_clipboard(true); + try { + tf_shell::write_unicode_clipboard(window_,u"TF_BYTES_SENTINEL 中文 e\u0301"); + } catch (const tf_shell::ClipboardError& error) { + observe_clipboard_error(true,error); + tf_shell_test::rethrow_clipboard_failure([&] { release_held_clipboard(owner); },std::cerr); + } + require(!owner,"occupied selection clipboard write unexpectedly succeeded"); }; const auto read_clipboard = [&]() { require(system, "local selection bytes mode must not read the system clipboard"); tf_shell_test::require_hosted_clipboard(); - const auto value = tf_shell::read_unicode_clipboard(window_); - const std::wstring wide(value.begin(),value.end()); return utf8(wide.c_str()); + auto owner = held_clipboard(false); + try { + const auto value = tf_shell::read_unicode_clipboard(window_); + require(!owner,"occupied selection clipboard read unexpectedly succeeded"); + const std::wstring wide(value.begin(),value.end()); return utf8(wide.c_str()); + } catch (const tf_shell::ClipboardError& error) { + observe_clipboard_error(false,error); + tf_shell_test::rethrow_clipboard_failure([&] { release_held_clipboard(owner); },std::cerr); + } }; const auto copy_if_system = [&]() { if (system) { sentinel(); queued_key('C',true,true); } @@ -168,6 +240,13 @@ inline void Workspace::scenario_selection_bytes() { if (stage_ == 0 && bottom->ready()) { samples.clear(); if (system) tf_shell_test::require_hosted_clipboard(); + wchar_t held[16]{}; + const auto count = GetEnvironmentVariableW(L"TERMFORGE_TEST_SELECTION_CLIPBOARD_HOLD",held,16); + require(count < 16,"selection clipboard fault mode is too long"); + const std::wstring mode(held); + require(mode.empty() || (system && (mode == L"sentinel-write" || mode == L"readback")), + "selection clipboard fault mode requires its hosted system scenario"); + hold_clipboard = mode == L"sentinel-write" ? 1 : mode == L"readback" ? 2 : 0; add(Group::main); stage_ = 1; } else if (stage_ == 1 && main && main->ready() && settled()) { transition(96); stage_ = 2; diff --git a/tools/prepare_hosted_shells.py b/tools/prepare_hosted_shells.py index 65a29e16..c2f497d2 100644 --- a/tools/prepare_hosted_shells.py +++ b/tools/prepare_hosted_shells.py @@ -29,6 +29,11 @@ ROOTFS_SHA256 = "8251e27ffff381a4af5f41dcb94d867de3e0d9774a9241908ab34555d99315ea" MAX_DOWNLOAD = 400 * 1024 * 1024 STEPS = {"preflight": 45, "download": 120, "import": 90, "check-before": 25, "check-after": 25} +COMMANDS_BY_STEP = {"preflight": ("cmd", "ps51", "ps7", "list"), "download": (), + "import": ("list-before-import", "import"), + "check-before": ("list", "startup"), "check-after": ("list", "startup")} +COMMAND_DEADLINES = {"cmd": 10, "ps51": 10, "ps7": 10, "list": 10, + "list-before-import": 10, "import": 80, "startup": 20} class PreparationError(Exception): @@ -165,16 +170,95 @@ def copy_verified_rootfs(response, destination, *, expected_hash=ROOTFS_SHA256, raise PreparationError("download_hash") +def validate_command_events(value, step): + """Whitelist an ordered prefix; an invoking tail is not a completed command.""" + if (step not in COMMANDS_BY_STEP or not isinstance(value, dict) or set(value) != {"events"} or + not isinstance(value["events"], list) or len(value["events"]) > 8): + raise ValueError("command event schema") + expected = COMMANDS_BY_STEP[step] + index, waiting, timed_out = 0, False, False + for event in value["events"]: + if (not isinstance(event, dict) or + set(event) != {"command", "phase", "deadline_ms", "elapsed_ms", "returncode"} or + index >= len(expected) or timed_out or event["command"] != expected[index]): + raise ValueError("command event identity") + phase = event["phase"] + if (phase not in (("returned", "timed_out") if waiting else ("invoking",)) or + type(event["deadline_ms"]) is not int or + event["deadline_ms"] != 1000 * COMMAND_DEADLINES[expected[index]] or + type(event["elapsed_ms"]) is not int or not 0 <= event["elapsed_ms"] <= STEPS[step] * 1000 + 10000): + raise ValueError("command event boundary") + if phase == "returned": + if type(event["returncode"]) is not int or not -(2**31) <= event["returncode"] < 2**32: + raise ValueError("command exit code") + waiting = False + index += 1 + else: + if event["returncode"] is not None or (phase == "invoking" and event["elapsed_ms"] != 0): + raise ValueError("unobserved command exit") + waiting = phase == "invoking" + timed_out = phase == "timed_out" + return value["events"] + + +def read_command_events(directory, step): + with (directory / "command-events.json").open("rb") as stream: + raw = stream.read(8193) + if len(raw) > 8192: + raise ValueError("command event size") + return validate_command_events(json.loads(raw), step) + + +def write_command_event(directory, name, phase, elapsed_ms, returncode=None): + try: + path = directory / "command-events.json" + events = read_command_events(directory, directory.name) if path.exists() else [] + event = {"command": name, "phase": phase, "deadline_ms": 1000 * COMMAND_DEADLINES[name], + "elapsed_ms": elapsed_ms, "returncode": returncode} + value = {"events": events + [event]} + validate_command_events(value, directory.name) + write_json(path, value) + except (OSError, ValueError, KeyError, RecursionError): + raise PreparationError("command_observation") from None + + +def public_command_observation(directory, step): + # Read only after the worker is reaped. Partial traces survive its deadline; + # raw arguments/output and malformed data never enter the public report. + if step not in COMMANDS_BY_STEP: + return {"status": "invalid", "events": []} + if not COMMANDS_BY_STEP[step]: + return {"status": "not_applicable", "events": []} + try: + return {"status": "observed", "events": read_command_events(directory, step)} + except FileNotFoundError: + return {"status": "not_observed", "events": []} + except (OSError, ValueError, RecursionError): + return {"status": "invalid", "events": []} + + def run_command(command, directory, name, *, cwd, timeout=10, data=None): # This function only runs inside a GO-gated worker already in the parent's # exact Job. No shell string construction or global-process cleanup. + if name not in COMMANDS_BY_STEP.get(directory.name, ()) or timeout != COMMAND_DEADLINES[name]: + raise PreparationError("command_observation") + def observe(phase, elapsed_ms, returncode=None): + try: + write_command_event(directory, name, phase, elapsed_ms, returncode) + except PreparationError: + # A missing/partial trace cannot replace the actual command result. + pass stdout_path, stderr_path = directory / (name + ".stdout"), directory / (name + ".stderr") with stdout_path.open("xb") as stdout, stderr_path.open("xb") as stderr: + observe("invoking", 0) + started = time.monotonic_ns() try: result = subprocess.run(command, input=data, stdout=stdout, stderr=stderr, cwd=cwd, timeout=timeout, creationflags=subprocess.CREATE_NO_WINDOW) except subprocess.TimeoutExpired: + observe("timed_out", (time.monotonic_ns() - started) // 1000000) raise PreparationError("command_deadline") from None + observe("returned", (time.monotonic_ns() - started) // 1000000, result.returncode) if stdout_path.stat().st_size > 65536: raise PreparationError("command_report_size") return result.returncode, stdout_path.read_bytes() @@ -268,7 +352,8 @@ def worker(step, out): FAILURES = {"hosted_guard", "workspace_guard", "windows_admin_guard", "worker_job_guard", "cwd_identity", "wsl_report_size", "wsl_list_encoding", "wsl_list_identity", "marker_encoding", "shell_startup", "download_deadline", "download_size", "download_hash", "command_deadline", "command_report_size", "worker_directory_guard", - "wsl_list", "download_response", "ubuntu_already_present", "wsl_import", "ubuntu_missing", "worker_step"} + "wsl_list", "download_response", "ubuntu_already_present", "wsl_import", "ubuntu_missing", "worker_step", + "command_observation"} def run_step(step, out): @@ -375,6 +460,8 @@ def terminate_job(): job.close() (private / "worker.stdout").write_bytes(stdout) (private / "worker.stderr").write_bytes(stderr) + record["command_observation"] = (public_command_observation(private, step) if cleanup["root_reaped"] else + {"status": "worker_not_reaped", "events": []}) return record diff --git a/tools/test_prepare_hosted_shells.py b/tools/test_prepare_hosted_shells.py index b7110612..9036d951 100644 --- a/tools/test_prepare_hosted_shells.py +++ b/tools/test_prepare_hosted_shells.py @@ -166,5 +166,155 @@ def test_local_runtime_is_rejected_before_any_subprocess_or_download(self): urlopen.assert_not_called() +class CommandBoundaryTests(unittest.TestCase): + @staticmethod + def event(command="cmd", phase="invoking", elapsed=0, code=None): + return {"command": command, "phase": phase, "deadline_ms": 10000, + "elapsed_ms": elapsed, "returncode": code} + + def test_timeout_keeps_actual_command_boundary_without_private_arguments(self): + secret = "synthetic-private-command-path" + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "preflight" + directory.mkdir() + failure = prep.subprocess.TimeoutExpired([secret], 10, output=b"synthetic-private-output") + with mock.patch.object(prep.subprocess, "run", side_effect=failure) as command, \ + mock.patch.object(prep.time, "monotonic_ns", side_effect=[0, 10000000000]), \ + mock.patch.object(prep.subprocess, "CREATE_NO_WINDOW", 0, create=True): + with self.assertRaisesRegex(prep.PreparationError, "^command_deadline$"): + prep.run_command([secret], directory, "cmd", cwd=directory) + command.assert_called_once() + self.assertEqual(command.call_args.kwargs["timeout"], 10) + data = json.loads((directory / "command-events.json").read_text(encoding="utf-8")) + self.assertEqual(data, {"events": [ + {"command": "cmd", "phase": "invoking", "deadline_ms": 10000, "elapsed_ms": 0, "returncode": None}, + {"command": "cmd", "phase": "timed_out", "deadline_ms": 10000, "elapsed_ms": 10000, "returncode": None}]}) + self.assertNotIn(secret, json.dumps(data)) + self.assertNotIn("synthetic-private-output", json.dumps(data)) + + def test_returned_command_and_partial_invocation_are_reported_independently(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "preflight" + directory.mkdir() + def returned(command, **kwargs): + kwargs["stdout"].write(b"private-native-output") + return prep.subprocess.CompletedProcess(command, 7) + with mock.patch.object(prep.subprocess, "run", side_effect=returned), \ + mock.patch.object(prep.time, "monotonic_ns", side_effect=[0, 123000000]), \ + mock.patch.object(prep.subprocess, "CREATE_NO_WINDOW", 0, create=True): + self.assertEqual(prep.run_command(["private-command"], directory, "cmd", cwd=directory), + (7, b"private-native-output")) + self.assertEqual(prep.public_command_observation(directory, "preflight"), + {"status": "observed", "events": [self.event(), self.event(phase="returned", elapsed=123, code=7)]}) + prep.write_json(directory / "command-events.json", {"events": [self.event()]}) + self.assertEqual(prep.public_command_observation(directory, "preflight"), + {"status": "observed", "events": [self.event()]}) + + def test_unknown_or_unordered_data_is_rejected_without_echoing_it(self): + good = self.event() + values = [ + {"events": [{**good, "command": "private-path"}]}, + {"events": [{**good, "argv": ["private-token"]}]}, + {"events": [self.event(phase="returned", code=0)]}, + {"events": [good, self.event(phase="returned", code=True)]}, + {"events": [good, self.event(phase="timed_out", code=91)]}, + {"events": [good, self.event(phase="returned", code=0), self.event(command="ps7")]}, + {"events": [{**good, "deadline_ms": 11000}]}, + {"events": [good, self.event(phase="returned", elapsed=-1, code=0)]}, + {"events": [good] * 9}, + ] + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "preflight" + directory.mkdir() + for value in values: + with self.subTest(value=value): + prep.write_json(directory / "command-events.json", value) + self.assertEqual(prep.public_command_observation(directory, "preflight"), + {"status": "invalid", "events": []}) + (directory / "command-events.json").write_bytes(b"private-token" * 1000) + self.assertEqual(prep.public_command_observation(directory, "preflight"), + {"status": "invalid", "events": []}) + (directory / "command-events.json").write_bytes(b"[" * 2000 + b"]" * 2000) + self.assertEqual(prep.public_command_observation(directory, "preflight"), + {"status": "invalid", "events": []}) + + def test_trace_write_failure_cannot_replace_observed_timeout(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "preflight" + directory.mkdir() + with mock.patch.object(prep.subprocess, "run", side_effect=prep.subprocess.TimeoutExpired(["synthetic"], 10)), \ + mock.patch.object(prep, "write_command_event", side_effect=[None, prep.PreparationError("command_observation")]), \ + mock.patch.object(prep.subprocess, "CREATE_NO_WINDOW", 0, create=True): + with self.assertRaisesRegex(prep.PreparationError, "^command_deadline$"): + prep.run_command(["synthetic"], directory, "cmd", cwd=directory) + + def test_trace_write_failure_preserves_command_and_marker_result(self): + marker = "TERMFORGE_HOSTED_CMD_READY" + output = (marker + "\r\n").encode("ascii") + write_json = prep.write_json + for phase in ("invoking", "returned"): + for code in (0, 7): + with self.subTest(phase=phase, code=code), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "preflight" + directory.mkdir() + def failed_write(path, value): + if value["events"][-1]["phase"] == phase: + raise OSError("synthetic-private-storage-error") + write_json(path, value) + def returned(command, **kwargs): + kwargs["stdout"].write(output) + return prep.subprocess.CompletedProcess(command, code) + with mock.patch.object(prep, "write_json", side_effect=failed_write), \ + mock.patch.object(prep.subprocess, "run", side_effect=returned) as command, \ + mock.patch.object(prep.subprocess, "CREATE_NO_WINDOW", 0, create=True): + result_code, result_output = prep.run_command(["synthetic"], directory, "cmd", cwd=directory) + command.assert_called_once() + self.assertEqual((result_code, result_output), (code, output)) + if code: + with self.assertRaisesRegex(prep.PreparationError, "^shell_startup$"): + prep.validate_marker(result_output, result_code, marker) + else: + prep.validate_marker(result_output, result_code, marker) + observation = prep.public_command_observation(directory, "preflight") + self.assertEqual(observation, {"status": "not_observed", "events": []} if phase == "invoking" else + {"status": "observed", "events": [self.event()]}) + + def test_parent_retains_command_or_worker_deadline_after_cleanup(self): + for worker_timeout in (False, True): + with self.subTest(worker_timeout=worker_timeout), tempfile.TemporaryDirectory() as temporary: + out = Path(temporary) + events = [self.event(), self.event(phase="returned", elapsed=2, code=0), self.event(command="ps51")] + if not worker_timeout: + events.append(self.event(command="ps51", phase="timed_out", elapsed=10000)) + process = mock.Mock(pid=123, returncode=None) + process.poll.side_effect = lambda: process.returncode + calls = [] + def communicate(*args, **kwargs): + calls.append(kwargs["timeout"]) + prep.write_json(out / "_private/preflight/command-events.json", {"events": events}) + if worker_timeout and len(calls) == 1: + raise prep.subprocess.TimeoutExpired(["synthetic-worker"], 45) + process.returncode = 99 if worker_timeout else 1 + return (b"" if worker_timeout else b'{"failure_code":"command_deadline"}', b"private-worker-error") + process.communicate.side_effect = communicate + job = mock.Mock() + job.drain.return_value = {"active": 0, "total": 2, "terminated": 0} + audit = mock.Mock(stable=True, members=[], private=[]) + audit.process_ids.return_value = [] + with mock.patch.object(prep, "OwnedJob", return_value=job), \ + mock.patch.object(prep, "JobMembers", return_value=audit), \ + mock.patch.object(prep.subprocess, "Popen", return_value=process), \ + mock.patch.object(prep.subprocess, "CREATE_NO_WINDOW", 0, create=True): + result = prep.run_step("preflight", out) + self.assertEqual(result["status"], "failed") + self.assertEqual(result["failure_code"], "worker_deadline" if worker_timeout else "command_deadline") + self.assertEqual(result["command_observation"], {"status": "observed", "events": events}) + self.assertTrue(result["cleanup"]["root_reaped"]) + self.assertEqual(result["cleanup"]["root_natural_exit"], not worker_timeout) + self.assertEqual(result["cleanup"]["job_termination_requests"], int(worker_timeout)) + self.assertEqual(calls, [45, 5] if worker_timeout else [45]) + self.assertNotIn("private-worker-error", json.dumps(result)) + + if __name__ == "__main__": unittest.main()