cleanup after DI graph rewrite

This commit is contained in:
2026-06-03 17:27:58 -05:00
parent b14189c294
commit 2ffd237ddd
19 changed files with 553 additions and 216 deletions
@@ -18,6 +18,10 @@ public sealed class Logger : ILogger
private readonly IComputer _computer;
private readonly TimeProvider _timeProvider;
private readonly string _baseDirectory;
// Guards the _sensors/_identifiers pair: the timer thread reassigns them on rotation while background
// hardware-discovery threads read/write them via SensorAdded/SensorRemoved (Computer.HardwareAdded now fires
// off the caller thread once any deferred-detection flag is active).
private readonly object _sync = new();
private DateTime _day = DateTime.MinValue;
private string _fileName = "";
private string[]? _identifiers;
@@ -76,21 +80,28 @@ public sealed class Logger : ILogger
break;
}
// Snapshot the sensor array under the lock so a concurrent SensorAdded/SensorRemoved, or a rotation that swaps
// in a different-length array, on a discovery thread cannot tear the read below. Element reads stay safe: a
// reference write to a slot is atomic, so each read yields either a sensor or null.
ISensor?[]? sensors;
lock (_sync)
sensors = _sensors;
try
{
using StreamWriter writer = new(new FileStream(_fileName, FileMode.Append, FileAccess.Write, FileShare.ReadWrite));
writer.Write(now.ToString("G", CultureInfo.InvariantCulture));
writer.Write(",");
if (_sensors != null)
if (sensors != null)
{
for (int i = 0; i < _sensors.Length; i++)
for (int i = 0; i < sensors.Length; i++)
{
float? value = _sensors[i]?.Value;
float? value = sensors[i]?.Value;
if (value.HasValue)
writer.Write(value.Value.ToString("R", CultureInfo.InvariantCulture));
writer.Write(i < _sensors.Length - 1 ? "," : Environment.NewLine);
writer.Write(i < sensors.Length - 1 ? "," : Environment.NewLine);
}
}
}
@@ -112,12 +123,21 @@ public sealed class Logger : ILogger
SensorVisitor visitor = new(sensor => sensors.Add(sensor));
visitor.VisitComputer(_computer);
_sensors = sensors.Cast<ISensor?>().ToArray();
_identifiers = sensors.Select(sensor => sensor.Identifier.ToString()).ToArray();
ISensor?[] sensorArray = sensors.Cast<ISensor?>().ToArray();
string[] identifiers = sensors.Select(sensor => sensor.Identifier.ToString()).ToArray();
// Publish the paired arrays together under the lock so a concurrent SensorAdded/SensorRemoved never observes
// mismatched lengths. VisitComputer and the file write run outside the lock to avoid coupling with Computer's
// own locks.
lock (_sync)
{
_sensors = sensorArray;
_identifiers = identifiers;
}
using StreamWriter writer = new(_fileName, false);
writer.Write(",");
writer.WriteLine(string.Join(",", _identifiers));
writer.WriteLine(string.Join(",", identifiers));
writer.Write("Time,");
writer.WriteLine(string.Join(",", sensors.Select(sensor => $"\"{sensor.Name}\"")));
}
@@ -127,6 +147,7 @@ public sealed class Logger : ILogger
if (!File.Exists(_fileName))
return false;
string[] identifiers;
try
{
using StreamReader reader = new(_fileName);
@@ -134,30 +155,35 @@ public sealed class Logger : ILogger
if (string.IsNullOrEmpty(line))
return false;
_identifiers = line.Split(',').Skip(1).ToArray();
identifiers = line.Split(',').Skip(1).ToArray();
}
catch
{
_identifiers = null;
return false;
}
if (_identifiers.Length == 0)
{
_identifiers = null;
if (identifiers.Length == 0)
return false;
}
_sensors = new ISensor?[_identifiers.Length];
// Build into local arrays, then publish the pair together under the lock, so a discovery thread reading
// _sensors/_identifiers never observes a half-populated or length-mismatched pair.
ISensor?[] sensors = new ISensor?[identifiers.Length];
SensorVisitor visitor = new(sensor =>
{
for (int i = 0; i < _identifiers.Length; i++)
for (int i = 0; i < identifiers.Length; i++)
{
if (sensor.Identifier.ToString() == _identifiers[i])
_sensors[i] = sensor;
if (sensor.Identifier.ToString() == identifiers[i])
sensors[i] = sensor;
}
});
visitor.VisitComputer(_computer);
lock (_sync)
{
_identifiers = identifiers;
_sensors = sensors;
}
return true;
}
@@ -187,25 +213,32 @@ public sealed class Logger : ILogger
private void SensorAdded(ISensor sensor)
{
if (_sensors == null || _identifiers == null)
return;
for (int i = 0; i < _sensors.Length; i++)
lock (_sync)
{
if (sensor.Identifier.ToString() == _identifiers[i])
_sensors[i] = sensor;
if (_sensors == null || _identifiers == null)
return;
// _sensors and _identifiers are always published together with equal lengths, so indexing both is safe.
for (int i = 0; i < _sensors.Length; i++)
{
if (sensor.Identifier.ToString() == _identifiers[i])
_sensors[i] = sensor;
}
}
}
private void SensorRemoved(ISensor sensor)
{
if (_sensors == null)
return;
for (int i = 0; i < _sensors.Length; i++)
lock (_sync)
{
if (sensor == _sensors[i])
_sensors[i] = null;
if (_sensors == null)
return;
for (int i = 0; i < _sensors.Length; i++)
{
if (sensor == _sensors[i])
_sensors[i] = null;
}
}
}
}
@@ -81,6 +81,11 @@ internal static class PasswordHasher
return false;
}
// Reject a missing salt or hash segment: an empty expected hash would make FixedTimeEquals(empty, empty) return
// true and authenticate any password. Hash() never produces this, so it only arises from a corrupted value.
if (salt.Length == 0 || expected.Length == 0)
return false;
byte[] actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
@@ -408,6 +408,13 @@ public sealed class RemoteWebServer : IRemoteWebServer
sensor.Control.SetSoftware(float.Parse(value, CultureInfo.InvariantCulture));
}
// Reused across requests: building a JsonSerializerOptions per request defeats System.Text.Json's per-options
// metadata cache, which matters because data.json is polled frequently.
private static readonly JsonSerializerOptions JsonOptions = new()
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
};
private async Task SendJsonAsync(HttpListenerResponse response, HttpListenerRequest request)
{
Dictionary<string, object?> json = new()
@@ -425,12 +432,7 @@ public sealed class RemoteWebServer : IRemoteWebServer
SensorTreeItemViewModel? root = _rootProvider();
json["Children"] = root == null ? Array.Empty<object>() : new List<object> { GenerateJsonForNode(root, ref nodeIndex) };
JsonSerializerOptions options = new()
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
};
byte[] buffer = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(json, options));
byte[] buffer = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(json, JsonOptions));
bool acceptGzip = request.Headers["Accept-Encoding"]?.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) >= 0;
WriteCommonHeaders(response);
@@ -94,6 +94,16 @@ internal sealed class SensorIconRenderer
}
}
/// <summary>
/// Returns a key identifying the pixels <see cref="CreateIcon" /> would produce (the drawn text and the background
/// color). When it is unchanged the previously created icon can be reused instead of re-rendering a new GDI icon.
/// </summary>
public string GetRenderKey(ISensor sensor)
{
WinUIColor color = GetSensorTrayColor(sensor);
return $"{GetSensorIconText(sensor)}|{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}";
}
private WinUIColor GetSensorTrayColor(ISensor sensor)
{
WinUIColor defaultColor = sensor.SensorType is SensorType.Load or SensorType.Control or SensorType.Level
@@ -158,8 +158,17 @@ public sealed class TrayIconService : IDisposable
private void Update(SensorTrayIcon icon)
{
string renderKey = _iconRenderer.GetRenderKey(icon.Sensor);
if (icon.IconHandle != IntPtr.Zero && renderKey == icon.LastRenderKey)
{
// The icon pixels are unchanged; refresh only the (cheap) tooltip and skip the GDI re-render + handle churn.
ModifyNotifyIcon(icon.Id, icon.IconHandle, GetSensorToolTip(icon.Sensor));
return;
}
IntPtr previousIcon = icon.IconHandle;
icon.IconHandle = _iconRenderer.CreateIcon(icon.Sensor);
icon.LastRenderKey = renderKey;
ModifyNotifyIcon(icon.Id, icon.IconHandle, GetSensorToolTip(icon.Sensor));
if (previousIcon != IntPtr.Zero)
DestroyIcon(previousIcon);
@@ -373,5 +382,8 @@ public sealed class TrayIconService : IDisposable
public IntPtr IconHandle { get; set; }
public ISensor Sensor { get; set; } = sensor;
/// <summary>Key of the pixels currently rendered into <see cref="IconHandle" />, used to skip redundant re-renders.</summary>
public string? LastRenderKey { get; set; }
}
}
@@ -112,6 +112,8 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
private bool _isStarted;
private bool _isStarting;
private int _rootUpdateQueued;
private int _statusHardwareCount;
private int _statusSensorCount;
private string _statusText = "";
private TemperatureUnit _temperatureUnit;
private int _updateIntervalIndex;
@@ -1041,14 +1043,21 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
MeasureStartup("MainWindowViewModel.ConfigureRoot", () => root.Configure(TemperatureUnit, ShowHiddenSensors, ShowValueColumn, ShowMinColumn, ShowMaxColumn), GetRootDetail);
MeasureStartup("MainWindowViewModel.RootItems.Add", () => RootItems.Add(root), GetRootDetail);
MeasureStartup("MainWindowViewModel.ApplySensorValuesTimeWindow", ApplySensorValuesTimeWindow, GetRootDetail);
RefreshStatusCounts();
}
// Hardware/sensor counts change only when the tree is rebuilt, so cache them here (called from UpdateRoot) instead
// of re-walking the whole tree and re-aggregating Computer.Hardware on every per-tick UpdateStatus().
private void RefreshStatusCounts()
{
_statusHardwareCount = _hardwareMonitor.Computer.Hardware.Count;
_statusSensorCount = RootItems.FirstOrDefault()?.EnumerateSensors().Count() ?? 0;
}
private void UpdateStatus()
{
int hardwareCount = _hardwareMonitor.Computer.Hardware.Count;
int sensorCount = RootItems.FirstOrDefault()?.EnumerateSensors().Count() ?? 0;
string webServerStatus = RunWebServer ? $", web server {WebServerUrl}" : "";
StatusText = $"{hardwareCount} hardware devices, {sensorCount} sensors, update every {FormatInterval(UpdateInterval)}{webServerStatus}";
StatusText = $"{_statusHardwareCount} hardware devices, {_statusSensorCount} sensors, update every {FormatInterval(UpdateInterval)}{webServerStatus}";
}
private void RestartWebServerIfRunning()
@@ -78,17 +78,20 @@ internal sealed class PlotTrackingService
series.Color = sensorItem.PenColor.Value;
}
// The pipeline below sorts and de-duplicates, so there is no need to OrderBy the whole (potentially 24h)
// history every tick; track the latest timestamp in the same pass instead of assuming the list is ordered.
List<PlotPointViewModel> points = [];
foreach (SensorValue sensorValue in sensor.Values.OrderBy(value => value.Time))
DateTime? latestHistoryTimestamp = null;
foreach (SensorValue sensorValue in sensor.Values)
{
double? displayedValue = SensorFormatter.GetPlotValue(sensor, sensorValue.Value, temperatureUnit);
if (displayedValue is not { } pointValue || !double.IsFinite(pointValue))
continue;
points.Add(new PlotPointViewModel(sensorValue.Time, pointValue));
if (!latestHistoryTimestamp.HasValue || sensorValue.Time > latestHistoryTimestamp.Value)
latestHistoryTimestamp = sensorValue.Time;
}
DateTime? latestHistoryTimestamp = points.Count > 0 ? points[^1].Timestamp : null;
foreach (PlotPointViewModel existingPoint in series.Points)
{
if (now - existingPoint.Timestamp > MaximumSyntheticPlotPointRetention)