Fixes applied (14 of 15 findings — see note on R5)

Concurrency crashes
  - H1 HardwareMonitorService.RebuildTree now builds the tree under _updateLock, so it can't enumerate a hardware's _active HashSet while the update loop mutates it.
  - A1 AppSettings now guards every dictionary read/write (and snapshots in Save) with a lock — safe under concurrent access from the parallel discovery threads.
  - R2 Plumbed that same lock (HardwareMonitorService.SensorReadLock) into RemoteWebServer and wrapped the Prometheus sensor.Values enumeration with it.
  - L1 Computer — refactored Add into AddCore, which performs the cancellation/enabled re-check and the _groups insertion atomically under _lock. A deferred task can
  no longer add (and leak) a group after Close() drained the list; if it loses the race it closes the group instead.
  - M1 UpdateTimer_Tick now bails before/after the await when _isShuttingDown is set in MainWindow_Closed, so an in-flight tick won't touch the disposed
  view-model/Computer.

  Broken behavior
  - T1 Tray callback now decodes NOTIFYICON_VERSION_4 correctly (message = LOWORD(lParam), icon id = HIWORD(lParam)) — right-click menu and double-click work again.
  - M2 A transient update exception no longer calls _timer.Stop(); the loop keeps running.
  - H2 Newly discovered (deferred) storage devices get the current ForceDriveWakeup setting applied in HardwareChanged.
  - V2 Sensor items carry a parent reference; toggling IsVisible recomputes the parent group's visibility, so no empty group headers. (Strengthened the existing test
  that had skipped this assertion.)
  - H3 Tree-rebuild coalescing now uses a dirty flag with a re-check, so a change arriving during a rebuild isn't lost.
  - R4 Web routing matches endpoints exactly on the query-stripped path (Url.AbsolutePath), so static assets like metrics.html aren't hijacked.
  - L8 IntelCpu.Update skips the bus/core-clock math while TimeStampCounterFrequency is still 0 (deferred-TSC window), so clocks keep their prior value instead of
  reporting 0 MHz.
  - V1 Existing plot series keep their assigned color; only an explicit user pen color updates them (no per-tick color shifting).
  - M3 Runtime errors write to a dedicated runtime.log (once), instead of overwriting the shared startup.log.
  - T2 CreateSensorIcon returns IntPtr.Zero on DIB failure instead of the shared main-icon handle (which callers DestroyIcon).

  I also set _isOpen = false in HardwareMonitorService.Dispose so the rebuild guard actually holds during shutdown (the latent after-close-rebuild issue adjacent to
  H3
This commit is contained in:
2026-05-30 22:56:25 -05:00
parent 5f88f71e61
commit f9edc1dc64
10 changed files with 216 additions and 84 deletions
@@ -25,6 +25,7 @@ namespace LibreHardwareMonitor.Windows.WinUI.Services;
public sealed class RemoteWebServer : IDisposable
{
private readonly IComputer _computer;
private readonly object _sensorReadLock;
private readonly Func<SensorTreeItemViewModel?> _rootProvider;
private readonly Version _version = typeof(RemoteWebServer).Assembly.GetName().Version ?? new Version(0, 0);
private CancellationTokenSource? _cts;
@@ -34,6 +35,7 @@ public sealed class RemoteWebServer : IDisposable
public RemoteWebServer(
Func<SensorTreeItemViewModel?> rootProvider,
IComputer computer,
object sensorReadLock,
string listenerIp,
int listenerPort,
bool authEnabled,
@@ -42,6 +44,7 @@ public sealed class RemoteWebServer : IDisposable
{
_rootProvider = rootProvider;
_computer = computer;
_sensorReadLock = sensorReadLock;
ListenerIp = listenerIp;
ListenerPort = listenerPort;
AuthEnabled = authEnabled;
@@ -196,7 +199,10 @@ public sealed class RemoteWebServer : IDisposable
return;
}
string requestedFile = request.RawUrl?.TrimStart('/') ?? "";
// Match on the path component only (AbsolutePath excludes the query string) and compare endpoints exactly.
// Prefix matching previously let any static asset whose name starts with an endpoint name (e.g.
// "metrics.html", "sensor-icons.css") be hijacked by the API handlers.
string requestedFile = (request.Url?.AbsolutePath ?? request.RawUrl ?? "").TrimStart('/');
if (requestedFile.Length == 0)
requestedFile = "index.html";
@@ -206,13 +212,13 @@ public sealed class RemoteWebServer : IDisposable
return;
}
if (requestedFile.StartsWith("metrics", StringComparison.OrdinalIgnoreCase))
if (requestedFile.Equals("metrics", StringComparison.OrdinalIgnoreCase))
{
await SendPrometheusAsync(context.Response, request);
return;
}
if (requestedFile.StartsWith("Sensor", StringComparison.OrdinalIgnoreCase))
if (requestedFile.Equals("Sensor", StringComparison.OrdinalIgnoreCase))
{
Dictionary<string, object?> result = [];
HandleSensorRequest(request, result);
@@ -220,7 +226,7 @@ public sealed class RemoteWebServer : IDisposable
return;
}
if (requestedFile.StartsWith("ResetAllMinMax", StringComparison.OrdinalIgnoreCase))
if (requestedFile.Equals("ResetAllMinMax", StringComparison.OrdinalIgnoreCase))
{
_computer.Accept(new SensorVisitor(sensor =>
{
@@ -427,7 +433,12 @@ public sealed class RemoteWebServer : IDisposable
private async Task SendPrometheusAsync(HttpListenerResponse response, HttpListenerRequest request)
{
Dictionary<string, int> settings = ParsePrometheusSettings(request);
string content = GeneratePrometheusResponse(_rootProvider(), settings);
// Serialize against the sensor update loop: GeneratePrometheusResponse enumerates each sensor's live Values ring
// buffer, which the update thread mutates concurrently (an unsynchronized enumeration would throw).
string content;
lock (_sensorReadLock)
content = GeneratePrometheusResponse(_rootProvider(), settings);
response.AddHeader("Cache-Control", "no-cache");
response.AddHeader("Access-Control-Allow-Origin", "*");