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
@@ -70,6 +70,8 @@ public sealed class MainWindow : Window
private bool _isUpdating;
private bool _initialWindowStateApplied;
private bool _isClosingForExit;
private bool _isShuttingDown;
private bool _runtimeErrorLogged;
private bool _isMainWindowHidden;
private bool _isMonitoringStarted;
private bool _startupCompletionRequested;
@@ -635,13 +637,18 @@ public sealed class MainWindow : Window
private async void UpdateTimer_Tick(DispatcherQueueTimer sender, object args)
{
if (_isUpdating)
if (_isUpdating || _isShuttingDown)
return;
_isUpdating = true;
try
{
await ViewModel.UpdateAsync();
// The window may have been closed (and the view model/computer disposed) while we awaited the update.
if (_isShuttingDown)
return;
UpdateSensorColumnWidths();
_trayIconService.Update();
_gadgetWindow?.UpdateSensors(ViewModel.GetGadgetSensorItems());
@@ -649,7 +656,7 @@ public sealed class MainWindow : Window
}
catch (Exception ex)
{
_timer.Stop();
// A transient update failure must not permanently freeze the UI: keep the timer running and just record it.
RecordRuntimeError("Sensor update failed", ex);
}
finally
@@ -662,8 +669,24 @@ public sealed class MainWindow : Window
{
_startupTrace?.Mark("MainWindow.RuntimeError", $"{message}: {exception.GetType().FullName}: {exception.Message}");
_startupTrace?.Flush();
string logPath = IOPath.Combine(AppContext.BaseDirectory, "LibreHardwareMonitor.Windows.WinUI.startup.log");
File.WriteAllText(logPath, exception.ToString());
// Use a dedicated runtime log (not the startup log that App.xaml.cs appends to) and write it only once, so a
// recurring per-tick failure neither clobbers the startup diagnostics nor grows the file without bound.
string logPath = IOPath.Combine(AppContext.BaseDirectory, "LibreHardwareMonitor.Windows.WinUI.runtime.log");
if (!_runtimeErrorLogged)
{
try
{
File.WriteAllText(logPath, exception.ToString());
}
catch
{
// Never let diagnostic logging throw out of the update loop.
}
_runtimeErrorLogged = true;
}
ViewModel.SetStatusText($"{message}. See {IOPath.GetFileName(logPath)}.");
}
@@ -682,6 +705,7 @@ public sealed class MainWindow : Window
return;
}
_isShuttingDown = true;
_timer.Stop();
_deviceColumnWidthSettleTimer.Stop();
_trayIconService.Dispose();