fix(commons): resolve Commons-001..004 — stale-fire race, JsonDocument lifetime, GetNullable strictness, registry symmetry
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
| Last reviewed | 2026-05-16 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `9c60592` |
|
||||
| Open findings | 12 |
|
||||
| Open findings | 8 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -55,7 +55,7 @@ wire command.
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.Commons/Types/StaleTagMonitor.cs:42-46`, `:62-67` |
|
||||
|
||||
**Description**
|
||||
@@ -81,7 +81,14 @@ only then reschedule the timer.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending) — confirmed the race against the source. Replaced
|
||||
the `volatile bool` guard with a lock-protected monotonic generation token: `Start`,
|
||||
`OnValueReceived` and `Stop` each bump the generation under a gate, and the timer
|
||||
callback only raises `Stale` if its scheduled generation still matches. `OnValueReceived`
|
||||
now recreates the timer (rather than `Change`-ing it) so the rescheduled callback carries
|
||||
the new token. A superseded or stopped period can no longer emit a spurious staleness
|
||||
signal. Regression tests added in `StaleTagMonitorRaceTests` (deterministic via an
|
||||
internal `CallbackEnteredHook` test seam).
|
||||
|
||||
### Commons-002 — `DynamicJsonElement` retains a `JsonElement` whose `JsonDocument` lifetime it does not own
|
||||
|
||||
@@ -89,7 +96,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Performance & resource management |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.Commons/Types/DynamicJsonElement.cs:10-17` |
|
||||
|
||||
**Description**
|
||||
@@ -113,7 +120,13 @@ regardless.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending) — confirmed the hazard: `ExternalCallResult.Response`
|
||||
constructs the wrapper from `JsonDocument.Parse(...).RootElement` with no reference kept
|
||||
to the document, so deferred script-time access could fault. Fixed at the root by cloning
|
||||
the element with `JsonElement.Clone()` in the `DynamicJsonElement` constructor, detaching
|
||||
it from the owning document; the public constructor signature is unchanged. Added a
|
||||
remarks block documenting the lifetime contract. Regression tests added in
|
||||
`DynamicJsonElementTests` (access after the source document is disposed / GC-collected).
|
||||
|
||||
### Commons-003 — `ScriptParameters.GetNullable` silently swallows conversion failures
|
||||
|
||||
@@ -121,7 +134,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.Commons/Types/ScriptParameters.cs:72-86` |
|
||||
|
||||
**Description**
|
||||
@@ -146,7 +159,15 @@ element handling. If the swallowing must stay for compatibility, at minimum surf
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending) — confirmed the silent-swallow path against the
|
||||
source. Removed the `catch (ScriptParameterException)` block in `GetNullable<T>`: an
|
||||
absent or explicitly-null parameter still returns `null`, but a parameter that is
|
||||
*present but holds an unconvertible value* now throws `ScriptParameterException` with a
|
||||
descriptive message, consistent with `Get<T>()` and the array/list element paths. The
|
||||
`Get<T>` XML doc was corrected accordingly. This is a deliberate behavioral change toward
|
||||
correctness — the previous behavior masked caller/script bugs; the type-level public
|
||||
contract is unchanged. Regression tests added in `ScriptParametersTests`
|
||||
(`Get_NullableInt_PresentButUnparsable_Throws` and siblings).
|
||||
|
||||
### Commons-004 — `ManagementCommandRegistry` name mapping is asymmetric and namespace-scoped
|
||||
|
||||
@@ -154,7 +175,7 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Code organization & conventions |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.Commons/Messages/Management/ManagementCommandRegistry.cs:14-35` |
|
||||
|
||||
**Description**
|
||||
@@ -187,7 +208,20 @@ scope, and reconsider whether the `Mgmt*` prefixed duplicates are still needed.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
Resolved 2026-05-16 (commit pending) — confirmed the asymmetry: `GetCommandName` stripped
|
||||
`Command` from any type while `BuildRegistry` only registered the `Messages.Management`
|
||||
namespace. In practice no defect was observed because every command type the CLI and
|
||||
ManagementService actually use is in `Messages.Management` (a round-trip test over all
|
||||
registered commands confirms no name collision). Closed the asymmetry by making
|
||||
`GetCommandName` registry-bound: it now looks up a reverse `Type→name` frozen dictionary
|
||||
built from the same registry and throws `ArgumentException` for any unregistered type, so
|
||||
`Resolve(GetCommandName(t)) == t` holds for every type it accepts. Added an XML remarks
|
||||
block documenting the registry scope and the symmetry guarantee. The `Mgmt*` prefixed
|
||||
records were left in place — they are the genuine Management-namespace command types the
|
||||
CLI constructs and renaming them would change wire command names (out of scope for a
|
||||
behavior-preserving fix; noted for a future cleanup). CLI, ManagementService, and
|
||||
SiteRuntime all build clean against the change. Regression tests added in
|
||||
`ManagementCommandRegistryTests`.
|
||||
|
||||
### Commons-005 — `OpcUaEndpointConfigSerializer.Deserialize` discards malformed legacy input and over-reports `IsLegacy`
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Reflection;
|
||||
using ScadaLink.Commons.Messages.Management;
|
||||
|
||||
namespace ScadaLink.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ManagementCommandRegistry"/>, including the Commons-004
|
||||
/// regression: <c>GetCommandName</c> and <c>Resolve</c> must be symmetric — every
|
||||
/// type for which <c>GetCommandName</c> yields a name must round-trip back to the
|
||||
/// same type via <c>Resolve</c>.
|
||||
/// </summary>
|
||||
public class ManagementCommandRegistryTests
|
||||
{
|
||||
private static IEnumerable<Type> RegisteredCommandTypes() =>
|
||||
typeof(ManagementEnvelope).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||||
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
|
||||
&& !t.IsAbstract);
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand()
|
||||
{
|
||||
foreach (var type in RegisteredCommandTypes())
|
||||
{
|
||||
var name = ManagementCommandRegistry.GetCommandName(type);
|
||||
var resolved = ManagementCommandRegistry.Resolve(name);
|
||||
Assert.Equal(type, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_KnownCommand_ReturnsType()
|
||||
{
|
||||
var type = ManagementCommandRegistry.Resolve("CreateSite");
|
||||
Assert.Equal(typeof(CreateSiteCommand), type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnknownCommand_ReturnsNull()
|
||||
{
|
||||
Assert.Null(ManagementCommandRegistry.Resolve("NoSuchCommand"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_IsCaseInsensitive()
|
||||
{
|
||||
Assert.Equal(typeof(CreateSiteCommand), ManagementCommandRegistry.Resolve("createsite"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commons-004: <c>GetCommandName</c> previously stripped a <c>Command</c> suffix
|
||||
/// from <em>any</em> type, producing names the registry cannot resolve. It must
|
||||
/// only return a name for a command type the registry actually contains.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetCommandName_UnregisteredCommandType_Throws()
|
||||
{
|
||||
// A *Command type that is not in the Messages.Management namespace.
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => ManagementCommandRegistry.GetCommandName(typeof(UnregisteredFakeCommand)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_NonCommandType_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => ManagementCommandRegistry.GetCommandName(typeof(string)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_RegisteredCommand_ReturnsStrippedName()
|
||||
{
|
||||
Assert.Equal("CreateSite", ManagementCommandRegistry.GetCommandName(typeof(CreateSiteCommand)));
|
||||
}
|
||||
|
||||
/// <summary>A *Command record outside the Management namespace, for the negative test.</summary>
|
||||
private record UnregisteredFakeCommand(int Id);
|
||||
}
|
||||
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