Skip to content

Implements comprehensive GPU/NPU monitoring and service enhancements - #54

Merged
TekuSP merged 20 commits into
masterfrom
feature/addNPUGPU
Jul 26, 2026
Merged

Implements comprehensive GPU/NPU monitoring and service enhancements#54
TekuSP merged 20 commits into
masterfrom
feature/addNPUGPU

Conversation

@TekuSP

@TekuSP TekuSP commented Jul 26, 2026

Copy link
Copy Markdown
Owner

This major update delivers live GPU and NPU utilization monitoring across Windows and Linux, alongside significant performance optimizations and critical service/UI stability improvements.

Added

  • Live GPU and NPU Utilization: Introduces real-time utilization percentages for graphics adapters and neural processing units. On Windows, this uses persistent PDH queries and SetupAPI for device matching. On Linux, it leverages amdgpu sysfs for AMD, runtime-loaded NVML for NVIDIA, and kernel PMU/sysfs for Intel GPUs and NPUs (including AMD XDNA ioctl). Each device reports its own utilization, never blended.
  • Enhanced Device Capabilities:
    • Linux Graphics and Displays: Device Capabilities now populates graphics adapters and displays directly from the kernel's DRM tree and EDID, enabling headless operation and accurate display attribution.
    • NPU Hardware Details: The Neural processor page now displays comprehensive hardware information including vendor, driver, firmware version, and location, complementing the utilization graph.
    • Dynamic Category Visibility: Device Capabilities categories without detected hardware now disable their rail entries, providing a clearer user experience.
  • In-App Service Logs: A new "Settings ▸ Logs" page displays a bounded, in-memory buffer of both service and app logs, interleaved by timestamp with filtering capabilities, facilitating easier troubleshooting and bug reporting.
  • Telemetry Rate-Limiting Analyzer (SZF0013): A new code analysis rule ensures high-frequency telemetry subscriptions apply appropriate rate-limiting operators (Batch for change sets, Sample/Throttle for snapshots) to prevent UI-thread starvation.

Changed

  • Telemetry Stream Pacing: UI subscriptions to telemetry streams now apply rate-limiting operators (Sample, Batch) before marshalling to the UI thread. This significantly reduces UI processing overhead, especially for history charts which previously re-processed entire windows on every update.
  • Consolidated Telemetry Mapping: The internal wire protocol enumeration mapping is now shared between the service and the client, preventing silent data loss or misattribution due to enum drift.
  • Optimized UI Resource Management: Theme brushes and chart paint objects are now cached and recreated only when necessary, eliminating excessive allocations of native Skia objects that previously contributed to UI-thread finalizer overhead.
  • Service Hardening: Applies systemd sandboxing directives to the Linux service, enhancing security and resource isolation.

Fixed

  • Stale Fan Duty Reporting: When the service stops actively driving a fan (e.g., mode switched to Auto), it now explicitly restores firmware control, preventing the fan from remaining at a last-commanded duty while the UI incorrectly reports "Auto".
  • In-App Uninstall Logic: The Windows uninstall button now correctly triggers the official Windows Installer uninstall process for installed builds, ensuring proper application removal. For development/extracted builds, it clarifies that only the service is removed.
  • Analyzer Rule Accuracy: Corrects SZF0004 (ObserveOn) and SZF0005 (DisposeWith) to accurately apply to IObservable receivers and recognizes legitimate Rx patterns, reducing false positives. ObserveOn is no longer required in non-UI projects.
  • Unreadable Error Status Text: Improves readability of critical status indicators (e.g., "Unavailable", "Stalled", ≥85°C temperatures, critical battery) by adjusting their foreground colors for better contrast on dark backgrounds.
  • Telemetry Channel Misattribution: Prevents older client versions from misinterpreting unknown telemetry channel IDs by explicitly rejecting unrecognized enum values rather than defaulting, which previously could cause GPU percentages to be incorrectly assigned to thermal sensors.
  • Fan Curve "Apply All" Logic: Ensures that explicitly staged fan modes (Manual/Max) are applied ahead of any parked custom curve drafts, preventing the unintended re-application of a custom curve when switching to a simple mode.
  • Service Crash Recovery: Guarantees that fan control is safely restored to firmware when the background service host crashes, preventing fans from remaining at unsafe manual settings.

Relates to feature/addNPUGPU

