081fa693af
I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and re-subscribed it every 90 s forever. Both access-denied arms now also record an answer: Faulted is kept (worst state wins), the stamp advances, and the sticky write fault still clears only on a real success. Pinned end to end in Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog is not reconnected across 2x the threshold; the control (every read timing out) is. Minors: - m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now asserted strictly, including the write-refusal case. - m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list. - m-3: a release that races an init publish also cancels the probe loop the publish started after the release took its snapshot. - m-4: a retained poll failing during a lifecycle operation writes no health. - m-5: a multi-var chunk that fell back memoises its members refused alone. - m-7: access-denied needs S7.Net's exact wording and no transport fault in the chain. - m-8: sync teardown clears the write-side Degraded owner; a sync Dispose racing a running shutdown is tested. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
207 lines
9.7 KiB
C#
207 lines
9.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// R07:H5 — overlapping lifecycle calls. <c>DriverInstanceActor</c> dispatches initialize on a
|
|
/// fire-and-forget task and a reconnect retry can start a second attempt while the first is still in
|
|
/// <c>OpenAsync</c>; an abandoned initialize can also still be running when the next shutdown starts.
|
|
/// Every wait here is a condition wait on the fake's own signals, never a sleep.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7LifecycleGateTests
|
|
{
|
|
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10);
|
|
|
|
private static S7DriverOptions Options(bool probe = false) => new()
|
|
{
|
|
Host = "192.0.2.1",
|
|
Timeout = TimeSpan.FromSeconds(30), // the fake's gated open is released by the test, never timed out
|
|
Probe = new S7ProbeOptions { Enabled = probe, Interval = TimeSpan.FromSeconds(30) },
|
|
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
|
};
|
|
|
|
/// <summary>A factory whose FIRST open blocks until released; later opens complete immediately.</summary>
|
|
private sealed class FirstOpenBlocks
|
|
{
|
|
public readonly ScriptedS7PlcFactory Factory = new();
|
|
public readonly TaskCompletionSource FirstOpenEntered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public readonly TaskCompletionSource ReleaseFirstOpen = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public FirstOpenBlocks()
|
|
{
|
|
Factory.Open = async (index, ct) =>
|
|
{
|
|
if (index != 0) return;
|
|
FirstOpenEntered.TrySetResult();
|
|
await ReleaseFirstOpen.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A second initialize waits for the first instead of running beside it, then releases the first
|
|
/// attempt's connection: exactly one live connection remains. Before the gate, attempt B opened
|
|
/// its own connection while A was still in <c>OpenAsync</c>, and A's published connection (and
|
|
/// probe loop) was overwritten and never released.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Overlapping_initializes_are_serialised_and_leave_one_live_connection()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var f = new FirstOpenBlocks();
|
|
using var drv = new S7Driver(Options(), "s7-overlap", f.Factory);
|
|
|
|
var a = drv.InitializeAsync("{}", ct);
|
|
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
|
var b = drv.InitializeAsync("{}", ct);
|
|
|
|
// Positive control first: B is genuinely pending (not finished, not faulted)...
|
|
b.IsCompleted.ShouldBeFalse();
|
|
// ...and while A holds the gate B has not created a connection of its own. B can only reach
|
|
// its open after A completes, so this is not a race the test can lose by being slow.
|
|
f.Factory.Created.Count.ShouldBe(1, "a second initialize must not open beside the first");
|
|
|
|
f.ReleaseFirstOpen.SetResult();
|
|
await a.WaitAsync(Bound, ct);
|
|
await b.WaitAsync(Bound, ct);
|
|
|
|
f.Factory.Created.Count.ShouldBe(2);
|
|
f.Factory.Created[0].Disposed.ShouldBeTrue("B releases A's published connection before replacing it");
|
|
f.Factory.Created[1].Disposed.ShouldBeFalse();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An initialize still running when a shutdown gives up waiting for it must not publish its
|
|
/// connection, its health or its probe loop over the shut-down driver; it fails and disposes what
|
|
/// it opened. Before the fix the late attempt published Healthy + a live connection after the
|
|
/// shutdown returned.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_initialize_superseded_by_shutdown_publishes_nothing()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var f = new FirstOpenBlocks();
|
|
using var drv = new S7Driver(Options(probe: true), "s7-superseded", f.Factory)
|
|
{
|
|
LifecycleGateTimeout = TimeSpan.FromSeconds(1),
|
|
};
|
|
|
|
var init = drv.InitializeAsync("{}", ct);
|
|
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
|
|
|
// The shutdown cannot get the lifecycle gate (the attempt holds it), gives up after its bound
|
|
// and tears down anyway.
|
|
await drv.ShutdownAsync(ct).WaitAsync(Bound, ct);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
|
|
// The abandoned attempt now completes its open...
|
|
f.ReleaseFirstOpen.SetResult();
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
|
|
ex.Message.ShouldContain("superseded");
|
|
|
|
// ...and published nothing: no connection kept, health untouched, the driver still shut down.
|
|
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded attempt must not write Healthy or Faulted");
|
|
await Should.ThrowAsync<InvalidOperationException>(() => drv.ReadAsync(["W0"], ct));
|
|
|
|
// Positive control: the driver is not wedged — a fresh initialize publishes normally.
|
|
await drv.InitializeAsync("{}", ct).WaitAsync(Bound, ct);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review m-4: a retained poll that runs into a re-initialize in progress ("not initialized")
|
|
/// must not write Degraded over the new attempt's Initializing. Waits on the poll's own log line
|
|
/// (a counted signal), then asserts; the control is that a successful init then reports Healthy.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_retained_poll_does_not_overwrite_a_reinitialize_in_progress()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var factory = new ScriptedS7PlcFactory();
|
|
var secondOpenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var releaseSecondOpen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
factory.Open = async (index, token) =>
|
|
{
|
|
if (index != 1) return;
|
|
secondOpenEntered.TrySetResult();
|
|
await releaseSecondOpen.Task.WaitAsync(token);
|
|
};
|
|
var log = new CapturingS7Logger();
|
|
using var drv = new S7Driver(Options(), "s7-retained-poll", factory, log);
|
|
await drv.InitializeAsync("{}", ct);
|
|
await drv.SubscribeAsync(["W0"], TimeSpan.FromMilliseconds(100), ct);
|
|
|
|
var reinit = drv.InitializeAsync("{}", ct);
|
|
await secondOpenEntered.Task.WaitAsync(Bound, ct);
|
|
|
|
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
|
{
|
|
cts.CancelAfter(Bound);
|
|
while (log.Entries.Count(e => e.Message.Contains("during a lifecycle operation")) < 2)
|
|
await Task.Delay(20, cts.Token); // condition wait on the poll's counted log line
|
|
}
|
|
drv.GetHealth().State.ShouldBe(DriverState.Initializing, "a retained poll must not write over the new attempt");
|
|
|
|
releaseSecondOpen.SetResult();
|
|
await reinit.WaitAsync(Bound, ct);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review m-8: the SYNCHRONOUS <c>Dispose()</c> racing a still-running <c>ShutdownAsync</c> is safe too
|
|
/// — neither throws, the superseded init publishes nothing, and the driver ends torn down.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Sync_dispose_while_a_shutdown_is_still_running_is_safe()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var f = new FirstOpenBlocks();
|
|
var drv = new S7Driver(Options(), "s7-sync-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
|
|
|
|
var init = drv.InitializeAsync("{}", ct);
|
|
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
|
|
|
var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds
|
|
Should.NotThrow(() => drv.Dispose());
|
|
await shutdown.WaitAsync(Bound, ct);
|
|
|
|
f.ReleaseFirstOpen.SetResult();
|
|
await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
|
|
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dispose racing a shutdown that is still running is safe: neither throws, and the driver ends
|
|
/// torn down. The semaphores are no longer disposed, so the running shutdown cannot hit an
|
|
/// ObjectDisposedException.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Dispose_while_a_shutdown_is_still_running_is_safe()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var f = new FirstOpenBlocks();
|
|
var drv = new S7Driver(Options(), "s7-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
|
|
|
|
var init = drv.InitializeAsync("{}", ct);
|
|
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
|
|
|
|
var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds
|
|
var dispose = drv.DisposeAsync().AsTask();
|
|
await shutdown.WaitAsync(Bound, ct);
|
|
await dispose.WaitAsync(Bound, ct);
|
|
|
|
f.ReleaseFirstOpen.SetResult();
|
|
await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
|
|
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
}
|
|
}
|