fix(siteruntime): normalize routed WaitForAttribute response value for cross-process transport

This commit is contained in:
Joseph Doherty
2026-06-17 11:10:17 -04:00
parent adc8ee4afa
commit b88f04ec2d
2 changed files with 82 additions and 1 deletions
@@ -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<WaitForAttributeResponse>(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<T>
// (List<int>/List<double>/List<DateTime>/… 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,
@@ -78,6 +78,30 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Builds a config carrying a single STATIC List attribute whose canonical
/// JSON-array default the Instance Actor decodes into a typed <c>List&lt;int&gt;</c>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Builds a config carrying a single callable (no-trigger) script that
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
@@ -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<int> — 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<int>, Good).
actor.Tell(new DeployInstanceCommand(
"dep-wait-list", "WaitPumpList", "sha256:wait-list",
MakeConfigWithListAttributeJson("WaitPumpList"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(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<int> for its codec-equality match (value-equality across the wire).
var encodedTarget = AttributeValueCodec.Encode(new List<int> { 10, 20, 30 });
actor.Tell(new RouteToWaitForAttributeRequest(
"wait-corr-list", "WaitPumpList", "Setpoints", encodedTarget,
TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToWaitForAttributeResponse>(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<object?> of primitives — NOT the Instance Actor's concrete
// List<int> (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<List<object?>>(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]