Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
T

492 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Covers the bounded post-connect re-discovery loop: when an <see cref="ITagDiscovery"/> driver
/// reaches Connected, <see cref="DriverInstanceActor"/> runs repeated discovery passes (FOCAS-style:
/// the FixedTree is suppressed until the driver's cache populates ~02s after connect) and ships each
/// pass's captured nodes to its parent as <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The
/// loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not
/// spin forever. A driver that does not implement <see cref="ITagDiscovery"/> produces no passes at all.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
/// <summary>
/// A discoverable driver whose first two passes yield nothing (cache still warming) and whose third
/// pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty
/// set repeats. The final <see cref="DriverInstanceActor.DiscoveredNodesReady"/> carries the 3 nodes
/// and no further passes arrive — proving the loop is bounded.
/// </summary>
[Fact]
public void Discovery_retries_until_set_stabilises_then_stops()
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
// Drive Connecting → Connected; the Connected entry kicks discovery.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4
// (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops.
var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
for (var i = 0; i < 4; i++)
msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));
// The loop must STOP once the non-empty set has stabilised — no fifth pass.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// Early passes were empty (FixedTree cache still populating).
msgs[0].Nodes.Count.ShouldBe(0);
msgs[1].Nodes.Count.ShouldBe(0);
// The set then appears and stabilises at 3 nodes.
msgs[2].Nodes.Count.ShouldBe(3);
var final = msgs[^1];
final.Nodes.Count.ShouldBe(3);
final.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" });
// The driver was asked exactly as many times as messages published — no extra zombie pass.
driver.DiscoverCount.ShouldBe(4);
}
/// <summary>A driver that does not implement <see cref="ITagDiscovery"/> produces no discovery passes —
/// the Connected entry's discovery kick is a no-op, so the parent receives no
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.</summary>
[Fact]
public void Driver_without_ITagDiscovery_produces_no_discovery()
{
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// No discovery capability ⇒ never any DiscoveredNodesReady to the parent.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Discovery RE-RUNS on every return to Connected: after the initial discovery settles, a
/// <see cref="DriverInstanceActor.ForceReconnect"/> drives the actor through Reconnecting and
/// back to Connected (via the auto-retry timer, the same path the existing reconnect tests use),
/// and a fresh bounded discovery loop fires — keeping the injected tree current if the backend's
/// capabilities changed across the reconnect. The new init bumps the generation, so any
/// pre-reconnect tick is discarded by the generation guard (the initial loop has already settled
/// here, so none are in flight).
/// </summary>
[Fact]
public void Discovery_reruns_after_reconnect()
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromMilliseconds(50),
rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Drain the initial settling passes (0,0,3,3) and confirm the first loop stopped.
for (var i = 0; i < 4; i++)
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var passesBeforeReconnect = driver.DiscoverCount; // 4
// Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
actor.Tell(new DriverInstanceActor.ForceReconnect());
// A fresh discovery pass must arrive after the reconnect — the cache is warm now, so it sees
// the stable 3-node set immediately.
var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
afterReconnect.Nodes.Count.ShouldBe(3);
afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// The driver was discovered again — proves a fresh loop ran, not a replay of the old one.
driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeReconnect);
}
/// <summary>
/// Regression for the Critical: a driver whose <c>DiscoverAsync</c> completes ASYNCHRONOUSLY (off the
/// actor thread) must still ship <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The handler
/// touches <c>Context.Parent</c> + <c>Timers</c> AFTER awaiting discovery; if it awaited with
/// <c>ConfigureAwait(false)</c> the continuation would resume off the actor context and those calls
/// would throw <c>NotSupportedException("no active ActorContext")</c> — the handler would fault and no
/// message would arrive. Synchronous (<c>Task.CompletedTask</c>) stubs mask the bug; this one forces a
/// genuine off-context resume (modelled on <c>SubscribableStubDriver.UnsubscribeYields</c>).
/// </summary>
[Fact]
public void Async_completing_discovery_resumes_on_actor_context_and_publishes()
{
var driver = new YieldingDiscoverableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// With the fix the handler resumes on the actor context, so the publish succeeds and the parent gets
// a non-empty set. Without it the handler faults at Context.Parent.Tell and this times out.
var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
published.Nodes.Count.ShouldBe(3);
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
}
/// <summary>
/// The attempt cap bounds a discovered set that never stabilises: a driver whose set keeps GROWING
/// (1,2,3,…) never repeats its signature, so the loop is stopped only by
/// <c>rediscoverMaxAttempts</c>. With a cap of 3, exactly 3 passes are published, then the stream stops.
/// </summary>
[Fact]
public void Never_stabilising_discovery_is_bounded_by_the_attempt_cap()
{
var driver = new GrowingDiscoverableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
for (var i = 0; i < 3; i++)
msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));
// Cap reached — no fourth pass even though the set never stabilised.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// The set genuinely kept growing across the capped passes (1,2,3 nodes).
msgs.Select(m => m.Nodes.Count).ShouldBe(new[] { 1, 2, 3 });
driver.DiscoverCount.ShouldBe(3);
}
/// <summary>
/// A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
/// <see cref="DiscoveryRediscoverPolicy.Never"/> opts out of post-connect discovery entirely: the
/// Connected entry's discovery kick returns before scheduling the first tick, so the driver is never
/// asked to discover and the parent receives no <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.
/// </summary>
[Fact]
public void Discovery_policy_Never_runs_no_passes_and_publishes_nothing()
{
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Connect happened (the discovery decision is made on the Connected entry)...
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// ...but policy=Never ⇒ no discovery pass is ever run and nothing is published.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(0);
}
/// <summary>
/// A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
/// <see cref="DiscoveryRediscoverPolicy.Once"/> runs EXACTLY one post-connect pass even when its
/// discovered set would keep growing forever — under <c>UntilStable</c> the never-repeating signature
/// would retry to the attempt cap. Exactly one <see cref="DriverInstanceActor.DiscoveredNodesReady"/>
/// is published and no further <c>RediscoverTick</c> is scheduled.
/// </summary>
[Fact]
public void Discovery_policy_Once_publishes_exactly_one_pass_even_when_set_keeps_growing()
{
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("{}"));
// Exactly one pass is published (the first, growing set → 1 node)...
var only = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
only.Nodes.Count.ShouldBe(1);
only.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// ...and NO second tick is scheduled, even though the set would keep growing under UntilStable.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(1);
}
/// <summary>
/// <see cref="DiscoveryRediscoverPolicy.Once"/> means one pass PER (re)connect cycle — not one pass
/// ever. After the initial single pass settles, a <see cref="DriverInstanceActor.ForceReconnect"/>
/// drives the actor through Reconnecting and back to Connected (via the auto retry-connect timer), and
/// <c>StartDiscovery</c> re-kicks discovery — which must run EXACTLY ONE more pass, not the full attempt
/// cap. Uses the ever-growing fake with a small cap (3): under a (wrong) policy-ignoring loop the
/// never-stabilising set would publish 3 passes per connect, so a single post-reconnect pass proves
/// <c>Once</c> is honoured on the reconnect path too. Guards the exact StartDiscovery-on-reconnect path
/// the follow-on TriggerRediscovery task touches.
/// </summary>
[Fact]
public void Discovery_policy_Once_reruns_one_pass_on_reconnect()
{
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
// visibly more than the one pass Once must run per (re)connect.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromMilliseconds(50),
rediscoverInterval: TimeSpan.FromMilliseconds(20),
rediscoverMaxAttempts: 3));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then no more.
var first = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
first.Nodes.Count.ShouldBe(1);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
driver.DiscoverCount.ShouldBe(1);
// Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
actor.Tell(new DriverInstanceActor.ForceReconnect());
// Once = one pass PER (re)connect: exactly ONE additional pass after the reconnect, NOT the full cap.
// The set keeps growing across the reconnect (same driver instance), so this pass yields 2 nodes.
var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
afterReconnect.Nodes.Count.ShouldBe(2);
afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// No further passes — Once did NOT run the attempt cap on reconnect; one pass per connect cycle.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(2);
}
/// <summary>
/// The per-pass discovery timeout is injectable via <see cref="DriverInstanceActor.Props"/> so tests
/// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving).
/// Wiring is verified by constructing via <c>Props</c> with a custom value and confirming the actor starts
/// and begins discovery normally.
/// </summary>
[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<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
}
/// <summary>
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> received while Connected re-kicks the
/// post-connect discovery loop: after the initial discovery has settled, sending the message drives a
/// FRESH discovery pass — the driver's <c>DiscoverCount</c> advances and a new
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> is published. This is the message
/// <see cref="DriverHostActor"/> uses to re-run discovery after rebinding the driver to a new equipment.
/// </summary>
[Fact]
public void TriggerRediscovery_when_Connected_reruns_discovery()
{
var driver = new DiscoverableStubDriver();
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));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var passesBeforeTrigger = driver.DiscoverCount; // 4
// Re-kick discovery via the new message — the cache is warm, so the fresh pass sees the 3-node set.
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
var afterTrigger = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
afterTrigger.Nodes.Count.ShouldBe(3);
afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// A fresh pass genuinely ran — DiscoverCount advanced past the settled count.
driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeTrigger);
}
/// <summary>
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> on a driver whose
/// <see cref="ITagDiscovery.RediscoverPolicy"/> is <see cref="DiscoveryRediscoverPolicy.Never"/> does
/// NOT re-discover: the handler calls <c>StartDiscovery</c>, which returns early for <c>Never</c>, so
/// no pass runs and nothing is published — mirroring the Connected-entry Never opt-out.
/// </summary>
[Fact]
public void TriggerRediscovery_with_policy_Never_does_not_rediscover()
{
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return.
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(0);
}
/// <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.
/// </summary>
[Fact]
public void TriggerRediscovery_when_not_Connected_is_a_silent_noop()
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, 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.
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));
ExpectNoMsg(TimeSpan.FromMilliseconds(100));
driver.DiscoverCount.ShouldBe(0);
// Sanity: the actor still works — driving it to Connected discovers normally afterwards.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
}
/// <summary>
/// A <see cref="StubDriver"/> that also exposes <see cref="ITagDiscovery"/>. Each <c>DiscoverAsync</c>
/// pass is counted; passes 12 yield nothing (cache warming), passes 3+ yield a stable 3-node set —
/// modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put.
/// </summary>
private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery
{
private int _passCount;
/// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
/// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
/// existing UntilStable tests are unaffected.</summary>
public DiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
=> RediscoverPolicy = policy;
/// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
public DiscoveryRediscoverPolicy RediscoverPolicy { get; }
/// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
public int DiscoverCount => Volatile.Read(ref _passCount);
/// <summary>Streams a growing-then-stable node set into the builder (0,0,3,3,…).</summary>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
var count = pass >= 3 ? 3 : 0;
var fixedTree = builder.Folder("FixedTree", "FixedTree");
for (var i = 0; i < count; i++)
{
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
FullName: $"m.fixed.v{i}",
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
return Task.CompletedTask;
}
}
/// <summary>
/// A discoverable driver whose <c>DiscoverAsync</c> genuinely SUSPENDS and resumes on a fresh
/// thread-pool thread that carries NO Akka actor cell — modelled on
/// <c>SubscribableStubDriver.UnsubscribeYields</c>. This forces the actor's <c>await DiscoverAsync(...)</c>
/// continuation to resume off-context unless the handler omits <c>ConfigureAwait(false)</c>, so it is a
/// deterministic repro of the no-ActorContext race. Returns a stable 3-node set on every pass.
/// </summary>
private sealed class YieldingDiscoverableStubDriver : StubDriver, ITagDiscovery
{
/// <summary>Suspends on a TCS completed from a background thread, then streams 3 nodes.</summary>
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(() => tcs.SetResult(), cancellationToken);
await tcs.Task.ConfigureAwait(false); // resume on a clean thread-pool thread (no actor cell)
var fixedTree = builder.Folder("FixedTree", "FixedTree");
for (var i = 0; i < 3; i++)
{
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
FullName: $"m.fixed.v{i}",
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
}
}
/// <summary>
/// A discoverable driver whose set NEVER stabilises: pass N yields N nodes (1,2,3,…), so the
/// full-reference signature differs every pass and the loop can only be bounded by the attempt cap.
/// </summary>
private sealed class GrowingDiscoverableStubDriver : StubDriver, ITagDiscovery
{
private int _passCount;
/// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
/// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
/// existing attempt-cap test is unaffected. With <see cref="DiscoveryRediscoverPolicy.Once"/> the
/// ever-growing set proves the actor stops after a single pass (UntilStable would keep retrying).</summary>
public GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
=> RediscoverPolicy = policy;
/// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
public DiscoveryRediscoverPolicy RediscoverPolicy { get; }
/// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
public int DiscoverCount => Volatile.Read(ref _passCount);
/// <summary>Streams an ever-growing node set (pass N → N nodes).</summary>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
var fixedTree = builder.Folder("FixedTree", "FixedTree");
for (var i = 0; i < pass; i++)
{
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
FullName: $"m.fixed.v{i}",
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
return Task.CompletedTask;
}
}
}