TekuSP added 13 commits July 25, 2026 22:57
Adds Settings -> Service logs: what the background service has logged since it
started, with a level filter and one-click "Copy all" for bug reports. The
service mirrors its log into a bounded in-memory buffer (last 2000 entries)
alongside the Event Log / journald sinks rather than reading them back, so one
implementation covers both platforms; when older entries have been evicted the
page says so instead of implying a complete history.

Fixed:

- Telemetry history was sorted and rebuilt into an array AFTER being marshalled
  to the UI thread, on streams that re-emit their whole window ~3x/s per series.
  With several series live (one per sensor, per fan, per battery metric) that
  starved rendering and left pages half-painted. History streams are now sampled
  and projected off the UI thread, which only receives the finished array.
  Fixed in FanHistoryStore, ThermalTelemetryModel and PowerTelemetryModel; the
  telemetry UI guide documented the broken order and now documents the correct
  one, which is why it had spread to four places.
- "Apply all" put an unselected fan back on its curve instead of applying the
  mode staged on it. The fan still looked curve-driven to the service - exactly
  what the staged change was about to end - so the curve branch always matched,
  re-activated the parked draft, and cleared the staged mode unapplied. A staged
  simple mode is now applied ahead of any parked curve draft, matching the order
  the staged-work check already used.
- A fan handed back to firmware control kept reporting the duty it was last
  commanded; that duty is now cleared as part of the handover, with mode, curve
  and driving sensors untouched.
- Mode sync polled an unattached navigation region, logging "Unable to find
  service provider for root navigator" on every tick; it now waits for the
  region host's Loaded event.
- x:Bind bindings left at the OneTime default evaluated against a null ViewModel
  (navigation assigns it after InitializeComponent) and never refreshed - an
  empty level filter on the new logs page, and three command bindings with the
  same latent trap.
