fix(core-abstractions): resolve Low code-review findings (Core.Abstractions-004,005,006,007,008)

- Core.Abstractions-004: guard DriverTypeRegistry.Register with a Lock so
  concurrent registrations are atomic.
- Core.Abstractions-005: narrow PollGroupEngine catch blocks to non-fatal
  exceptions, add optional onError callback, tolerate disposed-CTS races.
- Core.Abstractions-006: document the deliberate int-vs-uint asymmetry on
  IHistoryProvider.ReadEventsAsync / IHistorianDataSource.ReadEventsAsync.
- Core.Abstractions-007: pin the gaps with PollGroupEngine + DriverHealth
  contract tests.
- Core.Abstractions-008: correct XML docs on DriverHealth.LastError and
  the optional / required asymmetry on the history-read surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 05:37:54 -04:00
parent a02c0ffe36
commit ff2e75ab98
10 changed files with 422 additions and 33 deletions

View File

@@ -342,6 +342,108 @@ public sealed class PollGroupEngineTests
shortReadCount.ShouldBeGreaterThanOrEqualTo(2);
}
/// <summary>
/// Core.Abstractions-005: the engine documents that "transient poll errors are logged on
/// the driver health surface", but until an error callback exists the driver has no way
/// to observe a caught reader exception. Subscribing without supplying an error callback
/// must continue to swallow exceptions (backward compatible). When an error callback IS
/// supplied, every exception caught during a poll cycle must be routed to it.
/// </summary>
[Fact]
public async Task Reader_exception_is_reported_to_onError_callback()
{
var observed = new ConcurrentQueue<Exception>();
var readCount = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
if (Interlocked.Increment(ref readCount) <= 3)
throw new InvalidOperationException($"boom-{readCount}");
var now = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
refs.Select(_ => new DataValueSnapshot(1, 0u, now, now)).ToList());
}
await using var engine = new PollGroupEngine(
Reader,
(_, _, _) => { },
minInterval: TimeSpan.FromMilliseconds(50),
onError: ex => observed.Enqueue(ex));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(50));
await WaitForAsync(() => observed.Count >= 3, TimeSpan.FromSeconds(3));
engine.Unsubscribe(handle);
observed.Count.ShouldBeGreaterThanOrEqualTo(3);
observed.All(e => e is InvalidOperationException).ShouldBeTrue();
observed.All(e => e.Message.StartsWith("boom-")).ShouldBeTrue();
}
/// <summary>
/// Core.Abstractions-005: a contract-violating reader (Core.Abstractions-002 path) that
/// throws the descriptive <see cref="InvalidOperationException"/> from inside the engine
/// must also be routed to the error callback so the driver health surface can observe
/// repeated contract violations.
/// </summary>
[Fact]
public async Task Reader_contract_violation_routes_to_onError_callback()
{
var observed = new ConcurrentQueue<Exception>();
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
// Always return zero snapshots — short-result-list contract violation.
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(new List<DataValueSnapshot>());
}
await using var engine = new PollGroupEngine(
Reader,
(_, _, _) => { },
minInterval: TimeSpan.FromMilliseconds(50),
onError: ex => observed.Enqueue(ex));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(50));
await WaitForAsync(() => observed.Count >= 2, TimeSpan.FromSeconds(2));
engine.Unsubscribe(handle);
observed.Count.ShouldBeGreaterThanOrEqualTo(2);
observed.All(e => e is InvalidOperationException
&& e.Message.Contains("Reader contract violation"))
.ShouldBeTrue();
}
/// <summary>
/// Core.Abstractions-005: the engine must defend itself against an <c>onError</c> handler
/// that itself throws — otherwise a buggy health-surface forwarder would crash the poll
/// loop and silently stall the subscription, defeating the whole point of the callback.
/// </summary>
[Fact]
public async Task OnError_handler_that_throws_does_not_crash_loop()
{
var readCount = 0;
var events = new ConcurrentQueue<string>();
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
if (Interlocked.Increment(ref readCount) <= 2)
throw new InvalidOperationException("boom");
var now = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
refs.Select(_ => new DataValueSnapshot(1, 0u, now, now)).ToList());
}
await using var engine = new PollGroupEngine(
Reader,
(_, r, _) => events.Enqueue(r),
minInterval: TimeSpan.FromMilliseconds(50),
onError: _ => throw new ApplicationException("error-handler-bug"));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(50));
// Wait long enough for the reader to recover and for the engine to deliver a change.
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(3));
engine.Unsubscribe(handle);
events.Count.ShouldBeGreaterThanOrEqualTo(1);
}
private sealed record DummyHandle : ISubscriptionHandle
{
public string DiagnosticId => "dummy";