diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index f7c0ea94..0c6fb2a3 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -59,6 +59,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分 | 工作区恢复的运输完成端点(#38 夹具修正) | [限定合同](research/workspace-restore-transport-oracle.md)、[原失败及默认/候选验证](../experiments/shell/results/2026-10-26-workspace-transport-oracle/validation.json) | OSC 标题中的重复标记与 column-one CUP 误导旧端点;新判定保留完整 UTF-8、期限、负载及恢复断言。仅修夹具,不宣称修复产品恢复。 | | 工作区恢复的命令编辑前缀(#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 准备超时未由此验收。 | | 候选文字观测与回显端点(#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 6dcc232b..ba83f865 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -34,7 +34,7 @@ | VT 核心 | **实现中,未验收**:默认自研支持基础光标/擦除(含 ECH)、CR/LF/BS/TAB、有限 SGR 和有界恢复。显式候选 `.4` 接入 `Term/Processor`、C++ 拥有值适配、稳定行元数据、每缓冲区 margin 与流式字素;[共同轨迹](../experiments/differential/results/2026-10-26-candidate-common-v6/summary.json)及[源 margin 合同](../experiments/shell/results/2026-10-26-margin-state/validation.json)为各自固定版本结果 | [#27](https://github.com/JavaZeroo/termforge/issues/27) 的 origin/wrap、插删、模式/resize 交叉未闭合;[#41](https://github.com/JavaZeroo/termforge/issues/41) 系统 ConPTY 已丢失的 margin 不能由末端补回。ECH pending-wrap 差异见[限定合同](research/more-capture-ech.md);#36/#38、完整呈现和平台支持独立验收 | R2 / R4 | | Unicode 与文字显示 | **实现中,未验收**:默认自研保留 Unicode 15。显式候选 `.4` 的 [#39 Unicode 17 流式字素合同](research/streaming-graphemes.md)已限定验收:固定 ZWJ/keycap 的单 lead/两列、光标、SGR/查询、原子选择复制、单列 resize 与有界无损分片;实际 ConPTY/DWrite 核对固定样本的单 glyph、字体和网格 | [#30](https://github.com/JavaZeroo/termforge/issues/30) 仍需完整输入/IME 交叉;所有脚本/整形、通用 fallback、所有原生重放、真彩色/更多属性、Powerline 和真实跨屏未验收。分段 helper 的规范数据通过不代表完整终端 Unicode 正确性;[#54](https://github.com/JavaZeroo/termforge/issues/54) 保留已观察的性能成本 | R2 / R4 | | resize 与 reflow | **限定验收**:默认按 ADR-0005 物理行裁剪;候选在[固定 ConPTY 重放](../experiments/core/results/2026-10-26-resize-replay/validation.json)与连续同步中保持行身份、Find/选区和有界历史,淘汰/清空不复活;候选 `.4` 另覆盖主/备用屏缩到单列 | [#29](https://github.com/JavaZeroo/termforge/issues/29) 的完整 resize 交叉未完成;固定事务不等于完整 reflow 或 WindTerm 等价。系统/实验 ConPTY、备用屏/滚动区、真实 DPI、呈现、性能及 #36/#38 分别验收;默认缩窄不可恢复 | R2 | -| 本地 shell 选择 | **实现中,未验收**:`ShellLaunch` 分离可执行文件、参数、工作目录及 shell 命令差异;cmd、Windows PowerShell 5.1、PowerShell 7、显式 Ubuntu WSL 各有[两轮真实启动/目录/尺寸/正常退出证据](../experiments/shell/results/2026-10-24-r2-shell-lifecycle/validation.json),无效程序启动错误可见且可关闭后恢复 cmd | 仅声明上述固定入口和隔离目录;任意 profile/参数、通用分屏/排序、全部运行时错误提示及管理员 shell 未完成 | R2;权限场景 R4 | +| 本地 shell 选择 | **实现中,未验收**:`ShellLaunch` 分离可执行文件、参数、工作目录及 shell 命令差异;cmd、Windows PowerShell 5.1、PowerShell 7、显式 Ubuntu WSL 各有[两轮真实启动/目录/尺寸/正常退出证据](../experiments/shell/results/2026-10-24-r2-shell-lifecycle/validation.json),无效程序启动错误可见且可关闭后恢复 cmd。四 shell 测试已补 [WSL 原生尺寸程序返回后的新提示符同步](research/wsl-size-return.md),正常退出证据只在实际全部回收后写入;默认/system 的事件反例、原两轮及完整回归分别留证 | 仅声明上述固定入口和隔离目录;#80 原 hosted stage6 是否同因仍未知,#81 hosted 准备超时另行定位;任意 profile/参数、通用分屏/排序、全部运行时错误提示及管理员 shell 未完成 | R2;权限场景 R4 | | 常用 TUI | **实现中,未验收**:显式候选 + 系统 ConPTY 有固定 Vim 进入、编辑、60×12 resize、退出写盘、主屏恢复及光标回复的[产品证据](../experiments/core/CANDIDATE.md);More 尾部 ECH 与捕获端点有[限定修复](research/more-capture-ech.md) | [#28](https://github.com/JavaZeroo/termforge/issues/28) 的更多应用及完整输入/输出模式未完成;PR #50 实验后端的 GUI Vim [#52](https://github.com/JavaZeroo/termforge/issues/52) 仍有失败;[native 捕获目录复用](research/vim-fixture-isolation.md)已独立定位并修正,不能代替 GUI 或完整 TUI 验收。headless 比较与自有 DIB 不等于真实呈现,`presented_done=null` | R2 | ## 工作区与会话 @@ -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 等独立失败仍未闭合;#72 保存完成判据的限定修正不代替完整回归 | R2 / R4 | +| 自动正确性与构建 | **限定验收**:Windows shell/ConPTY/呈现及 Windows/Linux 核心 CI、差分/恢复/队列/生命周期场景 | 产品打包、干净机器使用、依赖升级、长期回归和更广平台矩阵;#40 的 CI 纯夹具超时、窄窗口选择 #73、四 shell 原事件 #80 及 hosted 准备 #81 等独立失败仍未闭合;#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/milestones/R2-local-terminal.md b/docs/milestones/R2-local-terminal.md index 4e9dc8bc..905f4a1f 100644 --- a/docs/milestones/R2-local-terminal.md +++ b/docs/milestones/R2-local-terminal.md @@ -63,7 +63,7 @@ flowchart LR | 门槛与责任项 | 验收必须证明什么 | 不能代替验收的结果 | |---|---|---| -| 回归可靠性:#36、#38(含 #40、#44、#57、#72、#73) | 持续输出中断、controller 子进程到 shell 的正常退出、原 10 秒纯夹具及新保存/选择失败路径均有明确阶段、字节、退出和自有 Job 回收证据;修复对应真实失败,最终声明配置的完整回归通过 | 多次重跑绿不能回填旧失败原因;close 后的退出码不能冒充自然退出;增加超时或跳过不能解除阻塞 | +| 回归可靠性:#36、#38(含 #40、#44、#57、#72、#73、#80、#81) | 持续输出中断、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/wsl-size-return.md b/docs/research/wsl-size-return.md new file mode 100644 index 00000000..bb24c321 --- /dev/null +++ b/docs/research/wsl-size-return.md @@ -0,0 +1,34 @@ +# 四 shell 验证中的 WSL 原生子进程返回边界 + +## 问题与证据边界 + +[#80](https://github.com/JavaZeroo/termforge/issues/80) 来自 [PR #79 的默认/system CI](https://github.com/JavaZeroo/termforge/actions/runs/40363003416/job/121713140988):原四 shell 方法第一轮停在 stage6,30 秒内部期限耗尽,外层 40 秒未超时。四者目录与真实尺寸标记已经核对;失败清理后 Job 为零。旧代码在 stage5 看到尺寸输出便向 WSL 发送 `exit`,并提前写入 `normal_exit=true`。 + +同一原源码在本机 Windows 11 的有界六次运行均通过,不能解释该 CI。随后只改变合成原生尺寸程序的返回时机:程序输出真实 `TF_SIZE` 后,用 `PeekConsoleInputW` 观察到 `exit\r` 才返回,始终不消费输入。这一次复现 stage6 超时,独立记录实际字节 `657869740d`;返回 WSL 后有新 `TF>`,没有正常退出。源码补丁、二进制和原件均保留。 + +这证明该测试存在可触发的提前发送窗口。原 hosted 样本没有 native helper 输入边界记录,因此**尚未证明原 CI 必定同因**。该修正也不承诺终端能保存 WSL/native 交接期间用户任意提前输入的内容。 + +## 限定修正与真实回归 + +尺寸输出只证明原生程序查到了控制台尺寸。`ui-shells` 仍检查四个会话的实际尺寸与 resize settled;另外要求 WSL 的同次尺寸输出之后出现完整新提示符,才开始原来的 WSL → pwsh → PowerShell → cmd 顺次退出。提示符使用已有独立运输 oracle,保持 8192 B 观察上限,未放宽其输出、转义或编辑判据。四个会话实际回收且无错误后,才写正常退出文件。 + +新回归仍走实际 `ui-shells`、WM_CHAR、输入队列、ConPTY 和真实 WSL native helper。仅显式测试开关启用一个自有命名事件:helper 输出真实尺寸后等待 release;四个尺寸标记齐全时,宿主先断言实际退出条件仍阻止下一命令,随后释放 helper,再完成原四次正常退出。它没有固定延时、伪造尺寸或改变接收字节。helper 的 15 秒是失联失败期限,场景内部 30 秒及外部 40 秒保持原值。 + +保留旧退出条件时,同一事件夹具在 4.795 秒明确失败:`size output was mistaken for shell input readiness`。最终修正后,新握手与原两轮共三个进程场景全部通过,四会话依次 stage6/7/8/9、exit0、Job0、desktopgone;两个 unittest 方法耗时 10.872 秒,仅作回归运行记录。 + +中间曾对全部 shell 使用同一提示符 oracle:握手及原第一轮通过,第二轮 cmd 的 CUP/CR/OSC 重绘使其停在 stage5。该失败原件继续保留。最终只处理已经复现的 WSL 交接;没有放宽共用 oracle,也不声称 cmd/PowerShell 重绘遵循 WSL 运输格式。 + +## 来源与复验 + +实现固定为 `2b85fef48149f022b50d9d8878f62539e02c92ba`,基点 `c8206911fb511bc2996579f2bb108ef54ae7acbd`。当前证据配置为 Windows 11 26200、默认自研核心/system 后端;[审核后记录](../../experiments/shell/results/2026-10-27-wsl-size-return/validation.json)分别保存原 CI、原六次未复现、受控 stage6、旧条件红例、中间 stage5 和最终结果的身份与限制。私有目录、实际桌面名及完整运输原件留在本地。 + +在完整 Release 构建后,设 `TERMFORGE_BIN_DIR` 指向该构建的 `bin/Release`,运行: + +```powershell +python -m unittest experiments.shell.test_shell.ShellTests.test_local_shell_exit_waits_for_native_helper_return experiments.shell.test_shell.ShellTests.test_local_shell_launch_working_directory_and_cleanup -v +ctest --test-dir build -C Release --output-on-failure +``` + +同源码默认/system 完整 CTest 为 46/46,283.89 秒;shell 为 39 方法、81.664 秒,其中 3 项真实系统剪贴板保护跳过。全套共 12 个内部跳过,完整清单和原日志哈希列于记录,包括可选候选/真实应用依赖、hosted CF 保护、opt-in 原版故障注入和私有窗口站 OpenClipboard 拒绝;这些项目没有因 CTest 聚合通过而验收。新握手、原两轮以及 #72 保存/恢复方法在本次完整 shell 回归中均通过。 + +candidate/system、实验官方后端及 hosted 合并配置需要各自验证。没有实际呈现、WindTerm 行为/性能等价或 R2 完成声明。#38/#35 的其余真实阻塞继续开放。 diff --git a/experiments/shell/local_shell_diagnostics.hpp b/experiments/shell/local_shell_diagnostics.hpp index 8c5048f0..3b05e137 100644 --- a/experiments/shell/local_shell_diagnostics.hpp +++ b/experiments/shell/local_shell_diagnostics.hpp @@ -12,7 +12,7 @@ inline void local_shell_progress(const std::filesystem::path& directory, std::st bool cleanup_complete = false, unsigned long active_children = 0, std::string_view output = {}) { require(role=="cmd" || role=="powershell" || role=="pwsh" || role=="wsl", "invalid local shell diagnostic role"); - require(event=="started" || event=="cwd-verified" || event=="exited", "invalid local shell diagnostic event"); + require(event=="started" || event=="cwd-verified" || event=="size-helper-held" || event=="sizes-ready" || event=="exited", "invalid local shell diagnostic event"); require(output.size()<=8192, "local shell diagnostic output exceeds its bound"); std::ofstream progress(directory/"shells-progress.jsonl",std::ios::binary|std::ios::app); progress<<"{\"schema\":\"termforge-local-shell-progress-v1\",\"role\":\""<(renderer_->x(parser_.screen.width()), renderer_->y(parser_.screen.height())); } } - void query_size(bool fixture_output_only = false) { + void query_size(bool fixture_output_only = false, std::wstring_view release_event = {}) { wchar_t path[32768]{}; const DWORD n = GetModuleFileNameW(nullptr, path, 32768); require(n && n < 32768, "size client path failed"); @@ -194,7 +194,9 @@ class ShellWindow { file = file.substr(0, file.find_last_of(L'\\')+1) + L"shell_size_client.exe"; if (wsl_) file = tf_shell::wsl_path(file); // Absolute synthetic helper; it independently queries the child console. - const auto command=command_prefix_ + L"\"" + file + L"\"\r"; + auto command=command_prefix_ + L"\"" + file + L"\""; + if(!release_event.empty()) command+=L" --wait-event \""+std::wstring(release_event)+L"\""; + command+=L"\r"; if(fixture_output_only) fixture_command(command); else type(command); } @@ -1220,7 +1222,7 @@ class ShellWindow { bool closing() const { return closing_; } void close() { begin_close(); } void command(const std::wstring& text) { type(text); } - void check_size() { query_size(); } + void check_size(std::wstring_view release_event = {}) { query_size(false,release_event); } void fixture_command(const std::wstring& text) { // Producer control for the owned history fixture, not a UI input // gesture: background output must be tested without typing in a view. diff --git a/experiments/shell/size_client.cpp b/experiments/shell/size_client.cpp index 25122019..60bdb0a0 100644 --- a/experiments/shell/size_client.cpp +++ b/experiments/shell/size_client.cpp @@ -1,11 +1,23 @@ // Synthetic console client: query the OS, not the host's requested dimensions. #include "../conpty/session.hpp" #include -int main() { +int wmain(int argc, wchar_t** argv) { + tf_conpty::Handle release; + if(argc!=1) { + if(argc!=3 || std::wstring_view(argv[1])!=L"--wait-event") return 4; + const std::wstring_view name(argv[2]); + if(name.rfind(L"Local\\TermForgeShellSize-",0)!=0 || name.size()>160) return 4; + release.reset(OpenEventW(SYNCHRONIZE,FALSE,argv[2])); + if(!release.get()) return 4; + } CONSOLE_SCREEN_BUFFER_INFO info{}; const HANDLE output = GetStdHandle(STD_OUTPUT_HANDLE); if (!GetConsoleScreenBufferInfo(output, &info)) return 2; const auto text = "\r\nTF_SIZE_" + std::to_string(info.dwSize.X) + "x" + std::to_string(info.dwSize.Y) + "\r\n"; DWORD written = 0; - return WriteFile(output, text.data(), static_cast(text.size()), &written, nullptr) && written == text.size() ? 0 : 3; + if(!WriteFile(output, text.data(), static_cast(text.size()), &written, nullptr) || written!=text.size()) return 3; + // A marker proves dimensions, not that this process has returned to its shell. + // The opt-in fixture holds that boundary until the UI observes it explicitly. + if(release.get() && WaitForSingleObject(release.get(),15000)!=WAIT_OBJECT_0) return 5; + return 0; } diff --git a/experiments/shell/test_shell.py b/experiments/shell/test_shell.py index 09782b1c..0d8a087a 100644 --- a/experiments/shell/test_shell.py +++ b/experiments/shell/test_shell.py @@ -819,6 +819,16 @@ def test_real_monitor_round_trip_preserves_workspace(self): with self.assertRaises(ValueError): validate_monitor_geometry(broken) + def test_local_shell_exit_waits_for_native_helper_return(self): + out = self.out / "shells-held-size-helper" + with mock.patch.dict(os.environ, {"TERMFORGE_TEST_SHELL_SIZE_FENCE": "1"}): + execute(self.exe, "ui-shells", out) + progress = [json.loads(line) for line in (out / "shells-progress.jsonl").read_text(encoding="utf-8").splitlines()] + self.assertEqual([(p["event"], p["stage"]) for p in progress if p["event"] in ("size-helper-held", "sizes-ready")], + [("size-helper-held", 5), ("sizes-ready", 5)]) + self.assertEqual([(p["role"], p["stage"], p["exit_code"]) for p in progress if p["event"] == "exited"], + [("wsl", 6, 0), ("pwsh", 7, 0), ("powershell", 8, 0), ("cmd", 9, 0)]) + def test_local_shell_launch_working_directory_and_cleanup(self): for repeat in range(2): out = self.out / f"shells-{repeat}" diff --git a/experiments/shell/transport_completion.hpp b/experiments/shell/transport_completion.hpp index e038584c..ee3c8475 100644 --- a/experiments/shell/transport_completion.hpp +++ b/experiments/shell/transport_completion.hpp @@ -3,7 +3,7 @@ #include namespace tf_shell_test { -// An independent transport oracle for the fixed persistence commands. This is +// An independent transport oracle for fixed persistence and size commands. This is // deliberately not a terminal screen/parser: it only removes complete OSC/CSI // envelopes and recognizes exact literal output lines followed by our prompt. // The fixed ConPTY output can separate its marker and prompt with an explicit diff --git a/experiments/shell/workspace.hpp b/experiments/shell/workspace.hpp index f2411d64..4ae1310d 100644 --- a/experiments/shell/workspace.hpp +++ b/experiments/shell/workspace.hpp @@ -49,6 +49,9 @@ class Workspace { std::unique_ptr ime_scenario_; std::unique_ptr workflow_; std::unique_ptr workflow_system_; + tf_conpty::Handle shell_size_release_; + std::wstring shell_size_event_; + bool shell_size_fence_observed_ = false; int width_ = 1360, height_ = 860, dpi_ = 96; int physical_width_ = 1360, physical_height_ = 860; int left_ = 226, right_ = 226, left_middle_ = 289, right_middle_ = 289; diff --git a/experiments/shell/workspace_shells_scenario.hpp b/experiments/shell/workspace_shells_scenario.hpp index 0f400181..12e54bad 100644 --- a/experiments/shell/workspace_shells_scenario.hpp +++ b/experiments/shell/workspace_shells_scenario.hpp @@ -1,5 +1,6 @@ // Real local shell launch contracts on owned workspaces and isolated directories. #pragma once +#include "transport_completion.hpp" inline void Workspace::scenario_shells() { if (image_.empty() || closing_) return; @@ -22,10 +23,18 @@ inline void Workspace::scenario_shells() { return launch; }; auto check_size = [&] { - for (const auto group : {Group::bottom, Group::main}) for (auto& tab : group_tabs(group)) - tab.shell->check_size(); + wchar_t fence[2]{}; + if(GetEnvironmentVariableW(L"TERMFORGE_TEST_SHELL_SIZE_FENCE",fence,2)==1 && fence[0]==L'1') { + shell_size_event_=L"Local\\TermForgeShellSize-"+std::to_wstring(GetCurrentProcessId())+L"-"+std::to_wstring(GetTickCount64()); + shell_size_release_.reset(CreateEventW(nullptr,TRUE,FALSE,shell_size_event_.c_str())); + require(shell_size_release_.get() && GetLastError()!=ERROR_ALREADY_EXISTS,"size helper release event creation failed"); + } + for (const auto group : {Group::bottom, Group::main}) for (auto& tab : group_tabs(group)) { + tab.shell->begin_output_observation(); + tab.shell->check_size(tab.launch && tab.launch->kind==L"wsl"?shell_size_event_:L""); + } }; - auto sizes_ready = [&] { + auto size_markers_ready = [&] { for (const auto group : {Group::bottom, Group::main}) for (auto& tab : group_tabs(group)) { const auto [columns, rows] = tab.shell->grid_dimensions(); if (!tab.shell->settled() || !tab.shell->contains_line("TF_SIZE_" + std::to_string(columns) + "x" + std::to_string(rows))) @@ -33,6 +42,25 @@ inline void Workspace::scenario_shells() { } return true; }; + auto sizes_ready = [&] { + if(!size_markers_ready()) return false; + for(const auto group : {Group::bottom, Group::main}) for(auto& tab : group_tabs(group)) { + if(!tab.launch || tab.launch->kind!=L"wsl") continue; + const auto [columns,rows]=tab.shell->grid_dimensions(); + // WSL's native helper can still own console input after its marker. + // Wait for its shell's fresh prompt before starting sequential exits. + if(!tf_shell_test::transport_output_completed(tab.shell->observed_output(), + "TF_SIZE_"+std::to_string(columns)+"x"+std::to_string(rows))) return false; + } + return true; + }; + + if(stage_==5 && shell_size_release_.get() && !shell_size_fence_observed_ && size_markers_ready()) { + require(!sizes_ready(),"size output was mistaken for shell input readiness"); + tf_shell_test::local_shell_progress(directory,"wsl","size-helper-held",stage_); + require(SetEvent(shell_size_release_.get())!=FALSE,"size helper release failed"); + shell_size_fence_observed_=true; + } if (stage_ == 0 && active()->ready()) { active()->begin_output_observation(); @@ -67,18 +95,13 @@ inline void Workspace::scenario_shells() { } else if (stage_ == 5 && sizes_ready()) { require(session_count() == 4 && tabs_.size() == 2 && main_tabs_.size() == 2, "local shell targets were not isolated"); + tf_shell_test::local_shell_progress(directory,"wsl","sizes-ready",stage_); Bitmap frame(physical_width_, physical_height_); paint(frame.dc); const RECT main = physical(main_terminal_), bottom = physical(terminal_); selected(Group::main)->render_to(frame.dc, main.left, main.top); selected(Group::bottom)->render_to(frame.dc, bottom.left, bottom.top); frame.save(image_); - std::ofstream out(std::filesystem::path(image_).parent_path() / "shells.json"); - out << "{\"schema\":\"termforge-local-shells-v1\",\"shells\":[\"cmd\",\"powershell\",\"pwsh\",\"wsl-Ubuntu\"]," - << "\"working_directory\":"; - termforge::Screen::quoted(out, utf8(expected_directory.c_str())); - out << ",\"main_sessions\":2,\"bottom_sessions\":2,\"normal_exit\":true,\"presented_done\":null}\n"; - require(bool(out), "local shell evidence write failed"); active()->command(L"exit\r"); stage_ = 6; } else if (stage_ == 6 && collected_ == 1 && session_count() == 3) { @@ -94,6 +117,14 @@ inline void Workspace::scenario_shells() { active()->command(L"exit\r"); stage_ = 9; } else if (stage_ == 9 && collected_ == 4 && session_count() == 0) { + require(failure_.empty(),"local shell exit verification failed"); + // Only publish normal-exit evidence after every real shell was collected. + std::ofstream out(directory / "shells.json"); + out << "{\"schema\":\"termforge-local-shells-v1\",\"shells\":[\"cmd\",\"powershell\",\"pwsh\",\"wsl-Ubuntu\"]," + << "\"working_directory\":"; + termforge::Screen::quoted(out, utf8(expected_directory.c_str())); + out << ",\"main_sessions\":2,\"bottom_sessions\":2,\"normal_exit\":true,\"presented_done\":null}\n"; + out.close(); require(bool(out), "local shell evidence write failed"); stage_ = 100; close_all(); }