fix(driver-s7): resolve Low code-review findings (Driver.S7-003,005,009,010,013)
- Driver.S7-003: ArgumentNullException.ThrowIfNull on the references argument at the top of ReadAsync / WriteAsync (was reaching .Count before any null check). - Driver.S7-005: drop the redundant global::S7.Net.Plc qualifiers in ReadOneAsync / WriteOneAsync — using S7.Net already covers Plc. - Driver.S7-009: PollLoopAsync degrades _health to Degraded after sustained failure and backs off exponentially up to PollBackoffCap; resets on a healthy tick so an operator can see the loop wedge. - Driver.S7-010: Dispose runs the synchronous teardown directly with a bounded WhenAll Wait drain instead of bridging via DisposeAsync(). - Driver.S7-013: reject unsupported S7DataType values (Int64 / UInt64 / Float64 / String / DateTime) at InitializeAsync so half-implemented types no longer leak BadNotSupported live nodes into the address space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
// read (Driver.S7-001). Drop this guard when Timer/Counter reads are wired through.
|
||||
RejectUnsupportedTagAddresses();
|
||||
|
||||
// S7DataType values that ReadOneAsync / WriteOneAsync currently throw
|
||||
// NotSupportedException for (Int64, UInt64, Float64, String, DateTime) must also
|
||||
// be rejected at init — without this guard a site can configure e.g. a Float64
|
||||
// tag, see the node appear in the address space via DiscoverAsync, and get
|
||||
// BadNotSupported on every access. Half-implemented types must not leak into the
|
||||
// configurable surface (Driver.S7-013). Drop entries from the set as each data
|
||||
// type is wired through.
|
||||
RejectUnsupportedTagDataTypes();
|
||||
|
||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
||||
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||
@@ -262,6 +271,44 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects tags configured with an <see cref="S7DataType"/> that
|
||||
/// <see cref="ReinterpretRawValue"/> / <see cref="BoxValueForWrite"/> still throw
|
||||
/// <see cref="NotSupportedException"/> for. Without this guard those tags create live
|
||||
/// OPC UA nodes via <see cref="DiscoverAsync"/> but every Read/Write returns
|
||||
/// <c>BadNotSupported</c> — code-review finding Driver.S7-013. Drop entries from
|
||||
/// <see cref="UnimplementedDataTypes"/> as each type is wired through.
|
||||
/// </summary>
|
||||
private void RejectUnsupportedTagDataTypes()
|
||||
{
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
if (UnimplementedDataTypes.Contains(t.DataType))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " +
|
||||
"supported by the S7 driver — Read/Write would return BadNotSupported. " +
|
||||
"Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " +
|
||||
$"{t.DataType} is wired through.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S7DataType members that the read/write helpers throw NotSupportedException for.
|
||||
/// Kept here (rather than reflecting over <see cref="ReinterpretRawValue"/>) so
|
||||
/// <see cref="RejectUnsupportedTagDataTypes"/> is a single grep target for the
|
||||
/// follow-up PR that wires each through.
|
||||
/// </summary>
|
||||
private static readonly HashSet<S7DataType> UnimplementedDataTypes = new()
|
||||
{
|
||||
S7DataType.Int64,
|
||||
S7DataType.UInt64,
|
||||
S7DataType.Float64,
|
||||
S7DataType.String,
|
||||
S7DataType.DateTime,
|
||||
};
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
@@ -278,6 +325,10 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate the list before RequirePlc() so a null argument produces an
|
||||
// ArgumentNullException (consistent with DiscoverAsync) rather than an
|
||||
// InvalidOperationException from the not-initialized check — Driver.S7-003.
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var plc = RequirePlc();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
@@ -333,7 +384,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
private async Task<object> ReadOneAsync(Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||
@@ -381,6 +432,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
// Same as ReadAsync — validate before RequirePlc() so a null argument is a
|
||||
// typed argument error, not the "not initialized" surface (Driver.S7-003).
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var plc = RequirePlc();
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
@@ -446,7 +500,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
private async Task WriteOneAsync(Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||
@@ -574,32 +628,103 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on the poll-loop backoff window. After enough consecutive failures the
|
||||
/// loop waits this long between retries instead of <see cref="SubscriptionState.Interval"/>,
|
||||
/// so a subscription against a dropped / uninitialised driver doesn't spin (Driver.S7-009).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive poll failures before the loop transitions the driver's
|
||||
/// health to <see cref="DriverState.Degraded"/>. One stray failure can be transient;
|
||||
/// a sustained run indicates the operator should see it. Threshold of 1 because the
|
||||
/// first failure already lives in the LastError surface — see Driver.S7-009.
|
||||
/// </summary>
|
||||
private const int PollFailureHealthThreshold = 1;
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
var consecutiveFailures = 0;
|
||||
|
||||
// Initial-data push per OPC UA Part 4 convention.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
try
|
||||
{
|
||||
await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false);
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// First-read error — polling continues; log so the operator has an event trail.
|
||||
_logger.LogWarning(ex, "S7 poll initial-read failed. Driver={DriverInstanceId}", driverInstanceId);
|
||||
consecutiveFailures++;
|
||||
HandlePollFailure(ex, consecutiveFailures, initial: true);
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
// Capped exponential backoff: Interval, 2×, 4×, ... up to PollBackoffCap. Healthy
|
||||
// ticks reset consecutiveFailures back to 0 so the cadence snaps back to Interval.
|
||||
var delay = ComputeBackoffDelay(state.Interval, consecutiveFailures);
|
||||
try { await Task.Delay(delay, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
try
|
||||
{
|
||||
await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false);
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transient polling error — loop continues; log so the operator has an event trail.
|
||||
_logger.LogWarning(ex, "S7 poll tick failed. Driver={DriverInstanceId}", driverInstanceId);
|
||||
// Sustained polling error — loop continues with backoff; log + update health.
|
||||
consecutiveFailures++;
|
||||
HandlePollFailure(ex, consecutiveFailures, initial: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the swallowed poll exception and, once <see cref="PollFailureHealthThreshold"/>
|
||||
/// consecutive failures have accumulated, degrades the driver health so the failure
|
||||
/// surfaces on the dashboard — see Driver.S7-009. The probe loop owns Running/Stopped
|
||||
/// transitions for the host-connectivity surface, so we touch <see cref="_health"/>
|
||||
/// rather than the probe state.
|
||||
/// </summary>
|
||||
private void HandlePollFailure(Exception ex, int consecutiveFailures, bool initial)
|
||||
{
|
||||
if (initial)
|
||||
_logger.LogWarning(ex, "S7 poll initial-read failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
||||
driverInstanceId, consecutiveFailures);
|
||||
else
|
||||
_logger.LogWarning(ex, "S7 poll tick failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
||||
driverInstanceId, consecutiveFailures);
|
||||
|
||||
if (consecutiveFailures >= PollFailureHealthThreshold)
|
||||
{
|
||||
// Don't downgrade a Faulted state (e.g. PUT/GET-denied set by ReadAsync) — Faulted
|
||||
// is a stronger signal than Degraded and is reserved for permanent config faults.
|
||||
if (_health.State != DriverState.Faulted)
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capped exponential backoff. <c>consecutiveFailures == 0</c> returns the configured
|
||||
/// <paramref name="interval"/>; each subsequent failure doubles the wait up to
|
||||
/// <see cref="PollBackoffCap"/>. Computed in ticks to avoid overflow at large counts.
|
||||
/// </summary>
|
||||
internal static TimeSpan ComputeBackoffDelay(TimeSpan interval, int consecutiveFailures)
|
||||
{
|
||||
if (consecutiveFailures <= 0) return interval;
|
||||
// Cap the shift to avoid overflow — at 30 the result already saturates PollBackoffCap
|
||||
// for any reasonable Interval.
|
||||
var shift = Math.Min(consecutiveFailures - 1, 30);
|
||||
var ticks = interval.Ticks << shift;
|
||||
if (ticks <= 0 || ticks > PollBackoffCap.Ticks) return PollBackoffCap;
|
||||
return TimeSpan.FromTicks(ticks);
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
||||
@@ -702,7 +827,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public void Dispose()
|
||||
{
|
||||
// Driver.S7-010: avoid the sync-over-async DisposeAsync().AsTask().GetAwaiter().GetResult()
|
||||
// pattern (a known deadlock surface even when currently safe here). ShutdownAsync's
|
||||
// body is effectively synchronous apart from waiting on probe/poll Tasks; do the same
|
||||
// teardown directly, blocking only on the drain — and only with a bounded timeout so
|
||||
// a wedged loop can't hang Dispose() indefinitely.
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
SynchronousTeardown();
|
||||
_gate.Dispose();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -712,4 +848,46 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
catch { /* disposal is best-effort */ }
|
||||
_gate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous teardown — mirrors <see cref="ShutdownAsync"/> but blocks (with a bounded
|
||||
/// timeout) on the probe + poll Tasks instead of awaiting them. Used by the sync
|
||||
/// <see cref="Dispose"/> path so we don't sync-over-async <see cref="DisposeAsync"/>
|
||||
/// (Driver.S7-010).
|
||||
/// </summary>
|
||||
private void SynchronousTeardown()
|
||||
{
|
||||
var drain = new List<Task>();
|
||||
|
||||
var probeCts = _probeCts;
|
||||
var probeTask = _probeTask;
|
||||
try { probeCts?.Cancel(); } catch { }
|
||||
if (probeTask is not null) drain.Add(probeTask);
|
||||
|
||||
var subscriptions = _subscriptions.Values.ToArray();
|
||||
_subscriptions.Clear();
|
||||
foreach (var state in subscriptions)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
drain.Add(state.PollTask);
|
||||
}
|
||||
|
||||
if (drain.Count > 0)
|
||||
{
|
||||
try { Task.WhenAll(drain).Wait(DrainTimeout); }
|
||||
catch { /* timeouts/loop faults are tolerated — teardown continues */ }
|
||||
}
|
||||
|
||||
probeCts?.Dispose();
|
||||
_probeCts = null;
|
||||
_probeTask = null;
|
||||
foreach (var state in subscriptions)
|
||||
{
|
||||
try { state.Cts.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user