fix(commons): resolve Commons-001..004 — stale-fire race, JsonDocument lifetime, GetNullable strictness, registry symmetry
This commit is contained in:
@@ -2,21 +2,56 @@ using System.Collections.Frozen;
|
||||
|
||||
namespace ScadaLink.Commons.Messages.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Bidirectional name registry for management command records. The registry contains
|
||||
/// exactly the non-abstract <c>*Command</c> types declared in the
|
||||
/// <c>ScadaLink.Commons.Messages.Management</c> namespace; these are the commands that
|
||||
/// travel over the HTTP / ClusterClient management boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Resolve"/> and <see cref="GetCommandName"/> are symmetric:
|
||||
/// <c>Resolve(GetCommandName(t))</c> returns <c>t</c> for every type
|
||||
/// <see cref="GetCommandName"/> accepts. <see cref="GetCommandName"/> rejects any type
|
||||
/// the registry does not contain rather than computing an unresolvable name.
|
||||
/// </remarks>
|
||||
public static class ManagementCommandRegistry
|
||||
{
|
||||
private static readonly FrozenDictionary<string, Type> Commands = BuildRegistry();
|
||||
|
||||
/// <summary>
|
||||
/// Names keyed by command type, for the reverse lookup. Keeps
|
||||
/// <see cref="GetCommandName"/> in lock-step with the forward registry.
|
||||
/// </summary>
|
||||
private static readonly FrozenDictionary<Type, string> NamesByType =
|
||||
Commands.ToFrozenDictionary(kv => kv.Value, kv => kv.Key);
|
||||
|
||||
public static Type? Resolve(string commandName)
|
||||
{
|
||||
return Commands.GetValueOrDefault(commandName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the registered wire name for a management command type.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="commandType"/> is not a registered management
|
||||
/// command — i.e. not a non-abstract <c>*Command</c> type in the
|
||||
/// <c>ScadaLink.Commons.Messages.Management</c> namespace. This keeps the method
|
||||
/// symmetric with <see cref="Resolve"/>: it never yields a name that
|
||||
/// <see cref="Resolve"/> cannot turn back into the same type.
|
||||
/// </exception>
|
||||
public static string GetCommandName(Type commandType)
|
||||
{
|
||||
var name = commandType.Name;
|
||||
return name.EndsWith("Command", StringComparison.Ordinal)
|
||||
? name[..^"Command".Length]
|
||||
: name;
|
||||
ArgumentNullException.ThrowIfNull(commandType);
|
||||
|
||||
if (NamesByType.TryGetValue(commandType, out var name))
|
||||
return name;
|
||||
|
||||
throw new ArgumentException(
|
||||
$"'{commandType.FullName}' is not a registered management command. " +
|
||||
$"Management commands must be non-abstract '*Command' records declared in " +
|
||||
$"the '{typeof(ManagementEnvelope).Namespace}' namespace.",
|
||||
nameof(commandType));
|
||||
}
|
||||
|
||||
private static FrozenDictionary<string, Type> BuildRegistry()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,7 +24,8 @@ public class ScriptParameters : IReadOnlyDictionary<string, object?>
|
||||
/// Gets a parameter value with typed conversion.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Get<int>("key")</c> — throws if missing, null, or unconvertible.</item>
|
||||
/// <item><c>Get<int?>("key")</c> — returns null if missing, null, or unconvertible.</item>
|
||||
/// <item><c>Get<int?>("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<int[]>("key")</c> — converts list to typed array; throws on first bad element.</item>
|
||||
/// <item><c>Get<List<int>>("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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user