Implements comprehensive GPU/NPU monitoring and service enhancements - #54
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
amdgpusysfs 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.Batchfor change sets,Sample/Throttlefor snapshots) to prevent UI-thread starvation.Changed
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.systemdsandboxing directives to the Linux service, enhancing security and resource isolation.Fixed
ObserveOn) and SZF0005 (DisposeWith) to accurately apply toIObservablereceivers and recognizes legitimate Rx patterns, reducing false positives.ObserveOnis no longer required in non-UI projects.Relates to feature/addNPUGPU