Device Capabilities now reports how busy each GPU and NPU is, on Windows.
Every graphics adapter's detail carries its own live utilization - current
percentage over a rolling 30-second graph, the same card the CPU uses per
core - and a new Neural processor category presents the NPU the way the CPU
category presents packages: a count, a processor list, and the picked
device's detail. On a Framework 16 that is the integrated Radeon, the
discrete GPU module and the Ryzen AI NPU, each with its own reading, never
blended into one number.
The service samples the "GPU Engine" performance counters through one
persistent PDH query (~1.5 ms per second-tick, measured; reopening the query
each tick costs ~3 s) and derives busy time from Running Time deltas over
elapsed wall time - Utilization Percentage reads 0.00 on this hardware while
Running Time accumulates. Per adapter it takes the maximum across engine
types, which is what Task Manager shows. Devices are identified through
SetupAPI by adapter LUID and named from the PnP device description; a
counter-side adapter with no physical device behind it (WARP, ~250 instances)
is dropped rather than synthesized a name. All Windows interop goes through
Vanara (Pdh, SetupAPI), never hand-written DllImport.
Core and Service are now multi-targeted (net10.0-windows10.0.26100 first, so
Visual Studio's F5 does not silently run the Linux flavor; then net10.0). The
readers and their Vanara references exist only in the Windows TFM behind
#if WINDOWS10_0_26100_0_OR_GREATER - a linux-x64 publish contains no Vanara
assemblies and no reader types, needs nothing new at install time, and shows
an honest empty state until the per-vendor Linux readers land. Publish
profiles and both packaging scripts pass an explicit -f; EnableWindowsTargeting
keeps the ubuntu test job restoring.
Wire contract: new Compute area, Gpu/Npu entity kinds and UtilizationPercent
metric, published as one telemetry channel per device with a stable per-device
index.
Also: categories with nothing in them are disabled in the rail (dimmed, with
"No graphics detected" on hover) instead of opening onto an empty state.
Onboard devices and System profile always stay open, so a machine with no
service still has somewhere to land. This is what Linux shows for Graphics
today.
Fixed:
- Error status text was drawn in the palette's dark red fill tone (#442726)
  as a foreground - "Unavailable", "Stalled", "Not Present", a critical
  battery, a >=85 C reading - which is near-invisible on the dark card. They
  now use StatusErrorTextBrush like the rest of the app. Same swap for the
  usage bar past 90%, whose fill was the odd one out beside its bright green
  and amber siblings.
- The app's copy of the telemetry enum mapper parsed an unknown wire value
  into the first member instead of rejecting it, yielding a valid-looking
  (Thermal, TemperatureSensor, TemperatureCelsius) identity that collided
  with real sensors - a GPU percentage could enter the thermal cache as
  degrees. That is how compute telemetry silently vanished during
  development. Unknown values are now rejected on both sides, and the stream
  appliers skip the update instead of throwing, so a newer service can no
  longer end an older client's subscription.
Device Capabilities was empty on Linux: Hardware.Info enumerates both the
video controller and monitor lists by shelling out to xrandr, which needs a
display server the background service does not have. Both now come from the
kernel instead. Adapters are read from /sys/class/drm and named through the
system pci.ids database; displays come from each DRM connector's EDID, which
is decoded here rather than shelled out for - model, manufacturer, product
code, serial, manufacture date, physical size and density. This works
headless and behaves identically under X11, Wayland or no session at all.
Current resolution and refresh rate come from the kernel's mode-setting
state via four read-only DRM ioctls on /dev/dri/cardN, not from the EDID.
The distinction matters: a 165 Hz laptop panel advertises a 60 Hz "preferred"
timing in its base EDID block because the high-refresh modes live in a
DisplayID extension, so the EDID answer would be wrong on exactly the panels
people care about. The connector query deliberately passes a non-zero mode
count - asking for zero is the documented trigger for a full DDC re-probe of
a live display, which a service must not do on every refresh. Nothing here
takes DRM master or sets a mode.
Because a connector belongs to its card by kernel topology (card0-eDP-1),
each display is attributed to its GPU exactly, rather than by the
display-name matching heuristic the Windows path is stuck with.
GPU utilization now covers all three vendors, one card per device as on
Windows:
- AMD reads the amdgpu driver's own gpu_busy_percent, already a percentage.
- NVIDIA goes through NVML, dlopen'd at runtime by soname with distro path
  fallbacks, gated on /proc/driver/nvidia/version so a machine with the
  module deliberately blacklisted is never made to autoload it. There is no
  link-time or package dependency; on an AMD-only machine the load fails and
  the reader is silent.
- Intel reads the i915 or xe PMU through perf_event_open. Engines are
  DISCOVERED, not derived: i915 publishes one events/<engine>-busy entry per
  engine that physically exists, with the exact config value, which sidesteps
  hand-packing the class/instance bit layout and automatically respects
  fused-off engines and multi-GT parts. xe is a separate interface - active
  and total GuC tick counters whose ratio needs no wall clock, with the
  config field shifts read from the PMU's own format/ directory - so its
  layout is discovered too. Per class the mean over instances, across classes
  the maximum, never the sum: engines run concurrently and a sum exceeds 100%
  while describing nothing.
A composite reader merges the sources, since a Framework 16 with the graphics
module runs two at once and Linux has no single counter set covering every
vendor the way Windows' GPU Engine does. A source that throws is dropped for
that tick and the rest still publish.
POWER: sleeping GPUs are not woken to be measured. Reading AMD's busy
attribute reaches the SMU, and any per-device NVML call takes a runtime-PM
reference; either can hold a discrete GPU awake, which is how monitoring
tools have historically wrecked laptop battery life. Both readers consult
power/runtime_status first and report a suspended GPU as 0% - which is what
it is - without touching it. There is a test that fails if that is optimized
away.
Where a reading genuinely cannot be taken, nothing is published. Intel's
counters need kernel 6.15+ on xe-only GPUs, and perf_event_open is blocked
outright by a container's default seccomp policy even for root. Two
alternatives were considered and rejected rather than shipped as
approximations: inverting RC6 residency reads ~100% busy whenever the GPU is
merely awake, which in a fan-control app would command maximum fan at idle;
and summing per-process DRM fdinfo is per-client, double-counts shared file
descriptors, misses kernel-side work, requires walking all of /proc every
tick, and on xe forces the GPU awake to be read.
Gating differs from the Windows readers on purpose. Those are excluded by
#if because their TFM is Windows-only, but net10.0 is shared between the
Linux service and the desktop app head, so these are plain file I/O over an
injectable sysfs root - which is what makes the enumeration testable off
Linux - and are selected at DI registration instead. The single genuine OS
check sits where libc is called directly.
The catch around app.RunAsync() resolved FrameworkShutdownCoordinator from
app.Services, but by that point the host has already disposed its service
provider, so the resolve threw ObjectDisposedException and replaced the crash
handler wholesale: StopTelemetryLoops never ran (leaving fans on whatever
override the last curve applied, despite the comment right above saying the
point was to restore them), the LogCritical describing the real cause never
appeared, and the process died as an unhandled exception rather than
returning FatalExitCode, which is the signal systemd/SCM restart-on-failure
keys off.
The coordinator is now resolved into a local before the host runs. It is a
singleton the host constructs anyway (it doubles as a hosted service), so
holding it early costs nothing, and StopTelemetryLoops already tolerates a
provider that disposal beat it to.
Reproduced by running the service unprivileged on Linux so Kestrel's bind of
the unix socket in /run fails: previously that ended in ObjectDisposedException
with no restore; it now logs the crash, runs the telemetry shutdown, and exits
with FatalExitCode.
…at: Linux NPU utilization, and NPU device details on both platforms

refactor: one telemetry wire mapper instead of a copy per side
The service and the app each carried their own model<->wire enum mapping.
They drifted, and the failure was silent: compute telemetry was added to the
service's copy and not the app's, so every GPU and NPU reading was discarded,
and the app's TryParse made it worse by mapping unknown values onto the FIRST
enum member instead of rejecting them - turning an unrecognised channel into a
valid (Thermal, TemperatureSensor, TemperatureCelsius) identity that collided
with a real sensor and fed GPU percentages into the thermal cache as degrees.
Both copies now forward to TelemetryWireMapper in SubZeroFramework.GrpcContracts,
which gained a project reference to Core so it can own the translation; Core
stays free of any protobuf dependency. A test asserts the service mapper and
the shared mapper agree for every enum value, so re-inlining a copy fails the
build rather than dropping readings in the field a year later.Completes the GPU/NPU plan. Intel NPUs (ivpu) report through npu_busy_time_us,
a cumulative busy-microsecond counter that touches no hardware to read and
leaves a runtime-suspended NPU suspended; the kernel's own documentation asks
for ~1 s sampling, which is the telemetry tier's cadence. It measures a
queue-non-empty duty cycle rather than occupancy, so the UI calls it busy time.
AMD Ryzen AI NPUs (amdxdna) report per-column utilization through the sensor
ioctl, averaged across columns - the mean, not the maximum, because columns are
independently schedulable slices, which is the opposite of the GPU engine
readers where concurrency makes the maximum honest. It is narrowly available:
a kernel new enough to carry column utilization, AMD's platform-management
driver bound, and a Strix/Krackan part. Where unsupported the query fails and
the reader latches off after one attempt.
That latch matters because of a correction from adversarial verification of the
research: reading AMD's sensors DOES resume the NPU. The runtime-PM reference
lives in the driver callback, not the ioctl entry point that first appeared to
lack one, so a 1 Hz poll would hold the NPU powered up permanently. The reader
therefore checks the sysfs power state first and answers a suspended NPU as 0%
without touching it - the same rule the AMD and NVIDIA GPU readers follow. A
test pins it: the fixture has no device node, so the 0% can only come from the
power gate.
The Neural processor page also now shows what the device IS. A compute
accelerator inventory (vendor, driver, driver version, firmware, location,
description) travels with the hardware snapshot and renders as stat tiles
beside the utilization graph, matching what the CPU and graphics pages show.
Windows fills it from the device properties; Linux from /sys/class/accel,
including the NPU firmware version where the driver publishes one. Live cards
and inventory entries are joined by name, which is exact rather than heuristic
because both come from the same platform source on each OS.
…mise

docs: close two validated release items, drop the config-override promise
Validated the two open items rather than assuming the plan was current, and
both were stale in the same direction - describing work that no longer exists.
Packaged-helper discovery (P0-1) is resolved by how the product actually ships:
the MSI installs the service to INSTALLFOLDER\service-package\windows, which is
exactly the second path the probe checks, so PackagedHelperAvailable is true on
every installed Windows build. The "only true in CI artifacts" wording predates
the installer. On Linux it is false BY DESIGN - install/update/uninstall are
withdrawn in favour of apt/dnf/pacman while restart/shutdown/autorun keep
working through the systemctl fallback. The packaging section of the same
document already recorded this; the two halves simply disagreed.
framework-dotnet metadata versions (P1-6) is blocked on the dependency, and the
SubZero half is already written: SettingsAboutSectionModel.ResolveAssemblyMetadata
reflects over AssemblyMetadataAttribute and degrades to a placeholder, so the two
rows light up with no code change here once the library embeds the keys.
Confirmed by dumping every assembly-level attribute of the resolved
FrameworkDotnet 0.8.213 - it carries exactly one AssemblyMetadataAttribute, key
RepositoryUrl. There is no fallback route either: the native DLL has no
VERSIONINFO resource and no version export. What remains is a small MSBuild
target in framework-dotnet reading the two Cargo.toml files it already has paths
to, then a package bump here.
Also stops offering the ServiceControl:* path overrides as a support escape
hatch. They bind only from an embedded appsettings.json that is never copied to
the publish output, so an installed app has no file to edit and they cannot be
set post-install - they are build-time only. That is now stated on the options
type itself rather than only in a plan document, since it is the wrong-turn a
reader takes while debugging a layout problem.
…the MSI

fix: in-app uninstall runs the real uninstaller instead of orphaning the MSI
The Uninstall button deleted the service's SCM registration directly - the same
registration the installer declaratively owns (ServiceControl Remove="uninstall"
Stop="both"). Uninstalling in-app and then removing SubZero through Add/Remove
Programs therefore left Windows Installer deleting a service that was already
gone, and left its view of the machine wrong in between.
On an installed Windows build the button now hands over to the real uninstaller
(msiexec /x{ProductCode}) and closes the app so its files can actually be
removed. It deliberately does NOT uninstall the service first: the package stops
and deregisters it as part of its own uninstall, and tearing it down early would
strand the machine installed-but-serviceless if the user then cancelled the
installer.
The ProductCode cannot be hardcoded. packaging/windows/subzeroframework.wxs
authors none, so WiX mints a fresh one on every build (verified by building the
same file twice and diffing the Property tables) and a major upgrade replaces it
again. The product is therefore located through the stable UpgradeCode via
MsiEnumRelatedProducts, using Vanara.PInvoke.Msi per the standing interop rule,
referenced from the Windows head only - on Linux the distro package manager owns
uninstall and the Skia head must not carry it.
That leaves the UpgradeCode as a second source of truth across two files, and
drift would fail silently in the worst way: the button would decide the app "was
not installed by the installer" and quietly fall back to service-only removal on
a machine where the MSI owns that service. Two tests pin it - one comparing the
constant against the .wxs, one that fails if a ProductCode is ever authored,
since that would make this whole approach unnecessary.
Service-only uninstall is kept for builds that did not come from the installer,
because the app can still register a service there and must be able to remove
it; the button label switches between "Uninstall SubZero" and "Uninstall
service" so the two actions are never confusable. Both paths now confirm first -
msiexec /x preselects the remove action, which suppresses the installer's own
maintenance confirmation, so this dialog is the only one the user sees. It says
saved fan profiles are kept, which is true: RemoveFolderEx is On="install" and
gated on CLEANINSTALL, so %ProgramData%\SubZeroFramework survives.
Elevation runs off the UI thread (the consent prompt blocks until dismissed, and
blocking the dispatcher behind the secure desktop freezes the window), and a
declined prompt is reported rather than treated as success. msiexec is never
waited on - it cannot finish until this process exits.
NOT verified on hardware: the MSI is not installed on the development machine,
so MsiEnumRelatedProducts has not run against a real product and the uninstall
UI has not been observed. First real run should be on a machine that can be
reinstalled.
A release-build CPU trace of the idle Dashboard showed the app burning 59% of
one core, and 91% of that was the FINALIZER thread - not the UI thread, which
was only 3.75%. The breakdown: WinRT.IObjectReference.Finalize 50.3% and
SkiaSharp SKFont.DisposeNative 25.9%. Both trace back to one pattern: native and
WinRT objects allocated inside property getters that re-evaluate at telemetry
rate, then abandoned.
AppThemeBrushes.Get now caches. It is called from ~157 sites, many of them
getters that re-run every tick, and each call reached Application.Current
.Resources - a WinRT projection allocated per call and immediately garbage. The
brushes themselves were never the problem: a resource brush is already a single
shared instance owned by the application's ResourceDictionary, so handing out
the same reference is exactly what StaticResource does. Only the repeated lookup
goes away. The cache never expires because App.xaml pins RequestedTheme="Dark"
and every colour here is a constant; the code says so, and says what must change
if a light theme is ever added.
The usage-card tier visuals become STORED values instead of computed ones, in
both the CPU-core and compute-device models. As getters they allocated a fresh
SolidColorPaint - wrapping a native Skia object nobody disposed - on every
evaluation, once per core and per device per second. They are now rebuilt only
when the tier actually changes, which happens when load crosses 1%, 50% or 90%.
The previous paint is deliberately not disposed: LiveCharts may still be drawing
with it, and a use-after-dispose on a native handle is far worse than one
garbage object per tier transition. This also brings both models in line with
the project's existing "store derived values, do not compute in getters" rule.
Measured on the same Debug build and conditions as the baseline, Dashboard idle:
app CPU went from a sustained 31-54% to 0-6% with occasional redraw spikes,
roughly a 6-10x reduction. The absolute numbers are Debug; the trace that
identified the cause was Release, and the mechanism is identical.
Not separable from this change: how much came from the WinRT half versus the
Skia half, since both were fixed together. A fresh Release trace would tell us,
and would also re-rank what is left.
…reporting Auto

Two defects in the same path, found while investigating the recorded
"config reload clobbers a live preview" note. The note understated it.
The service watches the very configuration file it writes, so EVERY persisting
fan command - and a Settings save - triggers a reload, and the reload re-applied
the persisted overlay to ALL fans, not just the one that changed. That made the
persisted file behave as a live authority, which the per-tick path already
refuses to do for exactly this reason (see the comment on the telemetry
overlay). ApplyConfiguredStates now skips fans with an open preview hold: a
preview is deliberately volatile, unpersisted state, and overlaying the
persisted mode on top of it reverted what the user was mid-way through testing.
The worse half was what happened next. ApplyConfiguredStates moves in-memory
state without actuating anything, and the curve worker's NotDriven branch only
forgot the fan - it never restored EC control. So a fan whose mode was overlaid
back to Auto stayed physically held at whatever duty we last wrote, possibly 0%,
while the store, the streams and the UI all reported Auto. A stopped fan
reported as Auto is the worst state this service can leave hardware in. The
NotDriven branch now performs a real EC restore when we were the thing driving
that fan, skipping it when safe-fallback already handed the fan back, and on
failure it restores the marker so the next pass retries instead of stranding the
fan after one transient error.
This path had never been testable: TestOptionsMonitor.OnChange returned a no-op
disposable and never invoked its listener, so no test could reach a
configuration reload. It now supports Set/RaiseChanged, and four new tests cover
the overlay - including that a previewing fan survives a reload carrying a
DIFFERENT mode, and that it seeds normally again once the preview ends.
Two notes for whoever reads this next:
- A no-op check ("skip fans whose overlay changed nothing") was written and then
  removed. It silently never fired: FanControlStateSnapshot is a record, but its
  CurveProfiles is an ImmutableArray<T>, whose equality compares the underlying
  array REFERENCE, and the overlay rebuilds that array on every call. A deep
  comparison the snapshot types do not offer would be needed. The redundant
  notifications are cheap and idempotent, so they are left alone rather than
  guarded by a comparison that looks correct and does nothing. The reasoning is
  in the code so it is not re-attempted.
- The NotDriven restore has NO automated test. EvaluateAsync is private and
  _controlStates is populated by a subscription started in ExecuteAsync, so
  covering it needs a worker harness - which is the integration-test harness
  recorded as P1-5 and never built. This fix should be its first customer.
A release-build CPU trace of the idle Dashboard showed 59% of one core consumed
with 91% of it on the FINALIZER thread, not the UI thread (3.75%):
WinRT.IObjectReference.Finalize 50.3% and SkiaSharp SKFont.DisposeNative 25.9%.
Both come from one pattern - native and WinRT objects allocated inside property
getters that re-evaluate at telemetry rate, then abandoned.
AppThemeBrushes.Get now caches. It is called from ~157 sites, many of them
getters re-running every tick, and each call reached Application.Current
.Resources - a WinRT projection allocated per call and immediately garbage. The
brushes were never the problem: a resource brush is already a single shared
instance owned by the ResourceDictionary, so returning the same reference is
what StaticResource does anyway. Only the repeated lookup goes away. The cache
never expires because App.xaml pins RequestedTheme="Dark" and every colour here
is a constant; the code says what must change if a light theme is ever added.
The usage-card tier visuals become stored values rather than computed ones, in
both the CPU-core and compute-device models, rebuilt only when the tier actually
changes (load crossing 1%, 50% or 90%). As getters they allocated a fresh
SolidColorPaint - wrapping a native Skia object nobody disposed - on every
evaluation, once per core and per device per second. The previous paint is
deliberately not disposed: LiveCharts may still be drawing with it, and a
use-after-dispose on a native handle is worse than one garbage object per tier
change. This also matches the project's existing "store derived values" rule.
Also: the CPU history projection is now sampled, and the clock histories are
trimmed to the visible window like the usage histories already were.
NOT QUANTIFIED. Before/after CPU sampling during development was invalidated by
an uncontrolled variable - the development machine's own load, which reached
80 C with fans at 4,500 RPM and drives continuous chart animation. The trace
above is the only sound evidence here and it identifies the cause, not the size
of the win. A release trace on an idle machine is needed to measure it.
Deliberately NOT changed, having been tried and reverted: shortening the
hardware-history window, and moving the CPU-history projection off the UI thread
as the telemetry UI guide asks. The second is unsafe as the code stands -
_cpuHistoryRecords is shared with the UI-thread-bound snapshot subscription, so
an off-thread writer lets RefreshCpuVisualsAsync run concurrently with itself
over shared state. Doing it properly means giving the CPU visuals their own
projection state, not reordering operators. The reasoning is in the code.
…vidence at every silent boundary

feat: Trace-level diagnostics in Debug, Information in Release, and evidence at every silent boundary
Debug builds now log at Trace and Release builds at Information, set in code rather than
appsettings: a Debug build registered with the SCM or systemd has no Development environment
to key off, so a config-only switch would silently not apply where it matters most. Framework
namespaces stay capped in Debug so ASP.NET and Kestrel per-request records do not bury the
app's own telemetry.
Fixes a level bug in the app head. The Uno XamlLogLevel, XamlLayoutLogLevel,
XamlBindingLogLevel and BinderMemoryReferenceLogLevel groups were registered at Debug in
every configuration. A category filter beats the minimum level rather than being capped by
it, so Release builds really were emitting Uno layout, binding and binder-memory diagnostics
on the UI thread. They are behind #if DEBUG now.
Fan curve evaluation states its whole derivation in one record — the sensors read and their
individual values, the aggregation mode and resulting temperature, the curve's duty, the CPU
usage modifier's contribution, and the final target. The blind case names which sensor
stopped reporting and in what state rather than only that one was missing, and the
change-threshold skip is logged, which is most ticks and previously produced nothing.
The EC boundary logs every hardware write twice, with what was asked for and with what the
controller reported back, because those genuinely differ: duty is rounded to a whole percent
at that choke point and the EC is free to clamp anything handed to it. Each snapshot read is
individually timed, which is the only way to tell a slow driver from a slow poll interval.
GrpcFrameworkTelemetryClient had no logging at all in 494 lines and six empty catch blocks
across three identical reconnect loops, so a service refusing connections produced a client
showing stale data and reporting nothing anywhere. All six now log which stream faulted, with
what status, and when it will retry. FrameworkGrpcChannelFactory logs endpoint validation,
which is the failure that presents to the user as the app simply showing nothing.
FanPreviewWatchdog traces every hold transition, since a hold left open is a fan physically
overridden with nothing persisted to explain it; the watchdog actually firing on a vanished
client is Information, not Trace. The compute readers name the devices they drop — a
suspended NPU reporting 0% without being woken, a GPU that returned no reading, and a PDH
adapter with no enumerated PnP device are all indistinguishable from absence otherwise.
Argument evaluation precedes the level check, so two LogDebug calls that ran Count() over a
Take() iterator on every poll regardless of configured level now use the plain count.
Everything expensive is behind an IsEnabled(Trace) guard and every new record goes through
[LoggerMessage] source generation.
…ds, and a curve-worker test harness

Four hardening items that turned out to be smaller than expected — most of the surrounding
work already existed, so these close the specific gaps rather than build new subsystems.
DIAGNOSTICS
The app's own log records had nowhere to go. The desktop head is a GUI-subsystem binary, so
its console sink writes to a console that does not exist and its debug sink only exists under
a debugger; a released build could warn about a broken service connection every second and the
user would never see one line of it. The bounded buffer the service already used moves to Core
as InMemoryLogBuffer/InMemoryLogProvider — one implementation, one set of tests — and the app
registers one for itself. Settings > Logs now interleaves both sides by timestamp with a source
filter and an APP/SVC column, so a client reconnect warning reads next to the service restart
that caused it. The clipboard line carries the source, since "which process said this" is the
first question asked of a pasted log. A failing service fetch no longer discards the app's own
entries — those are precisely what explain why the service looks dead.
SYSTEMD
The unit had no sandboxing at all. It must run as root (ioperm port I/O, /dev/mem for SMBIOS,
/dev/cros_ec), so the directives narrow what that root process can reach rather than dropping
privileges. The four that would break EC access — PrivateDevices, ProcSubset=pid,
MemoryDenyWriteExecute, ProtectSystem=strict — are listed in the file with the reason each is
omitted, so nobody "hardens" it further and silently kills fan control. SystemCallFilter needs
@raw-io on top of @System-service because ioperm is not in the general service set.
Verified with systemd-analyze verify: every directive is accepted.
PREVIEW HOLDS
Fixes a real defect. Holds were keyed by fan index alone, so a second client opening a hold on
an already-held fan got a silent no-op — but when THAT client disconnected it reverted the fan
out from under the client still previewing it, using a pre-preview state it never captured.
Begin now returns an ownership token (null when another stream already holds the fan) and only
the owner can revert. A non-owning stream closing leaves the hold untouched instead of
reverting or releasing it.
TEST HARNESS
The curve worker's NotDriven restore had no automated test — the decision depends on state that
only exists once the store subscription has delivered a change set, so testing the private
method in isolation would not exercise the path that actually runs. A harness now starts the
worker as a hosted service against the real store and the stub EC, with the evaluation interval
injectable so tests drive evaluations instead of waiting out the production sampling window.
It pumps until the effect lands rather than sleeping a fixed amount, because both the change-set
delivery and the thermal sampling are asynchronous and a fixed delay passes or fails on machine
load rather than on behavior.
Six cases now cover the safety-critical paths, including the restore-failure retry that keeps a
fan from being stranded on an applied duty while the store, the streams and the UI all report
Auto. Confirmed the tests actually catch the regression: disabling the restore fails two of
them.
…ules that were silently inert

feat: rate-limit telemetry reaching the UI, and repair two analyzer rules that were silently inert
Adds SZF0013, which flags a subscription starting at a per-poll telemetry source that does not bound
its rate. Writing it turned up two problems bigger than the rule itself.
THE RULE WAS INERT, AND SO WERE TWO SHIPPED ONES
The first version reported nothing anywhere. Rather than conclude the codebase was clean, a
deliberate violation was compiled — and it caught nothing either, including SZF0004 and SZF0005.
AnalyzerSymbolHelpers.IsType compares ToDisplayString ("System.IObservable<T>") against a metadata
name ("System.IObservable`1"), which never matches for a generic, and ImplementsInterface looks at
AllInterfaces, which does not include the type itself. A receiver statically typed as IObservable<T>
therefore failed both checks — and that is what every Watch*/Connect* method and every Rx operator
returns, so both rules had been skipping nearly every subscription in the repo.
SAMPLE WOULD HAVE CORRUPTED STATE
The rule initially recommended Sample/Throttle. On a DynamicData change set — a delta, not a
snapshot — those DROP items, so a dropped change set loses an add or a remove permanently. DynamicData
ships no Sample/Throttle for change sets for precisely this reason. Applied mechanically to the 35
sites below that would have introduced silent state corruption across the UI. The rule now picks by
stream shape and names the right operator in the message. It also reads that shape off the SOURCE
rather than off Subscribe, because .Select(...).Concat() over an async handler leaves an
IObservable<Unit> and would have been misread as a snapshot stream.
WHAT THE RULES NOW ACCEPT
Fixing the inertness surfaced ~53 pre-existing violations, and most turned out to be correct code the
rules could not read. Rather than edit correct code, the rules learned the patterns: Observable.Create
factories (disposal is the returned disposable; the scheduler belongs to the consumer), ownership by
assignment to a member, SerialDisposable slots and keyed registries (already governed by SZF0010/0011),
using var, returning the subscription, and marshalling inside the handler via the dispatcher — followed
one hop into same-type helpers, since marshalling is routinely factored into a small private method.
ObserveOn and the new rule are both scoped to the compilation that actually has a UI thread; requiring
them in the service is meaningless, and rate-limiting the service's fan-control state store would add
latency to a safety path to save work nobody was doing.
WHAT CHANGED IN THE APP
Thirty-five subscriptions across seventeen files now state a ceiling, applied before ObserveOn so
coalescing happens off the UI thread: Batch for change sets, Sample for snapshots. Cadences live in
TelemetryRateLimits with the reasoning attached — 250 ms live, 500 ms history, 1 s inventory. They are
ceilings, not floors: both operators only emit when something arrived, so a one-second poll still
updates once a second.
Settings > Service logs is renamed to Logs, and its subtitle corrected — it stopped being
service-only when the app's own records were merged into it.
@TekuSP TekuSP self-assigned this Jul 26, 2026
@TekuSP TekuSP added the enhancement New feature or request label Jul 26, 2026
@TekuSP
TekuSP merged commit 45406bf into master Jul 26, 2026
13 checks passed
@TekuSP
TekuSP deleted the feature/addNPUGPU branch July 26, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant