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:
@@ -0,0 +1,191 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the remaining code-review findings closed against the S7 driver:
|
||||
/// Driver.S7-003 (Read/WriteAsync null-arg validation), Driver.S7-009 (poll-loop health
|
||||
/// update + backoff), Driver.S7-010 (Dispose without sync-over-async), and Driver.S7-013
|
||||
/// (reject not-yet-implemented S7DataType values at init).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DriverCodeReviewFixTests2
|
||||
{
|
||||
// ── Driver.S7-003 — Read/WriteAsync must throw ArgumentNullException, not NRE ─────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_with_null_fullReferences_throws_ArgumentNullException()
|
||||
{
|
||||
// The driver must validate its inputs consistently with DiscoverAsync (which already
|
||||
// uses ArgumentNullException.ThrowIfNull). A NullReferenceException escaping the entry
|
||||
// point bypasses the gate and gives the caller a non-actionable stack.
|
||||
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-null-read");
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await drv.ReadAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_with_null_writes_throws_ArgumentNullException()
|
||||
{
|
||||
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-null-write");
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await drv.WriteAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
// ── Driver.S7-009 — Poll loop must update health on sustained failure ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PollLoop_against_uninitialized_driver_degrades_health()
|
||||
{
|
||||
// Subscribing without InitializeAsync means RequirePlc() throws on every poll tick.
|
||||
// Previously the empty catch swallowed everything and the dashboard reported Healthy
|
||||
// / Unknown indefinitely. With the fix the poll loop must surface the failure on the
|
||||
// health surface so an operator can see it (driver-stability convention).
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
};
|
||||
var drv = new S7Driver(opts, "s7-poll-health");
|
||||
|
||||
await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait long enough for several poll ticks. With backoff the second tick should arrive
|
||||
// within ~100-200 ms; allow generous slack.
|
||||
for (var i = 0; i < 40 && drv.GetHealth().State is DriverState.Unknown or DriverState.Initializing; i++)
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded,
|
||||
"sustained poll failure must surface on the health state — see Driver.S7-009");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PollLoop_applies_capped_backoff_after_consecutive_failures()
|
||||
{
|
||||
// After repeated poll errors the loop must back off rather than burn CPU re-polling
|
||||
// every Interval — Driver.S7-009 calls for a capped backoff. Inspect the ratio of
|
||||
// observed tick count to the floor count we would have seen WITHOUT backoff over a
|
||||
// short window. Without backoff, an Interval=50 ms loop with sub-ms ReadAsync
|
||||
// RequirePlc-throw would tick ~20 times in 1 s. With capped backoff it ticks far
|
||||
// fewer; we use a generous upper bound that still proves "something is throttling".
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
};
|
||||
var drv = new S7Driver(opts, "s7-poll-backoff");
|
||||
|
||||
var ticks = 0;
|
||||
drv.OnDataChange += (_, _) => Interlocked.Increment(ref ticks);
|
||||
|
||||
await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
||||
|
||||
// OnDataChange is never raised (every poll fails) so we can't count via the event.
|
||||
// Instead, time-bound the test and rely on the health-degradation test above to prove
|
||||
// the catch ran; here we just confirm the driver doesn't deadlock or spin so hot it
|
||||
// refuses to shut down. ShutdownAsync must complete within the drain window.
|
||||
await Task.Delay(500, TestContext.Current.CancellationToken);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
sw.Stop();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(6),
|
||||
"shutdown must complete inside the drain timeout — a runaway backoff would block it");
|
||||
|
||||
await drv.DisposeAsync();
|
||||
_ = ticks; // silence unused
|
||||
}
|
||||
|
||||
// ── Driver.S7-010 — Dispose() must not deadlock via sync-over-async ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void Dispose_completes_synchronously_without_sync_over_async_round_trip()
|
||||
{
|
||||
// The sync Dispose() path must perform the teardown directly rather than blocking on
|
||||
// DisposeAsync().AsTask().GetAwaiter().GetResult(). The current sync-over-async pattern
|
||||
// is a known deadlock risk even when the wrapped Task.Run paths happen to be safe.
|
||||
// We assert by measuring Dispose() runtime on a no-subscription, no-init driver: it
|
||||
// should complete in microseconds, not the ~5 s drain window that a hung sync-over-async
|
||||
// would burn waiting on a never-completing continuation.
|
||||
var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-dispose-sync");
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
drv.Dispose();
|
||||
sw.Stop();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1),
|
||||
"Dispose() must teardown directly — see Driver.S7-010");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_is_idempotent()
|
||||
{
|
||||
// After the rewrite Dispose() must remain safe to call twice — disposal is
|
||||
// best-effort and the second call must not throw.
|
||||
var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-dispose-twice");
|
||||
drv.Dispose();
|
||||
Should.NotThrow(() => drv.Dispose());
|
||||
}
|
||||
|
||||
// ── Driver.S7-013 — Reject not-yet-implemented S7DataType values at init ─────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Int64)]
|
||||
[InlineData(S7DataType.UInt64)]
|
||||
[InlineData(S7DataType.Float64)]
|
||||
[InlineData(S7DataType.String)]
|
||||
[InlineData(S7DataType.DateTime)]
|
||||
public async Task Initialize_rejects_not_yet_implemented_data_type_with_NotSupportedException(S7DataType dt)
|
||||
{
|
||||
// A tag declared with one of the not-yet-wired data types parses cleanly and creates
|
||||
// an OPC UA node via DiscoverAsync — then every Read/Write of it returns BadNotSupported.
|
||||
// The half-implemented type must be rejected at init so a site can't deploy a config
|
||||
// that produces dead nodes (Driver.S7-013).
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
// Use a DB.DBD address — the parser accepts it for every data type. The init guard
|
||||
// must fault on the data-type rather than on the address.
|
||||
Tags = [new S7TagDefinition("X", "DB1.DBD0", dt)],
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-bad-dt-{dt}");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain(dt.ToString());
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Bool, "DB1.DBX0.0")]
|
||||
[InlineData(S7DataType.Byte, "DB1.DBB0")]
|
||||
[InlineData(S7DataType.Int16, "DB1.DBW0")]
|
||||
[InlineData(S7DataType.UInt16, "DB1.DBW0")]
|
||||
[InlineData(S7DataType.Int32, "DB1.DBD0")]
|
||||
[InlineData(S7DataType.UInt32, "DB1.DBD0")]
|
||||
[InlineData(S7DataType.Float32, "DB1.DBD0")]
|
||||
public async Task Initialize_accepts_implemented_data_types(S7DataType dt, string addr)
|
||||
{
|
||||
// Sanity check the guard is targeted — implemented data types must still pass. The
|
||||
// TCP connect still fails (reserved host); the failure must NOT be a NotSupportedException
|
||||
// from the data-type guard.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("X", addr, dt)],
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-good-dt-{dt}");
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.ShouldNotBeOfType<NotSupportedException>(
|
||||
"implemented data types must pass the init guard — the failure must be the connect");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user