test(archreview #10): cover actor-context preservation through the REAL CapabilityInvoker

The pass-through NullDriverCapabilityInvoker returns the call-site task directly, so it
never exercises the real invoker's internal `pipeline.ExecuteAsync(...).ConfigureAwait(false)`.
Adds a test-only Core reference to Runtime.Tests (the PRODUCTION Runtime assembly stays
Polly-free via the seam) and a discovery test that drives DriverInstanceActor through a real
CapabilityInvoker over a genuinely-yielding ITagDiscovery driver — proving the invoker's
internal ConfigureAwait(false) does NOT leak to the actor's own await, so the post-await
Context.Parent.Tell still runs (DiscoveredNodesReady arrives). Runtime.Tests 358, Core.Tests 238.
This commit is contained in:
Joseph Doherty
2026-07-08 21:38:14 -04:00
parent bacea1a44f
commit 62556c245a
2 changed files with 37 additions and 0 deletions
@@ -2,6 +2,7 @@ using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
@@ -144,6 +145,38 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
}
/// <summary>
/// Arch-review #10: the SAME async-discovery context-preservation guarantee, but driven through the
/// REAL <see cref="CapabilityInvoker"/> — whose Polly pipeline is awaited with
/// <c>ConfigureAwait(false)</c> internally. This is the one path the pass-through
/// <c>NullDriverCapabilityInvoker</c> (used by the sibling test) cannot cover: it returns the
/// call-site task directly, so it never exercises the invoker's internal off-context await. If that
/// internal <c>ConfigureAwait(false)</c> leaked to the actor's own <c>await _invoker.ExecuteAsync(…)</c>,
/// the post-await <c>Context.Parent.Tell</c> in <c>HandleRediscoverAsync</c> would fault with
/// "no active ActorContext" and no message would arrive. Its arrival proves the actor context survives
/// the real resilience pipeline.
/// </summary>
[Fact]
public void Async_discovery_through_real_CapabilityInvoker_preserves_actor_context()
{
var pipelineBuilder = new DriverResiliencePipelineBuilder(); // real Polly pipeline, tier-A defaults
var invoker = new CapabilityInvoker(
pipelineBuilder,
driverInstanceId: "yielding-discoverable",
optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A },
driverType: "Stub");
var driver = new YieldingDiscoverableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(5));
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
@@ -22,6 +22,10 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
<!-- TEST-ONLY reference to Core (Polly): lets the resilience-wiring guards construct the REAL
CapabilityInvoker. The PRODUCTION Runtime assembly stays Polly-free (consumes the
IDriverCapabilityInvoker seam) — this reference is in the test project only. -->
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>