feat(dcl): add StaleTagMonitor for heartbeat-based disconnect detection

Composable StaleTagMonitor class in Commons fires a Stale event when no
value is received within a configurable max silence period. Integrated
into both LmxProxyDataConnection and OpcUaDataConnection adapters via
optional HeartbeatTagPath/HeartbeatMaxSilence connection config keys.
When stale, the adapter fires Disconnected triggering the standard
reconnect cycle. 10 unit tests cover timer behavior.
This commit is contained in:
Joseph Doherty
2026-03-24 14:28:11 -04:00
parent 02a7e8abc6
commit d4397910f0
4 changed files with 285 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
public class StaleTagMonitorTests
{
[Fact]
public void Constructor_ZeroTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.Zero));
}
[Fact]
public void Constructor_NegativeTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.FromSeconds(-1)));
}
[Fact]
public async Task Stale_FiresAfterMaxSilence()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task Stale_FiresOnlyOnce()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task OnValueReceived_ResetsTimer()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(200));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Keep resetting before the 200ms deadline
for (int i = 0; i < 5; i++)
{
await Task.Delay(100);
monitor.OnValueReceived();
}
// Should not have gone stale
Assert.Equal(0, staleCount);
}
[Fact]
public async Task OnValueReceived_AllowsStaleAfterSilence()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Reset once
await Task.Delay(50);
monitor.OnValueReceived();
// Then go silent
await Task.Delay(250);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task OnValueReceived_ResetsStaleFlag_AllowsSecondFire()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Wait for first stale
await Task.Delay(250);
Assert.Equal(1, staleCount);
// Reset — should allow second stale fire
monitor.OnValueReceived();
await Task.Delay(250);
Assert.Equal(2, staleCount);
}
[Fact]
public async Task Stop_PreventsStale()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
monitor.Stop();
await Task.Delay(200);
Assert.Equal(0, staleCount);
}
[Fact]
public async Task Dispose_PreventsStale()
{
var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
monitor.Dispose();
await Task.Delay(200);
Assert.Equal(0, staleCount);
}
[Fact]
public void MaxSilence_ReturnsConfiguredValue()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromSeconds(42));
Assert.Equal(TimeSpan.FromSeconds(42), monitor.MaxSilence);
}
}