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

@@ -231,6 +231,117 @@ public sealed class PollGroupEngineTests
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
{
public string DiagnosticId => "dummy";