diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 60e989ce..c1ad189b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -1170,7 +1170,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers try { cts.Cancel(); } catch (ObjectDisposedException) { } _ = attempt.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted); replyTo.Tell(new WriteAttributeResult(false, - $"StatusCode=0x800A0000 (BadTimeout: write did not complete within {writeTimeout.TotalSeconds:F0}s)")); + $"StatusCode=0x800A0000 (BadTimeout: write did not complete within {writeTimeout.TotalMilliseconds:F0} ms)")); return; } if (results is { Count: 1 } && IsGoodStatus(results[0].StatusCode)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorConcurrentWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorConcurrentWriteTests.cs index f6f99002..484b2b10 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorConcurrentWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorConcurrentWriteTests.cs @@ -171,7 +171,12 @@ public sealed class DriverInstanceActorConcurrentWriteTests : RuntimeActorTestBa [Fact] public async Task A_hung_token_ignoring_write_is_abandoned_and_releases_its_tag_for_a_later_write() { - var driver = new SlowWritableDriver { HoldWrites = true, IgnoreCancellation = true }; + // ONLY the first write wedges (ignoring its token); every later write completes at once. The fixture used + // to wedge EVERY write on one shared release gate, so the later write was ALSO token-ignoring and the + // 300 ms pessimistic bound abandoned it too while the test was still polling/asserting before + // ReleaseWrites() — its reason was "write did not complete within" (the bound), not "queued for" (the + // queue budget), and it had entered the driver, so the tag HAD been released. Fixture defect, not code. + var driver = new SlowWritableDriver { HangFirstWriteIgnoringCancellation = true }; var (actor, _) = Connect(driver, maxWritesInFlight: 1, writeTimeout: TimeSpan.FromMilliseconds(300)); var hung = actor.Ask( @@ -181,6 +186,7 @@ public sealed class DriverInstanceActorConcurrentWriteTests : RuntimeActorTestBa var hungResult = await hung; hungResult.Success.ShouldBeFalse(); hungResult.Reason!.ShouldContain("800A0000", customMessage: "BadTimeout"); + hungResult.Reason!.ShouldContain("within 300 ms", customMessage: "the abandon reason names the real bound (it used to round 300 ms down to \"0s\")"); driver.MaxConcurrentWrites.ShouldBe(1); // The tag (and the single slot) are free again: a later tag-a write reaches the device even though the @@ -189,10 +195,10 @@ public sealed class DriverInstanceActorConcurrentWriteTests : RuntimeActorTestBa new DriverInstanceActor.WriteAttribute("tag-a", 2), PresenceBudget); AwaitCondition(() => driver.WritesEntered == 2, PresenceBudget); driver.EnteredRefs.ShouldBe(new[] { "tag-a", "tag-a" }); + var laterResult = await later; + laterResult.Success.ShouldBeTrue(laterResult.Reason); driver.MaxConcurrentWrites.ShouldBe(2, "positive control: the abandoned write really is still wedged inside the driver"); - - driver.ReleaseWrites(); - (await later).Success.ShouldBeTrue(); + driver.Writes.Select(w => w.Value).ShouldBe(new object[] { 2 }, "only the later write reached the device's commit"); } [Fact] @@ -233,8 +239,10 @@ public sealed class DriverInstanceActorConcurrentWriteTests : RuntimeActorTestBa public TimeSpan Latency { get; init; } public bool HoldWrites { get; init; } - /// When true a held write ignores its cancellation token — a wedged driver. - public bool IgnoreCancellation { get; init; } + /// When true the FIRST write never completes and ignores its cancellation token — a wedged + /// driver; every later write takes the normal path. + public bool HangFirstWriteIgnoringCancellation { get; init; } + private readonly TaskCompletionSource _never = new(TaskCreationOptions.RunContinuationsAsynchronously); public IReadOnlyList Writes => _writes.ToArray(); public IReadOnlyList EnteredRefs => _enteredRefs.ToArray(); @@ -253,7 +261,7 @@ public sealed class DriverInstanceActorConcurrentWriteTests : RuntimeActorTestBa public async Task> WriteAsync(IReadOnlyList writes, CancellationToken cancellationToken) { - Interlocked.Increment(ref _entered); + var seq = Interlocked.Increment(ref _entered); foreach (var w in writes) _enteredRefs.Enqueue(w.FullReference); var inside = Interlocked.Increment(ref _inside); int seen; @@ -261,7 +269,7 @@ public sealed class DriverInstanceActorConcurrentWriteTests : RuntimeActorTestBa Interlocked.CompareExchange(ref _maxInside, inside, seen); try { - if (HoldWrites && IgnoreCancellation) await _release.Task; + if (HangFirstWriteIgnoringCancellation && seq == 1) await _never.Task; else if (HoldWrites) await _release.Task.WaitAsync(cancellationToken); else if (Latency > TimeSpan.Zero) await Task.Delay(Latency, cancellationToken); foreach (var w in writes) _writes.Enqueue(w);