From c2c368dcec1b10fd02646be87796c22fe2e03ad9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 12:12:47 -0400 Subject: [PATCH] feat(otopcua): make FixedTree re-discovery per-pass timeout injectable (follow-up A) --- .../Drivers/DriverInstanceActor.cs | 23 +++++++++++++--- .../DriverInstanceActorDiscoveryTests.cs | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 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 ebb77b8d..f14ab7a9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -38,6 +38,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Default cap on the number of post-connect re-discovery passes. public const int DefaultRediscoverMaxAttempts = 15; + /// Default per-pass timeout for during + /// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s. + public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30); + public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeSucceeded(int Generation); public sealed record InitializeFailed(string Reason, int Generation); @@ -135,6 +139,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15. private readonly int _rediscoverMaxAttempts; + + /// Per-pass timeout for during bounded post-connect + /// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter + /// value. Stored to allow injection rather than hardcoding. + private readonly TimeSpan _rediscoverDiscoverTimeout; private readonly ILoggingAdapter _log = Context.GetLogger(); private string? _currentConfigJson; @@ -192,6 +201,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// defaults to an empty string when not provided (e.g. in unit tests). /// Optional interval between post-connect re-discovery passes; defaults to 2 seconds. /// Optional cap on re-discovery passes; defaults to 15. + /// Optional per-pass timeout for ; defaults to 30 seconds. public static Props Props( IDriver driver, TimeSpan? reconnectInterval = null, @@ -199,7 +209,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts) => + int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, + TimeSpan? rediscoverDiscoverTimeout = null) => Akka.Actor.Props.Create(() => new DriverInstanceActor( driver, reconnectInterval ?? DefaultReconnectInterval, @@ -207,7 +218,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers healthPublisher ?? NullDriverHealthPublisher.Instance, clusterId ?? string.Empty, rediscoverInterval, - rediscoverMaxAttempts)); + rediscoverMaxAttempts, + rediscoverDiscoverTimeout)); /// /// Returns true when the driver should boot in DEV-STUB mode based on host platform and @@ -241,6 +253,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Cluster identifier forwarded in health snapshots. /// Interval between post-connect re-discovery passes; defaults to 2 seconds. /// Cap on the number of re-discovery passes; defaults to 15. + /// Per-pass timeout for ; defaults to 30 seconds. public DriverInstanceActor( IDriver driver, TimeSpan reconnectInterval, @@ -248,7 +261,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts) + int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, + TimeSpan? rediscoverDiscoverTimeout = null) { _driver = driver; _driverInstanceId = driver.DriverInstanceId; @@ -257,6 +271,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _reconnectInterval = reconnectInterval; _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; _rediscoverMaxAttempts = rediscoverMaxAttempts; + _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout; OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"), new KeyValuePair("driver_type", driver.DriverType)); @@ -762,7 +777,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers var builder = new CapturingAddressSpaceBuilder(); // Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded // DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it. - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout); // NO ConfigureAwait(false): a genuinely-async DiscoverAsync (Galaxy / OpcUaClient / TwinCAT) must // resume on the actor task scheduler so the Context.Parent.Tell + Timers calls below run with a // live ActorContext. ConfigureAwait(false) would resume off-context and throw diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs index e319cc66..70ff8938 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs @@ -171,6 +171,32 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase driver.DiscoverCount.ShouldBe(3); } + /// + /// The per-pass discovery timeout is injectable via so tests + /// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving). + /// Wiring is verified by constructing via Props with a custom value and confirming the actor starts + /// and begins discovery normally. + /// + [Fact] + public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value() + { + // The constant must exist and preserve the pre-refactor 30 s literal. + DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30)); + + // Props must accept the new optional parameter — no throw and actor starts normally. + var driver = new DiscoverableStubDriver(); + var parent = CreateTestProbe(); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, + rediscoverInterval: TimeSpan.FromMilliseconds(20), + rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5))); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + // Actor starts and discovery publishes — confirms the custom timeout was wired without error. + parent.ExpectMsg(TimeSpan.FromSeconds(2)); + } + /// /// A that also exposes . Each DiscoverAsync /// pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set —