fix(commons): resolve Commons-001..004 — stale-fire race, JsonDocument lifetime, GetNullable strictness, registry symmetry
This commit is contained in:
103
tests/ScadaLink.Commons.Tests/Types/DynamicJsonElementTests.cs
Normal file
103
tests/ScadaLink.Commons.Tests/Types/DynamicJsonElementTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DynamicJsonElement"/>, including the Commons-002 regression:
|
||||
/// a wrapped element must remain valid for deferred (script-time) access even after
|
||||
/// the <see cref="JsonDocument"/> it was parsed from has been disposed.
|
||||
/// </summary>
|
||||
public class DynamicJsonElementTests
|
||||
{
|
||||
private static DynamicJsonElement Wrap(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return new DynamicJsonElement(doc.RootElement);
|
||||
// doc is disposed here — a wrapper that retained a non-cloned element would
|
||||
// now throw ObjectDisposedException on the first member access.
|
||||
}
|
||||
|
||||
// ── Commons-002 regression: lifetime independence from the source document ──
|
||||
|
||||
[Fact]
|
||||
public void MemberAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "name": "pump", "id": 7 }""");
|
||||
|
||||
Assert.Equal("pump", (string)obj.name);
|
||||
Assert.Equal(7, (int)obj.id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
|
||||
|
||||
Assert.Equal("b", obj.items[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "outer": { "inner": { "value": 42 } } }""");
|
||||
|
||||
Assert.Equal(42, (int)obj.outer.inner.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "label": "site-1" }""");
|
||||
|
||||
Assert.Equal("site-1", obj.label.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Access_SurvivesGarbageCollection_OfSourceDocument()
|
||||
{
|
||||
// No reference to the source document is held anywhere; force collection
|
||||
// and finalization to prove the wrapper does not depend on it.
|
||||
var obj = MakeWrapperAndDropDocument();
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
dynamic d = obj;
|
||||
Assert.Equal("ok", (string)d.status);
|
||||
}
|
||||
|
||||
private static DynamicJsonElement MakeWrapperAndDropDocument()
|
||||
{
|
||||
var doc = JsonDocument.Parse("""{ "status": "ok" }""");
|
||||
var wrapper = new DynamicJsonElement(doc.RootElement);
|
||||
doc.Dispose();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ── Basic conversion / access behavior ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Convert_NumberToInt()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "n": 123 }""");
|
||||
Assert.Equal(123, (int)obj.n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_BoolFromJson()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "flag": true }""");
|
||||
Assert.True((bool)obj.flag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingMember_Throws()
|
||||
{
|
||||
// TryGetMember returns false for an absent property, so the dynamic binder
|
||||
// surfaces a RuntimeBinderException — the standard DynamicObject contract.
|
||||
dynamic obj = Wrap("""{ "a": 1 }""");
|
||||
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
|
||||
() => { var _ = obj.doesNotExist; });
|
||||
}
|
||||
}
|
||||
@@ -148,11 +148,30 @@ public class ScriptParametersTests
|
||||
Assert.Equal(42, p.Get<int?>("x"));
|
||||
}
|
||||
|
||||
// Commons-003: a parameter that is *present but unconvertible* is a caller/script
|
||||
// bug and must throw — not be silently mapped to null (which a script would
|
||||
// misread as "not supplied"). Genuinely absent/null still returns null.
|
||||
[Fact]
|
||||
public void Get_NullableInt_Unparsable_ReturnsNull()
|
||||
public void Get_NullableInt_PresentButUnparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "abc" });
|
||||
Assert.Null(p.Get<int?>("x"));
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "banana" });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_PresentButOverflowing_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = long.MaxValue });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableDateTime_PresentButUnparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = "not-a-date" });
|
||||
Assert.Throws<ScriptParameterException>(() => p.Get<DateTime?>("dt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
119
tests/ScadaLink.Commons.Tests/Types/StaleTagMonitorRaceTests.cs
Normal file
119
tests/ScadaLink.Commons.Tests/Types/StaleTagMonitorRaceTests.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Commons-001: the check-then-act race between the timer
|
||||
/// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>.
|
||||
///
|
||||
/// The original implementation guarded firing with a single <c>volatile bool</c> that
|
||||
/// was both read by the callback and reset by the caller threads. Because the
|
||||
/// check-then-set was not atomic with the timer reschedule, a callback that had
|
||||
/// already entered could raise <c>Stale</c> after the period it was scheduled for
|
||||
/// had been cancelled or restarted — a spurious staleness signal that, for a
|
||||
/// connection-health monitor, triggers an unnecessary reconnect.
|
||||
///
|
||||
/// These tests use the internal <c>CallbackEnteredHook</c> seam to deterministically
|
||||
/// interleave a caller-thread operation with an in-flight callback.
|
||||
/// </summary>
|
||||
public class StaleTagMonitorRaceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A value arrives (<c>OnValueReceived</c>) while a previous-period timer callback
|
||||
/// is in flight, before that callback decides whether to fire. The old period has
|
||||
/// been superseded, so the in-flight callback must not raise <c>Stale</c>
|
||||
/// immediately; <c>Stale</c> may only fire later, for the fresh period, after a
|
||||
/// full <c>MaxSilence</c> with no further values.
|
||||
///
|
||||
/// With the original single-volatile-bool guard the in-flight callback fired
|
||||
/// <c>Stale</c> right after the value arrived (a spurious, wrong-moment signal);
|
||||
/// this test detects that by checking how soon the fire lands after the value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_DoesNotFirePromptly_WhenValueArrivesWhileCallbackInFlight()
|
||||
{
|
||||
var maxSilence = TimeSpan.FromMilliseconds(60);
|
||||
using var monitor = new StaleTagMonitor(maxSilence);
|
||||
|
||||
DateTime? valueArrivedAt = null;
|
||||
DateTime? staleFiredAt = null;
|
||||
monitor.Stale += () => staleFiredAt ??= DateTime.UtcNow;
|
||||
|
||||
// When the (old-period) callback is entered, simulate a fresh value arriving
|
||||
// on another thread before the callback's fire decision.
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null; // only intercept the first callback
|
||||
valueArrivedAt = DateTime.UtcNow;
|
||||
monitor.OnValueReceived();
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
|
||||
// Wait well past the intercepted callback and the fresh period's deadline.
|
||||
Thread.Sleep(300);
|
||||
monitor.Stop();
|
||||
|
||||
// The fresh period legitimately goes stale, so a fire is expected — but it
|
||||
// must land roughly MaxSilence after the value, not immediately. A spurious
|
||||
// wrong-moment fire from the superseded callback would land within a few ms.
|
||||
Assert.NotNull(valueArrivedAt);
|
||||
Assert.NotNull(staleFiredAt);
|
||||
var delay = staleFiredAt.Value - valueArrivedAt.Value;
|
||||
Assert.True(delay >= maxSilence * 0.5,
|
||||
$"Stale fired only {delay.TotalMilliseconds:F0}ms after the value arrived; " +
|
||||
$"expected at least {maxSilence.TotalMilliseconds * 0.5:F0}ms — the in-flight " +
|
||||
"callback fired spuriously for the superseded period.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Stop</c> races an in-flight timer callback. Once monitoring is stopped no
|
||||
/// <c>Stale</c> signal may be delivered, even for a callback that had already
|
||||
/// been entered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_DoesNotFire_WhenStopRacesInFlightCallback()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null;
|
||||
monitor.Stop();
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
Thread.Sleep(200);
|
||||
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Start</c> (a restart) races an in-flight callback from the prior run. The
|
||||
/// old callback belongs to a superseded period and must not fire; the new period
|
||||
/// fires exactly once on its own deadline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_FiresOnceForNewPeriod_WhenRestartRacesInFlightCallback()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null;
|
||||
monitor.Start(); // restart — supersedes the in-flight callback's period
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
|
||||
// Old callback must be suppressed; the restarted period fires exactly once.
|
||||
Thread.Sleep(250);
|
||||
monitor.Stop();
|
||||
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user