From b88f04ec2df0101b7eb0ebfa16494d471b2af4aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 11:10:17 -0400 Subject: [PATCH] fix(siteruntime): normalize routed WaitForAttribute response value for cross-process transport --- .../Actors/DeploymentManagerActor.cs | 13 +++- .../Actors/DeploymentManagerActorTests.cs | 70 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 233644c4..c33b6360 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -1108,8 +1108,19 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // Ask bounded by the WAIT timeout + slack — NOT a fixed 30s (the wait legitimately blocks up to request.Timeout). instanceActor.Ask(inner, request.Timeout + TimeSpan.FromSeconds(5)) .ContinueWith(t => t.IsCompletedSuccessfully + // The matched value crosses the Central↔Site PROCESS boundary inside this + // response. For a List-typed attribute the Instance Actor's matched + // `WaitForAttributeResponse.Value` is a concrete generic List + // (List/List/List/… built by TryCoerceListValue; + // see InstanceActor.HandleTagValueUpdate / ResolveMatchedWaiters) — a + // non-primitive shape that Akka's cross-process serializer cannot reliably + // round-trip, which would silently drop the reply and hang the caller's + // Ask. Project it through the same normalizer RouteInboundApiCall uses so + // the wire carries a plain CLR graph (List/primitive). Scalars/strings/null + // pass through unchanged. ? new RouteToWaitForAttributeResponse( - request.CorrelationId, t.Result.Matched, t.Result.Value, t.Result.Quality, t.Result.TimedOut, + request.CorrelationId, t.Result.Matched, NormalizeRoutedReturnValue(t.Result.Value), + t.Result.Quality, t.Result.TimedOut, true, null, DateTimeOffset.UtcNow) : new RouteToWaitForAttributeResponse( request.CorrelationId, false, null, null, false, diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index a6eb66eb..318dcb1f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -78,6 +78,30 @@ public class DeploymentManagerActorTests : TestKit, IDisposable return JsonSerializer.Serialize(config); } + /// + /// Builds a config carrying a single STATIC List attribute whose canonical + /// JSON-array default the Instance Actor decodes into a typed List<int> + /// in memory (InstanceActor.PreStart / DecodeAttributeValue). Used to drive a + /// routed wait whose matched value is a collection — the shape WS-4 normalizes + /// for cross-process transport. + /// + private static string MakeConfigWithListAttributeJson(string instanceName) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = instanceName, + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Setpoints", Value = "[10,20,30]", + DataType = "List", ElementDataType = "Int32" + } + ] + }; + return JsonSerializer.Serialize(config); + } + /// /// Builds a config carrying a single callable (no-trigger) script that /// returns a constant — enough for an inbound @@ -445,6 +469,52 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task RouteInboundApiWaitForAttribute_ListValuedMatch_ReturnsSerializerSafeValue() + { + // WS-4: a routed wait whose matched attribute is a List-typed value comes + // back from the Instance Actor as a concrete generic List — a shape the + // cross-process (Newtonsoft) serializer cannot reliably round-trip, which would + // otherwise silently drop the reply and hang the caller's Ask. The handler must + // normalize the value (via the same NormalizeRoutedReturnValue projection that + // RouteInboundApiCall uses) to a plain CLR graph that survives transport while + // preserving the matched elements. + var actor = CreateDeploymentManager(); + await Task.Delay(500); // empty startup + + // Static List attribute "Setpoints" = [10,20,30] (decoded to List, Good). + actor.Tell(new DeployInstanceCommand( + "dep-wait-list", "WaitPumpList", "sha256:wait-list", + MakeConfigWithListAttributeJson("WaitPumpList"), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(1000); // let the InstanceActor spin up + decode the List default + + // Encode the target the same canonical way the Instance Actor encodes the + // current List for its codec-equality match (value-equality across the wire). + var encodedTarget = AttributeValueCodec.Encode(new List { 10, 20, 30 }); + actor.Tell(new RouteToWaitForAttributeRequest( + "wait-corr-list", "WaitPumpList", "Setpoints", encodedTarget, + TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("wait-corr-list", response.CorrelationId); + Assert.True(response.Success, $"Routed wait failed: {response.ErrorMessage}"); + Assert.True(response.Matched, "Expected fast-path match (List attribute already at target)."); + Assert.False(response.TimedOut); + Assert.Equal("Good", response.Quality); + + // The matched value must have been projected to a serializer-safe graph: + // a plain List of primitives — NOT the Instance Actor's concrete + // List (which Akka's cross-process serializer drops). The JSON round-trip + // re-materializes the numbers as boxed primitives; assert numeric equality + // without pinning the exact CLR numeric type (long vs double is an artifact). + Assert.NotNull(response.Value); + var list = Assert.IsType>(response.Value); + Assert.Equal( + new[] { 10.0, 20.0, 30.0 }, + list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture))); + } + // ── M2.11: Debug-view routing — unknown-instance not-found signal ── [Fact]