fix(site-runtime): normalize routed GetAttributes List values for cross-process transport (#162)

This commit is contained in:
Joseph Doherty
2026-06-19 01:49:38 -04:00
parent a1eed1c2ab
commit f4e03ce8f7
2 changed files with 113 additions and 1 deletions
@@ -102,6 +102,31 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Builds a config carrying BOTH a static List attribute (whose JSON-array default
/// the Instance Actor decodes into a typed <c>List&lt;int&gt;</c>) and a static scalar
/// attribute. Used to drive a routed <see cref="RouteToGetAttributesRequest"/> that
/// exercises #162: the List value must be normalized for cross-process transport,
/// while the scalar must pass through the normalizer unchanged.
/// </summary>
private static string MakeConfigWithListAndScalarAttributesJson(string instanceName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Setpoints", Value = "[10,20,30]",
DataType = "List", ElementDataType = "Int32"
},
new ResolvedAttribute { CanonicalName = "Mode", Value = "Auto", DataType = "String" }
]
};
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"/>
@@ -515,6 +540,82 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture)));
}
// ── #162: routed RouteToGetAttributesRequest → InstanceActor (List normalization) ──
[Fact]
public async Task RouteInboundApiGetAttributes_ListValuedAttribute_ReturnsSerializerSafeValueAndScalarUnchanged()
{
// #162: a routed GetAttributes read of a List-typed static attribute comes back
// from the Instance Actor as a concrete generic List<int> — the SAME non-primitive
// shape the cross-process (Newtonsoft) serializer cannot reliably round-trip that
// WS-4 fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and
// the caller's Route.To().GetAttributes() Ask hangs to timeout. The handler must
// project EACH value through NormalizeRoutedReturnValue so the List survives the
// wire — while leaving scalar/string/null values unchanged (no regression).
var actor = CreateDeploymentManager();
await Task.Delay(500); // empty startup
// Static List attribute "Setpoints" = [10,20,30] (decoded to List<int>, Good)
// alongside a scalar string attribute "Mode" = "Auto".
actor.Tell(new DeployInstanceCommand(
"dep-getattr-list", "GetAttrPumpList", "sha256:getattr-list",
MakeConfigWithListAndScalarAttributesJson("GetAttrPumpList"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000); // let the InstanceActor spin up + decode the List default
// Request the List attribute, the scalar attribute, and a name that does not
// exist (must come back null — the Found:false path) in one round-trip.
actor.Tell(new RouteToGetAttributesRequest(
"getattr-corr-list", "GetAttrPumpList",
["Setpoints", "Mode", "DoesNotExist"], DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToGetAttributesResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("getattr-corr-list", response.CorrelationId);
Assert.True(response.Success, $"Routed GetAttributes failed: {response.ErrorMessage}");
// Keys preserved: all three requested names present.
Assert.True(response.Values.ContainsKey("Setpoints"));
Assert.True(response.Values.ContainsKey("Mode"));
Assert.True(response.Values.ContainsKey("DoesNotExist"));
// The List 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 of the round-trip).
var listValue = Assert.IsType<List<object?>>(response.Values["Setpoints"]);
Assert.Equal(
new[] { 10.0, 20.0, 30.0 },
listValue.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture)));
// Scalar/string value passes through the normalizer UNCHANGED (no regression).
Assert.Equal("Auto", response.Values["Mode"]);
// A not-found attribute remains null (the Found:false path is untouched by
// normalization — NormalizeRoutedReturnValue(null) is a no-op).
Assert.Null(response.Values["DoesNotExist"]);
}
[Fact]
public async Task RouteInboundApiGetAttributes_UnknownInstance_RepliesNotFound()
{
// A routed GetAttributes for an instance never deployed to this site must come
// back Success:false with a not-found message (routing-level outcome), mirroring
// the other RouteTo* unknown-instance paths.
var actor = CreateDeploymentManager();
await Task.Delay(500);
actor.Tell(new RouteToGetAttributesRequest(
"getattr-corr-nf", "NeverDeployedGetAttr", ["Setpoints"], DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToGetAttributesResponse>(TimeSpan.FromSeconds(5));
Assert.Equal("getattr-corr-nf", response.CorrelationId);
Assert.False(response.Success);
Assert.Empty(response.Values);
Assert.NotNull(response.ErrorMessage);
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
}
// ── M2.11: Debug-view routing — unknown-instance not-found signal ──
[Fact]