fix(otopcua): cancel pending rediscover timer on TriggerRediscovery + test hardening (follow-up C)

This commit is contained in:
Joseph Doherty
2026-06-26 12:57:08 -04:00
parent f7358bf4fd
commit e7d5ebe956
2 changed files with 53 additions and 24 deletions
@@ -302,28 +302,31 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
[Fact]
public void TriggerRediscovery_when_Connected_reruns_discovery()
{
var driver = new DiscoverableStubDriver();
// Once-policy growing stub: exactly ONE pass per (re)kick, so each StartDiscovery publishes precisely
// one DiscoveredNodesReady — the trigger's effect is asserted with a single ExpectMsg + ExpectNoMsg
// (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Let the initial post-connect loop settle (passes 0,0,3,3) and confirm it stopped.
for (var i = 0; i < 4; i++)
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
// Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then it settles.
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var passesBeforeTrigger = driver.DiscoverCount; // 4
var passesBeforeTrigger = driver.DiscoverCount; // 1
// Re-kick discovery via the new message — the cache is warm, so the fresh pass sees the 3-node set.
// Re-kick discovery via the new message — Once ⇒ exactly one fresh pass (growing set → 2 nodes).
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
var afterTrigger = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
afterTrigger.Nodes.Count.ShouldBe(3);
afterTrigger.Nodes.Count.ShouldBe(2);
afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// A fresh pass genuinely ran — DiscoverCount advanced past the settled count.
driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeTrigger);
// Exactly one fresh pass ran — DiscoverCount advanced by one and no extra pass arrived.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(passesBeforeTrigger + 1);
}
/// <summary>
@@ -351,32 +354,49 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
}
/// <summary>
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> received while NOT Connected (still Connecting,
/// before init completes) is a clean silent no-op: no discovery pass runs, nothing is published, and
/// the actor neither crashes nor dies (the driver's eventual reconnect re-discovers anyway). A
/// follow-up connect then discovers normally, proving the actor is unharmed.
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> received while NOT Connected is a clean silent
/// no-op in EVERY non-Connected state: no discovery pass runs, nothing is published, and the actor
/// neither crashes nor dies (its eventual (re)connect re-discovers anyway). Covers both <c>Connecting</c>
/// (before init completes) and <c>Reconnecting</c> (after a <see cref="DriverInstanceActor.ForceReconnect"/>,
/// parked there by a long reconnect interval), with an intervening connect proving the actor is unharmed.
/// </summary>
[Fact]
public void TriggerRediscovery_when_not_Connected_is_a_silent_noop()
{
var driver = new DiscoverableStubDriver();
// Once-growing stub so a successful connect publishes exactly one pass (clean confirmation of state);
// a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
driver,
reconnectInterval: TimeSpan.FromSeconds(30),
rediscoverInterval: TimeSpan.FromMilliseconds(20)));
Watch(actor);
// The actor boots into Connecting; send the trigger BEFORE InitializeRequested so it is handled
// in a non-Connected state.
// (1) Connecting: the actor boots into Connecting; send the trigger BEFORE InitializeRequested so it
// is handled in that non-Connected state.
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
// No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor).
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
ExpectNoMsg(TimeSpan.FromMilliseconds(100));
driver.DiscoverCount.ShouldBe(0);
// Sanity: the actor still works — driving it to Connected discovers normally afterwards.
// Drive to Connected (proves the Connecting-state trigger left the actor working); Once ⇒ one pass.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var passesAfterConnect = driver.DiscoverCount; // 1
// (2) Reconnecting: ForceReconnect parks the actor in Reconnecting (30s retry interval ⇒ no auto
// reconnect within the window). A TriggerRediscovery here must ALSO be a clean silent no-op. Both
// messages are processed in order, so the trigger is handled while Reconnecting.
actor.Tell(new DriverInstanceActor.ForceReconnect());
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // still alive — no Terminated
driver.DiscoverCount.ShouldBe(passesAfterConnect); // no fresh pass while Reconnecting
}
/// <summary>