diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index fb4a7b14..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 成功不能覆盖这些失败。 @@ -61,6 +61,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 工作区保存的空记录重绘(#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 29a4df5c..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 尚未接入,不放宽期限、负载或几何断言 | +| 新增实际回归 | [恢复 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/#73/#80/#81;已闭合切片和旧失败保留在该总项,不再排入当前队列 | -| 显式官方 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/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-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;