fix(commons): resolve Commons-001..004 — stale-fire race, JsonDocument lifetime, GetNullable strictness, registry symmetry

This commit is contained in:
Joseph Doherty
2026-05-16 20:58:03 -04:00
parent dba1a1b25f
commit 3e7a3d7e31
9 changed files with 501 additions and 37 deletions

View 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; });
}
}

View File

@@ -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]

View 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);
}
}