diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs
index 7b0c8ed5..92fa44de 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs
@@ -23,6 +23,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Telemetry;
///
public sealed class TelemetryDialDeadlineTests : TestKit
{
+ /// Explicit AwaitCondition poll interval: Akka's default is max / 10 of the dilated budget, i.e.
+ /// seconds under CI's timefactor (lmxopcua#775).
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20);
+
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8);
[Fact]
@@ -35,7 +39,7 @@ public sealed class TelemetryDialDeadlineTests : TestKit
first.Ct.IsCancellationRequested.ShouldBeFalse("the deadline has not elapsed yet");
// The stalled dial is cut at the deadline...
- AwaitCondition(() => first.Ct.IsCancellationRequested, Timeout);
+ AwaitCondition(() => first.Ct.IsCancellationRequested, Timeout, PollInterval);
// ...and the supervisor re-dials (a stalled dial is a transport failure, never a silent end).
var invocations = dial.WaitForCount(2, Timeout);
invocations[1].CorrelationId.ShouldBe("central-node-1-2");
@@ -49,7 +53,7 @@ public sealed class TelemetryDialDeadlineTests : TestKit
Spawn(dial, callTimeoutSeconds: 1, sinks);
var first = dial.WaitForCount(1, Timeout).Single();
- AwaitCondition(() => sinks.HealthStore.Upserts.Count == 1, Timeout);
+ AwaitCondition(() => sinks.HealthStore.Upserts.Count == 1, Timeout, PollInterval);
// Positive control: well past the deadline, the established stream is untouched.
await Task.Delay(TimeSpan.FromSeconds(2.5));
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs
index f07fd6d3..31a8f33e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs
@@ -23,6 +23,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Telemetry;
///
public sealed class TelemetryDialSupervisorTests : TestKit
{
+ /// Explicit AwaitCondition poll interval: Akka's default is max / 10 of the dilated budget, i.e.
+ /// seconds under CI's timefactor (lmxopcua#775).
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20);
+
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
[Fact]
@@ -66,7 +70,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit
actor.Tell(new TelemetryDialSupervisor.RefreshNodes());
// node-1's dialer is cancelled and dropped.
- AwaitCondition(() => node1.Ct.IsCancellationRequested, Timeout);
+ AwaitCondition(() => node1.Ct.IsCancellationRequested, Timeout, PollInterval);
// node-3 gets a fresh dialer.
var invocations = dial.WaitForCount(3, Timeout);
invocations.ShouldContain(i => i.Target.NodeId == "node-3" && i.CorrelationId == "central-node-3-1");
@@ -98,7 +102,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit
actor.Tell(new TelemetryDialSupervisor.RefreshNodes());
// The old node-1 stream is torn down and a fresh dialer opens at the NEW endpoint (gen 2).
- AwaitCondition(() => node1Original.Ct.IsCancellationRequested, Timeout);
+ AwaitCondition(() => node1Original.Ct.IsCancellationRequested, Timeout, PollInterval);
var invocations = dial.WaitForCount(3, Timeout);
invocations.ShouldContain(i =>
i.Target.NodeId == "node-1"
@@ -246,7 +250,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit
Sys.Stop(actor);
- AwaitCondition(() => inv.All(i => i.Ct.IsCancellationRequested), Timeout);
+ AwaitCondition(() => inv.All(i => i.Ct.IsCancellationRequested), Timeout, PollInterval);
}
[Fact]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs
index aba03afd..d98828a0 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs
@@ -1,4 +1,5 @@
using Akka.Cluster;
+using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
@@ -21,7 +22,9 @@ public abstract class ControlPlaneActorTestBase : TestKit
/// AwaitAssert) — the same doctrine as RuntimeActorTestBase.PresenceBudget.
///
///
- /// A budget, not a delay: the helpers return the instant the condition holds, so raising it costs
+ /// A budget, not a delay: the helpers return within one poll interval of the condition holding
+ /// (AwaitCondition polls every through this class's overloads, not Akka's
+ /// max / 10 of the dilated budget — lmxopcua#775), so raising it costs
/// nothing on the happy path and cannot make a genuinely failing assertion pass. Several actors here
/// do their first EF Core (InMemory) work inside the waited-for step — model build is a multi-second
/// cold cost on a loaded CI runner — and 3–5 s budgets sized on an idle machine failed there (#606).
@@ -29,6 +32,40 @@ public abstract class ControlPlaneActorTestBase : TestKit
///
protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
+ ///
+ /// Poll interval for every AwaitCondition in this class's subclasses that does not pass its own
+ /// (lmxopcua#775). Akka's own default is max / 10 of the DILATED budget — 1.5 s for a 15 s budget
+ /// locally, 4.5 s under CI's AKKA_TEST_TIMEFACTOR=3 — so a wait could overshoot the moment its
+ /// condition held by seconds. That wasted CI time everywhere, and failed tests where an undilated
+ /// deadline in the code under test elapsed during the overshoot (lmxopcua 342a825d). AwaitAssert
+ /// is unaffected: its default interval is a fixed 100 ms.
+ ///
+ protected static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20);
+
+ ///
+ /// with
+ /// defaulting to instead of Akka's max / 10.
+ /// Hides the TestKit overloads, so every subclass call without an interval binds here.
+ ///
+ /// The condition to poll.
+ /// The budget (dilated by Akka); null takes the enclosing Within or the default.
+ /// The poll interval; null means .
+ /// The failure message.
+ /// Cancels the wait.
+ protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max = null, TimeSpan? interval = null,
+ string? message = null, CancellationToken cancellationToken = default) =>
+ base.AwaitCondition(conditionIsFulfilled, max, interval ?? PollInterval, message!, cancellationToken);
+
+ /// The message-carrying overload of ,
+ /// polled every .
+ /// The condition to poll.
+ /// The budget (dilated by Akka).
+ /// The failure message.
+ /// Cancels the wait.
+ protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max, string message,
+ CancellationToken cancellationToken = default) =>
+ base.AwaitCondition(conditionIsFulfilled, max, PollInterval, message, cancellationToken);
+
/// Gets the Akka configuration for test actors, including cluster and pub-sub extensions.
///
/// akka.test.single-expect-default is raised from Akka's 3 s to :
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs
index 3e52f0e0..8f130035 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs
@@ -1,4 +1,5 @@
using Akka.Cluster;
+using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
@@ -15,8 +16,11 @@ public abstract class RuntimeActorTestBase : TestKit
/// Shared upper bound for a presence wait (AwaitAssert / AwaitCondition).
///
///
- /// A budget, not a delay. These helpers poll and return the instant the condition holds,
- /// so the value is how long to wait before giving up. Raising it costs nothing on the happy path and
+ /// A budget, not a delay. These helpers poll and return within one poll interval of the
+ /// condition holding (ExpectMsg the instant the message arrives; AwaitAssert every 100 ms,
+ /// Akka's default; AwaitCondition every through this class's overloads —
+ /// Akka's own default there is max / 10 of the dilated budget, 4.5 s in CI, lmxopcua#775), so the
+ /// value is how long to wait before giving up. Raising it costs nothing on the happy path and
/// cannot make a genuinely failing assertion pass — it only changes how quickly a real
/// breakage is reported. That is the opposite of an absence window (ExpectNoMsg,
/// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on
@@ -33,6 +37,41 @@ public abstract class RuntimeActorTestBase : TestKit
///
public static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
+ ///
+ /// Poll interval for every AwaitCondition in this class's subclasses that does not pass its own
+ /// (lmxopcua#775). Akka's own default is max / 10 of the DILATED budget — 1.5 s for a 15 s budget
+ /// locally, 4.5 s under CI's AKKA_TEST_TIMEFACTOR=3 — so a wait could overshoot the moment its
+ /// condition held by seconds. That wasted CI time everywhere, and failed tests where an undilated
+ /// deadline in the code under test elapsed during the overshoot (lmxopcua 342a825d). AwaitAssert
+ /// is unaffected: its default interval is a fixed 100 ms. Public, like , for
+ /// test classes that are not subclasses (a bare ) to pass explicitly.
+ ///
+ public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20);
+
+ ///
+ /// with
+ /// defaulting to instead of Akka's max / 10.
+ /// Hides the TestKit overloads, so every subclass call without an interval binds here.
+ ///
+ /// The condition to poll.
+ /// The budget (dilated by Akka); null takes the enclosing Within or the default.
+ /// The poll interval; null means .
+ /// The failure message.
+ /// Cancels the wait.
+ protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max = null, TimeSpan? interval = null,
+ string? message = null, CancellationToken cancellationToken = default) =>
+ base.AwaitCondition(conditionIsFulfilled, max, interval ?? PollInterval, message!, cancellationToken);
+
+ /// The message-carrying overload of ,
+ /// polled every .
+ /// The condition to poll.
+ /// The budget (dilated by Akka).
+ /// The failure message.
+ /// Cancels the wait.
+ protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max, string message,
+ CancellationToken cancellationToken = default) =>
+ base.AwaitCondition(conditionIsFulfilled, max, PollInterval, message, cancellationToken);
+
/// Gets the Akka test HOCON configuration string for single-node cluster setup.
///
/// akka.test.single-expect-default is raised from Akka's 3 s to
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs
index 2ac05597..aa2ea86a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Redundancy/OpcUaEndpointStepDownActorTests.cs
@@ -56,11 +56,13 @@ akka {
_nodeA = new TestKit(Hocon + TestTimeFactor.Hocon);
var clusterA = Akka.Cluster.Cluster.Get(_nodeA.Sys);
clusterA.Join(clusterA.SelfAddress);
- AwaitCondition(() => UpCount(clusterA) == 1, RuntimeActorTestBase.PresenceBudget);
+ AwaitCondition(() => UpCount(clusterA) == 1, RuntimeActorTestBase.PresenceBudget,
+ RuntimeActorTestBase.PollInterval);
var clusterB = Akka.Cluster.Cluster.Get(Sys);
clusterB.Join(clusterA.SelfAddress);
- AwaitCondition(() => UpCount(clusterA) == 2 && UpCount(clusterB) == 2, RuntimeActorTestBase.PresenceBudget);
+ AwaitCondition(() => UpCount(clusterA) == 2 && UpCount(clusterB) == 2, RuntimeActorTestBase.PresenceBudget,
+ RuntimeActorTestBase.PollInterval);
// Fixture sanity: A must be the Primary going in, or every assertion below is about the wrong node.
ClusterActiveNode.SelfIsActive(clusterA, RoleParser.Driver).ShouldBeTrue("node A formed the cluster, so it is the oldest Up driver member");
@@ -100,7 +102,7 @@ akka {
var clusterB = Akka.Cluster.Cluster.Get(Sys);
AwaitCondition(() => ClusterActiveNode.SelfIsActive(clusterB, RoleParser.Driver)
&& clusterB.State.Members.Count(m => m.Status == MemberStatus.Up) == 1,
- RuntimeActorTestBase.PresenceBudget);
+ RuntimeActorTestBase.PresenceBudget, RuntimeActorTestBase.PollInterval);
}
/// A failed endpoint on the SECONDARY changes nothing: there is no role to hand over.