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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user