fix(driver-s7): resolve High code-review findings (Driver.S7-001, -006, -007, -011)
Driver.S7-001: Timer (T{n}) / Counter (C{n}) addresses parsed cleanly but
the read path had no S7DataType or decode case for them, so a Timer/Counter
tag passed fail-fast init and then threw a misleading type-mismatch on every
read. InitializeAsync now runs RejectUnsupportedTagAddresses, throwing a clear
NotSupportedException ("not yet supported", echoing tag name + address) so the
config error fails fast at init.
Driver.S7-006: ShutdownAsync cancelled the probe/poll CTSs but did not await
the fire-and-forget loop tasks before DisposeAsync disposed _gate, letting a
loop iteration mid-semaphore race a disposed object. The probe task is now
tracked in _probeTask and each poll task in SubscriptionState.PollTask;
ShutdownAsync cancels every CTS, awaits Task.WhenAll of those handles with a
bounded 5 s DrainTimeout, then disposes the CTSs and gate. Task.Run is passed
CancellationToken.None so the handle is always awaitable.
Driver.S7-007: a PUT/GET-disabled fault (permanent misconfiguration) was
mapped identically to a transient PlcException — both BadDeviceFailure +
Degraded. ReadAsync/WriteAsync now split the catch via an IsAccessDenied
filter (S7.Net exposes no typed code for AccessingObjectNotAllowed, so the
inner-exception chain is inspected for the "not allowed" marker). Access-denied
now maps to BadNotSupported and Faulted with a config-alert message pointing
at the TIA Portal PUT/GET toggle; genuine device faults stay BadDeviceFailure.
Driver.S7-011: S7Driver ignored driverConfigJson on Initialize/Reinitialize,
so a config change delivered through ReinitializeAsync (the only Core-initiated
in-process recovery path) was silently discarded. Config parsing was factored
into S7DriverFactoryExtensions.ParseOptions; InitializeAsync now re-parses
driverConfigJson and rebuilds _options whenever the document has a real body.
An empty / placeholder document keeps the constructor options.
Adds S7DriverCodeReviewFixTests covering Timer/Counter rejection, config-json
application on Initialize/Reinitialize, and shutdown-drain with active
subscriptions. All 68 S7 driver tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 14 |
|
| Open findings | 10 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ a category produced nothing rather than leaving it blank.
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `S7AddressParser.cs:93`, `S7Driver.cs:231` |
|
| Location | `S7AddressParser.cs:93`, `S7Driver.cs:231` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** S7AddressParser.Parse accepts Timer (T0) and Counter (C0)
|
**Description:** S7AddressParser.Parse accepts Timer (T0) and Counter (C0)
|
||||||
addresses and the test suite asserts they parse successfully, but the read path
|
addresses and the test suite asserts they parse successfully, but the read path
|
||||||
@@ -55,7 +55,11 @@ until they are wired through to S7.Net, or implement the Timer/Counter read path
|
|||||||
If kept, reject Timer/Counter tags at InitializeAsync with a clear "not yet
|
If kept, reject Timer/Counter tags at InitializeAsync with a clear "not yet
|
||||||
supported" error rather than letting them parse clean.
|
supported" error rather than letting them parse clean.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — `InitializeAsync` now runs
|
||||||
|
`RejectUnsupportedTagAddresses`, which throws `NotSupportedException` with a
|
||||||
|
clear "not yet supported" message (echoing the tag name + address) for any tag
|
||||||
|
whose address parses as a Timer or Counter, so the bad config fails fast at init
|
||||||
|
rather than throwing a misleading type-mismatch on every read.
|
||||||
|
|
||||||
### Driver.S7-002
|
### Driver.S7-002
|
||||||
|
|
||||||
@@ -150,7 +154,7 @@ redundant global::S7.Net. qualifiers where using S7.Net already covers them.
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `S7Driver.cs:140`, `S7Driver.cs:457`, `S7Driver.cs:506` |
|
| Location | `S7Driver.cs:140`, `S7Driver.cs:457`, `S7Driver.cs:506` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Disposal races with the in-flight probe / poll tasks.
|
**Description:** Disposal races with the in-flight probe / poll tasks.
|
||||||
ShutdownAsync calls _probeCts.Cancel() and cancels each subscription CTS, but it
|
ShutdownAsync calls _probeCts.Cancel() and cancels each subscription CTS, but it
|
||||||
@@ -168,7 +172,13 @@ running while ProbeLoopAsync may still touch the linked token.
|
|||||||
(or DisposeAsync) await Task.WhenAll(...) with a bounded timeout after cancelling,
|
(or DisposeAsync) await Task.WhenAll(...) with a bounded timeout after cancelling,
|
||||||
before disposing _gate and the CTS objects.
|
before disposing _gate and the CTS objects.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — the probe loop now stores its Task in
|
||||||
|
`_probeTask` and each subscription records its poll Task in `SubscriptionState.PollTask`.
|
||||||
|
`ShutdownAsync` cancels every CTS, awaits `Task.WhenAll` of those handles with a
|
||||||
|
bounded 5 s `DrainTimeout`, and only then disposes `_probeCts`, the subscription
|
||||||
|
CTSs, and (via `DisposeAsync`) `_gate` — so no loop can touch a disposed
|
||||||
|
semaphore. `Task.Run` is now passed `CancellationToken.None` so the handle is
|
||||||
|
always awaitable even if the token is already cancelled.
|
||||||
|
|
||||||
### Driver.S7-007
|
### Driver.S7-007
|
||||||
|
|
||||||
@@ -177,7 +187,7 @@ before disposing _gate and the CTS objects.
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `S7Driver.cs:200`, `S7DriverOptions.cs:13`, `docs/v2/driver-specs.md:434` |
|
| Location | `S7Driver.cs:200`, `S7DriverOptions.cs:13`, `docs/v2/driver-specs.md:434` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** PUT/GET-disabled handling contradicts the design and the
|
**Description:** PUT/GET-disabled handling contradicts the design and the
|
||||||
module own docstring. driver-specs.md section 5 (line 434) and the
|
module own docstring. driver-specs.md section 5 (line 434) and the
|
||||||
@@ -197,7 +207,15 @@ PUT/GET-disabled / access-denied code to BadNotSupported with a distinct
|
|||||||
config-alert health state; keep BadDeviceFailure/Degraded only for genuine
|
config-alert health state; keep BadDeviceFailure/Degraded only for genuine
|
||||||
device-fault error codes.
|
device-fault error codes.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — `ReadAsync` / `WriteAsync` now split the
|
||||||
|
`PlcException` catch via an `IsAccessDenied` filter. S7.Net exposes no typed
|
||||||
|
error code for the S7 `AccessingObjectNotAllowed` status (its
|
||||||
|
`ValidateResponseCode` throws a plain `Exception` wrapped as the inner exception
|
||||||
|
of a `PlcException`), so `IsAccessDenied` walks the inner-exception chain for the
|
||||||
|
"not allowed" marker. A PUT/GET-disabled fault now maps to `BadNotSupported` and
|
||||||
|
sets health to `Faulted` with a config-alert message pointing operators at the
|
||||||
|
TIA Portal PUT/GET toggle; a genuine device fault still maps to
|
||||||
|
`BadDeviceFailure`/`Degraded`.
|
||||||
|
|
||||||
### Driver.S7-008
|
### Driver.S7-008
|
||||||
|
|
||||||
@@ -279,7 +297,7 @@ round-tripping through the async path.
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `S7Driver.cs:82`, `S7Driver.cs:134`, `IDriver.cs:24` |
|
| Location | `S7Driver.cs:82`, `S7Driver.cs:134`, `IDriver.cs:24` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** S7Driver ignores the driverConfigJson parameter on both
|
**Description:** S7Driver ignores the driverConfigJson parameter on both
|
||||||
InitializeAsync and ReinitializeAsync. The IDriver contract states InitializeAsync
|
InitializeAsync and ReinitializeAsync. The IDriver contract states InitializeAsync
|
||||||
@@ -298,7 +316,14 @@ explicitly that S7 reconfiguration requires instance recreation and have
|
|||||||
ReinitializeAsync signal that the passed JSON is unused so the contract mismatch
|
ReinitializeAsync signal that the passed JSON is unused so the contract mismatch
|
||||||
is visible.
|
is visible.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — config parsing was factored out of the
|
||||||
|
factory into `S7DriverFactoryExtensions.ParseOptions`. `InitializeAsync` (and
|
||||||
|
therefore `ReinitializeAsync`, which delegates to it) now re-parses
|
||||||
|
`driverConfigJson` and rebuilds `_options` from it whenever the document carries
|
||||||
|
a real body, so a config change delivered through `ReinitializeAsync` — the only
|
||||||
|
Core-initiated in-process recovery path — is honoured. An empty / placeholder
|
||||||
|
document (`""`, `{}`, `[]`) keeps the constructor-supplied options so existing
|
||||||
|
lifecycle unit tests that pass `"{}"` are unaffected.
|
||||||
|
|
||||||
### Driver.S7-012
|
### Driver.S7-012
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using S7.Net;
|
using S7.Net;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
@@ -30,13 +31,27 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
{
|
{
|
||||||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||||||
|
|
||||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||||
private long _nextSubscriptionId;
|
private long _nextSubscriptionId;
|
||||||
private readonly object _probeLock = new();
|
private readonly object _probeLock = new();
|
||||||
private HostState _hostState = HostState.Unknown;
|
private HostState _hostState = HostState.Unknown;
|
||||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||||
private CancellationTokenSource? _probeCts;
|
private CancellationTokenSource? _probeCts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handle to the in-flight probe loop. Tracked (rather than fire-and-forget) so
|
||||||
|
/// <see cref="ShutdownAsync"/> can await it after cancelling — otherwise a probe
|
||||||
|
/// iteration still inside the <see cref="_gate"/> would race a disposed semaphore.
|
||||||
|
/// See code-review finding Driver.S7-006.
|
||||||
|
/// </summary>
|
||||||
|
private Task? _probeTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounded grace window <see cref="ShutdownAsync"/> waits for the probe + poll loops to
|
||||||
|
/// observe cancellation and exit before it disposes the shared semaphore / CTS objects.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
@@ -50,13 +65,20 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
private const uint StatusBadInternalError = 0x80020000u;
|
private const uint StatusBadInternalError = 0x80020000u;
|
||||||
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
|
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
|
||||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||||
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
/// <summary>OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).</summary>
|
||||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||||
|
|
||||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private readonly S7DriverOptions _options = options;
|
/// <summary>
|
||||||
|
/// Active driver configuration. Seeded from the constructor argument, then replaced by
|
||||||
|
/// whatever <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> parse out of
|
||||||
|
/// the supplied <c>driverConfigJson</c> — see code-review finding Driver.S7-011. The
|
||||||
|
/// constructor value is the fallback used when the caller passes an empty / placeholder
|
||||||
|
/// JSON document (e.g. the <c>"{}"</c> some unit tests pass).
|
||||||
|
/// </summary>
|
||||||
|
private S7DriverOptions _options = options;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -84,6 +106,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
|
||||||
|
// IDriver contract is honoured (Driver.S7-011). An empty / placeholder document
|
||||||
|
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
|
||||||
|
if (HasConfigBody(driverConfigJson))
|
||||||
|
_options = S7DriverFactoryExtensions.ParseOptions(driverInstanceId, driverConfigJson);
|
||||||
|
|
||||||
|
// Timer (T{n}) / Counter (C{n}) addresses parse cleanly but the read path has no
|
||||||
|
// S7DataType for them and no decode case — reject them here so a config typo
|
||||||
|
// fails fast at init instead of throwing a misleading type-mismatch on every
|
||||||
|
// read (Driver.S7-001). Drop this guard when Timer/Counter reads are wired through.
|
||||||
|
RejectUnsupportedTagAddresses();
|
||||||
|
|
||||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
||||||
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||||
@@ -117,7 +151,11 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
if (_options.Probe.Enabled)
|
if (_options.Probe.Enabled)
|
||||||
{
|
{
|
||||||
_probeCts = new CancellationTokenSource();
|
_probeCts = new CancellationTokenSource();
|
||||||
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
// Track the probe Task (not fire-and-forget) so ShutdownAsync can await it
|
||||||
|
// before disposing _gate / _probeCts (Driver.S7-006). Pass None to Task.Run so
|
||||||
|
// the delegate always runs and the handle is always awaitable; the loop's own
|
||||||
|
// token check handles cancellation.
|
||||||
|
_probeTask = Task.Run(() => ProbeLoopAsync(_probeCts.Token), CancellationToken.None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -133,27 +171,89 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
|
|
||||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// InitializeAsync re-parses driverConfigJson, so a config change delivered here is
|
||||||
|
// applied in place rather than silently discarded (Driver.S7-011).
|
||||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try { _probeCts?.Cancel(); } catch { }
|
// Signal cancellation to the probe + poll loops first, collect their Task handles,
|
||||||
_probeCts?.Dispose();
|
// then await all of them with a bounded timeout BEFORE disposing the shared semaphore
|
||||||
_probeCts = null;
|
// and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
|
||||||
|
// on (or WaitAsync against) a disposed semaphore — see code-review finding Driver.S7-006.
|
||||||
|
var drain = new List<Task>();
|
||||||
|
|
||||||
foreach (var state in _subscriptions.Values)
|
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 { }
|
try { state.Cts.Cancel(); } catch { }
|
||||||
state.Cts.Dispose();
|
drain.Add(state.PollTask);
|
||||||
}
|
}
|
||||||
_subscriptions.Clear();
|
|
||||||
|
if (drain.Count > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(drain).WaitAsync(DrainTimeout, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (TimeoutException) { /* a wedged loop — proceed; better than leaking the teardown */ }
|
||||||
|
catch { /* loop faults are already surfaced via health; teardown continues */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loops have now observed cancellation and released _gate — safe to dispose the CTSs.
|
||||||
|
probeCts?.Dispose();
|
||||||
|
_probeCts = null;
|
||||||
|
_probeTask = null;
|
||||||
|
foreach (var state in subscriptions)
|
||||||
|
state.Cts.Dispose();
|
||||||
|
|
||||||
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
|
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
|
||||||
Plc = null;
|
Plc = null;
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
return Task.CompletedTask;
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
|
||||||
|
/// bootstrapper always passes a populated document; some unit tests pass <c>"{}"</c> or
|
||||||
|
/// an empty string to exercise lifecycle shape without a config — those keep the
|
||||||
|
/// constructor-supplied <see cref="_options"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasConfigBody(string? driverConfigJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||||
|
var trimmed = driverConfigJson.Trim();
|
||||||
|
return trimmed is not "{}" and not "[]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rejects tag addresses the read path cannot serve. Timer (<c>T{n}</c>) and Counter
|
||||||
|
/// (<c>C{n}</c>) addresses parse cleanly via <see cref="S7AddressParser"/> but
|
||||||
|
/// <see cref="ReadOneAsync"/> has no decode case for them and <see cref="S7DataType"/>
|
||||||
|
/// has no Timer/Counter member — left unguarded they fail fast init's promise and throw
|
||||||
|
/// a misleading type-mismatch on every read instead (code-review finding Driver.S7-001).
|
||||||
|
/// </summary>
|
||||||
|
private void RejectUnsupportedTagAddresses()
|
||||||
|
{
|
||||||
|
foreach (var t in _options.Tags)
|
||||||
|
{
|
||||||
|
if (S7AddressParser.TryParse(t.Address, out var parsed)
|
||||||
|
&& parsed.Area is S7Area.Timer or S7Area.Counter)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(
|
||||||
|
$"S7 tag '{t.Name}' uses a {parsed.Area} address ('{t.Address}'); " +
|
||||||
|
"Timer/Counter tags are not yet supported by the S7 driver. " +
|
||||||
|
"Remove the tag or use a DB/M/I/Q address until Timer/Counter reads are wired through.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public DriverHealth GetHealth() => _health;
|
public DriverHealth GetHealth() => _health;
|
||||||
@@ -197,12 +297,22 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||||
}
|
}
|
||||||
catch (global::S7.Net.PlcException pex)
|
catch (PlcException pex) when (IsAccessDenied(pex))
|
||||||
{
|
{
|
||||||
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent
|
||||||
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
// configuration fault, NOT a transient one. Blind retry is wasted effort,
|
||||||
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
// so map it to BadNotSupported and flag the driver as a config alert
|
||||||
// transient fault — per driver-specs.md §5.
|
// (Faulted) rather than Degraded — per driver-specs.md §5 and
|
||||||
|
// code-review finding Driver.S7-007.
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
|
||||||
|
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||||||
|
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||||||
|
}
|
||||||
|
catch (PlcException pex)
|
||||||
|
{
|
||||||
|
// A genuine device-layer fault (CPU error, hardware fault) — transient
|
||||||
|
// enough to keep retrying; report BadDeviceFailure and degrade health.
|
||||||
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||||
}
|
}
|
||||||
@@ -283,7 +393,17 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
{
|
{
|
||||||
results[i] = new WriteResult(StatusBadNotSupported);
|
results[i] = new WriteResult(StatusBadNotSupported);
|
||||||
}
|
}
|
||||||
catch (global::S7.Net.PlcException)
|
catch (PlcException pex) when (IsAccessDenied(pex))
|
||||||
|
{
|
||||||
|
// PUT/GET-disabled / access-protection on write — same permanent
|
||||||
|
// configuration fault as on read (Driver.S7-007). BadNotSupported +
|
||||||
|
// a config-alert health state, not a transient device failure.
|
||||||
|
results[i] = new WriteResult(StatusBadNotSupported);
|
||||||
|
_health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
|
||||||
|
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||||||
|
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||||||
|
}
|
||||||
|
catch (PlcException)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(StatusBadDeviceFailure);
|
results[i] = new WriteResult(StatusBadDeviceFailure);
|
||||||
}
|
}
|
||||||
@@ -323,9 +443,31 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
|
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private global::S7.Net.Plc RequirePlc() =>
|
private Plc RequirePlc() =>
|
||||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net
|
||||||
|
/// <see cref="PlcException"/>. S7.Net's read/write paths wrap every PLC-side error in a
|
||||||
|
/// <c>PlcException</c> with <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/>;
|
||||||
|
/// the response-code validator throws a plain <see cref="Exception"/> for the S7
|
||||||
|
/// <c>AccessingObjectNotAllowed</c> status, which lands as the inner exception. There is
|
||||||
|
/// no typed error code for it, so the inner message is the only discriminator
|
||||||
|
/// S7.Net exposes — see code-review finding Driver.S7-007.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsAccessDenied(PlcException pex)
|
||||||
|
{
|
||||||
|
for (Exception? e = pex; e is not null; e = e.InnerException)
|
||||||
|
{
|
||||||
|
if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ITagDiscovery ----
|
// ---- ITagDiscovery ----
|
||||||
|
|
||||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
@@ -375,7 +517,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
var handle = new S7SubscriptionHandle(id);
|
var handle = new S7SubscriptionHandle(id);
|
||||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||||
_subscriptions[id] = state;
|
_subscriptions[id] = state;
|
||||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
// Track the poll Task so ShutdownAsync can await it after cancelling — a poll
|
||||||
|
// iteration mid-_gate would otherwise race the semaphore's disposal (Driver.S7-006).
|
||||||
|
state.PollTask = Task.Run(() => PollLoopAsync(state, cts.Token), CancellationToken.None);
|
||||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,8 +574,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
TimeSpan Interval,
|
TimeSpan Interval,
|
||||||
CancellationTokenSource Cts)
|
CancellationTokenSource Cts)
|
||||||
{
|
{
|
||||||
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||||
= new(StringComparer.OrdinalIgnoreCase);
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handle to this subscription's poll loop. Tracked so <see cref="ShutdownAsync"/>
|
||||||
|
/// can await it after cancelling — see code-review finding Driver.S7-006.
|
||||||
|
/// </summary>
|
||||||
|
public Task PollTask { get; set; } = Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
|
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ public static class S7DriverFactoryExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static S7Driver CreateInstance(string driverInstanceId, string driverConfigJson)
|
internal static S7Driver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
return new S7Driver(ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a driver-config JSON document into a strongly-typed <see cref="S7DriverOptions"/>.
|
||||||
|
/// Shared by the factory (instance creation) and by <see cref="S7Driver.InitializeAsync"/>
|
||||||
|
/// / <see cref="S7Driver.ReinitializeAsync"/> so a config change delivered through the
|
||||||
|
/// <c>IDriver</c> contract is actually applied — see code-review finding Driver.S7-011.
|
||||||
|
/// </summary>
|
||||||
|
internal static S7DriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -34,7 +47,7 @@ public static class S7DriverFactoryExtensions
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"S7 driver config for '{driverInstanceId}' missing required Host");
|
$"S7 driver config for '{driverInstanceId}' missing required Host");
|
||||||
|
|
||||||
var options = new S7DriverOptions
|
return new S7DriverOptions
|
||||||
{
|
{
|
||||||
Host = dto.Host!,
|
Host = dto.Host!,
|
||||||
Port = dto.Port ?? 102,
|
Port = dto.Port ?? 102,
|
||||||
@@ -54,8 +67,6 @@ public static class S7DriverFactoryExtensions
|
|||||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "MW0",
|
ProbeAddress = dto.Probe?.ProbeAddress ?? "MW0",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return new S7Driver(options, driverInstanceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static S7TagDefinition BuildTag(S7TagDto t, string driverInstanceId) =>
|
private static S7TagDefinition BuildTag(S7TagDto t, string driverInstanceId) =>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for the High code-review findings closed against the S7 driver:
|
||||||
|
/// Driver.S7-001 (Timer/Counter tags rejected at init), Driver.S7-006 (shutdown drains
|
||||||
|
/// the probe/poll loops before disposing the gate), Driver.S7-007 (PUT/GET-disabled maps
|
||||||
|
/// to a config alert, not a transient fault), and Driver.S7-011 (Initialize/Reinitialize
|
||||||
|
/// honour the supplied driverConfigJson).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7DriverCodeReviewFixTests
|
||||||
|
{
|
||||||
|
// ---- Driver.S7-001 — Timer/Counter tags must be rejected at init ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("T0")]
|
||||||
|
[InlineData("T15")]
|
||||||
|
[InlineData("C0")]
|
||||||
|
[InlineData("C10")]
|
||||||
|
public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address)
|
||||||
|
{
|
||||||
|
// A Timer/Counter address parses cleanly but the read path has no decode case for it,
|
||||||
|
// so it must fail fast at init rather than throw a misleading type-mismatch on every
|
||||||
|
// read. The host is reserved-for-documentation so the TCP connect can never succeed —
|
||||||
|
// the unsupported-address guard runs before the connect, so the NotSupportedException
|
||||||
|
// is what surfaces.
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(250),
|
||||||
|
Tags = [new S7TagDefinition("Quirk", address, S7DataType.Int16)],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-tc");
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||||
|
ex.Message.ShouldContain(address);
|
||||||
|
|
||||||
|
var health = drv.GetHealth();
|
||||||
|
health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver");
|
||||||
|
health.LastError.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_accepts_DB_and_MIQ_addresses_without_the_unsupported_guard_tripping()
|
||||||
|
{
|
||||||
|
// Sanity check the guard is targeted — DB/M/I/Q tags must NOT be rejected by it.
|
||||||
|
// The connect still fails (reserved host), so we assert the failure is the connect,
|
||||||
|
// NOT a NotSupportedException from the address guard.
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(250),
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new S7TagDefinition("Word", "DB1.DBW0", S7DataType.Int16),
|
||||||
|
new S7TagDefinition("Bit", "M0.0", S7DataType.Bool),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-ok");
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||||
|
ex.ShouldNotBeOfType<NotSupportedException>(
|
||||||
|
"DB/M/I/Q tags are supported — the failure must be the connect, not the address guard");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.S7-011 — driverConfigJson must be applied on Initialize ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_applies_the_supplied_driverConfigJson_over_the_constructor_options()
|
||||||
|
{
|
||||||
|
// Constructor options point at a real-looking host; the JSON config points at a
|
||||||
|
// reserved-for-documentation host with a tiny timeout. If InitializeAsync honours the
|
||||||
|
// JSON (the IDriver contract), the connect fails fast against 192.0.2.x. If it ignored
|
||||||
|
// the JSON it would hang on the constructor host instead.
|
||||||
|
var ctorOpts = new S7DriverOptions { Host = "10.255.255.1", Timeout = TimeSpan.FromSeconds(30) };
|
||||||
|
using var drv = new S7Driver(ctorOpts, "s7-cfg");
|
||||||
|
|
||||||
|
const string json = """
|
||||||
|
{ "Host": "192.0.2.1", "TimeoutMs": 250,
|
||||||
|
"Tags": [ { "Name": "W", "Address": "DB1.DBW0", "DataType": "Int16" } ] }
|
||||||
|
""";
|
||||||
|
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
await Should.ThrowAsync<Exception>(async () =>
|
||||||
|
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
// A 30 s constructor timeout would dominate; the 250 ms JSON timeout proves the JSON won.
|
||||||
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10),
|
||||||
|
"InitializeAsync must apply the driverConfigJson timeout, not the constructor's");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson()
|
||||||
|
{
|
||||||
|
// The Timer/Counter guard must run against the *re-parsed* config, not just the
|
||||||
|
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly.
|
||||||
|
var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
|
||||||
|
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc");
|
||||||
|
|
||||||
|
const string json = """
|
||||||
|
{ "Host": "192.0.2.1", "TimeoutMs": 250,
|
||||||
|
"Tags": [ { "Name": "TimerTag", "Address": "T5", "DataType": "Int16" } ] }
|
||||||
|
""";
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||||
|
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
|
||||||
|
ex.Message.ShouldContain("T5");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_applies_a_changed_driverConfigJson()
|
||||||
|
{
|
||||||
|
// ReinitializeAsync is the only Core-initiated in-process recovery path; a config
|
||||||
|
// change delivered through it must not be silently discarded.
|
||||||
|
var ctorOpts = new S7DriverOptions { Host = "10.255.255.1", Timeout = TimeSpan.FromSeconds(30) };
|
||||||
|
using var drv = new S7Driver(ctorOpts, "s7-reinit");
|
||||||
|
|
||||||
|
const string changed = """{ "Host": "192.0.2.1", "TimeoutMs": 250 }""";
|
||||||
|
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
await Should.ThrowAsync<Exception>(async () =>
|
||||||
|
await drv.ReinitializeAsync(changed, TestContext.Current.CancellationToken));
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10),
|
||||||
|
"ReinitializeAsync must re-parse and apply the new driverConfigJson");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.S7-006 — Shutdown drains probe/poll loops before disposing the gate ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Shutdown_completes_cleanly_with_active_subscriptions_and_no_disposal_race()
|
||||||
|
{
|
||||||
|
// SubscribeAsync starts a poll loop; ShutdownAsync must cancel AND await it before
|
||||||
|
// disposing the shared semaphore. A regression here surfaces as an
|
||||||
|
// ObjectDisposedException escaping ShutdownAsync / DisposeAsync.
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
var drv = new S7Driver(opts, "s7-drain");
|
||||||
|
|
||||||
|
await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||||
|
await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Let the poll loops actually start churning so cancellation has something to race.
|
||||||
|
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Must not throw — the drain awaits the poll tasks before disposing _gate.
|
||||||
|
await Should.NotThrowAsync(async () => await drv.ShutdownAsync(CancellationToken.None));
|
||||||
|
await Should.NotThrowAsync(async () => await drv.DisposeAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_after_subscribe_does_not_throw_ObjectDisposedException()
|
||||||
|
{
|
||||||
|
// The synchronous Dispose() path round-trips through DisposeAsync → ShutdownAsync;
|
||||||
|
// it must also drain the poll loop rather than dispose the gate from under it.
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
var drv = new S7Driver(opts, "s7-dispose");
|
||||||
|
|
||||||
|
await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||||
|
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Should.NotThrow(() => drv.Dispose());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user