From f14dc633aed11f0644d08b84feab5e64c0be67de Mon Sep 17 00:00:00 2001 From: Ahmed Zamil Date: Thu, 20 Aug 2026 00:32:01 +0300 Subject: [PATCH] RegisterDynamicTypeDef fall back --- .../Esiur/Protocol/EpConnectionProtocol.cs | 29 ++++++-- Libraries/Esiur/Resource/Warehouse.cs | 11 ++++ .../typedef-info-v3-flat.json | 26 ++++++++ Tests/Unit/Esiur.Tests.Unit.csproj | 4 ++ Tests/Unit/TypeDefConformanceFixtureTests.cs | 66 +++++++++++++++++++ Tests/Unit/WarehouseLifecycleTests.cs | 35 ++++++++++ 6 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 Tests/Unit/ConformanceFixtures/typedef-info-v3-flat.json create mode 100644 Tests/Unit/TypeDefConformanceFixtureTests.cs diff --git a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs index d085e8b..4fd2240 100644 --- a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs +++ b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs @@ -1393,7 +1393,11 @@ partial class EpConnection { var typeDefs = LocalTypeDef.GetDependencies(localTypeDef, Instance.Warehouse); // Send - SendReply(EpPacketReply.Completed, callback, typeDefs.Select(x => x.Compose(this)).ToArray()); + // Cast reference-type arrays to object so C# does not expand + // them into SendReply's params object[] argument. The array is + // one protocol value, not N reply arguments. + SendReply(EpPacketReply.Completed, callback, + (object)typeDefs.Select(x => x.Compose(this)).ToArray()); } else { @@ -1582,7 +1586,10 @@ partial class EpConnection var list = children .Where(x => IsOperationAllowed(x, null, ActionType.Attach)) .ToArray(); - SendReply(EpPacketReply.Completed, callback, list); + // IResource[] is covariant with object[]; without this + // cast a one-child Query becomes one bare resource on the + // wire instead of a ResourceList. + SendReply(EpPacketReply.Completed, callback, (object)list); }).Error(e => { SendError(e.Type, callback, (ushort)e.Code, e.Message); @@ -3098,7 +3105,14 @@ partial class EpConnection var defs = new List(); - foreach (var def in (byte[][])result) + var typeDefinitions = result switch + { + byte[][] values => values, + object[] values => values.Cast().ToArray(), + _ => throw new InvalidCastException($"Expected an array of type definitions, received {result?.GetType().FullName ?? "null"}.") + }; + + foreach (var def in typeDefinitions) { var od = new RemoteTypeDef(); await RemoteTypeDef.Parse(od, _remoteDomain, def, this, null); @@ -3825,7 +3839,14 @@ partial class EpConnection SendRequest(EpPacketRequest.Query, path) .Then(result => { - reply.Trigger((IResource[])result); + var resources = result switch + { + IResource[] values => values, + object[] values => values.Cast().ToArray(), + _ => throw new InvalidCastException($"Expected a resource array, received {result?.GetType().FullName ?? "null"}.") + }; + + reply.Trigger(resources); }).Error(ex => reply.TriggerError(ex)); return reply; diff --git a/Libraries/Esiur/Resource/Warehouse.cs b/Libraries/Esiur/Resource/Warehouse.cs index 8b77d86..463374d 100644 --- a/Libraries/Esiur/Resource/Warehouse.cs +++ b/Libraries/Esiur/Resource/Warehouse.cs @@ -1375,7 +1375,18 @@ public class Warehouse } if (_remoteTypeDefs[domain].ContainsKey(typeDef.Id)) + { + // Remote definitions are scoped by domain and survive an + // EpConnection reconnect in the Warehouse cache. A freshly + // parsed definition for that same remote id must inherit the + // Warehouse-local id of the cached definition. Leaving it at + // zero makes Instance.RegisterDynamicTypeDef fall back to the + // producer's wire id, which can collide with an unrelated + // local CLR definition. + var existing = _remoteTypeDefs[domain][typeDef.Id]; + typeDef.LocalTypeDefId = existing.LocalTypeDefId; return false; + } // @TODO: Try to find a proxy type for the remote type def, if not found, create a new proxy type and register it in the warehouse. _remoteTypeDefs[domain][typeDef.Id] = typeDef; diff --git a/Tests/Unit/ConformanceFixtures/typedef-info-v3-flat.json b/Tests/Unit/ConformanceFixtures/typedef-info-v3-flat.json new file mode 100644 index 0000000..de81feb --- /dev/null +++ b/Tests/Unit/ConformanceFixtures/typedef-info-v3-flat.json @@ -0,0 +1,26 @@ +{ + "fixtureVersion": 1, + "protocolVersion": "3.1", + "producer": "esiur-ts", + "encoding": "indexed-TypeDefInfo", + "payloadBase64": "iaSIolEDAckWCAAIAQgCCAMIBAggCCEIIggjCCQIJcmFCQMIEUkEUHVtcEkTQnVpbGRpbmcuQXV0b21hdGlvbggASRxDb250cm9scyBhIGNpcmN1bGF0aW9uIHB1bXAuSQ9QdW1wIGNvbnRyb2xsZXJJDGVuYWJsZWQ9dHJ1ZUkESFZBQ0kDMy4xiBpREhLJB0kFb3duZXLJDEkKb3BlcmF0aW9ucw==", + "expected": { + "id": 17, + "kind": "Resource", + "version": 3, + "name": "Building.Automation.Pump", + "namespace": "Building.Automation", + "usage": "Controls a circulation pump.", + "description": "Pump controller", + "example": "enabled=true", + "category": "HVAC", + "since": "3.1", + "annotations": { + "owner": "operations" + }, + "properties": [], + "functions": [], + "events": [], + "constants": [] + } +} diff --git a/Tests/Unit/Esiur.Tests.Unit.csproj b/Tests/Unit/Esiur.Tests.Unit.csproj index 96da8ca..1882e9b 100644 --- a/Tests/Unit/Esiur.Tests.Unit.csproj +++ b/Tests/Unit/Esiur.Tests.Unit.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/Tests/Unit/TypeDefConformanceFixtureTests.cs b/Tests/Unit/TypeDefConformanceFixtureTests.cs new file mode 100644 index 0000000..882df2e --- /dev/null +++ b/Tests/Unit/TypeDefConformanceFixtureTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using Esiur.Data; +using Esiur.Data.Types; +using Esiur.Resource; + +namespace Esiur.Tests.Unit; + +public class TypeDefConformanceFixtureTests +{ + [Fact] + public void DotnetEncoder_MatchesCanonicalTypeScriptFixture() + { + var root = ReadFixture(); + var info = new TypeDefInfo + { + Version = 3, + Id = 17, + Name = "Pump", + Namespace = "Building.Automation", + Kind = TypeDefKind.Resource, + Usage = "Controls a circulation pump.", + Description = "Pump controller", + Example = "enabled=true", + Category = "HVAC", + Since = "3.1", + Annotations = new Map { ["owner"] = "operations" }, + }; + + var encoded = Codec.Compose(info, Warehouse.Default, null); + Assert.Equal(root.GetProperty("payloadBase64").GetString(), Convert.ToBase64String(encoded)); + } + + [Fact] + public void TypeScriptIndexedTypeDefInfoFixture_DecodesWithExpectedSemantics() + { + var root = ReadFixture(); + var expected = root.GetProperty("expected"); + var payload = Convert.FromBase64String(root.GetProperty("payloadBase64").GetString()!); + + var (size, info) = Codec.ParseIndexedType(payload, 0, Warehouse.Default); + + Assert.Equal((uint)payload.Length, size); + Assert.Equal(expected.GetProperty("id").GetUInt64(), info.Id); + Assert.Equal(expected.GetProperty("version").GetInt32(), info.Version); + Assert.Equal("Pump", info.Name); + Assert.Equal(expected.GetProperty("namespace").GetString(), info.Namespace); + Assert.Equal(expected.GetProperty("usage").GetString(), info.Usage); + Assert.Equal(expected.GetProperty("description").GetString(), info.Description); + Assert.Equal(expected.GetProperty("example").GetString(), info.Example); + Assert.Equal(expected.GetProperty("category").GetString(), info.Category); + Assert.Equal(expected.GetProperty("since").GetString(), info.Since); + Assert.Equal( + expected.GetProperty("annotations").GetProperty("owner").GetString(), + info.Annotations!["owner"]); + } + + private static JsonElement ReadFixture() + { + var location = Path.Combine( + AppContext.BaseDirectory, + "ConformanceFixtures", + "typedef-info-v3-flat.json"); + using var document = JsonDocument.Parse(File.ReadAllText(location)); + return document.RootElement.Clone(); + } +} diff --git a/Tests/Unit/WarehouseLifecycleTests.cs b/Tests/Unit/WarehouseLifecycleTests.cs index d1f433e..57d579c 100644 --- a/Tests/Unit/WarehouseLifecycleTests.cs +++ b/Tests/Unit/WarehouseLifecycleTests.cs @@ -1,4 +1,5 @@ using Esiur.Core; +using Esiur.Data.Types; using Esiur.Resource; using Esiur.Stores; @@ -6,6 +7,25 @@ namespace Esiur.Tests.Unit; public sealed class WarehouseLifecycleTests { + [Fact] + public void ReparsedRemoteTypeDef_ReusesWarehouseLocalId() + { + var warehouse = new Warehouse(); + _ = new LocalTypeDef(typeof(LocalCollisionMarker), warehouse); + + var first = new TestRemoteTypeDef(1, "Remote.ReportingService"); + Assert.True(warehouse.TryRegisterRemoteTypeDef("flow.example", first)); + Assert.NotEqual(0u, first.LocalTypeDefId); + + var reparsed = new TestRemoteTypeDef(1, "Remote.ReportingService"); + Assert.False(warehouse.TryRegisterRemoteTypeDef("flow.example", reparsed)); + Assert.Equal(first.LocalTypeDefId, reparsed.LocalTypeDefId); + + // Dynamic EpResource construction registers the definition again. + // This must use the reused local id, not the colliding wire id 1. + warehouse.RegisterDynamicTypeDef(reparsed); + } + [Fact] public async Task Close_AllowsWarehouseToBeOpenedAgain() { @@ -176,6 +196,21 @@ public sealed class WarehouseLifecycleTests private static async Task Observe(AsyncReply reply) => await reply; + private enum LocalCollisionMarker + { + Value, + } + + private sealed class TestRemoteTypeDef : RemoteTypeDef + { + public TestRemoteTypeDef(ulong id, string name) + { + _typeId = id; + _typeName = name; + _typeDefKind = TypeDefKind.Resource; + } + } + private sealed class ControlledLifecycleResource : IResource { private readonly AsyncReply? terminateReply;