fix(core-abstractions): resolve Medium code-review findings (Core.Abstractions-001, -002, -003)

Core.Abstractions-001: PollGroupEngine compares array values with structural
equality so a driver returning a fresh T[] each poll no longer fires spuriously.
Core.Abstractions-002: PollOnceAsync guards reader result cardinality and
throws a descriptive InvalidOperationException on mismatch instead of a
swallowed ArgumentOutOfRangeException that stalled the subscription.
Core.Abstractions-003: the poll loop Task is tracked; Unsubscribe/DisposeAsync
await loop completion before disposing the CTS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 08:29:49 -04:00
parent 4dcfaace62
commit 11612900ba
3 changed files with 185 additions and 14 deletions

View File

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 | | Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` | | Commit reviewed | `76d35d1` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 8 | | Open findings | 5 |
## Checklist coverage ## Checklist coverage
@@ -36,13 +36,13 @@ a category produced nothing rather than leaving it blank.
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs:112` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs:112` |
| Status | Open | | Status | Resolved |
**Description:** `PollOnceAsync` detects a change with `!Equals(lastSeen?.Value, current.Value)`. `object.Equals` falls back to reference equality for reference types that do not override it — including `T[]` array values. The capability interfaces explicitly support 1-D array attributes (`DriverAttributeInfo.IsArray`, `ValueRank=1`), and a driver's batch reader produces a fresh array instance on every poll. As a result every poll of an array-valued tag is treated as a change, so `OnDataChange` fires on every tick regardless of whether the array contents actually changed. This produces spurious data-change notifications and unnecessary OPC UA monitored-item publishes for any poll-based driver (Modbus, S7, AB CIP, FOCAS) that exposes array tags. **Description:** `PollOnceAsync` detects a change with `!Equals(lastSeen?.Value, current.Value)`. `object.Equals` falls back to reference equality for reference types that do not override it — including `T[]` array values. The capability interfaces explicitly support 1-D array attributes (`DriverAttributeInfo.IsArray`, `ValueRank=1`), and a driver's batch reader produces a fresh array instance on every poll. As a result every poll of an array-valued tag is treated as a change, so `OnDataChange` fires on every tick regardless of whether the array contents actually changed. This produces spurious data-change notifications and unnecessary OPC UA monitored-item publishes for any poll-based driver (Modbus, S7, AB CIP, FOCAS) that exposes array tags.
**Recommendation:** Compare array values structurally — e.g. when both `lastSeen?.Value` and `current.Value` are arrays, compare with `StructuralComparisons.StructuralEqualityComparer.Equals` (or element-wise) — instead of relying on `object.Equals`. Add a test covering an array-valued tag whose contents are unchanged across polls. **Recommendation:** Compare array values structurally — e.g. when both `lastSeen?.Value` and `current.Value` are arrays, compare with `StructuralComparisons.StructuralEqualityComparer.Equals` (or element-wise) — instead of relying on `object.Equals`. Add a test covering an array-valued tag whose contents are unchanged across polls.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-22 — introduced `ValuesAreDifferent` helper in `PollGroupEngine` that uses `StructuralComparisons.StructuralEqualityComparer` for `Array` values, falling back to `object.Equals` for scalars; added `Array_valued_tag_unchanged_contents_raises_only_once` and `Array_valued_tag_changed_contents_raises_event` tests.
### Core.Abstractions-002 ### Core.Abstractions-002
@@ -51,13 +51,13 @@ a category produced nothing rather than leaving it blank.
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs:105-109` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs:105-109` |
| Status | Open | | Status | Resolved |
**Description:** `PollOnceAsync` iterates `state.TagReferences` and indexes the reader's result with `snapshots[i]`, assuming the driver-supplied `_reader` delegate returns exactly one snapshot per input reference in input order. The contract is documented (ctor XML doc: "snapshots MUST be returned in the same order as the input references"), but it is never validated. A reader that returns a shorter list — a plausible driver bug, or a partial result on a backend error — throws `ArgumentOutOfRangeException` from the indexer. That exception escapes `PollOnceAsync`, is swallowed by the catch-all in `PollLoopAsync` (line 99), and the subscription then silently produces no further `OnDataChange` callbacks for the rest of its lifetime with no diagnostic. The failure mode is a permanently stalled subscription that looks healthy. **Description:** `PollOnceAsync` iterates `state.TagReferences` and indexes the reader's result with `snapshots[i]`, assuming the driver-supplied `_reader` delegate returns exactly one snapshot per input reference in input order. The contract is documented (ctor XML doc: "snapshots MUST be returned in the same order as the input references"), but it is never validated. A reader that returns a shorter list — a plausible driver bug, or a partial result on a backend error — throws `ArgumentOutOfRangeException` from the indexer. That exception escapes `PollOnceAsync`, is swallowed by the catch-all in `PollLoopAsync` (line 99), and the subscription then silently produces no further `OnDataChange` callbacks for the rest of its lifetime with no diagnostic. The failure mode is a permanently stalled subscription that looks healthy.
**Recommendation:** Validate `snapshots.Count == state.TagReferences.Count` at the top of `PollOnceAsync` and throw a descriptive exception (or skip the tick with a logged diagnostic) so the contract violation is visible rather than silently degrading. Consider surfacing repeated reader-contract failures through a callback the driver can route to its health surface. **Recommendation:** Validate `snapshots.Count == state.TagReferences.Count` at the top of `PollOnceAsync` and throw a descriptive exception (or skip the tick with a logged diagnostic) so the contract violation is visible rather than silently degrading. Consider surfacing repeated reader-contract failures through a callback the driver can route to its health surface.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-22 — added count-guard at the top of `PollOnceAsync` that throws `InvalidOperationException` with a descriptive message when the reader returns the wrong number of snapshots; added `Reader_short_result_list_raises_descriptive_exception_and_loop_continues` test verifying the loop survives contract violations and resumes delivering events once the reader recovers.
### Core.Abstractions-003 ### Core.Abstractions-003
@@ -66,7 +66,7 @@ a category produced nothing rather than leaving it blank.
| Severity | Medium | | Severity | Medium |
| Category | Concurrency & thread safety | | Category | Concurrency & thread safety |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs:64,121-130` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs:64,121-130` |
| Status | Open | | Status | Resolved |
**Description:** `Subscribe` starts the poll loop with a fire-and-forget `Task.Run` and keeps no reference to the returned `Task`. Neither `Unsubscribe` nor `DisposeAsync` awaits the loop's completion — they only cancel the `CancellationTokenSource` and dispose it. Two consequences: **Description:** `Subscribe` starts the poll loop with a fire-and-forget `Task.Run` and keeps no reference to the returned `Task`. Neither `Unsubscribe` nor `DisposeAsync` awaits the loop's completion — they only cancel the `CancellationTokenSource` and dispose it. Two consequences:
@@ -75,7 +75,7 @@ a category produced nothing rather than leaving it blank.
**Recommendation:** Track each loop `Task` in `SubscriptionState` and await it (with a timeout) in `Unsubscribe`/`DisposeAsync` before disposing the CTS, so disposal is deterministic and no callback can fire after teardown. At minimum, defer `Cts.Dispose()` until the loop task has observed cancellation, or wrap the `Task.Delay` await to also tolerate `ObjectDisposedException`. **Recommendation:** Track each loop `Task` in `SubscriptionState` and await it (with a timeout) in `Unsubscribe`/`DisposeAsync` before disposing the CTS, so disposal is deterministic and no callback can fire after teardown. At minimum, defer `Cts.Dispose()` until the loop task has observed cancellation, or wrap the `Task.Delay` await to also tolerate `ObjectDisposedException`.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-22 — stored the loop `Task` in `SubscriptionState.LoopTask`; `Unsubscribe` calls `StopState` which cancels then awaits the task (5 s timeout) before disposing the CTS; `DisposeAsync` cancels all loops in parallel then awaits them all via `Task.WhenAll` with a 5 s timeout before disposing CTSs, making teardown deterministic and preventing post-disposal callbacks.
### Core.Abstractions-004 ### Core.Abstractions-004

View File

@@ -1,3 +1,4 @@
using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -61,7 +62,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
var handle = new PollSubscriptionHandle(id); var handle = new PollSubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts); var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state; _subscriptions[id] = state;
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token); state.LoopTask = Task.Run(() => PollLoopAsync(state, cts.Token));
return handle; return handle;
} }
@@ -71,13 +72,27 @@ public sealed class PollGroupEngine : IAsyncDisposable
{ {
if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state)) if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{ {
try { state.Cts.Cancel(); } catch { } StopState(state);
state.Cts.Dispose();
return true; return true;
} }
return false; return false;
} }
private static void StopState(SubscriptionState state)
{
try { state.Cts.Cancel(); } catch { }
// Await the loop task (with a generous timeout) before disposing the CTS so:
// (a) no _onChange callback fires after the caller considers the engine torn down, and
// (b) the CTS is not disposed while Task.Delay is still holding a reference to its token,
// which can turn OperationCanceledException into ObjectDisposedException.
var task = state.LoopTask;
if (task is not null)
{
try { task.Wait(TimeSpan.FromSeconds(5)); } catch { }
}
state.Cts.Dispose();
}
/// <summary>Snapshot of active subscription count — exposed for driver diagnostics.</summary> /// <summary>Snapshot of active subscription count — exposed for driver diagnostics.</summary>
public int ActiveSubscriptionCount => _subscriptions.Count; public int ActiveSubscriptionCount => _subscriptions.Count;
@@ -103,13 +118,22 @@ public sealed class PollGroupEngine : IAsyncDisposable
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct) private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{ {
var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false); var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);
// Core.Abstractions-002: validate the reader contract before indexing. A reader that
// returns fewer snapshots than references would silently stall the subscription; surface
// the violation immediately with a descriptive exception instead.
if (snapshots.Count != state.TagReferences.Count)
throw new InvalidOperationException(
$"Reader contract violation: expected {state.TagReferences.Count} snapshots but received {snapshots.Count}. " +
"The reader delegate must return one snapshot per input reference in input order.");
for (var i = 0; i < state.TagReferences.Count; i++) for (var i = 0; i < state.TagReferences.Count; i++)
{ {
var tagRef = state.TagReferences[i]; var tagRef = state.TagReferences[i];
var current = snapshots[i]; var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default; var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode) if (forceRaise || ValuesAreDifferent(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{ {
state.LastValues[tagRef] = current; state.LastValues[tagRef] = current;
_onChange(state.Handle, tagRef, current); _onChange(state.Handle, tagRef, current);
@@ -117,16 +141,44 @@ public sealed class PollGroupEngine : IAsyncDisposable
} }
} }
/// <summary>Cancel every active subscription. Idempotent.</summary> /// <summary>
public ValueTask DisposeAsync() /// Returns <c>true</c> when <paramref name="previous"/> and <paramref name="current"/>
/// represent different values. Array values are compared structurally
/// (element-by-element) so that a driver producing a fresh array instance on every poll
/// does not trigger spurious change events when the contents are identical.
/// </summary>
private static bool ValuesAreDifferent(object? previous, object? current)
{ {
if (previous is Array prevArr && current is Array currArr)
return !StructuralComparisons.StructuralEqualityComparer.Equals(prevArr, currArr);
return !Equals(previous, current);
}
/// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
public async ValueTask DisposeAsync()
{
// Cancel all loops first so they can all start winding down in parallel.
foreach (var state in _subscriptions.Values) foreach (var state in _subscriptions.Values)
{ {
try { state.Cts.Cancel(); } catch { } try { state.Cts.Cancel(); } catch { }
}
// Await every loop task before disposing CTSs, ensuring no callback fires after disposal.
var waitTasks = _subscriptions.Values
.Select(s => s.LoopTask ?? Task.CompletedTask)
.ToArray();
if (waitTasks.Length > 0)
{
try { await Task.WhenAll(waitTasks).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
catch { }
}
foreach (var state in _subscriptions.Values)
{
state.Cts.Dispose(); state.Cts.Dispose();
} }
_subscriptions.Clear(); _subscriptions.Clear();
return ValueTask.CompletedTask;
} }
private sealed record SubscriptionState( private sealed record SubscriptionState(
@@ -137,6 +189,14 @@ public sealed class PollGroupEngine : IAsyncDisposable
{ {
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; } public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase); = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The background poll-loop task. Assigned immediately after creation in
/// <see cref="Subscribe"/>; awaited during <see cref="Unsubscribe"/> /
/// <see cref="DisposeAsync"/> so disposal is deterministic and no
/// <c>_onChange</c> callback can fire after the caller tears down the subscription.
/// </summary>
public Task? LoopTask { get; set; }
} }
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle

View File

@@ -231,6 +231,117 @@ public sealed class PollGroupEngineTests
events.Count.ShouldBe(afterDispose); events.Count.ShouldBe(afterDispose);
} }
/// <summary>
/// Core.Abstractions-001: an array-valued tag whose contents are unchanged across polls
/// must fire only the initial change event, not a spurious event on every tick, even
/// when the driver produces a fresh array instance on each read.
/// </summary>
[Fact]
public async Task Array_valued_tag_unchanged_contents_raises_only_once()
{
// Each read produces a new int[] instance with the same contents — reference equality
// would consider these different, structural equality must not.
var callCount = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
Interlocked.Increment(ref callCount);
var now = DateTime.UtcNow;
// Fresh array instance every call — same logical value.
IReadOnlyList<DataValueSnapshot> snaps = refs
.Select(_ => new DataValueSnapshot(new int[] { 1, 2, 3 }, 0u, now, now))
.ToList();
return Task.FromResult(snaps);
}
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(Reader,
(h, r, s) => events.Enqueue((h, r, s)),
minInterval: TimeSpan.FromMilliseconds(50));
var handle = engine.Subscribe(["A"], TimeSpan.FromMilliseconds(50));
// Allow several poll cycles so a broken implementation would accumulate extra events.
await Task.Delay(400);
engine.Unsubscribe(handle);
// Only the initial-data push should have fired; subsequent polls with identical
// array contents must not produce additional events.
events.Count.ShouldBe(1);
}
/// <summary>
/// Core.Abstractions-001: an array-valued tag whose contents change between polls
/// must fire a change event for each distinct set of contents.
/// </summary>
[Fact]
public async Task Array_valued_tag_changed_contents_raises_event()
{
var generation = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
var gen = Interlocked.Increment(ref generation);
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> snaps = refs
.Select(_ => new DataValueSnapshot(new int[] { gen, gen + 1 }, 0u, now, now))
.ToList();
return Task.FromResult(snaps);
}
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(Reader,
(h, r, s) => events.Enqueue((h, r, s)),
minInterval: TimeSpan.FromMilliseconds(50));
var handle = engine.Subscribe(["A"], TimeSpan.FromMilliseconds(50));
await WaitForAsync(() => events.Count >= 3, TimeSpan.FromSeconds(2));
engine.Unsubscribe(handle);
events.Count.ShouldBeGreaterThanOrEqualTo(3);
}
/// <summary>
/// Core.Abstractions-002: a reader that returns fewer snapshots than input references
/// violates the documented contract. The engine must throw a descriptive exception
/// rather than silently stalling.
/// </summary>
[Fact]
public async Task Reader_short_result_list_raises_descriptive_exception_and_loop_continues()
{
var shortReadCount = 0;
var normalReadCount = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (Interlocked.Increment(ref shortReadCount) <= 2)
{
// Return fewer snapshots than refs — contract violation.
IReadOnlyList<DataValueSnapshot> bad = new List<DataValueSnapshot>();
return Task.FromResult(bad);
}
Interlocked.Increment(ref normalReadCount);
IReadOnlyList<DataValueSnapshot> good = refs
.Select(r => new DataValueSnapshot(42, 0u, now, now))
.ToList();
return Task.FromResult(good);
}
var events = new ConcurrentQueue<string>();
await using var engine = new PollGroupEngine(Reader,
(h, r, s) => events.Enqueue(r),
minInterval: TimeSpan.FromMilliseconds(50));
// Even though the first reads violate the contract the loop must survive and eventually
// deliver changes once the reader returns correct results.
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(50));
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
engine.Unsubscribe(handle);
// At least one event must have arrived from the well-formed reads.
events.Count.ShouldBeGreaterThanOrEqualTo(1);
// The short-read counter confirms the contract-violating reads were attempted.
shortReadCount.ShouldBeGreaterThanOrEqualTo(2);
}
private sealed record DummyHandle : ISubscriptionHandle private sealed record DummyHandle : ISubscriptionHandle
{ {
public string DiagnosticId => "dummy"; public string DiagnosticId => "dummy";