From bf2f481bb438b25a42972ae19b0c8c73097d066e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 09:19:12 -0400 Subject: [PATCH] fix(siteruntime): normalize routed script return value for cross-process transport A routed inbound-API call (Route.To(inst).Call(script)) runs the script on the Site and returns its value to Central inside RouteToCallResponse, which crosses the Central<->Site PROCESS boundary. A script's natural 'return new { ... }' is a compiler-generated anonymous type that Akka's cross-process serializer cannot reconstruct on the receiving node, so the reply was silently dropped and the caller's Route.To().Call() Ask timed out at 30s with 'Script execution timed out' -- even though the script completed and all device writes committed. DeploymentManagerActor.RouteInboundApiCall now projects the routed return value to a plain CLR graph (Dictionary/List/string/long/double/bool/null) via a JSON round-trip before placing it in RouteToCallResponse. The graph round-trips the wire and re-serializes to the same JSON shape the inbound API expects for the HTTP body / ReturnDefinition validation. Diagnosed live: IpsenMESMoveIn writes committed + site_events showed the IpsenMoveIn script completed in ~0.6s, yet the inbound POST returned 500 at 30s; Central's Akka serializer logged 'Writing value of type <>f__AnonymousType0`1 as Json' at the timeout moment. 379/379 SiteRuntime tests green. --- .../Actors/DeploymentManagerActor.cs | 66 ++++++++++++++++++- 1 file changed, 65 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 d412572e..40a86b40 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -954,7 +954,18 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers { var result = t.Result; return new RouteToCallResponse( - request.CorrelationId, result.Success, result.ReturnValue, + request.CorrelationId, result.Success, + // The routed script's return value crosses the Central↔Site + // PROCESS boundary inside this response. A script's natural + // `return new { ... }` is an anonymous type, which Akka's + // cross-process serializer cannot round-trip — the reply is + // silently dropped and the caller's Route.To().Call() Ask + // times out even though the script succeeded. Project the + // value to a plain CLR graph (Dictionary/List/primitive) that + // round-trips the wire and reproduces the same JSON shape on + // the inbound side (which JsonSerializer-serializes it for the + // HTTP body / ReturnDefinition validation). + NormalizeRoutedReturnValue(result.ReturnValue), result.ErrorMessage, DateTimeOffset.UtcNow); } return new RouteToCallResponse( @@ -972,6 +983,59 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers } } + /// + /// Projects a routed script's return value to a cross-process-serializable plain + /// CLR graph ( / / + /// string / long / double / bool / null) via a JSON round-trip. A script's natural + /// return new { ... } is a compiler-generated anonymous type that Akka's + /// cross-process serializer cannot reconstruct on the receiving node, so the + /// reply is silently dropped and the caller's + /// Ask times out. The projected graph round-trips the wire and re-serializes to the + /// same JSON shape the inbound API expects. Returns + /// unchanged on a (non-expected) serialization failure so a quirky value still has a + /// chance to deliver rather than being forced to null. + /// + private object? NormalizeRoutedReturnValue(object? value) + { + if (value is null) + { + return null; + } + + try + { + var json = System.Text.Json.JsonSerializer.Serialize(value); + using var doc = System.Text.Json.JsonDocument.Parse(json); + return FromJsonElement(doc.RootElement); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to normalize routed script return value of type {Type} for transport; sending as-is", + value.GetType().Name); + return value; + } + } + + /// + /// Converts a to a plain CLR value + /// (string / long / double / bool / null, or nested Dictionary / List) — never a + /// (which is itself not cross-process + /// serializable). Companion to . + /// + private static object? FromJsonElement(System.Text.Json.JsonElement e) => e.ValueKind switch + { + System.Text.Json.JsonValueKind.Object => + e.EnumerateObject().ToDictionary(p => p.Name, p => FromJsonElement(p.Value)), + System.Text.Json.JsonValueKind.Array => + e.EnumerateArray().Select(FromJsonElement).ToList(), + System.Text.Json.JsonValueKind.String => e.GetString(), + System.Text.Json.JsonValueKind.Number => e.TryGetInt64(out var l) ? l : e.GetDouble(), + System.Text.Json.JsonValueKind.True => true, + System.Text.Json.JsonValueKind.False => false, + _ => null, + }; + /// /// Reads attribute values from a deployed instance for a Route.To().GetAttribute(s) /// call (or a central Test Run bound to the instance). Asks the Instance Actor