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:
@@ -19,14 +19,21 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// from the previously-seen snapshot.</para>
|
||||
///
|
||||
/// <para>Exceptions thrown by the reader on the initial poll or any subsequent poll are
|
||||
/// swallowed — the loop continues on the next tick. The driver's own health surface is
|
||||
/// where transient poll failures should be reported; the engine intentionally does not
|
||||
/// double-book that responsibility.</para>
|
||||
/// caught — the loop continues on the next tick. When an <c>onError</c> callback is supplied
|
||||
/// to the constructor the caught exception is routed to it so the driver's health surface
|
||||
/// can record the failure. Without an <c>onError</c> callback the exception is silently
|
||||
/// swallowed (preserves the original behaviour for drivers that have not opted in yet).</para>
|
||||
///
|
||||
/// <para>Programmer errors and obviously-fatal exceptions (<see cref="OutOfMemoryException"/>,
|
||||
/// <see cref="ThreadAbortException"/>, <see cref="StackOverflowException"/>,
|
||||
/// <see cref="AccessViolationException"/>) are NOT caught — they propagate and tear the poll
|
||||
/// loop down rather than spin a silently-broken subscription.</para>
|
||||
/// </remarks>
|
||||
public sealed class PollGroupEngine : IAsyncDisposable
|
||||
{
|
||||
private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
|
||||
private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
|
||||
private readonly Action<Exception>? _onError;
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextId;
|
||||
@@ -40,15 +47,21 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// <see cref="ISubscribable.OnDataChange"/> event.</param>
|
||||
/// <param name="minInterval">Interval floor; anything below is clamped. Defaults to 100 ms
|
||||
/// per <see cref="DefaultMinInterval"/>.</param>
|
||||
/// <param name="onError">Optional error sink — invoked once per caught reader exception (or
|
||||
/// internal contract-violation throw) so the owning driver can route the failure to its
|
||||
/// health surface (Core.Abstractions-005). Defensive: an <c>onError</c> handler that
|
||||
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
|
||||
public PollGroupEngine(
|
||||
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
|
||||
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
|
||||
TimeSpan? minInterval = null)
|
||||
TimeSpan? minInterval = null,
|
||||
Action<Exception>? onError = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reader);
|
||||
ArgumentNullException.ThrowIfNull(onChange);
|
||||
_reader = reader;
|
||||
_onChange = onChange;
|
||||
_onError = onError;
|
||||
_minInterval = minInterval ?? DefaultMinInterval;
|
||||
}
|
||||
|
||||
@@ -102,19 +115,54 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
// whether it has changed, satisfying OPC UA Part 4 initial-value semantics.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error tolerated — loop continues */ }
|
||||
catch (Exception ex) when (!IsFatal(ex))
|
||||
{
|
||||
// first-read error tolerated — loop continues; forward to driver health surface.
|
||||
ReportError(ex);
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
// Defensive: the CTS may be disposed by Unsubscribe/DisposeAsync between the
|
||||
// cancellation check above and the Task.Delay touching the token. Treat that race
|
||||
// as a normal cancellation rather than a fatal exception.
|
||||
catch (ObjectDisposedException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient poll error — loop continues, driver health surface logs it */ }
|
||||
catch (Exception ex) when (!IsFatal(ex))
|
||||
{
|
||||
// transient poll error — loop continues, driver health surface logs it
|
||||
// via the supplied onError callback (Core.Abstractions-005).
|
||||
ReportError(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Programmer-error / process-fatal exception classification: anything that cannot be
|
||||
/// safely "swallowed and retry on the next tick" must escape the poll loop instead.
|
||||
/// </summary>
|
||||
private static bool IsFatal(Exception ex)
|
||||
=> ex is OutOfMemoryException
|
||||
or StackOverflowException
|
||||
or AccessViolationException
|
||||
or ThreadAbortException;
|
||||
|
||||
/// <summary>
|
||||
/// Forward a caught exception to the optional <c>onError</c> callback. Defensive
|
||||
/// against an <c>onError</c> implementation that itself throws — that would crash the
|
||||
/// poll loop and re-introduce the silent-stall failure mode this method exists to prevent.
|
||||
/// </summary>
|
||||
private void ReportError(Exception ex)
|
||||
{
|
||||
if (_onError is null) return;
|
||||
try { _onError(ex); }
|
||||
catch { /* never let a buggy error sink stop the poll loop */ }
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user