From 9e180b72a37538b5337f7d11d29b630b562579f6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 13:39:33 -0400 Subject: [PATCH] test(runtime): de-flake the hung-write release test; name the real write bound The fixture wedged EVERY write on one shared token-ignoring release gate, so the later same-tag write was also hung, and the 300 ms pessimistic bound abandoned it while the test was still polling before ReleaseWrites(). Its reason was "write did not complete within" (the bound), not "queued for" (the queue budget), and it had already entered the driver, so the tag had been released. The release path is correct. Only the first write now hangs, and later writes complete at once. The abandon reason formatted the bound with TotalSeconds:F0, which printed a 300 ms bound as "0s". It now uses milliseconds, and the test asserts it. Refs #731 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Drivers/DriverInstanceActor.cs | 2 +- ...DriverInstanceActorConcurrentWriteTests.cs | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) 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);