From 96d4cf0179b9aca78e4120982dc91a299ce8cfce Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 15 Sep 2026 09:42:11 +0800 Subject: [PATCH] fix(windows): reclaim terminal resources and retain font owners Persist the pending ConPTY lifetime, process snapshot and native font ownership fixes. Keep lifecycle regression tests and the Windows memory stress tool together with the ownership contracts. --- Cargo.lock | 50 +- Cargo.toml | 21 +- docs/architecture-decisions.md | 37 ++ nebula_app/Cargo.toml | 3 + nebula_app/src/platform/mod.rs | 1 + nebula_app/src/platform/process_snapshot.rs | 90 +++ .../src/platform/process_snapshot/windows.rs | 184 ++++++ nebula_app/src/process_tree.rs | 170 ++--- nebula_terminal/src/tty/windows/blocking.rs | 5 + nebula_terminal/src/tty/windows/child.rs | 130 +++- nebula_terminal/src/tty/windows/conpty.rs | 158 +++-- .../tests/windows_pty_lifecycle.rs | 148 +++++ scripts/tests/test_windows_memory_stress.py | 128 ++++ scripts/windows-memory-stress.md | 111 ++++ scripts/windows_memory_stress.py | 579 ++++++++++++++++++ 15 files changed, 1588 insertions(+), 227 deletions(-) create mode 100644 nebula_app/src/platform/process_snapshot.rs create mode 100644 nebula_app/src/platform/process_snapshot/windows.rs create mode 100644 nebula_terminal/tests/windows_pty_lifecycle.rs create mode 100644 scripts/tests/test_windows_memory_stress.py create mode 100644 scripts/windows-memory-stress.md create mode 100644 scripts/windows_memory_stress.py diff --git a/Cargo.lock b/Cargo.lock index 32399d6..b9c8ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,7 +1414,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "gpui_util", "indexmap", @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "proc-macro2", "quote", @@ -3159,7 +3159,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "accesskit", "anyhow", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" +source = "git+https://github.com/Kuddev/gpui-component?rev=e411cc06dfff83a96c4705076483a87a26ea992d#e411cc06dfff83a96c4705076483a87a26ea992d" dependencies = [ "aho-corasick", "anyhow", @@ -3328,7 +3328,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" +source = "git+https://github.com/Kuddev/gpui-component?rev=e411cc06dfff83a96c4705076483a87a26ea992d#e411cc06dfff83a96c4705076483a87a26ea992d" dependencies = [ "anyhow", "gpui", @@ -3342,7 +3342,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" +source = "git+https://github.com/Kuddev/gpui-component?rev=e411cc06dfff83a96c4705076483a87a26ea992d#e411cc06dfff83a96c4705076483a87a26ea992d" dependencies = [ "proc-macro2", "quote", @@ -3352,7 +3352,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "accesskit", "accesskit_unix", @@ -3404,7 +3404,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "accesskit", "accesskit_macos", @@ -3453,7 +3453,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3464,7 +3464,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "console_error_panic_hook", "gpui", @@ -3477,7 +3477,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "schemars", "serde", @@ -3487,7 +3487,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "anyhow", "log", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3520,7 +3520,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "anyhow", "bytemuck", @@ -3550,7 +3550,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "accesskit", "accesskit_windows", @@ -3846,7 +3846,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "anyhow", "async-compression", @@ -4808,7 +4808,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "anyhow", "bindgen", @@ -6280,7 +6280,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "collections", "serde", @@ -7179,7 +7179,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "derive_refineable", ] @@ -7774,7 +7774,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "async-task", "backtrace", @@ -8637,7 +8637,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "heapless", "log", @@ -9962,7 +9962,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "perf", "quote", @@ -11973,7 +11973,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "anyhow", "chrono", @@ -11990,7 +11990,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" dependencies = [ "tracing", "tracing-subscriber", @@ -12001,7 +12001,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" +source = "git+https://github.com/Kuddev/zed?rev=7c662384cd77c1d1e1566206920e3a420ea6767b#7c662384cd77c1d1e1566206920e3a420ea6767b" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 454a063..3b0a96d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ toml_edit = "0.24.0" # GPUI v1.16.1 把平台启动入口拆到独立 crate。它必须与 `gpui` 使用完全相同 # 的 Git URL 和 rev,否则 Cargo 会把同名类型解析成两套互不兼容的 crate。 # Without font-kit, the macOS platform uses NoopTextSystem and renders no text. -gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.1.0", features = ["font-kit"] } +gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "7c662384cd77c1d1e1566206920e3a420ea6767b", version = "=0.1.0", features = ["font-kit"] } # TODO: Drop this pin and return to the crates.io release once the fix ships there. [patch.crates-io] @@ -97,7 +97,7 @@ winit = { path = "third_party/winit-0.30.13" } # @ fa3763c681dba5b3ea028395148488e1281d478d # (c20dbef8 基线 + 下游虚拟窗口测试支持) # -# 当前窗口生命周期修补(2026-09-06,保留以上上游/组件基线): +# 窗口生命周期修补(2026-09-06,保留以上上游/组件基线): # Kuddev/zed nebula-v1.16.1-virtual-window-handles-20260906 # @ 8a1a62727bcb2ead7352e32e7e0d2e919a943535 # (TestWindow 对不存在的原生窗口/显示句柄返回 Unavailable;真实窗口路径不变) @@ -109,6 +109,15 @@ winit = { path = "third_party/winit-0.30.13" } # XCB 内部队列而使 fd 不可读;延后 drain 并唤醒 calloop,才能启动首次刷新。 # 该问题由原样 Xvfb/libxcb 与 CI AppImage 重现;上游包含这些合同后撤销薄修补。 # +# 当前字体内存修补(2026-09-13,基于以上窗口生命周期提交): +# Kuddev/zed pebrel-v1.16.1-font-owner-20260913 +# @ 7c662384cd77c1d1e1566206920e3a420ea6767b +# Kuddev/gpui-component pebrel-v1.16.1-font-owner-20260913 +# @ e411cc06dfff83a96c4705076483a87a26ea992d +# DirectWrite 用 COM owner 保持不可变字体数据的生命周期,避免完整复制内嵌字体。 +# 原生测试覆盖借用/自有字节、最后引用释放、错误路径和长度;组件五个 Zed 引用同步。 +# 上游提供同等字体所有权与释放合同后撤销此薄修补,保留其余固定基线。 +# # 所有 Zed 依赖统一使用 Kuddev URL:Cargo 的 source identity 包含 URL,应用 # 与 component 内部若混用官方/自有 URL,即使 SHA 相同也会解析成两套 gpui # 类型。新增 Zed/component 补丁必须从固定基线开独立分支、逐条提交并更新 @@ -173,7 +182,7 @@ winit = { path = "third_party/winit-0.30.13" } # tests/root_test_window.rs 验证以依赖方式编译的 Root 可使用虚拟窗口; # 上游提供等价的下游 headless 测试支持后撤销本补丁。 # -# 当前精确引用已包含补丁 1-3 与 6;Input 补丁 4-5 仍保持独立,尚未带入本次升级。 -gpui = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.2.2" } -gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "2dd896281102c7d5a0137ac7c754886dc0d5e1fc", version = "=0.5.2" } -gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "2dd896281102c7d5a0137ac7c754886dc0d5e1fc", version = "=0.5.1" } +# 当前精确引用包含补丁 1-3、6 及字体内存修补;Input 补丁 4-5 仍保持独立。 +gpui = { git = "https://github.com/Kuddev/zed", rev = "7c662384cd77c1d1e1566206920e3a420ea6767b", version = "=0.2.2" } +gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "e411cc06dfff83a96c4705076483a87a26ea992d", version = "=0.5.2" } +gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "e411cc06dfff83a96c4705076483a87a26ea992d", version = "=0.5.1" } diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index bf67bb8..8f80c49 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -420,3 +420,40 @@ settings files. - **Replacement condition:** Revisit the budgets or an OS-backed index only with measured query latency, completeness and sustained allocation evidence. Do not restore eager full-tree indexing to improve a synthetic latency number. + +## ADR-0014 — Native font ownership and terminal resource reclamation + +- **Status:** Existing local fixes selected for commit at the maintainer's request, + 2026-09-15. This records ownership contracts, not a release or process-memory claim. +- **Context:** Registering immutable font bytes without a DirectWrite owner creates + a full private copy. Retained caller-side ConPTY pipe handles prevent output EOF + after a terminal closes, leaving reader tasks and buffers alive. Process-ID reuse + can also attach an unrelated older process to a newly created terminal's tree. +- **Font decision:** Pin the existing GPUI fork and its component consumers to the + matching font-owner revisions. A COM owner retains borrowed static bytes or the + original owned buffer until the last native consumer releases it. Preserve font + data, fallback behavior and lifetime; do not remove CJK coverage to reduce memory. + All GPUI dependency edges retain one source identity and exact revision. +- **PTY decision:** Close caller-owned pipe ends after ConPTY has duplicated them. + Own the process handle, primary-thread handle, process attribute list and loaded + console library separately. Keep output draining during teardown and failed spawn. + Wait for cancellation and completion of native exit callbacks before releasing + their context; an unconfirmed cancellation retains the context and reports an + error instead of permitting native code to access freed memory. +- **Process boundary:** The platform adapter reads identifiers and creation times + in one bounded Windows snapshot, without opening protected processes. The shared + process-tree rules reject an older child's edge to a younger reused parent PID. + Unknown creation times preserve the edge and existing busy-process protection. + Unix retains its existing process listing behavior. No resident polling service, + persistence change or additional production crate is introduced. +- **Validation:** Native font regressions cover borrowed and owned buffers, original + pointers, final-reference release and invalid lengths. Terminal regressions cover + idle and busy close, failed launch, in-flight callbacks and native handle recovery + using the packaged console host. Process tests cover PID reuse, real busy children, + unknown timestamps and malformed native records. The maintained memory stress + tool distinguishes peak, warmup and retained growth, and fails incomplete runs or + forced cleanup. Historical native results and new checks must be reported with + their actual source/build identities; budgets are not whole-process guarantees. +- **Revisit condition:** Remove the font fork patch when upstream provides the same + ownership contract. Revisit native adapters when supported platform interfaces + provide equivalent process identity and resource-lifetime guarantees. diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 8358e5d..4764527 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -223,6 +223,9 @@ windows-sys = { version = "0.59", features = [ "Win32_System_JobObjects", "Win32_System_LibraryLoader", "Win32_System_SystemInformation", + # A single process snapshot includes creation times even for protected + # processes, so reused parent PIDs cannot attach unrelated processes to a pane. + "Wdk_System_SystemInformation", # RtlGetVersion:系统 backdrop 只从 Windows 11 build 22621 起可用, # 不能依赖会受兼容清单影响的旧版本查询 API。 "Wdk_System_SystemServices", diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index b6c19cd..9dce90c 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -25,6 +25,7 @@ pub mod fonts; #[cfg(all(windows, feature = "gpui-shell"))] pub(crate) mod keyboard; pub mod notifications; +pub(crate) mod process_snapshot; pub mod shell; pub mod shell_integration; pub mod startup; diff --git a/nebula_app/src/platform/process_snapshot.rs b/nebula_app/src/platform/process_snapshot.rs new file mode 100644 index 0000000..1e6681c --- /dev/null +++ b/nebula_app/src/platform/process_snapshot.rs @@ -0,0 +1,90 @@ +//! One native process snapshot for close checks and process identity routing. +//! OS reads live here; tree traversal and PID-reuse rules live in process_tree. + +#[cfg(windows)] +#[path = "process_snapshot/windows.rs"] +mod windows; + +/// Native process identity at one snapshot instant. A zero creation time means +/// that the platform could not provide it; consumers must preserve that edge. +pub(crate) struct ProcessRow { + pub(crate) pid: u32, + pub(crate) parent: u32, + pub(crate) executable: String, + pub(crate) created: u64, +} + +pub(crate) fn snapshot() -> Result, String> { + #[cfg(windows)] + { + windows::snapshot() + } + #[cfg(not(windows))] + { + unix_snapshot() + } +} + +/// Parse the stable three-column `ps` output used by Unix snapshots. The +/// command column is the remainder so executable paths containing spaces are +/// not split into a fake fourth field. +#[allow(dead_code)] +fn parse_ps_line(line: &str) -> Option<(u32, u32, String)> { + fn field(input: &str) -> Option<(&str, &str)> { + let input = input.trim_start(); + let end = input.find(char::is_whitespace).unwrap_or(input.len()); + let value = &input[..end]; + (!value.is_empty()).then(|| (value, &input[end..])) + } + + let (pid, rest) = field(line)?; + let (parent_pid, executable) = field(rest)?; + let executable = executable.trim(); + if executable.is_empty() { + return None; + } + Some((pid.parse().ok()?, parent_pid.parse().ok()?, executable.to_owned())) +} + +/// Unix has no Toolhelp equivalent shared by Linux and macOS. `ps` is part of +/// both base systems and this path only runs for explicit process inspection +/// or a throttled identity check, never on the render loop. +#[cfg(not(windows))] +fn unix_snapshot() -> Result, String> { + let output = std::process::Command::new("ps") + .args(["-axo", "pid=,ppid=,comm="]) + .output() + .map_err(|error| format!("could not launch ps: {error}"))?; + if !output.status.success() { + return Err(format!( + "ps exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let mut processes = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((pid, parent_pid, executable)) = parse_ps_line(line) { + processes.push(ProcessRow { pid, parent: parent_pid, executable, created: 0 }); + } + } + if processes.is_empty() { + return Err("ps returned no process rows".to_owned()); + } + Ok(processes) +} + +#[cfg(test)] +mod tests { + use super::parse_ps_line; + + #[test] + fn unix_process_rows_keep_the_complete_command_column() { + assert_eq!( + parse_ps_line(" 42 7 /Applications/Nebula Preview/bin/zsh"), + Some((42, 7, "/Applications/Nebula Preview/bin/zsh".to_owned())) + ); + assert_eq!(parse_ps_line("header"), None); + } +} diff --git a/nebula_app/src/platform/process_snapshot/windows.rs b/nebula_app/src/platform/process_snapshot/windows.rs new file mode 100644 index 0000000..0a8a0d0 --- /dev/null +++ b/nebula_app/src/platform/process_snapshot/windows.rs @@ -0,0 +1,184 @@ +//! Read process identifiers and creation times together without opening protected +//! processes. Parent PID reuse is resolved by the shared process-tree rules. + +use super::ProcessRow; +use std::{mem, ptr}; + +use windows_sys::Wdk::System::SystemInformation::{ + NtQuerySystemInformation, SystemProcessInformation, +}; +use windows_sys::Win32::Foundation::{ + HANDLE, STATUS_BUFFER_TOO_SMALL, STATUS_INFO_LENGTH_MISMATCH, UNICODE_STRING, +}; + +// The SystemProcessInformation prefix through InheritedFromUniqueProcessId on +// Vista and later. Windows' SDK presents CreateTime and the parent as reserved +// fields; the native layout used by this information class keeps them here. +// Limit the adapter to the prefix, validate every variable-size record, and +// compare CreateTime with the independent Win32 API in a native regression. +#[repr(C)] +#[derive(Clone, Copy)] +struct ProcessHeader { + next_offset: u32, + threads: u32, + private_working_set: i64, + hard_faults: u32, + thread_high_watermark: u32, + cycle_time: u64, + created: i64, + user_time: i64, + kernel_time: i64, + name: UNICODE_STRING, + base_priority: i32, + pid: HANDLE, + parent: HANDLE, +} + +pub(super) fn snapshot() -> Result, String> { + const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024; + let mut storage = vec![0usize; 64 * 1024 / mem::size_of::()]; + loop { + let capacity = mem::size_of_val(storage.as_slice()); + let mut needed = 0; + let status = unsafe { + NtQuerySystemInformation( + SystemProcessInformation, + storage.as_mut_ptr().cast(), + capacity as u32, + &mut needed, + ) + }; + if matches!(status, STATUS_INFO_LENGTH_MISMATCH | STATUS_BUFFER_TOO_SMALL) { + let next = (needed as usize).saturating_add(64 * 1024).max(capacity * 2); + if next > MAX_SNAPSHOT_BYTES { + return Err("process snapshot exceeds the 16 MiB inspection limit".to_owned()); + } + storage.resize(next.div_ceil(mem::size_of::()), 0); + continue; + } + if status < 0 { + return Err(format!("NtQuerySystemInformation failed: NTSTATUS {status:#x}")); + } + if needed == 0 || needed as usize > capacity { + return Err("invalid process snapshot length".to_owned()); + } + // Native string pointers refer to this allocation, which remains live + // until the parser has copied each name into its owning ProcessRow. + let bytes = + unsafe { std::slice::from_raw_parts(storage.as_ptr().cast::(), needed as usize) }; + return parse_processes(bytes); + } +} + +fn parse_processes(bytes: &[u8]) -> Result, String> { + let mut rows = Vec::new(); + let mut offset = 0usize; + loop { + let remaining = bytes.get(offset..).ok_or("invalid process record offset")?; + if remaining.len() < mem::size_of::() { + return Err("truncated process record".to_owned()); + } + let header = unsafe { ptr::read_unaligned(remaining.as_ptr().cast::()) }; + let pid = u32::try_from(header.pid as usize).map_err(|_| "invalid process id")?; + let parent = u32::try_from(header.parent as usize).map_err(|_| "invalid parent id")?; + let created = u64::try_from(header.created).map_err(|_| "invalid process creation time")?; + let executable = read_name(bytes, header.name)?; + rows.push(ProcessRow { pid, parent, executable, created }); + let step = header.next_offset as usize; + if step == 0 { + return Ok(rows); + } + if step < mem::size_of::() || step > remaining.len() { + return Err("invalid next process record offset".to_owned()); + } + offset += step; + } +} + +fn read_name(bytes: &[u8], name: UNICODE_STRING) -> Result { + let length = name.Length as usize; + if length == 0 { + return Ok(String::new()); + } + let start = (name.Buffer as usize) + .checked_sub(bytes.as_ptr() as usize) + .ok_or("process name lies outside the snapshot")?; + if !length.is_multiple_of(2) || length > name.MaximumLength as usize { + return Err("invalid process name length".to_owned()); + } + let end = start.checked_add(length).ok_or("process name range overflow")?; + let encoded = bytes.get(start..end).ok_or("truncated process name")?; + // Reading pairs of bytes avoids imposing alignment on a kernel pointer. + Ok(char::decode_utf16( + encoded.chunks_exact(2).map(|pair| u16::from_ne_bytes([pair[0], pair[1]])), + ) + .map(|value| value.unwrap_or(char::REPLACEMENT_CHARACTER)) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_native_records_are_rejected() { + assert!(parse_processes(&[0u8; 8]).is_err()); + let mut data = vec![0u8; mem::size_of::()]; + let mut header: ProcessHeader = unsafe { mem::zeroed() }; + header.next_offset = 1; + unsafe { ptr::write_unaligned(data.as_mut_ptr().cast::(), header) }; + assert!(parse_processes(&data).is_err()); + header.next_offset = 0; + header.name.Length = 2; + header.name.MaximumLength = 2; + header.name.Buffer = ptr::null_mut(); + unsafe { ptr::write_unaligned(data.as_mut_ptr().cast::(), header) }; + assert!(parse_processes(&data).is_err()); + } + + #[test] + fn native_creation_time_matches_the_win32_process_clock() { + use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes}; + + let rows = snapshot().unwrap(); + let current = rows.iter().find(|row| row.pid == std::process::id()).unwrap(); + assert!(!current.executable.is_empty()); + let mut times = [unsafe { mem::zeroed() }; 4]; + let [created, exited, kernel, user] = &mut times; + assert_ne!( + unsafe { GetProcessTimes(GetCurrentProcess(), created, exited, kernel, user) }, + 0 + ); + let expected = u64::from(created.dwHighDateTime) << 32 | u64::from(created.dwLowDateTime); + assert_eq!(current.created, expected); + } + + #[test] + fn native_snapshot_preserves_a_live_childs_parent() { + use std::process::{Child, Command, Stdio}; + + struct OwnedChild(Child); + impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + // The shell waits for input from our pipe, so the snapshot cannot race + // its normal exit. The guard also reaps it if an assertion fails. + let child = OwnedChild( + Command::new("cmd.exe") + .args(["/d", "/q", "/c", "set /p _PEBREL_PARENT_TEST="]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let rows = snapshot().unwrap(); + let entry = rows.iter().find(|row| row.pid == child.0.id()).unwrap(); + assert_eq!(entry.parent, std::process::id()); + assert!(entry.executable.eq_ignore_ascii_case("cmd.exe")); + } +} diff --git a/nebula_app/src/process_tree.rs b/nebula_app/src/process_tree.rs index 7824066..f572292 100644 --- a/nebula_app/src/process_tree.rs +++ b/nebula_app/src/process_tree.rs @@ -7,6 +7,8 @@ //! needs no shell integration (unlike the OSC 133 approach), so it works with //! any shell out of the box. +use crate::platform::process_snapshot::{self, ProcessRow}; + /// Programs whose presence never blocks a close: shells themselves plus the /// console plumbing every ConPTY session drags along. `git.exe` is here /// because Nebula's own prompt integration spawns it on every prompt render @@ -126,121 +128,50 @@ fn is_stateless_process(executable: &str) -> bool { INTERACTIVE_SHELLS.contains(&stem) || login_shell || stem == "git" } -/// Parse the stable three-column `ps` output used by Unix snapshots. The -/// command column is the remainder so executable paths containing spaces are -/// not split into a fake fourth field. -#[allow(dead_code)] -fn parse_ps_line(line: &str) -> Option<(u32, u32, String)> { - fn field(input: &str) -> Option<(&str, &str)> { - let input = input.trim_start(); - let end = input.find(char::is_whitespace).unwrap_or(input.len()); - let value = &input[..end]; - (!value.is_empty()).then(|| (value, &input[end..])) - } - - let (pid, rest) = field(line)?; - let (parent_pid, executable) = field(rest)?; - let executable = executable.trim(); - if executable.is_empty() { - return None; - } - Some((pid.parse().ok()?, parent_pid.parse().ok()?, executable.to_owned())) +fn snapshot() -> Result, String> { + Ok(verified_parentage(process_snapshot::snapshot()?)) } -/// One Toolhelp pass over every process on the machine: `pid -> (parent, exe)`. -/// -/// Shared by the descendant walk and the ancestry check so a caller never pays -/// for two snapshots, and so both see the same instant. -#[cfg(windows)] -fn snapshot() -> Result, String> { - use std::collections::HashMap; - use std::mem; - - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, - TH32CS_SNAPPROCESS, - }; - - let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; - if handle == INVALID_HANDLE_VALUE { - return Err(format!( - "CreateToolhelp32Snapshot failed: {}", - std::io::Error::last_os_error() - )); - } - - let mut processes: HashMap = HashMap::new(); - let read_result = unsafe { - let mut entry: PROCESSENTRY32W = mem::zeroed(); - entry.dwSize = mem::size_of::() as u32; - if Process32FirstW(handle, &mut entry) == 0 { - Err(std::io::Error::last_os_error()) - } else { - loop { - let len = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(0); - let name = String::from_utf16_lossy(&entry.szExeFile[..len]); - processes.insert(entry.th32ProcessID, (entry.th32ParentProcessID, name)); - if Process32NextW(handle, &mut entry) == 0 { - break; - } - } - Ok(()) - } - }; - unsafe { CloseHandle(handle) }; - read_result.map_err(|error| format!("Process32FirstW failed: {error}"))?; - Ok(processes) -} - -/// Unix has no Toolhelp equivalent shared by Linux and macOS. `ps` is part of -/// both base systems and this path only runs for explicit process inspection -/// or a throttled identity check, never on the render loop. -#[cfg(not(windows))] -fn snapshot() -> Result, String> { - let output = std::process::Command::new("ps") - .args(["-axo", "pid=,ppid=,comm="]) - .output() - .map_err(|error| format!("could not launch ps: {error}"))?; - if !output.status.success() { - return Err(format!( - "ps exited with {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - - let mut processes = std::collections::HashMap::new(); - for line in String::from_utf8_lossy(&output.stdout).lines() { - if let Some((pid, parent_pid, executable)) = parse_ps_line(line) { - processes.insert(pid, (parent_pid, executable)); - } - } - if processes.is_empty() { - return Err("ps returned no process rows".to_owned()); - } - Ok(processes) +fn verified_parentage(rows: Vec) -> std::collections::HashMap { + // Windows keeps a creator's PID after it exits. If that number now belongs + // to a younger process, it cannot be this child's parent. Missing creation + // times preserve the edge so uncertainty cannot hide a real busy child. + let creation_times: std::collections::HashMap<_, _> = + rows.iter().map(|row| (row.pid, row.created)).collect(); + rows.into_iter() + .map(|row| { + let reused = row.created != 0 + && creation_times.get(&row.parent).is_some_and(|parent| *parent > row.created); + let parent = if reused { 0 } else { row.parent }; + (row.pid, (parent, row.executable)) + }) + .collect() } /// Snapshot the real local descendants owned by one terminal shell. The root /// is included at depth zero so clients can distinguish the PTY owner from the -/// commands below it. Toolhelp is intentionally sampled on demand; running it +/// commands below it. The OS is intentionally sampled on demand; running this /// on the 1 Hz UI state pump would scan the whole machine continuously. pub fn descendants(root_pid: u32) -> Result, String> { - use std::collections::{HashMap, HashSet, VecDeque}; - if root_pid == 0 { return Err("the pane does not own a local shell process".to_owned()); } - let processes = snapshot()?; + descendants_from_snapshot(root_pid, &snapshot()?) +} + +fn descendants_from_snapshot( + root_pid: u32, + processes: &std::collections::HashMap, +) -> Result, String> { + use std::collections::{HashMap, HashSet, VecDeque}; if !processes.contains_key(&root_pid) { return Err(format!("shell process {root_pid} is no longer present")); } let mut children: HashMap> = HashMap::new(); - for (&pid, &(parent, _)) in &processes { + for (&pid, &(parent, _)) in processes { if parent != pid { children.entry(parent).or_default().push(pid); } @@ -440,8 +371,9 @@ mod tests { use std::collections::HashMap; use super::{ - ProcessEntry, busy_descendant, display_name, is_interactive_shell_command, - is_stateless_process, parse_ps_line, resolve_agent_ancestor, resolve_within_tree, + ProcessEntry, ProcessRow, busy_descendant, descendants_from_snapshot, display_name, + is_interactive_shell_command, is_stateless_process, resolve_agent_ancestor, + resolve_within_tree, verified_parentage, }; #[test] @@ -473,13 +405,45 @@ mod tests { assert_eq!(busy_descendant(entries(&["login", "-bash", "vim"])), Some("vim".to_owned())); } + fn row(pid: u32, parent: u32, created: u64, executable: &str) -> ProcessRow { + ProcessRow { pid, parent, created, executable: executable.to_owned() } + } + #[test] - fn unix_process_rows_keep_the_complete_command_column() { + fn reused_parent_pid_cannot_attach_an_older_process_to_a_new_shell() { + let processes = verified_parentage(vec![ + row(100, 4, 300, "cmd.exe"), + row(200, 100, 100, "csrss.exe"), + row(300, 200, 200, "other-user-work.exe"), + ]); + let descendants = descendants_from_snapshot(100, &processes).unwrap(); + assert_eq!(descendants.len(), 1); + assert_eq!(busy_descendant(descendants), None); + assert_eq!(resolve_within_tree(&processes, 300, 100), Some(false)); + } + + #[test] + fn a_real_busy_child_still_requires_confirmation() { + let processes = verified_parentage(vec![ + row(100, 4, 100, "cmd.exe"), + row(200, 100, 100, "powershell.exe"), + row(300, 200, 101, "cargo.exe"), + ]); assert_eq!( - parse_ps_line(" 42 7 /Applications/Nebula Preview/bin/zsh"), - Some((42, 7, "/Applications/Nebula Preview/bin/zsh".to_owned())) + busy_descendant(descendants_from_snapshot(100, &processes).unwrap()), + Some("cargo.exe".to_owned()) + ); + assert_eq!(resolve_within_tree(&processes, 300, 100), Some(true)); + } + + #[test] + fn unknown_creation_time_does_not_hide_a_busy_child() { + let processes = + verified_parentage(vec![row(100, 4, 100, "cmd.exe"), row(200, 100, 0, "vim.exe")]); + assert_eq!( + busy_descendant(descendants_from_snapshot(100, &processes).unwrap()), + Some("vim.exe".to_owned()) ); - assert_eq!(parse_ps_line("header"), None); } fn table(rows: &[(u32, u32)]) -> HashMap { diff --git a/nebula_terminal/src/tty/windows/blocking.rs b/nebula_terminal/src/tty/windows/blocking.rs index 0f63cfe..644b926 100644 --- a/nebula_terminal/src/tty/windows/blocking.rs +++ b/nebula_terminal/src/tty/windows/blocking.rs @@ -83,6 +83,9 @@ impl UnblockedReader { continue; }, + // Windows reports normal anonymous-pipe EOF this way. + Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => return, + Poll::Ready(Err(e)) => { log::error!("error writing to pipe: {}", e); return; @@ -233,6 +236,8 @@ impl UnblockedWriter { continue; }, + Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => return, + Poll::Ready(Err(e)) => { log::error!("error writing to pipe: {}", e); return; diff --git a/nebula_terminal/src/tty/windows/child.rs b/nebula_terminal/src/tty/windows/child.rs index 9d1a49a..6582e2e 100644 --- a/nebula_terminal/src/tty/windows/child.rs +++ b/nebula_terminal/src/tty/windows/child.rs @@ -1,6 +1,8 @@ use std::ffi::c_void; use std::io::Error; +use std::mem::ManuallyDrop; use std::num::NonZeroU32; +use std::os::windows::io::{AsRawHandle, OwnedHandle}; use std::os::windows::process::ExitStatusExt; use std::process::ExitStatus; use std::ptr; @@ -10,9 +12,9 @@ use std::sync::{Arc, Mutex, mpsc}; use polling::os::iocp::{CompletionPacket, PollerIocpExt}; use polling::{Event, Poller}; -use windows_sys::Win32::Foundation::{BOOLEAN, FALSE, HANDLE}; +use windows_sys::Win32::Foundation::{BOOLEAN, FALSE, HANDLE, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::Threading::{ - GetExitCodeProcess, GetProcessId, INFINITE, RegisterWaitForSingleObject, UnregisterWait, + GetExitCodeProcess, GetProcessId, INFINITE, RegisterWaitForSingleObject, UnregisterWaitEx, WT_EXECUTEINWAITTHREAD, WT_EXECUTEONLYONCE, }; @@ -25,8 +27,8 @@ struct Interest { struct ChildExitSender { sender: mpsc::Sender, - interest: Arc>>, - child_handle: AtomicPtr, + interest: Mutex>, + child_handle: OwnedHandle, } /// WinAPI callback to run when child process exits. @@ -35,10 +37,12 @@ extern "system" fn child_exit_callback(ctx: *mut c_void, timed_out: BOOLEAN) { return; } - let event_tx: Box<_> = unsafe { Box::from_raw(ctx as *mut ChildExitSender) }; + // The watcher retains this allocation and process handle until unregister + // has waited for every callback. The callback only borrows the context. + let event_tx = unsafe { &*ctx.cast::() }; let mut exit_code = 0_u32; - let child_handle = event_tx.child_handle.load(Ordering::Relaxed) as HANDLE; + let child_handle = event_tx.child_handle.as_raw_handle() as HANDLE; let status = unsafe { GetExitCodeProcess(child_handle, &mut exit_code) }; let exit_status = if status == FALSE { None } else { Some(ExitStatus::from_raw(exit_code)) }; event_tx.sender.send(ChildEvent::Exited(exit_status)).ok(); @@ -52,29 +56,29 @@ extern "system" fn child_exit_callback(ctx: *mut c_void, timed_out: BOOLEAN) { pub struct ChildExitWatcher { wait_handle: AtomicPtr, event_rx: mpsc::Receiver, - interest: Arc>>, - child_handle: AtomicPtr, + context: ManuallyDrop>, pid: Option, } impl ChildExitWatcher { - pub fn new(child_handle: HANDLE) -> Result { + pub fn new(child_handle: OwnedHandle) -> Result { let (event_tx, event_rx) = mpsc::channel(); let mut wait_handle: HANDLE = ptr::null_mut(); - let interest = Arc::new(Mutex::new(None)); - let sender_ref = Box::new(ChildExitSender { + let raw_handle = child_handle.as_raw_handle() as HANDLE; + let pid = unsafe { NonZeroU32::new(GetProcessId(raw_handle)) }; + let context = Arc::new(ChildExitSender { sender: event_tx, - interest: interest.clone(), - child_handle: AtomicPtr::from(child_handle), + interest: Mutex::new(None), + child_handle, }); let success = unsafe { RegisterWaitForSingleObject( &mut wait_handle, - child_handle, + raw_handle, Some(child_exit_callback), - Box::into_raw(sender_ref).cast(), + Arc::as_ptr(&context).cast_mut().cast(), INFINITE, WT_EXECUTEINWAITTHREAD | WT_EXECUTEONLYONCE, ) @@ -83,12 +87,10 @@ impl ChildExitWatcher { if success == 0 { Err(Error::last_os_error()) } else { - let pid = unsafe { NonZeroU32::new(GetProcessId(child_handle)) }; Ok(ChildExitWatcher { event_rx, - interest, pid, - child_handle: AtomicPtr::from(child_handle), + context: ManuallyDrop::new(context), wait_handle: AtomicPtr::from(wait_handle), }) } @@ -99,11 +101,11 @@ impl ChildExitWatcher { } pub fn register(&self, poller: &Arc, event: Event) { - *self.interest.lock().unwrap() = Some(Interest { poller: poller.clone(), event }); + *self.context.interest.lock().unwrap() = Some(Interest { poller: poller.clone(), event }); } pub fn deregister(&self) { - *self.interest.lock().unwrap() = None; + *self.context.interest.lock().unwrap() = None; } /// Retrieve the process handle of the underlying child process. @@ -115,7 +117,7 @@ impl ChildExitWatcher { /// If you terminate the process using this handle, the terminal will get a /// timeout error, and the child watcher will emit an `Exited` event. pub fn raw_handle(&self) -> HANDLE { - self.child_handle.load(Ordering::Relaxed) as HANDLE + self.context.child_handle.as_raw_handle() as HANDLE } /// Retrieve the Process ID associated to the underlying child process. @@ -126,30 +128,57 @@ impl ChildExitWatcher { impl Drop for ChildExitWatcher { fn drop(&mut self) { - unsafe { - UnregisterWait(self.wait_handle.load(Ordering::Relaxed) as HANDLE); + // This runs on the PTY owner, never inside child_exit_callback. Waiting + // here makes both callback completion and cancellation safe to reclaim. + let success = unsafe { + UnregisterWaitEx(self.wait_handle.load(Ordering::Relaxed), INVALID_HANDLE_VALUE) + }; + if success != 0 { + unsafe { ManuallyDrop::drop(&mut self.context) }; + } else { + // An OS failure leaves callback liveness unknown. Retain its context + // rather than freeing memory that native code might still access. + log::error!( + "cannot unregister child wait; retaining callback context: {}", + Error::last_os_error() + ); } } } #[cfg(test)] mod tests { - use std::os::windows::io::AsRawHandle; - use std::process::Command; + use std::os::windows::io::AsHandle; + use std::process::{Child, Command, Stdio}; use std::sync::Arc; + use std::thread; use std::time::Duration; use super::super::PTY_CHILD_EVENT_TOKEN; use super::*; + fn waiting_child() -> Child { + Command::new("cmd.exe") + .args(["/d", "/q", "/k"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap() + } + + fn watch(child: &Child) -> ChildExitWatcher { + ChildExitWatcher::new(child.as_handle().try_clone_to_owned().unwrap()).unwrap() + } + #[test] pub fn event_is_emitted_when_child_exits() { - const WAIT_TIMEOUT: Duration = Duration::from_millis(200); + const WAIT_TIMEOUT: Duration = Duration::from_secs(5); let poller = Arc::new(Poller::new().unwrap()); - let mut child = Command::new("cmd.exe").spawn().unwrap(); - let child_exit_watcher = ChildExitWatcher::new(child.as_raw_handle() as HANDLE).unwrap(); + let mut child = waiting_child(); + let child_exit_watcher = watch(&child); child_exit_watcher.register(&poller, Event::readable(PTY_CHILD_EVENT_TOKEN)); child.kill().unwrap(); @@ -164,5 +193,50 @@ mod tests { child_exit_watcher.event_rx().try_recv(), Ok(ChildEvent::Exited(Some(expected_status))) ); + child.wait().unwrap(); + } + + #[test] + fn cancellation_releases_context_and_poller_before_child_exit() { + let mut child = waiting_child(); + let watcher = watch(&child); + let context = Arc::downgrade(&watcher.context); + let poller = Arc::new(Poller::new().unwrap()); + let poller_weak = Arc::downgrade(&poller); + watcher.register(&poller, Event::readable(PTY_CHILD_EVENT_TOKEN)); + drop(poller); + drop(watcher); + + let context_released = context.upgrade().is_none(); + let poller_released = poller_weak.upgrade().is_none(); + let still_running = child.try_wait().unwrap().is_none(); + child.kill().unwrap(); + child.wait().unwrap(); + assert!(context_released && poller_released); + assert!(still_running, "dropping a watcher must not terminate its borrowed child"); + } + + #[test] + fn drop_waits_for_an_in_flight_callback() { + let mut child = waiting_child(); + let watcher = watch(&child); + let context = Arc::clone(&watcher.context); + // The callback publishes the exit event before acquiring this lock. + let interest_guard = context.interest.lock().unwrap(); + child.kill().unwrap(); + watcher.event_rx().recv_timeout(Duration::from_secs(5)).unwrap(); + let (finished, result) = mpsc::channel(); + let dropper = thread::spawn(move || { + drop(watcher); + finished.send(()).unwrap(); + }); + let returned_early = result.recv_timeout(Duration::from_millis(100)).is_ok(); + drop(interest_guard); + if !returned_early { + result.recv_timeout(Duration::from_secs(5)).unwrap(); + } + dropper.join().unwrap(); + child.wait().unwrap(); + assert!(!returned_early, "callback context cannot be freed while the callback runs"); } } diff --git a/nebula_terminal/src/tty/windows/conpty.rs b/nebula_terminal/src/tty/windows/conpty.rs index 507c39f..90b350e 100644 --- a/nebula_terminal/src/tty/windows/conpty.rs +++ b/nebula_terminal/src/tty/windows/conpty.rs @@ -4,11 +4,13 @@ use std::collections::HashMap; use std::ffi::{OsStr, OsString}; use std::io::{Error, Result}; use std::os::windows::ffi::OsStrExt; -use std::os::windows::io::IntoRawHandle; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::path::{Path, PathBuf}; use std::{mem, ptr}; -use windows_sys::Win32::Foundation::{HANDLE, S_OK}; +use windows_sys::Win32::Foundation::{ + ERROR_INSUFFICIENT_BUFFER, FreeLibrary, HANDLE, HMODULE, S_OK, +}; use windows_sys::Win32::Globalization::{ CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN, CompareStringOrdinal, }; @@ -20,9 +22,10 @@ use windows_sys::core::{HRESULT, PWSTR}; use windows_sys::s; use windows_sys::Win32::System::Threading::{ - CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, - InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, PROCESS_INFORMATION, - STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, UpdateProcThreadAttribute, + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, + STARTUPINFOW, UpdateProcThreadAttribute, }; use crate::event::{OnResize, WindowSize}; @@ -55,6 +58,17 @@ struct ConptyApi { /// (bundled OpenConsole) rather than the in-box kernel32 ConPTY — /// gates the DA1 handshake priming in `new`. sideloaded: bool, + // Keep the loaded code alive through ClosePseudoConsole, including when + // several PTYs share the same DLL. Each LoadLibrary owns one reference. + _library: Option, +} + +struct ConptyLibrary(HMODULE); + +impl Drop for ConptyLibrary { + fn drop(&mut self) { + unsafe { FreeLibrary(self.0) }; + } } impl ConptyApi { @@ -79,6 +93,7 @@ impl ConptyApi { resize: ResizePseudoConsole, close: ClosePseudoConsole, sideloaded: false, + _library: None, } }, } @@ -106,6 +121,7 @@ impl ConptyApi { if hmodule.is_null() { return None; } + let library = ConptyLibrary(hmodule); let create_fn = GetProcAddress(hmodule, s!("CreatePseudoConsole"))?; let resize_fn = GetProcAddress(hmodule, s!("ResizePseudoConsole"))?; let close_fn = GetProcAddress(hmodule, s!("ClosePseudoConsole"))?; @@ -115,6 +131,7 @@ impl ConptyApi { resize: mem::transmute::(resize_fn), close: mem::transmute::(close_fn), sideloaded: true, + _library: Some(library), }) } } @@ -187,8 +204,8 @@ pub fn new(config: &Options, window_size: WindowSize) -> Result { // flag instead of dying — a host created flagless never issues DECSET // 9001, the terminal never sets WIN32_INPUT_MODE, and the encoder stays // on the legacy VT path, so the degradation is self-gating end to end. - let conin_handle = conin_pty_handle.into_raw_handle() as HANDLE; - let conout_handle = conout_pty_handle.into_raw_handle() as HANDLE; + let conin_handle = conin_pty_handle.as_raw_handle() as HANDLE; + let conout_handle = conout_pty_handle.as_raw_handle() as HANDLE; let mut result = unsafe { (api.create)( window_size.into(), @@ -216,70 +233,47 @@ pub fn new(config: &Options, window_size: WindowSize) -> Result { } crate::pty_trace("CreatePseudoConsole done"); + // ConPTY duplicates these handles; it does not take ownership of ours. + // Retaining our output writer prevents EOF even after the host exits, + // stranding both the reader/drain threads and their bounded pipe buffer. + drop(conin_pty_handle); + drop(conout_pty_handle); + if result != S_OK { return Err(Error::other(format!("CreatePseudoConsole failed: HRESULT {result:#x}"))); } - let mut success; + let mut conout = UnblockedReader::new(conout, PIPE_CAPACITY); + // Declared after conout so every error closes the host while its reader + // still exists. Spawn failures need the same drain-before-close order as Pty. + let conpty = Conpty { handle: pty_handle, api }; + let child_watcher = match spawn_shell(config, &conpty) { + Ok(watcher) => watcher, + Err(error) => { + conout.drain_detached(); + return Err(error); + }, + }; + let conin = UnblockedWriter::new(conin, PIPE_CAPACITY); - // Prepare child process startup info. - - let mut size: usize = 0; + Ok(Pty::new(conpty, conout, conin, child_watcher)) +} +fn spawn_shell(config: &Options, conpty: &Conpty) -> Result { + let mut attributes = ProcThreadAttributes::new()?; let mut startup_info_ex: STARTUPINFOEXW = unsafe { mem::zeroed() }; - - startup_info_ex.StartupInfo.lpTitle = std::ptr::null_mut() as PWSTR; - startup_info_ex.StartupInfo.cb = mem::size_of::() as u32; - - // Setting this flag but leaving all the handles as default (null) ensures the - // PTY process does not inherit any handles from this Nebula process. - startup_info_ex.StartupInfo.dwFlags |= STARTF_USESTDHANDLES; - - // Create the appropriately sized thread attribute list. - unsafe { - let failure = - InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut size as *mut usize) > 0; - - // This call was expected to return false. - if failure { - return Err(Error::last_os_error()); - } - } - - let mut attr_list: Box<[u8]> = vec![0; size].into_boxed_slice(); - - // Set startup info's attribute list & initialize it - // - // Lint failure is spurious; it's because winapi's definition of PROC_THREAD_ATTRIBUTE_LIST - // implies it is one pointer in size (32 or 64 bits) but really this is just a dummy value. - // Casting a *mut u8 (pointer to 8 bit type) might therefore not be aligned correctly in - // the compiler's eyes. - #[allow(clippy::cast_ptr_alignment)] - { - startup_info_ex.lpAttributeList = attr_list.as_mut_ptr() as _; - } - - unsafe { - success = InitializeProcThreadAttributeList( - startup_info_ex.lpAttributeList, - 1, - 0, - &mut size as *mut usize, - ) > 0; - - if !success { - return Err(Error::last_os_error()); - } - } + // Null standard handles and disabled inheritance keep parent handles private. + startup_info_ex.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup_info_ex.lpAttributeList = attributes.as_mut_ptr(); // Set thread attribute list's Pseudo Console to the specified ConPTY. unsafe { - success = UpdateProcThreadAttribute( + let success = UpdateProcThreadAttribute( startup_info_ex.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, - pty_handle as *mut std::ffi::c_void, + conpty.handle as *mut std::ffi::c_void, mem::size_of::(), ptr::null_mut(), ptr::null_mut(), @@ -291,7 +285,7 @@ pub fn new(config: &Options, window_size: WindowSize) -> Result { } // Prepare child process creation arguments. - let cmdline = win32_string(&cmdline(config)); + let mut cmdline = win32_string(&cmdline(config)); let cwd = config.working_directory.as_ref().map(win32_string); let mut creation_flags = EXTENDED_STARTUPINFO_PRESENT; let custom_env_block = convert_custom_env(&config.env, config.env_is_complete); @@ -306,9 +300,9 @@ pub fn new(config: &Options, window_size: WindowSize) -> Result { let mut proc_info: PROCESS_INFORMATION = unsafe { mem::zeroed() }; crate::pty_trace("CreateProcessW (shell attach) begin"); unsafe { - success = CreateProcessW( + let success = CreateProcessW( ptr::null(), - cmdline.as_ptr() as PWSTR, + cmdline.as_mut_ptr() as PWSTR, ptr::null_mut(), ptr::null_mut(), false as i32, @@ -325,13 +319,47 @@ pub fn new(config: &Options, window_size: WindowSize) -> Result { } crate::pty_trace("CreateProcessW (shell attach) done"); - let conin = UnblockedWriter::new(conin, PIPE_CAPACITY); - let conout = UnblockedReader::new(conout, PIPE_CAPACITY); + // CreateProcess returns two caller-owned handles, even though the primary + // thread needs no further interaction. The watcher owns the process handle. + let process = unsafe { OwnedHandle::from_raw_handle(proc_info.hProcess) }; + let thread = unsafe { OwnedHandle::from_raw_handle(proc_info.hThread) }; + drop(thread); + ChildExitWatcher::new(process) +} - let child_watcher = ChildExitWatcher::new(proc_info.hProcess)?; - let conpty = Conpty { handle: pty_handle as HPCON, api }; +struct ProcThreadAttributes { + // The opaque native list requires pointer alignment, not byte alignment. + storage: Box<[usize]>, +} - Ok(Pty::new(conpty, conout, conin, child_watcher)) +impl ProcThreadAttributes { + fn new() -> Result { + let mut size = 0; + let success = + unsafe { InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut size) }; + let error = Error::last_os_error(); + if success != 0 || error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { + return Err(error); + } + let mut storage = vec![0_usize; size.div_ceil(mem::size_of::())].into_boxed_slice(); + if unsafe { + InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) + } == 0 + { + return Err(Error::last_os_error()); + } + Ok(Self { storage }) + } + + fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.storage.as_mut_ptr().cast() + } +} + +impl Drop for ProcThreadAttributes { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.as_mut_ptr()) }; + } } // Windows environment variables are case-insensitive, and the caller is responsible for diff --git a/nebula_terminal/tests/windows_pty_lifecycle.rs b/nebula_terminal/tests/windows_pty_lifecycle.rs new file mode 100644 index 0000000..8af086c --- /dev/null +++ b/nebula_terminal/tests/windows_pty_lifecycle.rs @@ -0,0 +1,148 @@ +//! Run in its own test process: handle counts must not include other tests. +#![cfg(windows)] + +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; +use std::{env, fs, process::Command}; + +use nebula_terminal::event::WindowSize; +use nebula_terminal::tty::{self, Options, Shell}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessHandleCount}; + +const SIZE: WindowSize = WindowSize { num_lines: 24, num_cols: 80, cell_width: 8, cell_height: 16 }; + +fn handle_count() -> u32 { + let mut count = 0; + assert_ne!(unsafe { GetProcessHandleCount(GetCurrentProcess(), &mut count) }, 0); + count +} + +fn options(program: &str, args: &[&str]) -> Options { + Options { + shell: Some(Shell::new(program.into(), args.iter().map(|arg| (*arg).into()).collect())), + ..Options::default() + } +} + +fn close_pty(options: &Options) { + let pty = tty::new(options, SIZE, 0).expect("create native ConPTY"); + // Let the host and shell start, including the busy shell used below. Do not + // consume its output: closing must drain a backed-up pipe by itself. + thread::sleep(Duration::from_millis(300)); + drop(pty); +} + +fn assert_handles_return(baseline: u32, phase: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let current = handle_count(); + // Allow two process-wide lazy wait-pool handles, not a per-PTY allowance. + if current <= baseline + 2 { + eprintln!("{phase}: {current} handles (warm baseline {baseline})"); + return; + } + assert!( + Instant::now() < deadline, + "{phase}: native handles leaked: {baseline} -> {current}" + ); + thread::sleep(Duration::from_millis(25)); + } +} + +#[test] +fn bundled_conpty_resources_return_after_close_and_failed_spawn() { + if env::var_os("PEBREL_PTY_LIFECYCLE_CHILD").is_none() { + run_with_bundled_host(); + return; + } + // A drop deadlock must fail the test instead of hanging cargo indefinitely. + let (finished, result) = mpsc::channel(); + thread::spawn(move || { + let outcome = std::panic::catch_unwind(|| { + let idle = options("cmd.exe", &["/d", "/q", "/k"]); + for _ in 0..3 { + close_pty(&idle); + } + thread::sleep(Duration::from_millis(500)); + let baseline = handle_count(); + + for _ in 0..8 { + close_pty(&idle); + } + assert_handles_return(baseline, "idle tab close"); + + let busy = options( + "cmd.exe", + &[ + "/d", + "/q", + "/c", + "for /L %i in (1,1,2147483647) do @echo 0123456789012345678901234567890123456789", + ], + ); + for _ in 0..8 { + close_pty(&busy); + } + assert_handles_return(baseline, "busy tab close"); + + let missing = options("__pebrel_missing_shell_memory_regression__.exe", &[]); + for _ in 0..8 { + assert!(tty::new(&missing, SIZE, 0).is_err()); + } + assert_handles_return(baseline, "failed shell spawn"); + }); + finished.send(outcome.is_ok()).ok(); + }); + assert_eq!( + result.recv_timeout(Duration::from_secs(45)), + Ok(true), + "ConPTY resource regression failed or teardown did not complete within 45 seconds" + ); +} + +fn run_with_bundled_host() { + // Match the product's shipped host. A native-only probe on Windows 11 + // build 22631 retained a conhost process handle per create/close without + // a shell or Nebula code. Do not relax the budget to absorb that OS defect. + let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_nanos(); + let directory = + env::temp_dir().join(format!("pebrel-pty-lifecycle-{}-{stamp}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let executable = directory.join("pty-lifecycle.exe"); + let fixtures = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../assets/windows/conhost"); + fs::copy(env::current_exe().unwrap(), &executable).unwrap(); + for name in ["conpty.dll", "OpenConsole.exe"] { + fs::copy(fixtures.join(name), directory.join(name)).unwrap(); + } + fs::write(directory.join("pebrel_settings.txt"), "openconsole=on\n").unwrap(); + let status = Command::new(executable) + .args([ + "--exact", + "bundled_conpty_resources_return_after_close_and_failed_spawn", + "--nocapture", + ]) + .env("PEBREL_PTY_LIFECYCLE_CHILD", "1") + .env("PEBREL_CONFIG_DIR", &directory) + .env("NEBULA_CONFIG_DIR", &directory) + .status(); + // The host can finish asynchronously after ClosePseudoConsole. Windows + // keeps its image mapped briefly after the test process has exited. + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match fs::remove_dir_all(&directory) { + Ok(()) => break, + Err(error) + if Instant::now() < deadline + && error.kind() == std::io::ErrorKind::PermissionDenied => + { + thread::sleep(Duration::from_millis(50)); + }, + Err(error) => { + panic!("cannot release the test host files at {}: {error}", directory.display()) + }, + } + } + assert!(status.unwrap().success(), "the bundled-host lifecycle regression failed"); +} diff --git a/scripts/tests/test_windows_memory_stress.py b/scripts/tests/test_windows_memory_stress.py new file mode 100644 index 0000000..98162dc --- /dev/null +++ b/scripts/tests/test_windows_memory_stress.py @@ -0,0 +1,128 @@ +import unittest +from unittest.mock import Mock +import subprocess + +from scripts.windows_memory_stress import MIB, close_owned_process, summarize + + +def sample(phase, commit, resident=40): + return {"phase": phase, "private_commit_bytes": commit * MIB, + "private_working_set_bytes": resident * MIB, "working_set_bytes": (resident + 50) * MIB} + + +def complete_samples(): + return [sample("idle", 100), sample("settled-0", 130), sample("settled-1", 131), + sample("tabs-open-0", 170), sample("tabs-closed-0", 132), + sample("tabs-open-1", 171), sample("tabs-closed-1", 133)] + + +class WindowsMemoryStressTests(unittest.TestCase): + def checkpoints(self): + return [{"phase": row["phase"], "handles": 700, "threads": 120} + for row in complete_samples() if not row["phase"].startswith("tabs-open")] + + def test_warm_cache_and_reclaimed_tabs_fit_both_budgets(self): + result = summarize(complete_samples(), 2, 180, 4) + self.assertTrue(result["budget_enforced"]) + self.assertEqual(result["violations"], []) + self.assertEqual(result["tab_growth_after_warmup_bytes"], MIB) + + def test_falling_working_set_does_not_hide_private_commit_growth(self): + samples = complete_samples() + samples[-1] = sample("tabs-closed-1", 155, resident=10) + result = summarize(samples, 2, 180, 4) + self.assertEqual(len(result["violations"]), 1) + self.assertIn("retained", result["violations"][0]) + + def test_transient_peak_is_checked_even_when_all_tabs_release(self): + samples = complete_samples() + [sample("tabs-open-1", 300)] + result = summarize(samples, 2, 180, 4) + self.assertEqual(result["sampled_peak_private_commit_bytes"], 300 * MIB) + self.assertEqual(len(result["violations"]), 1) + self.assertIn("peak", result["violations"][0]) + + def test_late_release_does_not_hide_earlier_retained_growth(self): + samples = complete_samples() + samples[-1] = sample("tabs-closed-1", 155) + samples += [sample("settled-2", 130), sample("tabs-closed-2", 132)] + result = summarize(samples, 3, 180, 4) + self.assertGreater(result["tab_growth_after_warmup_bytes"], 4 * MIB) + self.assertEqual(len(result["violations"]), 1) + + def test_incomplete_run_cannot_report_a_budget_pass(self): + with self.assertRaisesRegex(ValueError, "tabs-closed-1"): + summarize(complete_samples()[:-1], 2, 180, 4) + with self.assertRaises(ValueError): + summarize([], 2, 180, 4) + + def test_observation_without_limits_does_not_claim_a_budget(self): + result = summarize(complete_samples(), 2, None, None) + self.assertFalse(result["budget_enforced"]) + self.assertIsNone(result["peak_budget_mib"]) + self.assertIsNone(result["growth_budget_mib"]) + + def test_resource_leak_fails_even_when_memory_is_below_budget(self): + checkpoints = self.checkpoints() + checkpoints[-1].update(handles=736, threads=128) + result = summarize(complete_samples(), 2, 180, 4, checkpoints=checkpoints, + handle_growth_limit=8, thread_growth_limit=4) + self.assertEqual(result["resource_growth_after_warmup"], {"handles": 36, "threads": 8}) + self.assertEqual(len(result["violations"]), 2) + + def test_missing_resource_checkpoints_cannot_pass_resource_budgets(self): + with self.assertRaisesRegex(ValueError, "checkpoints"): + summarize(complete_samples(), 2, 180, 4, handle_growth_limit=8) + with self.assertRaisesRegex(ValueError, "tabs-closed-1"): + summarize(complete_samples(), 2, 180, 4, checkpoints=self.checkpoints()[:-1], + thread_growth_limit=4) + + def test_invalid_budgets_cannot_disable_the_gate(self): + for limit in (float("nan"), float("inf"), -1): + with self.subTest(limit=limit), self.assertRaises(ValueError): + summarize(complete_samples(), 2, limit, 4) + + def test_normal_window_close_does_not_force_terminate(self): + process = Mock(pid=123) + process.poll.return_value = None + process.wait.side_effect = lambda **_: setattr(process.poll, "return_value", 0) + native = Mock() + result = close_owned_process(process, native) + self.assertTrue(result["normal_exit"]) + self.assertFalse(result["forced_termination"]) + process.terminate.assert_not_called() + + def test_sampler_initialization_failure_still_reclaims_its_process(self): + process = Mock(pid=123) + process.poll.return_value = None + process.wait.side_effect = lambda **_: setattr(process.poll, "return_value", 1) + result = close_owned_process(process, None) + self.assertFalse(result["normal_exit"]) + self.assertTrue(result["forced_termination"]) + process.terminate.assert_called_once() + + def test_a_crash_cannot_count_as_normal_cleanup(self): + process = Mock(pid=123) + process.poll.return_value = -1073741819 + result = close_owned_process(process, Mock()) + self.assertFalse(result["normal_exit"]) + self.assertFalse(result["forced_termination"]) + process.terminate.assert_not_called() + + def test_hung_close_is_a_failure_even_if_forced_cleanup_succeeds(self): + process = Mock(pid=123) + process.poll.return_value = None + + def wait(**_): + if not process.terminate.called: + raise subprocess.TimeoutExpired("owned test process", 15) + process.poll.return_value = 1 + + process.wait.side_effect = wait + result = close_owned_process(process, Mock()) + self.assertFalse(result["normal_exit"]) + self.assertTrue(result["forced_termination"]) + self.assertTrue(result["errors"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/windows-memory-stress.md b/scripts/windows-memory-stress.md new file mode 100644 index 0000000..ba2fc7e --- /dev/null +++ b/scripts/windows-memory-stress.md @@ -0,0 +1,111 @@ +# Windows 产品内存压力测试 + +`windows_memory_stress.py` 启动真实 GPUI 产品,通过真实 PTY 写入持续输出, +再反复创建和关闭标签。它检查输出结束后内存、线程和句柄能否保持稳定。 +测试使用独立配置、工作目录和临时目录;每次输出目录必须是新目录。 + +需要 Windows 原生 Python 3.11+,不需要第三方 Python 包。先构建真实产品: + +```powershell +cargo build --release --locked -p nebula --bin pebrel --features gpui-shell +``` + +确认 `pebrel.exe` 同目录存在产品附带的 `conpty.dll` 和 `OpenConsole.exe`。 +后面的命令在仓库根目录运行;构建及测试输出都应放在当前环境允许的路径内。 + +## 短回归与长测 + +以下预算用于本机 Windows 11、144 DPI、1280 × 900 外窗、默认 10000 行历史、 +Maple Mono Normal NF CN 16 px 的回归条件。首次校准可不传预算,仅观察数据; +未配置预算的报告会明确标为观察运行。预算一旦选定,应在前后对照中保持一致, +超标时先定位分配和资源生命周期。 + +```powershell +python scripts/windows_memory_stress.py ` + --app target/release/pebrel.exe ` + --output tmp/memory-short-01 ` + --cycles 8 --tabs 4 --payload-mib 11 ` + --max-private-commit-mib 256 --max-growth-mib 16 ` + --max-handle-growth 8 --max-thread-growth 4 + +python scripts/windows_memory_stress.py ` + --app target/release/pebrel.exe ` + --output tmp/memory-long-01 ` + --cycles 32 --tabs 4 --payload-mib 64 ` + --max-private-commit-mib 256 --max-growth-mib 16 ` + --max-handle-growth 8 --max-thread-growth 4 +``` + +长测包含约 2 GiB 混合 ASCII、CJK、Nerd Font 和 ANSI 彩色输出,以及 +128 次标签关闭。每一轮输出后还写入 240 次交替屏幕重绘。 +语料按完整块向上取整,报告记录实际字节数。它不是固定帧率动画或显示 FPS 测试。 + +程序会固定窗口大小,并从终端内读取真实行列数。比较两个程序时必须检查报告中的 +行列数相同,仅设置相同像素尺寸并不足够。测试期间应保留正常显示的窗口,避免 +输入、切换配置或调整窗口尺寸。将编译和其他高负载工作与正式测量分开。 + +## 测量口径与通过条件 + +| 数据 | 含义 | +| --- | --- | +| `private_commit_bytes` | Pebrel 自己的私有提交量,是本脚本内存预算的依据 | +| `private_working_set_bytes` | Pebrel 当前驻留在 RAM 中的私有页面 | +| `working_set_bytes` | 总工作集,包含驻留的共享页面,不能再与私有工作集相加 | +| `cpu_seconds` | Pebrel 累积的用户态和内核态 CPU 时间 | +| `handles` / `threads` | Pebrel 的句柄和线程数;线程在阶段检查点读取 | + +每 100 ms 采样一次。完成输出并等待稳定、或关闭新增标签并恢复单标签后, +用该阶段最后五个内存样本的中位数比较。第一轮负责预热,后续每一轮都与它比较, +取最大增长量;不能让最后一次回收掩盖前面某一轮的残留增长。 + +脚本同时检查全部采样中的私有提交峰值、预热后的提交增长、句柄增长和线程增长。 +输出必须在实际终端里出现完成标记;所有阶段必须齐全。测试窗口必须恢复到一个 +标签并保持原几何状态,进程最终正常退出。任一预算超标或阶段失败均返回非零退出码。 +关闭确认失败时保留现场进程树,不自动确认关闭来绕过产品的工作保护。 + +清理先对本次创建的窗口发送关闭请求。若失败,仅终止脚本保有句柄的那个测试进程, +并把该次运行记为失败。不会按进程名称批量结束实例。脚本没有设置进程内存上限、 +调用工作集裁剪或生成完整内存转储。 + +## 证据文件 + +- `instance.json`:PID、进程创建时间、EXE SHA256、脚本 SHA256、Windows/Python + 版本和逻辑处理器数;可通过 `--source-manifest <文件>` 同时记录源码清单的路径和 SHA256。 +- `report.json`:实际窗口/网格、每轮输出量、提交峰值、残留增长及预算结果。 +- `samples.json` / `checkpoints.json`:原始计数,即使测试中断也保存。 + 检查点还记录系统总物理内存、可用物理内存和内存负载,帮助识别页面驻留差异。 +- `failure.json` / `failure-context.json`:失败原因、所处阶段及可读到的测试实例进程树。 +- `cleanup.json`:是否正常退出,是否发生强制清理。 +- `work/*-<轮次>.json`:终端内写入程序记录的实际输出量、行列数和写入耗时。 + +`drain_seconds` 包含 PTY 背压等待,不代表输入延迟或屏幕真正呈现的帧率。 +100 ms 采样可能错过更短的峰值。计数不含 shell、输出程序或 WSL 虚拟机; +如需比较整个终端产品的进程组,应另行使用一致的进程归属和计数口径。 +工作集还受页面驻留、其他应用和驱动影响,不能凭它下降就宣称减少了分配。 +上述测试预算也不是应用在任意硬件、任意标签数下的运行时硬上限。 + +## 相关回归与实现依据 + +```powershell +python -m unittest scripts.tests.test_windows_memory_stress +cargo test --locked -p nebula_terminal --test windows_pty_lifecycle -- --nocapture +cargo test --locked -p nebula --bin pebrel --features gpui-shell process_tree:: -- --test-threads=1 +``` + +本地终端输出沿用现有有界 PTY 缓冲和有限历史网格。此次回收修复关闭了创建 +ConPTY 时遗留的调用方管道副本,让 EOF 能结束读取/排空线程;shell、主线程、 +属性表和旁加载 DLL 使用各自的所有权释放。等待回调完成或取消后才释放其上下文, +避免把内存回收变成回调访问已释放数据。 + +字体修复由精确固定的 GPUI 分支提供:DirectWrite 持有字体数据的 COM 所有者, +静态字体直接使用程序映像中的字节,导入字体保留原 Vec,直到最后一个原生消费者 +释放。原生内容、指针、引用寿命测试与真实窗口字体比对属于独立验收项。 + +Windows 会保留已经退出的父进程 PID;号码复用后,仅按 PID 连边会把无关进程 +误接到新标签上。平台适配层现在一次读取进程创建时间,公共进程树规则断开 +“父进程比子进程还年轻”的关系。缺失创建时间时保留关系,真实忙碌子进程仍受保护。 +这使用已存在的 Windows 绑定,无额外后台轮询或持久化格式。 + +生命周期回归使用产品附带的 OpenConsole。Windows 11 build 22631 的系统内置 +ConPTY 在独立最小探针中仍出现每次创建/关闭保留一个宿主句柄的问题;本次修改 +没有修复该系统实现,也没有放宽产品回归预算去吸收它。 diff --git a/scripts/windows_memory_stress.py b/scripts/windows_memory_stress.py new file mode 100644 index 0000000..64507a9 --- /dev/null +++ b/scripts/windows_memory_stress.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python3 +"""Exercise an isolated Windows product and sample its own memory counters.""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import ctypes +from ctypes import wintypes +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import statistics +import subprocess +import sys +import threading +import time + +if __package__ in {None, ""}: + sys.path.insert(0, os.fspath(Path(__file__).resolve().parent.parent)) + +from scripts.conformance.harness import RuntimeClient + + +MIB = 1024 * 1024 + + +def summarize(samples: list[dict], cycles: int, peak_limit: float | None, + growth_limit: float | None, *, checkpoints: list[dict] | None = None, + handle_growth_limit: int | None = None, thread_growth_limit: int | None = None) -> dict: + if cycles < 2 or not samples: + raise ValueError("at least two complete cycles and memory samples are required") + for limit in (peak_limit, growth_limit, handle_growth_limit, thread_growth_limit): + if limit is not None and (not math.isfinite(limit) or limit < 0): + raise ValueError("budgets must be finite and nonnegative") + phases = {} + for phase in ["idle"] + [f"settled-{n}" for n in range(cycles)] + [ + f"tabs-closed-{n}" for n in range(cycles) + ]: + rows = [row for row in samples if row["phase"] == phase] + if not rows: + raise ValueError(f"missing measurement phase: {phase}") + phases[phase] = { + key: statistics.median(row[key] for row in rows[-5:]) + for key in ("private_commit_bytes", "private_working_set_bytes", "working_set_bytes") + } + peak = max(row["private_commit_bytes"] for row in samples) + # Compare equal workloads after warmup. A falling working set cannot mask + # growth in committed private allocations. + output_growth = max(phases[f"settled-{n}"]["private_commit_bytes"] for n in range(1, cycles)) - phases["settled-0"]["private_commit_bytes"] + tab_growth = max(phases[f"tabs-closed-{n}"]["private_commit_bytes"] for n in range(1, cycles)) - phases["tabs-closed-0"]["private_commit_bytes"] + violations = [] + if peak_limit is not None and peak > peak_limit * MIB: + violations.append("sampled private commit exceeded the configured peak budget") + if growth_limit is not None and max(output_growth, tab_growth) > growth_limit * MIB: + violations.append("retained private commit grew beyond the configured warm-cycle budget") + resources = {} + if checkpoints is not None: + for phase in phases: + rows = [row for row in checkpoints if row["phase"] == phase] + if len(rows) != 1: + raise ValueError(f"require one resource checkpoint for phase: {phase}") + resources[phase] = {key: rows[0][key] for key in ("handles", "threads")} + elif handle_growth_limit is not None or thread_growth_limit is not None: + raise ValueError("resource budgets require complete resource checkpoints") + resource_growth = {} + for key, limit in (("handles", handle_growth_limit), ("threads", thread_growth_limit)): + growth = None + if resources: + growth = max( + resources[f"{prefix}-{n}"][key] - resources[f"{prefix}-0"][key] + for prefix in ("settled", "tabs-closed") for n in range(1, cycles) + ) + resource_growth[key] = growth + if limit is not None and growth > limit: + violations.append(f"retained {key} grew beyond the configured warm-cycle budget") + return { + "phases": phases, + "resource_checkpoints": resources, + "resource_growth_after_warmup": resource_growth, + "sampled_peak_private_commit_bytes": peak, + "sampled_peak_private_working_set_bytes": max(row["private_working_set_bytes"] for row in samples), + "output_growth_after_warmup_bytes": output_growth, + "tab_growth_after_warmup_bytes": tab_growth, + "peak_budget_mib": peak_limit, + "growth_budget_mib": growth_limit, + "handle_growth_budget": handle_growth_limit, + "thread_growth_budget": thread_growth_limit, + "budget_enforced": any(limit is not None for limit in ( + peak_limit, growth_limit, handle_growth_limit, thread_growth_limit)), + "violations": violations, + } + + +class NativeProcess: + class Counters(ctypes.Structure): + _fields_ = [("size", wintypes.DWORD), ("page_faults", wintypes.DWORD)] + [ + (name, ctypes.c_size_t) for name in ( + "peak_working_set", "working_set", "peak_paged_pool", "paged_pool", + "peak_nonpaged_pool", "nonpaged_pool", "pagefile", "peak_pagefile", + "private_usage", "private_working_set", "shared_commit", + ) + ] + + class SystemMemory(ctypes.Structure): + _fields_ = [("length", wintypes.DWORD), ("load", wintypes.DWORD)] + [ + (name, ctypes.c_ulonglong) for name in ( + "total_physical", "available_physical", "total_pagefile", "available_pagefile", + "total_virtual", "available_virtual", "available_extended_virtual", + ) + ] + + def __init__(self, process: subprocess.Popen, executable: Path): + self.process = process + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.user = ctypes.WinDLL("user32", use_last_error=True) + self.kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + self.kernel.OpenProcess.restype = wintypes.HANDLE + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.kernel.K32GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(self.Counters), wintypes.DWORD] + self.kernel.QueryFullProcessImageNameW.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)] + self.kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + self.kernel.GetProcessHandleCount.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + self.kernel.GlobalMemoryStatusEx.argtypes = [ctypes.POINTER(self.SystemMemory)] + self.handle = self.kernel.OpenProcess(0x1010, False, process.pid) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + try: + image = ctypes.create_unicode_buffer(32768) + length = wintypes.DWORD(len(image)) + if not self.kernel.QueryFullProcessImageNameW(self.handle, 0, image, ctypes.byref(length)): + raise ctypes.WinError(ctypes.get_last_error()) + if Path(image.value).resolve() != executable.resolve(): + raise RuntimeError("the launched PID does not match the test executable") + except BaseException: + self.close() + raise + self.user.SetThreadDpiAwarenessContext.argtypes = [wintypes.HANDLE] + self.user.SetThreadDpiAwarenessContext.restype = wintypes.HANDLE + self.user.SetThreadDpiAwarenessContext(ctypes.c_void_p(-4)) + self.user.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + self.user.IsWindowVisible.argtypes = [wintypes.HWND] + self.user.IsIconic.argtypes = [wintypes.HWND] + self.user.GetClientRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] + self.user.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] + self.user.GetDpiForWindow.argtypes = [wintypes.HWND] + self.user.SetWindowPos.argtypes = [wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.UINT] + self.user.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + + def window(self) -> int: + found = [] + callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + + @callback_type + def visit(window, _): + owner = wintypes.DWORD() + self.user.GetWindowThreadProcessId(window, ctypes.byref(owner)) + if owner.value == self.process.pid and self.user.IsWindowVisible(window): + rect = wintypes.RECT() + self.user.GetClientRect(window, ctypes.byref(rect)) + found.append(((rect.right - rect.left) * (rect.bottom - rect.top), window)) + return True + + self.user.EnumWindows.argtypes = [callback_type, wintypes.LPARAM] + self.user.EnumWindows(visit, 0) + if not found: + raise RuntimeError("the test process has no visible window") + return max(found)[1] + + def resize(self, width: int, height: int) -> None: + if not self.user.SetWindowPos(self.window(), None, 0, 0, width, height, 0x16): + raise ctypes.WinError(ctypes.get_last_error()) + + def geometry(self) -> dict: + window = self.window() + client, outer = wintypes.RECT(), wintypes.RECT() + self.user.GetClientRect(window, ctypes.byref(client)) + self.user.GetWindowRect(window, ctypes.byref(outer)) + return {"client_width": client.right - client.left, "client_height": client.bottom - client.top, + "outer_width": outer.right - outer.left, "outer_height": outer.bottom - outer.top, + "dpi": self.user.GetDpiForWindow(window), "minimized": bool(self.user.IsIconic(window))} + + def sample(self) -> dict: + counters = self.Counters() + counters.size = ctypes.sizeof(counters) + if not self.kernel.K32GetProcessMemoryInfo(self.handle, ctypes.byref(counters), counters.size): + raise ctypes.WinError(ctypes.get_last_error()) + created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4)) + if not self.kernel.GetProcessTimes(self.handle, *map(ctypes.byref, (created, exited, kernel, user))): + raise ctypes.WinError(ctypes.get_last_error()) + ticks = lambda value: value.dwHighDateTime * 2**32 + value.dwLowDateTime + handles = wintypes.DWORD() + if not self.kernel.GetProcessHandleCount(self.handle, ctypes.byref(handles)): + raise ctypes.WinError(ctypes.get_last_error()) + return {"private_commit_bytes": counters.private_usage, + "private_working_set_bytes": counters.private_working_set, + "working_set_bytes": counters.working_set, + "cpu_seconds": (ticks(kernel) + ticks(user)) / 10_000_000, + "handles": handles.value, + "created_filetime": ticks(created)} + + def thread_count(self) -> int: + class ThreadEntry(ctypes.Structure): + _fields_ = [(name, wintypes.DWORD) for name in ("size", "usage", "thread_id", "process_id")] + [ + ("base_priority", wintypes.LONG), ("delta_priority", wintypes.LONG), ("flags", wintypes.DWORD) + ] + self.kernel.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + self.kernel.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + self.kernel.Thread32First.argtypes = [wintypes.HANDLE, ctypes.POINTER(ThreadEntry)] + self.kernel.Thread32Next.argtypes = [wintypes.HANDLE, ctypes.POINTER(ThreadEntry)] + snapshot = self.kernel.CreateToolhelp32Snapshot(4, 0) + if snapshot == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + count = 0 + try: + entry = ThreadEntry() + entry.size = ctypes.sizeof(entry) + valid = self.kernel.Thread32First(snapshot, ctypes.byref(entry)) + while valid: + count += entry.process_id == self.process.pid + valid = self.kernel.Thread32Next(snapshot, ctypes.byref(entry)) + if ctypes.get_last_error() != 18: + raise ctypes.WinError(ctypes.get_last_error()) + return count + finally: + self.kernel.CloseHandle(snapshot) + + def system_memory(self) -> dict: + status = self.SystemMemory() + status.length = ctypes.sizeof(status) + if not self.kernel.GlobalMemoryStatusEx(ctypes.byref(status)): + raise ctypes.WinError(ctypes.get_last_error()) + return {"physical_total_bytes": status.total_physical, + "physical_available_bytes": status.available_physical, + "memory_load_percent": status.load} + + def close(self) -> None: + if self.handle: + self.kernel.CloseHandle(self.handle) + self.handle = None + + +def close_owned_process(process, native) -> dict: + """Prefer WM_CLOSE; a forced cleanup is a test failure, never a budget pass.""" + result = {"pid": process.pid, "normal_exit": False, "forced_termination": False, "errors": []} + if process.poll() is None and native is not None: + try: + if not native.user.PostMessageW(native.window(), 0x10, 0, 0): + raise RuntimeError("WM_CLOSE was rejected") + process.wait(timeout=15) + except (OSError, RuntimeError, subprocess.TimeoutExpired) as error: + result["errors"].append(str(error)) + result["normal_exit"] = process.poll() == 0 + if process.poll() is None: + result["forced_termination"] = True + try: + # Popen retains the handle to the exact process we created. Do not + # search by executable name or touch another instance's children. + process.terminate() + process.wait(timeout=15) + except (OSError, subprocess.TimeoutExpired) as error: + result["errors"].append(str(error)) + result["returncode"] = process.poll() + return result + + +@contextmanager +def isolated_process(executable: Path, work: Path, env: dict, output: Path): + with (output / "product.log").open("wb") as log: + process = subprocess.Popen( + [os.fspath(executable), "--gpui", "--working-directory", os.fspath(work), + "-e", os.path.join(os.environ["SystemRoot"], "System32", "cmd.exe"), "/d", "/q"], + cwd=work, env=env, stdout=log, stderr=subprocess.STDOUT, + ) + native = None + try: + native = NativeProcess(process, executable) + yield process, native + except BaseException as error: + (output / "failure.json").write_text( + json.dumps({"pid": process.pid, "error": str(error)}, indent=2), encoding="utf-8") + raise + finally: + cleanup = close_owned_process(process, native) + if native is not None: + native.close() + (output / "cleanup.json").write_text(json.dumps(cleanup, indent=2), encoding="utf-8") + report_path = output / "report.json" + if report_path.exists(): + report = json.loads(report_path.read_text(encoding="utf-8")) + report["cleanup"] = cleanup + if not cleanup["normal_exit"]: + report["summary"]["violations"].append("the owned test process did not close normally") + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + if not cleanup["normal_exit"]: + raise RuntimeError(f"test process cleanup failed: {cleanup}") + + +def writer(args: argparse.Namespace) -> None: + if os.name == "nt": + import msvcrt + if not ctypes.windll.kernel32.SetConsoleOutputCP(65001): + raise ctypes.WinError() + msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) + grid = os.get_terminal_size(sys.stdout.fileno()) + started = time.perf_counter() + byte_count = 0 + with Path(args.payload).open("rb") as stream: + while chunk := stream.read(65536): + remaining = memoryview(chunk) + while remaining: + written = os.write(sys.stdout.fileno(), remaining) + if written <= 0: + raise RuntimeError("terminal output stopped accepting bytes") + byte_count += written + remaining = remaining[written:] + elapsed = time.perf_counter() - started + marker = "PEBREL_STRESS_COMPLETED_" + Path(args.result).stem + os.write(sys.stdout.fileno(), ("\r\n" + marker + "\r\n").encode()) + Path(args.result).write_text(json.dumps({"bytes": byte_count, "drain_seconds": elapsed, "marker": marker, + "columns": grid.columns, "rows": grid.lines}), encoding="utf-8") + + +def corpus(directory: Path, mebibytes: int) -> tuple[Path, Path]: + text = directory / "mixed-output.txt" + rows = [] + for index in range(512): + cjk = "".join(chr(0x4E00 + (index * 16 + offset) % 4096) for offset in range(16)) + rows.append(f"\x1b[38;5;{index % 256}m{index:04d} ascii 0123456789 {cjk} \ue0b0 \uf120 \uf07b\x1b[0m\r\n") + block = "".join(rows).encode("utf-8") + with text.open("wb") as stream: + for _ in range((mebibytes * MIB + len(block) - 1) // len(block)): + stream.write(block) + redraw = directory / "redraw.txt" + with redraw.open("wb") as stream: + stream.write(b"\x1b[?1049h\x1b[?25l") + for frame in range(240): + stream.write(b"\x1b[H") + for row in range(24): + stream.write((f"\x1b[38;5;{(frame + row) % 256}m" + "▀▄" * 40 + "\x1b[0m\r\n").encode("utf-8")) + stream.write(b"\x1b[?25h\x1b[?1049l") + return text, redraw + + +def run(args: argparse.Namespace) -> int: + if os.name != "nt": + raise RuntimeError("this sampler requires native Windows Python") + executable, output = Path(args.app).resolve(), Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=False) + config, work, temporary = (output / name for name in ("config", "work", "temp")) + for directory in (config, work, temporary): + directory.mkdir() + settings = ["shell=cmd", "language=en", "theme=SilverLight", "follow_system_theme=0", + "font_family=Maple Mono Normal NF CN", "font_size=16", "keep_session=0", + "restore_session=0", "resume_ai=0", "tray=0", "ai_hooks=0", "ai_toasts=0", + "fetch=0", "powerline=0", "auto_check_updates=0", "quick_terminal_hotkey=", + "opacity=1", "blur=0", "windowing_behavior=use_new"] + (config / "pebrel_settings.txt").write_text("\n".join(settings) + "\n", encoding="utf-8") + (config / "pebrel.toml").write_text("# Isolated memory stress configuration.\n", encoding="utf-8") + payloads = corpus(work, args.payload_mib) + env = {key: value for key, value in os.environ.items() if not key.startswith(("PEBREL_", "NEBULA_", "GPUI_"))} + for prefix in ("PEBREL", "NEBULA"): + env[prefix + "_CONFIG_DIR"] = os.fspath(config) + env[prefix + "_GPUI_CONFIG"] = os.fspath(config / "pebrel.toml") + env[prefix + "_CONFIG_FILE"] = os.fspath(config / "pebrel.toml") + env.update(TEMP=os.fspath(temporary), TMP=os.fspath(temporary), PYTHONUTF8="1") + with isolated_process(executable, work, env, output) as (process, native): + return measure(args, process, native, executable, output, config, work, payloads) + + +def measure(args, process, native, executable, output, config, work, payloads) -> int: + with executable.open("rb") as binary: + executable_hash = hashlib.file_digest(binary, "sha256").hexdigest() + source_manifest = None + if args.source_manifest: + path = Path(args.source_manifest).resolve() + source_manifest = {"path": os.fspath(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + identity = {"pid": process.pid, "executable": os.fspath(executable), + "sha256": executable_hash, + "source_manifest": source_manifest, + "harness_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "python_version": platform.python_version(), + "windows_version": platform.version(), "machine": platform.machine(), + "logical_processors": os.cpu_count(), "system_memory": native.system_memory(), + **native.sample()} + (output / "instance.json").write_text(json.dumps(identity, indent=2), encoding="utf-8") + samples, runs, sampler_errors, checkpoints = [], [], [], [] + stop = threading.Event() + phase = "startup" + started = time.monotonic() + + def sample_loop(): + while not stop.is_set(): + try: + samples.append({"elapsed_seconds": time.monotonic() - started, "phase": phase, **native.sample()}) + except OSError as error: + sampler_errors.append(str(error)) + return + stop.wait(0.1) + + sampler = threading.Thread(target=sample_loop, daemon=True) + sampler.start() + client = None + window_id = None + + def checkpoint(): + row = {"phase": phase, "pid": process.pid, "elapsed_seconds": time.monotonic() - started, + "threads": native.thread_count(), "system_memory": native.system_memory(), **native.sample()} + checkpoints.append(row) + print(json.dumps(row), flush=True) + try: + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(f"the product exited during startup: {process.returncode}") + try: + client = RuntimeClient.from_port_file(config / "runtime.port") + snapshot = client.request("runtime.snapshot")["result"] + window = snapshot["windows"][0] + pane_id = window["tabs"][0]["panes"][0]["id"] + window_id = window["id"] + if snapshot.get("process_id") != process.pid: + raise RuntimeError("runtime endpoint belongs to a different process") + break + except (OSError, KeyError, IndexError): + time.sleep(0.2) + else: + raise RuntimeError("the isolated runtime did not become ready") + native.resize(args.width, args.height) + time.sleep(5) + geometry = native.geometry() + phase = "idle" + time.sleep(10) + checkpoint() + + def request(method, params=None): + return client.request(method, params or {}, timeout=15)["result"] + + def prompt(command): + return request("pane.prompt", {"window_id": window_id, "pane_id": pane_id, "text": command, "submit": True}) + + prompt("chcp 65001 >nul") + time.sleep(0.5) + for cycle in range(args.cycles): + for kind, payload in zip(("output", "redraw"), payloads): + phase = f"{kind}-{cycle}" + result_path = work / f"{kind}-{cycle}.json" + command = subprocess.list2cmdline([sys.executable, "-I", os.fspath(Path(__file__).resolve()), + "--writer", "--payload", os.fspath(payload), "--result", os.fspath(result_path)]) + prompt(command) + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + if result_path.is_file(): + try: + result = json.loads(result_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + time.sleep(0.1) + continue + read = request("pane.read", {"window_id": window_id, "pane_id": pane_id, "lines": 80}) + if result["marker"] in read["text"].splitlines(): + runs.append({"kind": kind, "cycle": cycle, "history_available": read["history_available"], **result}) + break + time.sleep(0.1) + else: + raise RuntimeError(f"{phase} did not finish in the terminal") + phase = f"settled-{cycle}" + time.sleep(args.settle_seconds) + checkpoint() + + for cycle in range(args.cycles): + phase = f"tabs-open-{cycle}" + for _ in range(args.tabs): + request("tab.new", {"window_id": window_id, "cwd": os.fspath(work)}) + time.sleep(2) + snapshot = request("runtime.snapshot") + window = next(value for value in snapshot["windows"] if value["id"] == window_id) + if len(window["tabs"]) != args.tabs + 1: + raise RuntimeError("the tab stress did not create the expected live tabs") + for tab in sorted(window["tabs"], key=lambda value: value["index"], reverse=True): + if tab["index"] != 0: + request("tab.close", {"window_id": window_id, "tab_index": tab["index"]}) + phase = f"tabs-closed-{cycle}" + time.sleep(args.settle_seconds) + snapshot = request("runtime.snapshot") + window = next(value for value in snapshot["windows"] if value["id"] == window_id) + if len(window["tabs"]) != 1: + raise RuntimeError("closing the test tabs did not restore one tab") + checkpoint() + if sampler_errors: + raise RuntimeError(f"native sampling failed: {sampler_errors}") + stop.set() + sampler.join() + result = summarize(samples, args.cycles, args.max_private_commit_mib, args.max_growth_mib, + checkpoints=checkpoints, handle_growth_limit=args.max_handle_growth, + thread_growth_limit=args.max_thread_growth) + grids = {(row["columns"], row["rows"]) for row in runs} + if len(grids) != 1: + raise RuntimeError(f"the workload grid changed during the run: {grids}") + final_geometry = native.geometry() + if final_geometry != geometry or final_geometry["minimized"]: + raise RuntimeError(f"test window geometry changed: {geometry} -> {final_geometry}") + report = {"identity": identity, "geometry": geometry, "cycles": args.cycles, "tabs_per_cycle": args.tabs, + "sampling_interval_seconds": 0.1, "runs": runs, "checkpoints": checkpoints, "summary": result, + "notes": ["Counters belong to Pebrel only; shell and writer children are excluded.", + "Drain time includes PTY backpressure; it is not displayed FPS or input latency.", + "A 100 ms sample interval can miss shorter memory peaks.", + "No working-set trimming, process memory limit, or full-memory dump is used."]} + (output / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + print(json.dumps(result), flush=True) + return 1 if result["violations"] else 0 + except BaseException: + context = {"phase": phase, "elapsed_seconds": time.monotonic() - started} + if client is not None: + try: + snapshot = client.request("runtime.snapshot", timeout=2)["result"] + context["runtime"] = snapshot + context["pane_processes"] = [] + for window in snapshot["windows"]: + if window["id"] != window_id: + continue + for tab in window["tabs"]: + for pane in tab["panes"]: + try: + tree = client.request("pane.processes", { + "window_id": window_id, "pane_id": pane["id"], + }, timeout=2) + except (OSError, RuntimeError) as error: + tree = {"error": str(error)} + context["pane_processes"].append({"pane_id": pane["id"], "tree": tree}) + except (OSError, RuntimeError, KeyError) as error: + context["diagnostic_error"] = str(error) + (output / "failure-context.json").write_text(json.dumps(context, indent=2), encoding="utf-8") + raise + finally: + stop.set() + sampler.join(timeout=2) + (output / "samples.json").write_text(json.dumps(samples, indent=2), encoding="utf-8") + (output / "checkpoints.json").write_text(json.dumps(checkpoints, indent=2), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--writer", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--payload", help=argparse.SUPPRESS) + parser.add_argument("--result", help=argparse.SUPPRESS) + parser.add_argument("--app") + parser.add_argument("--output") + parser.add_argument("--source-manifest", help="source hashes associated with the test executable") + parser.add_argument("--cycles", type=int, default=5) + parser.add_argument("--tabs", type=int, default=4) + parser.add_argument("--payload-mib", type=int, default=11) + parser.add_argument("--settle-seconds", type=float, default=3) + parser.add_argument("--width", type=int, default=1280) + parser.add_argument("--height", type=int, default=900) + parser.add_argument("--max-private-commit-mib", type=float) + parser.add_argument("--max-growth-mib", type=float) + parser.add_argument("--max-handle-growth", type=int) + parser.add_argument("--max-thread-growth", type=int) + args = parser.parse_args() + if args.writer: + writer(args) + return 0 + if not args.app or not args.output: + parser.error("--app and a fresh --output directory are required") + if args.cycles < 2 or args.tabs < 1 or args.payload_mib < 1 or not math.isfinite(args.settle_seconds) or args.settle_seconds < 1: + parser.error("require cycles >= 2, tabs >= 1, payload-mib >= 1, settle-seconds >= 1") + if min(args.width, args.height) < 200: + parser.error("window width and height must be at least 200 pixels") + for limit in (args.max_private_commit_mib, args.max_growth_mib, args.max_handle_growth, args.max_thread_growth): + if limit is not None and (not math.isfinite(limit) or limit < 0): + parser.error("budgets must be finite and nonnegative") + return run(args) + + +if __name__ == "__main__": + raise SystemExit(main())