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.
This commit is contained in:
Joseph Doherty
2026-06-17 09:19:12 -04:00
parent eeb6210151
commit bf2f481bb4
@@ -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
}
}
/// <summary>
/// Projects a routed script's return value to a cross-process-serializable plain
/// CLR graph (<see cref="Dictionary{TKey,TValue}"/> / <see cref="List{T}"/> /
/// string / long / double / bool / null) via a JSON round-trip. A script's natural
/// <c>return new { ... }</c> is a compiler-generated anonymous type that Akka's
/// cross-process serializer cannot reconstruct on the receiving node, so the
/// <see cref="RouteToCallResponse"/> 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 <paramref name="value"/>
/// unchanged on a (non-expected) serialization failure so a quirky value still has a
/// chance to deliver rather than being forced to null.
/// </summary>
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;
}
}
/// <summary>
/// Converts a <see cref="System.Text.Json.JsonElement"/> to a plain CLR value
/// (string / long / double / bool / null, or nested Dictionary / List) — never a
/// <see cref="System.Text.Json.JsonElement"/> (which is itself not cross-process
/// serializable). Companion to <see cref="NormalizeRoutedReturnValue"/>.
/// </summary>
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,
};
/// <summary>
/// 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