fix(siteruntime): normalize routed WaitForAttribute response value for cross-process transport
This commit is contained in:
@@ -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<int></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]
|
||||
|
||||
Reference in New Issue
Block a user