From 68d6af88f202c0bb5a6b382e666dfe9091b3b090 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:08:48 +0800 Subject: [PATCH 1/2] docs: establish isolated Win11 display test environment --- .gitattributes | 2 + docs/milestones/R2-font-display.md | 1 + ...r2-win11-display-environment-20261029.json | 301 ++++++++++++++++++ docs/research/r2-win11-display-environment.md | 55 ++++ tools/probe_windows_display_environment.ps1 | 34 ++ 5 files changed, 393 insertions(+) create mode 100644 docs/research/data/r2-win11-display-environment-20261029.json create mode 100644 docs/research/r2-win11-display-environment.md create mode 100644 tools/probe_windows_display_environment.ps1 diff --git a/.gitattributes b/.gitattributes index 96560ee7..ba50dce8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,5 +6,7 @@ research/ui/*.js text eol=lf research/ui/reference_sources/*.py.txt text eol=lf *.ps1 text eol=crlf +# Preserve the byte identity of the measured standalone display probe. +tools/probe_windows_display_environment.ps1 text eol=lf third_party/conpty/LICENSE text eol=lf experiments/core/fixtures/*.bin binary diff --git a/docs/milestones/R2-font-display.md b/docs/milestones/R2-font-display.md index e2968b2b..31d2c682 100644 --- a/docs/milestones/R2-font-display.md +++ b/docs/milestones/R2-font-display.md @@ -30,4 +30,5 @@ - 真实跨屏证据必须能拒绝伪造的阶段、窗口位置、DPI、可见性和 `distinct_dpi`;少于两台显示器是明确环境缺口,不是通过。 - 当前环境若所有目标显示器 DPI 相同,只验收真实跨屏路径;#31 不因该结果自动关闭,不同 DPI 与实际呈现继续保持未完成。 +- 已建立 [Windows 11 x64 隔离测试环境](../research/r2-win11-display-environment.md),Windows 实际输出为 96/120 DPI,独立窗口两轮往返读值一致。这只完成 #131 环境基础,正式 C++/Qt 的 #31 行为与实际呈现合同仍需各自验证。 - 任何声明支持的 Windows 显示环境都记录系统版本、监视器矩形、实际 DPI、源码/二进制身份和重复结果。不得通过修改用户系统显示设置来制造无人值守验收。 diff --git a/docs/research/data/r2-win11-display-environment-20261029.json b/docs/research/data/r2-win11-display-environment-20261029.json new file mode 100644 index 00000000..7cf9e09b --- /dev/null +++ b/docs/research/data/r2-win11-display-environment-20261029.json @@ -0,0 +1,301 @@ +{ + "schema": "termforge-isolated-windows-display-environment-v1", + "recorded_utc": "2026-10-28T17:02:17.806412+00:00", + "scope": "Independent Windows 11 x64 VM foundation and actual OS distinct-DPI environment; no TermForge runtime acceptance", + "environment_ready": true, + "guest": { + "caption": "Microsoft Windows 11 企业评估版", + "os_version": "10.0.26300", + "build": "26300", + "ubr": 9457, + "display_version": "26H2", + "edition": "EnterpriseEval", + "os_architecture": "64 位", + "is_64_bit_os": true, + "logical_processors": 2, + "physical_memory_bytes": 6421921792, + "secure_boot": true, + "tpm_spec_version": "2.0, 0, 1.64" + }, + "vm_requested_resources": { + "vcpu": 2, + "memory_mib": 6144, + "thin_disk_gib": 64 + }, + "graphics_transport": "unix_socket", + "performance_fixture": false, + "host_exclusive": false, + "media": { + "schema": "termforge-windows-test-media-v1", + "windows_publisher_sha256_matches": true, + "windows_sha256": "b37a06d5121eee017826c0b133250bbd21bec694c2d607c60edd29090e667a84", + "virtio_sha256": "303f7ae40dad495d6ae474fdc571df58958a4dbc5c37a522d80f9a203867949d", + "virtio_publisher_hash_available": false, + "selected_image": { + "index": "1", + "name": "Windows 11 Enterprise Evaluation", + "flags": "EnterpriseEval", + "architecture": "9", + "edition": "EnterpriseEval", + "build": "26300" + }, + "driver_paths": { + "vioserial": "vioserial/w11/amd64", + "qxldod": "qxldod/w10/amd64" + } + }, + "qga": { + "command_exitcode": 0, + "command_exited": true, + "command_session_id": 0, + "own_guest_file_roundtrip": true, + "binary_roundtrip_bytes": 65792, + "binary_roundtrip_sha256": "120c518a83325c66464701a6ee080302f332bc768ea3f60473b209f1bfb091df", + "binary_bytes_equal": true + }, + "display_setup_method": "Normal Windows Display Settings UI Automation: one output set to 125 percent, other remains 100 percent; only the owned guest was changed", + "display_probe_sha256": "307122b2c9d8cb12bbc0a54cebb7e95078430874b2e2ea7b676f5c4df80bda80", + "display_repeats": [ + { + "completion": { + "exit_code": 0, + "root_reaped": true, + "native_wait": 0, + "session_id": 2, + "start_utc": "2026-10-28T17:00:12.6856385Z", + "end_utc": "2026-10-28T17:00:16.0052542Z" + }, + "measurement": { + "limitation": "Infrastructure probe only; full issue31 TermForge behaviors require separate product evidence", + "schema": "termforge-actual-display-probe-v1", + "session_id": 2, + "monitors": [ + { + "device": "\\\\.\\DISPLAY7", + "left": 0, + "top": 0, + "right": 1024, + "bottom": 768, + "primary": true, + "dpi_x": 96, + "dpi_y": 96, + "dpi_hr": 0 + }, + { + "device": "\\\\.\\DISPLAY5", + "left": 1024, + "top": 0, + "right": 2048, + "bottom": 768, + "primary": false, + "dpi_x": 120, + "dpi_y": 120, + "dpi_hr": 0 + } + ], + "window_samples": [ + { + "device": "\\\\.\\DISPLAY7", + "window_dpi": 96, + "visible": true, + "time_utc": "2026-10-28T17:00:14.4526281Z", + "window_rect": [ + 40, + 40, + 680, + 440 + ], + "target_matches": true, + "session_id": 2, + "actual_device": "\\\\.\\DISPLAY7" + }, + { + "device": "\\\\.\\DISPLAY5", + "window_dpi": 120, + "visible": true, + "time_utc": "2026-10-28T17:00:15.1655165Z", + "window_rect": [ + 1064, + 40, + 1704, + 440 + ], + "target_matches": true, + "session_id": 2, + "actual_device": "\\\\.\\DISPLAY5" + }, + { + "device": "\\\\.\\DISPLAY7", + "window_dpi": 96, + "visible": true, + "time_utc": "2026-10-28T17:00:15.8841725Z", + "window_rect": [ + 40, + 40, + 680, + 440 + ], + "target_matches": true, + "session_id": 2, + "actual_device": "\\\\.\\DISPLAY7" + } + ], + "distinct_actual_dpi": true, + "time_utc": "2026-10-28T17:00:15.9081062Z" + } + }, + { + "completion": { + "exit_code": 0, + "root_reaped": true, + "native_wait": 0, + "session_id": 2, + "start_utc": "2026-10-28T17:00:18.0670230Z", + "end_utc": "2026-10-28T17:00:21.3753185Z" + }, + "measurement": { + "limitation": "Infrastructure probe only; full issue31 TermForge behaviors require separate product evidence", + "schema": "termforge-actual-display-probe-v1", + "session_id": 2, + "monitors": [ + { + "device": "\\\\.\\DISPLAY7", + "left": 0, + "top": 0, + "right": 1024, + "bottom": 768, + "primary": true, + "dpi_x": 96, + "dpi_y": 96, + "dpi_hr": 0 + }, + { + "device": "\\\\.\\DISPLAY5", + "left": 1024, + "top": 0, + "right": 2048, + "bottom": 768, + "primary": false, + "dpi_x": 120, + "dpi_y": 120, + "dpi_hr": 0 + } + ], + "window_samples": [ + { + "device": "\\\\.\\DISPLAY7", + "window_dpi": 96, + "visible": true, + "time_utc": "2026-10-28T17:00:19.8183463Z", + "window_rect": [ + 40, + 40, + 680, + 440 + ], + "target_matches": true, + "session_id": 2, + "actual_device": "\\\\.\\DISPLAY7" + }, + { + "device": "\\\\.\\DISPLAY5", + "window_dpi": 120, + "visible": true, + "time_utc": "2026-10-28T17:00:20.5497237Z", + "window_rect": [ + 1064, + 40, + 1704, + 440 + ], + "target_matches": true, + "session_id": 2, + "actual_device": "\\\\.\\DISPLAY5" + }, + { + "device": "\\\\.\\DISPLAY7", + "window_dpi": 96, + "visible": true, + "time_utc": "2026-10-28T17:00:21.2698544Z", + "window_rect": [ + 40, + 40, + 680, + 440 + ], + "target_matches": true, + "session_id": 2, + "actual_device": "\\\\.\\DISPLAY7" + } + ], + "distinct_actual_dpi": true, + "time_utc": "2026-10-28T17:00:21.2897528Z" + } + } + ], + "before_qt": { + "recorded_utc": "2026-10-28T16:49:32.1895759Z", + "qt_executed": false, + "crt_files": [ + { + "present": false, + "name": "vcruntime140.dll" + }, + { + "present": false, + "name": "vcruntime140_1.dll" + }, + { + "present": false, + "name": "msvcp140.dll" + }, + { + "present": false, + "name": "msvcp140_1.dll" + }, + { + "present": false, + "name": "msvcp140_2.dll" + } + ], + "vc_products": [], + "signed_guest_drivers": [ + { + "DeviceName": "VirtIO Serial Driver", + "DeviceClass": "SYSTEM", + "DriverVersion": "100.103.104.30200", + "DriverProviderName": "Red Hat, Inc.", + "IsSigned": true + }, + { + "DeviceName": "Red Hat QXL controller", + "DeviceClass": "DISPLAY", + "DriverVersion": "10.0.0.21000", + "DriverProviderName": "Red Hat, Inc.", + "IsSigned": true + }, + { + "DeviceName": "Red Hat QXL controller", + "DeviceClass": "DISPLAY", + "DriverVersion": "10.0.0.21000", + "DriverProviderName": "Red Hat, Inc.", + "IsSigned": true + } + ], + "guest_bundle_exit": 0 + }, + "termforge_runtime_tested": false, + "r2_issue31_accepted": false, + "presented_done": null, + "limitations": [ + "Virtual QXL outputs were observed by actual Windows APIs; no host display settings were changed.", + "Windows infrastructure window only. Full formal TermForge sessions, resize, focus, IME, Find, rename and hit-test behavior remain untested here.", + "Native WS_VISIBLE and process exit do not establish physical presentation or physical input.", + "No performance acceptance or numeric tolerance is implied." + ], + "harness_corrections": [ + "Initial post-bundle pnputil vioserial add returned 259. Initial failure preserved; signed installed drivers and running QGA were verified before interactive-task recovery.", + "Initial .NET Process.ExitCode was null. Final runner retains the process handle and verifies native wait plus native exit code.", + "An initial Win32 visibility sample was false due to the hidden worker startup setting. Final probe explicitly calls ShowWindow and verifies IsWindowVisible, preserving the earlier failed observation." + ] +} diff --git a/docs/research/r2-win11-display-environment.md b/docs/research/r2-win11-display-environment.md new file mode 100644 index 00000000..9059ee9b --- /dev/null +++ b/docs/research/r2-win11-display-environment.md @@ -0,0 +1,55 @@ +# R2 隔离 Windows 11 显示测试环境 + +[#131](https://github.com/JavaZeroo/termforge/issues/131) 的环境基础已建立:独立 Windows 11 x64 guest 可通过 guest agent 执行命令、读写合成文件,并在交互会话运行可回收的窗口探针。两个实际 Windows 显示输出分别为 96、120 DPI,独立窗口两次完成 96→120→96 往返。这里记录的是测试环境;[#31](https://github.com/JavaZeroo/termforge/issues/31) 的正式产品与实际呈现验收继续保留。 + +脱敏原始读值见 [JSON](data/r2-win11-display-environment-20261029.json),采集时间为 2026-10-29 01:00 左右(Asia/Shanghai)。没有提交 ISO、驱动二进制、无人值守配置、口令、原始 XML、截图或宿主私有清单。 + +## 环境身份与来源 + +用户已明确授权在提供的宿主创建隔离 VM。创建前检查 VM 名称和独占目录均不存在,UUID、目录、账号、图形 socket 及回收入口留在私有 manifest/交接。只操作这个新 VM,未启停修改已有 guest、宿主显示、默认网络、DNS、路由或防火墙。大文件位于现有数据池,未扩容或清理宿主磁盘。 + +| 项目 | 实际配置或读值 | +|---|---| +| 虚拟化与资源 | KVM/QEMU、2 vCPU、6144 MiB、64 GiB thin disk;共享宿主,不是性能夹具 | +| 启动 | q35、Microsoft-enrolled UEFI、TPM2;guest `Confirm-SecureBootUEFI=true`,TPM Spec `2.0, 0, 1.64` | +| Windows | CIM `Microsoft Windows 11 企业评估版`,`10.0.26300` / UBR `9457`、`26H2`、`EnterpriseEval`、x64 | +| 图形与网络 | 两个独立 QXL 设备;SPICE 仅 Unix socket;复用已有 NAT,不向 LAN 开放图形或 RDP | +| 驱动 | 签名 VirtIO Serial `100.103.104.30200`;两个签名 Red Hat QXL `10.0.0.21000`,均由 guest WMI 实际读取 | + +Windows 来自[微软 Enterprise Evaluation 下载页](https://www.microsoft.com/en-us/evalcenter/download-windows-11-enterprise)的公开中文版 x64 链接。完整 8,548,628,480 字节 ISO 的 SHA256 为 `b37a06d5121eee017826c0b133250bbd21bec694c2d607c60edd29090e667a84`,与[微软对应 26H2 Enterprise Eval ZH-CN 校验页](https://support.microsoft.com/en-us/servicing/os/windows/docs/2026/09/verify-the-authenticity-of-a-windows-11-enterprise-evaluation-iso-file)一致。安装前另用 WIM 元数据确认唯一 index 1、`EnterpriseEval`、x64 architecture 9、build 26300;通过正常无人值守部署创建测试账号,未绕过 TPM、Secure Boot 或软件授权。 + +驱动来自[官方 virtio-win 项目](https://github.com/virtio-win/virtio-win-pkg-scripts)指向的固定 `virtio-win-0.1.302-1/virtio-win-0.1.302.iso`。完整 877,373,440 字节 ISO 的实际 SHA256 为 `303f7ae40dad495d6ae474fdc571df58958a4dbc5c37a522d80f9a203867949d`。没有找到独立发布方校验值,不把自算 SHA 说成发布方比对成功。使用 `vioserial/w11/amd64`、`qxldod/w10/amd64` 与同 ISO guest-tools bundle;bundle 退出 0。两 QXL 的选择参考 [SPICE Windows 多显示器说明](https://www.spice-space.org/multiple-monitors.html),能力仍由实际 guest 读值确认。 + +QGA 报告的 `pretty-name` 保留旧的 `Windows 10 Enterprise Evaluation` 字符串;同时 `version-id=11`、kernel release 26300、CIM Caption 与注册表 DisplayVersion 均另行核实,未靠这个旧名称判定系统身份。 + +## 自动控制与真实显示读值 + +guest agent 的 PowerShell 命令在 session 0 退出 0,合成目录写读一致。另将 65,792 字节合成二进制经 guest-file 写入再读回,逐字节一致,SHA256 为 `120c518a83325c66464701a6ee080302f332bc768ea3f60473b209f1bfb091df`。guest 文件 API 只开放自有测试目录。 + +独立测试账号的交互计划任务在实际 session 2 执行请求。每次窗口探针结束后保留自身进程句柄,通过原生 `WaitForSingleObject=0` 与 `GetExitCodeProcess=0` 证明根进程退出;这不是产品 ConPTY 或任意子树的清理验收。队列、超时、错误和身份记录留在自有目录,常驻 runner 是后续测试入口。 + +缩放通过 **guest 自身 Windows Display Settings 的正常 UI Automation 控件**设置:一个输出保持 100%,另一个选择界面实际提供的 125%。未注入受控 DPI 消息,也没有修改个人显示设置。设置请求没有被计为成功;后续新窗口另行读到: + +| Windows 输出 | 实际物理矩形 | `GetDpiForMonitor` | 跨屏 `GetDpiForWindow` | +|---|---|---|---| +| `DISPLAY7`,主屏 | `(0,0)–(1024,768)` | 96×96 | 96 | +| `DISPLAY5` | `(1024,0)–(2048,768)` | 120×120 | 120 | + +两轮均为 **96→120→96**。每个样本另用 `MonitorFromWindow` / `GetMonitorInfo` 核对实际输出,用 `GetWindowRect` 记录真实矩形,用 `IsWindowVisible` 核对窗口可见标志;全部目标匹配、原生可见,探针进程自然退出 0。没有启动 SPICE 客户端去请求或模拟显示数量。 + +可复现读值的独立[探针源码](../../tools/probe_windows_display_environment.ps1) SHA256 为 `307122b2c9d8cb12bbc0a54cebb7e95078430874b2e2ea7b676f5c4df80bda80`。在已获授权的隔离 Windows 交互会话执行: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File tools/probe_windows_display_environment.ps1 +``` + +探针只移动并关闭自身 WinForms 窗口,输出实际 API 读值;不设置系统缩放、监视器或产品状态。直接运行探针没有附带私有队列的进程句柄回收记录,也不能据 `distinct_actual_dpi` 一项判断 #31 通过。 + +## 失败记录与边界 + +- 首轮 bundle 已装好驱动和 QGA,随后重复 `pnputil vioserial` 返回 259,严格脚本因此中止。原始失败留存;核实已装签名 QXL、运行中的 QGA 后,只补齐交互计划任务,没有把 259 改写成驱动安装成功。 +- 首轮 `.NET Process.ExitCode` 为 null,不计进程回收通过。最终 runner 保留进程句柄,改用原生 wait 与 exit code 后重跑。 +- 加强原生可见性查询时,首个窗口因隐藏 worker 的启动设置实际为 hidden。该失败留存;最终探针显式 `ShowWindow`,再独立核对 `IsWindowVisible`,两轮全部通过。`WS_VISIBLE` 仍不等于用户实际看见或物理呈现。 +- Qt 尚未传入或执行。Qt 前快照中,System32 的 `vcruntime140.dll`、`vcruntime140_1.dll`、`msvcp140.dll`、`msvcp140_1.dll`、`msvcp140_2.dll` 均不存在,VC++ 卸载产品列表为空。没有安装 CRT;不能据本环境基础宣称开发包首次加载可用。 + +`presented_done=null`。本结果没有覆盖正式 C++/Qt/fixed-ConPTY 的字体、可见/隐藏会话、子进程尺寸、焦点、IME、Find、rename、鼠标命中或实际呈现,也不转移到旧 Win32、Alacritty 或其他构建。没有测量性能等价或设置任何性能容差;R2 整体与 #31 均保持未验收。 diff --git a/tools/probe_windows_display_environment.ps1 b/tools/probe_windows_display_environment.ps1 new file mode 100644 index 00000000..3fd9146a --- /dev/null +++ b/tools/probe_windows_display_environment.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference='Stop' +Add-Type @' +using System;using System.Runtime.InteropServices;using System.Collections.Generic;public static class TFDisplays { + [StructLayout(LayoutKind.Sequential)] public struct RECT{public int L,T,R,B;} + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] public struct INFO{public int Size;public RECT Monitor,Work;public uint Flags;[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)]public string Device;} + public delegate bool CB(IntPtr m,IntPtr d,ref RECT r,IntPtr p); + [DllImport("user32.dll")]public static extern bool EnumDisplayMonitors(IntPtr dc,IntPtr clip,CB callback,IntPtr data); + [DllImport("user32.dll",CharSet=CharSet.Unicode)]public static extern bool GetMonitorInfo(IntPtr h,ref INFO i); + [DllImport("shcore.dll")]public static extern int GetDpiForMonitor(IntPtr h,int type,out uint x,out uint y); + [DllImport("user32.dll")]public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr c); + [DllImport("user32.dll")]public static extern uint GetDpiForWindow(IntPtr hwnd); + [DllImport("user32.dll")]public static extern IntPtr MonitorFromWindow(IntPtr hwnd,uint flags); + [DllImport("user32.dll")]public static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")]public static extern bool ShowWindow(IntPtr hwnd,int command); + [DllImport("user32.dll")]public static extern bool GetWindowRect(IntPtr hwnd,out RECT rect); + public static string WindowDevice(IntPtr hwnd){var i=new INFO();i.Size=Marshal.SizeOf(i);if(!GetMonitorInfo(MonitorFromWindow(hwnd,2),ref i))throw new InvalidOperationException("GetMonitorInfo failed");return i.Device;} + public static RECT WindowRect(IntPtr hwnd){RECT r;if(!GetWindowRect(hwnd,out r))throw new InvalidOperationException("GetWindowRect failed");return r;} + public static object[] Read(){var rows=new List();CB cb=delegate(IntPtr h,IntPtr dc,ref RECT r,IntPtr p){var i=new INFO();i.Size=Marshal.SizeOf(i);GetMonitorInfo(h,ref i);uint x,y;int hr=GetDpiForMonitor(h,0,out x,out y);rows.Add(new{device=i.Device,left=i.Monitor.L,top=i.Monitor.T,right=i.Monitor.R,bottom=i.Monitor.B,primary=(i.Flags&1)!=0,dpi_x=x,dpi_y=y,dpi_hr=hr});return true;};EnumDisplayMonitors(IntPtr.Zero,IntPtr.Zero,cb,IntPtr.Zero);return rows.ToArray();} +} +'@ +[TFDisplays]::SetThreadDpiAwarenessContext([IntPtr](-4)) | Out-Null +Add-Type -AssemblyName System.Windows.Forms +$Displays=@([TFDisplays]::Read()) +$Form=New-Object Windows.Forms.Form +$Form.Text='TermForge R2 isolated actual DPI probe';$Form.Width=640;$Form.Height=400;$Form.StartPosition='Manual' +$Samples=@() +try{ + foreach($M in @($Displays)+@($Displays[0])){ + $Form.Location=New-Object Drawing.Point(($M.left+40),($M.top+40));$Form.Show();[TFDisplays]::ShowWindow($Form.Handle,5) | Out-Null;[Windows.Forms.Application]::DoEvents();Start-Sleep -Milliseconds 700;[Windows.Forms.Application]::DoEvents() + $Actual=[TFDisplays]::WindowDevice($Form.Handle);$Rect=[TFDisplays]::WindowRect($Form.Handle) + $Samples+=@{device=$M.device;actual_device=$Actual;target_matches=($Actual -eq $M.device);window_dpi=[TFDisplays]::GetDpiForWindow($Form.Handle);window_rect=@($Rect.L,$Rect.T,$Rect.R,$Rect.B);visible=[TFDisplays]::IsWindowVisible($Form.Handle);session_id=(Get-Process -Id $PID).SessionId;time_utc=[DateTime]::UtcNow.ToString('o')} + } +}finally{$Form.Close();$Form.Dispose()} +@{schema='termforge-actual-display-probe-v1';time_utc=[DateTime]::UtcNow.ToString('o');session_id=(Get-Process -Id $PID).SessionId;monitors=$Displays;window_samples=$Samples;distinct_actual_dpi=(@($Samples.window_dpi | Sort-Object -Unique).Count -gt 1);limitation='Infrastructure probe only; full issue31 TermForge behaviors require separate product evidence'} | ConvertTo-Json -Depth 8 From 9644b42137a25f66edc47c58c22ec7a1526455e0 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:48:06 +0800 Subject: [PATCH 2/2] fix: bound VM requests and reap their owned process jobs --- .gitattributes | 3 + .../r2-win11-request-deadline-20261029.json | 236 ++++++++++++++++++ docs/research/r2-win11-display-environment.md | 23 +- tools/fixtures/vm_request_after_timeout.ps1 | 3 + tools/fixtures/vm_request_sleep.ps1 | 10 + tools/owned_vm_request.cs | 199 +++++++++++++++ tools/run_vm_request_queue.ps1 | 37 +++ 7 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 docs/research/data/r2-win11-request-deadline-20261029.json create mode 100644 tools/fixtures/vm_request_after_timeout.ps1 create mode 100644 tools/fixtures/vm_request_sleep.ps1 create mode 100644 tools/owned_vm_request.cs create mode 100644 tools/run_vm_request_queue.ps1 diff --git a/.gitattributes b/.gitattributes index ba50dce8..9d950e1a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,5 +8,8 @@ research/ui/reference_sources/*.py.txt text eol=lf *.ps1 text eol=crlf # Preserve the byte identity of the measured standalone display probe. tools/probe_windows_display_environment.ps1 text eol=lf +tools/owned_vm_request.cs text eol=lf +tools/run_vm_request_queue.ps1 text eol=lf +tools/fixtures/vm_request_*.ps1 text eol=lf third_party/conpty/LICENSE text eol=lf experiments/core/fixtures/*.bin binary diff --git a/docs/research/data/r2-win11-request-deadline-20261029.json b/docs/research/data/r2-win11-request-deadline-20261029.json new file mode 100644 index 00000000..cd058866 --- /dev/null +++ b/docs/research/data/r2-win11-request-deadline-20261029.json @@ -0,0 +1,236 @@ +{ + "schema": "termforge-vm-request-execution-deadline-v2", + "recorded_utc": "2026-10-28T17:39:00.552572+00:00", + "scope": "Trusted synthetic VM requests and ordinary inherited Job descendants only; no Qt, CRT installation or #31 product acceptance", + "original_display_probe_sha256_unchanged": "307122b2c9d8cb12bbc0a54cebb7e95078430874b2e2ea7b676f5c4df80bda80", + "sources": { + "tools/owned_vm_request.cs": "e5dd42e51af8cd2a714d5060446424ad72a0cbe8537339179013a691412b8e75", + "tools/run_vm_request_queue.ps1": "dd7a1112bfabce3f68706f789e572f11bbfde3c921cca46244eaa924f2e27221", + "tools/fixtures/vm_request_sleep.ps1": "ea3a5a45ee8b0828d499ddaa8600fa10a02e4c9effaa81b2834f159dcda3fc29", + "tools/fixtures/vm_request_after_timeout.ps1": "e010de8a19d11e5046f8c4c38a571f120b875f9dfc23517abef23a0a8a74622b" + }, + "protocol": { + "host_poll_is_execution_deadline": false, + "default_execution_timeout_seconds": 30, + "allowed_execution_timeout_seconds": [ + 1, + 300 + ], + "cleanup_total_budget_milliseconds": 5000, + "forbid_breakaway": true, + "out_of_process_brokers_supported": false, + "self_contained_requests_only": true, + "unverified_cleanup_quarantines_queue": true + }, + "initial_cleanup_observation": { + "request_status": "cleanup_failed", + "protocol": "termforge-owned-vm-request-v2", + "start_utc": "2026-10-28T17:28:54.1198931Z", + "end_utc": "2026-10-28T17:28:57.3086460Z", + "session_id": 2, + "root_reaped": true, + "cleanup_verified": false, + "execution": { + "Schema": "termforge-owned-vm-request-v2", + "Status": "cleanup_failed", + "Error": null, + "StartedUtc": "2026-10-28T17:28:54.2747128Z", + "EndedUtc": "2026-10-28T17:28:57.3026093Z", + "ExecutionTimeoutMilliseconds": 3000, + "ExecutionElapsedMilliseconds": 3010, + "CreatedSuspended": true, + "RootHandleIdentityVerified": true, + "RootImageVerified": true, + "RootAssignedBeforeResume": true, + "KillOnCloseVerified": true, + "BreakawayDenied": true, + "TimedOut": true, + "TerminationRequested": true, + "RootWait": 0, + "RootExitCode": 124, + "RootReaped": true, + "JobBeforeActive": 4, + "JobBeforeTotal": 4, + "JobAfterActive": 0, + "MembersStable": true, + "CleanupVerified": false + }, + "members": [ + { + "is_root": false, + "image_basename": "conhost.exe", + "exact_job_membership": true, + "exited": false, + "wait": 258, + "exit_code": 124, + "query_error": null + }, + { + "is_root": true, + "image_basename": "powershell.exe", + "exact_job_membership": true, + "exited": true, + "wait": 0, + "exit_code": 124, + "query_error": null + }, + { + "is_root": false, + "image_basename": "conhost.exe", + "exact_job_membership": true, + "exited": true, + "wait": 0, + "exit_code": 124, + "query_error": null + }, + { + "is_root": false, + "image_basename": "powershell.exe", + "exact_job_membership": true, + "exited": true, + "wait": 0, + "exit_code": 124, + "query_error": null + } + ], + "source_sha256": "90418fa7a7a2f433b0d9b7117774264f137f32886ee6a22386f282c1e166011e" + }, + "timeout_fixture": { + "requested_sleep_seconds": 60, + "host_cli_exit": 124, + "fixture_child_in_exact_job": true, + "fixture_child_handle_signalled": true, + "observed": { + "request_status": "timed_out", + "protocol": "termforge-owned-vm-request-v2", + "start_utc": "2026-10-28T17:31:28.6569859Z", + "end_utc": "2026-10-28T17:31:31.7561559Z", + "session_id": 2, + "root_reaped": true, + "cleanup_verified": true, + "execution": { + "Schema": "termforge-owned-vm-request-v2", + "Status": "timed_out", + "Error": null, + "StartedUtc": "2026-10-28T17:31:28.7261616Z", + "EndedUtc": "2026-10-28T17:31:31.7521177Z", + "ExecutionTimeoutMilliseconds": 3000, + "ExecutionElapsedMilliseconds": 3001, + "CreatedSuspended": true, + "RootHandleIdentityVerified": true, + "RootImageVerified": true, + "RootAssignedBeforeResume": true, + "KillOnCloseVerified": true, + "BreakawayDenied": true, + "TimedOut": true, + "TerminationRequested": true, + "RootWait": 0, + "RootExitCode": 124, + "RootReaped": true, + "JobBeforeActive": 4, + "JobBeforeTotal": 4, + "JobAfterActive": 0, + "MembersStable": true, + "CleanupVerified": true + }, + "members": [ + { + "is_root": false, + "image_basename": "powershell.exe", + "exact_job_membership": true, + "exited": true, + "wait": 0, + "exit_code": 124, + "query_error": null + }, + { + "is_root": false, + "image_basename": "conhost.exe", + "exact_job_membership": true, + "exited": true, + "wait": 0, + "exit_code": 124, + "query_error": null + }, + { + "is_root": false, + "image_basename": "conhost.exe", + "exact_job_membership": true, + "exited": true, + "wait": 0, + "exit_code": 124, + "query_error": null + }, + { + "is_root": true, + "image_basename": "powershell.exe", + "exact_job_membership": true, + "exited": true, + "wait": 0, + "exit_code": 124, + "query_error": null + } + ] + } + }, + "next_normal_request": { + "host_cli_exit": 0, + "fixture_output": { + "ok": true, + "fixture": "request-after-timeout", + "session_id": 2 + }, + "observed": { + "request_status": "passed", + "protocol": "termforge-owned-vm-request-v2", + "start_utc": "2026-10-28T17:31:35.2207283Z", + "end_utc": "2026-10-28T17:31:36.2796552Z", + "session_id": 2, + "root_reaped": true, + "cleanup_verified": true, + "execution": { + "Schema": "termforge-owned-vm-request-v2", + "Status": "passed", + "Error": null, + "StartedUtc": "2026-10-28T17:31:35.2325677Z", + "EndedUtc": "2026-10-28T17:31:36.2796552Z", + "ExecutionTimeoutMilliseconds": 3000, + "ExecutionElapsedMilliseconds": 1047, + "CreatedSuspended": true, + "RootHandleIdentityVerified": true, + "RootImageVerified": true, + "RootAssignedBeforeResume": true, + "KillOnCloseVerified": true, + "BreakawayDenied": true, + "TimedOut": false, + "TerminationRequested": false, + "RootWait": 0, + "RootExitCode": 0, + "RootReaped": true, + "JobBeforeActive": 0, + "JobBeforeTotal": 2, + "JobAfterActive": 0, + "MembersStable": true, + "CleanupVerified": true + }, + "members": [] + } + }, + "queue_recovery": { + "same_live_runner_for_final_pair": true, + "runner_creation_utc": "2026-10-28T17:31:10.4590119Z", + "session_id": 2, + "protocol": "termforge-owned-vm-request-v2", + "pending_requests": 0, + "in_flight_requests": 0, + "quarantined": false + }, + "limitations": [ + "Raw QGA exec is an administrative control transport; its host polling timeout is not a workload execution guarantee. Test workloads use the guest-owned request queue.", + "Only trusted, self-contained requests using ordinary inherited CreateProcess descendants are supported. WMI Win32_Process.Create, external COM/ShellExecute/service/task activation and detached external lifetimes are outside the protocol.", + "The initial cleanup_failed result and old source remain private originals. It was not counted as a passed timeout scenario; final code used the already bounded cleanup budget for native handle waits.", + "No Qt or CRT command was run in this validation, and original display measurements were neither rerun nor rewritten.", + "No runtime, physical-presentation or performance acceptance is implied." + ], + "source_identity_note": "Fixture hashes identify published input bytes. The controller prepends the fixed UTF-8 PowerShell output-encoding prelude and BOM; guest metadata hashes and validates the exact staged request before resume." +} diff --git a/docs/research/r2-win11-display-environment.md b/docs/research/r2-win11-display-environment.md index 9059ee9b..5d5d2a02 100644 --- a/docs/research/r2-win11-display-environment.md +++ b/docs/research/r2-win11-display-environment.md @@ -26,7 +26,7 @@ QGA 报告的 `pretty-name` 保留旧的 `Windows 10 Enterprise Evaluation` 字 guest agent 的 PowerShell 命令在 session 0 退出 0,合成目录写读一致。另将 65,792 字节合成二进制经 guest-file 写入再读回,逐字节一致,SHA256 为 `120c518a83325c66464701a6ee080302f332bc768ea3f60473b209f1bfb091df`。guest 文件 API 只开放自有测试目录。 -独立测试账号的交互计划任务在实际 session 2 执行请求。每次窗口探针结束后保留自身进程句柄,通过原生 `WaitForSingleObject=0` 与 `GetExitCodeProcess=0` 证明根进程退出;这不是产品 ConPTY 或任意子树的清理验收。队列、超时、错误和身份记录留在自有目录,常驻 runner 是后续测试入口。 +独立测试账号的交互计划任务在实际 session 2 执行请求。每次窗口探针结束后保留自身进程句柄,通过原生 `WaitForSingleObject=0` 与 `GetExitCodeProcess=0` 证明根进程退出;这不是产品 ConPTY 或任意子树的清理验收。队列、错误和身份记录留在自有目录,常驻 runner 是后续测试入口。原始显示快照只证明正常请求完成;挂起请求的执行时限由下述独立协议和失败场景补充,不能从宿主轮询超时推导。 缩放通过 **guest 自身 Windows Display Settings 的正常 UI Automation 控件**设置:一个输出保持 100%,另一个选择界面实际提供的 125%。未注入受控 DPI 消息,也没有修改个人显示设置。设置请求没有被计为成功;后续新窗口另行读到: @@ -45,11 +45,30 @@ powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File tools/probe_window 探针只移动并关闭自身 WinForms 窗口,输出实际 API 读值;不设置系统缩放、监视器或产品状态。直接运行探针没有附带私有队列的进程句柄回收记录,也不能据 `distinct_actual_dpi` 一项判断 #31 通过。 +## 请求执行时限与自有 Job + +原队列的 `Child.WaitForExit()` 无界,宿主停止轮询仍可能让请求永久堵住队列。`termforge-owned-vm-request-v2` 改为 guest 内执行时限;新的[独立原始读值](data/r2-win11-request-deadline-20261029.json)不修改前面的两轮 DPI 成功数据或旧失败原件。 + +[队列](../../tools/run_vm_request_queue.ps1)验证每请求的协议、源码 SHA256 和 1–300 秒整数执行时限,再调用[原生监督器](../../tools/owned_vm_request.cs)。控制端默认 30 秒执行时限;宿主轮询时限另算,不作为 guest 执行或回收证明。每次请求都以 `CREATE_SUSPENDED` 创建根进程,验证保留句柄的 PID、创建时间、实际镜像与新建无名 Job 的归属后,才 `ResumeThread`。Job 的 kill-on-close 和禁止 breakaway 标志读回核实,仅三个标准流句柄允许继承。机制参考微软 [Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects) 与 [Process Creation Flags](https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags)。 + +原生 wait 的执行预算覆盖根进程和仍活动的普通继承子树;根先退出时,只在原执行预算内等待剩余成员。到期枚举这个 Job 的完整、稳定成员清单,保留每个进程句柄并复核精确 Job 归属,再仅终止这个 Job。清理共享一个最多 5 秒的预算,独立核实根和已观测成员句柄 signaled、退出码与 Job active=0。无法证明清理时记录 `cleanup_failed` 并隔离队列,不能继续派发;runner 退出时 kill-on-close 是额外回收后盾,不被当作已观测退出。 + +实测失败路径与紧接的正常请求使用同一个存活的 session 2 runner: + +| 场景 | 执行时限与结果 | 自有进程与队列 | +|---|---|---| +| [Sleep 夹具](../../tools/fixtures/vm_request_sleep.ps1):根与子 PowerShell 均 Sleep 60 秒 | 3000 ms 原生时限,实测到期 `timed_out`;控制端退出 124,不能算成功 | 实际 4 个 Job 成员(含两个 console host),全部精确归属、保留句柄 wait=0 / exit=124;active 4→0 | +| 紧接的[正常夹具](../../tools/fixtures/vm_request_after_timeout.ps1) | 同为 3000 ms 执行预算,输出 `ok=true`,自然退出 0 | 未请求强制终止,Job active=0,根句柄 wait=0 / exit=0;队列继续,pending/in-flight 均 0 | + +超时夹具只能通过队列执行,不能脱离监督器直接运行。请求必须自包含;只支持普通 `CreateProcess` 继承子树,不支持 fire-and-forget、WMI `Win32_Process.Create`、COM/ShellExecute、服务或计划任务等外部代理激活。原始 QGA `exec` 仍是管理控制传输,其宿主轮询超时也不是任意工作负载的执行保证;测试工作负载走上述队列。监督器的 `passed` 只表示请求根退出 0 且自有 Job 已清理,功能断言仍由请求承担,不自动证明 Qt 或产品通过。 + +首个超时实验曾正确记录 `cleanup_failed`:Job active 已为 0,但一个 console host 的保留句柄尚未立即 signaled。队列因此隔离,没有冒充清理成功。核实原成员已不活动后,在原有 5 秒清理预算内等待各保留句柄;最终新实验全部 signaled,执行时限仍为 3 秒。初次失败原件与最终成功回收记录分开保留。本次没有运行 Qt、安装 CRT 或重跑 DPI 两轮。 + ## 失败记录与边界 - 首轮 bundle 已装好驱动和 QGA,随后重复 `pnputil vioserial` 返回 259,严格脚本因此中止。原始失败留存;核实已装签名 QXL、运行中的 QGA 后,只补齐交互计划任务,没有把 259 改写成驱动安装成功。 - 首轮 `.NET Process.ExitCode` 为 null,不计进程回收通过。最终 runner 保留进程句柄,改用原生 wait 与 exit code 后重跑。 - 加强原生可见性查询时,首个窗口因隐藏 worker 的启动设置实际为 hidden。该失败留存;最终探针显式 `ShowWindow`,再独立核对 `IsWindowVisible`,两轮全部通过。`WS_VISIBLE` 仍不等于用户实际看见或物理呈现。 -- Qt 尚未传入或执行。Qt 前快照中,System32 的 `vcruntime140.dll`、`vcruntime140_1.dll`、`msvcp140.dll`、`msvcp140_1.dll`、`msvcp140_2.dll` 均不存在,VC++ 卸载产品列表为空。没有安装 CRT;不能据本环境基础宣称开发包首次加载可用。 +- 本环境基础采集期间未传入或执行 Qt。Qt 前快照中,System32 的 `vcruntime140.dll`、`vcruntime140_1.dll`、`msvcp140.dll`、`msvcp140_1.dll`、`msvcp140_2.dll` 均不存在,VC++ 卸载产品列表为空。本次未安装 CRT;不能据本环境基础宣称开发包首次加载可用。 `presented_done=null`。本结果没有覆盖正式 C++/Qt/fixed-ConPTY 的字体、可见/隐藏会话、子进程尺寸、焦点、IME、Find、rename、鼠标命中或实际呈现,也不转移到旧 Win32、Alacritty 或其他构建。没有测量性能等价或设置任何性能容差;R2 整体与 #31 均保持未验收。 diff --git a/tools/fixtures/vm_request_after_timeout.ps1 b/tools/fixtures/vm_request_after_timeout.ps1 new file mode 100644 index 00000000..bfdef8de --- /dev/null +++ b/tools/fixtures/vm_request_after_timeout.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference='Stop' +@{fixture='request-after-timeout';ok=$true;session_id=(Get-Process -Id $PID).SessionId} | ConvertTo-Json +exit 0 diff --git a/tools/fixtures/vm_request_sleep.ps1 b/tools/fixtures/vm_request_sleep.ps1 new file mode 100644 index 00000000..1559ce70 --- /dev/null +++ b/tools/fixtures/vm_request_sleep.ps1 @@ -0,0 +1,10 @@ +# Ordinary descendants must inherit this request's Job and share its deadline. +$ErrorActionPreference='Stop' +$Stem=[IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Path) +$ChildMarker=Join-Path $PSScriptRoot ($Stem+'.child-started.json') +$ChildCode="@{pid=`$PID;fixture='owned-sleep-child'} | ConvertTo-Json | Set-Content '$ChildMarker'; Start-Sleep -Seconds 60" +$Encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($ChildCode)) +$Child=Start-Process "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -ArgumentList @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$Encoded) -WindowStyle Hidden -PassThru +@{fixture='owned-sleep-root';root_pid=$PID;child_pid=$Child.Id;sleep_seconds=60} | ConvertTo-Json +Start-Sleep -Seconds 60 +throw 'The execution deadline should have stopped this request' diff --git a/tools/owned_vm_request.cs b/tools/owned_vm_request.cs new file mode 100644 index 00000000..0d686c70 --- /dev/null +++ b/tools/owned_vm_request.cs @@ -0,0 +1,199 @@ +// Own one trusted test request and its ordinary process descendants. Out-of-process +// service/COM/task activation is outside this protocol and must not be requested. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace TermForgeVmRequest { + public sealed class Member { + public uint Pid; + public bool IsRoot, ExactJobMembership, Exited; + public uint Wait, ExitCode; + public string CreationUtc, Image, QueryError; + internal IntPtr Handle; + } + public sealed class Result { + public string Schema = "termforge-owned-vm-request-v2", Status = "failed", Error, StartedUtc, EndedUtc; + public uint RootPid, RootWait, RootExitCode, JobBeforeActive, JobBeforeTotal, JobAfterActive; + public string RootImage, RootCreationUtc; + public int ExecutionTimeoutMilliseconds; + public long ExecutionElapsedMilliseconds; + public bool CreatedSuspended, RootHandleIdentityVerified, RootImageVerified, RootAssignedBeforeResume, KillOnCloseVerified; + public bool BreakawayDenied, TimedOut, TerminationRequested, RootReaped, MembersStable, CleanupVerified; + public List Members = new List(); + } + public static class Supervisor { + const uint WAIT_OBJECT_0 = 0, WAIT_TIMEOUT = 258, KILL_ON_CLOSE = 0x2000; + const uint QUERY_LIMITED = 0x1000, SYNCHRONIZE = 0x100000; + static readonly IntPtr InvalidHandle = new IntPtr(-1); + [StructLayout(LayoutKind.Sequential)] struct Security { public int Length; public IntPtr Descriptor; public int Inherit; } + [StructLayout(LayoutKind.Sequential)] struct Startup { + public uint Size; public IntPtr Reserved, Desktop, Title; + public uint X, Y, XSize, YSize, XChars, YChars, Fill, Flags; + public ushort Show, ReservedSize; public IntPtr ReservedBytes, Input, Output, Error; + } + [StructLayout(LayoutKind.Sequential)] struct StartupEx { public Startup Startup; public IntPtr Attributes; } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr Process, Thread; public uint Pid, Tid; } + [StructLayout(LayoutKind.Sequential)] struct BasicLimits { + public long ProcessTime, JobTime; public uint Flags; public UIntPtr MinWorkingSet, MaxWorkingSet; + public uint ActiveLimit; public UIntPtr Affinity; public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct IoCounters { public ulong A, B, C, D, E, F; } + [StructLayout(LayoutKind.Sequential)] struct ExtendedLimits { + public BasicLimits Basic; public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [StructLayout(LayoutKind.Sequential)] struct Accounting { + public long User, Kernel, PeriodUser, PeriodKernel; + public uint PageFaults, Total, Active, Terminated; + } + [StructLayout(LayoutKind.Sequential)] struct FileTime { public uint Low, High; } + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern IntPtr CreateJobObjectW(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits data, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int kind, IntPtr data, uint size, IntPtr returned); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool member); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint GetProcessId(IntPtr process); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetProcessTimes(IntPtr process, out FileTime created, out FileTime exited, out FileTime kernel, out FileTime user); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool QueryFullProcessImageNameW(IntPtr process, uint flags, StringBuilder name, ref uint size); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern IntPtr CreateFileW(string path, uint access, uint sharing, ref Security security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr attributes, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr attributes, uint flags, IntPtr kind, IntPtr value, IntPtr size, IntPtr previous, IntPtr returned); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr attributes); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool CreateProcessW(string application, StringBuilder command, IntPtr processAttributes, IntPtr threadAttributes, bool inherit, uint flags, IntPtr environment, string directory, ref StartupEx startup, out ProcessInfo info); + static void Check(bool ok) { if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); } + static void Close(ref IntPtr h) { if (h != IntPtr.Zero && h != InvalidHandle) CloseHandle(h); h = IntPtr.Zero; } + static string Image(IntPtr handle) { var text = new StringBuilder(32768); uint size = (uint)text.Capacity; Check(QueryFullProcessImageNameW(handle, 0, text, ref size)); return text.ToString(); } + static string Created(IntPtr handle) { FileTime c,e,k,u; Check(GetProcessTimes(handle,out c,out e,out k,out u)); return DateTime.FromFileTimeUtc((long)(((ulong)c.High << 32) | c.Low)).ToString("o"); } + static T JobInfo(IntPtr job, int kind) { + IntPtr p = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(T))); + try { Check(QueryInformationJobObject(job,kind,p,(uint)Marshal.SizeOf(typeof(T)),IntPtr.Zero)); return (T)Marshal.PtrToStructure(p,typeof(T)); } + finally { Marshal.FreeHGlobal(p); } + } + static uint[] ProcessIds(IntPtr job) { + int bytes = 8 + IntPtr.Size * 256; IntPtr p = Marshal.AllocHGlobal(bytes); + try { + Check(QueryInformationJobObject(job,3,p,(uint)bytes,IntPtr.Zero)); + int assigned=Marshal.ReadInt32(p), listed=Marshal.ReadInt32(p,4); + if (assigned != listed || listed < 0 || listed > 256) throw new InvalidOperationException("Incomplete owned Job PID list"); + var ids=new uint[listed]; for(int i=0;i300000) throw new ArgumentException("Requires x64 and a 1..300 second execution deadline"); + executable=Path.GetFullPath(executable); script=Path.GetFullPath(script); + if(!File.Exists(executable) || !File.Exists(script)) throw new FileNotFoundException("Owned request input missing"); + var r=new Result { ExecutionTimeoutMilliseconds=timeoutMilliseconds }; + IntPtr job=IntPtr.Zero, input=IntPtr.Zero, output=IntPtr.Zero, errorHandle=IntPtr.Zero, attributes=IntPtr.Zero, handles=IntPtr.Zero; + bool attributesReady=false, assigned=false; ProcessInfo process=new ProcessInfo(); + try { + job=CreateJobObjectW(IntPtr.Zero,null); Check(job!=IntPtr.Zero); + var limits=new ExtendedLimits(); limits.Basic.Flags=KILL_ON_CLOSE; + Check(SetInformationJobObject(job,9,ref limits,(uint)Marshal.SizeOf(typeof(ExtendedLimits)))); + var actual=JobInfo(job,9); r.KillOnCloseVerified=(actual.Basic.Flags&KILL_ON_CLOSE)!=0; r.BreakawayDenied=(actual.Basic.Flags&0x1800)==0; + if(!r.KillOnCloseVerified || !r.BreakawayDenied) throw new InvalidOperationException("Owned Job limits did not round-trip"); + var security=new Security { Length=Marshal.SizeOf(typeof(Security)), Inherit=1 }; + input=CreateFileW("NUL",0x80000000,3,ref security,3,0,IntPtr.Zero); Check(input!=InvalidHandle); + output=CreateFileW(stdout,0x40000000,3,ref security,2,0,IntPtr.Zero); Check(output!=InvalidHandle); + errorHandle=CreateFileW(stderr,0x40000000,3,ref security,2,0,IntPtr.Zero); Check(errorHandle!=InvalidHandle); + IntPtr size=IntPtr.Zero; InitializeProcThreadAttributeList(IntPtr.Zero,1,0,ref size); + attributes=Marshal.AllocHGlobal(size); Check(InitializeProcThreadAttributeList(attributes,1,0,ref size)); attributesReady=true; + handles=Marshal.AllocHGlobal(3*IntPtr.Size); Marshal.WriteIntPtr(handles,0,input); Marshal.WriteIntPtr(handles,IntPtr.Size,output); Marshal.WriteIntPtr(handles,2*IntPtr.Size,errorHandle); + Check(UpdateProcThreadAttribute(attributes,0,new IntPtr(0x20002),handles,new IntPtr(3*IntPtr.Size),IntPtr.Zero,IntPtr.Zero)); + var startup=new StartupEx(); startup.Startup.Size=(uint)Marshal.SizeOf(typeof(StartupEx)); startup.Startup.Flags=0x101; startup.Startup.Show=0; + startup.Startup.Input=input; startup.Startup.Output=output; startup.Startup.Error=errorHandle; startup.Attributes=attributes; + var command=new StringBuilder("\""+executable+"\" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -File \""+script+"\""); + Check(CreateProcessW(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x08080004,IntPtr.Zero,Path.GetDirectoryName(script),ref startup,out process)); + r.CreatedSuspended=true; r.RootPid=process.Pid; r.RootImage=Image(process.Process); r.RootCreationUtc=Created(process.Process); + r.RootHandleIdentityVerified=GetProcessId(process.Process)==process.Pid; + r.RootImageVerified=String.Equals(r.RootImage,executable,StringComparison.OrdinalIgnoreCase); + if(!r.RootImageVerified || !r.RootHandleIdentityVerified) throw new InvalidOperationException("Created root image/handle identity differs"); + Check(AssignProcessToJobObject(job,process.Process)); assigned=true; + bool rootMember; Check(IsProcessInJob(process.Process,job,out rootMember)); r.RootAssignedBeforeResume=rootMember; + if(!rootMember) throw new InvalidOperationException("Suspended root was not in its owned Job"); + Close(ref input); Close(ref output); Close(ref errorHandle); + var execution=Stopwatch.StartNew(); r.StartedUtc=DateTime.UtcNow.ToString("o"); + Check(ResumeThread(process.Thread)!=0xFFFFFFFF); Close(ref process.Thread); + uint waited=WaitForSingleObject(process.Process,(uint)timeoutMilliseconds); + r.TimedOut=waited==WAIT_TIMEOUT; + if(waited!=WAIT_OBJECT_0 && !r.TimedOut) throw new Win32Exception(Marshal.GetLastWin32Error(),"Owned root wait failed"); + Accounting before=JobInfo(job,1); + // A root may exit just before its console host or ordinary children. + // Drain only inside the original execution deadline, never beyond it. + while(!r.TimedOut && before.Active!=0 && execution.ElapsedMilliseconds(job,1); + } + if(before.Active!=0 && execution.ElapsedMilliseconds>=timeoutMilliseconds) r.TimedOut=true; + r.ExecutionElapsedMilliseconds=execution.ElapsedMilliseconds; + r.JobBeforeActive=before.Active; r.JobBeforeTotal=before.Total; + if(r.TimedOut || before.Active!=0) { + ObserveMembers(job,r); r.TerminationRequested=true; Check(TerminateJobObject(job,124)); + r.Status=r.TimedOut?"timed_out":"failed_lingering_children"; + } else { r.MembersStable=true; } + } catch(Exception error) { r.Error=error.Message; r.Status="failed"; } + finally { + // Direct handles originate from this CreateProcess call; no name-based termination. + if(process.Process!=IntPtr.Zero) { + try { + if(WaitForSingleObject(process.Process,0)!=WAIT_OBJECT_0 || (assigned && JobInfo(job,1).Active!=0)) { + r.TerminationRequested=true; + if(assigned) Check(TerminateJobObject(job,124)); else Check(TerminateProcess(process.Process,125)); + } + var cleanup=Stopwatch.StartNew(); + r.RootWait=WaitForSingleObject(process.Process,5000); r.RootReaped=r.RootWait==WAIT_OBJECT_0; + Check(GetExitCodeProcess(process.Process,out r.RootExitCode)); + while(assigned && JobInfo(job,1).Active!=0 && cleanup.ElapsedMilliseconds<5000) Thread.Sleep(10); + r.JobAfterActive=assigned?JobInfo(job,1).Active:0; + bool members=true; + foreach(Member m in r.Members) { + if(m.Handle==IntPtr.Zero) { members=false; continue; } + uint remaining=(uint)Math.Max(0,5000-cleanup.ElapsedMilliseconds); + m.Wait=WaitForSingleObject(m.Handle,remaining); m.Exited=m.Wait==WAIT_OBJECT_0; + if(!GetExitCodeProcess(m.Handle,out m.ExitCode)) { m.QueryError="GetExitCodeProcess failed"; members=false; } + members=members && m.ExactJobMembership && m.Exited && m.QueryError==null; + } + r.CleanupVerified=r.RootReaped && r.JobAfterActive==0 && members && r.MembersStable; + if(r.Error==null && !r.TimedOut && !r.TerminationRequested) r.Status=r.RootExitCode==0?"passed":"failed"; + } catch(Exception error) { r.Error=(r.Error==null?"":r.Error+"; ")+"cleanup: "+error.Message; } + } else { r.CleanupVerified=true; } + foreach(Member m in r.Members) Close(ref m.Handle); + Close(ref process.Thread); Close(ref process.Process); + Close(ref job); // KILL_ON_JOB_CLOSE is a final backstop, including runner failure. + Close(ref input); Close(ref output); Close(ref errorHandle); + if(attributesReady) DeleteProcThreadAttributeList(attributes); + if(attributes!=IntPtr.Zero) Marshal.FreeHGlobal(attributes); + if(handles!=IntPtr.Zero) Marshal.FreeHGlobal(handles); + r.EndedUtc=DateTime.UtcNow.ToString("o"); + if(!r.CleanupVerified) r.Status="cleanup_failed"; + } + return r; + } + } +} diff --git a/tools/run_vm_request_queue.ps1 b/tools/run_vm_request_queue.ps1 new file mode 100644 index 00000000..8810e226 --- /dev/null +++ b/tools/run_vm_request_queue.ps1 @@ -0,0 +1,37 @@ +# Trusted requests are self-contained: ordinary descendants stay in the owned Job. +# Service, COM or scheduled-task activation and persistent external processes are +# outside this protocol. Stop on unproven cleanup instead of advancing the queue. +param([string]$Root='C:\TermForgeHarness') +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +$Root=[IO.Path]::GetFullPath($Root) +if($Root.TrimEnd('\') -eq [IO.Path]::GetPathRoot($Root).TrimEnd('\')){throw 'A dedicated owned harness directory is required'} +Add-Type -Path (Join-Path $PSScriptRoot 'owned_vm_request.cs') +if(Test-Path "$Root\State\queue-quarantined.json"){throw 'Earlier cleanup is unverified; inspect the owned record before resuming'} +@{protocol='termforge-owned-vm-request-v2';ready_utc=[DateTime]::UtcNow.ToString('o');pid=$PID;session_id=(Get-Process -Id $PID).SessionId;user=[Security.Principal.WindowsIdentity]::GetCurrent().Name} | ConvertTo-Json | Set-Content "$Root\State\interactive-ready.json" -Encoding UTF8 +while($true){ + foreach($Req in @(Get-ChildItem "$Root\Requests" -Filter '*.ps1' -File | Sort-Object Name)){ + $Id=$Req.BaseName + if($Id -notmatch '^[A-Za-z0-9_-]{1,80}$'){continue} + $Claim="$Root\State\$Id.running.ps1" + try{Move-Item -LiteralPath $Req.FullName -Destination $Claim -ErrorAction Stop}catch{continue} + $Record=@{id=$Id;protocol='termforge-owned-vm-request-v2';start_utc=[DateTime]::UtcNow.ToString('o');session_id=(Get-Process -Id $PID).SessionId;status='invalid_request';cleanup_verified=$true} + $Invoked=$false + try{ + $Metadata=Get-Content "$Root\Requests\$Id.request.json" -Raw | ConvertFrom-Json + if($Metadata.protocol -ne 'termforge-owned-vm-request-v2' -or -not($Metadata.execution_timeout_seconds -is [int] -or $Metadata.execution_timeout_seconds -is [long]) -or $Metadata.execution_timeout_seconds -lt 1 -or $Metadata.execution_timeout_seconds -gt 300){throw 'Request requires an integer execution deadline of 1..300 seconds'} + if((Get-FileHash -Algorithm SHA256 $Claim).Hash -ne $Metadata.script_sha256){throw 'Request source hash differs from its atomic metadata'} + Copy-Item "$Root\Requests\$Id.request.json" "$Root\State\$Id.request.json" + $Invoked=$true + $Result=[TermForgeVmRequest.Supervisor]::Run("$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe",$Claim,"$Root\Results\$Id.stdout.txt","$Root\Results\$Id.stderr.txt",([int]$Metadata.execution_timeout_seconds*1000)) + $Record.status=$Result.Status;$Record.cleanup_verified=$Result.CleanupVerified;$Record.execution=$Result + $Record.exit_code=$Result.RootExitCode;$Record.root_reaped=$Result.RootReaped + }catch{$Record.error=$_.Exception.Message;if($Invoked){$Record.status='cleanup_failed';$Record.cleanup_verified=$false}} + $Record.end_utc=[DateTime]::UtcNow.ToString('o') + $Temp="$Root\Results\$Id.complete.upload" + $Record | ConvertTo-Json -Depth 12 | Set-Content $Temp -Encoding UTF8 + Move-Item -LiteralPath $Temp -Destination "$Root\Results\$Id.complete.json" + if(-not $Record.cleanup_verified){$Record | ConvertTo-Json -Depth 12 | Set-Content "$Root\State\queue-quarantined.json" -Encoding UTF8;throw 'Owned request cleanup is unverified; queue quarantined'} + } + Start-Sleep -Milliseconds 200 +}