diff --git a/CLAUDE.md b/CLAUDE.md index 69fb5e7..e2c15d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`). |---|---| | `Abstractions/` | `IHealthMonitor`, `IStopwatch`, `ISystemTimeProvider` | | `Detection/` | `RealStopwatch`, `SystemTimeProvider` — production implementations of the abstractions | -| `Monitors/` | `HealthMonitorBase` (state machine), `NamedHealthMonitor`, `HealthMonitorCoordinator` | +| `Monitors/` | `HealthMonitorBase` (state machine), `NamedHealthMonitor`, `HealthMonitorCoordinator`, `DynamicHealthMonitorManager` | | `Services/` | `HealthMonitorHostedService` — background loop, ticks coordinator at `MinCheckInterval` | | `Configuration/` | `HealthMonitorOptions`, `HealthMonitorRegistration` | | `Extensions/` | `AddHealthMonitor()` DI extension | @@ -39,9 +39,13 @@ Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`). `AddHealthMonitor()` registers monitors as keyed services (by name, .NET 8+) and via `IEnumerable` on all targets. The hosted service resolves all monitors through the coordinator. +### Dynamic registration (no DI) + +`DynamicHealthMonitorManager` is a standalone `IDisposable` class for runtime monitor management. Each monitor added via `Add(name, options?)` gets its own `System.Threading.Timer` firing at its `CheckInterval`. The manager exposes aggregate `Degraded` / `Recovered` events that forward from all registered monitors — subscribers added before or after `Add()` both receive events. `Remove()` disposes the timer and unsubscribes event forwarding atomically under a lock. + ### Testability -`IStopwatch` and `ISystemTimeProvider` abstractions allow time-controlled unit tests. The `tests/HealthMonitor.Tests/Fakes/` directory provides `FakeStopwatch` and related fakes. +`IStopwatch` and `ISystemTimeProvider` abstractions allow time-controlled unit tests. The `tests/HealthMonitor.Tests/Fakes/` directory provides `FakeStopwatch` and related fakes. Tests use **xunit.v3** (not v2 — the assertion API differs). ### Multi-targeting diff --git a/README.md b/README.md index 0d30375..0789bf5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A lightweight .NET library for monitoring application health via periodic heartb - **Multiple monitors** — register any number of independent monitors, each with its own degraded threshold and check interval - **Two events** — `Degraded` fires when no heartbeat is received within the threshold; `Recovered` fires immediately when a signal arrives while degraded - **DI-first** — integrates with `IServiceCollection` and runs as a `BackgroundService` +- **Dynamic management** — `DynamicHealthMonitorManager` adds and removes monitors at runtime; each runs its own `Timer` with per-monitor options - **Keyed injection** — resolve a specific monitor by name via `IServiceProvider.GetRequiredKeyedService("name")` (.NET 8+) - **Testable** — `IStopwatch` and `ISystemTimeProvider` abstractions decouple timing from wall-clock time @@ -98,6 +99,44 @@ monitor.Signal(); | `Timestamp` | `DateTimeOffset` | UTC time of the transition | | `DegradedDuration` | `TimeSpan` | How long the monitor was degraded before recovering | +## Dynamic Monitor Management + +`DynamicHealthMonitorManager` lets you register and remove monitors at runtime — no DI or hosted service required. Each monitor owns its own `System.Threading.Timer` that fires at its configured `CheckInterval`. + +```csharp +using var manager = new DynamicHealthMonitorManager(); + +// Subscribe once on the manager — fires for all monitors, including those added later +manager.Degraded += (_, e) => Console.WriteLine($"{e.MonitorName} degraded"); +manager.Recovered += (_, e) => Console.WriteLine($"{e.MonitorName} recovered"); + +// Add monitors at any time; each runs its own Timer at its own CheckInterval +var db = manager.Add("database", new HealthMonitorOptions +{ + DegradedThreshold = TimeSpan.FromSeconds(10), + CheckInterval = TimeSpan.FromSeconds(2), +}); + +// Heartbeat from your business logic +db.Signal(); + +// Add more monitors dynamically while the app runs +var cache = manager.Add("cache", new HealthMonitorOptions +{ + DegradedThreshold = TimeSpan.FromSeconds(5), + CheckInterval = TimeSpan.FromSeconds(1), +}); + +// Remove a monitor — disposes its timer and unsubscribes event forwarding +manager.Remove("cache"); + +// Enumerate all active monitors +foreach (var m in manager.Monitors) + Console.WriteLine($"{m.Name}: {(m.IsHealthy ? "healthy" : "degraded")}"); +``` + +`DynamicHealthMonitorManager` implements `IDisposable`; disposing it stops all timers. + ## State Machine ``` @@ -133,6 +172,7 @@ Build outputs land in `./artifacts/bin/`. - **多监控器** — 可注册任意数量的独立监控器,每个监控器拥有独立的降级阈值和检查间隔 - **两种事件** — 心跳超时触发 `Degraded`;降级期间收到信号立即触发 `Recovered` - **DI 优先** — 集成 `IServiceCollection`,以 `BackgroundService` 形式运行 +- **动态管理** — `DynamicHealthMonitorManager` 支持运行时动态添加/移除监控器,每个监控器有独立 `Timer` 和配置 - **按名称注入** — 通过 `IServiceProvider.GetRequiredKeyedService("name")` 按名称解析(.NET 8+) - **易于测试** — `IStopwatch` 和 `ISystemTimeProvider` 抽象解耦了时间依赖 @@ -220,6 +260,44 @@ monitor.Signal(); | `Timestamp` | `DateTimeOffset` | 状态切换的 UTC 时间 | | `DegradedDuration` | `TimeSpan` | 恢复前的降级持续时长 | +## 动态监控管理 + +`DynamicHealthMonitorManager` 支持在运行时动态注册和移除监控器,无需 DI 或托管服务。每个监控器拥有独立的 `System.Threading.Timer`,按自身的 `CheckInterval` 周期触发检查。 + +```csharp +using var manager = new DynamicHealthMonitorManager(); + +// 在 manager 上订阅一次 — 对所有监控器生效,包括后续动态添加的 +manager.Degraded += (_, e) => Console.WriteLine($"{e.MonitorName} 已降级"); +manager.Recovered += (_, e) => Console.WriteLine($"{e.MonitorName} 已恢复"); + +// 随时添加监控器,每个监控器使用自己的 Timer 和 CheckInterval +var db = manager.Add("database", new HealthMonitorOptions +{ + DegradedThreshold = TimeSpan.FromSeconds(10), + CheckInterval = TimeSpan.FromSeconds(2), +}); + +// 在业务逻辑中发送心跳 +db.Signal(); + +// 在运行时动态添加更多监控器 +var cache = manager.Add("cache", new HealthMonitorOptions +{ + DegradedThreshold = TimeSpan.FromSeconds(5), + CheckInterval = TimeSpan.FromSeconds(1), +}); + +// 移除监控器 — 立即销毁其 Timer 并取消事件转发 +manager.Remove("cache"); + +// 枚举所有活跃监控器 +foreach (var m in manager.Monitors) + Console.WriteLine($"{m.Name}: {(m.IsHealthy ? "健康" : "降级")}"); +``` + +`DynamicHealthMonitorManager` 实现了 `IDisposable`,Dispose 时会停止所有计时器。 + ## 状态机 ``` diff --git a/examples/HealthMonitor.Examples/Program.cs b/examples/HealthMonitor.Examples/Program.cs index 5d5e9a5..9bf6b6c 100644 --- a/examples/HealthMonitor.Examples/Program.cs +++ b/examples/HealthMonitor.Examples/Program.cs @@ -1,18 +1,25 @@ using HealthMonitor.Core.Abstractions; using HealthMonitor.Core.Extensions; +using HealthMonitor.Core.Monitors; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Serilog; // ───────────────────────────────────────────────────────────────── -// HealthMonitor — usage example: quote feed health +// HealthMonitor — usage examples // -// Run with: dotnet run --project examples/ActivityMonitor.Examples +// Run with: dotnet run --project examples/HealthMonitor.Examples // -// Simulates two quote feeds. A background task sends periodic -// "quotes" for the fast feed but intentionally stalls the slow feed -// so you can observe Degraded / Recovered events. +// Demo 1 – DI-based monitors (quote feeds) +// Two monitors are registered at startup. A background task drives +// Signal() calls; the slow feed stalls intentionally so you can see +// Degraded / Recovered events. +// +// Demo 2 – DynamicHealthMonitorManager (runtime registration) +// A manager is created standalone (no DI). Monitors are added, +// signalled, and removed while the app runs — each uses its own +// Timer and per-monitor options. // // Press Ctrl+C to exit. // ───────────────────────────────────────────────────────────────── @@ -26,7 +33,8 @@ .UseSerilog() .ConfigureServices(services => { - // ── Monitor 1: fast feed ─────────────────────────────────── + // ── Demo 1: DI-based monitors ────────────────────────────── + // Expects a signal at least every 3 seconds. services.AddHealthMonitor("fast-feed", opt => { @@ -34,7 +42,6 @@ opt.CheckInterval = TimeSpan.FromSeconds(1); }); - // ── Monitor 2: slow feed ─────────────────────────────────── // Expects a signal at least every 10 seconds. services.AddHealthMonitor("slow-feed", opt => { @@ -45,14 +52,127 @@ services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + + // ── Demo 2: DynamicHealthMonitorManager ──────────────────── + services.AddHostedService(); }) .Build(); await host.RunAsync(); // ───────────────────────────────────────────────────────────────── -// HealthEventLogger — subscribes to Degraded / Recovered events. +// Demo 1 helpers // ───────────────────────────────────────────────────────────────── + +// ───────────────────────────────────────────────────────────────── +// Demo 2 – DynamicHealthMonitorManager +// +// Subscribes Degraded / Recovered on the manager itself — events from +// all monitors (including those added later) are forwarded centrally. +// +// Timeline (approximate): +// t=0s "api-gateway" and "worker" added and start receiving signals +// t=15s "cache" added dynamically — its events flow through manager too +// t=20s "worker" stops signalling — will degrade after 8 s +// t=35s "worker" resumes — fires Recovered +// t=40s "cache" removed — its timer and event forwarding disposed +// ───────────────────────────────────────────────────────────────── +internal sealed class DynamicMonitorDemo(ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation(new('─', 60)); + logger.LogInformation("Demo 2 (DynamicHealthMonitorManager) starting..."); + + using var manager = new DynamicHealthMonitorManager(); + + // Subscribe once on the manager — covers all current and future monitors + manager.Degraded += (sender, e) => + logger.LogWarning("[Dynamic/{Name}] *** DEGRADED *** (was healthy for {Duration:mm\\:ss})", + e.MonitorName, e.HealthyDuration); + manager.Recovered += (sender, e) => + logger.LogInformation("[Dynamic/{Name}] *** RECOVERED *** (was degraded for {Duration:mm\\:ss})", + e.MonitorName, e.DegradedDuration); + + // Add two monitors at startup + var gateway = manager.Add("api-gateway", new() + { + DegradedThreshold = TimeSpan.FromSeconds(6), + CheckInterval = TimeSpan.FromSeconds(2), + }); + + var worker = manager.Add("worker", new() + { + DegradedThreshold = TimeSpan.FromSeconds(8), + CheckInterval = TimeSpan.FromSeconds(2), + }); + + _ = SignalLoop(gateway, TimeSpan.FromSeconds(3), stoppingToken); + + var workerSignalling = true; + _ = Task.Run(async () => + { + while (!stoppingToken.IsCancellationRequested) + { + if (workerSignalling) + worker.Signal(); + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken).ConfigureAwait(false); + } + }, stoppingToken); + + // t=15s: add "cache" dynamically — manager events fire for it automatically + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken).ConfigureAwait(false); + if (stoppingToken.IsCancellationRequested) + return; + + var cache = manager.Add("cache", new() + { + DegradedThreshold = TimeSpan.FromSeconds(5), + CheckInterval = TimeSpan.FromSeconds(1), + }); + _ = SignalLoop(cache, TimeSpan.FromSeconds(2), stoppingToken); + logger.LogInformation("[Dynamic] 'cache' added at runtime. Active: {Names}", + string.Join(", ", manager.Monitors.Select(m => m.Name))); + + // t=20s: pause worker signals → will degrade + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false); + if (stoppingToken.IsCancellationRequested) + return; + + workerSignalling = false; + logger.LogInformation("[Dynamic] 'worker' signals paused — expect Degraded in ~8 s"); + + // t=35s: resume worker → fires Recovered + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken).ConfigureAwait(false); + if (stoppingToken.IsCancellationRequested) + return; + + workerSignalling = true; + worker.Signal(); + logger.LogInformation("[Dynamic] 'worker' signals resumed — expect Recovered"); + + // t=40s: remove cache — timer and event forwarding disposed + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false); + if (stoppingToken.IsCancellationRequested) + return; + + manager.Remove("cache"); + logger.LogInformation("[Dynamic] 'cache' removed. Active: {Names}", + string.Join(", ", manager.Monitors.Select(m => m.Name))); + + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken).ConfigureAwait(false); + } + + private static async Task SignalLoop(IHealthMonitor monitor, TimeSpan interval, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + monitor.Signal(); + await Task.Delay(interval, ct).ConfigureAwait(false); + } + } +} + internal sealed class HealthEventLogger(IEnumerable monitors, ILogger logger) : IHostedService { @@ -62,11 +182,11 @@ public Task StartAsync(CancellationToken cancellationToken) { var name = monitor.Name; monitor.Degraded += (_, e) => - logger.LogWarning("[{Monitor}] *** DEGRADED *** (was healthy for {Duration:mm\\:ss})", + logger.LogWarning("[DI/{Monitor}] *** DEGRADED *** (was healthy for {Duration:mm\\:ss})", name, e.HealthyDuration); monitor.Recovered += (_, e) => - logger.LogInformation("[{Monitor}] *** RECOVERED *** (was degraded for {Duration:mm\\:ss})", + logger.LogInformation("[DI/{Monitor}] *** RECOVERED *** (was degraded for {Duration:mm\\:ss})", name, e.DegradedDuration); } @@ -76,14 +196,6 @@ public Task StartAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } -// ───────────────────────────────────────────────────────────────── -// QuoteFeedSimulator — drives Signal() calls to simulate quote feeds. -// -// fast-feed: sends a signal every 1 second (healthy). -// slow-feed: sends a signal every 8 seconds, then intentionally -// stalls for 15 seconds to trigger a Degraded event, -// then resumes to trigger Recovered. -// ───────────────────────────────────────────────────────────────── internal sealed class QuoteFeedSimulator : BackgroundService { private readonly IHealthMonitor _fastFeed; @@ -97,7 +209,6 @@ public QuoteFeedSimulator(IEnumerable monitors) protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - // Run both feed simulations concurrently await Task.WhenAll( SimulateFastFeed(stoppingToken), SimulateSlowFeed(stoppingToken)); @@ -125,21 +236,16 @@ private async Task SimulateSlowFeed(CancellationToken ct) // Stall phase: no signal for 15 s → triggers Degraded await Task.Delay(TimeSpan.FromSeconds(15), ct).ConfigureAwait(false); - - // Resume: first signal → triggers Recovered immediately } } } -// ───────────────────────────────────────────────────────────────── -// StatusPrinter — prints current health state every second. -// ───────────────────────────────────────────────────────────────── internal sealed class StatusPrinter(IEnumerable monitors, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - logger.LogInformation("HealthMonitor running. Watching quote feeds..."); + logger.LogInformation("Demo 1 (DI-based) running. Watching quote feeds..."); logger.LogInformation(new('─', 60)); while (!stoppingToken.IsCancellationRequested) @@ -147,9 +253,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var parts = monitors.Select(m => $"{m.Name}: {(m.IsHealthy ? "HEALTHY" : "DEGRADED")}"); - logger.LogInformation("{Parts}", parts); + logger.LogInformation("[DI status] {Parts}", string.Join(" | ", parts)); - await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/HealthMonitor.Core/Monitors/DynamicHealthMonitorManager.cs b/src/HealthMonitor.Core/Monitors/DynamicHealthMonitorManager.cs new file mode 100644 index 0000000..9dc7081 --- /dev/null +++ b/src/HealthMonitor.Core/Monitors/DynamicHealthMonitorManager.cs @@ -0,0 +1,187 @@ +using HealthMonitor.Core.Abstractions; +using HealthMonitor.Core.Configuration; +using HealthMonitor.Core.Detection; +using HealthMonitor.Core.Events; + +namespace HealthMonitor.Core.Monitors; + +/// +/// Manages a dynamic set of health monitors, each driven by its own +/// firing at the monitor's configured . +/// Monitors can be added or removed at any time while the manager is running. +/// +/// Subscribing to / on the manager +/// receives forwarded events from all monitors, including those added after the subscription. +/// +/// +public sealed class DynamicHealthMonitorManager : IDisposable +{ + /// + /// The system clock provider, used by monitors to timestamp events. Injected for testability. + /// + private readonly ISystemTimeProvider _clock; + + /// + /// Stores entries indexed by key, using a case-insensitive string comparer. + /// + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Lock to protect the _entries dictionary and the _disposed flag, ensuring thread safety for all public methods. + /// + private readonly object _lock = new(); + + /// + /// Set to true when the manager has been disposed, preventing further operations and ensuring timers are cleaned up. + /// + private bool _disposed; + + /// + /// Initializes a new instance using the real system clock. + /// + public DynamicHealthMonitorManager() : this(new SystemTimeProvider()) { } + + /// + /// Initializes a new instance with an injected clock (enables unit-test control). + /// + public DynamicHealthMonitorManager(ISystemTimeProvider clock) + { + _clock = clock; + } + + /// + /// Raised when any registered monitor transitions to the degraded state. + /// The sender is the individual that degraded. + /// + public event EventHandler? Degraded; + + /// + /// Raised when any registered monitor recovers from a degraded state. + /// The sender is the individual that recovered. + /// + public event EventHandler? Recovered; + + /// + /// Snapshot of all currently registered monitors, in addition order. + /// + public IReadOnlyList Monitors + { + get + { + lock (_lock) + return _entries.Values.Select(e => (IHealthMonitor)e.Monitor).ToList(); + } + } + + /// + /// Adds a new monitor with the given name and options and starts its timer immediately. + /// Throws if a monitor with the same name already exists. + /// + public IHealthMonitor Add(string name, HealthMonitorOptions? options = null) + { + if (_disposed) + throw new ObjectDisposedException(nameof(DynamicHealthMonitorManager)); + + var opts = options ?? new HealthMonitorOptions(); + opts.Name = name; + + lock (_lock) + { + if (_entries.ContainsKey(name)) + throw new ArgumentException($"A monitor named '{name}' is already registered.", nameof(name)); + + var monitor = new NamedHealthMonitor(opts, _clock); + + EventHandler onDegraded = (s, e) => Degraded?.Invoke(s, e); + EventHandler onRecovered = (s, e) => Recovered?.Invoke(s, e); + monitor.Degraded += onDegraded; + monitor.Recovered += onRecovered; + + var timer = new Timer( + _ => monitor.Tick(), + null, + opts.CheckInterval, + opts.CheckInterval); + + _entries[name] = new(monitor, timer, onDegraded, onRecovered); + return monitor; + } + } + + /// + public void Dispose() + { + lock (_lock) + { + if (_disposed) + return; + _disposed = true; + + foreach (var entry in _entries.Values) + { + entry.Unsubscribe(); + entry.Timer.Dispose(); + } + + _entries.Clear(); + } + } + + /// + /// Removes and disposes the timer for the monitor with the given name. + /// Returns true if a monitor was found and removed; false if the name was not registered. + /// + public bool Remove(string name) + { + lock (_lock) + { + if (!_entries.TryGetValue(name, out var entry)) + return false; + + _entries.Remove(name); + entry.Unsubscribe(); + entry.Timer.Dispose(); + return true; + } + } + + /// + /// Retrieves a registered monitor by name, or null if not found. + /// + public IHealthMonitor? TryGet(string name) + { + lock (_lock) + return _entries.TryGetValue(name, out var e) ? e.Monitor : null; + } + + /// + /// Internal class to hold a monitor and its associated timer together in the dictionary. + /// + /// The health monitor instance. + /// The timer associated with the monitor. + private sealed class Entry( + NamedHealthMonitor monitor, + Timer timer, + EventHandler onDegraded, + EventHandler onRecovered) + { + /// + /// Gets the health monitor associated with this instance. + /// + public NamedHealthMonitor Monitor { get; } = monitor; + + /// + /// Gets the timer instance associated with this object. + /// + public Timer Timer { get; } = timer; + + /// + /// Unsubscribes the manager's event handlers from the monitor's events. Called when removing a monitor or disposing the manager. + /// + public void Unsubscribe() + { + Monitor.Degraded -= onDegraded; + Monitor.Recovered -= onRecovered; + } + } +} diff --git a/tests/HealthMonitor.Tests/Monitors/DynamicHealthMonitorManagerTests.cs b/tests/HealthMonitor.Tests/Monitors/DynamicHealthMonitorManagerTests.cs new file mode 100644 index 0000000..0e5cb8e --- /dev/null +++ b/tests/HealthMonitor.Tests/Monitors/DynamicHealthMonitorManagerTests.cs @@ -0,0 +1,308 @@ +using HealthMonitor.Core.Configuration; +using HealthMonitor.Core.Events; +using HealthMonitor.Core.Monitors; +using HealthMonitor.Tests.Fakes; + +namespace HealthMonitor.Tests.Monitors; + +public class DynamicHealthMonitorManagerTests +{ + [Fact] + public void Add_AfterDispose_ThrowsObjectDisposedException() + { + var manager = new DynamicHealthMonitorManager(); + manager.Dispose(); + + Assert.Throws(() => manager.Add("svc")); + } + + [Fact] + public void Add_AppliesProvidedOptions() + { + using var manager = new DynamicHealthMonitorManager(); + var opts = new HealthMonitorOptions + { + DegradedThreshold = TimeSpan.FromMinutes(5), + CheckInterval = TimeSpan.FromSeconds(10), + }; + + // Just verify it doesn't throw and name is applied + var monitor = manager.Add("svc", opts); + Assert.Equal("svc", monitor.Name); + } + + [Fact] + public void Add_DuplicateName_IsCaseInsensitive() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("SVC"); + + Assert.Throws(() => manager.Add("svc")); + } + + [Fact] + public void Add_DuplicateName_ThrowsArgumentException() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("svc"); + + Assert.Throws(() => manager.Add("svc")); + } + + [Fact] + public void Add_MonitorIsInitiallyHealthy() + { + using var manager = new DynamicHealthMonitorManager(); + + var monitor = manager.Add("svc"); + + Assert.True(monitor.IsHealthy); + } + + [Fact] + public void Add_ReturnsMonitorWithCorrectName() + { + using var manager = new DynamicHealthMonitorManager(); + + var monitor = manager.Add("svc"); + + Assert.Equal("svc", monitor.Name); + } + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var manager = new DynamicHealthMonitorManager(); + manager.Dispose(); + + var ex = Record.Exception(manager.Dispose); + Assert.Null(ex); + } + + [Fact] + public async Task ManagerDegraded_FiresForLaterAddedMonitor() + { + using var manager = new DynamicHealthMonitorManager(new FakeSystemTimeProvider()); + + HealthDegradedEventArgs? captured = null; + // Subscribe BEFORE adding the monitor + manager.Degraded += (_, e) => captured = e; + + manager.Add("late", new HealthMonitorOptions + { + DegradedThreshold = TimeSpan.FromMilliseconds(100), + CheckInterval = TimeSpan.FromMilliseconds(50), + }); + + await PollUntil(() => captured is not null, timeout: TimeSpan.FromSeconds(5)); + + Assert.NotNull(captured); + Assert.Equal("late", captured.MonitorName); + } + + [Fact] + public async Task ManagerDegraded_FiresWhenMonitorDegrades() + { + using var manager = new DynamicHealthMonitorManager(new FakeSystemTimeProvider()); + + HealthDegradedEventArgs? captured = null; + manager.Degraded += (_, e) => captured = e; + + manager.Add("svc", new HealthMonitorOptions + { + DegradedThreshold = TimeSpan.FromMilliseconds(100), + CheckInterval = TimeSpan.FromMilliseconds(50), + }); + + // Wait long enough for the real Timer + stopwatch to fire degradation + await PollUntil(() => captured is not null, timeout: TimeSpan.FromSeconds(5)); + + Assert.NotNull(captured); + Assert.Equal("svc", captured.MonitorName); + } + + [Fact] + public async Task ManagerRecovered_FiresWhenMonitorRecovers() + { + using var manager = new DynamicHealthMonitorManager(new FakeSystemTimeProvider()); + + HealthDegradedEventArgs? degraded = null; + HealthRecoveredEventArgs? recovered = null; + manager.Degraded += (_, e) => degraded = e; + manager.Recovered += (_, e) => recovered = e; + + var monitor = manager.Add("svc", new HealthMonitorOptions + { + DegradedThreshold = TimeSpan.FromMilliseconds(100), + CheckInterval = TimeSpan.FromMilliseconds(50), + }); + + // Wait for degradation first + await PollUntil(() => degraded is not null, timeout: TimeSpan.FromSeconds(5)); + + // Signal to recover + monitor.Signal(); + + await PollUntil(() => recovered is not null, timeout: TimeSpan.FromSeconds(2)); + + Assert.NotNull(recovered); + Assert.Equal("svc", recovered.MonitorName); + } + + [Fact] + public async Task ManagerSender_IsTheIndividualMonitor() + { + using var manager = new DynamicHealthMonitorManager(new FakeSystemTimeProvider()); + + object? capturedSender = null; + manager.Degraded += (sender, _) => capturedSender = sender; + + var monitor = manager.Add("svc", new HealthMonitorOptions + { + DegradedThreshold = TimeSpan.FromMilliseconds(100), + CheckInterval = TimeSpan.FromMilliseconds(50), + }); + + await PollUntil(() => capturedSender is not null, timeout: TimeSpan.FromSeconds(5)); + + Assert.Same(monitor, capturedSender); + } + + [Fact] + public void Monitors_IsEmptyAfterDispose() + { + var manager = new DynamicHealthMonitorManager(); + manager.Add("a"); + manager.Add("b"); + + manager.Dispose(); + + Assert.Empty(manager.Monitors); + } + + [Fact] + public void Monitors_ReflectsAllAddedMonitors() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("a"); + manager.Add("b"); + manager.Add("c"); + + var names = manager.Monitors.Select(m => m.Name).ToHashSet(); + + Assert.Equal(["a", "b", "c"], names); + } + + [Fact] + public void Remove_ExistingMonitor_ReturnsTrue() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("svc"); + + Assert.True(manager.Remove("svc")); + } + + [Fact] + public void Remove_IsCaseInsensitive() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("SVC"); + + Assert.True(manager.Remove("svc")); + Assert.Null(manager.TryGet("SVC")); + } + + [Fact] + public void Remove_MonitorDisappearsFromMonitorsList() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("a"); + manager.Add("b"); + + manager.Remove("a"); + + var names = manager.Monitors.Select(m => m.Name).ToList(); + Assert.DoesNotContain("a", names); + Assert.Contains("b", names); + } + + [Fact] + public void Remove_NonExistentMonitor_ReturnsFalse() + { + using var manager = new DynamicHealthMonitorManager(); + + Assert.False(manager.Remove("missing")); + } + + [Fact] + public async Task Remove_StopsEventForwarding() + { + using var manager = new DynamicHealthMonitorManager(new FakeSystemTimeProvider()); + + var firedCount = 0; + manager.Degraded += (_, _) => firedCount++; + + manager.Add("svc", new HealthMonitorOptions + { + DegradedThreshold = TimeSpan.FromMilliseconds(100), + CheckInterval = TimeSpan.FromMilliseconds(50), + }); + + // Wait for at least one degradation to confirm the path works + await PollUntil(() => firedCount > 0, timeout: TimeSpan.FromSeconds(5)); + + manager.Remove("svc"); + var countAtRemoval = firedCount; + + // Wait a bit — no further events should arrive + await Task.Delay(200, TestContext.Current.CancellationToken); + + Assert.Equal(countAtRemoval, firedCount); + } + + [Fact] + public void TryGet_AfterRemove_ReturnsNull() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("svc"); + manager.Remove("svc"); + + Assert.Null(manager.TryGet("svc")); + } + + [Fact] + public void TryGet_ExistingMonitor_ReturnsMonitor() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("svc"); + + var result = manager.TryGet("svc"); + + Assert.NotNull(result); + Assert.Equal("svc", result.Name); + } + + [Fact] + public void TryGet_IsCaseInsensitive() + { + using var manager = new DynamicHealthMonitorManager(); + manager.Add("SVC"); + + Assert.NotNull(manager.TryGet("svc")); + } + + [Fact] + public void TryGet_NonExistentMonitor_ReturnsNull() + { + using var manager = new DynamicHealthMonitorManager(); + + Assert.Null(manager.TryGet("missing")); + } + + private static async Task PollUntil(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition() && DateTime.UtcNow < deadline) + await Task.Delay(20); + } +} \ No newline at end of file