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:
@@ -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