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

@@ -7,13 +7,22 @@ namespace ScadaLink.Commons.Types;
/// Wraps a JsonElement as a dynamic object for convenient property access in scripts.
/// Supports property access (obj.name), indexing (obj.items[0]), and ToString().
/// </summary>
/// <remarks>
/// The element passed to the constructor is <see cref="JsonElement.Clone()">cloned</see>
/// so the wrapper owns a self-contained copy. This decouples its lifetime from the
/// <see cref="JsonDocument"/> the element originated from: a wrapper built from an
/// element inside a <c>using</c> block remains valid for deferred (e.g. script-time)
/// access after that document has been disposed.
/// </remarks>
public class DynamicJsonElement : DynamicObject
{
private readonly JsonElement _element;
public DynamicJsonElement(JsonElement element)
{
_element = element;
// Clone detaches the element from its owning JsonDocument so accessing it
// later cannot throw ObjectDisposedException once that document is disposed.
_element = element.Clone();
}
public override bool TryGetMember(GetMemberBinder binder, out object? result)

View File

@@ -24,7 +24,8 @@ public class ScriptParameters : IReadOnlyDictionary<string, object?>
/// Gets a parameter value with typed conversion.
/// <list type="bullet">
/// <item><c>Get&lt;int&gt;("key")</c> — throws if missing, null, or unconvertible.</item>
/// <item><c>Get&lt;int?&gt;("key")</c> — returns null if missing, null, or unconvertible.</item>
/// <item><c>Get&lt;int?&gt;("key")</c> — returns null if the parameter is missing or null;
/// throws if it is present but holds an unconvertible value.</item>
/// <item><c>Get&lt;int[]&gt;("key")</c> — converts list to typed array; throws on first bad element.</item>
/// <item><c>Get&lt;List&lt;int&gt;&gt;("key")</c> — converts list to typed List; throws on first bad element.</item>
/// </list>
@@ -71,18 +72,17 @@ public class ScriptParameters : IReadOnlyDictionary<string, object?>
private T GetNullable<T>(string key, Type underlyingType)
{
// Absent or explicitly-null parameter — the caller did not supply a value.
if (!_inner.TryGetValue(key, out var value) || value is null)
return default!; // null for Nullable<T>
try
{
var converted = ConvertScalar(value, underlyingType, key);
return (T)converted;
}
catch (ScriptParameterException)
{
return default!; // null on conversion failure for nullable
}
// A parameter that is *present but non-null* must be convertible. A value
// that cannot be converted is a caller/script bug, not "not supplied":
// throw with a descriptive message rather than silently returning null
// (which a script would misread as absent). This mirrors Get<T>() and the
// array/list element paths. See Commons-003.
var converted = ConvertScalar(value, underlyingType, key);
return (T)converted;
}
private Array ConvertToArray(string key, Type elementType)

View File

@@ -1,3 +1,7 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ScadaLink.Commons.Tests")]
namespace ScadaLink.Commons.Types;
/// <summary>
@@ -5,11 +9,29 @@ namespace ScadaLink.Commons.Types;
/// within <see cref="MaxSilence"/>, the <see cref="Stale"/> event fires.
/// Composable into any IDataConnection adapter.
/// </summary>
/// <remarks>
/// Thread-safe: <see cref="Start"/>, <see cref="OnValueReceived"/> and <see cref="Stop"/>
/// may be called from any thread and race the internal timer callback. Each call to
/// <see cref="Start"/> or <see cref="OnValueReceived"/> begins a new monitoring period
/// identified by a generation token; a timer callback only raises <see cref="Stale"/>
/// if it still belongs to the current period. A fresh value, a restart, or a
/// <see cref="Stop"/> arriving while a previous-period callback is in flight bumps the
/// generation, so that callback observes the mismatch and declines to fire — no spurious
/// staleness signal is emitted after the period it was scheduled for has ended.
/// </remarks>
public sealed class StaleTagMonitor : IDisposable
{
private readonly TimeSpan _maxSilence;
private readonly object _gate = new();
private Timer? _timer;
private volatile bool _staleFired;
/// <summary>
/// Monotonically increasing token identifying the current monitoring period.
/// Bumped on every <see cref="Start"/>, <see cref="OnValueReceived"/> and
/// <see cref="Stop"/> so that a timer callback scheduled for an earlier period
/// can detect that it is stale and decline to fire.
/// </summary>
private long _generation;
public StaleTagMonitor(TimeSpan maxSilence)
{
@@ -26,14 +48,25 @@ public sealed class StaleTagMonitor : IDisposable
public TimeSpan MaxSilence => _maxSilence;
/// <summary>
/// Test-only seam invoked by the timer callback after it has been entered but
/// before it acquires the synchronization gate. Allows a test to deterministically
/// interleave a <see cref="Stop"/> / <see cref="OnValueReceived"/> with an in-flight
/// callback to exercise the stale-fire race. Never set in production.
/// </summary>
internal Action? CallbackEnteredHook { get; set; }
/// <summary>
/// Start monitoring. The timer begins counting from now.
/// </summary>
public void Start()
{
_staleFired = false;
_timer?.Dispose();
_timer = new Timer(OnTimerElapsed, null, _maxSilence, Timeout.InfiniteTimeSpan);
lock (_gate)
{
_generation++;
_timer?.Dispose();
_timer = new Timer(OnTimerElapsed, _generation, _maxSilence, Timeout.InfiniteTimeSpan);
}
}
/// <summary>
@@ -41,8 +74,20 @@ public sealed class StaleTagMonitor : IDisposable
/// </summary>
public void OnValueReceived()
{
_staleFired = false;
_timer?.Change(_maxSilence, Timeout.InfiniteTimeSpan);
lock (_gate)
{
// No active monitoring — nothing to reset.
if (_timer is null)
return;
// Bump the generation: any timer callback for the previous period that
// has already been entered will see a generation mismatch and decline to
// raise Stale. The timer is recreated rather than re-armed with
// Change(...) so the new callback carries the new generation token.
_generation++;
_timer.Dispose();
_timer = new Timer(OnTimerElapsed, _generation, _maxSilence, Timeout.InfiniteTimeSpan);
}
}
/// <summary>
@@ -50,8 +95,14 @@ public sealed class StaleTagMonitor : IDisposable
/// </summary>
public void Stop()
{
_timer?.Dispose();
_timer = null;
lock (_gate)
{
// Bumping the generation invalidates any in-flight callback so a stopped
// monitor cannot deliver a Stale signal.
_generation++;
_timer?.Dispose();
_timer = null;
}
}
public void Dispose()
@@ -61,8 +112,24 @@ public sealed class StaleTagMonitor : IDisposable
private void OnTimerElapsed(object? state)
{
if (_staleFired) return;
_staleFired = true;
var scheduledGeneration = (long)state!;
CallbackEnteredHook?.Invoke();
// Only fire if this callback still represents the current period. The check
// and the generation bump happen under the gate, so a concurrent
// OnValueReceived / Stop / Start either completes before this guard (its
// generation bump makes this callback decline) or serializes after it.
lock (_gate)
{
if (_generation != scheduledGeneration)
return;
// Consume this period so a duplicate callback for the same generation
// cannot fire twice; the next Start/OnValueReceived issues a new token.
_generation++;
}
Stale?.Invoke();
}
}