From c56c70381b83f77f98a20e93a52d2518176bd2b6 Mon Sep 17 00:00:00 2001 From: Seth <80608102+xptea@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:55:18 -0400 Subject: [PATCH 1/2] Add interactive chart time navigation --- AGENTS.md | 80 ++++++++ CHANGELOG.md | 18 +- README.md | 15 +- src/main.c | 441 +++++++++++++++++++++++++++++++++++++++++++- src/version.h | 4 +- src/vgpu.h | 3 +- tests/test_conpty.c | 24 ++- 7 files changed, 567 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d86dc2c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md + +This file is the working guide for coding agents and contributors changing VGPU-Mon. + +## Project shape + +VGPU-Mon is a native 64-bit Windows GPU process monitor written in C11. The supported build toolchain is MSVC from Visual Studio 2022. The interactive UI uses Win32 console input plus VT output; it is not a curses application. + +Important areas: + +- `src/main.c`: application state, sampling loop, terminal rendering, keyboard/mouse input, CLI output +- `src/nvml_dyn.c`: dynamically loaded NVIDIA NVML telemetry +- `src/dxgi_gpu.c`: DXGI adapter and memory telemetry +- `src/pdh_gpu.c`: WDDM/PDH engine and per-process counters +- `src/updater.c`: signed-release metadata checks, download verification, and update handoff +- `tests/test_core.c`: native unit and CLI behavior tests +- `tests/test_conpty.c`: full terminal lifecycle, input, resize, and frame-bound tests +- `tests/test_installer.ps1`: install, PATH, upgrade, and uninstall lifecycle +- `tools/`: release metadata, PE-hardening, dependency, and packaging checks + +Read `docs/architecture.md`, `CONTRIBUTING.md`, and `SECURITY.md` before changing provider ownership, process termination, installation, or update behavior. + +## Build and verification + +Run commands from the repository root in PowerShell. The PowerShell/MSVC path is release-authoritative. + +```powershell +.\build.ps1 -Configuration Debug -Test +.\build.ps1 -Configuration Release -Test +.\build.ps1 -Configuration Sanitize -Test -OutputName vgpu-mon-asan +.\analyze.ps1 +.\tools\verify-pe.ps1 +``` + +For a release candidate, also run: + +```powershell +.\package.ps1 -SkipBuild +.\installer.ps1 -SkipBuild +.\tests\test_installer.ps1 -InstallerPath (Get-ChildItem .\dist\VGPU-Mon-*-setup.exe -File).FullName +``` + +Use `--demo` for UI work that must not depend on the local GPU: + +```powershell +.\build\vgpu-mon.exe --demo +.\build\vgpu-mon.exe --demo --chart vram +``` + +Do not commit `build/`, `dist/`, telemetry logs, installers, or machine-specific output. + +## Coding rules + +- Keep C warnings clean under `/W4 /WX /sdl` and static analysis. +- Use four-space indentation, bounded buffers, explicit ownership, and one cleanup path per acquired resource. +- Preserve graceful behavior when NVML, DXGI, PDH, a sensor, or an individual process query is unavailable. +- Do not add a mandatory runtime, SDK, administrator requirement, service, or driver. +- Keep optional vendor APIs dynamically discovered and keep AMD/Intel fallback behavior intact. +- Add or update automated tests for terminal input, resize, CLI parsing, cleanup, updater, or installer behavior changes. +- Update `README.md` and `CHANGELOG.md` for user-visible changes. Bump `src/version.h` only for a release-ready user-facing change. + +## Terminal rendering invariants + +- Every repaint must compose a complete frame for the current viewport. +- Never write the terminal's final column; a pending wrap can scroll or flash the viewport. +- Keep emitted rows within the current visible height and clear stale line/tail content. +- Mouse coordinates are console-buffer coordinates; normalize them with `viewport_left` and `viewport_top`. +- Resize, mouse, and chart changes must continue to pass the ConPTY bounds test. +- Chart history is a timestamped ring. Time labels and hover values must use stored timestamps, not assume the current refresh interval applied to older samples. +- A historical chart view should remain anchored while new samples arrive. `End` returns it to live data. + +## Safety and release rules + +- Treat process names, executable paths, UUIDs, and logs as potentially sensitive. +- Keep termination confirmation and protected/self-process checks intact. +- Never weaken HTTPS-only downloads, version parsing, release filename validation, SHA-256 verification, or current-user installer behavior. +- Do not publish a release unless Debug, Release, AddressSanitizer, static analysis, PE hardening, packaging, and installer lifecycle checks pass. +- Release tags must exactly match `VGPU_VERSION`, and release assets must include the installer, portable ZIP, bootstrap script, `version.txt`, and `SHA256SUMS.txt`. + +When a check cannot run, report that explicitly; do not describe unverified work as production-ready. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4a119..b1bdf4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable user-facing changes are documented here. This project follows [Seman ## [Unreleased] +## [1.3.0] - 2026-07-13 + +### Added + +- Zoom the chart's visible time span with the mouse wheel, from a tight live view through the retained history. +- Pan older/newer with `Shift`+wheel, arrow keys, or page keys, with `Home` for the oldest complete window and `End` for live data. +- Show an in-chart crosshair tooltip with the nearest real sample's exact value and age. +- Retain 14,400 timestamped samples per metric in a bounded ring and preserve historical view position while new samples arrive. +- Add a repository-level `AGENTS.md` with architecture, testing, terminal-rendering, security, and release guidance. + +### Security and quality + +- Keep chart time calculations correct across live refresh-interval changes by recording monotonic timestamps per sample. +- Exercise chart wheel, pan, hover, resize, and complete-frame bounds through the ConPTY integration test. + ## [1.2.0] - 2026-07-13 ### Added @@ -70,7 +85,8 @@ All notable user-facing changes are documented here. This project follows [Seman - Added `/W4 /WX /sdl`, control-flow protection, ASLR/DEP/CET-compatible linker flags, reproducible Release builds, MSVC AddressSanitizer tests, CRT leak checks, static analysis, CodeQL, and pinned CI dependencies. -[Unreleased]: https://github.com/xptea/VGPU-Mon/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/xptea/VGPU-Mon/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/xptea/VGPU-Mon/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/xptea/VGPU-Mon/compare/v1.1.4...v1.2.0 [1.1.4]: https://github.com/xptea/VGPU-Mon/compare/v1.1.3...v1.1.4 [1.1.3]: https://github.com/xptea/VGPU-Mon/compare/v1.1.2...v1.1.3 diff --git a/README.md b/README.md index c672225..d421b57 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ It is a real full-screen terminal application, not a wrapper around `nvidia-smi` ## Features - Live GPU and physical VRAM utilization with driver-reserved memory separated -- Full-screen scrolling charts for GPU, VRAM, engine, temperature, and power metrics +- Full-screen timestamped charts with time-span zoom, history panning, and exact hover values - WDDM dedicated/shared GPU-memory commitments per process - Per-process 3D, compute, copy, encode, and decode engine activity - Temperature, board power, power limit, fan, P-state, and clocks through NVML - Encoder, decoder, and PCIe link/throughput telemetry when the driver exposes it - Multiple-GPU switching -- Mouse-clickable sort headers and process rows, plus mouse-wheel navigation +- Mouse-clickable sort headers and process rows, plus context-aware wheel navigation - Responsive layouts with bounded full-frame repainting after every terminal resize - Interactive sorting, process-name filtering, and process details - Confirmed process termination with protection for system and self PIDs @@ -125,7 +125,12 @@ winget install --id JRSoftware.InnoSetup --exact --scope user | `p`, `n`, `e` | Sort by PID, name, or engine | | `o` | Reverse the current sort | | Mouse click | Sort a header or select a process row | -| Mouse wheel | Move through process rows | +| Mouse wheel | Move through process rows; zoom the time span over a chart | +| `Shift` + mouse wheel | Pan backward or forward through chart history | +| `Left` / `Right` | Pan a chart by one-quarter of its visible span | +| `PgUp` / `PgDn` | Pan a chart by one full visible span | +| `Home` / `End` | Jump to the oldest complete chart window or return live | +| Mouse hover | Show the exact chart value and how long ago it was sampled | | `f` | Edit the process-name filter | | `d` | Toggle details for the selected process | | `i` | Toggle the full GPU information panel | @@ -177,6 +182,10 @@ Demo values are clearly labeled and must not be treated as machine telemetry. The flag form requested by the UI is also supported, such as `--chart --vram`, `--chart --3d`, or simply `--vram`. In chart view, number keys switch metrics without restarting and `c` returns to the process table. +Charts open on a one-minute live window. Scroll the mouse wheel over the plot to zoom from a tighter view to the full retained range. Use `Shift`+wheel, `Left`/`Right`, or `PgUp`/`PgDn` to inspect older samples; `End` snaps back to live data. Hovering anywhere on the plot draws a crosshair and an in-chart tooltip with the nearest real sample's value and age. + +VGPU-Mon retains 14,400 timestamped samples per metric in a fixed-size ring, so memory use stays bounded. That represents about four hours at the default one-second interval, one hour at 250 ms, or twenty hours at five seconds. Stored timestamps keep zoom, panning, and hover ages correct when the sampling interval changes during a run. History is in memory only and resets when the app closes or the selected GPU changes; use CSV logging for long-term storage. + Options: ```text diff --git a/src/main.c b/src/main.c index 8323f2a..1a5f78f 100644 --- a/src/main.c +++ b/src/main.c @@ -123,8 +123,21 @@ typedef struct { size_t history_count; size_t history_next; double chart_history[CHART_METRIC_COUNT][VGPU_CHART_HISTORY_SIZE]; + ULONGLONG chart_history_time[VGPU_CHART_HISTORY_SIZE]; size_t chart_history_count; size_t chart_history_next; + ULONGLONG chart_window_ms; + ULONGLONG chart_pan_ms; + bool chart_hover_active; + int chart_hover_x; + int chart_hover_y; + int chart_plot_left; + int chart_plot_right; + int chart_plot_top; + int chart_plot_bottom; + double *chart_plot_values; + unsigned char *chart_plot_present; + size_t chart_plot_capacity; FILE *log_file; char log_path[MAX_PATH]; ProcessDetails details_cache; @@ -302,6 +315,14 @@ static void update_visible_processes(App *app) { } static void add_history_sample(App *app) { + ULONGLONG sampled_at = GetTickCount64(); + if (app->chart_history_count > 0 && app->chart_pan_ms > 0) { + size_t newest = (app->chart_history_next + VGPU_CHART_HISTORY_SIZE - 1) % + VGPU_CHART_HISTORY_SIZE; + ULONGLONG previous = app->chart_history_time[newest]; + if (sampled_at > previous) app->chart_pan_ms += sampled_at - previous; + } + app->utilization_history[app->history_next] = app->telemetry.gpu_util; double memory_percent = app->telemetry.memory_total ? (double)app->telemetry.memory_used * 100.0 / (double)app->telemetry.memory_total : 0.0; @@ -322,6 +343,7 @@ static void add_history_sample(App *app) { values[CHART_POWER] = app->telemetry.power_w >= 0 ? app->telemetry.power_w : 0.0; for (size_t i = 0; i < CHART_METRIC_COUNT; ++i) app->chart_history[i][app->chart_history_next] = values[i]; + app->chart_history_time[app->chart_history_next] = sampled_at; app->chart_history_next = (app->chart_history_next + 1) % VGPU_CHART_HISTORY_SIZE; if (app->chart_history_count < VGPU_CHART_HISTORY_SIZE) app->chart_history_count++; } @@ -371,6 +393,135 @@ static bool chart_metric_available(const App *app, ChartMetric metric) { } } +static size_t chart_oldest_index(const App *app) { + return app->chart_history_count < VGPU_CHART_HISTORY_SIZE ? 0 : app->chart_history_next; +} + +static size_t chart_ring_index(const App *app, size_t logical) { + return (chart_oldest_index(app) + logical) % VGPU_CHART_HISTORY_SIZE; +} + +static ULONGLONG chart_timestamp(const App *app, size_t logical) { + return app->chart_history_time[chart_ring_index(app, logical)]; +} + +static double chart_value(const App *app, ChartMetric metric, size_t logical) { + return app->chart_history[metric][chart_ring_index(app, logical)]; +} + +static ULONGLONG chart_max_window_ms(const App *app) { + ULONGLONG maximum = (ULONGLONG)app->interval_ms * (ULONGLONG)VGPU_CHART_HISTORY_SIZE; + ULONGLONG duration = 0; + if (app->chart_history_count > 1) { + duration = chart_timestamp(app, app->chart_history_count - 1) - chart_timestamp(app, 0); + } + if (duration > maximum) maximum = duration; + return maximum < 10000ULL ? 10000ULL : maximum; +} + +static void clamp_chart_window(App *app) { + ULONGLONG minimum = (ULONGLONG)app->interval_ms * 2ULL; + ULONGLONG maximum = chart_max_window_ms(app); + if (minimum < 10000ULL) minimum = 10000ULL; + if (minimum > maximum) minimum = maximum; + if (app->chart_window_ms < minimum) app->chart_window_ms = minimum; + if (app->chart_window_ms > maximum) app->chart_window_ms = maximum; +} + +static ULONGLONG chart_max_pan_ms(const App *app) { + if (app->chart_history_count < 2) return 0; + ULONGLONG duration = chart_timestamp(app, app->chart_history_count - 1) - chart_timestamp(app, 0); + return duration > app->chart_window_ms ? duration - app->chart_window_ms : 0; +} + +static void clamp_chart_pan(App *app) { + ULONGLONG maximum = chart_max_pan_ms(app); + if (app->chart_pan_ms > maximum) app->chart_pan_ms = maximum; +} + +static void format_chart_duration(ULONGLONG milliseconds, char *buffer, size_t buffer_size) { + if (milliseconds < 1000ULL) { + snprintf(buffer, buffer_size, "%.1fs", (double)milliseconds / 1000.0); + return; + } + ULONGLONG seconds = milliseconds / 1000ULL; + if (seconds < 60ULL) { + snprintf(buffer, buffer_size, "%llus", (unsigned long long)seconds); + } else if (seconds < 3600ULL) { + snprintf(buffer, buffer_size, "%llum%02llus", + (unsigned long long)(seconds / 60ULL), + (unsigned long long)(seconds % 60ULL)); + } else { + snprintf(buffer, buffer_size, "%lluh%02llum", + (unsigned long long)(seconds / 3600ULL), + (unsigned long long)((seconds % 3600ULL) / 60ULL)); + } +} + +static void zoom_chart(App *app, bool zoom_out) { + static const ULONGLONG windows[] = { + 10000ULL, 15000ULL, 30000ULL, 60000ULL, 120000ULL, 300000ULL, + 600000ULL, 1800000ULL, 3600000ULL, 7200000ULL, 14400000ULL, + 28800000ULL, 43200000ULL, 86400000ULL + }; + clamp_chart_window(app); + ULONGLONG minimum = (ULONGLONG)app->interval_ms * 2ULL; + ULONGLONG maximum = chart_max_window_ms(app); + if (minimum < 10000ULL) minimum = 10000ULL; + if (minimum > maximum) minimum = maximum; + ULONGLONG next = zoom_out ? maximum : minimum; + + if (zoom_out) { + for (size_t i = 0; i < _countof(windows); ++i) { + if (windows[i] > app->chart_window_ms && windows[i] <= maximum) { + next = windows[i]; + break; + } + } + if (maximum <= app->chart_window_ms) next = app->chart_window_ms; + } else { + for (size_t i = 0; i < _countof(windows); ++i) { + if (windows[i] >= minimum && windows[i] < app->chart_window_ms) next = windows[i]; + if (windows[i] >= app->chart_window_ms) break; + } + if (minimum >= app->chart_window_ms) next = app->chart_window_ms; + } + app->chart_window_ms = next; + clamp_chart_pan(app); +} + +static void pan_chart(App *app, int direction, bool full_window) { + clamp_chart_window(app); + clamp_chart_pan(app); + ULONGLONG step = full_window ? app->chart_window_ms : app->chart_window_ms / 4ULL; + if (step < 1000ULL) step = 1000ULL; + ULONGLONG maximum = chart_max_pan_ms(app); + if (direction > 0) { + app->chart_pan_ms = maximum - app->chart_pan_ms < step + ? maximum : app->chart_pan_ms + step; + } else { + app->chart_pan_ms = app->chart_pan_ms < step ? 0 : app->chart_pan_ms - step; + } +} + +static bool ensure_chart_plot_capacity(App *app, size_t needed) { + if (needed <= app->chart_plot_capacity) return true; + if (needed > SIZE_MAX / sizeof(*app->chart_plot_values)) return false; + double *values = (double *)malloc(needed * sizeof(*values)); + unsigned char *present = (unsigned char *)malloc(needed * sizeof(*present)); + if (!values || !present) { + free(present); + free(values); + return false; + } + free(app->chart_plot_present); + free(app->chart_plot_values); + app->chart_plot_values = values; + app->chart_plot_present = present; + app->chart_plot_capacity = needed; + return true; +} + static void log_sample(App *app) { if (!app->log_file) return; char timestamp[64], gpu_name[256], process_name[520], engine[96]; @@ -678,12 +829,16 @@ static void render_help(TextBuffer *buffer) { " l Toggle CSV logging 1-0 Select chart metric\n" " Space Pause/resume\n" " + / - Refresh faster/slower h Help q Quit\n\n" + "%sChart navigation%s\n" + " Wheel Zoom visible time Shift+wheel Pan through history\n" + " Left/Right Pan by 1/4 view PgUp/PgDn Pan by a full view\n" + " Home/End Oldest retained/live Hover Exact point value\n\n" "%sAccounting%s\n" " Process GPU %% is the busiest Windows GPU engine for that PID, matching Task Manager's\n" " model. Dedicated/shared values come from WDDM counters. A trailing ! marks a value\n" " larger than physical VRAM, a known Windows counter anomaly rather than residency.\n" " Board telemetry comes from NVML; DXGI supplies a vendor-neutral memory fallback.\n", - ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET); + ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET); } static void render_gpu_info(TextBuffer *buffer, const App *app) { @@ -842,7 +997,7 @@ static const char *chart_color(ChartMetric metric) { return ANSI_GREEN; } -static void render_chart_view(App *app, TextBuffer *buffer, int columns, int rows) { +static void render_chart_view_fallback(App *app, TextBuffer *buffer, int columns, int rows) { app->header_hit_count = 0; app->table_header_row = -1; app->table_first_process_row = -1; @@ -930,6 +1085,219 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row text_buffer_append(buffer, "%s%s%s", ANSI_DIM, clipped_controls, ANSI_RESET); } +static void render_chart_view(App *app, TextBuffer *buffer, int columns, int rows) { + app->header_hit_count = 0; + app->table_header_row = -1; + app->table_first_process_row = -1; + app->table_last_process_row = -1; + app->chart_plot_left = -1; + app->chart_plot_right = -1; + app->chart_plot_top = -1; + app->chart_plot_bottom = -1; + if (columns < 24 || rows < 8) { + app->chart_hover_active = false; + text_buffer_append(buffer, "%sVGPU-Mon%s\nResize to at least 24 x 8 for chart view.\n", + ANSI_BOLD, ANSI_RESET); + return; + } + + clamp_chart_window(app); + clamp_chart_pan(app); + ChartMetric metric = app->chart_metric; + const char *unit = chart_metric_unit(metric); + bool available = chart_metric_available(app, metric); + double current = current_chart_value(app, metric); + double observed_max = 0.0; + size_t count = app->chart_history_count; + ULONGLONG newest_time = count ? chart_timestamp(app, count - 1) : GetTickCount64(); + ULONGLONG view_end = newest_time >= app->chart_pan_ms + ? newest_time - app->chart_pan_ms : 0; + ULONGLONG view_start = view_end >= app->chart_window_ms + ? view_end - app->chart_window_ms : 0; + + const int prefix_width = 8; + int plot_width = columns - prefix_width; + int chart_height = rows - 6; + if (!ensure_chart_plot_capacity(app, (size_t)plot_width)) { + render_chart_view_fallback(app, buffer, columns, rows); + return; + } + double *plot_values = app->chart_plot_values; + unsigned char *plot_present = app->chart_plot_present; + memset(plot_values, 0, (size_t)plot_width * sizeof(*plot_values)); + memset(plot_present, 0, (size_t)plot_width * sizeof(*plot_present)); + + size_t first_visible = count; + size_t last_visible = count; + for (size_t i = 0; i < count; ++i) { + ULONGLONG timestamp = chart_timestamp(app, i); + if (timestamp < view_start || timestamp > view_end) continue; + if (first_visible == count) first_visible = i; + last_visible = i + 1; + double value = chart_value(app, metric, i); + if (value > observed_max) observed_max = value; + ULONGLONG offset = timestamp - view_start; + int x = (int)((offset * (ULONGLONG)(plot_width - 1)) / app->chart_window_ms); + if (x < 0) x = 0; + if (x >= plot_width) x = plot_width - 1; + if (!plot_present[x] || value > plot_values[x]) plot_values[x] = value; + plot_present[x] = 1; + } + + if (first_visible < count) { + size_t cursor = first_visible; + bool have_value = false; + double held_value = 0.0; + for (int x = 0; x < plot_width; ++x) { + ULONGLONG target = view_start + + (app->chart_window_ms * (ULONGLONG)x) / (ULONGLONG)(plot_width - 1); + while (cursor < last_visible && chart_timestamp(app, cursor) <= target) { + held_value = chart_value(app, metric, cursor); + have_value = true; + cursor++; + } + if (!plot_present[x] && have_value) { + plot_values[x] = held_value; + plot_present[x] = 1; + } + } + } + + double scale = 100.0; + if (metric == CHART_POWER) { + scale = app->telemetry.power_limit_w > 0 ? app->telemetry.power_limit_w : 100.0; + if (observed_max > scale) scale = ceil(observed_max * 1.10 / 10.0) * 10.0; + } else if (metric == CHART_TEMPERATURE && observed_max > scale) { + scale = ceil(observed_max * 1.10 / 10.0) * 10.0; + } + + char title[256], title_display[256], gpu_name[256], status[256], status_display[256]; + char span[32], retained[32], edge[48]; + format_chart_duration(app->chart_window_ms, span, sizeof(span)); + ULONGLONG retained_ms = count > 1 ? newest_time - chart_timestamp(app, 0) : 0; + format_chart_duration(retained_ms, retained, sizeof(retained)); + if (app->chart_pan_ms == 0) { + snprintf(edge, sizeof(edge), "LIVE"); + } else { + char age[32]; + format_chart_duration(app->chart_pan_ms, age, sizeof(age)); + snprintf(edge, sizeof(edge), "%s ago", age); + } + snprintf(title, sizeof(title), "VGPU-Mon %s | %s%s%s", + VGPU_VERSION, chart_metric_name(metric), app->paused ? " | PAUSED" : "", + app->log_file ? " | REC" : ""); + truncate_text(title, title_display, sizeof(title_display), (size_t)columns); + truncate_text(app->telemetry.name, gpu_name, sizeof(gpu_name), (size_t)columns); + if (available) { + snprintf(status, sizeof(status), + "Now %.1f%s | View peak %.1f%s | Span %s | %s | Retained %s", + current, unit, observed_max, unit, span, edge, retained); + } else { + snprintf(status, sizeof(status), + "Unavailable from this GPU, driver, or Windows driver mode | Span %s | %s", + span, edge); + } + truncate_text(status, status_display, sizeof(status_display), (size_t)columns); + text_buffer_append(buffer, "%s%s%s\n%s%s%s\n%s\n", + ANSI_BOLD, title_display, ANSI_RESET, + ANSI_CYAN, gpu_name, ANSI_RESET, status_display); + + app->chart_plot_left = prefix_width; + app->chart_plot_right = prefix_width + plot_width - 1; + app->chart_plot_top = 3; + app->chart_plot_bottom = 3 + chart_height - 1; + + int hover_plot_x = -1; + int tooltip_row = -1; + int tooltip_start = -1; + char tooltip[96] = ""; + char tooltip_display[96] = ""; + if (app->chart_hover_active && + app->chart_hover_x >= app->chart_plot_left && + app->chart_hover_x <= app->chart_plot_right && + app->chart_hover_y >= app->chart_plot_top && + app->chart_hover_y <= app->chart_plot_bottom) { + hover_plot_x = app->chart_hover_x - app->chart_plot_left; + tooltip_row = app->chart_hover_y - app->chart_plot_top; + ULONGLONG target = view_start + + (app->chart_window_ms * (ULONGLONG)hover_plot_x) / + (ULONGLONG)(plot_width - 1); + size_t nearest = count; + ULONGLONG nearest_distance = ~(ULONGLONG)0; + if (first_visible < count && target >= chart_timestamp(app, first_visible)) { + for (size_t i = first_visible; i < last_visible; ++i) { + ULONGLONG timestamp = chart_timestamp(app, i); + ULONGLONG distance = timestamp > target + ? timestamp - target : target - timestamp; + if (distance < nearest_distance) { + nearest = i; + nearest_distance = distance; + } + } + } + if (nearest < count) { + ULONGLONG age_ms = newest_time - chart_timestamp(app, nearest); + if (age_ms < 1000ULL) { + snprintf(tooltip, sizeof(tooltip), " Point %.1f%s | now ", + chart_value(app, metric, nearest), unit); + } else { + char age[32]; + format_chart_duration(age_ms, age, sizeof(age)); + snprintf(tooltip, sizeof(tooltip), " Point %.1f%s | %s ago ", + chart_value(app, metric, nearest), unit, age); + } + } else { + ULONGLONG age_ms = newest_time > target ? newest_time - target : 0; + char age[32]; + format_chart_duration(age_ms, age, sizeof(age)); + snprintf(tooltip, sizeof(tooltip), " No sample | %s ago ", age); + } + truncate_text(tooltip, tooltip_display, sizeof(tooltip_display), (size_t)plot_width); + int tooltip_length = (int)strlen(tooltip_display); + tooltip_start = hover_plot_x + 2; + if (tooltip_start + tooltip_length > plot_width) + tooltip_start = hover_plot_x - tooltip_length - 1; + if (tooltip_start < 0) tooltip_start = 0; + if (tooltip_start + tooltip_length > plot_width) + tooltip_start = plot_width - tooltip_length; + } + + const char *color = chart_color(metric); + for (int row = 0; row < chart_height; ++row) { + double upper = scale * (double)(chart_height - row) / (double)chart_height; + double lower = scale * (double)(chart_height - row - 1) / (double)chart_height; + bool labeled = row == 0 || row == chart_height - 1 || row == chart_height / 2; + if (labeled) text_buffer_append(buffer, "%6.1f%s|", upper, unit); + else text_buffer_append(buffer, " |"); + text_buffer_append(buffer, "%s", color); + for (int x = 0; x < plot_width; ++x) { + if (row == tooltip_row && x == tooltip_start && tooltip_display[0]) { + text_buffer_append(buffer, "%s%s%s%s", ANSI_RESET, ANSI_REVERSE, + tooltip_display, ANSI_RESET); + text_buffer_append(buffer, "%s", color); + x += (int)strlen(tooltip_display) - 1; + continue; + } + const char *cell = (row == chart_height / 2) ? "-" : " "; + if (plot_present[x]) { + if (plot_values[x] >= upper) cell = "\xE2\x96\x88"; + else if (plot_values[x] > lower) cell = "\xE2\x96\x84"; + } + if (x == hover_plot_x) text_buffer_append(buffer, "%s", ANSI_REVERSE); + text_buffer_append(buffer, "%s", cell); + if (x == hover_plot_x) text_buffer_append(buffer, "%s%s", ANSI_RESET, color); + } + text_buffer_append(buffer, "%s\n", ANSI_RESET); + } + + const char *controls = columns >= 112 + ? "Wheel zoom | Shift+wheel / Left/Right pan | Home oldest | End live | Hover exact | 1-0 metric | c table | q quit" + : "Wheel zoom | Left/Right pan | End live | Hover exact value | c table | q quit"; + char clipped_controls[256]; + truncate_text(controls, clipped_controls, sizeof(clipped_controls), (size_t)columns); + text_buffer_append(buffer, "%s%s%s", ANSI_DIM, clipped_controls, ANSI_RESET); +} + static void render_app(App *app, TextBuffer *buffer) { int columns, rows; terminal_geometry(&columns, &rows, &app->viewport_left, &app->viewport_top); @@ -1114,6 +1482,8 @@ static void select_gpu(App *app, int delta) { app->history_next = 0; app->chart_history_count = 0; app->chart_history_next = 0; + app->chart_pan_ms = 0; + app->chart_hover_active = false; sample_app(app); } @@ -1164,7 +1534,10 @@ static bool handle_key(App *app, int key) { case 'q': case 'Q': return false; case 'h': case 'H': app->show_help = !app->show_help; break; case 'i': case 'I': app->show_gpu_info = !app->show_gpu_info; break; - case 'c': case 'C': app->chart_view = !app->chart_view; break; + case 'c': case 'C': + app->chart_view = !app->chart_view; + if (!app->chart_view) app->chart_hover_active = false; + break; case 'd': case 'D': app->show_details = !app->show_details; break; case 'u': case 'U': set_sort(app, SORT_GPU); break; case 'm': case 'M': set_sort(app, SORT_DEDICATED); break; @@ -1200,10 +1573,14 @@ static bool handle_key(App *app, int key) { case '+': case '=': if (app->interval_ms > 250) app->interval_ms -= 250; if (app->interval_ms < 250) app->interval_ms = 250; + clamp_chart_window(app); + clamp_chart_pan(app); break; case '-': case '_': if (app->interval_ms < 5000) app->interval_ms += 250; if (app->interval_ms > 5000) app->interval_ms = 5000; + clamp_chart_window(app); + clamp_chart_pan(app); break; } return true; @@ -1212,6 +1589,29 @@ static bool handle_key(App *app, int key) { static bool handle_console_key(App *app, const KEY_EVENT_RECORD *event) { if (!event->bKeyDown) return true; int repeat = event->wRepeatCount > 0 ? event->wRepeatCount : 1; + if (app->chart_view && !app->editing_filter && !app->confirm_pid) { + switch (event->wVirtualKeyCode) { + case VK_LEFT: + for (int i = 0; i < repeat; ++i) pan_chart(app, 1, false); + return true; + case VK_RIGHT: + for (int i = 0; i < repeat; ++i) pan_chart(app, -1, false); + return true; + case VK_PRIOR: + for (int i = 0; i < repeat; ++i) pan_chart(app, 1, true); + return true; + case VK_NEXT: + for (int i = 0; i < repeat; ++i) pan_chart(app, -1, true); + return true; + case VK_HOME: + clamp_chart_window(app); + app->chart_pan_ms = chart_max_pan_ms(app); + return true; + case VK_END: + app->chart_pan_ms = 0; + return true; + } + } switch (event->wVirtualKeyCode) { case VK_UP: select_relative(app, -repeat); return true; case VK_DOWN: select_relative(app, repeat); return true; @@ -1230,16 +1630,38 @@ static bool handle_console_key(App *app, const KEY_EVENT_RECORD *event) { } static void handle_console_mouse(App *app, const MOUSE_EVENT_RECORD *event) { + int x = event->dwMousePosition.X - app->viewport_left; + int y = event->dwMousePosition.Y - app->viewport_top; + bool in_chart = app->chart_view && + x >= app->chart_plot_left && x <= app->chart_plot_right && + y >= app->chart_plot_top && y <= app->chart_plot_bottom; + + if (app->chart_view && event->dwEventFlags == MOUSE_MOVED) { + app->chart_hover_active = in_chart; + if (in_chart) { + app->chart_hover_x = x; + app->chart_hover_y = y; + } + return; + } if (event->dwEventFlags == MOUSE_WHEELED) { SHORT delta = (SHORT)HIWORD(event->dwButtonState); + if (in_chart) { + app->chart_hover_active = true; + app->chart_hover_x = x; + app->chart_hover_y = y; + if (event->dwControlKeyState & SHIFT_PRESSED) + pan_chart(app, delta > 0 ? 1 : -1, false); + else + zoom_chart(app, delta < 0); + return; + } select_relative(app, delta > 0 ? -3 : 3); return; } if (event->dwEventFlags != 0 || !(event->dwButtonState & FROM_LEFT_1ST_BUTTON_PRESSED) || app->chart_view) return; - int x = event->dwMousePosition.X - app->viewport_left; - int y = event->dwMousePosition.Y - app->viewport_top; if (y == app->table_header_row) { for (size_t i = 0; i < app->header_hit_count; ++i) { HeaderHit *hit = &app->header_hits[i]; @@ -1415,7 +1837,8 @@ static void print_help(void) { " --help Show this help\n" " --version Show the version\n\n" "Run without options in Windows Terminal for the interactive UI.\n" - "Headers are mouse-clickable; click again to reverse the sort.\n", + "Headers are mouse-clickable; click again to reverse the sort.\n" + "Charts support wheel zoom, Shift+wheel or arrow-key panning, and exact hover values.\n", VGPU_VERSION); } @@ -1435,6 +1858,11 @@ static bool initialize_app(App *app) { static void cleanup_app(App *app) { stop_logging(app); + free(app->chart_plot_present); + free(app->chart_plot_values); + app->chart_plot_present = NULL; + app->chart_plot_values = NULL; + app->chart_plot_capacity = 0; pdh_gpu_close(&app->pdh); dxgi_close(&app->dxgi); nvml_close(&app->nvml); @@ -1465,6 +1893,7 @@ static int app_main(int argc, char **argv) { app->sort_mode = SORT_GPU; app->sort_descending = true; app->chart_metric = CHART_GPU; + app->chart_window_ms = VGPU_CHART_DEFAULT_WINDOW_MS; bool once = false; bool json = false; const char *initial_log = NULL; diff --git a/src/version.h b/src/version.h index d096174..395d02c 100644 --- a/src/version.h +++ b/src/version.h @@ -1,7 +1,7 @@ #ifndef VGPU_VERSION_H #define VGPU_VERSION_H -#define VGPU_VERSION "1.2.0" -#define VGPU_VERSION_NUM 1,2,0,0 +#define VGPU_VERSION "1.3.0" +#define VGPU_VERSION_NUM 1,3,0,0 #endif diff --git a/src/vgpu.h b/src/vgpu.h index 4e456ce..1e6bfe9 100644 --- a/src/vgpu.h +++ b/src/vgpu.h @@ -12,7 +12,8 @@ #define VGPU_MAX_GPUS 16 #define VGPU_MAX_PROCESSES 4096 #define VGPU_HISTORY_SIZE 72 -#define VGPU_CHART_HISTORY_SIZE 512 +#define VGPU_CHART_HISTORY_SIZE 14400 +#define VGPU_CHART_DEFAULT_WINDOW_MS 60000ULL typedef struct { unsigned int index; diff --git a/tests/test_conpty.c b/tests/test_conpty.c index 06f1ca0..4506116 100644 --- a/tests/test_conpty.c +++ b/tests/test_conpty.c @@ -239,8 +239,8 @@ int main(int argc, char **argv) { return 1; } - /* Exercise chart switching, mouse sorting, and both shrink and grow paths - before requesting the application's normal quit path. */ + /* Exercise chart switching, mouse sorting, chart zoom/hover input, and + both shrink and grow paths before requesting the normal quit path. */ Sleep(700); DWORD written = 0; WriteFile(input_write, "c", 1, &written, NULL); @@ -248,12 +248,23 @@ int main(int argc, char **argv) { const char mouse_click[] = "\x1b[<0;72;11M\x1b[<0;72;11m"; WriteFile(input_write, mouse_click, (DWORD)(sizeof(mouse_click) - 1), &written, NULL); Sleep(300); + WriteFile(input_write, "c", 1, &written, NULL); + Sleep(300); COORD small = {60, 12}; HRESULT resize_small = ResizePseudoConsole(pseudo_console, small); Sleep(500); COORD large = {120, 30}; HRESULT resize_large = ResizePseudoConsole(pseudo_console, large); Sleep(500); + const char chart_wheel[] = "\x1b[<65;90;15M"; + WriteFile(input_write, chart_wheel, (DWORD)(sizeof(chart_wheel) - 1), &written, NULL); + Sleep(300); + const char chart_pan[] = "\x1b[<68;90;15M"; + WriteFile(input_write, chart_pan, (DWORD)(sizeof(chart_pan) - 1), &written, NULL); + Sleep(300); + const char chart_hover[] = "\x1b[<35;119;14M"; + WriteFile(input_write, chart_hover, (DWORD)(sizeof(chart_hover) - 1), &written, NULL); + Sleep(400); WriteFile(input_write, "q", 1, &written, NULL); DWORD wait = WaitForSingleObject(process.hProcess, 5000); @@ -285,18 +296,21 @@ int main(int argc, char **argv) { bool left_alternate = strstr(capture.data, "\x1b[?1049l") != NULL; bool rendered_chart = strstr(capture.data, "Allocated VRAM") != NULL; bool clicked_sort = strstr(capture.data, "GPU ^") != NULL; + bool zoomed_chart = strstr(capture.data, "Span 2m00s") != NULL; + bool rendered_hover = strstr(capture.data, "Point ") != NULL; bool no_debug_leaks = strstr(capture.data, "Detected memory leaks!") == NULL; bool passed = SUCCEEDED(resize_small) && SUCCEEDED(resize_large) && wait == WAIT_OBJECT_0 && exit_code == 0 && clears >= 1 && line_erases >= 20 && max_frame_lines <= 29 && max_frame_columns <= 120 && entered_alternate && left_alternate && rendered_chart && clicked_sort && - no_debug_leaks && !capture.overflow; + zoomed_chart && rendered_hover && no_debug_leaks && !capture.overflow; if (!passed) { fprintf(stderr, - "ConPTY test failed: exit=%lu wait=%lu clears=%zu line-erases=%zu max-frame-newlines=%zu max-frame-columns=%zu enter=%d leave=%d chart=%d mouse-sort=%d no-leaks=%d overflow=%d resize=0x%08lx/0x%08lx bytes=%zu\n", + "ConPTY test failed: exit=%lu wait=%lu clears=%zu line-erases=%zu max-frame-newlines=%zu max-frame-columns=%zu enter=%d leave=%d chart=%d mouse-sort=%d zoom=%d hover=%d no-leaks=%d overflow=%d resize=0x%08lx/0x%08lx bytes=%zu\n", (unsigned long)exit_code, (unsigned long)wait, clears, line_erases, max_frame_lines, max_frame_columns, - entered_alternate, left_alternate, rendered_chart, clicked_sort, no_debug_leaks, capture.overflow, + entered_alternate, left_alternate, rendered_chart, clicked_sort, + zoomed_chart, rendered_hover, no_debug_leaks, capture.overflow, (unsigned long)resize_small, (unsigned long)resize_large, capture.length); fputs("--- captured pseudoconsole output ---\n", stderr); fwrite(capture.data, 1, capture.length, stderr); From 2c231c3cd4a500c9d5590742d07f988d053898cb Mon Sep 17 00:00:00 2001 From: Seth <80608102+xptea@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:28:23 -0400 Subject: [PATCH 2/2] Fix WDDM memory anomalies and updater handoff --- CHANGELOG.md | 13 ++++++ README.md | 8 ++-- docs/architecture.md | 6 +-- src/main.c | 82 ++++++++++++++++++++------------- src/updater.c | 92 +++++++++----------------------------- src/util.c | 23 ++++++++++ src/version.h | 4 +- src/vgpu.h | 4 ++ tests/test_conpty.c | 7 +-- tests/test_core.c | 23 ++++++++++ tests/test_live_update.ps1 | 8 +++- 11 files changed, 156 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bdf4f..0366594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable user-facing changes are documented here. This project follows [Seman ## [Unreleased] +## [1.3.1] - 2026-07-13 + +### Fixed + +- Quarantine the complete WDDM dedicated-memory snapshot when Windows returns an impossible per-process value, preventing smaller stale values from being presented as trustworthy VRAM usage. +- Detach the installer helper from the terminal and stop relaunching a competing foreground monitor after the invoking shell regains keyboard input. +- Remove the persistent `Click headers to sort` hint from the interactive footer. + +### Security and quality + +- Return `null`/blank dedicated-memory values in JSON and CSV whenever the affected WDDM snapshot is quarantined. +- Exercise snapshot-wide WDDM quarantine and verify that live update handoff never relaunches an input-competing monitor. + ## [1.3.0] - 2026-07-13 ### Added diff --git a/README.md b/README.md index d421b57..53f99c2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It is a real full-screen terminal application, not a wrapper around `nvidia-smi` - Pause/resume and adjustable 250-5000 ms refresh interval - CSV logging - One-shot human-readable and JSON output for scripts -- Automatic, checksum-verified updates for installed interactive launches +- Automatic, checksum-verified background updates for installed interactive launches - Statically linked MSVC runtime; no NVML SDK or third-party runtime required ## Runtime requirements and compatibility @@ -63,7 +63,7 @@ vgpu --json Existing terminal processes keep their old environment. Uninstalling VGPU-Mon from Windows Settings removes the PATH entry only when the installer originally added it. -Starting with VGPU-Mon 1.2.0, an installed interactive launch checks the latest stable release's small `version.txt` manifest. If a newer version exists, VGPU-Mon downloads that exact versioned installer, verifies its SHA-256 with Windows cryptography, installs it without elevation, and reopens with the same interactive options. Network or GitHub failures do not prevent the monitor from opening. Redirected output and script commands (`--json`, `--once`, `--version`, and `--demo`) never perform an automatic update check. +Starting with VGPU-Mon 1.2.0, an installed interactive launch checks the latest stable release's small `version.txt` manifest. If a newer version exists, VGPU-Mon downloads that exact versioned installer, verifies its SHA-256 with Windows cryptography, and installs it without elevation. The command then returns cleanly; run `vgpu` again after a few seconds to start the updated monitor. The updater deliberately does not relaunch inside the old terminal because the shell would already own that terminal's input. Network or GitHub failures do not prevent the monitor from opening. Redirected output and script commands (`--json`, `--once`, `--version`, and `--demo`) never perform an automatic update check. Use `vgpu --update` to check immediately. Use `--no-update` for one launch or set `VGPU_MON_NO_UPDATE=1` to disable automatic checks. Portable ZIP copies do not update silently; `vgpu --update` converts a portable copy into the normal per-user installation. @@ -211,9 +211,9 @@ Options: - **DXGI 1.4** enumerates hardware adapters and reports local video-memory usage and the current OS memory budget. - **NVML** supplies NVIDIA board telemetry. VGPU-Mon uses the versioned memory API to separate driver/firmware-reserved VRAM from application allocation. `nvml.dll` is discovered and loaded at runtime; the project does not ship NVIDIA libraries. -VRAM figures from NVML, DXGI, and WDDM can differ because they describe different layers: physical board usage, the operating-system budget, and per-process commitments. A WDDM process commitment can exceed physical VRAM because it is not a resident-byte measurement. VGPU-Mon marks process columns with `*`, labels JSON/CSV fields as commitments, and keeps the physical total and OS budget separate. +VRAM figures from NVML, DXGI, and WDDM can differ because they describe different accounting layers. Per-process WDDM values include memory shared across processes, so adding every row can exceed the physical total even though a valid individual dedicated-memory reading cannot. VGPU-Mon marks process columns with `*`, labels JSON/CSV fields as commitments, and keeps the physical total and OS budget separate. -Windows also has a documented GPU Process Memory counter issue that can produce implausibly large per-process values. VGPU-Mon appends `!`, raises `wddm_process_memory_warning` in JSON, and preserves the raw value for diagnosis instead of silently clamping it. Physical board usage at the top remains sourced from NVML/DXGI. +Windows also has a documented GPU Process Memory counter issue that accumulates stale allocations and can produce implausibly large per-process values. If one process's dedicated value exceeds the GPU's physical total, VGPU-Mon quarantines that snapshot's entire dedicated column: every row displays `N/A !`, CSV fields are blank, JSON values are `null`, and `wddm_process_memory_warning` is raised. The complete snapshot is quarantined because smaller values from the same affected counter source can look plausible while still being stale. Physical board usage at the top remains sourced from NVML/DXGI. Terminating a process is not the same as resetting a GPU. VGPU-Mon deliberately does not expose driver resets or unsafe attempts to cancel individual command queues. diff --git a/docs/architecture.md b/docs/architecture.md index 8331aba..919e63b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -VGPU-Mon is a native Windows application. It has no service, kernel component, injected DLL, persistent background updater, or vendor SDK dependency. An installed interactive launch performs a bounded HTTPS update check before provider initialization; a verified update uses a short-lived PowerShell handoff only after the monitor exits. +VGPU-Mon is a native Windows application. It has no service, kernel component, injected DLL, persistent background updater, or vendor SDK dependency. An installed interactive launch performs a bounded HTTPS update check before provider initialization; a verified update uses a short-lived, console-detached PowerShell handoff only after the monitor exits. ## Data flow @@ -10,7 +10,7 @@ VGPU-Mon is a native Windows application. It has no service, kernel component, i 4. The sampler normalizes those sources into one `GpuTelemetry` snapshot plus bounded process rows and chart histories. 5. The renderer builds one bounded frame in memory and repaints the visible terminal region in a single write. -The updater reads `version.txt` from the latest stable GitHub Release, validates its strict version, installer, and hash fields, and compares semantic versions. It downloads an exact tag-specific installer to the user temporary directory and hashes it with Windows CNG before starting a transient installer helper. Offline, malformed, oversized, downgraded, or hash-mismatched responses fail closed for the update and fail open for monitoring. Non-interactive output paths do not access the network. +The updater reads `version.txt` from the latest stable GitHub Release, validates its strict version, installer, and hash fields, and compares semantic versions. It downloads an exact tag-specific installer to the user temporary directory and hashes it with Windows CNG before starting a transient installer helper. The helper has no inherited console handles and never relaunches a second foreground process after the invoking shell regains input ownership. Offline, malformed, oversized, downgraded, or hash-mismatched responses fail closed for the update and fail open for monitoring. Non-interactive output paths do not access the network. `--demo` replaces steps 1–3 with deterministic values while keeping the real sampling, layout, JSON, input, and rendering paths. It exists for testing and UI previews; it is never presented as hardware telemetry. @@ -28,7 +28,7 @@ The interactive UI uses Windows console input records and VT output. Every updat ## Accounting caveats -NVML physical allocation, DXGI OS budget/usage, and WDDM per-process commitments describe different layers and do not necessarily sum. The UI labels commitments explicitly and preserves implausible WDDM values with a warning instead of disguising a Windows counter anomaly. +NVML physical allocation, DXGI OS budget/usage, and WDDM per-process commitments describe different layers and do not necessarily sum. Microsoft documents that GPU Process Memory can accumulate stale allocations. If one dedicated row exceeds the physical GPU total, the entire dedicated snapshot is reported as unavailable: once the counter source is demonstrably corrupt, smaller rows cannot be distinguished safely from stale values. The displayed per-process GPU percentage is the busiest engine for that process, matching the accounting style used by Windows Task Manager. It is not additive across dissimilar engines. diff --git a/src/main.c b/src/main.c index 1a5f78f..a8e9b20 100644 --- a/src/main.c +++ b/src/main.c @@ -554,9 +554,11 @@ static void log_sample(App *app) { } sanitize_csv_field(process->name, process_name, sizeof(process_name)); sanitize_csv_field(process->engine, engine, sizeof(engine)); - fprintf(app->log_file, "%lu,\"%s\",%.2f,%llu,%llu,\"%s\"\n", - (unsigned long)process->pid, process_name, process->gpu_percent, - (unsigned long long)process->dedicated_bytes, + fprintf(app->log_file, "%lu,\"%s\",%.2f,", + (unsigned long)process->pid, process_name, process->gpu_percent); + if (!process->dedicated_memory_invalid) + fprintf(app->log_file, "%llu", (unsigned long long)process->dedicated_bytes); + fprintf(app->log_file, ",%llu,\"%s\"\n", (unsigned long long)process->shared_bytes, engine); } if (fflush(app->log_file) != 0 || ferror(app->log_file)) { @@ -681,15 +683,13 @@ static bool sample_app(App *app) { } else { app->process_count = 0; } - app->wddm_process_memory_suspect = false; - if (app->telemetry.memory_total) { - for (size_t i = 0; i < app->process_count; ++i) { - if (app->processes[i].dedicated_bytes > app->telemetry.memory_total) { - app->wddm_process_memory_suspect = true; - break; - } - } - } + /* Microsoft documents that one bad GPU Process Memory value means the + affected counter source is accumulating stale allocations. There is no + trustworthy way to identify the other poisoned rows, so quarantine the + dedicated column for the whole snapshot instead of allowing a smaller + but equally stale value (for example DWM) to look real. */ + app->wddm_process_memory_suspect = quarantine_invalid_dedicated_gpu_memory( + app->processes, app->process_count, app->telemetry.memory_total); update_visible_processes(app); add_history_sample(app); log_sample(app); @@ -835,8 +835,9 @@ static void render_help(TextBuffer *buffer) { " Home/End Oldest retained/live Hover Exact point value\n\n" "%sAccounting%s\n" " Process GPU %% is the busiest Windows GPU engine for that PID, matching Task Manager's\n" - " model. Dedicated/shared values come from WDDM counters. A trailing ! marks a value\n" - " larger than physical VRAM, a known Windows counter anomaly rather than residency.\n" + " model. Dedicated/shared values come from WDDM counters. If Windows returns one\n" + " impossible dedicated value, the full dedicated snapshot is shown as N/A because\n" + " other rows can contain the same documented stale-allocation counter error.\n" " Board telemetry comes from NVML; DXGI supplies a vendor-neutral memory fallback.\n", ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET); } @@ -955,10 +956,11 @@ static void render_process_table(App *app, TextBuffer *buffer, int columns, int char pid[32], gpu[32], dedicated[32], shared[32]; snprintf(pid, sizeof(pid), "%lu", (unsigned long)process->pid); snprintf(gpu, sizeof(gpu), "%.1f%%", process->gpu_percent); - format_bytes(process->dedicated_bytes, dedicated, sizeof(dedicated)); + if (process->dedicated_memory_invalid) + snprintf(dedicated, sizeof(dedicated), "N/A !"); + else + format_bytes(process->dedicated_bytes, dedicated, sizeof(dedicated)); format_bytes(process->shared_bytes, shared, sizeof(shared)); - if (app->telemetry.memory_total && process->dedicated_bytes > app->telemetry.memory_total) - strncat_s(dedicated, sizeof(dedicated), " !", _TRUNCATE); if (row == app->selection) text_buffer_append(buffer, "%s", ANSI_REVERSE); for (int i = 0; i < layout.indent; ++i) text_buffer_append(buffer, " "); @@ -1103,6 +1105,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row clamp_chart_window(app); clamp_chart_pan(app); + /* Define the visible range with monotonic sample timestamps so changing + the refresh interval cannot distort older data. */ ChartMetric metric = app->chart_metric; const char *unit = chart_metric_unit(metric); bool available = chart_metric_available(app, metric); @@ -1118,6 +1122,7 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row const int prefix_width = 8; int plot_width = columns - prefix_width; int chart_height = rows - 6; + /* Scratch columns are retained across frames and only grow on resize. */ if (!ensure_chart_plot_capacity(app, (size_t)plot_width)) { render_chart_view_fallback(app, buffer, columns, rows); return; @@ -1129,6 +1134,7 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row size_t first_visible = count; size_t last_visible = count; + /* Bucket real samples into terminal columns while preserving peaks. */ for (size_t i = 0; i < count; ++i) { ULONGLONG timestamp = chart_timestamp(app, i); if (timestamp < view_start || timestamp > view_end) continue; @@ -1145,6 +1151,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row } if (first_visible < count) { + /* Carry the last sample through empty columns to form a continuous + step chart without fabricating samples before history begins. */ size_t cursor = first_visible; bool have_value = false; double held_value = 0.0; @@ -1164,6 +1172,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row } double scale = 100.0; + /* Percent metrics use a fixed scale; temperature and power expand only + when the visible data exceeds their normal ceiling. */ if (metric == CHART_POWER) { scale = app->telemetry.power_limit_w > 0 ? app->telemetry.power_limit_w : 100.0; if (observed_max > scale) scale = ceil(observed_max * 1.10 / 10.0) * 10.0; @@ -1207,9 +1217,12 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row app->chart_plot_top = 3; app->chart_plot_bottom = 3 + chart_height - 1; + /* Resolve the pointer's time coordinate to the nearest stored sample and + prepare an ASCII tooltip that can be overlaid without changing width. */ int hover_plot_x = -1; int tooltip_row = -1; int tooltip_start = -1; + int tooltip_length = 0; char tooltip[96] = ""; char tooltip_display[96] = ""; if (app->chart_hover_active && @@ -1253,7 +1266,7 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row snprintf(tooltip, sizeof(tooltip), " No sample | %s ago ", age); } truncate_text(tooltip, tooltip_display, sizeof(tooltip_display), (size_t)plot_width); - int tooltip_length = (int)strlen(tooltip_display); + tooltip_length = (int)strlen(tooltip_display); tooltip_start = hover_plot_x + 2; if (tooltip_start + tooltip_length > plot_width) tooltip_start = hover_plot_x - tooltip_length - 1; @@ -1263,6 +1276,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row } const char *color = chart_color(metric); + /* Draw one bounded terminal row at a time. Tooltip-covered iterations + emit no cell because the first iteration emitted the complete overlay. */ for (int row = 0; row < chart_height; ++row) { double upper = scale * (double)(chart_height - row) / (double)chart_height; double lower = scale * (double)(chart_height - row - 1) / (double)chart_height; @@ -1271,11 +1286,13 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row else text_buffer_append(buffer, " |"); text_buffer_append(buffer, "%s", color); for (int x = 0; x < plot_width; ++x) { - if (row == tooltip_row && x == tooltip_start && tooltip_display[0]) { - text_buffer_append(buffer, "%s%s%s%s", ANSI_RESET, ANSI_REVERSE, - tooltip_display, ANSI_RESET); - text_buffer_append(buffer, "%s", color); - x += (int)strlen(tooltip_display) - 1; + if (row == tooltip_row && x >= tooltip_start && + x < tooltip_start + tooltip_length) { + if (x == tooltip_start) { + text_buffer_append(buffer, "%s%s%s%s", ANSI_RESET, ANSI_REVERSE, + tooltip_display, ANSI_RESET); + text_buffer_append(buffer, "%s", color); + } continue; } const char *cell = (row == chart_height / 2) ? "-" : " "; @@ -1401,7 +1418,7 @@ static void render_app(App *app, TextBuffer *buffer) { char footer[512], clipped[512]; const char *health = !app->pdh_ready && !app->demo_mode ? "! WDDM counters unavailable | " : app->wddm_process_memory_suspect ? "! WDDM process-memory anomaly | " : ""; - snprintf(footer, sizeof(footer), "%sClick headers to sort | %s %s | %zu processes | filter: %s | c chart | h help | q quit", + snprintf(footer, sizeof(footer), "%s%s %s | %zu processes | filter: %s | c chart | h help | q quit", health, sort_name(app->sort_mode), app->sort_descending ? "high-low" : "low-high", app->visible_count, app->filter[0] ? app->filter : "none"); @@ -1765,8 +1782,10 @@ static void print_once_json(const App *app) { const GpuProcess *process = app->visible[i]; if (i) putchar(','); printf("{\"pid\":%lu,\"name\":", (unsigned long)process->pid); write_json_string(process->name); - printf(",\"gpu_percent\":%.2f,\"dedicated_commit_bytes\":%llu,\"shared_commit_bytes\":%llu,\"engine\":", - process->gpu_percent, (unsigned long long)process->dedicated_bytes, + printf(",\"gpu_percent\":%.2f,\"dedicated_commit_bytes\":", process->gpu_percent); + if (process->dedicated_memory_invalid) fputs("null", stdout); + else printf("%llu", (unsigned long long)process->dedicated_bytes); + printf(",\"shared_commit_bytes\":%llu,\"engine\":", (unsigned long long)process->shared_bytes); write_json_string(process->engine); putchar('}'); @@ -1790,9 +1809,10 @@ static void print_once_table(const App *app) { const GpuProcess *process = app->visible[i]; char name[64], dedicated[32], shared[32]; truncate_text(process->name, name, sizeof(name), 32); + if (process->dedicated_memory_invalid) snprintf(dedicated, sizeof(dedicated), "N/A !"); + else format_bytes(process->dedicated_bytes, dedicated, sizeof(dedicated)); printf("%-8lu %-32s %7.1f%% %12s %12s %-14s\n", (unsigned long)process->pid, name, - process->gpu_percent, - format_bytes(process->dedicated_bytes, dedicated, sizeof(dedicated)), + process->gpu_percent, dedicated, format_bytes(process->shared_bytes, shared, sizeof(shared)), process->engine); } } @@ -1815,8 +1835,8 @@ static bool parse_chart_metric(const char *name, ChartMetric *metric) { } static void print_help(void) { - printf( - "VGPU-Mon %s - live Windows GPU process monitor\n\n" + fputs( + "VGPU-Mon " VGPU_VERSION " - live Windows GPU process monitor\n\n" "Usage: vgpu [options]\n\n" " --once Print one snapshot and exit\n" " --json Print one snapshot as JSON\n" @@ -1839,7 +1859,7 @@ static void print_help(void) { "Run without options in Windows Terminal for the interactive UI.\n" "Headers are mouse-clickable; click again to reverse the sort.\n" "Charts support wheel zoom, Shift+wheel or arrow-key panning, and exact hover values.\n", - VGPU_VERSION); + stdout); } static bool initialize_app(App *app) { diff --git a/src/updater.c b/src/updater.c index 16bab99..80175cf 100644 --- a/src/updater.c +++ b/src/updater.c @@ -3,7 +3,6 @@ #include #include -#include #include #include #include @@ -382,14 +381,6 @@ static bool executable_is_installed(const wchar_t *executable) { return _wcsicmp(current, directory) == 0; } -static bool get_default_installed_executable(wchar_t *path, size_t capacity) { - wchar_t local_app_data[MAX_PATH]; - if (SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA | CSIDL_FLAG_CREATE, NULL, - SHGFP_TYPE_CURRENT, local_app_data) != S_OK) return false; - return _snwprintf_s(path, capacity, _TRUNCATE, - L"%ls\\Programs\\VGPU-Mon\\vgpu.exe", local_app_data) > 0; -} - static bool script_append(ScriptBuffer *buffer, const char *text) { size_t length = strlen(text); if (!buffer || !buffer->data || length > buffer->capacity - buffer->length - 1U) @@ -400,30 +391,7 @@ static bool script_append(ScriptBuffer *buffer, const char *text) { return true; } -static bool script_append_quoted_wide(ScriptBuffer *buffer, const wchar_t *text) { - if (!script_append(buffer, "'")) return false; - int required = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, -1, - NULL, 0, NULL, NULL); - if (required <= 0) return false; - char *utf8 = (char *)HeapAlloc(GetProcessHeap(), 0, (size_t)required); - if (!utf8) return false; - bool success = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, -1, - utf8, required, NULL, NULL) > 0; - if (success) { - for (char *cursor = utf8; *cursor; ++cursor) { - if (*cursor == '\'') success = script_append(buffer, "''"); - else { - char value[2] = {*cursor, '\0'}; - success = script_append(buffer, value); - } - if (!success) break; - } - } - HeapFree(GetProcessHeap(), 0, utf8); - return success && script_append(buffer, "'"); -} - -static bool write_helper_script(const wchar_t *path, int argc, wchar_t **argv) { +static bool write_helper_script(const wchar_t *path) { ScriptBuffer script; memset(&script, 0, sizeof(script)); script.capacity = UPDATE_HELPER_CAPACITY; @@ -431,41 +399,22 @@ static bool write_helper_script(const wchar_t *path, int argc, wchar_t **argv) { if (!script.data) return false; bool success = script_append(&script, "[CmdletBinding()]\r\n" - "param([Parameter(Mandatory=$true)][string]$InstallerPath," - "[Parameter(Mandatory=$true)][string]$RelaunchPath)\r\n" + "param([Parameter(Mandatory=$true)][string]$InstallerPath)\r\n" "$ErrorActionPreference = 'Stop'\r\n" - "$relaunchArguments = @('--no-update'"); - if (success && updater_is_forced(argc, argv)) { - success = script_append(&script, ",'--version'"); - } - for (int index = 1; success && index < argc; ++index) { - if (wide_argument_equals(argv[index], L"--update") || - wide_argument_equals(argv[index], L"--no-update")) continue; - success = script_append(&script, ",") && - script_append_quoted_wide(&script, argv[index]); - } - success = success && script_append(&script, - ")\r\n" - "$updateError = $null\r\n" + "$exitCode = 1\r\n" "try {\r\n" " Start-Sleep -Milliseconds 750\r\n" " $process = Start-Process -FilePath $InstallerPath -ArgumentList " "@('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-') " "-Wait -PassThru -WindowStyle Hidden\r\n" - " if ($process.ExitCode -ne 0) { throw \"Setup exited with code " - "$($process.ExitCode).\" }\r\n" + " $exitCode = $process.ExitCode\r\n" "}\r\n" - "catch { $updateError = $_.Exception.Message }\r\n" + "catch { $exitCode = 1 }\r\n" "finally {\r\n" " Remove-Item -LiteralPath $InstallerPath -Force -ErrorAction SilentlyContinue\r\n" " Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue\r\n" "}\r\n" - "if ($updateError) { Write-Warning \"VGPU-Mon update failed: $updateError\" }\r\n" - "if (Test-Path -LiteralPath $RelaunchPath) {\r\n" - " & $RelaunchPath @relaunchArguments\r\n" - " exit $LASTEXITCODE\r\n" - "}\r\n" - "exit 1\r\n"); + "exit $exitCode\r\n"); HANDLE file = INVALID_HANDLE_VALUE; if (success) { @@ -488,8 +437,7 @@ static bool write_helper_script(const wchar_t *path, int argc, wchar_t **argv) { return success; } -static bool launch_helper(const wchar_t *helper, const wchar_t *installer, - const wchar_t *relaunch) { +static bool launch_helper(const wchar_t *helper, const wchar_t *installer) { wchar_t system_directory[MAX_PATH], powershell[MAX_PATH]; const size_t command_capacity = 32768U; wchar_t *command = (wchar_t *)HeapAlloc( @@ -502,8 +450,8 @@ static bool launch_helper(const wchar_t *helper, const wchar_t *installer, system_directory) <= 0 || _snwprintf_s(command, command_capacity, _TRUNCATE, L"\"%ls\" -NoLogo -NoProfile -ExecutionPolicy Bypass " - L"-File \"%ls\" -InstallerPath \"%ls\" -RelaunchPath \"%ls\"", - powershell, helper, installer, relaunch) <= 0) { + L"-File \"%ls\" -InstallerPath \"%ls\"", + powershell, helper, installer) <= 0) { HeapFree(GetProcessHeap(), 0, command); return false; } @@ -513,8 +461,12 @@ static bool launch_helper(const wchar_t *helper, const wchar_t *installer, memset(&startup, 0, sizeof(startup)); memset(&process, 0, sizeof(process)); startup.cb = sizeof(startup); - if (!CreateProcessW(powershell, command, NULL, NULL, TRUE, - CREATE_UNICODE_ENVIRONMENT, NULL, NULL, &startup, &process)) { + /* The invoking shell starts reading from the console again as soon as this + process exits. The installer helper must therefore be fully detached and + must never relaunch another foreground console reader in the same tab. */ + if (!CreateProcessW(powershell, command, NULL, NULL, FALSE, + CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + NULL, NULL, &startup, &process)) { HeapFree(GetProcessHeap(), 0, command); return false; } @@ -527,14 +479,13 @@ static bool launch_helper(const wchar_t *helper, const wchar_t *installer, VgpuUpdateResult updater_check_and_start(int argc, wchar_t **argv, const char *current_version, bool force) { - wchar_t current_executable[MAX_PATH], relaunch_path[MAX_PATH]; + (void)argc; + (void)argv; + wchar_t current_executable[MAX_PATH]; if (!get_current_executable(current_executable, _countof(current_executable))) return VGPU_UPDATE_ERROR; bool installed = executable_is_installed(current_executable); if (!installed && !force) return VGPU_UPDATE_SKIPPED; - if (installed) wcscpy_s(relaunch_path, _countof(relaunch_path), current_executable); - else if (!get_default_installed_executable(relaunch_path, _countof(relaunch_path))) - return VGPU_UPDATE_ERROR; char manifest_text[UPDATE_MAX_MANIFEST_BYTES + 1U]; size_t manifest_size = 0; @@ -576,13 +527,14 @@ VgpuUpdateResult updater_check_and_start(int argc, wchar_t **argv, !sha256_file(installer_path, actual_hash) || memcmp(actual_hash, manifest.sha256, sizeof(actual_hash)) != 0) goto cleanup; installer_ready = true; - if (!write_helper_script(helper_path, argc, argv)) goto cleanup; + if (!write_helper_script(helper_path)) goto cleanup; helper_ready = true; - printf("Updating VGPU-Mon %s to %s; the monitor will reopen automatically...\n", + printf("Updating VGPU-Mon %s to %s in the background. " + "Run vgpu again in a few seconds.\n", current_version, manifest.version); fflush(stdout); - if (!launch_helper(helper_path, installer_path, relaunch_path)) goto cleanup; + if (!launch_helper(helper_path, installer_path)) goto cleanup; SecureZeroMemory(actual_hash, sizeof(actual_hash)); return VGPU_UPDATE_STARTED; diff --git a/src/util.c b/src/util.c index de2817e..3d0f17c 100644 --- a/src/util.c +++ b/src/util.c @@ -88,6 +88,29 @@ bool contains_case_insensitive(const char *haystack, const char *needle) { return false; } +bool dedicated_gpu_memory_plausible(uint64_t bytes, uint64_t physical_total) { + return physical_total == 0 || bytes <= physical_total; +} + +bool quarantine_invalid_dedicated_gpu_memory(GpuProcess *processes, size_t count, + uint64_t physical_total) { + if (!processes || physical_total == 0) return false; + bool invalid = false; + for (size_t i = 0; i < count; ++i) { + if (!dedicated_gpu_memory_plausible(processes[i].dedicated_bytes, + physical_total)) { + invalid = true; + break; + } + } + if (!invalid) return false; + for (size_t i = 0; i < count; ++i) { + processes[i].dedicated_memory_invalid = true; + processes[i].dedicated_bytes = 0; + } + return true; +} + void iso_timestamp(char *buffer, size_t buffer_size) { SYSTEMTIME st; GetLocalTime(&st); diff --git a/src/version.h b/src/version.h index 395d02c..7e57ec0 100644 --- a/src/version.h +++ b/src/version.h @@ -1,7 +1,7 @@ #ifndef VGPU_VERSION_H #define VGPU_VERSION_H -#define VGPU_VERSION "1.3.0" -#define VGPU_VERSION_NUM 1,3,0,0 +#define VGPU_VERSION "1.3.1" +#define VGPU_VERSION_NUM 1,3,1,0 #endif diff --git a/src/vgpu.h b/src/vgpu.h index 1e6bfe9..1855a61 100644 --- a/src/vgpu.h +++ b/src/vgpu.h @@ -54,6 +54,7 @@ typedef struct { double gpu_percent; uint64_t dedicated_bytes; uint64_t shared_bytes; + bool dedicated_memory_invalid; char engine[48]; } GpuProcess; @@ -90,6 +91,9 @@ const char *format_rate(double mib_s, char *buffer, size_t buffer_size); void wide_to_utf8(const wchar_t *source, char *target, size_t target_size); void sanitize_csv_field(const char *source, char *target, size_t target_size); bool contains_case_insensitive(const char *haystack, const char *needle); +bool dedicated_gpu_memory_plausible(uint64_t bytes, uint64_t physical_total); +bool quarantine_invalid_dedicated_gpu_memory(GpuProcess *processes, size_t count, + uint64_t physical_total); void iso_timestamp(char *buffer, size_t buffer_size); bool query_process_details(DWORD pid, ProcessDetails *details); bool terminate_process_safely(DWORD pid, char *message, size_t message_size); diff --git a/tests/test_conpty.c b/tests/test_conpty.c index 4506116..abaf92f 100644 --- a/tests/test_conpty.c +++ b/tests/test_conpty.c @@ -298,19 +298,20 @@ int main(int argc, char **argv) { bool clicked_sort = strstr(capture.data, "GPU ^") != NULL; bool zoomed_chart = strstr(capture.data, "Span 2m00s") != NULL; bool rendered_hover = strstr(capture.data, "Point ") != NULL; + bool no_sort_hint = strstr(capture.data, "Click headers to sort") == NULL; bool no_debug_leaks = strstr(capture.data, "Detected memory leaks!") == NULL; bool passed = SUCCEEDED(resize_small) && SUCCEEDED(resize_large) && wait == WAIT_OBJECT_0 && exit_code == 0 && clears >= 1 && line_erases >= 20 && max_frame_lines <= 29 && max_frame_columns <= 120 && entered_alternate && left_alternate && rendered_chart && clicked_sort && - zoomed_chart && rendered_hover && no_debug_leaks && !capture.overflow; + zoomed_chart && rendered_hover && no_sort_hint && no_debug_leaks && !capture.overflow; if (!passed) { fprintf(stderr, - "ConPTY test failed: exit=%lu wait=%lu clears=%zu line-erases=%zu max-frame-newlines=%zu max-frame-columns=%zu enter=%d leave=%d chart=%d mouse-sort=%d zoom=%d hover=%d no-leaks=%d overflow=%d resize=0x%08lx/0x%08lx bytes=%zu\n", + "ConPTY test failed: exit=%lu wait=%lu clears=%zu line-erases=%zu max-frame-newlines=%zu max-frame-columns=%zu enter=%d leave=%d chart=%d mouse-sort=%d zoom=%d hover=%d no-sort-hint=%d no-leaks=%d overflow=%d resize=0x%08lx/0x%08lx bytes=%zu\n", (unsigned long)exit_code, (unsigned long)wait, clears, line_erases, max_frame_lines, max_frame_columns, entered_alternate, left_alternate, rendered_chart, clicked_sort, - zoomed_chart, rendered_hover, no_debug_leaks, capture.overflow, + zoomed_chart, rendered_hover, no_sort_hint, no_debug_leaks, capture.overflow, (unsigned long)resize_small, (unsigned long)resize_large, capture.length); fputs("--- captured pseudoconsole output ---\n", stderr); fwrite(capture.data, 1, capture.length, stderr); diff --git a/tests/test_core.c b/tests/test_core.c index 008b577..84ef9d6 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -24,6 +24,29 @@ static void test_format_bytes(void) { EXPECT(strcmp(format_bytes(0, buffer, sizeof(buffer)), "0 B") == 0); EXPECT(strcmp(format_bytes(1024, buffer, sizeof(buffer)), "1.00 KiB") == 0); EXPECT(strcmp(format_bytes(1024ULL * 1024ULL * 1024ULL, buffer, sizeof(buffer)), "1.00 GiB") == 0); + EXPECT(dedicated_gpu_memory_plausible(16ULL * 1024ULL * 1024ULL * 1024ULL, + 16ULL * 1024ULL * 1024ULL * 1024ULL)); + EXPECT(!dedicated_gpu_memory_plausible(75ULL * 1024ULL * 1024ULL * 1024ULL, + 16ULL * 1024ULL * 1024ULL * 1024ULL)); + EXPECT(dedicated_gpu_memory_plausible(UINT64_MAX, 0)); + + GpuProcess valid[2] = {{0}}; + valid[0].dedicated_bytes = 9ULL * 1024ULL * 1024ULL * 1024ULL; + valid[1].dedicated_bytes = 512ULL * 1024ULL * 1024ULL; + EXPECT(!quarantine_invalid_dedicated_gpu_memory( + valid, _countof(valid), 16ULL * 1024ULL * 1024ULL * 1024ULL)); + EXPECT(!valid[0].dedicated_memory_invalid && valid[0].dedicated_bytes != 0); + + GpuProcess corrupt[3] = {{0}}; + corrupt[0].dedicated_bytes = 9ULL * 1024ULL * 1024ULL * 1024ULL; + corrupt[1].dedicated_bytes = 75ULL * 1024ULL * 1024ULL * 1024ULL; + corrupt[2].dedicated_bytes = 512ULL * 1024ULL * 1024ULL; + EXPECT(quarantine_invalid_dedicated_gpu_memory( + corrupt, _countof(corrupt), 16ULL * 1024ULL * 1024ULL * 1024ULL)); + for (size_t i = 0; i < _countof(corrupt); ++i) { + EXPECT(corrupt[i].dedicated_memory_invalid); + EXPECT(corrupt[i].dedicated_bytes == 0); + } } static void test_parse_gpu_instance(void) { diff --git a/tests/test_live_update.ps1 b/tests/test_live_update.ps1 index efbd6f8..1453a4e 100644 --- a/tests/test_live_update.ps1 +++ b/tests/test_live_update.ps1 @@ -103,6 +103,12 @@ try { throw "The updater did not install VGPU-Mon $ExpectedVersion before the timeout." } + $unexpectedRelaunch = @(Get-Process -Name 'vgpu' -ErrorAction SilentlyContinue | + Where-Object { $_.Path -and $_.Path -ieq $installedExe }) + if ($unexpectedRelaunch.Count -ne 0) { + throw 'The updater relaunched a foreground monitor after returning terminal ownership.' + } + $remainingTemporaryFiles = @(Wait-UpdaterTemporaryCleanup -Seconds 30) if ($remainingTemporaryFiles.Count -ne 0) { throw "The updater handoff did not finish: $($remainingTemporaryFiles.FullName -join ', ')" @@ -146,4 +152,4 @@ if ($newTemporaryFiles.Count -ne 0) { throw "Updater temporary files were not cleaned: $($newTemporaryFiles.FullName -join ', ')" } -Write-Host "Live automatic update, relaunch, PATH, cleanup, and uninstall tests passed for $ExpectedVersion." +Write-Host "Live automatic update, clean terminal return, PATH, cleanup, and uninstall tests passed for $ExpectedVersion."