From ca26a35877de4df08bc588ddf06a552936fb8daf Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:15:27 +0200 Subject: [PATCH 1/8] Read the GPU counters through PerfLib instead of PDH The refinement's read of the GPU Engine counters left some 3.7 MB and 19 handles in the watcher for the rest of its run: PdhCloseQuery frees the query, not what PDH loaded to resolve the counter path. GPU Engine is a V2 counter set, and Microsoft documents the PerfLib consumer functions for reading those with minimal overhead. The reader now finds the counter set and its counter by their English names, checks the counter's registered type (PERF_100NSEC_TIMER) before applying its formula, and walks the documented sample layout in plain code, every size bounded, tested on blocks built by hand. The same read now leaves 0.23 MB and 18 handles; its values match typeperf's over the same seconds. presence-probe gains menu-cost, which measured that one shell open leaves 141 handles and 1.2 MB and one update check 111 handles and 1 MB, and gpu-load, to set the reader beside typeperf. The Lot 16 page records the measurements and proposes what to do about the menu. Cargo.toml's comment on the registry feature still described the change notification Lot 15 measured never arrives; corrected. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- Cargo.toml | 5 +- docs/design/15-marked-games.md | 3 +- docs/design/16-footprint.md | 154 ++++++++ docs/design/README.md | 1 + src/bin/presence-probe.rs | 148 ++++++++ src/detect/gpu.rs | 643 +++++++++++++++++++++++++++------ 7 files changed, 852 insertions(+), 108 deletions(-) create mode 100644 docs/design/16-footprint.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7b592..da7ae42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,11 @@ a section is written. ## [Unreleased] -Nothing yet. +### Changed + +- Telling a game from its launcher by what the graphics card is drawing no + longer adds some 3.5 MB to the watcher's memory for the rest of its run: + the counters are read the lighter way Windows offers for them. ## [0.3.0] - 2026-09-23 diff --git a/Cargo.toml b/Cargo.toml index 097209a..25bf08d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,8 @@ windows = { version = "0.62", features = [ # FindFirstChangeNotificationW: the configuration folder, watched for the # live reload. "Win32_Storage_FileSystem", - # RegNotifyChangeKeyValue: Windows' game list, watched rather than polled. + # RegQueryInfoKeyW: the last-write time of Windows' game list, asked at + # each idle look, since its change notification never arrives (Lot 15). "Win32_System_Registry", # WNDCLASSEXW names HBRUSH, HICON and HCURSOR, so the window class needs Gdi # even though this program never draws anything. @@ -64,6 +65,8 @@ windows = { version = "0.62", features = [ # K32EnumProcesses: the process ids alone, which the idle look takes # every poll instead of a Toolhelp snapshot, about 130 times cheaper. "Win32_System_ProcessStatus", + # The PerfLib consumer functions: the GPU Engine counters the refinement + # reads, without what PDH keeps for the process's life (Lot 16). "Win32_System_Performance", "Win32_System_SystemInformation", "Win32_System_Threading", diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 3224581..985a585 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -499,7 +499,8 @@ leak. Naming a game (every process asked its path and package) leaves after: a cost paid once, at the first session, not a leak. It accounts for about twenty handles in the probe; the watcher gains some 260 over its first sessions, and the rest is not yet traced. Both are proposed as a -separate piece of work — the lot's own cost is nil. +separate piece of work, [Lot 16](16-footprint.md) — the lot's own cost is +nil. ## What it changed in the program — built 2026-09-23 diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md new file mode 100644 index 0000000..aff7a39 --- /dev/null +++ b/docs/design/16-footprint.md @@ -0,0 +1,154 @@ +# Lot 16 — What the watcher keeps + +**Status: in progress since 2026-09-23.** Proposed the same day in +[Lot 15](15-marked-games.md)'s record, which found that reading the GPU +counters left 3.7 MB behind and that the installed watcher had gained some +260 handles over its first sessions, only twenty of them traced; taken up +at once, at the maintainer's request. + +- [x] The instrument: `presence-probe footprint` step by step, `menu-cost` for what one click leaves over five minutes, `gpu-load` to set the GPU reader beside Windows' own `typeperf`, and a scratch watcher driven through sessions and reloads — 2026-09-23 +- [x] Where the handles come from — 2026-09-23, below: the GPU read, the shell and the update check; sessions and reloads leave nothing +- [x] The GPU counters read through PerfLib instead of PDH — built 2026-09-23: 0.23 MB and 18 handles left instead of 3.6–3.9 MB and 19, the values checked against `typeperf` +- [ ] The menu's shell opens: a proposal below, the maintainer's call +- [ ] What *Check for updates* leaves: a proposal below, the maintainer's call +- [ ] Measured on the whole installed process against 0.3.0: just started, after a session whose refinement reads the counters, after the menu's clicks +- [ ] What the watcher costs, written for the people who use it in [How it works](../how-it-works.md), from those figures +- [ ] Verified in the field: a session whose refinement reads the counters, on the maintainer's machine + +**Done when** a session, a reload and each entry of the menu leave the +watcher holding no more than a figure written here over what it held just +started, measured on the whole installed process against 0.3.0 — and every +figure the documentation gives for what the watcher costs is one measured. + +## Sessions and reloads leave nothing, 2026-09-23 + +A watcher built from this branch before any change (0.3.0, `da26e0f2`), on +a scratch configuration — two-second poll, one-second stop delay, the +refinement three seconds in, three `cmd /c exit 0` commands — with the +installed one stopped. Sessions made by activating Windows' presence +writer, as `presence-probe activate` does; reloads by rewriting the file +with another `log_level`. Three runs: + +| | handles | private | threads | +| --- | --- | --- | --- | +| just started | 168–170 | 2.1–2.2 MB | 6 | +| after the first session | 174 | 2.3 MB | 6, then 3 | +| after the other sessions, up to five, and the reloads | 174 | 2.3–2.4 MB | 3 | + +The three threads that go are the loader's own workers, which Windows ends +when they have been idle. The first run read differently — 225 handles +after its third session and 371, with 12 threads, after its first reload — +and two runs after it, the second sampling four times a second, never +did it again. Its size is the size of a shell call, measured below, and +nothing in that run is known to have made one: recorded as not reproduced, +not explained. + +None of these sessions read the GPU counters. The refinement reads them +only when two processes or more match the game ([Lot 3](03-game-naming.md)); +a presence writer started by hand has no game, and the log said +*Refinement has nothing to arbitrate* every time. + +## What each step leaves + +`presence-probe footprint` runs the watcher's steps one at a time in one +process and reports its private bytes and handles after each. A step that +leaves the same after ten more is a cost paid once; one that leaves more is +a leak. None leaks. The steps that cost anything: + +| Step, the first time | private | handles | +| --- | --- | --- | +| naming a game: every process asked its path and package | +0.1 to +0.2 MB | +2 | +| reading the GPU counters through PDH, 0.3.0 | **+3.6 to +3.9 MB** | +19 | +| reading them through PerfLib, this lot | +0.23 MB | +18 | +| opening a program through the shell, as the menu opens a file | **+1.45 MB** | **+152** | + +`presence-probe menu-cost` makes one click's worth of work in a fresh +process and samples it for five minutes. The shell is asked to run a hidden +`cmd /c exit 0`, which takes the same road as *Edit configuration* without +putting anything on screen; *Check for updates* goes over the network as +the menu's does: + +| One click | 1 s after | 30 s | 120 s | 300 s | +| --- | --- | --- | --- | --- | +| a shell open, `ShellExecuteW` | +1.48 MB, +167 handles, +6 threads | +1.35 MB, +150, +5 | +1.23 MB, +147, +1 | **+1.20 MB, +141, +0** | +| *Check for updates*, WinHTTP | +1.48 MB, +162, +6 | +1.43 MB, +156, +6 | +1.08 MB, +125, +2 | **+1.03 MB, +111, +1** | +| a plain `CreateProcess` | +0.01 MB, +2, +0 | | | | + +The threads are the thread pool's and expire within five minutes; the +handles and the memory stay for the life of the process. Initialising COM +first, as Microsoft asks callers of `ShellExecute` to do, changed nothing +— 314 handles and 10 threads either way — and neither did making the call +on a thread that ends afterwards (312 and 10). The cost belongs to the +shell, not to how it is called. + +What this says of the watcher that read 424 handles after four sessions in +Lot 15 is inferred, not measured: one shell open would account for some 150 +of its 260 — the maintainer changed the poll interval in their file at +13:36 that day, by a road not recorded — and a naming or two for a few +more. The rest is not traced to a step; the whole-process measurement +below is where it will be looked for again. + +## The GPU counters: PDH keeps what it loaded + +What Microsoft documents: `PdhCloseQuery` "frees all memory associated with +the query" — the query's own. What PDH loads to resolve a counter path is +the process's, and nothing in the documentation releases it. Measured in a +fresh PowerShell process: `pdh.dll` is the only module added, adding the +counter is the step that leaves the memory and seven handles, and closing +the query gives two handles back. + +*GPU Engine* is a V2 counter set: registered by the display kernel, +`dxgmms2.sys`, provider *GPU Performance Counters*, two counters, *Running +Time* and *Utilization Percentage*. For V2 counter sets Microsoft documents +a second consumer, the PerfLib functions, for collecting "with minimal +dependencies and overhead". Its guide recommends PDH for most applications +and says these are harder to use; what is harder is a documented byte +layout to walk, which is written once and tested without Windows. + + +How the reader does it, each step as the guide describes: + +- The counter set is found by its English name among those + `PerfEnumerateCounterSet` lists — 162 here, 33 ms the first time — and + the counter by its English name in the set, since hard-coding their + identifiers needs the provider's symbol file, which is not published. +- The counter's registered type is checked: `0x20510500`, + `PERF_100NSEC_TIMER`, whose formula is `100 × (N1 − N0) / (D1 − D0)` with + `D` the sample's time in 100 ns units + ([Calculating Counter Values](https://learn.microsoft.com/en-us/windows/win32/perfctrs/calculating-counter-values)). + Another type is refused, not guessed at: no opinion, as when the counters + cannot be read at all. +- One query, every instance (`*`), two samples a second apart; an instance + missing from one of them, or whose value went backwards, says nothing — + Microsoft's own example drops those samples too. +- `PERF_DATA_HEADER`, `PERF_COUNTER_HEADER`, `PERF_MULTI_INSTANCES`, + `PERF_INSTANCE_HEADER` and `PERF_COUNTER_DATA` are read by offset with + every size bounded by the block around it; a cut or inconsistent block is + an error, and ten tests build such blocks by hand. + +Set beside `typeperf "\GPU Engine(*engtype_3D)\Utilization Percentage"` +over the same seconds, 17:10:34–40, the desktop idle: the same three +processes at the same magnitudes — 2.39, 1.65 and 1.08 % against 2.87, +1.71 and 1.09 % for the first second — and in the same order in three of +the four seconds compared, the two windows half a second apart. A game's +load, tens of percent on one process, is the field run's to confirm. + +## Proposed, for the maintainer's decision + +**The menu's shell opens, through a short-lived helper.** *Edit +configuration*, *Open log folder*, *Documentation* and the release page +all go through `ShellExecuteW`, and the first of them leaves 141 handles +and 1.2 MB for the rest of the watcher's life. The watcher could start its +own executable with a hidden command that makes that one call — and the +Notepad fallback when a `.toml` has no association — and exits: a plain +`CreateProcess` leaves two handles. What it costs: one hidden command, the +open's outcome read from the helper's exit code, some 30 ms on a click. +Recommended: it is the larger of the two costs a player can see in Task +Manager, and the code it takes is small and ordinary. + +**What *Check for updates* leaves, left as it is.** 111 handles and 1 MB +after the threads expire. The check is a click made once a release; a +successful update replaces the watcher anyway; moving the check into a +helper would mean handing its verdict back across processes — a second +protocol, for a figure the helper saves once a month. Written down in +[How it works](../how-it-works.md) instead, with the other figures. diff --git a/docs/design/README.md b/docs/design/README.md index 8167176..66a6d8a 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -27,6 +27,7 @@ session rather than when the code compiles. Each has its own page. | 13 | [Updating](13-updating.md) | done | | 14 | [Release notes people can read](14-release-notes.md) | done | | 15 | [Games Windows knows only from you](15-marked-games.md) | done | +| 16 | [What the watcher keeps](16-footprint.md) | in progress | **Dependency order:** 1 → 2 → 4 → 5 → 6 → 7, with 3 independent and 7 needing both 3 and 6. Logging sits before the icon deliberately — the icon logs too, diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 678a430..07e0a3b 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -23,6 +23,10 @@ //! several ways of being told the game list changed, at once //! presence-probe cost [rounds] time what an idle poll costs, today and with Lot 15 //! presence-probe footprint what each step of the watcher leaves in memory and handles +//! presence-probe menu-cost +//! what one click of the menu leaves, over five minutes +//! presence-probe gpu-load [ms] [rounds] +//! the rendering load the refinement reads, busiest first //! presence-probe microsoft-list ... //! whether Microsoft's own game list covers each executable //! presence-probe activate activate the class ourselves and time it @@ -983,6 +987,139 @@ fn cmd_footprint() -> windows::core::Result<()> { let _ = sensor.rendering_load(std::time::Duration::from_millis(200)); } say("reading them three times more"); + // What a click on *Edit configuration* costs: the menu opens the file + // through the shell. A hidden `cmd /c exit 0` takes the same road + // without putting anything on screen. + let shell = |times: usize| { + use windows::Win32::UI::Shell::ShellExecuteW; + use windows::Win32::UI::WindowsAndMessaging::SW_HIDE; + use windows::core::w; + for _ in 0..times { + // SAFETY: every string is a NUL-terminated literal. + unsafe { + ShellExecuteW( + None, + w!("open"), + w!("cmd.exe"), + w!("/c exit 0"), + None, + SW_HIDE, + ) + }; + } + std::thread::sleep(std::time::Duration::from_secs(2)); + }; + shell(1); + say("opening a program through the shell once, as the menu does"); + shell(3); + say("three times more"); + Ok(()) +} + +// ------------------------------------------------------- the GPU reader -- + +/// The rendering load the refinement reads, every `ms` milliseconds, for +/// `rounds` rounds: the processes with any, busiest first. Run beside +/// `typeperf "\GPU Engine(*engtype_3D)\Utilization Percentage"` to check +/// the reader against Windows' own tool. +fn cmd_gpu_load(ms: u64, rounds: u32) -> windows::core::Result<()> { + use game_mode_executor::detect::gpu; + for _ in 0..rounds { + match gpu::rendering_load(std::time::Duration::from_millis(ms)) { + Ok(load) => { + let mut busiest: Vec<_> = load.into_iter().collect(); + busiest.sort_by(|a, b| b.1.total_cmp(&a.1)); + let line: Vec = busiest + .iter() + .take(5) + .map(|(pid, share)| format!("{pid}={share:.2}%")) + .collect(); + println!("{} {}", timestamp(), line.join(" ")); + } + Err(error) => println!("{} cannot read: {error:#}", timestamp()), + } + } + Ok(()) +} + +/// What one click of the menu leaves in this process, sampled for five +/// minutes: `open`, the shell road *Edit configuration* takes, here to a +/// hidden `cmd /c exit 0` so nothing shows; `check`, *Check for updates* +/// over the network; `spawn`, a plain CreateProcess, for comparison. +fn cmd_menu_cost(click: &str) -> windows::core::Result<()> { + use windows::Win32::UI::Shell::ShellExecuteW; + use windows::Win32::UI::WindowsAndMessaging::SW_HIDE; + use windows::core::w; + + let threads = || { + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + let me = std::process::id(); + let mut count = 0; + // SAFETY: the snapshot is closed below; the entry carries its size. + unsafe { + let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) else { + return 0; + }; + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut more = Thread32First(snapshot, &mut entry).is_ok(); + while more { + if entry.th32OwnerProcessID == me { + count += 1; + } + more = Thread32Next(snapshot, &mut entry).is_ok(); + } + let _ = windows::Win32::Foundation::CloseHandle(snapshot); + } + count + }; + let say = |step: &str| { + let (private, handles) = footprint(); + println!( + "{private:>7.2} MB private {handles:>5} handles {:>3} threads {step}", + threads() + ); + }; + say("before"); + match click { + "open" => { + // SAFETY: every string is a NUL-terminated literal. + unsafe { + ShellExecuteW( + None, + w!("open"), + w!("cmd.exe"), + w!("/c exit 0"), + None, + SW_HIDE, + ) + }; + } + "check" => { + use game_mode_executor::update; + if let Ok(context) = update::Context::of_this_process(None, None) { + let verdict = update::check_now(&context); + println!("check: {:?}", verdict.map(|_| "answered")); + } + } + "spawn" => { + let _ = std::process::Command::new("cmd.exe") + .args(["/c", "exit", "0"]) + .status(); + } + other => { + eprintln!("usage: presence-probe menu-cost , not `{other}`"); + std::process::exit(2); + } + } + for (wait, at) in [(1, 1), (4, 5), (25, 30), (90, 120), (180, 300)] { + std::thread::sleep(std::time::Duration::from_secs(wait)); + say(&format!("{click}, {at} s after")); + } Ok(()) } @@ -1002,6 +1139,17 @@ fn main() -> windows::core::Result<()> { } Some("activate") => cmd_activate(5, 60), Some("footprint") => cmd_footprint(), + Some("gpu-load") => cmd_gpu_load( + std::env::args() + .nth(2) + .and_then(|value| value.parse().ok()) + .unwrap_or(1000), + std::env::args() + .nth(3) + .and_then(|value| value.parse().ok()) + .unwrap_or(5), + ), + Some("menu-cost") => cmd_menu_cost(std::env::args().nth(2).as_deref().unwrap_or("")), Some("watch-methods") => match std::env::args().nth(3) { Some(sid) => cmd_watch_methods(seconds(), &sid), None => { diff --git a/src/detect/gpu.rs b/src/detect/gpu.rs index 3f30127..765f0a7 100644 --- a/src/detect/gpu.rs +++ b/src/detect/gpu.rs @@ -9,141 +9,388 @@ //! presentation through ETW, but that needs administrator rights or membership //! of *Performance Log Users*. These counters need neither on most accounts, //! and every caller here treats a failure to read them as "no opinion". +//! +//! The counters are read through the PerfLib V2 consumer functions, not PDH. +//! *GPU Engine* is a V2 counter set, registered by the display kernel, and +//! Microsoft offers these functions "when you need to collect V2 countersets +//! with minimal dependencies and overhead". PDH read the same values, but +//! kept what it loaded to resolve the counter's path for the rest of the +//! process's life: some 3.7 MB, left by the first read. Through PerfLib the +//! same read leaves 0.2 MB. `docs/design/16-footprint.md` has the +//! measurements. +//! +//! The counter set and its counter are found by their English names, as +//! Microsoft's consumer guide says to do without the provider's symbol +//! file, and the counter's registered type is checked, since the formula +//! depends on it. What PerfLib hands back is a documented byte layout, +//! parsed here in plain code with every size bounded, so the parsing is +//! tested without Windows. +//! use std::collections::HashMap; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; +use windows::Win32::Foundation::{ERROR_NOT_ENOUGH_MEMORY, HANDLE}; use windows::Win32::System::Performance::{ - PDH_FMT_COUNTERVALUE_ITEM_W, PDH_FMT_DOUBLE, PDH_HCOUNTER, PDH_HQUERY, PdhAddEnglishCounterW, - PdhCloseQuery, PdhCollectQueryData, PdhGetFormattedCounterArrayW, PdhOpenQueryW, + PERF_COUNTER_IDENTIFIER, PERF_COUNTER_RATE, PERF_DATA_HEADER, PERF_DELTA_COUNTER, + PERF_DISPLAY_PERCENT, PERF_ERROR_RETURN, PERF_MULTIPLE_INSTANCES, + PERF_REG_COUNTER_ENGLISH_NAMES, PERF_REG_COUNTERSET_ENGLISH_NAME, PERF_REG_COUNTERSET_STRUCT, + PERF_SIZE_LARGE, PERF_TIMER_100NS, PERF_TYPE_COUNTER, PerfAddCounters, PerfCloseQueryHandle, + PerfEnumerateCounterSet, PerfOpenQueryHandle, PerfQueryCounterData, + PerfQueryCounterSetRegistrationInfo, PerfRegInfoType, }; -use windows::core::PCWSTR; +use windows::core::GUID; -/// Wildcard over every process and every engine of every adapter. -const COUNTER_PATH: &str = r"\GPU Engine(*)\Utilization Percentage"; +/// The counter set, and the counter in it, by their English names. +const COUNTER_SET: &str = "GPU Engine"; +const COUNTER: &str = "Utilization Percentage"; -const PDH_MORE_DATA: u32 = 0x8000_07D2; +/// `PERF_100NSEC_TIMER`, as `winperf.h` composes it: a percentage of the +/// elapsed time, computed as `100 * (N1 - N0) / (D1 - D0)` over two samples, +/// with `D` the sample's time in 100 ns units. +/// +const PERF_100NSEC_TIMER: u32 = PERF_SIZE_LARGE + | PERF_TYPE_COUNTER + | PERF_COUNTER_RATE + | PERF_TIMER_100NS + | PERF_DELTA_COUNTER + | PERF_DISPLAY_PERCENT; /// Engines that mean "this process is drawing". Video decode and copy engines /// are busy for a video player or a download too, so they are left out. const RENDERING_ENGINES: &[&str] = &["3d", "vr", "compute"]; -/// A PDH query, closed on drop. -struct Query(PDH_HQUERY); +/// Rendering load per process id, as a percentage summed over the engines of +/// every adapter. Values can exceed 100 on a multi-adapter or multi-engine +/// machine, which is fine: only their order matters here. +/// +/// Utilisation is a rate, so it needs two samples. `interval` is how long to +/// wait between them; a second is what Task Manager uses. +pub fn rendering_load(interval: std::time::Duration) -> Result> { + let (set, counter) = locate()?; + let query = Query::open(&set, counter)?; + let first = query.sample()?; + std::thread::sleep(interval); + let second = query.sample()?; + Ok(load_between( + &parse_sample(&first)?, + &parse_sample(&second)?, + )) +} + +/// The counter set's identifier and the counter's, found by name, with the +/// counter's type checked against the formula `load_between` applies. +fn locate() -> Result<(GUID, u32)> { + let set = counter_sets()? + .into_iter() + .find(|set| { + registration(set, PERF_REG_COUNTERSET_ENGLISH_NAME) + .is_ok_and(|name| utf16_at(&name, 0).as_deref() == Some(COUNTER_SET)) + }) + .with_context(|| format!("no `{COUNTER_SET}` counter set on this machine"))?; + let names = registration(&set, PERF_REG_COUNTER_ENGLISH_NAMES)?; + let counter = counter_names(&names) + .into_iter() + .find_map(|(id, name)| (name == COUNTER).then_some(id)) + .with_context(|| format!("no `{COUNTER}` counter in `{COUNTER_SET}`"))?; + let structure = registration(&set, PERF_REG_COUNTERSET_STRUCT)?; + match counter_type(&structure, counter) { + Some(PERF_100NSEC_TIMER) => Ok((set, counter)), + other => bail!("`{COUNTER}` has type {other:X?}, not the timer it was"), + } +} + +/// Every counter set registered on this machine. +fn counter_sets() -> Result> { + let mut count = 0u32; + let mut sets = Vec::new(); + loop { + // SAFETY: the slice carries its own length, which bounds the write; + // `count` is a local. + let status = unsafe { PerfEnumerateCounterSet(None, Some(&mut sets), &mut count) }; + match status { + 0 => { + sets.truncate(count as usize); + return Ok(sets); + } + _ if status == ERROR_NOT_ENOUGH_MEMORY.0 => { + sets = vec![GUID::zeroed(); count as usize]; + } + _ => bail!("PerfEnumerateCounterSet failed ({status})"), + } + } +} + +/// One piece of a counter set's registration, sized then read. +fn registration(set: &GUID, code: PerfRegInfoType) -> Result> { + let mut size = 0u32; + let mut buffer = Vec::new(); + loop { + // SAFETY: `set` outlives the call; the slice carries its own length, + // which bounds the write; `size` is a local. + let status = unsafe { + PerfQueryCounterSetRegistrationInfo(None, set, code, 0, Some(&mut buffer), &mut size) + }; + match status { + 0 => { + buffer.truncate(size as usize); + return Ok(buffer); + } + _ if status == ERROR_NOT_ENOUGH_MEMORY.0 => buffer = vec![0; size as usize], + _ => bail!("PerfQueryCounterSetRegistrationInfo failed ({status})"), + } + } +} + +/// A PerfLib query handle, closed on drop. +struct Query(HANDLE); impl Drop for Query { fn drop(&mut self) { - // SAFETY: the handle came from `PdhOpenQueryW` and is closed once. - unsafe { PdhCloseQuery(self.0) }; + // SAFETY: the handle came from `PerfOpenQueryHandle` and is closed once. + unsafe { PerfCloseQueryHandle(self.0) }; } } -fn wide(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() +/// What `PerfAddCounters` takes for a multi-instance counter set: the +/// identifier, then the instance name -- `*`, every instance -- padded to a +/// multiple of eight bytes, as in Microsoft's own sample. +#[repr(C)] +struct Specification { + identifier: PERF_COUNTER_IDENTIFIER, + instance: [u16; 4], } -/// Rendering load per process id, as a percentage summed over the engines of -/// every adapter. Values can exceed 100 on a multi-adapter or multi-engine -/// machine, which is fine: only their order matters here. -/// -/// Utilisation is a rate, so it needs two samples. `interval` is how long to -/// wait between them; a second is what Task Manager uses. -pub fn rendering_load(interval: std::time::Duration) -> Result> { - let mut handle = PDH_HQUERY::default(); - // SAFETY: a null data source means the live machine; `handle` is a valid - // out pointer, and the query it receives is owned by `Query` below. - let status = unsafe { PdhOpenQueryW(PCWSTR::null(), 0, &mut handle) }; - if status != 0 { - bail!("PdhOpenQueryW failed (0x{status:08X})"); - } - let query = Query(handle); - - let path = wide(COUNTER_PATH); - let mut counter = PDH_HCOUNTER::default(); - // SAFETY: `path` is NUL-terminated and outlives the call; the query is - // open, and the counter lives and dies with it. - let status = unsafe { PdhAddEnglishCounterW(query.0, PCWSTR(path.as_ptr()), 0, &mut counter) }; - if status != 0 { - bail!("cannot add the GPU Engine counter (0x{status:08X})"); - } - - // A rate needs a baseline and a second reading. - // SAFETY: the query is open for as long as `query` lives. - let status = unsafe { PdhCollectQueryData(query.0) }; - if status != 0 { - bail!("first PdhCollectQueryData failed (0x{status:08X})"); +impl Query { + fn open(set: &GUID, counter: u32) -> Result { + let mut handle = HANDLE::default(); + // SAFETY: a null machine is this one; `handle` is a valid out pointer, + // and the query it receives is owned by `Query` below. + let status = unsafe { PerfOpenQueryHandle(None, &mut handle) }; + if status != 0 { + bail!("PerfOpenQueryHandle failed ({status})"); + } + let query = Self(handle); + let mut specification = Specification { + identifier: PERF_COUNTER_IDENTIFIER { + CounterSetGuid: *set, + Size: size_of::() as u32, + CounterId: counter, + // Every instance id: the name filter is what selects. + InstanceId: u32::MAX, + ..Default::default() + }, + instance: [u16::from(b'*'), 0, 0, 0], + }; + let size = specification.identifier.Size; + // SAFETY: the pointer is to the whole block, whose first field is the + // identifier (`repr(C)`); the block is `size` bytes long, as the call + // is told, and lives on this frame for the whole call. + let status = unsafe { + PerfAddCounters( + query.0, + (&raw mut specification).cast::(), + size, + ) + }; + // The call can succeed while refusing the one specification it was + // given; that answer is in the block itself. + let refused = specification.identifier.Status; + if status != 0 || refused != 0 { + bail!("PerfAddCounters failed ({status}, {refused})"); + } + Ok(query) } - std::thread::sleep(interval); - // SAFETY: as above. - let status = unsafe { PdhCollectQueryData(query.0) }; - if status != 0 { - bail!("second PdhCollectQueryData failed (0x{status:08X})"); - } - - collect(counter) -} - -fn collect(counter: PDH_HCOUNTER) -> Result> { - let mut bytes = 0u32; - let mut items = 0u32; - - // First call sizes the buffer and is expected to fail with PDH_MORE_DATA. - // SAFETY: with no buffer the API only writes the two sizes. - let status = unsafe { - PdhGetFormattedCounterArrayW(counter, PDH_FMT_DOUBLE, &mut bytes, &mut items, None) - }; - if status != PDH_MORE_DATA { - bail!("sizing the counter array failed (0x{status:08X})"); - } - if items == 0 { - return Ok(HashMap::new()); - } - - // Allocated as items rather than bytes so the buffer is aligned for them. - // PDH writes the instance name strings into the tail of the same block. - let size = size_of::(); - let mut buffer: Vec = - Vec::with_capacity(bytes as usize / size + 1); - // SAFETY: the capacity is at least `bytes` bytes, which is what `bytes` - // tells the API it may write, so the items and the strings behind them - // all land inside the allocation. - let status = unsafe { - PdhGetFormattedCounterArrayW( - counter, - PDH_FMT_DOUBLE, - &mut bytes, - &mut items, - Some(buffer.as_mut_ptr()), - ) - }; - if status != 0 { - bail!("reading the counter array failed (0x{status:08X})"); - } - // SAFETY: the API initialised exactly `items` structs at the front of the - // buffer. The strings it wrote after them stay inside the capacity, so the - // `szName` pointers remain valid until `buffer` is dropped -- which is - // after the loop below. - unsafe { buffer.set_len(items as usize) }; + /// One sample, as the bytes PerfLib wrote. + fn sample(&self) -> Result> { + let mut size = 0u32; + // Eight-byte words, so the header's 64-bit fields are aligned. + let mut words: Vec = Vec::new(); + loop { + // SAFETY: the buffer is `len * 8` bytes, eight-aligned, and the + // call is told exactly that; `size` is a local. + let status = unsafe { + PerfQueryCounterData( + self.0, + Some(words.as_mut_ptr().cast::()), + (words.len() * 8) as u32, + &mut size, + ) + }; + match status { + 0 => break, + _ if status == ERROR_NOT_ENOUGH_MEMORY.0 => { + words = vec![0; (size as usize).div_ceil(8)]; + } + _ => bail!("PerfQueryCounterData failed ({status})"), + } + } + Ok(words + .iter() + .flat_map(|word| word.to_le_bytes()) + .take(size as usize) + .collect()) + } +} + +// ------------------------------------------------ the documented layouts -- + +fn u32_at(bytes: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes( + bytes.get(at..at.checked_add(4)?)?.try_into().ok()?, + )) +} + +fn u64_at(bytes: &[u8], at: usize) -> Option { + Some(u64::from_le_bytes( + bytes.get(at..at.checked_add(8)?)?.try_into().ok()?, + )) +} + +/// A NUL-terminated UTF-16LE string starting at `at`; `None` when the NUL +/// is missing, which a well-formed block never does. +fn utf16_at(bytes: &[u8], at: usize) -> Option { + let mut units = Vec::new(); + for pair in bytes.get(at..)?.as_chunks::<2>().0 { + match u16::from_le_bytes(*pair) { + 0 => return Some(String::from_utf16_lossy(&units)), + unit => units.push(unit), + } + } + None +} + +/// The counters' names: a `PERF_STRING_BUFFER_HEADER` -- its size and a +/// count -- then one `PERF_STRING_COUNTER_HEADER` per counter, an id and +/// the offset of its name from the start of the block. +fn counter_names(block: &[u8]) -> Vec<(u32, String)> { + let count = u32_at(block, 4).unwrap_or(0) as usize; + (0..count) + .map_while(|i| { + let at = 8 + i * 8; + Some((u32_at(block, at)?, u32_at(block, at + 4)?)) + }) + .filter_map(|(id, offset)| Some((id, utf16_at(block, offset as usize)?))) + .collect() +} + +/// A counter's type: a `PERF_COUNTERSET_REG_INFO` of 32 bytes -- its +/// fourth field the number of counters -- then that many +/// `PERF_COUNTER_REG_INFO` of 48 bytes, id first and type second. +fn counter_type(block: &[u8], counter: u32) -> Option { + const SET: usize = 32; + const COUNTER: usize = 48; + let count = u32_at(block, 24)? as usize; + (0..count) + .map_while(|i| { + let at = SET + i * COUNTER; + Some((u32_at(block, at)?, u32_at(block, at + 4)?)) + }) + .find_map(|(id, kind)| (id == counter).then_some(kind)) +} + +/// One sample of a single counter over every instance: when, and each +/// instance's raw value by name and id. +#[derive(Debug, PartialEq)] +struct Sample { + /// `PerfTime100NSec`, the time base of a `PERF_100NSEC_TIMER`. + time: i64, + values: HashMap<(String, u32), u64>, +} + +/// A `PERF_DATA_HEADER` of 48 bytes, then one `PERF_COUNTER_HEADER` of 16 +/// -- status, type, size -- of type `PERF_MULTIPLE_INSTANCES`: a +/// `PERF_MULTI_INSTANCES` of 8 bytes, its second field the count, then per +/// instance a `PERF_INSTANCE_HEADER` -- its size, its id, its name, padded +/// -- and a `PERF_COUNTER_DATA` -- the value's size, the block's, the value. +fn parse_sample(bytes: &[u8]) -> Result { + const DATA_HEADER: usize = 48; + const COUNTER_HEADER: usize = 16; + let bad = || anyhow::anyhow!("the counter data is not laid out as documented"); + + let total = u32_at(bytes, 0).ok_or_else(bad)? as usize; + let bytes = bytes.get(..total).ok_or_else(bad)?; + if u32_at(bytes, 4) != Some(1) { + bail!("one counter was asked for, not {:?}", u32_at(bytes, 4)); + } + let time = u64_at(bytes, 16).ok_or_else(bad)? as i64; + + let status = u32_at(bytes, DATA_HEADER).ok_or_else(bad)?; + let kind = u32_at(bytes, DATA_HEADER + 4).ok_or_else(bad)?; + if kind == PERF_ERROR_RETURN.0 as u32 { + bail!("the provider answered with error {status}"); + } + if kind != PERF_MULTIPLE_INSTANCES.0 as u32 { + bail!("unexpected result type {kind}"); + } + let end = DATA_HEADER + u32_at(bytes, DATA_HEADER + 8).ok_or_else(bad)? as usize; + let block = bytes.get(..end).ok_or_else(bad)?; + + let instances = DATA_HEADER + COUNTER_HEADER; + let count = u32_at(block, instances + 4).ok_or_else(bad)?; + let mut at = instances + 8; + let mut values = HashMap::new(); + for _ in 0..count { + // Each size covers its own header, so neither can be under eight + // bytes, and every step moves forward. + let header = u32_at(block, at).ok_or_else(bad)? as usize; + if header < 8 { + return Err(bad()); + } + let id = u32_at(block, at + 4).ok_or_else(bad)?; + let data = at.checked_add(header).ok_or_else(bad)?; + // The name ends inside its own block, or it is not a name. + let name = block + .get(..data) + .and_then(|instance| utf16_at(instance, at + 8)) + .ok_or_else(bad)?; + let value_size = u32_at(block, data).ok_or_else(bad)?; + let data_size = u32_at(block, data + 4).ok_or_else(bad)? as usize; + if data_size < 8 { + return Err(bad()); + } + let value = match value_size { + 8 => u64_at(block, data + 8), + 4 => u32_at(block, data + 8).map(u64::from), + _ => None, + } + .ok_or_else(bad)?; + values.insert((name, id), value); + at = data.checked_add(data_size).ok_or_else(bad)?; + } + Ok(Sample { time, values }) +} + +/// The rendering load per process between two samples. An instance that +/// is not in both, or whose value went backwards -- an engine that came or +/// went, which Microsoft's example drops too -- says nothing. +fn load_between(first: &Sample, second: &Sample) -> HashMap { + let elapsed = second.time - first.time; let mut load: HashMap = HashMap::new(); - for item in &buffer { - // SAFETY: `szName` points into `buffer`'s tail, still allocated and - // NUL-terminated by PDH. - let name = unsafe { item.szName.to_string() }.unwrap_or_default(); - let Some((pid, engine)) = parse_instance(&name) else { + if elapsed <= 0 { + return load; + } + for (key, &value) in &second.values { + let Some((pid, engine)) = parse_instance(&key.0) else { continue; }; if !RENDERING_ENGINES.contains(&engine.as_str()) { continue; } - // SAFETY: the array was requested as PDH_FMT_DOUBLE, so this is the - // union member PDH wrote. - let value = unsafe { item.FmtValue.Anonymous.doubleValue }; - if value.is_finite() && value > 0.0 { - *load.entry(pid).or_default() += value; + let Some(busy) = first + .values + .get(key) + .and_then(|&before| value.checked_sub(before)) + else { + continue; + }; + let share = 100.0 * busy as f64 / elapsed as f64; + if share > 0.0 { + *load.entry(pid).or_default() += share; } } - Ok(load) + load } /// Instance names look like @@ -164,6 +411,192 @@ fn parse_instance(name: &str) -> Option<(u32, String)> { mod tests { use super::*; + fn utf16(text: &str) -> Vec { + text.encode_utf16() + .chain(std::iter::once(0)) + .flat_map(u16::to_le_bytes) + .collect() + } + + fn pad(bytes: &mut Vec) { + while !bytes.len().is_multiple_of(8) { + bytes.push(0); + } + } + + /// A sample laid out as PerfLib lays one out: `time`, then each + /// instance's name, id and 8-byte value. + fn sample_bytes(time: i64, instances: &[(&str, u32, u64)]) -> Vec { + let mut body = Vec::new(); + for (name, id, value) in instances { + let mut header = vec![0; 8]; + header.extend(utf16(name)); + pad(&mut header); + let size = header.len() as u32; + header[0..4].copy_from_slice(&size.to_le_bytes()); + header[4..8].copy_from_slice(&id.to_le_bytes()); + body.extend(header); + body.extend(8u32.to_le_bytes()); + body.extend(16u32.to_le_bytes()); + body.extend(value.to_le_bytes()); + } + let mut instances_block = Vec::new(); + instances_block.extend((8 + body.len() as u32).to_le_bytes()); + instances_block.extend((instances.len() as u32).to_le_bytes()); + instances_block.extend(body); + + let mut counter = Vec::new(); + counter.extend(0u32.to_le_bytes()); + counter.extend((PERF_MULTIPLE_INSTANCES.0 as u32).to_le_bytes()); + counter.extend((16 + instances_block.len() as u32).to_le_bytes()); + counter.extend(0u32.to_le_bytes()); + counter.extend(instances_block); + + let mut bytes = Vec::new(); + bytes.extend((48 + counter.len() as u32).to_le_bytes()); + bytes.extend(1u32.to_le_bytes()); + bytes.extend(0i64.to_le_bytes()); // PerfTimeStamp + bytes.extend(time.to_le_bytes()); // PerfTime100NSec + bytes.extend(10_000_000i64.to_le_bytes()); // PerfFreq + bytes.extend([0; 16]); // SystemTime + bytes.extend(counter); + bytes + } + + const GAME_3D: &str = "pid_4242_luid_0x00000000_0x0001A2B3_phys_0_eng_0_engtype_3D"; + const GAME_COPY: &str = "pid_4242_luid_0x00000000_0x0001A2B3_phys_0_eng_5_engtype_Copy"; + const LAUNCHER_3D: &str = "pid_77_luid_0x00000000_0x0001A2B3_phys_0_eng_0_engtype_3D"; + + #[test] + fn a_sample_is_read_as_laid_out() { + let bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000), (LAUNCHER_3D, 0, 20)]); + let sample = parse_sample(&bytes).unwrap(); + assert_eq!(sample.time, 5_000); + assert_eq!(sample.values.len(), 2); + assert_eq!(sample.values[&(GAME_3D.to_owned(), 3)], 1_000); + assert_eq!(sample.values[&(LAUNCHER_3D.to_owned(), 0)], 20); + } + + #[test] + fn an_empty_sample_is_empty() { + let sample = parse_sample(&sample_bytes(1, &[])).unwrap(); + assert!(sample.values.is_empty()); + } + + #[test] + fn a_cut_sample_is_refused_not_misread() { + let bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000), (LAUNCHER_3D, 0, 20)]); + for cut in [0, 10, 48, 60, 72, 100, bytes.len() - 1] { + // The header's own total says more than there is. + assert!(parse_sample(&bytes[..cut]).is_err(), "cut at {cut}"); + } + } + + #[test] + fn a_count_larger_than_the_block_is_refused() { + let mut bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000)]); + // PERF_MULTI_INSTANCES' count, just after the two headers. + bytes[68..72].copy_from_slice(&9u32.to_le_bytes()); + assert!(parse_sample(&bytes).is_err()); + } + + #[test] + fn an_error_from_the_provider_is_an_error() { + let mut bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000)]); + bytes[52..56].copy_from_slice(&(PERF_ERROR_RETURN.0 as u32).to_le_bytes()); + assert!(parse_sample(&bytes).is_err()); + } + + #[test] + fn load_is_the_busy_share_of_the_interval() { + // One second in 100 ns units; the game's 3D engine busy 600 ms of it, + // the launcher's 5 ms, the game's copy engine all of it. + let first = parse_sample(&sample_bytes( + 0, + &[(GAME_3D, 0, 0), (GAME_COPY, 0, 0), (LAUNCHER_3D, 0, 100)], + )) + .unwrap(); + let second = parse_sample(&sample_bytes( + 10_000_000, + &[ + (GAME_3D, 0, 6_000_000), + (GAME_COPY, 0, 10_000_000), + (LAUNCHER_3D, 0, 50_100), + ], + )) + .unwrap(); + let load = load_between(&first, &second); + assert!((load[&4242] - 60.0).abs() < 1e-9, "copy engine left out"); + assert!((load[&77] - 0.5).abs() < 1e-9); + } + + #[test] + fn an_engine_that_came_or_went_says_nothing() { + let first = parse_sample(&sample_bytes(0, &[(GAME_3D, 0, 500)])).unwrap(); + let second = parse_sample(&sample_bytes( + 10_000_000, + &[(GAME_3D, 0, 100), (LAUNCHER_3D, 0, 9_000)], + )) + .unwrap(); + // The game's value went backwards, the launcher was not there before. + assert!(load_between(&first, &second).is_empty()); + } + + #[test] + fn no_time_elapsed_is_no_load() { + let sample = parse_sample(&sample_bytes(7, &[(GAME_3D, 0, 500)])).unwrap(); + assert!(load_between(&sample, &sample).is_empty()); + } + + #[test] + fn counter_names_are_read_from_their_offsets() { + let mut block = vec![0; 8 + 2 * 8]; + let first = block.len() as u32; + block.extend(utf16("Running Time")); + let second = block.len() as u32; + block.extend(utf16("Utilization Percentage")); + block[4..8].copy_from_slice(&2u32.to_le_bytes()); + for (i, (id, offset)) in [(1u32, first), (2, second)].into_iter().enumerate() { + block[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes()); + block[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes()); + } + assert_eq!( + counter_names(&block), + vec![ + (1, "Running Time".to_owned()), + (2, "Utilization Percentage".to_owned()) + ] + ); + // A name whose offset points outside the block is left out. + block[12..16].copy_from_slice(&9_999u32.to_le_bytes()); + assert_eq!(counter_names(&block).len(), 1); + } + + #[test] + fn a_counter_type_is_found_by_id() { + let mut block = vec![0; 32 + 2 * 48]; + block[24..28].copy_from_slice(&2u32.to_le_bytes()); + for (i, (id, kind)) in [(1u32, 0x0001_0100u32), (2, PERF_100NSEC_TIMER)] + .into_iter() + .enumerate() + { + let at = 32 + i * 48; + block[at..at + 4].copy_from_slice(&id.to_le_bytes()); + block[at + 4..at + 8].copy_from_slice(&kind.to_le_bytes()); + } + assert_eq!(counter_type(&block, 2), Some(PERF_100NSEC_TIMER)); + assert_eq!(counter_type(&block, 1), Some(0x0001_0100)); + assert_eq!(counter_type(&block, 3), None); + assert_eq!(counter_type(&block[..40], 2), None); + } + + #[test] + fn the_timer_type_is_the_one_registered() { + // What `GPU Engine\Utilization Percentage` declares, read on + // 2026-09-23: 0x20510500, `PERF_100NSEC_TIMER` in winperf.h. + assert_eq!(PERF_100NSEC_TIMER, 0x2051_0500); + } + #[test] fn instance_names_are_parsed() { assert_eq!( From 0bb6b5ac2f12f2420ebf09731be4e4af7db16bbb Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:53:18 +0200 Subject: [PATCH 2/8] Record Lot 16's field run on Battlefield 6 The branch build installed, sampled once a second from outside while the maintainer played Battlefield 6 and used the menu: the refinement's read through PerfLib left 18 handles and 0.24 MB and named bf6.exe at 75%, as PDH did; the menu's first opening cost 41 handles and 1.1 MB, the shell open 147 handles. A configuration fault and its fix, twice, left nothing. Co-Authored-By: Claude Opus 5.5 --- docs/design/16-footprint.md | 52 ++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md index aff7a39..f6e4a0d 100644 --- a/docs/design/16-footprint.md +++ b/docs/design/16-footprint.md @@ -7,20 +7,21 @@ counters left 3.7 MB behind and that the installed watcher had gained some at once, at the maintainer's request. - [x] The instrument: `presence-probe footprint` step by step, `menu-cost` for what one click leaves over five minutes, `gpu-load` to set the GPU reader beside Windows' own `typeperf`, and a scratch watcher driven through sessions and reloads — 2026-09-23 -- [x] Where the handles come from — 2026-09-23, below: the GPU read, the shell and the update check; sessions and reloads leave nothing +- [x] Where the handles come from — 2026-09-23, below: the GPU read, the menu itself, the shell and the update check; sessions, reloads and configuration faults leave nothing - [x] The GPU counters read through PerfLib instead of PDH — built 2026-09-23: 0.23 MB and 18 handles left instead of 3.6–3.9 MB and 19, the values checked against `typeperf` - [ ] The menu's shell opens: a proposal below, the maintainer's call - [ ] What *Check for updates* leaves: a proposal below, the maintainer's call - [ ] Measured on the whole installed process against 0.3.0: just started, after a session whose refinement reads the counters, after the menu's clicks - [ ] What the watcher costs, written for the people who use it in [How it works](../how-it-works.md), from those figures -- [ ] Verified in the field: a session whose refinement reads the counters, on the maintainer's machine +- [x] Verified in the field: a session whose refinement reads the counters, on the maintainer's machine — 2026-09-23, Battlefield 6, below: *bf6.exe (75% of the rendering)* through PerfLib, 18 handles and 0.24 MB for the read +- [ ] The menu opened a second and a third time, to tell a cost paid once from a leak **Done when** a session, a reload and each entry of the menu leave the watcher holding no more than a figure written here over what it held just started, measured on the whole installed process against 0.3.0 — and every figure the documentation gives for what the watcher costs is one measured. -## Sessions and reloads leave nothing, 2026-09-23 +## Sessions, reloads and faults leave nothing, 2026-09-23 A watcher built from this branch before any change (0.3.0, `da26e0f2`), on a scratch configuration — two-second poll, one-second stop delay, the @@ -43,6 +44,11 @@ did it again. Its size is the size of a shell call, measured below, and nothing in that run is known to have made one: recorded as not reproduced, not explained. +A configuration made unusable then fixed, twice, on the same scratch +watcher — each time two notifications, the icon red and back, the engine +frozen then built anew — left 171 handles against 169 before, and the +same private bytes, 17:16–17:18. + None of these sessions read the GPU counters. The refinement reads them only when two processes or more match the game ([Lot 3](03-game-naming.md)); a presence writer started by hand has no game, and the log said @@ -133,6 +139,41 @@ processes at the same magnitudes — 2.39, 1.65 and 1.08 % against 2.87, the four seconds compared, the two windows half a second apart. A game's load, tens of percent on one process, is the field run's to confirm. +## The field run, 2026-09-23 + +This branch's build (`ca26a358`) installed on the maintainer's machine at +17:18 through the logon task, as a release installs it, and its handles, +private bytes and threads sampled once a second from outside, a line each +time one of them changed. The maintainer played Battlefield 6 and did +what the menu offers, noting the minute of each gesture: + +| Time | What happened | handles | private | threads | +| --- | --- | --- | --- | --- | +| 17:18:40 | just started | 162 | 2.09 MB | 6, then 3 | +| 17:45:29 | *Game detected: EAAntiCheat.GameServiceLauncher.exe*, the commands run | 167 | 2.28 MB | 3 | +| 17:45:50 | *Game identified more precisely: bf6.exe (75% of the rendering)* — the counters read through PerfLib | 185 | 2.52 MB | 3 | +| 17:47 | the pointer on the icon, its tooltip shown | 184 | 2.52 MB | 3 | +| 17:48:11 | the menu opened, then closed by a click beside it | **225** | **3.62 MB** | 6 | +| 17:49:06 | *Edit configuration*, VS Code opened, closed unsaved | **373** | 4.36 MB | 12 | +| 17:50 | `gamemode-executor status` in a terminal, another process | 365 | 4.23 MB | 8 | +| 17:51:59 | *Game no longer detected: bf6.exe*, the stop commands run | 363 | 4.16 MB | 6 | + +The refinement's read is the one the probe measured: 18 handles and +0.24 MB, and a name as sure as PDH's — the same game read *75% of the +rendering* on 2026-09-16 through PDH ([Lot 3](03-game-naming.md)). The +shell open is too: 147 handles. What the probe had not measured is the +menu itself: **41 handles and 1.1 MB** the first time it is shown. The +menu is plain — strings, `TrackPopupMenuEx`, the foreground window its +documentation requires, dark by `uxtheme` — so the cost is Windows' own, +for a process's first menu; whether a second opening adds to it is the +next thing to measure. The tooltip costs nothing: the shell draws it, in +its own process. + +Those steps, had they all happened that day, would make some 215 of the +261 handles Lot 15 read (424 − 163): 41 for the menu, some 150 for a shell +open, 19 for PDH, a few for naming. Inferred, not measured — which of them +did happen then is not recorded, and the rest stays untraced. + ## Proposed, for the maintainer's decision **The menu's shell opens, through a short-lived helper.** *Edit @@ -146,6 +187,11 @@ open's outcome read from the helper's exit code, some 30 ms on a click. Recommended: it is the larger of the two costs a player can see in Task Manager, and the code it takes is small and ordinary. +**The menu itself, left as it is.** Its 41 handles and 1.1 MB are what +Windows takes to show a process's first menu; a menu cannot be shown +without them, and no other road to the same menu is cheaper. Unless the +second opening shows a leak, nothing to do. + **What *Check for updates* leaves, left as it is.** 111 handles and 1 MB after the threads expire. The check is a click made once a release; a successful update replaces the watcher anyway; moving the check into a From 57edd8a7f48d1a6abcc5fb4a5f39d6855e479f8b Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:02:10 +0200 Subject: [PATCH 3/8] Record that the menu's cost is paid once Opened and closed again at 18:00 and 18:01, the menu left the watcher at 357 handles both times, its private bytes 4.18 then 4.22 MB: the 41 handles and 1.1 MB of its first opening are Windows' cost for a process's first menu, not a leak. Co-Authored-By: Claude Opus 5.5 --- docs/design/16-footprint.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md index f6e4a0d..b45666e 100644 --- a/docs/design/16-footprint.md +++ b/docs/design/16-footprint.md @@ -14,7 +14,7 @@ at once, at the maintainer's request. - [ ] Measured on the whole installed process against 0.3.0: just started, after a session whose refinement reads the counters, after the menu's clicks - [ ] What the watcher costs, written for the people who use it in [How it works](../how-it-works.md), from those figures - [x] Verified in the field: a session whose refinement reads the counters, on the maintainer's machine — 2026-09-23, Battlefield 6, below: *bf6.exe (75% of the rendering)* through PerfLib, 18 handles and 0.24 MB for the read -- [ ] The menu opened a second and a third time, to tell a cost paid once from a leak +- [x] The menu opened a second and a third time, to tell a cost paid once from a leak — 2026-09-23, 18:00 and 18:01: nothing more, below **Done when** a session, a reload and each entry of the menu leave the watcher holding no more than a figure written here over what it held just @@ -165,9 +165,13 @@ shell open is too: 147 handles. What the probe had not measured is the menu itself: **41 handles and 1.1 MB** the first time it is shown. The menu is plain — strings, `TrackPopupMenuEx`, the foreground window its documentation requires, dark by `uxtheme` — so the cost is Windows' own, -for a process's first menu; whether a second opening adds to it is the -next thing to measure. The tooltip costs nothing: the shell draws it, in -its own process. +for a process's first menu. Paid once: the maintainer opened and closed +the menu again at 18:00 and at 18:01, and the watcher read 357 handles +before and after both, its private bytes 4.18 then 4.22 MB. The tooltip +costs nothing: the shell draws it, in its own process. + +Between the session's end and 17:57 the count went down from 363 to 357 +by itself, as the thread pool let its idle threads go. Those steps, had they all happened that day, would make some 215 of the 261 handles Lot 15 read (424 − 163): 41 for the menu, some 150 for a shell @@ -189,8 +193,8 @@ Manager, and the code it takes is small and ordinary. **The menu itself, left as it is.** Its 41 handles and 1.1 MB are what Windows takes to show a process's first menu; a menu cannot be shown -without them, and no other road to the same menu is cheaper. Unless the -second opening shows a leak, nothing to do. +without them, and no other road to the same menu is cheaper. The second +and third openings added nothing: a cost paid once, nothing to do. **What *Check for updates* leaves, left as it is.** 111 handles and 1 MB after the threads expire. The check is a click made once a release; a From 1c8c3ec256b9271e8396b5292623289b29bb25e5 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:05:02 +0200 Subject: [PATCH 4/8] Measure the menu with and without dark menus presence-probe menu-cost gains menu and menu-dark: the tray's menu on a hidden window of a fresh process, shown three times and closed by a timer, with or without the SetPreferredAppMode call the tray makes at start. Both cost 55 handles and some 0.7 MB the first time and nothing after; the call itself costs a handle. The menu's cost is Windows', not the undocumented call's. Co-Authored-By: Claude Opus 5.5 --- docs/design/16-footprint.md | 18 +++++ src/bin/presence-probe.rs | 134 +++++++++++++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md index b45666e..844cd7b 100644 --- a/docs/design/16-footprint.md +++ b/docs/design/16-footprint.md @@ -170,6 +170,24 @@ the menu again at 18:00 and at 18:01, and the watcher read 357 handles before and after both, its private bytes 4.18 then 4.22 MB. The tooltip costs nothing: the shell draws it, in its own process. +**Not the dark theme.** The maintainer asked whether the undocumented +`SetPreferredAppMode` was the cause. `presence-probe menu-cost menu` and +`menu-dark` build the tray's menu on a hidden window of a fresh process and +show it three times, each closed by a timer after a second, the second +variant after the call the tray makes at start. Twice each, 18:10: + +| | the call itself | first menu | second and third | +| --- | --- | --- | --- | +| light | — | +55 handles, +0.63 and +0.71 MB | nothing | +| dark | +1 handle, +0.03 MB | +55 handles, +0.70 and +0.71 MB | nothing | + +The same cost either way, within the noise of private bytes; the call +costs a handle. The figures differ from the watcher's, 41 handles and +1.1 MB, because the watcher had loaded some of it before and the probe's +window never became the foreground one — it gained no thread where the +watcher gained three — but the comparison is between two runs of the same +probe, and it answers the question. + Between the session's end and 17:57 the count went down from 363 to 357 by itself, as the thread pool let its idle threads go. diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 07e0a3b..1108790 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -23,8 +23,9 @@ //! several ways of being told the game list changed, at once //! presence-probe cost [rounds] time what an idle poll costs, today and with Lot 15 //! presence-probe footprint what each step of the watcher leaves in memory and handles -//! presence-probe menu-cost -//! what one click of the menu leaves, over five minutes +//! presence-probe menu-cost +//! what one click of the menu leaves, over five minutes, +//! or the menu itself, light or dark, shown three times //! presence-probe gpu-load [ms] [rounds] //! the rendering load the refinement reads, busiest first //! presence-probe microsoft-list ... @@ -1086,6 +1087,10 @@ fn cmd_menu_cost(click: &str) -> windows::core::Result<()> { }; say("before"); match click { + "menu" | "menu-dark" => { + show_menus(click == "menu-dark", &say); + return Ok(()); + } "open" => { // SAFETY: every string is a NUL-terminated literal. unsafe { @@ -1112,7 +1117,9 @@ fn cmd_menu_cost(click: &str) -> windows::core::Result<()> { .status(); } other => { - eprintln!("usage: presence-probe menu-cost , not `{other}`"); + eprintln!( + "usage: presence-probe menu-cost , not `{other}`" + ); std::process::exit(2); } } @@ -1123,6 +1130,127 @@ fn cmd_menu_cost(click: &str) -> windows::core::Result<()> { Ok(()) } +/// The tray's menu, built the tray's way on a hidden window of this process +/// and shown three times, each closed by a timer after a second -- with or +/// without asking uxtheme for dark menus first, as the tray does at start. +/// It is on screen for that second. +fn show_menus(dark: bool, say: &dyn Fn(&str)) { + use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, WPARAM}; + use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW}; + use windows::Win32::UI::WindowsAndMessaging::{ + AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, + DestroyWindow, EndMenu, GetCursorPos, KillTimer, MF_DISABLED, MF_GRAYED, MF_SEPARATOR, + MF_STRING, PostMessageW, RegisterClassW, SetForegroundWindow, SetTimer, TPM_NONOTIFY, + TPM_RETURNCMD, TPM_RIGHTBUTTON, TrackPopupMenuEx, WINDOW_EX_STYLE, WM_NULL, WNDCLASSW, + WS_POPUP, + }; + use windows::core::{PCSTR, w}; + + extern "system" fn procedure(window: HWND, message: u32, w: WPARAM, l: LPARAM) -> LRESULT { + // SAFETY: the arguments are the ones Windows passed in. + unsafe { DefWindowProcW(window, message, w, l) } + } + extern "system" fn close(_: HWND, _: u32, _: usize, _: u32) { + // SAFETY: no arguments; ends whatever menu this thread shows. + let _ = unsafe { EndMenu() }; + } + + if dark { + // The tray's call, as `tray::dark` makes it: ordinals 135 and 136. + // SAFETY: the name is a literal; the pointers resolved by ordinal are + // called with the signatures the tray uses on this build. + unsafe { + if let Ok(uxtheme) = LoadLibraryW(w!("uxtheme.dll")) { + if let Some(set) = GetProcAddress(uxtheme, PCSTR(135 as *const u8)) { + let set: unsafe extern "system" fn(i32) -> i32 = std::mem::transmute(set); + set(1); + } + if let Some(flush) = GetProcAddress(uxtheme, PCSTR(136 as *const u8)) { + let flush: unsafe extern "system" fn() = std::mem::transmute(flush); + flush(); + } + } + } + say("after SetPreferredAppMode"); + } + + // SAFETY: the class name is a literal, the procedure lives for the + // process, and the window is destroyed at the end. + let window = unsafe { + let instance = GetModuleHandleW(None).unwrap_or_default(); + let class = WNDCLASSW { + lpfnWndProc: Some(procedure), + hInstance: instance.into(), + lpszClassName: w!("presence-probe-menu"), + ..Default::default() + }; + RegisterClassW(&class); + CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("presence-probe-menu"), + w!(""), + WS_POPUP, + 0, + 0, + 0, + 0, + None, + None, + Some(instance.into()), + None, + ) + }; + let Ok(window) = window else { + println!("no window"); + return; + }; + say("after creating a hidden window"); + + for round in 1..=3 { + // SAFETY: as the tray's `show_menu`: the strings are literals, the menu + // is destroyed on every path, the timer is killed after the menu + // closed, and nothing is borrowed across the modal loop. + unsafe { + let Ok(menu) = CreatePopupMenu() else { + return; + }; + let _ = AppendMenuW( + menu, + MF_STRING | MF_DISABLED | MF_GRAYED, + 0, + w!("GameModeExecutor - no game detected"), + ); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, None); + let _ = AppendMenuW(menu, MF_STRING, 1, w!("Edit configuration")); + let _ = AppendMenuW(menu, MF_STRING, 2, w!("Open log")); + let _ = AppendMenuW(menu, MF_STRING, 3, w!("Documentation")); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, None); + let _ = AppendMenuW(menu, MF_STRING, 4, w!("Check for updates")); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, None); + let _ = AppendMenuW(menu, MF_STRING, 5, w!("Quit")); + let mut at = POINT::default(); + let _ = GetCursorPos(&mut at); + let timer = SetTimer(None, 0, 1000, Some(close)); + let _ = SetForegroundWindow(window); + let _ = TrackPopupMenuEx( + menu, + TPM_RIGHTBUTTON.0 | TPM_RETURNCMD.0 | TPM_NONOTIFY.0, + at.x, + at.y, + window, + None, + ); + let _ = PostMessageW(Some(window), WM_NULL, WPARAM(0), LPARAM(0)); + let _ = KillTimer(None, timer); + let _ = DestroyMenu(menu); + } + std::thread::sleep(std::time::Duration::from_secs(2)); + say(&format!("after the menu, shown and closed, {round}")); + } + // SAFETY: created above, destroyed once. + let _ = unsafe { DestroyWindow(window) }; +} + fn main() -> windows::core::Result<()> { let seconds = || { std::env::args() From ecb6ff1fcdf4cf9638dd2c8247e5e84de6c3b84a Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:05:32 +0200 Subject: [PATCH 5/8] Format the probe's menu measurement The previous commit went in with cargo fmt --check failing on it; the test script is green again. Co-Authored-By: Claude Opus 5.5 --- src/bin/presence-probe.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 1108790..e85174d 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -1138,11 +1138,10 @@ fn show_menus(dark: bool, say: &dyn Fn(&str)) { use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, WPARAM}; use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW}; use windows::Win32::UI::WindowsAndMessaging::{ - AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, - DestroyWindow, EndMenu, GetCursorPos, KillTimer, MF_DISABLED, MF_GRAYED, MF_SEPARATOR, - MF_STRING, PostMessageW, RegisterClassW, SetForegroundWindow, SetTimer, TPM_NONOTIFY, - TPM_RETURNCMD, TPM_RIGHTBUTTON, TrackPopupMenuEx, WINDOW_EX_STYLE, WM_NULL, WNDCLASSW, - WS_POPUP, + AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow, + EndMenu, GetCursorPos, KillTimer, MF_DISABLED, MF_GRAYED, MF_SEPARATOR, MF_STRING, + PostMessageW, RegisterClassW, SetForegroundWindow, SetTimer, TPM_NONOTIFY, TPM_RETURNCMD, + TPM_RIGHTBUTTON, TrackPopupMenuEx, WINDOW_EX_STYLE, WM_NULL, WNDCLASSW, WS_POPUP, }; use windows::core::{PCSTR, w}; From 1aab95f64cd9974ccc7f04332dcbbd48b19fe41b Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:42:55 +0200 Subject: [PATCH 6/8] Open the menu's entries in a helper process that ends ShellExecute leaves the shell's machinery in its caller for the rest of the process's life: 141 handles and 1.2 MB after a first open, and 84 handles more when the log was opened after the configuration. The menu's four entries now start the watcher's own executable with a hidden `open` command, which makes the one call and exits; the watcher keeps what a plain CreateProcess keeps, two handles. The helper does what Microsoft documents for a caller that exits right after the call: COM as a single-threaded apartment first, and ShellExecuteExW with SEE_MASK_NOASYNC, plus SEE_MASK_FLAG_LOG_USAGE for a launch the user asked for. A file with no associated program still opens in Notepad. The watcher passes the helper the right to bring its window to the front and says in the log how the open went. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + Cargo.toml | 3 + docs/design/06-notification-icon.md | 2 +- docs/design/16-footprint.md | 24 ++++- docs/reference.md | 1 + src/cli.rs | 29 ++++++ src/lib.rs | 1 + src/open.rs | 135 ++++++++++++++++++++++++++++ src/tray.rs | 57 ++---------- 9 files changed, 205 insertions(+), 51 deletions(-) create mode 100644 src/open.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index da7ae42..4a74f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ a section is written. - Telling a game from its launcher by what the graphics card is drawing no longer adds some 3.5 MB to the watcher's memory for the rest of its run: the counters are read the lighter way Windows offers for them. +- *Edit configuration*, *Open log* and *Documentation* no longer leave some + 1.2 MB in the watcher for the rest of its run, and more for each kind of + file: a short-lived helper opens them and takes that cost with it when it + ends. ## [0.3.0] - 2026-09-23 diff --git a/Cargo.toml b/Cargo.toml index 25bf08d..932f2c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ windows = { version = "0.62", features = [ # MsiEnumRelatedProducts: whether the installer owns the executables, which # decides how `purge` removes them. "Win32_System_ApplicationInstallationAndServicing", + # CoInitializeEx: the apartment Microsoft asks for before ShellExecuteEx, + # in the helper that opens what the menu offers. + "Win32_System_Com", # The updater: WinHTTP for the release, CNG for the SHA-256 of what it # fetched. Microsoft's libraries, no HTTP or hashing crate. "Win32_Networking_WinHttp", diff --git a/docs/design/06-notification-icon.md b/docs/design/06-notification-icon.md index e72ec44..3f0bfa1 100644 --- a/docs/design/06-notification-icon.md +++ b/docs/design/06-notification-icon.md @@ -11,7 +11,7 @@ exits cleanly, and the icon survives killing and restarting Explorer. Closed - [x] `Shell_NotifyIcon` with `NOTIFYICON_VERSION_4`, on the Lot 5 window - [x] Context menu: edit configuration · open log · documentation · quit -- [x] All three opened through `ShellExecuteW`, with a Notepad fallback when nothing claims `.toml` +- [x] All three opened through `ShellExecuteW`, with a Notepad fallback when nothing claims `.toml` — in a helper process that ends since 2026-09-23, for what the shell leaves in its caller ([Lot 16](16-footprint.md)) - [x] The documentation entry opens the build's own commit on GitHub - [x] Re-added on `TaskbarCreated`, so an Explorer restart does not lose it - [x] Follows the taskbar theme, and re-reads it when the menu is about to open diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md index 844cd7b..a3cebb7 100644 --- a/docs/design/16-footprint.md +++ b/docs/design/16-footprint.md @@ -9,8 +9,9 @@ at once, at the maintainer's request. - [x] The instrument: `presence-probe footprint` step by step, `menu-cost` for what one click leaves over five minutes, `gpu-load` to set the GPU reader beside Windows' own `typeperf`, and a scratch watcher driven through sessions and reloads — 2026-09-23 - [x] Where the handles come from — 2026-09-23, below: the GPU read, the menu itself, the shell and the update check; sessions, reloads and configuration faults leave nothing - [x] The GPU counters read through PerfLib instead of PDH — built 2026-09-23: 0.23 MB and 18 handles left instead of 3.6–3.9 MB and 19, the values checked against `typeperf` -- [ ] The menu's shell opens: a proposal below, the maintainer's call -- [ ] What *Check for updates* leaves: a proposal below, the maintainer's call +- [x] The menu's shell opens: a proposal below, the maintainer's call — taken 2026-09-23, built the same night: `open`, a hidden command the watcher starts its own executable with +- [ ] The helper verified from the menu: the file, the log and the documentation open, in front, and the watcher keeps what a plain `CreateProcess` keeps +- [x] What *Check for updates* leaves: a proposal below, the maintainer's call — left as it is, 2026-09-23 - [ ] Measured on the whole installed process against 0.3.0: just started, after a session whose refinement reads the counters, after the menu's clicks - [ ] What the watcher costs, written for the people who use it in [How it works](../how-it-works.md), from those figures - [x] Verified in the field: a session whose refinement reads the counters, on the maintainer's machine — 2026-09-23, Battlefield 6, below: *bf6.exe (75% of the rendering)* through PerfLib, 18 handles and 0.24 MB for the read @@ -198,6 +199,25 @@ did happen then is not recorded, and the rest stays untraced. ## Proposed, for the maintainer's decision +Both taken as recommended on 2026-09-23, after the maintainer had opened +the log from the menu at 23:30 and the watcher gained 84 handles more than +the configuration's opening had left: a first open of another kind of file +pays again. + +**The helper as built.** The menu's four entries call `open::open`, which +starts the watcher's own executable with `open `, no console, its +standard handles closed, passes it the right to bring a window to the +front (`AllowSetForegroundWindow` — the menu made the watcher the +foreground process), and returns; a thread waits for the helper's exit to +say in the log how it went. The helper does what Microsoft documents for a +caller that exits right after: COM as a single-threaded apartment before +the shell is called, and `ShellExecuteExW` with `SEE_MASK_NOASYNC`, which +the documentation requires of a process that terminates soon after the +call, plus `SEE_MASK_FLAG_LOG_USAGE`, which it asks of a launch the user +asked for. A file the shell has no program for opens in Notepad, as +before. + + **The menu's shell opens, through a short-lived helper.** *Edit configuration*, *Open log folder*, *Documentation* and the release page all go through `ShellExecuteW`, and the first of them leaves 141 handles diff --git a/docs/reference.md b/docs/reference.md index 8a61031..13caecd 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -42,6 +42,7 @@ way. | `update [--check]` | Look for a newer release on GitHub; with `--check`, say so and stop. Otherwise download it, verify it against the release's `SHA256SUMS.txt` and install it the way the package or the zip's shell does — a running watcher hands its game session to the new one. The one command that connects to anything. Logged under `update`. | | `stop [--handover]` | Stop the running watcher the way *Quit* in its menu does — mid-game, the stop commands run on the way out — and wait until it has gone. With `--handover` an open game session is left to the watcher that follows: the stop commands do not run, and the next start resumes the session with nothing run twice — for an update or an upgrade, where one follows within seconds. None running is not an error. The task is left alone; `install-task` starts it again. The installer runs this before removing (plain) or replacing (`--handover`) the executables. Logged under `setup`. | | `purge [--yes]` | Remove every trace of the program: the logon task, the configuration, the log, the session marker, the executables. It lists what it will remove and asks; `--yes` is for scripts. Refuses while a game is running. See [Removing it](how-it-works.md#removing-it). | +| `open ` | Not in the help, because it is the menu's own: *Edit configuration*, *Open log*, *Documentation* and a release page start the watcher's executable with it, so that what the shell loads to open a file stays in a process that ends rather than in the watcher. Opens `` the way Explorer would — a file no program is associated with, in Notepad — and exits: 0 when it opened, 1 when nothing could. | Global options: `--config `, `--log-level `, `--version`. diff --git a/src/cli.rs b/src/cli.rs index c78ca70..1b1096a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -126,6 +126,14 @@ pub enum Command { #[arg(long)] yes: bool, }, + /// Open a file, a folder or an address the way the shell does, then + /// exit. What the watcher's menu runs, so that the shell's cost ends + /// with this process instead of staying in the watcher. + #[command(hide = true)] + Open { + /// What to open. + what: String, + }, } #[derive(Copy, Clone, Debug, ValueEnum)] @@ -175,6 +183,7 @@ pub fn run(cli: Cli, console: bool) -> Result<()> { } Some(Command::Check { path, pid }) => return check(path.as_deref(), pid), Some(Command::Purge { yes }) => return purge_command(cli.config, yes), + Some(Command::Open { what }) => return crate::open::open_here(&what), _ => {} } @@ -642,6 +651,26 @@ mod tests { )); } + #[test] + fn the_menus_helper_takes_what_to_open_and_is_not_offered() { + // A path with spaces arrives whole: the watcher passes it as one + // argument, quoted by std for `CommandLineToArgvW`, and std reads + // it back the same way. + match parse(&["open", r"C:\Users\A B\config.toml"]).command { + Some(Command::Open { what }) => assert_eq!(what, r"C:\Users\A B\config.toml"), + other => panic!("{other:?}"), + } + use clap::CommandFactory; + let help = Cli::command().render_help().to_string(); + assert!( + !help + .lines() + .any(|line| line.trim_start().starts_with("open ")), + "{help}" + ); + assert!(help.contains("purge"), "the list the check reads: {help}"); + } + #[test] fn check_takes_a_path_or_a_pid_but_not_both() { assert!(matches!( diff --git a/src/lib.rs b/src/lib.rs index eb2015b..3445873 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod engine; pub mod exit; pub mod logging; pub mod marker; +pub mod open; pub mod package; pub mod purge; pub mod registry; diff --git a/src/open.rs b/src/open.rs new file mode 100644 index 0000000..ecf8f93 --- /dev/null +++ b/src/open.rs @@ -0,0 +1,135 @@ +//! Opening a file, a folder or an address the way the user's settings say, +//! for the entries of the menu -- in a helper process that ends. +//! +//! `ShellExecute` loads the shell's machinery into the process that calls it +//! and leaves it there: 141 handles and 1.2 MB for the rest of the process's +//! life, measured on 2026-09-23, and a first open of another kind of file +//! adds more (`docs/design/16-footprint.md`). The watcher lives for days, so +//! it never calls the shell itself: it starts its own executable with the +//! hidden `open` command, which makes that one call and exits, and the cost +//! goes with it. A plain `CreateProcess` leaves two handles. +//! +//! The helper does what Microsoft documents for a caller that exits right +//! after: COM initialised as a single-threaded apartment before the shell is +//! called, and `ShellExecuteExW` with `SEE_MASK_NOASYNC`, so that the launch +//! has finished when the process ends. +//! + +use std::path::Path; + +use anyhow::{Context, Result}; +use windows::Win32::System::Com::{ + COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE, CoInitializeEx, CoUninitialize, +}; +use windows::Win32::UI::Shell::{ + SEE_MASK_FLAG_LOG_USAGE, SEE_MASK_NOASYNC, SHELLEXECUTEINFOW, ShellExecuteExW, +}; +use windows::Win32::UI::WindowsAndMessaging::{AllowSetForegroundWindow, SW_SHOWNORMAL}; +use windows::core::PCWSTR; + +use crate::logging::target; + +/// From the watcher: have the helper open `what`, and say in the log how +/// it went once it has. Returns at once; the menu is not held up. +pub fn open(what: &str) { + use std::os::windows::process::CommandExt; + /// CREATE_NO_WINDOW: no console, should the watcher be the console twin. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let spawned = std::env::current_exe() + .context("cannot find this executable") + .and_then(|exe| { + std::process::Command::new(exe) + .arg("open") + .arg(what) + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("cannot start the helper") + }); + let mut helper = match spawned { + Ok(helper) => helper, + Err(error) => { + tracing::warn!( + target: target::WATCHER, + what, + error = %format!("{error:#}"), + "Could not open it: the helper that opens files did not start" + ); + return; + } + }; + // The window the helper opens is the user's answer to a click, and should + // come to the front. The watcher may pass that right on: the menu made it + // the foreground process. + // SAFETY: a process id, no pointers. + let _ = unsafe { AllowSetForegroundWindow(helper.id()) }; + let what = what.to_owned(); + std::thread::spawn(move || match helper.wait() { + Ok(status) if status.success() => { + tracing::debug!(target: target::WATCHER, what, "Opened"); + } + Ok(status) => tracing::warn!( + target: target::WATCHER, + what, + code = status.code(), + "Could not open it: the shell refused, and so did Notepad" + ), + Err(error) => tracing::debug!( + target: target::WATCHER, + what, + error = %error, + "The helper that opens files could not be waited for" + ), + }); +} + +/// In the helper: open `what` through the shell, or -- for a file the +/// shell has no program for, the likely miss being a `.toml` -- in Notepad, +/// since a menu entry that silently does nothing is worse than a plain +/// editor. +pub fn open_here(what: &str) -> Result<()> { + // SAFETY: no pointers; paired with the uninitialise below, on this thread. + let com = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) }; + let opened = execute(what, None).or_else(|refused| { + if Path::new(what).is_file() { + execute("notepad.exe", Some(&format!("\"{what}\""))) + } else { + Err(refused) + } + }); + if com.is_ok() { + // SAFETY: COM was initialised above on this thread, and nothing of + // it is used after this point. + unsafe { CoUninitialize() }; + } + opened +} + +fn execute(file: &str, parameters: Option<&str>) -> Result<()> { + let verb = wide("open"); + let file_wide = wide(file); + let parameters_wide = parameters.map(wide); + let mut info = SHELLEXECUTEINFOW { + cbSize: size_of::() as u32, + // NOASYNC: this process ends right after. LOG_USAGE: a launch the + // user asked for, as Microsoft asks such calls to say. + fMask: SEE_MASK_NOASYNC | SEE_MASK_FLAG_LOG_USAGE, + lpVerb: PCWSTR(verb.as_ptr()), + lpFile: PCWSTR(file_wide.as_ptr()), + lpParameters: parameters_wide + .as_ref() + .map_or(PCWSTR::null(), |p| PCWSTR(p.as_ptr())), + nShow: SW_SHOWNORMAL.0, + ..Default::default() + }; + // SAFETY: the structure carries its own size, and every string it points + // to is NUL-terminated and outlives the call. + unsafe { ShellExecuteExW(&mut info) }.with_context(|| format!("the shell refused `{file}`")) +} + +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} diff --git a/src/tray.rs b/src/tray.rs index b6adc6a..730214a 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -27,14 +27,14 @@ use windows::Win32::UI::Shell::{ NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIIF_ERROR, NIIF_INFO, NIIF_NOSOUND, NIIF_RESPECT_QUIET_TIME, NIM_ADD, NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NOTIFY_ICON_DATA_FLAGS, NOTIFY_ICON_INFOTIP_FLAGS, NOTIFYICON_VERSION_4, NOTIFYICONDATAW, - Shell_NotifyIconW, ShellExecuteW, + Shell_NotifyIconW, }; use windows::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CreateIconFromResourceEx, CreatePopupMenu, DestroyIcon, DestroyMenu, GetSystemMetrics, HICON, IMAGE_FLAGS, LR_DEFAULTCOLOR, MF_DISABLED, MF_GRAYED, MF_SEPARATOR, - MF_STRING, PostMessageW, RegisterWindowMessageW, SM_CXSMICON, SM_CYSMICON, SW_SHOWNORMAL, - SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, TrackPopupMenuEx, WM_APP, - WM_CONTEXTMENU, WM_DPICHANGED, WM_NULL, WM_SETTINGCHANGE, + MF_STRING, PostMessageW, RegisterWindowMessageW, SM_CXSMICON, SM_CYSMICON, SetForegroundWindow, + TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, TrackPopupMenuEx, WM_APP, WM_CONTEXTMENU, + WM_DPICHANGED, WM_NULL, WM_SETTINGCHANGE, }; use windows::core::PCWSTR; @@ -955,7 +955,7 @@ fn run_update_action(action: crate::update::Action) { url, "Opening the release page" ); - open(&url, None); + crate::open::open(&url); } other => crate::update::perform(other), } @@ -964,8 +964,8 @@ fn run_update_action(action: crate::update::Action) { fn run_command(id: usize) { match id { ID_CONFIG | ID_LOG => { - // Copy the path out, then let go: ShellExecuteW can show UI of its - // own, which pumps messages like anything else. + // Copy the path out, then let go. The helper that opens it runs + // in its own process; nothing here waits on it. let path = TRAY.with(|cell| { cell.borrow().as_ref().map(|tray| { if id == ID_CONFIG { @@ -976,11 +976,11 @@ fn run_command(id: usize) { }) }); if let Some(path) = path { - open_path(&path); + crate::open::open(&path.to_string_lossy()); } } ID_DOCS => { - open(crate::build_info::DOCS_URL, None); + crate::open::open(crate::build_info::DOCS_URL); } ID_QUIT => { let stop = TRAY.with(|cell| cell.borrow().as_ref().map(|tray| Arc::clone(&tray.stop))); @@ -998,45 +998,6 @@ fn run_command(id: usize) { } } -/// Open a file the way the user's own settings say to, falling back to Notepad. -fn open_path(path: &std::path::Path) { - let target = path.to_string_lossy().into_owned(); - if open(&target, None) { - return; - } - // A `.toml` with no association is the likely miss, and a menu entry that - // silently does nothing is worse than one that opens a plain editor. - tracing::debug!( - target: crate::logging::target::WATCHER, - path = %path.display(), - "No association for this file, opening it in Notepad" - ); - open("notepad.exe", Some(&format!("\"{target}\""))); -} - -/// Returns false when the shell refused. `ShellExecuteW` hands back a fake -/// `HINSTANCE` whose value is an error code at or below 32. -fn open(target: &str, arguments: Option<&str>) -> bool { - let verb = wide("open"); - let target = wide(target); - let arguments = arguments.map(wide); - // SAFETY: every string is NUL-terminated and outlives the call, and - // nothing in the tray is borrowed while the shell may show UI. - let result = unsafe { - ShellExecuteW( - None, - PCWSTR(verb.as_ptr()), - PCWSTR(target.as_ptr()), - arguments - .as_ref() - .map_or(PCWSTR::null(), |a| PCWSTR(a.as_ptr())), - PCWSTR::null(), - SW_SHOWNORMAL, - ) - }; - result.0 as usize > 32 -} - /// Build an `HICON` at the size the shell asks for, from the compiled-in file. fn load_icon(state: State, theme: Theme, window: HWND) -> Result { let data = ICONS From e8e991a8b2dd9f58acc9c490305b774c934e0a03 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:47:36 +0200 Subject: [PATCH 7/8] Record the helper in the field and what the watcher costs The helper's build installed and used from the menu at 23:44: the configuration, the log and the documentation opened in front, three handles kept for the three where the build without it had kept 147 and 84. How it works gains a section with the measured figures, and the lot page a table against 0.3.0, saying where 0.3.0's side comes from. Co-Authored-By: Claude Opus 5.5 --- docs/design/16-footprint.md | 49 ++++++++++++++++++++++++++++++++++--- docs/how-it-works.md | 19 ++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md index a3cebb7..7bae675 100644 --- a/docs/design/16-footprint.md +++ b/docs/design/16-footprint.md @@ -10,10 +10,10 @@ at once, at the maintainer's request. - [x] Where the handles come from — 2026-09-23, below: the GPU read, the menu itself, the shell and the update check; sessions, reloads and configuration faults leave nothing - [x] The GPU counters read through PerfLib instead of PDH — built 2026-09-23: 0.23 MB and 18 handles left instead of 3.6–3.9 MB and 19, the values checked against `typeperf` - [x] The menu's shell opens: a proposal below, the maintainer's call — taken 2026-09-23, built the same night: `open`, a hidden command the watcher starts its own executable with -- [ ] The helper verified from the menu: the file, the log and the documentation open, in front, and the watcher keeps what a plain `CreateProcess` keeps +- [x] The helper verified from the menu: the file, the log and the documentation open, in front, and the watcher keeps what a plain `CreateProcess` keeps — 2026-09-23, 23:44, below: three handles for the three - [x] What *Check for updates* leaves: a proposal below, the maintainer's call — left as it is, 2026-09-23 -- [ ] Measured on the whole installed process against 0.3.0: just started, after a session whose refinement reads the counters, after the menu's clicks -- [ ] What the watcher costs, written for the people who use it in [How it works](../how-it-works.md), from those figures +- [x] Measured on the whole installed process against 0.3.0: just started, after a session whose refinement reads the counters, after the menu's clicks — 2026-09-23, the table at the end, with where 0.3.0's side comes from +- [x] What the watcher costs, written for the people who use it in [How it works](../how-it-works.md), from those figures — 2026-09-23 - [x] Verified in the field: a session whose refinement reads the counters, on the maintainer's machine — 2026-09-23, Battlefield 6, below: *bf6.exe (75% of the rendering)* through PerfLib, 18 handles and 0.24 MB for the read - [x] The menu opened a second and a third time, to tell a cost paid once from a leak — 2026-09-23, 18:00 and 18:01: nothing more, below @@ -218,6 +218,24 @@ asked for. A file the shell has no program for opens in Notepad, as before. +**Verified on the maintainer's machine, 2026-09-23.** The helper's build +(`1aab95f6`) installed at 23:43 through the logon task and sampled as +before; the maintainer opened the menu and chose the three entries in +turn, and saw each window come to the front, no console: + +| Time | What happened | handles | private | +| --- | --- | --- | --- | +| 23:43:51 | just started, idle | 162 | 1.93 MB | +| 23:44:29 | the menu opened | 214 | 2.62 MB | +| 23:44:32 | *Edit configuration*: VS Code, *Opened* in the log | 216 | 2.67 MB | +| 23:44:42 | *Open log*: Notepad | 216 | | +| 23:44:50 | *Documentation*: the browser | 217 | 2.77 MB | + +Three handles for the three, where the build without the helper had kept +147 for the configuration alone and 84 more for the log. The menu's own +first opening read 52 handles this time and 0.7 MB, against 41 and 1.1 MB +on the earlier build: Windows' cost, and not the same each time. + **The menu's shell opens, through a short-lived helper.** *Edit configuration*, *Open log folder*, *Documentation* and the release page all go through `ShellExecuteW`, and the first of them leaves 141 handles @@ -240,3 +258,28 @@ successful update replaces the watcher anyway; moving the check into a helper would mean handing its verdict back across processes — a second protocol, for a figure the helper saves once a month. Written down in [How it works](../how-it-works.md) instead, with the other figures. + +## Against 0.3.0 + +What each step leaves in the whole watcher, over what it held just +started. This lot's side is the installed watcher, sampled from outside, +on the maintainer's machine on 2026-09-23. 0.3.0's side is assembled: the +start and the four-session figure are 0.3.0's own installed process, from +Lot 15; the shell's cost was read on this lot's first build, whose menu +still called the shell itself, as 0.3.0's does; PDH's is the probe's. + +| Step | 0.3.0 | this lot | +| --- | --- | --- | +| just started | 157–163 handles, 1.9–2.0 MB | 160–162 handles, 1.9–2.1 MB | +| a session whose refinement reads the GPU counters | +19 handles, +3.6 to +3.9 MB | +18 handles, +0.24 MB | +| the menu, first opened | +41 to +52 handles, +0.7 to +1.1 MB | the same: Windows' own | +| *Edit configuration* | +147 handles, +0.8 MB | +2 handles | +| *Open log* after it | +84 handles, +0.9 MB | nothing | +| *Check for updates*, once its threads have gone | +111 handles, +1.0 MB | the same, left as it is | +| sessions, reloads, configuration faults | nothing | nothing | + +The figure the lot promises, then: a session leaves 20 handles and a +quarter of a megabyte, the menu Windows' fifty and a megabyte, its +entries nothing but *Check for updates*. A session, the menu, the +configuration and the log together left some 300 handles and 6 MB on +0.3.0; they leave some 70 handles and 1.2 MB. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 83a747a..b7e548b 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -116,6 +116,25 @@ signal mid-session, which does happen. Set `log_level = "debug"` and the log states plainly when Windows released the signal and whether the game had already exited by then. +## What it costs your machine + +Measured on the whole watcher, as Windows counts it, on a gaming PC in +September 2026. Memory here is private memory — Task Manager's *Commit +size* column in *Details*; its *Memory* column shows less. + +| When | Processor | Memory | +| --- | --- | --- | +| Waiting for a game, looking every two seconds | 0.03 % of one core | about 2 MB | +| During a game | nothing measurable: it waits for Windows to wake it | unchanged | +| Naming a game from what the graphics card draws, once a session | | about 0.25 MB, once | +| The icon's menu, the first time it opens | | about 1 MB, once — Windows' cost for a program's first menu | +| *Edit configuration*, *Open log*, *Documentation* | | nothing that stays: a short-lived helper opens them and takes the cost with it | +| *Check for updates* | | about 1 MB, until the watcher next starts | + +Over one evening — a Battlefield 6 session, five hours of GTA Online, the +menu used, and the rest idle, six hours in all — the watcher used 1.25 +seconds of processor time. + ## Why there are two executables Windows makes a program choose, when it is built, between two kinds: From 4a50c4a3adfa447665375789ee9829fdd896940d Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:49:55 +0200 Subject: [PATCH 8/8] Review Lot 16 before its pull request The three PerfLib calls that size their answer and are asked again now try four times and give no opinion after, instead of looping on the engine's thread for as long as Windows says the buffer is too small. The helper's failure line no longer says Notepad was tried for an address. AGENTS.md points at the open helper where it named ShellExecuteW, and its cost principle says to measure what a call leaves. The lot is marked done. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 12 +++++++++--- docs/design/16-footprint.md | 13 ++++++++++++- docs/design/README.md | 2 +- src/detect/gpu.rs | 30 ++++++++++++++++++++---------- src/open.rs | 2 +- 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c35760e..688c16c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,12 @@ These decide most questions before they are asked. must stay the cheapest thing it does; during a game the watcher waits on a handle and does nothing. A change to either is measured on the whole installed process against the last release -- processor over minutes, - private memory, handles -- with `presence-probe cost` and `footprint` - for the steps, and the figures go in the record and the changelog. + private memory, handles -- with `presence-probe cost`, `footprint` and + `menu-cost` for the steps, and the figures go in the record and the + changelog. A system library can keep what it loaded for the life of its + caller, whatever its close function says: PDH and the shell did + (`docs/design/16-footprint.md`). Measure what a call leaves, not only + what it takes. - **Strict and simple over clever.** An unambiguous state ("it is off, fix the file") beats a fallback whose behaviour needs explaining. Put the strict option first and argue for a fallback only if it protects something @@ -220,7 +224,9 @@ commands; do not. Task Scheduler exports them, and carry placeholders the release check verifies. Read and write them with that encoding. - `TrackPopupMenuEx` is modal and re-enters the window procedure; never hold a - `RefCell` borrow across it, or across `ShellExecuteW`. + `RefCell` borrow across it, or across any call that can show UI. The + shell is no longer called from the watcher at all: the menu's entries go + through the `open` helper, `src/open.rs`, and a new one should too. - `FindWindow` cannot find a window whose class was registered by another process; use `EnumWindows`. - A process started after `WM_QUERYENDSESSION` dies with diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md index 7bae675..1ba92bd 100644 --- a/docs/design/16-footprint.md +++ b/docs/design/16-footprint.md @@ -1,6 +1,6 @@ # Lot 16 — What the watcher keeps -**Status: in progress since 2026-09-23.** Proposed the same day in +**Status: done 2026-09-23.** Proposed the same day in [Lot 15](15-marked-games.md)'s record, which found that reading the GPU counters left 3.7 MB behind and that the installed watcher had gained some 260 handles over its first sessions, only twenty of them traced; taken up @@ -283,3 +283,14 @@ quarter of a megabyte, the menu Windows' fifty and a megabyte, its entries nothing but *Check for updates*. A session, the menu, the configuration and the log together left some 300 handles and 6 MB on 0.3.0; they leave some 70 handles and 1.2 MB. + +**The review before the pull request, 2026-09-23**, found and fixed: the +three calls that size their answer and are asked again — the list of +counter sets, a set's registration, a sample — looped for as long as +Windows said the buffer was too small, on the engine's thread, where a +refinement that never returns would hide the session's end; they now try +four times and give no opinion after. A log line said Notepad had been +tried for an address, which it never is. `AGENTS.md` named `ShellExecuteW` +as a call the tray makes; it points at the helper now, and its cost +principle says what this lot learnt: measure what a call leaves, not only +what it takes. diff --git a/docs/design/README.md b/docs/design/README.md index 66a6d8a..e30ae4d 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -27,7 +27,7 @@ session rather than when the code compiles. Each has its own page. | 13 | [Updating](13-updating.md) | done | | 14 | [Release notes people can read](14-release-notes.md) | done | | 15 | [Games Windows knows only from you](15-marked-games.md) | done | -| 16 | [What the watcher keeps](16-footprint.md) | in progress | +| 16 | [What the watcher keeps](16-footprint.md) | done | **Dependency order:** 1 → 2 → 4 → 5 → 6 → 7, with 3 independent and 7 needing both 3 and 6. Logging sits before the icon deliberately — the icon logs too, diff --git a/src/detect/gpu.rs b/src/detect/gpu.rs index 765f0a7..83bd21d 100644 --- a/src/detect/gpu.rs +++ b/src/detect/gpu.rs @@ -60,6 +60,12 @@ const PERF_100NSEC_TIMER: u32 = PERF_SIZE_LARGE /// are busy for a video player or a download too, so they are left out. const RENDERING_ENGINES: &[&str] = &["3d", "vr", "compute"]; +/// How many times a call that sizes its answer is asked again. Once is the +/// rule; more happens when instances appear between two calls. A bound, so +/// that an answer that never fits is "no opinion" rather than a refinement +/// that never returns -- it runs on the engine's thread. +const ATTEMPTS: usize = 4; + /// Rendering load per process id, as a percentage summed over the engines of /// every adapter. Values can exceed 100 on a multi-adapter or multi-engine /// machine, which is fine: only their order matters here. @@ -96,7 +102,7 @@ fn locate() -> Result<(GUID, u32)> { let structure = registration(&set, PERF_REG_COUNTERSET_STRUCT)?; match counter_type(&structure, counter) { Some(PERF_100NSEC_TIMER) => Ok((set, counter)), - other => bail!("`{COUNTER}` has type {other:X?}, not the timer it was"), + other => bail!("`{COUNTER}` has type {other:X?}, not the PERF_100NSEC_TIMER read here"), } } @@ -104,7 +110,7 @@ fn locate() -> Result<(GUID, u32)> { fn counter_sets() -> Result> { let mut count = 0u32; let mut sets = Vec::new(); - loop { + for _ in 0..ATTEMPTS { // SAFETY: the slice carries its own length, which bounds the write; // `count` is a local. let status = unsafe { PerfEnumerateCounterSet(None, Some(&mut sets), &mut count) }; @@ -119,13 +125,14 @@ fn counter_sets() -> Result> { _ => bail!("PerfEnumerateCounterSet failed ({status})"), } } + bail!("the list of counter sets never fit its buffer") } /// One piece of a counter set's registration, sized then read. fn registration(set: &GUID, code: PerfRegInfoType) -> Result> { let mut size = 0u32; let mut buffer = Vec::new(); - loop { + for _ in 0..ATTEMPTS { // SAFETY: `set` outlives the call; the slice carries its own length, // which bounds the write; `size` is a local. let status = unsafe { @@ -140,6 +147,7 @@ fn registration(set: &GUID, code: PerfRegInfoType) -> Result> { _ => bail!("PerfQueryCounterSetRegistrationInfo failed ({status})"), } } + bail!("a counter set's registration never fit its buffer") } /// A PerfLib query handle, closed on drop. @@ -207,7 +215,7 @@ impl Query { let mut size = 0u32; // Eight-byte words, so the header's 64-bit fields are aligned. let mut words: Vec = Vec::new(); - loop { + for _ in 0..ATTEMPTS { // SAFETY: the buffer is `len * 8` bytes, eight-aligned, and the // call is told exactly that; `size` is a local. let status = unsafe { @@ -219,18 +227,20 @@ impl Query { ) }; match status { - 0 => break, + 0 => { + return Ok(words + .iter() + .flat_map(|word| word.to_le_bytes()) + .take(size as usize) + .collect()); + } _ if status == ERROR_NOT_ENOUGH_MEMORY.0 => { words = vec![0; (size as usize).div_ceil(8)]; } _ => bail!("PerfQueryCounterData failed ({status})"), } } - Ok(words - .iter() - .flat_map(|word| word.to_le_bytes()) - .take(size as usize) - .collect()) + bail!("the counter data never fit its buffer") } } diff --git a/src/open.rs b/src/open.rs index ecf8f93..3c965d7 100644 --- a/src/open.rs +++ b/src/open.rs @@ -75,7 +75,7 @@ pub fn open(what: &str) { target: target::WATCHER, what, code = status.code(), - "Could not open it: the shell refused, and so did Notepad" + "Nothing opened: the shell would not take it, nor Notepad for a file" ), Err(error) => tracing::debug!( target: target::WATCHER,