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())