From a7c495c3faaca22672dea5dbdf15d1eb5dce00ab Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:14:33 +0800 Subject: [PATCH 001/399] feat(identity): introduce fixed-width RPC semantic hash --- .../RpcGeneratedCodecRegistry.cs | 7 ++- src/SharpLink.Abstractions/RpcHash128.cs | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/SharpLink.Abstractions/RpcHash128.cs diff --git a/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs b/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs index 22c9909cc..3df993274 100644 --- a/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs +++ b/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs @@ -6,10 +6,13 @@ public interface IRpcGeneratedCodecFactory /// Gets the closed DTO or collection type handled by the factory. Type TargetType { get; } - /// Gets the deterministic schema identifier used for idempotent registration. + /// Gets the deterministic identity of the finalized Codec semantics. + RpcHash128 CodecHash => default; + + /// Gets the legacy deterministic schema identifier. string SchemaId { get; } - /// Gets the stable binary wire-format identity. + /// Gets the legacy binary wire-format identity. string WireFormatId { get; } /// Gets the adapter lifecycle identity, or null for adapter-free Codecs. diff --git a/src/SharpLink.Abstractions/RpcHash128.cs b/src/SharpLink.Abstractions/RpcHash128.cs new file mode 100644 index 000000000..529ab87f2 --- /dev/null +++ b/src/SharpLink.Abstractions/RpcHash128.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace SharpLink.Abstractions; + +/// Represents a deterministic fixed-width RPC semantic identity. +public readonly struct RpcHash128 : IEquatable +{ + /// Creates a 128-bit identity from its high and low 64-bit words. + public RpcHash128(ulong high, ulong low) + { + High = high; + Low = low; + } + + /// Gets the high 64 bits. + public ulong High { get; } + + /// Gets the low 64 bits. + public ulong Low { get; } + + /// Gets whether all bits are zero. + public bool IsEmpty => (High | Low) == 0; + + /// + public bool Equals(RpcHash128 other) => High == other.High && Low == other.Low; + + /// + public override bool Equals(object? obj) => obj is RpcHash128 other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(High, Low); + + /// + public override string ToString() + => High.ToString("x16", CultureInfo.InvariantCulture) + + Low.ToString("x16", CultureInfo.InvariantCulture); + + /// Compares two RPC identities for exact equality. + public static bool operator ==(RpcHash128 left, RpcHash128 right) => left.Equals(right); + + /// Compares two RPC identities for inequality. + public static bool operator !=(RpcHash128 left, RpcHash128 right) => !left.Equals(right); +} From fbc144d8adf22378220f600313076fdd2a561c17 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:22:25 +0800 Subject: [PATCH 002/399] refactor(identity): carry finalized codec hashes in generator model --- .../RpcGenerator.Models.cs | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index 5fee88c31..91d065d0b 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -206,6 +206,17 @@ internal sealed record GeneratedCodecModel( public bool ElementIsString { get; init; } } +internal readonly record struct GeneratedCodecHashModel( + string TypeName, + ulong High, + ulong Low); + +internal readonly record struct RpcHashValue(ulong High, ulong Low) +{ + public string ToHex() + => High.ToString("x16", InvariantCulture) + Low.ToString("x16", InvariantCulture); +} + internal enum DtoDiagnosticKind { Unsupported, @@ -240,7 +251,11 @@ internal sealed record DtoGenerationResult( ImmutableArray ContractCodecs, ImmutableArray FinalCodecBoundTypes, ImmutableArray Diagnostics, - ImmutableArray Enums); + ImmutableArray Enums) +{ + public ImmutableArray CodecHashes { get; init; } = + ImmutableArray.Empty; +} internal sealed record GeneratedEnumModel( string TypeName, @@ -258,6 +273,7 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) if (x is null || y is null || x.Codecs.Length != y.Codecs.Length || x.ContractCodecs.Length != y.ContractCodecs.Length || x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || + x.CodecHashes.Length != y.CodecHashes.Length || x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length) { return false; @@ -274,6 +290,11 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) } if (!x.FinalCodecBoundTypes.SequenceEqual(y.FinalCodecBoundTypes, StringComparer.Ordinal)) return false; + for (var index = 0; index < x.CodecHashes.Length; index++) + { + if (x.CodecHashes[index] != y.CodecHashes[index]) + return false; + } for (var index = 0; index < x.Diagnostics.Length; index++) { var left = x.Diagnostics[index]; @@ -313,6 +334,12 @@ public int GetHashCode(DtoGenerationResult obj) } foreach (var type in obj.FinalCodecBoundTypes) hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(type)); + foreach (var codecHash in obj.CodecHashes) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName)); + hash = unchecked(hash * 31 + codecHash.High.GetHashCode()); + hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); + } foreach (var diagnostic in obj.Diagnostics) hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.Detail)); foreach (var item in obj.Enums) @@ -372,6 +399,23 @@ public static long GetInterfaceHash(string iName) public static string GetIdentifierHash(string value) => Hash(value).ToString("x16", CultureInfo.InvariantCulture); + public static RpcHashValue GetSemanticHash(params string[] parts) + { + var canonical = new StringBuilder(); + foreach (var part in parts) + { + var value = part ?? string.Empty; + canonical.Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append(':') + .Append(value); + } + + var hex = GetSha256(canonical.ToString()); + return new RpcHashValue( + ulong.Parse(hex.Substring(0, 16), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + ulong.Parse(hex.Substring(16, 16), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + } + public static string GetSha256(string value) { using (var sha = System.Security.Cryptography.SHA256.Create()) @@ -395,4 +439,4 @@ private static ulong Hash(string s) } return hash; } -} +} \ No newline at end of file From aa462016249163f0d7f3ce673b47f6e342b2835e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:23:55 +0800 Subject: [PATCH 003/399] feat(identity): compute deterministic final codec hashes --- .../RpcGenerator.CodecIdentity.cs | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs new file mode 100644 index 000000000..7b4db7399 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -0,0 +1,442 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private sealed partial class DtoAnalysisState + { + internal ImmutableArray BuildFinalCodecHashes( + bool includeSerializable, + bool includeContracts) + { + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable, + includeContracts); + + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + + var cache = new Dictionary(StringComparer.Ordinal); + return reachable + .OrderBy(static pair => pair.Key, StringComparer.Ordinal) + .Select(pair => + { + var hash = GetFinalCodecHash(pair.Value, cache, new HashSet(StringComparer.Ordinal)); + return new GeneratedCodecHashModel(pair.Key, hash.High, hash.Low); + }) + .ToImmutableArray(); + } + + private RpcHashValue GetFinalCodecHash( + ITypeSymbol type, + Dictionary cache, + HashSet stack) + { + var typeName = GetTypeName(type); + if (cache.TryGetValue(typeName, out var cached)) + return cached; + if (!stack.Add(typeName)) + return Hashing.GetSemanticHash("codec/v1", "recursive", typeName); + + RpcHashValue result; + if (TryGetFrameworkPrimitiveCodecHash(type, cache, stack, out result)) + { + stack.Remove(typeName); + cache[typeName] = result; + return result; + } + + if (_models.TryGetValue(typeName, out var model)) + { + result = GetGeneratedCodecHash(model, cache, stack); + stack.Remove(typeName); + cache[typeName] = result; + return result; + } + + if (TryGetCollection(type, out var collectionKind, out var elementType, out var keyType, out var valueType)) + { + var parts = new List + { + "codec/v1", + "collection", + collectionKind.ToString() + }; + if (elementType is not null) + parts.Add(GetFinalCodecHash(elementType, cache, stack).ToHex()); + if (keyType is not null) + parts.Add(GetFinalCodecHash(keyType, cache, stack).ToHex()); + if (valueType is not null) + parts.Add(GetFinalCodecHash(valueType, cache, stack).ToHex()); + result = Hashing.GetSemanticHash(parts.ToArray()); + } + else if (type.IsUnmanagedType) + { + var layout = new StringBuilder("unsafe-blit/v1"); + AppendUnsafeBlitPhysicalLayout( + type, + layout, + new HashSet(SymbolEqualityComparer.Default)); + result = Hashing.GetSemanticHash("codec/v1", layout.ToString()); + } + else + { + // A non-local generated dependency has no behavior that can be inferred from its CLR + // name. This fallback keeps the identity deterministic until the referenced manifest + // hash table is consumed by the downstream-assembly path. + result = Hashing.GetSemanticHash("codec/v1", "external-generated", typeName); + } + + stack.Remove(typeName); + cache[typeName] = result; + return result; + } + + private RpcHashValue GetGeneratedCodecHash( + GeneratedCodecModel model, + Dictionary cache, + HashSet stack) + { + switch (model.Kind) + { + case GeneratedCodecKind.Custom: + return Hashing.GetSemanticHash( + "codec/v1", + "custom-opaque", + model.WireFormatId, + model.SchemaId); + case GeneratedCodecKind.Adapter: + return Hashing.GetSemanticHash( + "codec/v1", + "adapter-opaque", + model.AdapterId ?? string.Empty, + model.WireFormatId); + case GeneratedCodecKind.Dto: + { + var parts = new List + { + "codec/v1", + "dto", + model.IsReferenceType ? "ref" : "value" + }; + foreach (var member in model.Members.OrderBy(static member => member.FieldId)) + { + parts.Add(member.FieldId.ToString(InvariantCulture)); + parts.Add(member.Kind.ToString()); + parts.Add(member.Required ? "required" : "optional"); + parts.Add(member.Nullable ? "nullable" : "non-nullable"); + parts.Add(member.NonNullableReference ? "non-null-ref" : "other-null-semantics"); + switch (member.Kind) + { + case GeneratedMemberKind.String: + parts.Add("string/utf8/v1"); + break; + case GeneratedMemberKind.Fixed: + case GeneratedMemberKind.NullableFixed: + parts.Add(GetFixedMemberSemanticIdentity(member)); + break; + case GeneratedMemberKind.Complex: + if (!TryResolveReachableType(member.TypeName, out var memberType)) + parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", member.TypeName).ToHex()); + else + parts.Add(GetFinalCodecHash(memberType, cache, stack).ToHex()); + break; + } + } + return Hashing.GetSemanticHash(parts.ToArray()); + } + default: + { + var parts = new List + { + "codec/v1", + "collection", + model.Kind.ToString() + }; + AppendChild(model.ElementType); + AppendChild(model.KeyType); + AppendChild(model.ValueType); + return Hashing.GetSemanticHash(parts.ToArray()); + + void AppendChild(string? childTypeName) + { + if (childTypeName is null) + return; + if (TryResolveReachableType(childTypeName, out var childType)) + parts.Add(GetFinalCodecHash(childType, cache, stack).ToHex()); + else + parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", childTypeName).ToHex()); + } + } + } + } + + private bool TryResolveReachableType(string typeName, out ITypeSymbol type) + { + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: !_contractMode, + includeContracts: _contractMode); + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + return reachable.TryGetValue(typeName, out type!); + } + + private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) + { + var typeName = member.FixedTypeName ?? member.TypeName; + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.EnumUnderlyingType ?? typeName); + } + + private bool TryGetFrameworkPrimitiveCodecHash( + ITypeSymbol type, + Dictionary cache, + HashSet stack, + out RpcHashValue hash) + { + if (type.TypeKind == TypeKind.Enum && + type is INamedTypeSymbol { EnumUnderlyingType: { } enumUnderlying }) + { + hash = Hashing.GetSemanticHash( + "codec/v1", + "enum", + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex()); + return true; + } + + if (type is INamedTypeSymbol nullable && + nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + nullable.TypeArguments.Length == 1 && + IsFrameworkWirePrimitive(nullable.TypeArguments[0])) + { + hash = Hashing.GetSemanticHash( + "codec/v1", + "nullable", + GetFinalCodecHash(nullable.TypeArguments[0], cache, stack).ToHex()); + return true; + } + + string? token = type.SpecialType switch + { + SpecialType.System_String => "string/utf8/v1", + SpecialType.System_Boolean => "bool/fixed1/v1", + SpecialType.System_Byte => "u8/fixed1/v1", + SpecialType.System_SByte => "i8/fixed1/v1", + SpecialType.System_Int16 => "i16/fixed2/v1", + SpecialType.System_UInt16 => "u16/fixed2/v1", + SpecialType.System_Char => "char/fixed2/v1", + SpecialType.System_Int32 => "i32/fixed4/v1", + SpecialType.System_UInt32 => "u32/fixed4/v1", + SpecialType.System_Single => "f32/fixed4/v1", + SpecialType.System_Int64 => "i64/fixed8/v1", + SpecialType.System_UInt64 => "u64/fixed8/v1", + SpecialType.System_Double => "f64/fixed8/v1", + SpecialType.System_Decimal => "decimal/fixed16/v1", + _ => null + }; + + if (token is null && type is IArrayTypeSymbol { Rank: 1, ElementType.SpecialType: SpecialType.System_Byte }) + token = "bytes/v1"; + if (token is null) + { + token = type.ToDisplayString() switch + { + "System.Half" => "half/fixed2/v1", + "System.Text.Rune" => "rune/fixed4/v1", + "System.Guid" => "guid/fixed16/v1", + "System.DateTimeOffset" => "datetime-offset/fixed16/v1", + "System.DateTime" => "datetime/fixed8/v1", + "System.DateOnly" => "date-only/fixed4/v1", + "System.TimeOnly" => "time-only/fixed8/v1", + "System.TimeSpan" => "timespan/fixed8/v1", + "System.Int128" => "i128/fixed16/v1", + "System.UInt128" => "u128/fixed16/v1", + "System.Index" => "index/fixed4/v1", + "System.Range" => "range/fixed8/v1", + _ => null + }; + } + + if (token is null) + { + hash = default; + return false; + } + + hash = Hashing.GetSemanticHash("codec/v1", "framework", token); + return true; + } + + private void AppendUnsafeBlitPhysicalLayout( + ITypeSymbol type, + StringBuilder builder, + HashSet stack) + { + if (TryAppendPhysicalPrimitive(type, builder)) + return; + + if (type.TypeKind == TypeKind.Enum && + type is INamedTypeSymbol { EnumUnderlyingType: { } enumUnderlying }) + { + builder.Append("|enum"); + AppendUnsafeBlitPhysicalLayout(enumUnderlying, builder, stack); + return; + } + + if (type is IPointerTypeSymbol pointer) + { + builder.Append("|pointer|"); + AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack); + return; + } + if (type is IFunctionPointerTypeSymbol) + { + builder.Append("|function-pointer"); + return; + } + if (type is not INamedTypeSymbol named) + { + builder.Append("|unknown-unmanaged"); + return; + } + + AppendPhysicalLayoutAttribute(builder, named, "System.Runtime.InteropServices.StructLayoutAttribute", stack); + AppendPhysicalLayoutAttribute(builder, named, "System.Runtime.CompilerServices.InlineArrayAttribute", stack); + + if (!stack.Add(type)) + { + builder.Append("|recursive"); + return; + } + + if (named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + named.TypeArguments.Length == 1) + { + builder.Append("|nullable-underlying"); + AppendUnsafeBlitPhysicalLayout(named.TypeArguments[0], builder, stack); + stack.Remove(type); + return; + } + + var fields = named.GetMembers() + .OfType() + .Where(static field => !field.IsStatic && !field.IsConst) + .ToArray(); + builder.Append("|fields:").Append(fields.Length.ToString(InvariantCulture)); + for (var index = 0; index < fields.Length; index++) + { + var field = fields[index]; + builder.Append("|field:").Append(index.ToString(InvariantCulture)); + if (field.IsFixedSizeBuffer) + builder.Append("|fixed-buffer:").Append(field.FixedSize.ToString(InvariantCulture)); + AppendPhysicalLayoutAttribute(builder, field, "System.Runtime.InteropServices.FieldOffsetAttribute", stack); + AppendPhysicalLayoutAttribute(builder, field, "System.Runtime.CompilerServices.FixedBufferAttribute", stack); + AppendUnsafeBlitPhysicalLayout(field.Type, builder, stack); + } + + stack.Remove(type); + } + + private static bool TryAppendPhysicalPrimitive(ITypeSymbol type, StringBuilder builder) + { + var token = type.SpecialType switch + { + SpecialType.System_Boolean => "bool1", + SpecialType.System_Byte => "u8", + SpecialType.System_SByte => "i8", + SpecialType.System_Int16 => "i16", + SpecialType.System_UInt16 => "u16", + SpecialType.System_Char => "char16", + SpecialType.System_Int32 => "i32", + SpecialType.System_UInt32 => "u32", + SpecialType.System_Single => "f32", + SpecialType.System_Int64 => "i64", + SpecialType.System_UInt64 => "u64", + SpecialType.System_Double => "f64", + SpecialType.System_Decimal => "decimal128", + _ => null + }; + if (token is null) + { + token = type.ToDisplayString() switch + { + "System.Half" => "half16", + "System.Text.Rune" => "rune32", + "System.Guid" => "guid128", + "System.DateTimeOffset" => "datetimeoffset128", + "System.DateTime" => "datetime64", + "System.DateOnly" => "dateonly32", + "System.TimeOnly" => "timeonly64", + "System.TimeSpan" => "timespan64", + "System.Int128" => "i128", + "System.UInt128" => "u128", + "System.Index" => "index32", + "System.Range" => "range64", + _ => null + }; + } + if (token is null) + return false; + builder.Append('|').Append(token); + return true; + } + + private static void AppendPhysicalLayoutAttribute( + StringBuilder builder, + ISymbol symbol, + string attributeName, + HashSet stack) + { + var attribute = symbol.GetAttributes().FirstOrDefault(item => + string.Equals(item.AttributeClass?.ToDisplayString(), attributeName, StringComparison.Ordinal)); + if (attribute is null) + return; + + builder.Append("|attr:").Append(attributeName); + foreach (var argument in attribute.ConstructorArguments) + AppendPhysicalLayoutConstant(builder, argument, stack); + foreach (var argument in attribute.NamedArguments.OrderBy(static item => item.Key, StringComparer.Ordinal)) + { + builder.Append('|').Append(argument.Key).Append('='); + AppendPhysicalLayoutConstant(builder, argument.Value, stack); + } + } + + private static void AppendPhysicalLayoutConstant( + StringBuilder builder, + TypedConstant constant, + HashSet stack) + { + builder.Append(':').Append(constant.Kind.ToString()).Append('='); + if (constant.Kind == TypedConstantKind.Array) + { + builder.Append('['); + foreach (var item in constant.Values) + AppendPhysicalLayoutConstant(builder, item, stack); + builder.Append(']'); + return; + } + + if (constant.Value is ITypeSymbol type) + { + if (!TryAppendPhysicalPrimitive(type, builder)) + builder.Append("layout-type"); + return; + } + + builder.Append(Convert.ToString(constant.Value, InvariantCulture) ?? "null"); + } + } +} \ No newline at end of file From a5a80e7a13da833757165b1fb4786cfee2408aae Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:24:42 +0800 Subject: [PATCH 004/399] fix(identity): use explicit invariant culture in hash model --- src/SharpLink.Generator/RpcGenerator.Models.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index 91d065d0b..ab3f90e63 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -214,7 +214,8 @@ internal readonly record struct GeneratedCodecHashModel( internal readonly record struct RpcHashValue(ulong High, ulong Low) { public string ToHex() - => High.ToString("x16", InvariantCulture) + Low.ToString("x16", InvariantCulture); + => High.ToString("x16", CultureInfo.InvariantCulture) + + Low.ToString("x16", CultureInfo.InvariantCulture); } internal enum DtoDiagnosticKind From 1c06241853f98d106ebf2c3a3b275a124d9473c0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:40:44 +0800 Subject: [PATCH 005/399] style(generator): format codec identity --- .../RpcGenerator.CodecIdentity.cs | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 7b4db7399..4d9790eff 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -116,62 +116,62 @@ private RpcHashValue GetGeneratedCodecHash( model.AdapterId ?? string.Empty, model.WireFormatId); case GeneratedCodecKind.Dto: - { - var parts = new List - { - "codec/v1", - "dto", - model.IsReferenceType ? "ref" : "value" - }; - foreach (var member in model.Members.OrderBy(static member => member.FieldId)) { - parts.Add(member.FieldId.ToString(InvariantCulture)); - parts.Add(member.Kind.ToString()); - parts.Add(member.Required ? "required" : "optional"); - parts.Add(member.Nullable ? "nullable" : "non-nullable"); - parts.Add(member.NonNullableReference ? "non-null-ref" : "other-null-semantics"); - switch (member.Kind) + var parts = new List { - case GeneratedMemberKind.String: - parts.Add("string/utf8/v1"); - break; - case GeneratedMemberKind.Fixed: - case GeneratedMemberKind.NullableFixed: - parts.Add(GetFixedMemberSemanticIdentity(member)); - break; - case GeneratedMemberKind.Complex: - if (!TryResolveReachableType(member.TypeName, out var memberType)) - parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", member.TypeName).ToHex()); - else - parts.Add(GetFinalCodecHash(memberType, cache, stack).ToHex()); - break; + "codec/v1", + "dto", + model.IsReferenceType ? "ref" : "value" + }; + foreach (var member in model.Members.OrderBy(static member => member.FieldId)) + { + parts.Add(member.FieldId.ToString(InvariantCulture)); + parts.Add(member.Kind.ToString()); + parts.Add(member.Required ? "required" : "optional"); + parts.Add(member.Nullable ? "nullable" : "non-nullable"); + parts.Add(member.NonNullableReference ? "non-null-ref" : "other-null-semantics"); + switch (member.Kind) + { + case GeneratedMemberKind.String: + parts.Add("string/utf8/v1"); + break; + case GeneratedMemberKind.Fixed: + case GeneratedMemberKind.NullableFixed: + parts.Add(GetFixedMemberSemanticIdentity(member)); + break; + case GeneratedMemberKind.Complex: + if (!TryResolveReachableType(member.TypeName, out var memberType)) + parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", member.TypeName).ToHex()); + else + parts.Add(GetFinalCodecHash(memberType, cache, stack).ToHex()); + break; + } } + return Hashing.GetSemanticHash(parts.ToArray()); } - return Hashing.GetSemanticHash(parts.ToArray()); - } default: - { - var parts = new List { - "codec/v1", - "collection", - model.Kind.ToString() - }; - AppendChild(model.ElementType); - AppendChild(model.KeyType); - AppendChild(model.ValueType); - return Hashing.GetSemanticHash(parts.ToArray()); - - void AppendChild(string? childTypeName) - { - if (childTypeName is null) - return; - if (TryResolveReachableType(childTypeName, out var childType)) - parts.Add(GetFinalCodecHash(childType, cache, stack).ToHex()); - else - parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", childTypeName).ToHex()); + var parts = new List + { + "codec/v1", + "collection", + model.Kind.ToString() + }; + AppendChild(model.ElementType); + AppendChild(model.KeyType); + AppendChild(model.ValueType); + return Hashing.GetSemanticHash(parts.ToArray()); + + void AppendChild(string? childTypeName) + { + if (childTypeName is null) + return; + if (TryResolveReachableType(childTypeName, out var childType)) + parts.Add(GetFinalCodecHash(childType, cache, stack).ToHex()); + else + parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", childTypeName).ToHex()); + } } - } } } @@ -439,4 +439,4 @@ private static void AppendPhysicalLayoutConstant( builder.Append(Convert.ToString(constant.Value, InvariantCulture) ?? "null"); } } -} \ No newline at end of file +} From 49faaf53c450b2d46ce41bae548f8b1af161590f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:41:24 +0800 Subject: [PATCH 006/399] style(generator): terminate identity model file --- src/SharpLink.Generator/RpcGenerator.Models.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index ab3f90e63..ab8d68574 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -440,4 +440,4 @@ private static ulong Hash(string s) } return hash; } -} \ No newline at end of file +} From 19372472195448ae5c67cca365c808b336c30ed0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:44:30 +0800 Subject: [PATCH 007/399] feat(generator): materialize final codec hashes --- .../RpcGenerator.CodecPolicyOwnership.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index d95cb4256..2dcf2c01e 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -27,6 +27,9 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var contractPolicy = contractPolicyState.AnalyzeWithFinalCodecBindings(); + var codecHashes = contractPolicyState.BuildFinalCodecHashes( + includeSerializable: false, + includeContracts: true); var currentContractTypes = contractPolicyState.GetCurrentContractReachableTypeNames(); var currentContractDefaultCodecs = contractDefault.Codecs @@ -97,7 +100,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( contractCodecs, finalCodecBoundTypes, diagnostics, - enums); + enums) + { + CodecHashes = codecHashes + }; } private static bool ContainsRpcContract(INamespaceSymbol namespaceSymbol) From ce3b3b3783ea455273c5156bb46c581483603139 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:46:22 +0800 Subject: [PATCH 008/399] feat(generator): compose RPC semantic hashes --- .../RpcGenerator.RpcIdentity.cs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs diff --git a/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs b/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs new file mode 100644 index 000000000..147814f76 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs @@ -0,0 +1,133 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private sealed record RpcAssemblyIdentityModel( + RpcHashValue AssemblyHash, + ImmutableArray Contracts); + + private sealed record RpcContractIdentityModel( + long ContractId, + RpcHashValue ContractHash, + ImmutableArray Methods); + + private sealed record RpcMethodIdentityModel( + long MethodId, + RpcHashValue MethodHash); + + private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity( + RpcInterfaceModel[] contracts, + ImmutableArray codecHashes) + { + var codecHashByType = codecHashes.ToDictionary( + static item => item.TypeName, + static item => new RpcHashValue(item.High, item.Low), + StringComparer.Ordinal); + var contractIdentities = contracts + .OrderBy(static contract => contract.Hash) + .Select(contract => BuildContractIdentity(contract, codecHashByType)) + .ToImmutableArray(); + var assemblyParts = new List + { + "rpc-assembly/v1", + contractIdentities.Length.ToString(InvariantCulture) + }; + foreach (var contract in contractIdentities) + { + assemblyParts.Add(contract.ContractId.ToString(InvariantCulture)); + assemblyParts.Add(contract.ContractHash.ToHex()); + } + + return new RpcAssemblyIdentityModel( + Hashing.GetSemanticHash(assemblyParts.ToArray()), + contractIdentities); + } + + private static RpcContractIdentityModel BuildContractIdentity( + RpcInterfaceModel contract, + IReadOnlyDictionary codecHashes) + { + var methods = contract.Methods + .OrderBy(static method => method.Hash) + .Select(method => new RpcMethodIdentityModel( + method.Hash, + BuildMethodHash(method, codecHashes))) + .ToImmutableArray(); + var parts = new List + { + "contract/v1", + contract.Hash.ToString(InvariantCulture), + methods.Length.ToString(InvariantCulture) + }; + foreach (var method in methods) + { + parts.Add(method.MethodId.ToString(InvariantCulture)); + parts.Add(method.MethodHash.ToHex()); + } + + return new RpcContractIdentityModel( + contract.Hash, + Hashing.GetSemanticHash(parts.ToArray()), + methods); + } + + private static RpcHashValue BuildMethodHash( + RpcMethodModel method, + IReadOnlyDictionary codecHashes) + { + var payloadParameters = method.Parameters + .Where(static parameter => !parameter.IsCancellationToken) + .ToArray(); + var parts = new List + { + "method/v1", + method.Hash.ToString(InvariantCulture), + GetMethodKind(method), + method.HasCancellationToken ? "cancellable" : "non-cancellable", + method.IsIdempotent ? "idempotent" : "non-idempotent", + method.HasTimeoutAttribute ? "timeout" : "no-timeout", + method.TimeoutSeconds?.ToString("R", InvariantCulture) ?? string.Empty, + payloadParameters.Length.ToString(InvariantCulture) + }; + for (var index = 0; index < payloadParameters.Length; index++) + { + var parameter = payloadParameters[index]; + var payloadType = parameter.IsStream + ? parameter.StreamItemType ?? throw new InvalidOperationException( + $"Streaming RPC parameter '{parameter.Name}' has no item type.") + : parameter.Type; + parts.Add(index.ToString(InvariantCulture)); + parts.Add(parameter.IsStream ? "stream" : "unary"); + parts.Add(parameter.PayloadNullable ? "nullable" : "non-nullable"); + parts.Add(GetRequiredCodecHash(payloadType, codecHashes).ToHex()); + } + + if (method.IsVoid) + { + parts.Add("response:void"); + } + else + { + var responseType = method.IsStreamReturn + ? method.StreamItemType ?? throw new InvalidOperationException( + $"Streaming RPC method '{method.Name}' has no item type.") + : method.GenericArgumentType ?? throw new InvalidOperationException( + $"RPC method '{method.Name}' has no response payload type."); + parts.Add(method.IsStreamReturn ? "response:stream" : "response:unary"); + parts.Add(method.ResponseNullable ? "nullable" : "non-nullable"); + parts.Add(GetRequiredCodecHash(responseType, codecHashes).ToHex()); + } + + return Hashing.GetSemanticHash(parts.ToArray()); + } + + private static RpcHashValue GetRequiredCodecHash( + string typeName, + IReadOnlyDictionary codecHashes) + { + if (codecHashes.TryGetValue(typeName, out var hash)) + return hash; + throw new InvalidOperationException( + $"Final RPC Codec graph is missing deterministic identity for '{typeName}'."); + } +} From 7e1ecd9ab8973d0044b6e2e17cef3e902111c138 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:47:13 +0800 Subject: [PATCH 009/399] feat(abstractions): expose RPC assembly hash --- .../SharpLinkGeneratedAssemblyManifest.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs b/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs index 2aa6f5311..a7fd1b16e 100644 --- a/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs +++ b/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs @@ -138,6 +138,9 @@ public interface ISharpLinkGeneratedAssemblyManifest /// Gets the assembly that owns this manifest. Assembly OwnerAssembly { get; } + /// Gets the deterministic identity of the complete RPC-visible semantic graph. + RpcHash128 RpcAssemblyHash => default; + /// Gets the canonical compile-time descriptor used by downstream analyzers. string CompileTimeDescriptor { get; } From 721028e2051f590cb4699d24392ce704830e1f16 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:48:07 +0800 Subject: [PATCH 010/399] feat(generator): emit RPC assembly hash --- src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index a2b866db3..b9942a640 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -7,7 +7,8 @@ private static string GenerateAssemblyManifest( ImmutableArray interfaces, ImmutableArray services, ImmutableArray codecs, - ImmutableArray contractCodecs) + ImmutableArray contractCodecs, + ImmutableArray codecHashes) { var contracts = GetContractModels(interfaces); var serviceModels = GetServiceModels(services); @@ -15,6 +16,7 @@ private static string GenerateAssemblyManifest( return string.Empty; var manifestTypeName = GetManifestTypeName(contracts, serviceModels, codecs, contractCodecs); + var rpcIdentity = BuildRpcAssemblyIdentity(contracts, codecHashes); // Module dependencies come from generated artifacts and the finalized Codec graph. Contract // signature CLR references alone are not evidence that the referenced assembly publishes a // SharpLink generated manifest and therefore must not become dynamic-module dependencies. @@ -58,6 +60,7 @@ private static string GenerateAssemblyManifest( sb.AppendLine(" public int ProtocolVersion => 2;"); sb.AppendLine($" public string GeneratorVersion => \"{EscapeString(ExecutingGeneratorVersion)}\";"); sb.AppendLine($" public Assembly OwnerAssembly => typeof({manifestTypeName}).Assembly;"); + sb.AppendLine($" public RpcHash128 RpcAssemblyHash => new RpcHash128({rpcIdentity.AssemblyHash.High.ToString(InvariantCulture)}UL, {rpcIdentity.AssemblyHash.Low.ToString(InvariantCulture)}UL);"); sb.AppendLine(" string ISharpLinkGeneratedAssemblyManifest.CompileTimeDescriptor => CompileTimeDescriptor;"); sb.AppendLine(); AppendContractArtifactFactories(sb, contracts); From ecdfe3c640435786f694d7344950b5571383e6e3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:48:57 +0800 Subject: [PATCH 011/399] feat(generator): pass final codec hashes to manifest --- src/SharpLink.Generator/RpcGenerator.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 24a3d118e..7f4d91a82 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -317,10 +317,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var services = value.Left.Right; var codecs = value.Right.Codecs; var contractCodecs = value.Right.ContractCodecs; + var codecHashes = value.Right.CodecHashes; var contracts = GetContractModels(interfaces); var serviceModels = GetServiceModels(services); - var code = GenerateAssemblyManifest(interfaces, services, codecs, contractCodecs); + var code = GenerateAssemblyManifest(interfaces, services, codecs, contractCodecs, codecHashes); if (!string.IsNullOrEmpty(code)) { var manifestTypeName = GetManifestTypeName(contracts, serviceModels, codecs, contractCodecs); From f4364e29b4776a584a8069bff1bca3f2bfd4aa44 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:56:13 +0800 Subject: [PATCH 012/399] feat(generator): attach codec hashes to models --- src/SharpLink.Generator/RpcGenerator.Models.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index ab8d68574..3623cce45 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -204,6 +204,8 @@ internal sealed record GeneratedCodecModel( Location? Location) { public bool ElementIsString { get; init; } + public ulong CodecHashHigh { get; init; } + public ulong CodecHashLow { get; init; } } internal readonly record struct GeneratedCodecHashModel( @@ -327,11 +329,15 @@ public int GetHashCode(DtoGenerationResult obj) { hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.TypeName)); hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.SchemaId)); + hash = unchecked(hash * 31 + codec.CodecHashHigh.GetHashCode()); + hash = unchecked(hash * 31 + codec.CodecHashLow.GetHashCode()); } foreach (var codec in obj.ContractCodecs) { hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.TypeName)); hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.SchemaId)); + hash = unchecked(hash * 31 + codec.CodecHashHigh.GetHashCode()); + hash = unchecked(hash * 31 + codec.CodecHashLow.GetHashCode()); } foreach (var type in obj.FinalCodecBoundTypes) hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(type)); @@ -356,6 +362,8 @@ private static bool CodecEquals(GeneratedCodecModel left, GeneratedCodecModel ri if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || !string.Equals(left.CodecName, right.CodecName, StringComparison.Ordinal) || !string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) || + left.CodecHashHigh != right.CodecHashHigh || + left.CodecHashLow != right.CodecHashLow || left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || From df4a4f0d009e549c769fefed089b8c8bc25f8d4b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:57:28 +0800 Subject: [PATCH 013/399] feat(generator): bind hashes to final codec registrations --- .../RpcGenerator.CodecPolicyOwnership.cs | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 2dcf2c01e..fa493449f 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -13,6 +13,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var standalone = standaloneState.AnalyzeWithFinalCodecBindings(); + var standaloneHashes = standaloneState.BuildFinalCodecHashes( + includeSerializable: true, + includeContracts: false); + var standaloneCodecs = AttachCodecHashes(standalone.Codecs, standaloneHashes); + var contractDefaultState = new DtoAnalysisState( compilation, cancellationToken, @@ -20,6 +25,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: true); var contractDefault = contractDefaultState.AnalyzeWithFinalCodecBindings(); + var contractDefaultHashes = contractDefaultState.BuildFinalCodecHashes( + includeSerializable: false, + includeContracts: true); + var contractDefaultCodecs = AttachCodecHashes(contractDefault.Codecs, contractDefaultHashes); + var contractPolicyState = new DtoAnalysisState( compilation, cancellationToken, @@ -30,12 +40,13 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( var codecHashes = contractPolicyState.BuildFinalCodecHashes( includeSerializable: false, includeContracts: true); + var contractPolicyCodecs = AttachCodecHashes(contractPolicy.Codecs, codecHashes); var currentContractTypes = contractPolicyState.GetCurrentContractReachableTypeNames(); - var currentContractDefaultCodecs = contractDefault.Codecs + var currentContractDefaultCodecs = contractDefaultCodecs .Where(codec => currentContractTypes.Contains(codec.TypeName)) .ToImmutableArray(); - var currentContractPolicyCodecs = contractPolicy.Codecs + var currentContractPolicyCodecs = contractPolicyCodecs .Where(codec => currentContractTypes.Contains(codec.TypeName)) .ToImmutableArray(); var contractOwnedPolicyRoots = new HashSet( @@ -43,7 +54,7 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( StringComparer.Ordinal); var standaloneTypes = new HashSet( - standalone.Codecs.Select(static codec => codec.TypeName), + standaloneCodecs.Select(static codec => codec.TypeName), StringComparer.Ordinal); var defaultByType = currentContractDefaultCodecs .ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); @@ -60,7 +71,7 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( var globalByType = currentContractDefaultCodecs .Where(codec => !globalExcludedTypes.Contains(codec.TypeName)) .ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); - foreach (var codec in standalone.Codecs) + foreach (var codec in standaloneCodecs) globalByType[codec.TypeName] = codec; var globalCodecs = globalByType.Values .OrderBy(static codec => codec.TypeName, StringComparer.Ordinal) @@ -106,6 +117,28 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( }; } + private static ImmutableArray AttachCodecHashes( + ImmutableArray codecs, + ImmutableArray hashes) + { + var hashByType = hashes.ToDictionary(static item => item.TypeName, StringComparer.Ordinal); + return codecs + .Select(codec => + { + if (!hashByType.TryGetValue(codec.TypeName, out var hash)) + { + throw new InvalidOperationException( + $"Final Codec graph is missing deterministic identity for generated Codec '{codec.TypeName}'."); + } + return codec with + { + CodecHashHigh = hash.High, + CodecHashLow = hash.Low + }; + }) + .ToImmutableArray(); + } + private static bool ContainsRpcContract(INamespaceSymbol namespaceSymbol) { foreach (var type in namespaceSymbol.GetTypeMembers()) From d27a544ce5fc213220ee133bb13385397f4bcf60 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:58:34 +0800 Subject: [PATCH 014/399] feat(generator): bind codec hash at manifest registration --- .../RpcGenerator.ManifestEmitter.cs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index b9942a640..e545c8756 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -64,6 +64,7 @@ private static string GenerateAssemblyManifest( sb.AppendLine(" string ISharpLinkGeneratedAssemblyManifest.CompileTimeDescriptor => CompileTimeDescriptor;"); sb.AppendLine(); AppendContractArtifactFactories(sb, contracts); + AppendIdentifiedCodecFactory(sb); AppendContractManifestArray(sb, contracts); AppendServiceManifestArray(sb, serviceModels); AppendCodecManifestArray(sb, codecs); @@ -115,6 +116,31 @@ private static void AppendContractArtifactFactories(StringBuilder sb, RpcInterfa } } + private static void AppendIdentifiedCodecFactory(StringBuilder sb) + { + sb.AppendLine(" private sealed class __SharpLinkIdentifiedCodecFactory : IRpcGeneratedCodecFactory"); + sb.AppendLine(" {"); + sb.AppendLine(" private readonly IRpcGeneratedCodecFactory __inner;"); + sb.AppendLine(); + sb.AppendLine(" internal __SharpLinkIdentifiedCodecFactory(IRpcGeneratedCodecFactory inner, ulong hashHigh, ulong hashLow)"); + sb.AppendLine(" {"); + sb.AppendLine(" __inner = inner ?? throw new ArgumentNullException(nameof(inner));"); + sb.AppendLine(" CodecHash = new RpcHash128(hashHigh, hashLow);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" public Type TargetType => __inner.TargetType;"); + sb.AppendLine(" public RpcHash128 CodecHash { get; }"); + sb.AppendLine(" public string SchemaId => __inner.SchemaId;"); + sb.AppendLine(" public string WireFormatId => __inner.WireFormatId;"); + sb.AppendLine(" public string? AdapterId => __inner.AdapterId;"); + sb.AppendLine(" public IRpcCodecAdapter? Adapter => __inner.Adapter;"); + sb.AppendLine(" public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope)"); + sb.AppendLine(" => __inner.Create(provider, adapterScope);"); + sb.AppendLine(" public bool IsCompatibleCodec(IRpcCodec codec) => __inner.IsCompatibleCodec(codec);"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + private static void AppendContractManifestArray(StringBuilder sb, RpcInterfaceModel[] contracts) { sb.AppendLine(" private static readonly SharpLinkGeneratedContractDescriptor[] __contracts = new SharpLinkGeneratedContractDescriptor[]"); @@ -177,7 +203,7 @@ private static void AppendCodecManifestArray(StringBuilder sb, ImmutableArray codec.TypeName, StringComparer.Ordinal)) - sb.AppendLine($" new {codec.CodecName}.Factory(),"); + AppendIdentifiedCodecFactoryRegistration(sb, codec); sb.AppendLine(" };"); } @@ -188,10 +214,14 @@ private static void AppendContractCodecManifestArray( sb.AppendLine(" private static readonly IRpcGeneratedCodecFactory[] __contractCodecs = new IRpcGeneratedCodecFactory[]"); sb.AppendLine(" {"); foreach (var codec in codecs.OrderBy(static codec => codec.TypeName, StringComparer.Ordinal)) - sb.AppendLine($" new {codec.CodecName}.Factory(),"); + AppendIdentifiedCodecFactoryRegistration(sb, codec); sb.AppendLine(" };"); } + private static void AppendIdentifiedCodecFactoryRegistration(StringBuilder sb, GeneratedCodecModel codec) + => sb.AppendLine( + $" new __SharpLinkIdentifiedCodecFactory(new {codec.CodecName}.Factory(), {codec.CodecHashHigh.ToString(InvariantCulture)}UL, {codec.CodecHashLow.ToString(InvariantCulture)}UL),"); + private static string BuildCompileTimeDescriptor( RpcInterfaceModel[] contracts, RpcServiceModel[] services, From 72c97cc14bf797a16d977e5b1e8e85e7a5d8b8c8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:00:06 +0800 Subject: [PATCH 015/399] refactor(runtime): compare generated codec hashes --- .../SharpLinkRuntimeContext.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs index afe50b803..fe4ea19b9 100644 --- a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs +++ b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs @@ -35,13 +35,12 @@ internal SharpLinkRuntimeContext( foreach (var pair in owner.Codecs) { if (generatedRegistrations.TryGetValue(pair.Key, out var existing) && - (!string.Equals(existing.Factory.SchemaId, pair.Value.Factory.SchemaId, StringComparison.Ordinal) || - !string.Equals(existing.Factory.WireFormatId, pair.Value.Factory.WireFormatId, StringComparison.Ordinal))) + !HasSameGeneratedCodecIdentity(existing.Factory, pair.Value.Factory)) { throw new InvalidOperationException( $"Generated Codec conflict for '{pair.Key.FullName}': " + - $"schema/wire '{existing.Factory.SchemaId}'/'{existing.Factory.WireFormatId}' and " + - $"'{pair.Value.Factory.SchemaId}'/'{pair.Value.Factory.WireFormatId}'."); + $"identity '{DescribeGeneratedCodecIdentity(existing.Factory)}' and " + + $"'{DescribeGeneratedCodecIdentity(pair.Value.Factory)}'."); } generatedRegistrations[pair.Key] = pair.Value; } @@ -57,6 +56,21 @@ internal SharpLinkRuntimeContext( Buffers = new SharpLinkBufferWriterPool(bufferPool); } + private static bool HasSameGeneratedCodecIdentity( + IRpcGeneratedCodecFactory left, + IRpcGeneratedCodecFactory right) + { + if (!left.CodecHash.IsEmpty || !right.CodecHash.IsEmpty) + return left.CodecHash == right.CodecHash; + return string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) && + string.Equals(left.WireFormatId, right.WireFormatId, StringComparison.Ordinal); + } + + private static string DescribeGeneratedCodecIdentity(IRpcGeneratedCodecFactory factory) + => factory.CodecHash.IsEmpty + ? $"legacy:{factory.SchemaId}/{factory.WireFormatId}" + : $"codec:{factory.CodecHash}"; + [System.Diagnostics.CodeAnalysis.DoesNotReturn] [MethodImpl(MethodImplOptions.NoInlining)] private static void ThrowAfterConstructionRollback( From 3b1ce2a229976f65285ec32ec9f0fecc501488b6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:01:12 +0800 Subject: [PATCH 016/399] feat(abstractions): publish generated codec identity metadata --- ...harpLinkGeneratedCodecIdentityAttribute.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/SharpLink.Abstractions/SharpLinkGeneratedCodecIdentityAttribute.cs diff --git a/src/SharpLink.Abstractions/SharpLinkGeneratedCodecIdentityAttribute.cs b/src/SharpLink.Abstractions/SharpLinkGeneratedCodecIdentityAttribute.cs new file mode 100644 index 000000000..ede5f97f9 --- /dev/null +++ b/src/SharpLink.Abstractions/SharpLinkGeneratedCodecIdentityAttribute.cs @@ -0,0 +1,23 @@ +namespace SharpLink.Abstractions; + +/// +/// Publishes the deterministic default Codec identity of one closed payload type for downstream +/// source-generation. The target is a metadata lookup key and is not part of the hash. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public sealed class SharpLinkGeneratedCodecIdentityAttribute : Attribute +{ + /// Creates one generated Codec identity entry. + public SharpLinkGeneratedCodecIdentityAttribute(Type targetType, ulong hashHigh, ulong hashLow) + { + TargetType = targetType ?? throw new ArgumentNullException(nameof(targetType)); + CodecHash = new RpcHash128(hashHigh, hashLow); + } + + /// Gets the closed payload type used to locate this generated identity. + public Type TargetType { get; } + + /// Gets the deterministic default Codec identity. + public RpcHash128 CodecHash { get; } +} From 67d3509de342162785f387197aa2ff5e55843be0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:02:08 +0800 Subject: [PATCH 017/399] feat(generator): publish default codec hashes --- src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index e545c8756..2bd7fea90 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -43,6 +43,13 @@ private static string GenerateAssemblyManifest( sb.AppendLine("using SharpLink.Abstractions;"); sb.AppendLine("using SharpLink.Sdk;"); sb.AppendLine(); + foreach (var codec in codecs.OrderBy(static codec => codec.TypeName, StringComparer.Ordinal)) + { + sb.AppendLine( + $"[assembly: SharpLinkGeneratedCodecIdentityAttribute(typeof({codec.TypeName}), {codec.CodecHashHigh.ToString(InvariantCulture)}UL, {codec.CodecHashLow.ToString(InvariantCulture)}UL)]"); + } + if (!codecs.IsDefaultOrEmpty) + sb.AppendLine(); sb.AppendLine($"[assembly: SharpLinkGeneratedAssemblyManifestAttribute(typeof(SharpLink.Generated.{manifestTypeName}), 4, 2, \"{EscapeString(ExecutingGeneratorVersion)}\", \"{GeneratedAbiIdentity}\")]"); sb.AppendLine(); sb.AppendLine("namespace SharpLink.Generated;"); From f08bef089a3c521355cfce84c456750b7499af22 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:24:10 +0800 Subject: [PATCH 018/399] feat(generator): consume referenced codec hashes --- .../RpcGenerator.CodecIdentity.cs | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 4d9790eff..f813b17d0 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -58,6 +58,13 @@ private RpcHashValue GetFinalCodecHash( return result; } + if (TryGetReferencedGeneratedCodecHash(type, out result)) + { + stack.Remove(typeName); + cache[typeName] = result; + return result; + } + if (TryGetCollection(type, out var collectionKind, out var elementType, out var keyType, out var valueType)) { var parts = new List @@ -85,10 +92,10 @@ private RpcHashValue GetFinalCodecHash( } else { - // A non-local generated dependency has no behavior that can be inferred from its CLR - // name. This fallback keeps the identity deterministic until the referenced manifest - // hash table is consumed by the downstream-assembly path. - result = Hashing.GetSemanticHash("codec/v1", "external-generated", typeName); + // Compatibility only for referenced assemblies produced before deterministic CodecHash + // metadata existed. Current SharpLink-generated dependencies take the exact published + // hash path above; this fallback is removed with the remaining legacy identity surface. + result = Hashing.GetSemanticHash("codec/v1", "legacy-external-generated", typeName); } stack.Remove(typeName); @@ -96,6 +103,38 @@ private RpcHashValue GetFinalCodecHash( return result; } + private bool TryGetReferencedGeneratedCodecHash(ITypeSymbol type, out RpcHashValue hash) + { + var assembly = type.ContainingAssembly; + if (assembly is null || SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly)) + { + hash = default; + return false; + } + + foreach (var attribute in assembly.GetAttributes()) + { + if (!IsAttribute( + attribute, + "SharpLink.Abstractions", + "SharpLinkGeneratedCodecIdentityAttribute") || + attribute.ConstructorArguments.Length != 3 || + attribute.ConstructorArguments[0].Value is not ITypeSymbol targetType || + !SymbolEqualityComparer.Default.Equals(targetType, type) || + attribute.ConstructorArguments[1].Value is not ulong high || + attribute.ConstructorArguments[2].Value is not ulong low) + { + continue; + } + + hash = new RpcHashValue(high, low); + return true; + } + + hash = default; + return false; + } + private RpcHashValue GetGeneratedCodecHash( GeneratedCodecModel model, Dictionary cache, @@ -141,7 +180,7 @@ private RpcHashValue GetGeneratedCodecHash( break; case GeneratedMemberKind.Complex: if (!TryResolveReachableType(member.TypeName, out var memberType)) - parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", member.TypeName).ToHex()); + parts.Add(Hashing.GetSemanticHash("codec/v1", "legacy-external-generated", member.TypeName).ToHex()); else parts.Add(GetFinalCodecHash(memberType, cache, stack).ToHex()); break; @@ -169,7 +208,7 @@ void AppendChild(string? childTypeName) if (TryResolveReachableType(childTypeName, out var childType)) parts.Add(GetFinalCodecHash(childType, cache, stack).ToHex()); else - parts.Add(Hashing.GetSemanticHash("codec/v1", "external-generated", childTypeName).ToHex()); + parts.Add(Hashing.GetSemanticHash("codec/v1", "legacy-external-generated", childTypeName).ToHex()); } } } From 905a3055069274bfed6bb73f5d924986c873c4ff Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:26:52 +0800 Subject: [PATCH 019/399] feat(codec): add fixed semantic identity attribute --- .../Sdk/RpcCodecSemanticIdentityAttribute.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs diff --git a/src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs b/src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs new file mode 100644 index 000000000..7b857f9aa --- /dev/null +++ b/src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs @@ -0,0 +1,22 @@ +namespace SharpLink.Sdk; + +/// +/// Declares the fixed-width semantic identity of an opaque hand-written Codec or Codec Adapter. +/// Change this value whenever the implementation's RPC-visible wire semantics change. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class RpcCodecSemanticIdentityAttribute : Attribute +{ + /// Creates one opaque serializer semantic identity. + public RpcCodecSemanticIdentityAttribute(ulong high, ulong low) + { + High = high; + Low = low; + } + + /// Gets the high 64 bits of the semantic identity. + public ulong High { get; } + + /// Gets the low 64 bits of the semantic identity. + public ulong Low { get; } +} From d2b8c3e2db8e61a5f1896cb027686e5bff2db101 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:27:46 +0800 Subject: [PATCH 020/399] feat(generator): fold opaque semantic hashes into codec identity --- .../RpcGenerator.CodecIdentity.cs | 112 +++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index f813b17d0..67d401c87 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -4,6 +4,9 @@ public partial class RpcGenerator { private sealed partial class DtoAnalysisState { + private readonly Dictionary _opaqueSemanticIdentityCache = + new(StringComparer.Ordinal); + internal ImmutableArray BuildFinalCodecHashes( bool includeSerializable, bool includeContracts) @@ -143,15 +146,29 @@ private RpcHashValue GetGeneratedCodecHash( switch (model.Kind) { case GeneratedCodecKind.Custom: + if (TryGetOpaqueSemanticIdentity(model.CustomCodecType, out var customIdentity)) + { + return Hashing.GetSemanticHash( + "codec/v1", + "custom-opaque", + customIdentity.ToHex()); + } return Hashing.GetSemanticHash( "codec/v1", - "custom-opaque", + "custom-opaque-legacy", model.WireFormatId, model.SchemaId); case GeneratedCodecKind.Adapter: + if (TryGetOpaqueSemanticIdentity(model.AdapterType, out var adapterIdentity)) + { + return Hashing.GetSemanticHash( + "codec/v1", + "adapter-opaque", + adapterIdentity.ToHex()); + } return Hashing.GetSemanticHash( "codec/v1", - "adapter-opaque", + "adapter-opaque-legacy", model.AdapterId ?? string.Empty, model.WireFormatId); case GeneratedCodecKind.Dto: @@ -214,6 +231,97 @@ void AppendChild(string? childTypeName) } } + private bool TryGetOpaqueSemanticIdentity(string? implementationTypeName, out RpcHashValue hash) + { + if (implementationTypeName is null) + { + hash = default; + return false; + } + + if (_opaqueSemanticIdentityCache.TryGetValue(implementationTypeName, out var cached)) + { + hash = cached ?? default; + return cached.HasValue; + } + + var assemblies = new Dictionary(StringComparer.Ordinal) + { + [_compilation.Assembly.Identity.ToString()] = _compilation.Assembly + }; + var pending = new Queue(); + pending.Enqueue(_compilation.Assembly); + while (pending.Count != 0) + { + var assembly = pending.Dequeue(); + if (TryFindNamedType(assembly.GlobalNamespace, implementationTypeName, out var implementationType)) + { + var attribute = implementationType.GetAttributes().FirstOrDefault(static item => + IsAttribute(item, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); + if (attribute is not null && + attribute.ConstructorArguments.Length == 2 && + attribute.ConstructorArguments[0].Value is ulong high && + attribute.ConstructorArguments[1].Value is ulong low) + { + hash = new RpcHashValue(high, low); + _opaqueSemanticIdentityCache[implementationTypeName] = hash; + return true; + } + } + + foreach (var referenced in assembly.Modules.SelectMany(static module => module.ReferencedAssemblySymbols)) + { + var identity = referenced.Identity.ToString(); + if (assemblies.ContainsKey(identity)) + continue; + assemblies.Add(identity, referenced); + pending.Enqueue(referenced); + } + } + + _opaqueSemanticIdentityCache[implementationTypeName] = null; + hash = default; + return false; + } + + private static bool TryFindNamedType( + INamespaceSymbol namespaceSymbol, + string typeName, + out INamedTypeSymbol type) + { + foreach (var candidate in namespaceSymbol.GetTypeMembers()) + { + if (TryFindNamedType(candidate, typeName, out type)) + return true; + } + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + { + if (TryFindNamedType(nestedNamespace, typeName, out type)) + return true; + } + type = null!; + return false; + } + + private static bool TryFindNamedType( + INamedTypeSymbol candidate, + string typeName, + out INamedTypeSymbol type) + { + if (string.Equals(GetTypeName(candidate), typeName, StringComparison.Ordinal)) + { + type = candidate; + return true; + } + foreach (var nested in candidate.GetTypeMembers()) + { + if (TryFindNamedType(nested, typeName, out type)) + return true; + } + type = null!; + return false; + } + private bool TryResolveReachableType(string typeName, out ITypeSymbol type) { var roots = new Dictionary(StringComparer.Ordinal); From 87143bcd17245c26a236a305fd325536425510b0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:29:35 +0800 Subject: [PATCH 021/399] test(generator): cover opaque semantic codec identity --- .../RpcDeterministicIdentityTests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs new file mode 100644 index 000000000..986d13f56 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task OpaqueSemanticIdentityShouldIgnoreLegacyStringChanges() + { + var first = GenerateOpaqueIdentityManifest( + wireFormatId: "legacy-wire-a/v1", + schemaId: "legacy-schema-a/v1", + semanticHigh: 0x0102030405060708UL, + semanticLow: 0x1112131415161718UL); + var second = GenerateOpaqueIdentityManifest( + wireFormatId: "legacy-wire-b/v9", + schemaId: "legacy-schema-b/v9", + semanticHigh: 0x0102030405060708UL, + semanticLow: 0x1112131415161718UL); + + Ensure( + ExtractGeneratedCodecIdentity(first) == ExtractGeneratedCodecIdentity(second), + "fixed opaque semantic identity must replace legacy WireFormatId/SchemaId as the CodecHash input"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) == ExtractGeneratedRpcAssemblyHash(second), + "legacy custom-codec strings must not perturb RpcAssemblyHash once fixed semantic identity is present"); + return Task.CompletedTask; + } + + [Test] + public Task OpaqueSemanticIdentityChangeShouldChangeFinalRpcIdentity() + { + var first = GenerateOpaqueIdentityManifest( + wireFormatId: "same-wire/v1", + schemaId: "same-schema/v1", + semanticHigh: 0x0102030405060708UL, + semanticLow: 0x1112131415161718UL); + var second = GenerateOpaqueIdentityManifest( + wireFormatId: "same-wire/v1", + schemaId: "same-schema/v1", + semanticHigh: 0x0102030405060708UL, + semanticLow: 0x2112131415161718UL); + + Ensure( + ExtractGeneratedCodecIdentity(first) != ExtractGeneratedCodecIdentity(second), + "changing opaque serializer semantics must change CodecHash"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second), + "changing a payload CodecHash must flow through MethodHash/ContractHash into RpcAssemblyHash"); + return Task.CompletedTask; + } + + private static string GenerateOpaqueIdentityManifest( + string wireFormatId, + string schemaId, + ulong semanticHigh, + ulong semanticLow) + { + var source = $$""" +using System; +using System.Buffers; +using System.Threading; +using System.Threading.Tasks; +using SharpLink.Abstractions; +using SharpLink.Sdk; + +[RpcSerializable] +[RpcCodec(typeof(OpaquePayloadCodec))] +public sealed class OpaquePayload +{ + public int Value { get; set; } +} + +[RpcCodecImplementation("{{wireFormatId}}", "{{schemaId}}")] +[RpcCodecSemanticIdentity({{semanticHigh}}UL, {{semanticLow}}UL)] +public sealed class OpaquePayloadCodec : IRpcCodec +{ + public void Serialize(in OpaquePayload value, IBufferWriter buffer) { } + public OpaquePayload Deserialize(in ReadOnlySequence buffer) => new(); +} + +[RpcContract] +public interface IOpaqueIdentityContract : IService +{ + ValueTask Echo(OpaquePayload value, CancellationToken cancellationToken); +} +"""; + + return RunGeneratorAndGetSources(source) + .Single(static generated => + generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + } + + private static string ExtractGeneratedCodecIdentity(string manifest) + => manifest.Split('\n') + .Single(static line => + line.Contains( + "SharpLinkGeneratedCodecIdentityAttribute(typeof(global::OpaquePayload)", + StringComparison.Ordinal)) + .Trim(); + + private static string ExtractGeneratedRpcAssemblyHash(string manifest) + => manifest.Split('\n') + .Single(static line => line.Contains("public RpcHash128 RpcAssemblyHash =>", StringComparison.Ordinal)) + .Trim(); +} From 26d8697ff6fc6f0defb5cd69b54623143093b416 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:33:42 +0800 Subject: [PATCH 022/399] refactor(runtime): require deterministic codec hashes --- .../SharpLinkGeneratedManifestStructureValidator.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Runtime/SharpLinkGeneratedManifestStructureValidator.cs b/src/SharpLink.Runtime/SharpLinkGeneratedManifestStructureValidator.cs index e6d13f0c1..89ebf289d 100644 --- a/src/SharpLink.Runtime/SharpLinkGeneratedManifestStructureValidator.cs +++ b/src/SharpLink.Runtime/SharpLinkGeneratedManifestStructureValidator.cs @@ -10,6 +10,11 @@ internal static void Validate(ISharpLinkGeneratedAssemblyManifest manifest) var ownerAssembly = manifest.OwnerAssembly ?? throw new InvalidOperationException("Generated manifest has no owner assembly."); + if (manifest.RpcAssemblyHash.IsEmpty) + { + throw new InvalidOperationException( + $"Generated manifest '{ownerAssembly.FullName}' has no deterministic RPC assembly identity."); + } var contracts = manifest.Contracts ?? throw new InvalidOperationException($"Generated manifest '{ownerAssembly.FullName}' has a null Contract table."); var codecs = manifest.Codecs ?? @@ -60,10 +65,10 @@ private static void ValidateFactories( var targetType = factory.TargetType ?? throw new InvalidOperationException( $"Generated manifest '{ownerAssembly.FullName}' contains a Codec factory without a target Type in the {scope} graph."); - if (string.IsNullOrWhiteSpace(factory.SchemaId) || string.IsNullOrWhiteSpace(factory.WireFormatId)) + if (factory.CodecHash.IsEmpty) { throw new InvalidOperationException( - $"Generated manifest '{ownerAssembly.FullName}' contains incomplete Codec identity for '{targetType.FullName}' in the {scope} graph."); + $"Generated manifest '{ownerAssembly.FullName}' contains no deterministic CodecHash for '{targetType.FullName}' in the {scope} graph."); } if (!targets.Add(targetType)) { From 580dd87035f0c9a9c7a1caea1f9564dd94a7a2ac Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:36:08 +0800 Subject: [PATCH 023/399] refactor(generator): remove codec identity fallbacks --- .../RpcGenerator.CodecIdentity.cs | 59 +++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 67d401c87..7b9cecd91 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -95,10 +95,8 @@ private RpcHashValue GetFinalCodecHash( } else { - // Compatibility only for referenced assemblies produced before deterministic CodecHash - // metadata existed. Current SharpLink-generated dependencies take the exact published - // hash path above; this fallback is removed with the remaining legacy identity surface. - result = Hashing.GetSemanticHash("codec/v1", "legacy-external-generated", typeName); + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve deterministic CodecHash metadata for referenced payload '{typeName}'. Rebuild the referenced SharpLink assembly with deterministic identity generation enabled."); } stack.Remove(typeName); @@ -146,31 +144,15 @@ private RpcHashValue GetGeneratedCodecHash( switch (model.Kind) { case GeneratedCodecKind.Custom: - if (TryGetOpaqueSemanticIdentity(model.CustomCodecType, out var customIdentity)) - { - return Hashing.GetSemanticHash( - "codec/v1", - "custom-opaque", - customIdentity.ToHex()); - } return Hashing.GetSemanticHash( "codec/v1", - "custom-opaque-legacy", - model.WireFormatId, - model.SchemaId); + "custom-opaque", + GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec").ToHex()); case GeneratedCodecKind.Adapter: - if (TryGetOpaqueSemanticIdentity(model.AdapterType, out var adapterIdentity)) - { - return Hashing.GetSemanticHash( - "codec/v1", - "adapter-opaque", - adapterIdentity.ToHex()); - } return Hashing.GetSemanticHash( "codec/v1", - "adapter-opaque-legacy", - model.AdapterId ?? string.Empty, - model.WireFormatId); + "adapter-opaque", + GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter").ToHex()); case GeneratedCodecKind.Dto: { var parts = new List @@ -197,9 +179,11 @@ private RpcHashValue GetGeneratedCodecHash( break; case GeneratedMemberKind.Complex: if (!TryResolveReachableType(member.TypeName, out var memberType)) - parts.Add(Hashing.GetSemanticHash("codec/v1", "legacy-external-generated", member.TypeName).ToHex()); - else - parts.Add(GetFinalCodecHash(memberType, cache, stack).ToHex()); + { + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve child payload '{member.TypeName}' while hashing '{model.TypeName}'."); + } + parts.Add(GetFinalCodecHash(memberType, cache, stack).ToHex()); break; } } @@ -222,15 +206,28 @@ void AppendChild(string? childTypeName) { if (childTypeName is null) return; - if (TryResolveReachableType(childTypeName, out var childType)) - parts.Add(GetFinalCodecHash(childType, cache, stack).ToHex()); - else - parts.Add(Hashing.GetSemanticHash("codec/v1", "legacy-external-generated", childTypeName).ToHex()); + if (!TryResolveReachableType(childTypeName, out var childType)) + { + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve child payload '{childTypeName}' while hashing '{model.TypeName}'."); + } + parts.Add(GetFinalCodecHash(childType, cache, stack).ToHex()); } } } } + private RpcHashValue GetRequiredOpaqueSemanticIdentity( + string? implementationTypeName, + string implementationKind) + { + if (TryGetOpaqueSemanticIdentity(implementationTypeName, out var hash)) + return hash; + + throw new InvalidOperationException( + $"Opaque {implementationKind} '{implementationTypeName ?? ""}' must declare [RpcCodecSemanticIdentity(high, low)]."); + } + private bool TryGetOpaqueSemanticIdentity(string? implementationTypeName, out RpcHashValue hash) { if (implementationTypeName is null) From 16aa1a24f37e0e00094e4851abd6d2fcbdfb8353 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:37:10 +0800 Subject: [PATCH 024/399] test(generator): make opaque identity tests semantic-only --- .../RpcDeterministicIdentityTests.cs | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index 986d13f56..b29000514 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -7,25 +7,23 @@ namespace SharpLink.Generator.Tests; public partial class RpcAnalyzerTests { [Test] - public Task OpaqueSemanticIdentityShouldIgnoreLegacyStringChanges() + public Task OpaqueSemanticIdentityShouldIgnoreUnrelatedImplementationChanges() { var first = GenerateOpaqueIdentityManifest( - wireFormatId: "legacy-wire-a/v1", - schemaId: "legacy-schema-a/v1", + implementationMarker: "first-build", semanticHigh: 0x0102030405060708UL, semanticLow: 0x1112131415161718UL); var second = GenerateOpaqueIdentityManifest( - wireFormatId: "legacy-wire-b/v9", - schemaId: "legacy-schema-b/v9", + implementationMarker: "second-build", semanticHigh: 0x0102030405060708UL, semanticLow: 0x1112131415161718UL); Ensure( ExtractGeneratedCodecIdentity(first) == ExtractGeneratedCodecIdentity(second), - "fixed opaque semantic identity must replace legacy WireFormatId/SchemaId as the CodecHash input"); + "opaque CodecHash must be controlled by its fixed semantic identity rather than unrelated implementation details"); Ensure( ExtractGeneratedRpcAssemblyHash(first) == ExtractGeneratedRpcAssemblyHash(second), - "legacy custom-codec strings must not perturb RpcAssemblyHash once fixed semantic identity is present"); + "unrelated implementation changes must not perturb RpcAssemblyHash when RPC semantics are unchanged"); return Task.CompletedTask; } @@ -33,13 +31,11 @@ public Task OpaqueSemanticIdentityShouldIgnoreLegacyStringChanges() public Task OpaqueSemanticIdentityChangeShouldChangeFinalRpcIdentity() { var first = GenerateOpaqueIdentityManifest( - wireFormatId: "same-wire/v1", - schemaId: "same-schema/v1", + implementationMarker: "same-implementation", semanticHigh: 0x0102030405060708UL, semanticLow: 0x1112131415161718UL); var second = GenerateOpaqueIdentityManifest( - wireFormatId: "same-wire/v1", - schemaId: "same-schema/v1", + implementationMarker: "same-implementation", semanticHigh: 0x0102030405060708UL, semanticLow: 0x2112131415161718UL); @@ -53,8 +49,7 @@ public Task OpaqueSemanticIdentityChangeShouldChangeFinalRpcIdentity() } private static string GenerateOpaqueIdentityManifest( - string wireFormatId, - string schemaId, + string implementationMarker, ulong semanticHigh, ulong semanticLow) { @@ -73,11 +68,13 @@ public sealed class OpaquePayload public int Value { get; set; } } -[RpcCodecImplementation("{{wireFormatId}}", "{{schemaId}}")] +[RpcCodecImplementation("opaque-test-wire/v1", "opaque-test-schema/v1")] [RpcCodecSemanticIdentity({{semanticHigh}}UL, {{semanticLow}}UL)] public sealed class OpaquePayloadCodec : IRpcCodec { - public void Serialize(in OpaquePayload value, IBufferWriter buffer) { } + private const string ImplementationMarker = "{{implementationMarker}}"; + + public void Serialize(in OpaquePayload value, IBufferWriter buffer) { _ = ImplementationMarker; } public OpaquePayload Deserialize(in ReadOnlySequence buffer) => new(); } From 48d52f2d7627d1f9f1c61ec55cdcde1c6bf58914 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:38:17 +0800 Subject: [PATCH 025/399] feat(sharppack): declare opaque codec semantic identity --- src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs b/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs index 93a2d60be..893091ef6 100644 --- a/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs +++ b/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs @@ -30,6 +30,7 @@ public static IRpcCodec Create< /// SharpPack integration selected by generated Manifest metadata. [EditorBrowsable(EditorBrowsableState.Never)] +[RpcCodecSemanticIdentity(0x3fd7540d55dfa977UL, 0xbb67b4932c1a5249UL)] public sealed class SharpPackRpcCodecAdapter : IRpcCodecAdapter { /// The stable Adapter implementation identity. From ed740076bd1d448142f9e7d50172dbc3b6797c68 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:39:50 +0800 Subject: [PATCH 026/399] refactor(runtime): remove adapter wire identity checks --- .../Codec/RpcCodecProvider.cs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs b/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs index 1b8b8cc4d..d120c2de1 100644 --- a/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs +++ b/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs @@ -346,7 +346,6 @@ internal static RpcGeneratedManifestRegistration Create( var scopeByAdapterId = new Dictionary(StringComparer.Ordinal); var allFactories = manifest.Codecs.Concat(manifest.ContractCodecs).ToArray(); foreach (var factory in allFactories.OrderBy(static factory => factory.AdapterId, StringComparer.Ordinal) - .ThenBy(static factory => factory.WireFormatId, StringComparer.Ordinal) .ThenBy(static factory => factory.TargetType.FullName, StringComparer.Ordinal)) { ValidateFactory(factory); @@ -357,11 +356,10 @@ internal static RpcGeneratedManifestRegistration Create( ValidateAdapter(factory, adapter); if (scopeByAdapterId.TryGetValue(factory.AdapterId, out var existing)) { - if (existing.Adapter.GetType() != adapter.GetType() || - !string.Equals(existing.WireFormatId, factory.WireFormatId, StringComparison.Ordinal)) + if (existing.Adapter.GetType() != adapter.GetType()) { throw new InvalidOperationException( - $"Adapter '{factory.AdapterId}' has inconsistent implementation or wire-format metadata in manifest '{manifest.OwnerAssembly.FullName}'."); + $"Adapter '{factory.AdapterId}' has inconsistent implementations in manifest '{manifest.OwnerAssembly.FullName}'."); } continue; } @@ -370,7 +368,7 @@ internal static RpcGeneratedManifestRegistration Create( $"Adapter '{factory.AdapterId}' returned a null scope."); scopes.Add(scope); scopeByAdapterId.Add(factory.AdapterId, - new AdapterScopeRegistration(adapter, factory.WireFormatId, scope)); + new AdapterScopeRegistration(adapter, scope)); } var ownerBox = new OwnerBox(); @@ -435,8 +433,11 @@ private static void ValidateFactory(IRpcGeneratedCodecFactory factory) { ArgumentNullException.ThrowIfNull(factory); ArgumentNullException.ThrowIfNull(factory.TargetType); - ArgumentException.ThrowIfNullOrWhiteSpace(factory.SchemaId); - ArgumentException.ThrowIfNullOrWhiteSpace(factory.WireFormatId); + if (factory.CodecHash.IsEmpty) + { + throw new InvalidOperationException( + $"Generated Codec factory for '{factory.TargetType.FullName}' has no deterministic CodecHash."); + } var hasAdapterId = factory.AdapterId is not null; var hasAdapter = factory.Adapter is not null; @@ -450,11 +451,10 @@ private static void ValidateFactory(IRpcGeneratedCodecFactory factory) private static void ValidateAdapter(IRpcGeneratedCodecFactory factory, IRpcCodecAdapter adapter) { - if (!string.Equals(adapter.AdapterId, factory.AdapterId, StringComparison.Ordinal) || - !string.Equals(adapter.WireFormatId, factory.WireFormatId, StringComparison.Ordinal)) + if (!string.Equals(adapter.AdapterId, factory.AdapterId, StringComparison.Ordinal)) { throw new InvalidOperationException( - $"Codec adapter '{adapter.GetType().FullName}' runtime identity does not match its generated registration metadata."); + $"Codec adapter '{adapter.GetType().FullName}' lifecycle identity does not match its generated registration metadata."); } } @@ -489,7 +489,6 @@ public void Dispose() private sealed record AdapterScopeRegistration( IRpcCodecAdapter Adapter, - string WireFormatId, IRpcCodecAdapterScope Scope); internal sealed class OwnerBox From e29144bcfffccd790ff20c177f250face705a441 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:40:01 +0800 Subject: [PATCH 027/399] refactor(codec): remove adapter wire identity API --- src/SharpLink.Abstractions/IRpcCodecAdapter.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/SharpLink.Abstractions/IRpcCodecAdapter.cs b/src/SharpLink.Abstractions/IRpcCodecAdapter.cs index d9410c301..a0c110030 100644 --- a/src/SharpLink.Abstractions/IRpcCodecAdapter.cs +++ b/src/SharpLink.Abstractions/IRpcCodecAdapter.cs @@ -6,9 +6,6 @@ public interface IRpcCodecAdapter /// Gets the implementation and lifecycle identity. string AdapterId { get; } - /// Gets the stable binary wire-format identity. - string WireFormatId { get; } - /// Creates isolated state for one runtime Context and generated manifest. IRpcCodecAdapterScope CreateScope(); } From 507a53b11c62e953a820338e65f21fce9554a59f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:42:58 +0800 Subject: [PATCH 028/399] refactor(generator): require fixed opaque semantic identity --- .../RpcGenerator.DtoAnalysis.cs | 70 +++++++++---------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs b/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs index e12c11594..aaa01fac7 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs @@ -20,14 +20,13 @@ private static IEnumerable GetCodecDependencies(GeneratedCodecModel code private static bool HasSameCodecDefinition(GeneratedCodecModel left, GeneratedCodecModel right) { if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - !string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) || left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || !string.Equals(left.ValueType, right.ValueType, StringComparison.Ordinal) || + !string.Equals(left.CustomCodecType, right.CustomCodecType, StringComparison.Ordinal) || !string.Equals(left.AdapterType, right.AdapterType, StringComparison.Ordinal) || !string.Equals(left.AdapterId, right.AdapterId, StringComparison.Ordinal) || - !string.Equals(left.WireFormatId, right.WireFormatId, StringComparison.Ordinal) || !left.ConstructorMembers.SequenceEqual(right.ConstructorMembers, StringComparer.Ordinal) || !left.AssemblyDependencies.SequenceEqual(right.AssemblyDependencies, StringComparer.Ordinal) || left.Members.Length != right.Members.Length) @@ -235,14 +234,13 @@ private void CollectAdapterRegistrations() .OrderBy(static attribute => attribute.ToString(), StringComparer.Ordinal)) { var location = attribute.ApplicationSyntaxReference?.GetSyntax(_cancellationToken).GetLocation() ?? Location.None; - if (attribute.ConstructorArguments.Length != 3 || + if (attribute.ConstructorArguments.Length != 2 || attribute.ConstructorArguments[0].Value is not INamedTypeSymbol adapterType || attribute.ConstructorArguments[1].Value is not string adapterId || - attribute.ConstructorArguments[2].Value is not string wireFormatId || - !IsStableIdentity(adapterId) || !IsStableIdentity(wireFormatId)) + !IsStableIdentity(adapterId)) { Report(DtoDiagnosticKind.AdapterRegistrationInvalid, assembly, - "registration requires a concrete Adapter type and non-empty stable ASCII Adapter/Wire Format IDs", location); + "registration requires a concrete Adapter type and non-empty stable ASCII AdapterId", location); continue; } @@ -258,6 +256,12 @@ attribute.ConstructorArguments[2].Value is not string wireFormatId || "Adapter must implement IRpcCodecAdapter, be public sealed, and expose a public parameterless constructor", location); continue; } + if (!HasValidOpaqueSemanticIdentity(adapterType)) + { + Report(DtoDiagnosticKind.AdapterRegistrationInvalid, adapterType, + "Adapter must declare a non-zero fixed semantic identity via [RpcCodecSemanticIdentity(high, low)]", location); + continue; + } if (selector is not null && !InheritsFromAttribute(selector)) { Report(DtoDiagnosticKind.AdapterRegistrationInvalid, selector, @@ -268,23 +272,20 @@ attribute.ConstructorArguments[2].Value is not string wireFormatId || var registration = new AdapterRegistration( adapterType, adapterId, - wireFormatId, selector, location); if (_adaptersByType.TryGetValue(adapterType, out var existingType) && - (!string.Equals(existingType.AdapterId, adapterId, StringComparison.Ordinal) || - !string.Equals(existingType.WireFormatId, wireFormatId, StringComparison.Ordinal))) + !string.Equals(existingType.AdapterId, adapterId, StringComparison.Ordinal)) { Report(DtoDiagnosticKind.AdapterIdentityConflict, adapterType, - "the same Adapter type has inconsistent Adapter or Wire Format IDs", location); + "the same Adapter type has inconsistent Adapter IDs", location); continue; } if (adapterIds.TryGetValue(adapterId, out var existingId) && - (!SymbolEqualityComparer.Default.Equals(existingId.AdapterType, adapterType) || - !string.Equals(existingId.WireFormatId, wireFormatId, StringComparison.Ordinal))) + !SymbolEqualityComparer.Default.Equals(existingId.AdapterType, adapterType)) { Report(DtoDiagnosticKind.AdapterIdentityConflict, adapterType, - $"Adapter ID '{adapterId}' is declared by inconsistent types or Wire Format IDs", location); + $"Adapter ID '{adapterId}' is declared by inconsistent implementation types", location); continue; } if (selector is not null && _adaptersBySelector.TryGetValue(selector, out var existingSelector) && @@ -341,7 +342,7 @@ private void Visit(ITypeSymbol type, List stack, int depth) _models[typeName] = new GeneratedCodecModel( typeName, GetCodecName(typeName, _contractMode), - GetSchemaId(typeName, customCodec.SchemaId), + GetSchemaId(typeName, "custom|" + GetTypeName(customCodec.CodecType)), GeneratedCodecKind.Custom, type.IsReferenceType, ImmutableArray.Empty, @@ -352,7 +353,7 @@ private void Visit(ITypeSymbol type, List stack, int depth) GetTypeName(customCodec.CodecType), null, null, - customCodec.WireFormatId, + string.Empty, GetAssemblyDependencies([type]), type.Locations.FirstOrDefault()); } @@ -889,8 +890,7 @@ private bool TryResolveExplicitBinding( private static bool AdapterRegistrationsEqual(AdapterRegistration left, AdapterRegistration right) => SymbolEqualityComparer.Default.Equals(left.AdapterType, right.AdapterType) && - string.Equals(left.AdapterId, right.AdapterId, StringComparison.Ordinal) && - string.Equals(left.WireFormatId, right.WireFormatId, StringComparison.Ordinal); + string.Equals(left.AdapterId, right.AdapterId, StringComparison.Ordinal); private static bool ImplementsRpcCodecAdapter(INamedTypeSymbol type) => type.AllInterfaces.Any(static item => @@ -963,14 +963,7 @@ private static bool IsValidCustomCodec(ITypeSymbol codecType, ITypeSymbol target return false; } - var identity = named.GetAttributes().FirstOrDefault(static attribute => - IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecImplementationAttribute")); - return identity is not null && - identity.ConstructorArguments.Length == 2 && - identity.ConstructorArguments[0].Value is string wireFormatId && - identity.ConstructorArguments[1].Value is string schemaId && - IsStableIdentity(wireFormatId) && - IsStableIdentity(schemaId); + return HasValidOpaqueSemanticIdentity(named); } private CustomCodecRegistration? ValidateCustomCodec( @@ -1010,21 +1003,14 @@ identity.ConstructorArguments[1].Value is string schemaId && return null; } - var identity = named.GetAttributes().FirstOrDefault(static attribute => - IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecImplementationAttribute")); - if (identity is null || - identity.ConstructorArguments.Length != 2 || - identity.ConstructorArguments[0].Value is not string wireFormatId || - identity.ConstructorArguments[1].Value is not string schemaId || - !IsStableIdentity(wireFormatId) || - !IsStableIdentity(schemaId)) + if (!HasValidOpaqueSemanticIdentity(named)) { Report(DtoDiagnosticKind.CustomCodecIdentityInvalid, codecType, - "custom Codec must declare stable ASCII WireFormatId and SchemaId via [RpcCodecImplementation]", location); + "custom Codec must declare a non-zero fixed semantic identity via [RpcCodecSemanticIdentity(high, low)]", location); return null; } - return new CustomCodecRegistration(named, wireFormatId, schemaId, location); + return new CustomCodecRegistration(named, location); } private bool TrySelectCustomCodec(ITypeSymbol type, out CustomCodecRegistration? selected) @@ -1098,6 +1084,17 @@ private static bool InheritsFromAttribute(ITypeSymbol type) return false; } + private static bool HasValidOpaqueSemanticIdentity(INamedTypeSymbol type) + { + var identity = type.GetAttributes().FirstOrDefault(static attribute => + IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); + return identity is not null && + identity.ConstructorArguments.Length == 2 && + identity.ConstructorArguments[0].Value is ulong high && + identity.ConstructorArguments[1].Value is ulong low && + (high | low) != 0; + } + private static bool IsStableIdentity(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -1435,14 +1432,11 @@ private sealed record ExplicitBindingCandidate( private sealed record AdapterRegistration( INamedTypeSymbol AdapterType, string AdapterId, - string WireFormatId, ITypeSymbol? SelectorType, Location Location); private sealed record CustomCodecRegistration( INamedTypeSymbol CodecType, - string WireFormatId, - string SchemaId, Location Location); } } From 2be8ec91262aa5b16ad50bd9dea34db43a092207 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:43:32 +0800 Subject: [PATCH 029/399] refactor(generator): remove adapter wire metadata from routing --- src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs b/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs index 7a77af559..33018f5cd 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs @@ -197,7 +197,7 @@ private bool HasMatchingAssemblyRoute(ITypeSymbol type) private void AddAdapterModel(ITypeSymbol type, string typeName, AdapterRegistration adapter) { - var schema = $"adapter|{adapter.AdapterId}|{GetTypeName(adapter.AdapterType)}|{adapter.WireFormatId}"; + var schema = $"adapter|{adapter.AdapterId}|{GetTypeName(adapter.AdapterType)}"; _models[typeName] = new GeneratedCodecModel( typeName, GetCodecName(typeName, _contractMode), @@ -212,7 +212,7 @@ private void AddAdapterModel(ITypeSymbol type, string typeName, AdapterRegistrat null, GetTypeName(adapter.AdapterType), adapter.AdapterId, - adapter.WireFormatId, + string.Empty, GetAssemblyDependencies([type]), type.Locations.FirstOrDefault()); } From b78a085d862bb1e29d328e21ec46ed5f2e425f1a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:44:46 +0800 Subject: [PATCH 030/399] refactor(codec): remove adapter wire identity registration --- .../Sdk/RpcCodecAdapterRegistrationAttribute.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Abstractions/Sdk/RpcCodecAdapterRegistrationAttribute.cs b/src/SharpLink.Abstractions/Sdk/RpcCodecAdapterRegistrationAttribute.cs index a25c0ebb6..99e89e366 100644 --- a/src/SharpLink.Abstractions/Sdk/RpcCodecAdapterRegistrationAttribute.cs +++ b/src/SharpLink.Abstractions/Sdk/RpcCodecAdapterRegistrationAttribute.cs @@ -1,15 +1,14 @@ namespace SharpLink.Sdk; -/// Declares the compile-time identity of a serializer Codec adapter. +/// Declares one serializer Codec adapter for source-generated selection and lifecycle ownership. [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] public sealed class RpcCodecAdapterRegistrationAttribute : Attribute { /// Creates an adapter registration. - public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId, string wireFormatId) + public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId) { AdapterType = adapterType ?? throw new ArgumentNullException(nameof(adapterType)); AdapterId = adapterId ?? throw new ArgumentNullException(nameof(adapterId)); - WireFormatId = wireFormatId ?? throw new ArgumentNullException(nameof(wireFormatId)); } /// Gets the public adapter implementation type. @@ -18,9 +17,6 @@ public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId, /// Gets the implementation and lifecycle identity. public string AdapterId { get; } - /// Gets the stable binary wire-format identity. - public string WireFormatId { get; } - /// Gets or initializes the serializer attribute that selects this adapter. public Type? SelectorAttributeType { get; init; } } From 52f2ed5a661b5fc4b38c71c7189d5e57f8f970e8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:44:56 +0800 Subject: [PATCH 031/399] refactor(codec): remove legacy custom codec identity attribute --- .../Sdk/RpcCodecImplementationAttribute.cs | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/SharpLink.Abstractions/Sdk/RpcCodecImplementationAttribute.cs diff --git a/src/SharpLink.Abstractions/Sdk/RpcCodecImplementationAttribute.cs b/src/SharpLink.Abstractions/Sdk/RpcCodecImplementationAttribute.cs deleted file mode 100644 index 3ad7fa9a4..000000000 --- a/src/SharpLink.Abstractions/Sdk/RpcCodecImplementationAttribute.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SharpLink.Sdk; - -/// Declares the stable wire-format and schema identity of a hand-written RPC Codec implementation. -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] -public sealed class RpcCodecImplementationAttribute : Attribute -{ - /// Creates a custom Codec implementation identity. - public RpcCodecImplementationAttribute(string wireFormatId, string schemaId) - { - WireFormatId = wireFormatId ?? throw new ArgumentNullException(nameof(wireFormatId)); - SchemaId = schemaId ?? throw new ArgumentNullException(nameof(schemaId)); - } - - /// Gets the stable binary wire-format identity. - public string WireFormatId { get; } - - /// Gets the deterministic payload schema identity. - public string SchemaId { get; } -} From 34a9befb23ec066de1c2bbed38b25e5040fd966d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:45:29 +0800 Subject: [PATCH 032/399] test(generator): remove legacy codec identity usage --- test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index b29000514..e931d334a 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -68,7 +68,6 @@ public sealed class OpaquePayload public int Value { get; set; } } -[RpcCodecImplementation("opaque-test-wire/v1", "opaque-test-schema/v1")] [RpcCodecSemanticIdentity({{semanticHigh}}UL, {{semanticLow}}UL)] public sealed class OpaquePayloadCodec : IRpcCodec { From f6b2b9afc187adcbcbc58a7340f1ea039944e159 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:07:25 +0800 Subject: [PATCH 033/399] fix: remove legacy SharpPack wire identity registration --- src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs b/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs index 893091ef6..099fafdcd 100644 --- a/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs +++ b/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs @@ -9,7 +9,6 @@ [assembly: RpcCodecAdapterRegistration( typeof(SharpLink.Serializer.SharpPack.SharpPackRpcCodecAdapter), SharpLink.Serializer.SharpPack.SharpPackRpcCodecAdapter.AdapterIdentity, - SharpLink.Serializer.SharpPack.SharpPackRpcCodecAdapter.WireFormatIdentity, SelectorAttributeType = typeof(SharpPackableAttribute))] namespace SharpLink.Serializer.SharpPack; @@ -36,15 +35,9 @@ public sealed class SharpPackRpcCodecAdapter : IRpcCodecAdapter /// The stable Adapter implementation identity. public const string AdapterIdentity = "sharplink.serializer.sharppack/v1"; - /// The MemoryPack-compatible wire-format identity. - public const string WireFormatIdentity = "memorypack-binary/v1"; - /// public string AdapterId => AdapterIdentity; - /// - public string WireFormatId => WireFormatIdentity; - /// public IRpcCodecAdapterScope CreateScope() => new SharpPackRpcCodecAdapterScope(); } @@ -177,4 +170,4 @@ internal readonly struct SharpPackBufferWriter(IBufferWriter writer) : IBu public void Advance(int count) => writer.Advance(count); public Memory GetMemory(int sizeHint = 0) => writer.GetMemory(sizeHint); public Span GetSpan(int sizeHint = 0) => writer.GetSpan(sizeHint); -} +} \ No newline at end of file From a2f647a877b5ced315275e75b7b28674ba2e1fc2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:09:11 +0800 Subject: [PATCH 034/399] fix: validate canonical custom codecs by semantic identity --- .../RpcGenerator.CodecPolicyOwnership.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index fa493449f..eb7ae1c15 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -394,21 +394,14 @@ private void AddCanonicalCustomCodecBinding(ITypeSymbol target, ITypeSymbol code return null; } - var codecIdentity = named.GetAttributes().FirstOrDefault(static attribute => - IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecImplementationAttribute")); - if (codecIdentity is null || - codecIdentity.ConstructorArguments.Length != 2 || - codecIdentity.ConstructorArguments[0].Value is not string wireFormatId || - codecIdentity.ConstructorArguments[1].Value is not string schemaId || - !IsStableIdentity(wireFormatId) || - !IsStableIdentity(schemaId)) + if (!HasValidOpaqueSemanticIdentity(named)) { Report(DtoDiagnosticKind.CustomCodecIdentityInvalid, codecType, - "custom Codec must declare stable ASCII WireFormatId and SchemaId via [RpcCodecImplementation]", location); + "custom Codec must declare a non-zero fixed semantic identity via [RpcCodecSemanticIdentity(high, low)]", location); return null; } - return new CustomCodecRegistration(named, wireFormatId, schemaId, location); + return new CustomCodecRegistration(named, location); } private void CollectCanonicalAssemblyBindings() From 0100964a0347627fa5ea1185b2281cca455dda34 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:09:34 +0800 Subject: [PATCH 035/399] refactor: remove legacy generated codec identity strings --- src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs b/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs index 3df993274..591df14cc 100644 --- a/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs +++ b/src/SharpLink.Abstractions/RpcGeneratedCodecRegistry.cs @@ -9,12 +9,6 @@ public interface IRpcGeneratedCodecFactory /// Gets the deterministic identity of the finalized Codec semantics. RpcHash128 CodecHash => default; - /// Gets the legacy deterministic schema identifier. - string SchemaId { get; } - - /// Gets the legacy binary wire-format identity. - string WireFormatId { get; } - /// Gets the adapter lifecycle identity, or null for adapter-free Codecs. string? AdapterId { get; } From bbccc564fd504d804e140db491aec8366bc9dd76 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:10:15 +0800 Subject: [PATCH 036/399] refactor: use CodecHash in generated manifest identity --- src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index 2bd7fea90..64c9c407c 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -137,8 +137,6 @@ private static void AppendIdentifiedCodecFactory(StringBuilder sb) sb.AppendLine(); sb.AppendLine(" public Type TargetType => __inner.TargetType;"); sb.AppendLine(" public RpcHash128 CodecHash { get; }"); - sb.AppendLine(" public string SchemaId => __inner.SchemaId;"); - sb.AppendLine(" public string WireFormatId => __inner.WireFormatId;"); sb.AppendLine(" public string? AdapterId => __inner.AdapterId;"); sb.AppendLine(" public IRpcCodecAdapter? Adapter => __inner.Adapter;"); sb.AppendLine(" public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope)"); @@ -241,9 +239,9 @@ private static string BuildCompileTimeDescriptor( foreach (var service in services) sb.Append("S:").Append(service.Interface.Hash).Append(':').Append(service.ServiceFullName).Append(':').Append(service.Lifetime).Append(';'); foreach (var codec in codecs.OrderBy(static codec => codec.TypeName, StringComparer.Ordinal)) - sb.Append("D:").Append(codec.TypeName).Append(':').Append(codec.SchemaId).Append(';'); + sb.Append("D:").Append(codec.TypeName).Append(':').Append(new RpcHashValue(codec.CodecHashHigh, codec.CodecHashLow).ToHex()).Append(';'); foreach (var codec in contractCodecs.OrderBy(static codec => codec.TypeName, StringComparer.Ordinal)) - sb.Append("K:").Append(codec.TypeName).Append(':').Append(codec.SchemaId).Append(';'); + sb.Append("K:").Append(codec.TypeName).Append(':').Append(new RpcHashValue(codec.CodecHashHigh, codec.CodecHashLow).ToHex()).Append(';'); return sb.ToString(); } From 1f0b33b1a1d42b80251308d53d3037bbe3789f61 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:10:51 +0800 Subject: [PATCH 037/399] refactor: compare generated codecs by CodecHash only --- src/SharpLink.Runtime/SharpLinkRuntimeContext.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs index fe4ea19b9..042ac0ba0 100644 --- a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs +++ b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs @@ -59,17 +59,10 @@ internal SharpLinkRuntimeContext( private static bool HasSameGeneratedCodecIdentity( IRpcGeneratedCodecFactory left, IRpcGeneratedCodecFactory right) - { - if (!left.CodecHash.IsEmpty || !right.CodecHash.IsEmpty) - return left.CodecHash == right.CodecHash; - return string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) && - string.Equals(left.WireFormatId, right.WireFormatId, StringComparison.Ordinal); - } + => left.CodecHash == right.CodecHash; private static string DescribeGeneratedCodecIdentity(IRpcGeneratedCodecFactory factory) - => factory.CodecHash.IsEmpty - ? $"legacy:{factory.SchemaId}/{factory.WireFormatId}" - : $"codec:{factory.CodecHash}"; + => $"codec:{factory.CodecHash}"; [System.Diagnostics.CodeAnalysis.DoesNotReturn] [MethodImpl(MethodImplOptions.NoInlining)] From d8427e223be045a4c2f031f752252df648dbf48a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:13:02 +0800 Subject: [PATCH 038/399] refactor: validate dynamic manifest codecs by CodecHash --- src/SharpLink.Runtime/SharpLinkDynamicModule.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Runtime/SharpLinkDynamicModule.cs b/src/SharpLink.Runtime/SharpLinkDynamicModule.cs index 0cc92212e..9c4d72c33 100644 --- a/src/SharpLink.Runtime/SharpLinkDynamicModule.cs +++ b/src/SharpLink.Runtime/SharpLinkDynamicModule.cs @@ -316,7 +316,7 @@ not SharpLinkServiceLifetime.Connection and for (var codecIndex = 0; codecIndex < manifest.Codecs.Count; codecIndex++) { var codec = manifest.Codecs[codecIndex]; - if (codec is null || codec.TargetType is null || string.IsNullOrWhiteSpace(codec.SchemaId)) + if (codec is null || codec.TargetType is null || codec.CodecHash.IsEmpty) { return Error( SharpLinkAssemblyRegistrationErrorCode.InvalidManifest, @@ -331,7 +331,7 @@ not SharpLinkServiceLifetime.Connection and $"Manifest contains more than one Codec for '{codec.TargetType.FullName}'.", assembly, "Codec", - incomingFingerprint: codec.SchemaId); + incomingFingerprint: codec.CodecHash.ToString()); } } From 0d2e114acc931b6eca681e8c0507fccafb00d6a8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:13:44 +0800 Subject: [PATCH 039/399] refactor: validate manifest codec identity by CodecHash --- .../SharpLinkGeneratedManifestCompatibility.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Runtime/SharpLinkGeneratedManifestCompatibility.cs b/src/SharpLink.Runtime/SharpLinkGeneratedManifestCompatibility.cs index c03ee7826..dee6e8dec 100644 --- a/src/SharpLink.Runtime/SharpLinkGeneratedManifestCompatibility.cs +++ b/src/SharpLink.Runtime/SharpLinkGeneratedManifestCompatibility.cs @@ -271,7 +271,7 @@ not SharpLinkServiceLifetime.Connection and for (var codecIndex = 0; codecIndex < manifest.Codecs.Count; codecIndex++) { var codec = manifest.Codecs[codecIndex]; - if (codec is null || codec.TargetType is null || string.IsNullOrWhiteSpace(codec.SchemaId)) + if (codec is null || codec.TargetType is null || codec.CodecHash.IsEmpty) { return Error( SharpLinkAssemblyRegistrationErrorCode.InvalidManifest, @@ -286,7 +286,7 @@ not SharpLinkServiceLifetime.Connection and $"Manifest contains more than one Codec for '{codec.TargetType.FullName}'.", diagnosticAssembly, "Codec", - incomingFingerprint: codec.SchemaId); + incomingFingerprint: codec.CodecHash.ToString()); } } From 4956d78c9a81035eb0238cbd13c9264a0de5ee14 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:14:41 +0800 Subject: [PATCH 040/399] test: cover deterministic identity propagation --- .../RpcDeterministicIdentityTests.cs | 109 +++++++++++++++++- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index e931d334a..2dd15b562 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -6,6 +6,57 @@ namespace SharpLink.Generator.Tests; public partial class RpcAnalyzerTests { + [Test] + public Task DeterministicIdentityShouldBeStableAcrossRepeatedGeneration() + { + var first = GenerateDtoIdentityManifest(includeExtraMember: false, idempotent: false); + var second = GenerateDtoIdentityManifest(includeExtraMember: false, idempotent: false); + + Ensure( + ExtractGeneratedCodecIdentity(first, "DeterministicPayload") == + ExtractGeneratedCodecIdentity(second, "DeterministicPayload"), + "unchanged RPC semantics must produce the same CodecHash across repeated generation"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) == ExtractGeneratedRpcAssemblyHash(second), + "unchanged RPC semantics must produce the same RpcAssemblyHash across repeated generation"); + return Task.CompletedTask; + } + + [Test] + public Task DtoWireShapeChangeShouldChangeFinalRpcIdentity() + { + var first = GenerateDtoIdentityManifest(includeExtraMember: false, idempotent: false); + var second = GenerateDtoIdentityManifest(includeExtraMember: true, idempotent: false); + + Ensure( + ExtractGeneratedCodecIdentity(first, "DeterministicPayload") != + ExtractGeneratedCodecIdentity(second, "DeterministicPayload"), + "changing generated DTO wire shape must change CodecHash"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second), + "changing a reachable DTO CodecHash must change RpcAssemblyHash"); + return Task.CompletedTask; + } + + [Test] + public Task MethodSemanticChangeShouldNotReuseRouteIdentityAsCompatibilityIdentity() + { + var first = GenerateDtoIdentityManifest(includeExtraMember: false, idempotent: false); + var second = GenerateDtoIdentityManifest(includeExtraMember: false, idempotent: true); + + Ensure( + ExtractGeneratedCodecIdentity(first, "DeterministicPayload") == + ExtractGeneratedCodecIdentity(second, "DeterministicPayload"), + "method-only semantics must not perturb payload CodecHash"); + Ensure( + ExtractGeneratedMethodId(first, "Echo") == ExtractGeneratedMethodId(second, "Echo"), + "a method semantic flag must not be encoded by changing the dispatch MethodId"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second), + "method semantic changes must flow through MethodHash/ContractHash into RpcAssemblyHash"); + return Task.CompletedTask; + } + [Test] public Task OpaqueSemanticIdentityShouldIgnoreUnrelatedImplementationChanges() { @@ -19,7 +70,8 @@ public Task OpaqueSemanticIdentityShouldIgnoreUnrelatedImplementationChanges() semanticLow: 0x1112131415161718UL); Ensure( - ExtractGeneratedCodecIdentity(first) == ExtractGeneratedCodecIdentity(second), + ExtractGeneratedCodecIdentity(first, "OpaquePayload") == + ExtractGeneratedCodecIdentity(second, "OpaquePayload"), "opaque CodecHash must be controlled by its fixed semantic identity rather than unrelated implementation details"); Ensure( ExtractGeneratedRpcAssemblyHash(first) == ExtractGeneratedRpcAssemblyHash(second), @@ -40,7 +92,8 @@ public Task OpaqueSemanticIdentityChangeShouldChangeFinalRpcIdentity() semanticLow: 0x2112131415161718UL); Ensure( - ExtractGeneratedCodecIdentity(first) != ExtractGeneratedCodecIdentity(second), + ExtractGeneratedCodecIdentity(first, "OpaquePayload") != + ExtractGeneratedCodecIdentity(second, "OpaquePayload"), "changing opaque serializer semantics must change CodecHash"); Ensure( ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second), @@ -48,6 +101,37 @@ public Task OpaqueSemanticIdentityChangeShouldChangeFinalRpcIdentity() return Task.CompletedTask; } + private static string GenerateDtoIdentityManifest(bool includeExtraMember, bool idempotent) + { + var extraMember = includeExtraMember + ? "public long Extra { get; set; }" + : string.Empty; + var methodAttribute = idempotent ? "[Idempotent]" : string.Empty; + var source = $$""" +using System.Threading; +using System.Threading.Tasks; +using SharpLink.Sdk; + +[RpcSerializable] +public sealed class DeterministicPayload +{ + public int Value { get; set; } + {{extraMember}} +} + +[RpcContract] +public interface IDeterministicIdentityContract : IService +{ + {{methodAttribute}} + ValueTask Echo(DeterministicPayload value, CancellationToken cancellationToken); +} +"""; + + return RunGeneratorAndGetSources(source) + .Single(static generated => + generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + } + private static string GenerateOpaqueIdentityManifest( string implementationMarker, ulong semanticHigh, @@ -89,14 +173,29 @@ public interface IOpaqueIdentityContract : IService generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); } - private static string ExtractGeneratedCodecIdentity(string manifest) + private static string ExtractGeneratedCodecIdentity(string manifest, string typeName) => manifest.Split('\n') - .Single(static line => + .Single(line => line.Contains( - "SharpLinkGeneratedCodecIdentityAttribute(typeof(global::OpaquePayload)", + $"SharpLinkGeneratedCodecIdentityAttribute(typeof(global::{typeName})", StringComparison.Ordinal)) .Trim(); + private static string ExtractGeneratedMethodId(string manifest, string methodName) + { + var lines = manifest.Split('\n'); + for (var index = 0; index + 2 < lines.Length; index++) + { + if (lines[index].Contains("new SharpLinkGeneratedMethodDescriptor(", StringComparison.Ordinal) && + lines[index + 1].Contains($"\"{methodName}\"", StringComparison.Ordinal)) + { + return lines[index + 2].Trim(); + } + } + + throw new InvalidOperationException($"Generated method descriptor '{methodName}' was not found."); + } + private static string ExtractGeneratedRpcAssemblyHash(string manifest) => manifest.Split('\n') .Single(static line => line.Contains("public RpcHash128 RpcAssemblyHash =>", StringComparison.Ordinal)) From eed9e830647b366d0ca803a96ce3c5e8b006965a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:19:05 +0800 Subject: [PATCH 041/399] refactor: compare client dynamic codecs by CodecHash --- .../SharpLinkClient.AssemblyRegistration.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs index dce5fb7fe..b5163b1ab 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs @@ -399,12 +399,15 @@ private RegistrationCandidate BuildRegistrationCandidate( var codec = pair.Value; if (nextFactories.TryGetValue(pair.Key, out var existingCodec)) { - if (!string.Equals(existingCodec.Factory.SchemaId, codec.Factory.SchemaId, StringComparison.Ordinal) || - !string.Equals(existingCodec.Factory.WireFormatId, codec.Factory.WireFormatId, StringComparison.Ordinal)) + if (existingCodec.Factory.CodecHash != codec.Factory.CodecHash) { - error = CreateError(SharpLinkAssemblyRegistrationErrorCode.CodecConflict, - $"Codec conflict for '{pair.Key.FullName}': existing schema/wire '{existingCodec.Factory.SchemaId}'/'{existingCodec.Factory.WireFormatId}', incoming schema/wire '{codec.Factory.SchemaId}'/'{codec.Factory.WireFormatId}'.", - incoming.OwnerAssembly, "Codec", existingCodec.Factory.SchemaId, codec.Factory.SchemaId); + error = CreateError( + SharpLinkAssemblyRegistrationErrorCode.CodecConflict, + $"Codec conflict for '{pair.Key.FullName}': existing CodecHash '{existingCodec.Factory.CodecHash}', incoming CodecHash '{codec.Factory.CodecHash}'.", + incoming.OwnerAssembly, + "Codec", + existingCodec.Factory.CodecHash.ToString(), + codec.Factory.CodecHash.ToString()); return default; } continue; From 87e66f0831983ebb8f079af1abbd449bdfdac880 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:20:06 +0800 Subject: [PATCH 042/399] refactor: compare server dynamic codecs by CodecHash --- .../SharpLinkServer.AssemblyRegistration.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs index ebc8ffa0b..10e78099a 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs @@ -385,16 +385,15 @@ private RegistrationCandidate BuildRegistrationCandidate( var codec = pair.Value; if (nextFactories.TryGetValue(pair.Key, out var existingCodec)) { - if (!string.Equals(existingCodec.Factory.SchemaId, codec.Factory.SchemaId, StringComparison.Ordinal) || - !string.Equals(existingCodec.Factory.WireFormatId, codec.Factory.WireFormatId, StringComparison.Ordinal)) + if (existingCodec.Factory.CodecHash != codec.Factory.CodecHash) { error = CreateError( SharpLinkAssemblyRegistrationErrorCode.CodecConflict, - $"Codec conflict for '{pair.Key.FullName}': existing schema/wire '{existingCodec.Factory.SchemaId}'/'{existingCodec.Factory.WireFormatId}', incoming schema/wire '{codec.Factory.SchemaId}'/'{codec.Factory.WireFormatId}'.", + $"Codec conflict for '{pair.Key.FullName}': existing CodecHash '{existingCodec.Factory.CodecHash}', incoming CodecHash '{codec.Factory.CodecHash}'.", incoming.OwnerAssembly, artifact: "Codec", - existingFingerprint: existingCodec.Factory.SchemaId, - incomingFingerprint: codec.Factory.SchemaId); + existingFingerprint: existingCodec.Factory.CodecHash.ToString(), + incomingFingerprint: codec.Factory.CodecHash.ToString()); return default; } continue; From d0b8e8f9600a5cab41306aee33e3a5c8ed96e457 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:22:59 +0800 Subject: [PATCH 043/399] style: restore final newline in SharpPack codec --- src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs b/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs index 099fafdcd..b8d12dae7 100644 --- a/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs +++ b/src/SharpLink.Serializer.SharpPack/SharpPackRpcCodec.cs @@ -170,4 +170,4 @@ internal readonly struct SharpPackBufferWriter(IBufferWriter writer) : IBu public void Advance(int count) => writer.Advance(count); public Memory GetMemory(int sizeHint = 0) => writer.GetMemory(sizeHint); public Span GetSpan(int sizeHint = 0) => writer.GetSpan(sizeHint); -} \ No newline at end of file +} From 072596e2df93256c5de36d4a54a1837a8bb8bab9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:35:06 +0800 Subject: [PATCH 044/399] test: migrate malformed codec semantic identity --- test/SharpLink.IntegrationTests/AssemblyCodecBindings.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/SharpLink.IntegrationTests/AssemblyCodecBindings.cs b/test/SharpLink.IntegrationTests/AssemblyCodecBindings.cs index 1dec4f19a..8e1e7b28f 100644 --- a/test/SharpLink.IntegrationTests/AssemblyCodecBindings.cs +++ b/test/SharpLink.IntegrationTests/AssemblyCodecBindings.cs @@ -8,9 +8,7 @@ namespace SharpLink.IntegrationTests; public readonly record struct MalformedHeader(int Value); -[RpcCodecImplementation( - "sharplink-integration-malformed-header/v1", - "sharplink-integration-malformed-header-schema/v1")] +[RpcCodecSemanticIdentity(0x4b166fb4cfa21e94UL, 0x915bac210ac312dbUL)] public sealed class MalformedHeaderCodec : IRpcCodec { public void Serialize(in MalformedHeader value, IBufferWriter buffer) From a3eb41eaa7f3d14122dfc22684c1b8a448c0f72e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:35:29 +0800 Subject: [PATCH 045/399] test: migrate pre-credit adapter identity --- test/SharpLink.PreCreditAotSmoke/Program.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.PreCreditAotSmoke/Program.cs b/test/SharpLink.PreCreditAotSmoke/Program.cs index 682f7fb49..92e6932ad 100644 --- a/test/SharpLink.PreCreditAotSmoke/Program.cs +++ b/test/SharpLink.PreCreditAotSmoke/Program.cs @@ -14,8 +14,7 @@ [assembly: RpcCodecAdapterRegistration( typeof(SharpLink.PreCreditAotSmoke.PreCreditPayloadCodecAdapter), - "sharplink.precredit-aot.unsized", - "sharplink.precredit-aot.unsized.v1")] + "sharplink.precredit-aot.unsized")] namespace SharpLink.PreCreditAotSmoke; @@ -191,12 +190,11 @@ public async IAsyncEnumerable StreamAsync(int count) [RpcCodecAdapter(typeof(PreCreditPayloadCodecAdapter))] public readonly record struct PreCreditPayload(int Sequence); +[RpcCodecSemanticIdentity(0x5937fbbdf810875fUL, 0xea08c7aeef8cbe0fUL)] public sealed class PreCreditPayloadCodecAdapter : IRpcCodecAdapter { public string AdapterId => "sharplink.precredit-aot.unsized"; - public string WireFormatId => "sharplink.precredit-aot.unsized.v1"; - public IRpcCodecAdapterScope CreateScope() => new Scope(); private sealed class Scope : IRpcCodecAdapterScope From 67265799bb085d58a38fa4b33a2aec5f37610d08 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:37:29 +0800 Subject: [PATCH 046/399] fix: skip invalid codec bindings during hash finalization --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 7b9cecd91..a174d467c 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -25,6 +25,7 @@ internal ImmutableArray BuildFinalCodecHashes( var cache = new Dictionary(StringComparer.Ordinal); return reachable + .Where(pair => !_failed.Contains(pair.Key)) .OrderBy(static pair => pair.Key, StringComparer.Ordinal) .Select(pair => { From 4479b32b6f510371f0876a75302198576bf5d466 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:47:39 +0800 Subject: [PATCH 047/399] test: add deterministic synthetic manifest identities --- .../TestGeneratedIdentity.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test/SharpLink.UnitTests/TestGeneratedIdentity.cs diff --git a/test/SharpLink.UnitTests/TestGeneratedIdentity.cs b/test/SharpLink.UnitTests/TestGeneratedIdentity.cs new file mode 100644 index 000000000..b3d0f6f1a --- /dev/null +++ b/test/SharpLink.UnitTests/TestGeneratedIdentity.cs @@ -0,0 +1,25 @@ +namespace SharpLink.UnitTests; + +internal static class TestGeneratedIdentity +{ + internal static readonly RpcHash128 ManifestHash = + new(0x746573742d6d616eUL, 0x69666573742d7631UL); + + internal static readonly RpcHash128 CodecHash = + new(0x746573742d636f64UL, 0x65632d6861736831UL); + + internal static readonly RpcHash128 AlternateCodecHash = + new(0x746573742d636f64UL, 0x65632d6861736832UL); +} + +internal interface ITestGeneratedManifest : ISharpLinkGeneratedAssemblyManifest +{ + RpcHash128 ISharpLinkGeneratedAssemblyManifest.RpcAssemblyHash + => TestGeneratedIdentity.ManifestHash; +} + +internal interface ITestGeneratedCodecFactory : IRpcGeneratedCodecFactory +{ + RpcHash128 IRpcGeneratedCodecFactory.CodecHash + => TestGeneratedIdentity.CodecHash; +} From 71012918645d63121b9f9657005a5b999b281887 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:48:05 +0800 Subject: [PATCH 048/399] test: give dynamic module manifests semantic identity --- test/SharpLink.UnitTests/Runtime/DynamicModuleTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Runtime/DynamicModuleTests.cs b/test/SharpLink.UnitTests/Runtime/DynamicModuleTests.cs index c37ecbb04..ebac839a6 100644 --- a/test/SharpLink.UnitTests/Runtime/DynamicModuleTests.cs +++ b/test/SharpLink.UnitTests/Runtime/DynamicModuleTests.cs @@ -162,7 +162,7 @@ private static void Ensure(bool condition, string message) throw new Exception(message); } - private sealed class EmptyManifest : ISharpLinkGeneratedAssemblyManifest + private sealed class EmptyManifest : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; From 6048c7aa3bda1f285bbcb5aac61049b0ecaa0b11 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:48:34 +0800 Subject: [PATCH 049/399] test: migrate routed codec identity fixtures --- .../Runtime/RpcCodecRouteRuntimeTests.cs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcCodecRouteRuntimeTests.cs b/test/SharpLink.UnitTests/Runtime/RpcCodecRouteRuntimeTests.cs index 1a8daaf4c..d1819a42d 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcCodecRouteRuntimeTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcCodecRouteRuntimeTests.cs @@ -20,7 +20,7 @@ public void ContractRoutesShouldCoexistWithoutChangingGlobalBuiltin() using var context = new SharpLinkRuntimeContextBuilder() .Build(includeGeneratedAssemblyCatalog: false); var registrationA = context.PrepareGeneratedManifest( - new RoutedManifest(ownerA, routeA, "route-a/v1", "wire-a/v1")); + new RoutedManifest(ownerA, routeA, "route-a/v1")); var registrationB = context.PrepareGeneratedManifest(new DefaultManifest(ownerB)); Ensure(registrationA.Codecs.Count == 0, @@ -62,11 +62,9 @@ public void Serialize(in int value, IBufferWriter buffer) private sealed class RouteAdapter( RoutedInt32Codec codec, - string adapterId, - string wireFormatId) : IRpcCodecAdapter + string adapterId) : IRpcCodecAdapter { public string AdapterId { get; } = adapterId; - public string WireFormatId { get; } = wireFormatId; public IRpcCodecAdapterScope CreateScope() => new RouteScope(codec); } @@ -82,11 +80,9 @@ public void Dispose() } } - private sealed class RoutedInt32Factory(RouteAdapter adapter) : IRpcGeneratedCodecFactory + private sealed class RoutedInt32Factory(RouteAdapter adapter) : ITestGeneratedCodecFactory { public Type TargetType => typeof(int); - public string SchemaId => $"route-native-int32-{adapter.AdapterId}"; - public string WireFormatId => adapter.WireFormatId; public string? AdapterId => adapter.AdapterId; public IRpcCodecAdapter? Adapter => adapter; @@ -97,16 +93,15 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt public bool IsCompatibleCodec(IRpcCodec candidate) => candidate is IRpcCodec; } - private sealed class RoutedManifest : ISharpLinkGeneratedAssemblyManifest + private sealed class RoutedManifest : ITestGeneratedManifest { public RoutedManifest( Assembly ownerAssembly, RoutedInt32Codec codec, - string adapterId, - string wireFormatId) + string adapterId) { OwnerAssembly = ownerAssembly; - ContractCodecs = [new RoutedInt32Factory(new RouteAdapter(codec, adapterId, wireFormatId))]; + ContractCodecs = [new RoutedInt32Factory(new RouteAdapter(codec, adapterId))]; } public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; @@ -121,7 +116,7 @@ public RoutedManifest( public IReadOnlyList Dependencies => []; } - private sealed class DefaultManifest : ISharpLinkGeneratedAssemblyManifest + private sealed class DefaultManifest : ITestGeneratedManifest { public DefaultManifest(Assembly ownerAssembly) { From 6eb59f9b0cd7e158d048cb8760439cb29ec731ce Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:48:51 +0800 Subject: [PATCH 050/399] test: migrate custom codec policy identity fixture --- .../Runtime/RpcCodecPolicyRegressionTests.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcCodecPolicyRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcCodecPolicyRegressionTests.cs index a572feeb9..3c78f5b5d 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcCodecPolicyRegressionTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcCodecPolicyRegressionTests.cs @@ -11,7 +11,7 @@ namespace SharpLink.UnitTests.Runtime; public sealed class RpcCodecPolicyRegressionTests { [Test] - public void CustomFactoryWithCustomWireFormatShouldPrepareAndResolve() + public void CustomFactoryWithSemanticIdentityShouldPrepareAndResolve() { var manifest = new TestManifest( typeof(IContractA).Assembly, @@ -69,11 +69,9 @@ public void Serialize(in CustomPayload value, IBufferWriter buffer) { } public CustomPayload Deserialize(in ReadOnlySequence buffer) => new(); } - private sealed class CustomPayloadFactory : IRpcGeneratedCodecFactory + private sealed class CustomPayloadFactory : ITestGeneratedCodecFactory { public Type TargetType => typeof(CustomPayload); - public string SchemaId => "custom-payload-schema/v1"; - public string WireFormatId => "custom-payload-wire/v1"; public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -85,7 +83,7 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt private sealed class TestManifest( Assembly ownerAssembly, - IReadOnlyList contractCodecs) : ISharpLinkGeneratedAssemblyManifest + IReadOnlyList contractCodecs) : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; From 815190e44f04b07328c5163a978894a6b040f0f5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:49:08 +0800 Subject: [PATCH 051/399] test: give enum override manifest semantic identity --- .../Runtime/RpcEnumCodecOverrideRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcEnumCodecOverrideRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcEnumCodecOverrideRegressionTests.cs index ed0059d53..3ed222c4d 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcEnumCodecOverrideRegressionTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcEnumCodecOverrideRegressionTests.cs @@ -53,7 +53,7 @@ public void Serialize(in TestMode value, IBufferWriter writer) public TestMode Deserialize(in ReadOnlySequence buffer) => TestMode.Active; } - private sealed class AssemblyEnumManifest : ISharpLinkGeneratedAssemblyManifest + private sealed class AssemblyEnumManifest : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; From d11924b1794bc71195e042bb8575c94df2f40c1d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:50:28 +0800 Subject: [PATCH 052/399] test: migrate manifest codec provider identity fixtures --- .../Runtime/RpcManifestCodecProviderTests.cs | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs b/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs index ee80b82d5..71f4c6abb 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs @@ -175,12 +175,10 @@ public void Serialize(in List value, IBufferWriter buffer) public List? Deserialize(in ReadOnlySequence buffer) => []; } - private sealed class NativeFactory(Func> create, string schemaId) - : IRpcGeneratedCodecFactory + private sealed class NativeFactory(Func> create) + : ITestGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId { get; } = schemaId; - public string WireFormatId => "sharplink-native/v1"; public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; @@ -197,7 +195,6 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt private sealed class PointAdapter(RoutedPointCodec codec) : IRpcCodecAdapter { public string AdapterId => "nested-point-route/v1"; - public string WireFormatId => "nested-point-wire/v1"; public IRpcCodecAdapterScope CreateScope() => new PointScope(codec); } @@ -213,11 +210,9 @@ public void Dispose() } } - private sealed class RoutedPointFactory(PointAdapter adapter) : IRpcGeneratedCodecFactory + private sealed class RoutedPointFactory(PointAdapter adapter) : ITestGeneratedCodecFactory { public Type TargetType => typeof(Point); - public string SchemaId => "nested-point-route"; - public string WireFormatId => adapter.WireFormatId; public string? AdapterId => adapter.AdapterId; public IRpcCodecAdapter? Adapter => adapter; @@ -240,14 +235,14 @@ public void Serialize(in NoRouteValue value, IBufferWriter buffer) { } public NoRouteValue Deserialize(in ReadOnlySequence buffer) => default; } - private sealed class NoRouteManifest : ISharpLinkGeneratedAssemblyManifest + private sealed class NoRouteManifest : ITestGeneratedManifest { internal NoRouteManifest(Assembly ownerAssembly) { OwnerAssembly = ownerAssembly; Codecs = [ - new NativeFactory(static _ => new GeneratedNoRouteCodec(), "no-route-generated") + new NativeFactory(static _ => new GeneratedNoRouteCodec()) ]; } @@ -262,7 +257,7 @@ internal NoRouteManifest(Assembly ownerAssembly) public IReadOnlyList Dependencies => []; } - private sealed class PreviousApiManifest(Assembly ownerAssembly) : ISharpLinkGeneratedAssemblyManifest + private sealed class PreviousApiManifest(Assembly ownerAssembly) : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api - 1; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; @@ -282,19 +277,19 @@ public void Serialize(in ContractValue value, IBufferWriter buffer) { } public ContractValue Deserialize(in ReadOnlySequence buffer) => default; } - private sealed class ContractCodecManifest(Assembly ownerAssembly, NamedContractCodec codec, string schemaId) - : ISharpLinkGeneratedAssemblyManifest + private sealed class ContractCodecManifest(Assembly ownerAssembly, NamedContractCodec codec, string descriptor) + : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "contract-codec-test"; public Assembly OwnerAssembly { get; } = ownerAssembly; - public string CompileTimeDescriptor => schemaId; + public string CompileTimeDescriptor => descriptor; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; public IReadOnlyList Codecs => []; public IReadOnlyList ContractCodecs { get; } = - [new NativeFactory(_ => codec, schemaId)]; + [new NativeFactory(_ => codec)]; public IReadOnlyList Dependencies => []; } @@ -316,15 +311,15 @@ private sealed class ThrowingBufferPool : IRpcBufferWriterPool public void Return(IRpcByteBufferWriter writer) { } } - private sealed class NestedRouteManifest : ISharpLinkGeneratedAssemblyManifest + private sealed class NestedRouteManifest : ITestGeneratedManifest { internal NestedRouteManifest(Assembly ownerAssembly, RoutedPointCodec routedPoint) { OwnerAssembly = ownerAssembly; ContractCodecs = [ - new NativeFactory(static provider => new EnvelopeCodec(provider), "nested-envelope-native"), - new NativeFactory>(static provider => new PointListCodec(provider), "nested-list-native"), + new NativeFactory(static provider => new EnvelopeCodec(provider)), + new NativeFactory>(static provider => new PointListCodec(provider)), new RoutedPointFactory(new PointAdapter(routedPoint)) ]; } From 477e46457bc4fbcf58495d54674f886dd6afda44 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:51:41 +0800 Subject: [PATCH 053/399] test: migrate rollback plugin to codec hash identity --- .../RollbackManifest.cs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/test/SharpLink.RollbackPlugin/RollbackManifest.cs b/test/SharpLink.RollbackPlugin/RollbackManifest.cs index 0fdf3b797..a8571e161 100644 --- a/test/SharpLink.RollbackPlugin/RollbackManifest.cs +++ b/test/SharpLink.RollbackPlugin/RollbackManifest.cs @@ -29,6 +29,16 @@ public sealed class RollbackManifest : ISharpLinkGeneratedAssemblyManifest { public RollbackManifest() { + var identity = Environment.GetEnvironmentVariable("SHARPLINK_ROLLBACK_CODEC_IDENTITY") ?? "default"; + var codecHash = ComputeIdentityHash(identity); + RpcAssemblyHash = new RpcHash128(0x726f6c6c6261636bUL, codecHash.Low); + Codecs = string.Equals( + Environment.GetEnvironmentVariable("SHARPLINK_ROLLBACK_DISABLE_CODEC"), + "1", + StringComparison.Ordinal) + ? [] + : [new RollbackCodecFactory(codecHash)]; + var started = RollbackState.ManifestConstructionStarted; if (started is null) return; @@ -41,22 +51,32 @@ public RollbackManifest() public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "rollback-test"; public Assembly OwnerAssembly => typeof(RollbackManifest).Assembly; + public RpcHash128 RpcAssemblyHash { get; } public string CompileTimeDescriptor => "rollback-test"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; - public IReadOnlyList Codecs { get; } = - string.Equals(Environment.GetEnvironmentVariable("SHARPLINK_ROLLBACK_DISABLE_CODEC"), "1", StringComparison.Ordinal) - ? [] - : [new RollbackCodecFactory(Environment.GetEnvironmentVariable("SHARPLINK_ROLLBACK_SCHEMA") ?? "default")]; + public IReadOnlyList Codecs { get; } public IReadOnlyList ContractCodecs => []; public IReadOnlyList Dependencies => []; + + private static RpcHash128 ComputeIdentityHash(string value) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var low = offset; + foreach (var character in value) + { + low ^= character; + low *= prime; + } + return new RpcHash128(0x726f6c6c6261636bUL, low == 0 ? 1UL : low); + } } -internal sealed class RollbackCodecFactory(string schemaId) : IRpcGeneratedCodecFactory +internal sealed class RollbackCodecFactory(RpcHash128 codecHash) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(string); - public string SchemaId { get; } = schemaId; - public string WireFormatId => "rollback-wire/v1"; + public RpcHash128 CodecHash { get; } = codecHash; public string AdapterId => "rollback-adapter/v1"; public IRpcCodecAdapter Adapter { get; } = new RollbackAdapter(); @@ -69,7 +89,6 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt internal sealed class RollbackAdapter : IRpcCodecAdapter { public string AdapterId => "rollback-adapter/v1"; - public string WireFormatId => "rollback-wire/v1"; public IRpcCodecAdapterScope CreateScope() => new RollbackScope(); } From 88c43186d0bb69784ed79d5595f9841c7970c083 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:52:17 +0800 Subject: [PATCH 054/399] test: preserve rollback identity injection compatibility --- test/SharpLink.RollbackPlugin/RollbackManifest.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.RollbackPlugin/RollbackManifest.cs b/test/SharpLink.RollbackPlugin/RollbackManifest.cs index a8571e161..f5ba4d8ad 100644 --- a/test/SharpLink.RollbackPlugin/RollbackManifest.cs +++ b/test/SharpLink.RollbackPlugin/RollbackManifest.cs @@ -29,7 +29,9 @@ public sealed class RollbackManifest : ISharpLinkGeneratedAssemblyManifest { public RollbackManifest() { - var identity = Environment.GetEnvironmentVariable("SHARPLINK_ROLLBACK_CODEC_IDENTITY") ?? "default"; + var identity = Environment.GetEnvironmentVariable("SHARPLINK_ROLLBACK_CODEC_IDENTITY") ?? + Environment.GetEnvironmentVariable("SHARPLINK_ROLLBACK_SCHEMA") ?? + "default"; var codecHash = ComputeIdentityHash(identity); RpcAssemblyHash = new RpcHash128(0x726f6c6c6261636bUL, codecHash.Low); Codecs = string.Equals( From 461f3fb74450dfc00e6c88331783dc34dce19265 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:52:51 +0800 Subject: [PATCH 055/399] test: migrate codec ownership identity fixtures --- ...pcManifestCodecOwnershipRegressionTests.cs | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcManifestCodecOwnershipRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcManifestCodecOwnershipRegressionTests.cs index 5771c6768..be7078005 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcManifestCodecOwnershipRegressionTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcManifestCodecOwnershipRegressionTests.cs @@ -149,7 +149,6 @@ public SharedValue Deserialize(in ReadOnlySequence buffer) private sealed class SharedAdapter(SharedScopeState state) : IRpcCodecAdapter { public string AdapterId => "shared-owner-lifetime/v1"; - public string WireFormatId => "shared-owner-wire/v1"; public IRpcCodecAdapterScope CreateScope() => new SharedAdapterScope(state); } @@ -163,12 +162,10 @@ public IRpcCodec CreateCodec() public void Dispose() => state.Disposed = true; } - private sealed class NativeFactory(Func> create, string schemaId) - : IRpcGeneratedCodecFactory + private sealed class NativeFactory(Func> create) + : ITestGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId { get; } = schemaId; - public string WireFormatId => "sharplink-native/v1"; public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; @@ -182,11 +179,9 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt public bool IsCompatibleCodec(IRpcCodec codec) => codec is IRpcCodec; } - private sealed class SharedAdapterFactory(SharedAdapter adapter) : IRpcGeneratedCodecFactory + private sealed class SharedAdapterFactory(SharedAdapter adapter) : ITestGeneratedCodecFactory { public Type TargetType => typeof(SharedValue); - public string SchemaId => "shared-owner-schema/v1"; - public string WireFormatId => adapter.WireFormatId; public string? AdapterId => adapter.AdapterId; public IRpcCodecAdapter? Adapter => adapter; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -195,7 +190,7 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt } private sealed class PolicyManifest(Assembly ownerAssembly, PolicyPointCodec codec) - : ISharpLinkGeneratedAssemblyManifest + : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; @@ -206,12 +201,12 @@ private sealed class PolicyManifest(Assembly ownerAssembly, PolicyPointCodec cod public IReadOnlyList Services => []; public IReadOnlyList Codecs => []; public IReadOnlyList ContractCodecs { get; } = - [new NativeFactory(_ => codec, "policy-point/v1")]; + [new NativeFactory(_ => codec)]; public IReadOnlyList Dependencies => []; } private sealed class SharedAdapterManifest(Assembly ownerAssembly, SharedAdapter adapter) - : ISharpLinkGeneratedAssemblyManifest + : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; @@ -226,7 +221,7 @@ private sealed class SharedAdapterManifest(Assembly ownerAssembly, SharedAdapter } private sealed class IncomingGeneratedManifest(Assembly ownerAssembly) - : ISharpLinkGeneratedAssemblyManifest + : ITestGeneratedManifest { public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; @@ -236,7 +231,7 @@ private sealed class IncomingGeneratedManifest(Assembly ownerAssembly) public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; public IReadOnlyList Codecs { get; } = - [new NativeFactory(static _ => new IncomingValueCodec(), "incoming-generated/v1")]; + [new NativeFactory(static _ => new IncomingValueCodec())]; public IReadOnlyList ContractCodecs => []; public IReadOnlyList Dependencies => []; } From 99b977384c4a540d1d0676f03b581577edc76fdc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:02:23 +0800 Subject: [PATCH 056/399] test: default synthetic generated identities --- .../TestGeneratedIdentityDefaults.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs diff --git a/test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs b/test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs new file mode 100644 index 000000000..bc7f7fd56 --- /dev/null +++ b/test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs @@ -0,0 +1,13 @@ +namespace SharpLink.UnitTests; + +internal interface ISharpLinkGeneratedAssemblyManifest : SharpLink.Abstractions.ISharpLinkGeneratedAssemblyManifest +{ + RpcHash128 SharpLink.Abstractions.ISharpLinkGeneratedAssemblyManifest.RpcAssemblyHash + => new(0x746573742d6d616eUL, 0x69666573742d7631UL); +} + +internal interface IRpcGeneratedCodecFactory : SharpLink.Abstractions.IRpcGeneratedCodecFactory +{ + RpcHash128 SharpLink.Abstractions.IRpcGeneratedCodecFactory.CodecHash + => new(0x746573742d636f64UL, 0x65632d6861736831UL); +} From b5728dbb7c33492bc93561102840aaf241867cb7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:04:29 +0800 Subject: [PATCH 057/399] revert: avoid shadowing generated interfaces --- .../TestGeneratedIdentityDefaults.cs | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs diff --git a/test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs b/test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs deleted file mode 100644 index bc7f7fd56..000000000 --- a/test/SharpLink.UnitTests/TestGeneratedIdentityDefaults.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SharpLink.UnitTests; - -internal interface ISharpLinkGeneratedAssemblyManifest : SharpLink.Abstractions.ISharpLinkGeneratedAssemblyManifest -{ - RpcHash128 SharpLink.Abstractions.ISharpLinkGeneratedAssemblyManifest.RpcAssemblyHash - => new(0x746573742d6d616eUL, 0x69666573742d7631UL); -} - -internal interface IRpcGeneratedCodecFactory : SharpLink.Abstractions.IRpcGeneratedCodecFactory -{ - RpcHash128 SharpLink.Abstractions.IRpcGeneratedCodecFactory.CodecHash - => new(0x746573742d636f64UL, 0x65632d6861736831UL); -} From 9b5d37415abd42e2ea298e516319a1cd6546bace Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:06:41 +0800 Subject: [PATCH 058/399] test: migrate dependency manifest identity --- .../Client/RpcCodecRouteMultiClusterTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs b/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs index 06e3fbc3a..75491cb9d 100644 --- a/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs +++ b/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs @@ -20,6 +20,8 @@ public Task DependencyManifestViewShouldHideContractPolicy() .Single(); var view = (ISharpLinkGeneratedAssemblyManifest)constructor.Invoke([source]); + Ensure(view.RpcAssemblyHash == source.RpcAssemblyHash, + "the dependency view must preserve the source assembly semantic identity"); Ensure(view.Codecs.Count == 0, "the dependency view must not republish a Contract-owned Codec globally"); Ensure(view.ContractCodecs.Count == 0, @@ -35,6 +37,7 @@ private sealed class RoutedDependencyManifest : ISharpLinkGeneratedAssemblyManif public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(IOrdersContract).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x72706f757465642dUL, 0x646570656e64656eUL); public string CompileTimeDescriptor => "dependency-view-test"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -46,8 +49,7 @@ private sealed class RoutedDependencyManifest : ISharpLinkGeneratedAssemblyManif private sealed class ScopedFactory : IRpcGeneratedCodecFactory { public Type TargetType => typeof(ScopedPayload); - public string SchemaId => "scoped-dependency/v1"; - public string WireFormatId => HiddenPolicyAdapter.Instance.WireFormatId; + public RpcHash128 CodecHash => new(0x73636f7065642d64UL, 0x6570656e64656e63UL); public string? AdapterId => HiddenPolicyAdapter.Instance.AdapterId; public IRpcCodecAdapter? Adapter => HiddenPolicyAdapter.Instance; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -59,7 +61,6 @@ private sealed class HiddenPolicyAdapter : IRpcCodecAdapter { internal static readonly HiddenPolicyAdapter Instance = new(); public string AdapterId => "hidden-dependency-policy/v1"; - public string WireFormatId => "hidden-dependency-wire/v1"; public IRpcCodecAdapterScope CreateScope() => throw new InvalidOperationException("hidden Contract policy adapter scope must not be created by a dependency view"); } @@ -71,4 +72,4 @@ private static void Ensure(bool condition, string message) if (!condition) throw new InvalidOperationException(message); } -} +} \ No newline at end of file From 5518b4b6a7ebb6cb66203ae054e473a7b1f84655 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:07:35 +0800 Subject: [PATCH 059/399] fix: preserve dependency manifest identity --- src/SharpLink.Client/SharpLinkMultiClusterClientBuilder.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Client/SharpLinkMultiClusterClientBuilder.cs b/src/SharpLink.Client/SharpLinkMultiClusterClientBuilder.cs index 6a37be229..54a03ff9a 100644 --- a/src/SharpLink.Client/SharpLinkMultiClusterClientBuilder.cs +++ b/src/SharpLink.Client/SharpLinkMultiClusterClientBuilder.cs @@ -251,7 +251,7 @@ internal static SharpLinkPreparedCluster PrepareRuntimeCluster( } if (!assemblyOwners.TryAdd(route.ContractAssembly, cluster)) continue; - AddManifestClosure(contractManifest, cluster, manifestsByCluster, manifestsByAssembly, includeContractPolicyDependencies: true); + AddManifestClosure(contractManifest, route.Cluster, manifestsByCluster, manifestsByAssembly, includeContractPolicyDependencies: true); } if (manifestsByCluster[cluster].Values.All(static manifest => manifest.Contracts.Count == 0) && @@ -565,6 +565,7 @@ private sealed class DependencyManifestView(ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => source.ProtocolVersion; public string GeneratorVersion => source.GeneratorVersion; public Assembly OwnerAssembly => source.OwnerAssembly; + public RpcHash128 RpcAssemblyHash => source.RpcAssemblyHash; public string CompileTimeDescriptor => source.CompileTimeDescriptor; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; From 732b4ce13d49ddaca5c97f7b80eac700ae47e2e2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:09:16 +0800 Subject: [PATCH 060/399] test: migrate build plan generated identities --- .../Builder/BuildPlanBuilderTests.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.UnitTests/Builder/BuildPlanBuilderTests.cs b/test/SharpLink.UnitTests/Builder/BuildPlanBuilderTests.cs index 0751d9971..864850637 100644 --- a/test/SharpLink.UnitTests/Builder/BuildPlanBuilderTests.cs +++ b/test/SharpLink.UnitTests/Builder/BuildPlanBuilderTests.cs @@ -13,6 +13,8 @@ namespace SharpLink.UnitTests.Builder; public sealed class BuildPlanBuilderTests { private const string ConsumedBuilderMessage = "This SharpLink builder has already been consumed."; + private static RpcHash128 SyntheticManifestHash => new(0x6275696c642d706cUL, 0x616e2d6d616e6966UL); + private static RpcHash128 SyntheticCodecHash => new(0x6275696c642d706cUL, 0x616e2d636f646563UL); [Test] public async Task CrossTopologyConfigurationShouldFailAtTheSecondCall() @@ -781,6 +783,7 @@ private sealed class EmptyManifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase11-test"; public Assembly OwnerAssembly => typeof(BuildPlanBuilderTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "phase11-empty"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -794,6 +797,7 @@ private sealed class IncompatibleManifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase11-test"; public Assembly OwnerAssembly => typeof(BuildPlanBuilderTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "phase11-incompatible"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -807,6 +811,7 @@ private sealed class MalformedApi4Manifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase11-test"; public Assembly OwnerAssembly => typeof(BuildPlanBuilderTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "phase11-malformed"; public IReadOnlyList Contracts => null!; public IReadOnlyList Services => []; @@ -820,6 +825,7 @@ private sealed class ForeignContractOwnershipManifest : ISharpLinkGeneratedAssem public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase11-test"; public Assembly OwnerAssembly => typeof(BuildPlanBuilderTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "phase11-foreign-contract"; public IReadOnlyList Contracts { get; } = [ @@ -843,6 +849,7 @@ private sealed class DeferredAdapterManifest(DeferredAdapterCodecFactory factory public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase11-test"; public Assembly OwnerAssembly => typeof(BuildPlanBuilderTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "phase11-deferred-adapter"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -856,8 +863,7 @@ private sealed class DeferredAdapterCodecFactory(DeferredAdapter adapter) : IRpc internal int CodecCreateCount => Volatile.Read(ref _codecCreateCount); public Type TargetType => typeof(DeferredCodecValue); - public string SchemaId => "phase11-deferred-adapter/v1"; - public string WireFormatId => "phase11-deferred-wire/v1"; + public RpcHash128 CodecHash => SyntheticCodecHash; public string? AdapterId => "phase11-deferred-adapter/v1"; public IRpcCodecAdapter Adapter { get; } = adapter; @@ -876,7 +882,6 @@ private sealed class DeferredAdapter : IRpcCodecAdapter internal int ScopeCreateCount => Volatile.Read(ref _scopeCreateCount); public string AdapterId => "phase11-deferred-adapter/v1"; - public string WireFormatId => "phase11-deferred-wire/v1"; public IRpcCodecAdapterScope CreateScope() { From ecc6c1640f6e0cd9e607888771904075bcd2a340 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:10:43 +0800 Subject: [PATCH 061/399] test: migrate builder rollback identities --- .../Builder/BuilderOwnershipRollbackTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.UnitTests/Builder/BuilderOwnershipRollbackTests.cs b/test/SharpLink.UnitTests/Builder/BuilderOwnershipRollbackTests.cs index 9d3a628dc..1c287bbe0 100644 --- a/test/SharpLink.UnitTests/Builder/BuilderOwnershipRollbackTests.cs +++ b/test/SharpLink.UnitTests/Builder/BuilderOwnershipRollbackTests.cs @@ -13,6 +13,9 @@ namespace SharpLink.UnitTests.Builder; public class BuilderOwnershipRollbackTests { + private static RpcHash128 SyntheticManifestHash => new(0x6275696c6465722dUL, 0x726f6c6c6261636bUL); + private static RpcHash128 SyntheticCodecHash => new(0x6275696c6465722dUL, 0x636f6465632d7631UL); + [Test] public void ClientProfileFailureShouldDisposeTransportAndPreserveBothFailures() { @@ -622,6 +625,7 @@ private sealed class ThrowingRuntimeContextManifest : ISharpLinkGeneratedAssembl public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(ThrowingRuntimeContextManifest).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "builder-runtime-context-throw"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -632,8 +636,7 @@ private sealed class ThrowingRuntimeContextManifest : ISharpLinkGeneratedAssembl private sealed class ThrowingRuntimeContextCodecFactory : IRpcGeneratedCodecFactory { public Type TargetType => typeof(CodecValue); - public string SchemaId => "builder-runtime-context-throw/v1"; - public string WireFormatId => "builder-runtime-context-wire/v1"; + public RpcHash128 CodecHash => SyntheticCodecHash; public string? AdapterId => "builder-runtime-context-adapter/v1"; public IRpcCodecAdapter Adapter { get; } = new ThrowingRuntimeContextAdapter(); @@ -646,7 +649,6 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt private sealed class ThrowingRuntimeContextAdapter : IRpcCodecAdapter { public string AdapterId => "builder-runtime-context-adapter/v1"; - public string WireFormatId => "builder-runtime-context-wire/v1"; public IRpcCodecAdapterScope CreateScope() => throw new InvalidOperationException("controlled Runtime Context construction failure"); @@ -686,6 +688,7 @@ private sealed class RegistrationRollbackManifest : ISharpLinkGeneratedAssemblyM public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(RegistrationRollbackManifest).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "builder-registration-rollback"; public IReadOnlyList Contracts { get; } = [ From cc71a64be5c4c4f733589ebef82d76996fb385e8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:11:26 +0800 Subject: [PATCH 062/399] test: migrate serializer generated identities --- test/SharpLink.UnitTests/Builder/SerializerBuilderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.UnitTests/Builder/SerializerBuilderTests.cs b/test/SharpLink.UnitTests/Builder/SerializerBuilderTests.cs index dbec24f09..f044bf038 100644 --- a/test/SharpLink.UnitTests/Builder/SerializerBuilderTests.cs +++ b/test/SharpLink.UnitTests/Builder/SerializerBuilderTests.cs @@ -157,8 +157,7 @@ public void Serialize(in Payload value, IBufferWriter buffer) private sealed class TaggedCodecFactory : IRpcGeneratedCodecFactory { public Type TargetType => typeof(Payload); - public string SchemaId => "generated-test-v1"; - public string WireFormatId => "sharplink-native/v1"; + public RpcHash128 CodecHash => new(0x73657269616c697aUL, 0x65722d636f646563UL); public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -174,6 +173,7 @@ private sealed class TaggedManifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(TaggedManifest).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x73657269616c697aUL, 0x65722d6d616e6966UL); public string CompileTimeDescriptor => "tagged-test"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; From 7da67e061c16452be622c41d6e5657f84abed7be Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:12:46 +0800 Subject: [PATCH 063/399] test: migrate service registration manifest identity --- test/SharpLink.UnitTests/Server/ServiceRegistrationTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/ServiceRegistrationTests.cs b/test/SharpLink.UnitTests/Server/ServiceRegistrationTests.cs index f6792f8e3..77dc294a8 100644 --- a/test/SharpLink.UnitTests/Server/ServiceRegistrationTests.cs +++ b/test/SharpLink.UnitTests/Server/ServiceRegistrationTests.cs @@ -448,6 +448,7 @@ private sealed class EmptyManifest(Assembly ownerAssembly) : ISharpLinkGenerated public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly { get; } = ownerAssembly; + public RpcHash128 RpcAssemblyHash => new(0x736572766963652dUL, 0x656d7074792d7631UL); public string CompileTimeDescriptor => "test"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -461,6 +462,7 @@ private sealed class StaticCleanupManifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(StaticCleanupManifest).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x736572766963652dUL, 0x636c65616e75702dUL); public string CompileTimeDescriptor => "service-cleanup"; public IReadOnlyList Contracts { get; } = [ From 0f931e69b1f2d70191ba998eabe3b7c8b9bf82f5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:14:55 +0800 Subject: [PATCH 064/399] test: migrate manifest source identities --- .../Runtime/ManifestSourceIsolationTests.cs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/ManifestSourceIsolationTests.cs b/test/SharpLink.UnitTests/Runtime/ManifestSourceIsolationTests.cs index bc9af354c..9c8f2d8b7 100644 --- a/test/SharpLink.UnitTests/Runtime/ManifestSourceIsolationTests.cs +++ b/test/SharpLink.UnitTests/Runtime/ManifestSourceIsolationTests.cs @@ -14,6 +14,10 @@ namespace SharpLink.UnitTests.Runtime; public sealed class ManifestSourceIsolationTests { + private static RpcHash128 SyntheticManifestHash => new(0x6d616e6966657374UL, 0x2d736f757263652dUL); + private static RpcHash128 NativeCodecHash => new(0x6d616e6966657374UL, 0x2d6e61746976652dUL); + private static RpcHash128 ScopedCodecHash => new(0x6d616e6966657374UL, 0x2d73636f7065642dUL); + [Test] public void RuntimeCompileShouldCaptureItsSourceExactlyOnceAndFreezeTheReturnedList() { @@ -464,17 +468,18 @@ private CodecManifest(string descriptor, IRpcGeneratedCodecFactory factory) } internal static CodecManifest For(string descriptor) - => new(descriptor, new TestCodecFactory(descriptor)); + => new(descriptor, new TestCodecFactory()); internal static CodecManifest ForDisposableScope( string descriptor, DisposableScopeCounters counters) - => new(descriptor, new DisposableScopeCodecFactory(descriptor, counters)); + => new(descriptor, new DisposableScopeCodecFactory(counters)); public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase15-test"; public Assembly OwnerAssembly => typeof(ManifestSourceIsolationTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor { get; } public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -504,6 +509,7 @@ internal static ContractManifest For( public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase15-test"; public Assembly OwnerAssembly => typeof(ManifestSourceIsolationTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "phase15-contract"; public IReadOnlyList Contracts { get; } public IReadOnlyList Services => []; @@ -534,7 +540,7 @@ internal static CompositeManifest ForClient( (channel, _) => proxyFactory(channel), static _ => new TestStub(8_301)), service: null, - new TestCodecFactory($"client-composite:{typeof(TCodec).FullName}")); + new TestCodecFactory()); internal static CompositeManifest ForServer( Type contractType, @@ -560,7 +566,7 @@ internal static CompositeManifest ForServer( return new CompositeManifest( contract, service, - new TestCodecFactory($"server-composite:{typeof(TCodec).FullName}")); + new TestCodecFactory()); } private static SharpLinkGeneratedContractDescriptor CreateContract( @@ -581,6 +587,7 @@ private static SharpLinkGeneratedContractDescriptor CreateContract( public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase15-test"; public Assembly OwnerAssembly => typeof(ManifestSourceIsolationTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => "phase15-composite"; public IReadOnlyList Contracts { get; } public IReadOnlyList Services { get; } @@ -594,6 +601,7 @@ private sealed class IncompatibleCatalogPoisonManifest : ISharpLinkGeneratedAsse public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "phase15-global-poison"; public Assembly OwnerAssembly => typeof(ManifestSourceIsolationTests).Assembly; + public RpcHash128 RpcAssemblyHash => SyntheticManifestHash; public string CompileTimeDescriptor => throw new InvalidOperationException("poison shape read"); public IReadOnlyList Contracts => throw new InvalidOperationException("poison shape read"); @@ -604,11 +612,10 @@ private sealed class IncompatibleCatalogPoisonManifest : ISharpLinkGeneratedAsse public IReadOnlyList Dependencies => throw new InvalidOperationException("poison shape read"); } - private sealed class TestCodecFactory(string schemaId) : IRpcGeneratedCodecFactory + private sealed class TestCodecFactory : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId { get; } = schemaId; - public string WireFormatId => "sharplink-native/v1"; + public RpcHash128 CodecHash => NativeCodecHash; public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -627,13 +634,10 @@ public void Serialize(in T value, IBufferWriter buffer) public T? Deserialize(in ReadOnlySequence buffer) => default; } - private sealed class DisposableScopeCodecFactory( - string schemaId, - DisposableScopeCounters counters) : IRpcGeneratedCodecFactory + private sealed class DisposableScopeCodecFactory(DisposableScopeCounters counters) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId { get; } = schemaId; - public string WireFormatId => "phase15-disposable/v1"; + public RpcHash128 CodecHash => ScopedCodecHash; public string AdapterId => "phase15.disposable-scope/v1"; public IRpcCodecAdapter Adapter { get; } = new DisposableScopeAdapter(counters); public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -644,7 +648,6 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt private sealed class DisposableScopeAdapter(DisposableScopeCounters counters) : IRpcCodecAdapter { public string AdapterId => "phase15.disposable-scope/v1"; - public string WireFormatId => "phase15-disposable/v1"; public IRpcCodecAdapterScope CreateScope() { Interlocked.Increment(ref counters.ScopeCreateCount); From 948ead30588aa294a8e0f5c98c4f1b5a16a8df6c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:19:33 +0800 Subject: [PATCH 065/399] style: add final newline --- .../Client/RpcCodecRouteMultiClusterTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs b/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs index 75491cb9d..287199119 100644 --- a/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs +++ b/test/SharpLink.UnitTests/Client/RpcCodecRouteMultiClusterTests.cs @@ -72,4 +72,4 @@ private static void Ensure(bool condition, string message) if (!condition) throw new InvalidOperationException(message); } -} \ No newline at end of file +} From 8ecdaa2e89981dc361683b73fb53631299cdea45 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:27:28 +0800 Subject: [PATCH 066/399] test: migrate multi-cluster generated identities --- .../Client/SharpLinkMultiClusterClientTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs b/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs index 9daae1286..7a146ea70 100644 --- a/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs +++ b/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs @@ -1678,6 +1678,7 @@ private sealed class Manifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => TestManifestAssembly; + public RpcHash128 RpcAssemblyHash => new(0x6d756c7469636c75UL, 0x737465722d763031UL); public string CompileTimeDescriptor => "multi-cluster-test"; public IReadOnlyList Contracts { get; } = [ @@ -1709,11 +1710,10 @@ private sealed class RouteManifest : ISharpLinkGeneratedClusterRouteManifest ]; } - private sealed class TestCodecFactory(string schemaId) : IRpcGeneratedCodecFactory + private sealed class TestCodecFactory(string _) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId { get; } = schemaId; - public string WireFormatId => "sharplink-native/v1"; + public RpcHash128 CodecHash => new(0x6d756c7469636c75UL, 0x737465722d636f64UL); public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) From 9d7df4e3b3a44453e3d4cbb1d29df0b7c3ef0fc8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:56:13 +0800 Subject: [PATCH 067/399] Temporarily suppress unread test fixture parameter warning --- test/SharpLink.UnitTests/Client/.editorconfig | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 test/SharpLink.UnitTests/Client/.editorconfig diff --git a/test/SharpLink.UnitTests/Client/.editorconfig b/test/SharpLink.UnitTests/Client/.editorconfig new file mode 100644 index 000000000..8cba9048f --- /dev/null +++ b/test/SharpLink.UnitTests/Client/.editorconfig @@ -0,0 +1,2 @@ +[SharpLinkMultiClusterClientTests.cs] +dotnet_diagnostic.CS9113.severity = none From 7baac44c6318a47e1a282e9b8b0e679a48e02e41 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:59:02 +0800 Subject: [PATCH 068/399] Migrate static contract codec provider fixture identity --- .../Server/StaticContractCodecProviderRegressionTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/StaticContractCodecProviderRegressionTests.cs b/test/SharpLink.UnitTests/Server/StaticContractCodecProviderRegressionTests.cs index bf11c08d8..c9642a511 100644 --- a/test/SharpLink.UnitTests/Server/StaticContractCodecProviderRegressionTests.cs +++ b/test/SharpLink.UnitTests/Server/StaticContractCodecProviderRegressionTests.cs @@ -107,13 +107,14 @@ internal TwoContractManifest() [], static _ => new ContractBService()) ]; - _contractCodecs = [new CustomFactory(SharedCodec, "test/assembly-shared")]; + _contractCodecs = [new CustomFactory(SharedCodec)]; } public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(IContractA).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x7374617469632d63UL, 0x6f6465632d6f776eUL); public string CompileTimeDescriptor => "test"; public IReadOnlyList Contracts => _contracts; public IReadOnlyList Services => _services; @@ -125,12 +126,11 @@ internal TwoContractManifest() internal IRpcCodec? CapturedA { get; private set; } internal IRpcCodec? CapturedB { get; private set; } - private sealed class CustomFactory(IRpcCodec codec, string schemaId) + private sealed class CustomFactory(IRpcCodec codec) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(SharedPayload); - public string SchemaId => schemaId; - public string WireFormatId => "test/shared-payload/v1"; + public RpcHash128 CodecHash => new(0x617373656d626c79UL, 0x2d7368617265642dUL); public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; From 11a230662bd59a649fbc4518194b341bb90d7b60 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:01:13 +0800 Subject: [PATCH 069/399] Add identity to time budget test manifest --- .../SharpLink.UnitTests/Client/SharpLinkClientTimeBudgetTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.UnitTests/Client/SharpLinkClientTimeBudgetTests.cs b/test/SharpLink.UnitTests/Client/SharpLinkClientTimeBudgetTests.cs index 9f48032ef..2d665920b 100644 --- a/test/SharpLink.UnitTests/Client/SharpLinkClientTimeBudgetTests.cs +++ b/test/SharpLink.UnitTests/Client/SharpLinkClientTimeBudgetTests.cs @@ -369,6 +369,7 @@ private sealed class EmptyManifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(SharpLinkClientTimeBudgetTests).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x74696d652d627564UL, 0x6765742d74657374UL); public string CompileTimeDescriptor => "test"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; From 9960c74b9fce65d6245369ae4656a5e9f5655d97 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:03:26 +0800 Subject: [PATCH 070/399] Migrate static endpoint rollback fixture identity --- .../SharpLink.UnitTests/Client/StaticEndpointBuilderTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.UnitTests/Client/StaticEndpointBuilderTests.cs b/test/SharpLink.UnitTests/Client/StaticEndpointBuilderTests.cs index 815e09e84..c5c544b3f 100644 --- a/test/SharpLink.UnitTests/Client/StaticEndpointBuilderTests.cs +++ b/test/SharpLink.UnitTests/Client/StaticEndpointBuilderTests.cs @@ -623,6 +623,7 @@ private sealed class ThrowingScopeManifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(ThrowingScopeManifest).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x6275696c6465722dUL, 0x726f6c6c6261636bUL); public string CompileTimeDescriptor => "client-build-rollback"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -633,8 +634,7 @@ private sealed class ThrowingScopeManifest : ISharpLinkGeneratedAssemblyManifest private sealed class ThrowingScopeCodecFactory : IRpcGeneratedCodecFactory { public Type TargetType => typeof(BuilderValue); - public string SchemaId => "builder-value/v1"; - public string WireFormatId => "builder-wire/v1"; + public RpcHash128 CodecHash => new(0x6275696c6465722dUL, 0x636f6465632d7631UL); public string AdapterId => "builder-adapter/v1"; public IRpcCodecAdapter Adapter { get; } = new ThrowingScopeAdapter(); public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -645,7 +645,6 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt private sealed class ThrowingScopeAdapter : IRpcCodecAdapter { public string AdapterId => "builder-adapter/v1"; - public string WireFormatId => "builder-wire/v1"; public IRpcCodecAdapterScope CreateScope() => new ThrowingScope(); } From 0fc2a75b197a70875242ff63158c717006a0ac69 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:06:15 +0800 Subject: [PATCH 071/399] Migrate runtime context fixtures to deterministic hashes --- .../Runtime/SharpLinkRuntimeContextTests.cs | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs index 7bf4a3aed..8cc12db58 100644 --- a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs +++ b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs @@ -9,6 +9,8 @@ namespace SharpLink.UnitTests.Runtime; public class SharpLinkRuntimeContextTests { private static readonly TimeSpan RaceCoordinationTimeout = TimeSpan.FromSeconds(10); + private static readonly RpcHash128 TestAssemblyHash = + new(0x72756e74696d652dUL, 0x746573742d763031UL); [Test] // This is the intentional default-global adapter test; the other RuntimeContext tests use fixed sources. @@ -551,7 +553,7 @@ public void AdapterIdentityMismatchShouldRejectAndDisposePreparedScopes() } catch (InvalidOperationException exception) { - Ensure(exception.Message.Contains("runtime identity", StringComparison.Ordinal), + Ensure(exception.Message.Contains("lifecycle identity", StringComparison.Ordinal), "identity mismatch is reported before publication"); } @@ -590,8 +592,8 @@ public void EveryFactoryAdapterInstanceShouldMatchGeneratedIdentity() } catch (InvalidOperationException exception) { - Ensure(exception.Message.Contains("runtime identity", StringComparison.Ordinal), - "a later same-type Adapter instance cannot bypass runtime identity validation"); + Ensure(exception.Message.Contains("lifecycle identity", StringComparison.Ordinal), + "a later same-type Adapter instance cannot bypass generated AdapterId validation"); } Ensure(preparedCounters.ScopeCreateCount == 1, "the first valid Adapter Scope was prepared"); @@ -660,14 +662,16 @@ public void ConflictingManifestCodecsShouldRollbackBothAdapterScopes() new AlternateCountingAdapter(secondCounters), AlternateCountingAdapter.Id, AlternateCountingAdapter.Wire, - schemaId: "incompatible-schema")) + codecHash: new RpcHash128( + 0x636f6e666c696374UL, + 0x2d636f6465632d32UL))) ]); throw new Exception("expected generated Codec conflict"); } catch (InvalidOperationException exception) { - Ensure(exception.Message.Contains("schema/wire", StringComparison.Ordinal), - "same-target schema/wire conflict is rejected"); + Ensure(exception.Message.Contains("Generated Codec conflict", StringComparison.Ordinal), + "same-target CodecHash conflict is rejected"); } Ensure(firstCounters.ScopeDisposeCount == 1, "first Manifest Scope is rolled back"); @@ -1073,7 +1077,7 @@ public void AdapterFreeCustomWireCodecShouldBeAccepted() context.PublishGeneratedCodecs(registration.Codecs); Ensure(context.Codecs.GetCodec() is TaggedThirdAdapterValueCodec { Tag: 7 }, - "an adapter-free Codec with a custom wire-format identity must resolve through the generated registration"); + "an adapter-free Codec with a custom deterministic identity must resolve through the generated registration"); } private sealed class TaggedValue; @@ -1115,8 +1119,7 @@ public void Serialize(in CatalogValue value, IBufferWriter buffer) private sealed class CatalogCodecFactory : IRpcGeneratedCodecFactory { public Type TargetType => typeof(CatalogValue); - public string SchemaId => "catalog-test-v1"; - public string WireFormatId => "sharplink-native/v1"; + public RpcHash128 CodecHash => new(0x636174616c6f672dUL, 0x636f6465632d7631UL); public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -1132,6 +1135,7 @@ private sealed class CatalogManifest : ISharpLinkGeneratedAssemblyManifest public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(CatalogManifest).Assembly; + public RpcHash128 RpcAssemblyHash => TestAssemblyHash; public string CompileTimeDescriptor => "catalog-test"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -1362,8 +1366,7 @@ public void Serialize(in ThirdAdapterValue value, IBufferWriter buffer) private sealed class FixedNativeFactory(IRpcCodec codec) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId => $"native:{typeof(T).FullName}"; - public string WireFormatId => "sharplink-native/v1"; + public RpcHash128 CodecHash => new(0x66697865642d6e61UL, 0x746976652d763031UL); public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) @@ -1376,7 +1379,7 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt private sealed class CustomWireFactory(IRpcCodec codec, string wireFormatId) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId => $"custom:{typeof(T).FullName}"; + public RpcHash128 CodecHash => new(0x637573746f6d2d63UL, 0x6f6465632d763031UL); public string WireFormatId => wireFormatId; public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; @@ -1393,8 +1396,7 @@ private sealed class BlockingNativeFactory( TaskCompletionSource release) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId => $"blocking-native:{typeof(T).FullName}"; - public string WireFormatId => "sharplink-native/v1"; + public RpcHash128 CodecHash => new(0x626c6f636b696e67UL, 0x2d636f6465632d31UL); public string? AdapterId => null; public IRpcCodecAdapter? Adapter => null; @@ -1413,8 +1415,7 @@ public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapt private sealed class AdapterFactory(AdapterCounters counters) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); - public string SchemaId => $"adapter:{typeof(T).FullName}"; - public string WireFormatId => "test-wire/v1"; + public RpcHash128 CodecHash => new(0x616461707465722dUL, 0x636f6465632d7631UL); public string? AdapterId => "test.adapter/v1"; public IRpcCodecAdapter Adapter { get; } = new CountingAdapter(counters); @@ -1432,16 +1433,21 @@ internal ConfigurableAdapterFactory( string adapterId, string wireFormatId, IRpcCodec? codec = null, - string? schemaId = null) + string? schemaId = null, + RpcHash128 codecHash = default) { Adapter = adapter; AdapterId = adapterId; WireFormatId = wireFormatId; SchemaId = schemaId ?? $"adapter:{typeof(T).FullName}"; + CodecHash = codecHash.IsEmpty + ? new RpcHash128(0x636f6e6669672d61UL, 0x6461707465722d31UL) + : codecHash; _codec = codec; } public Type TargetType => typeof(T); + public RpcHash128 CodecHash { get; } public string SchemaId { get; } public string WireFormatId { get; } public string AdapterId { get; } @@ -1459,6 +1465,7 @@ private sealed class AdapterManifest(AdapterCounters counters, bool includeSecon public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(AdapterManifest).Assembly; + public RpcHash128 RpcAssemblyHash => TestAssemblyHash; public string CompileTimeDescriptor => "adapter-test"; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; @@ -1475,6 +1482,7 @@ private sealed class TestManifest(string descriptor, params IRpcGeneratedCodecFa public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; public string GeneratorVersion => "test"; public Assembly OwnerAssembly => typeof(TestManifest).Assembly; + public RpcHash128 RpcAssemblyHash => TestAssemblyHash; public string CompileTimeDescriptor => descriptor; public IReadOnlyList Contracts => []; public IReadOnlyList Services => []; From e1c51a14d5c386611dc13b150d6297e7c3401b89 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:10:25 +0800 Subject: [PATCH 072/399] Trim legacy runtime fixture identity plumbing --- .../Runtime/SharpLinkRuntimeContextTests.cs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs index 8cc12db58..522fd0af1 100644 --- a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs +++ b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs @@ -9,8 +9,7 @@ namespace SharpLink.UnitTests.Runtime; public class SharpLinkRuntimeContextTests { private static readonly TimeSpan RaceCoordinationTimeout = TimeSpan.FromSeconds(10); - private static readonly RpcHash128 TestAssemblyHash = - new(0x72756e74696d652dUL, 0x746573742d763031UL); + private static readonly RpcHash128 TestAssemblyHash = new(0x72756e74696d652dUL, 0x746573742d763031UL); [Test] // This is the intentional default-global adapter test; the other RuntimeContext tests use fixed sources. @@ -1157,7 +1156,6 @@ public CountingAdapter() internal CountingAdapter(AdapterCounters counters) => _counters = counters; public string AdapterId => Id; - public string WireFormatId => Wire; public IRpcCodecAdapterScope CreateScope() { @@ -1180,7 +1178,6 @@ public AlternateCountingAdapter() internal AlternateCountingAdapter(AdapterCounters counters) => _counters = counters; public string AdapterId => Id; - public string WireFormatId => Wire; public IRpcCodecAdapterScope CreateScope() { @@ -1236,7 +1233,6 @@ public ThrowingDisposeAdapter() internal ThrowingDisposeAdapter(AdapterCounters counters) => _counters = counters; public string AdapterId => Id; - public string WireFormatId => Wire; public IRpcCodecAdapterScope CreateScope() { @@ -1264,7 +1260,6 @@ internal FailingScopeAdapter(AdapterCounters counters, bool returnNull) } public string AdapterId => Id; - public string WireFormatId => Wire; public IRpcCodecAdapterScope CreateScope() { @@ -1433,13 +1428,10 @@ internal ConfigurableAdapterFactory( string adapterId, string wireFormatId, IRpcCodec? codec = null, - string? schemaId = null, RpcHash128 codecHash = default) { Adapter = adapter; AdapterId = adapterId; - WireFormatId = wireFormatId; - SchemaId = schemaId ?? $"adapter:{typeof(T).FullName}"; CodecHash = codecHash.IsEmpty ? new RpcHash128(0x636f6e6669672d61UL, 0x6461707465722d31UL) : codecHash; @@ -1448,8 +1440,6 @@ internal ConfigurableAdapterFactory( public Type TargetType => typeof(T); public RpcHash128 CodecHash { get; } - public string SchemaId { get; } - public string WireFormatId { get; } public string AdapterId { get; } public IRpcCodecAdapter Adapter { get; } From db67d1a9a0aea20b7232e0b109f9b1a7bad37832 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:23:39 +0800 Subject: [PATCH 073/399] Avoid manifest emission after codec diagnostics --- src/SharpLink.Generator/RpcGenerator.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 7f4d91a82..268f69a41 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -313,6 +313,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var manifest = boundInterfaces.Collect().Combine(services.Collect()).Combine(generatedCodecs); context.RegisterSourceOutput(manifest, static (spc, value) => { + if (!value.Right.Diagnostics.IsDefaultOrEmpty) + return; + var interfaces = value.Left.Left; var services = value.Left.Right; var codecs = value.Right.Codecs; From 5d2235e01923c1e56b15c9c4a8d1b5b9ceadd122 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:31:16 +0800 Subject: [PATCH 074/399] Fix deterministic identity generator fixtures --- .../RpcDeterministicIdentityTests.cs | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index 2dd15b562..a1fe55191 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -106,26 +106,22 @@ private static string GenerateDtoIdentityManifest(bool includeExtraMember, bool var extraMember = includeExtraMember ? "public long Extra { get; set; }" : string.Empty; - var methodAttribute = idempotent ? "[Idempotent]" : string.Empty; - var source = $$""" -using System.Threading; -using System.Threading.Tasks; -using SharpLink.Sdk; - -[RpcSerializable] + var methodAttribute = idempotent ? "[SharpLink.Sdk.Idempotent]" : string.Empty; + var source = BuildSource($$""" +[SharpLink.Sdk.RpcSerializable] public sealed class DeterministicPayload { public int Value { get; set; } {{extraMember}} } -[RpcContract] -public interface IDeterministicIdentityContract : IService +[SharpLink.Sdk.RpcContract] +public interface IDeterministicIdentityContract : SharpLink.Sdk.IService { {{methodAttribute}} ValueTask Echo(DeterministicPayload value, CancellationToken cancellationToken); } -"""; +"""); return RunGeneratorAndGetSources(source) .Single(static generated => @@ -137,36 +133,38 @@ private static string GenerateOpaqueIdentityManifest( ulong semanticHigh, ulong semanticLow) { - var source = $$""" -using System; -using System.Buffers; -using System.Threading; -using System.Threading.Tasks; -using SharpLink.Abstractions; -using SharpLink.Sdk; + var source = BuildSource($$""" +namespace SharpLink.Sdk +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] + public sealed class RpcCodecSemanticIdentityAttribute : Attribute + { + public RpcCodecSemanticIdentityAttribute(ulong high, ulong low) { } + } +} -[RpcSerializable] -[RpcCodec(typeof(OpaquePayloadCodec))] +[SharpLink.Sdk.RpcSerializable] +[SharpLink.Sdk.RpcCodec(typeof(OpaquePayloadCodec))] public sealed class OpaquePayload { public int Value { get; set; } } -[RpcCodecSemanticIdentity({{semanticHigh}}UL, {{semanticLow}}UL)] -public sealed class OpaquePayloadCodec : IRpcCodec +[SharpLink.Sdk.RpcCodecSemanticIdentity({{semanticHigh}}UL, {{semanticLow}}UL)] +public sealed class OpaquePayloadCodec : SharpLink.Abstractions.IRpcCodec { private const string ImplementationMarker = "{{implementationMarker}}"; - public void Serialize(in OpaquePayload value, IBufferWriter buffer) { _ = ImplementationMarker; } - public OpaquePayload Deserialize(in ReadOnlySequence buffer) => new(); + public void Serialize(in OpaquePayload value, System.Buffers.IBufferWriter buffer) { _ = ImplementationMarker; } + public OpaquePayload Deserialize(in System.Buffers.ReadOnlySequence buffer) => new(); } -[RpcContract] -public interface IOpaqueIdentityContract : IService +[SharpLink.Sdk.RpcContract] +public interface IOpaqueIdentityContract : SharpLink.Sdk.IService { ValueTask Echo(OpaquePayload value, CancellationToken cancellationToken); } -"""; +"""); return RunGeneratorAndGetSources(source) .Single(static generated => From 4c7a324aef48745bc2e2797a1cfc3e487e0a760a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:31:47 +0800 Subject: [PATCH 075/399] Provide deterministic identity test attributes --- .../RpcDeterministicIdentityTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index a1fe55191..892be54c4 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -108,6 +108,12 @@ private static string GenerateDtoIdentityManifest(bool includeExtraMember, bool : string.Empty; var methodAttribute = idempotent ? "[SharpLink.Sdk.Idempotent]" : string.Empty; var source = BuildSource($$""" +namespace SharpLink.Sdk +{ + [AttributeUsage(AttributeTargets.Method)] + public sealed class IdempotentAttribute : Attribute { } +} + [SharpLink.Sdk.RpcSerializable] public sealed class DeterministicPayload { From b1898e65306cf520280ece45d633b51a508fa02c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:39:23 +0800 Subject: [PATCH 076/399] Add current identity test source shim --- .../RpcIdentityTestSources.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs diff --git a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs new file mode 100644 index 000000000..262ae07e9 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs @@ -0,0 +1,30 @@ +using System; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + private static string UseCurrentIdentitySdk(string source) + { + source = source.Replace( + "public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId, string wireFormatId) { }", + "public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId) { }", + StringComparison.Ordinal); + return source.Replace( + """ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class RpcCodecImplementationAttribute : Attribute + { + public RpcCodecImplementationAttribute(string wireFormatId, string schemaId) { } + } +""", + """ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] + public sealed class RpcCodecSemanticIdentityAttribute : Attribute + { + public RpcCodecSemanticIdentityAttribute(ulong high, ulong low) { } + } +""", + StringComparison.Ordinal); + } +} From af7ad0d314e2d4e5d441b1f3987bed70b0210d36 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:39:43 +0800 Subject: [PATCH 077/399] Migrate tuple codec fixtures to semantic identity --- .../RpcCodecFifthReviewRegressionTests.cs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecFifthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFifthReviewRegressionTests.cs index 736b7bd9f..a62b80ce5 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecFifthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecFifthReviewRegressionTests.cs @@ -9,13 +9,14 @@ public partial class RpcAnalyzerTests [Test] public Task CanonicalTupleAliasBindingsShouldDiagnoseConflictingAdapters() { - var source = AddAssemblyAttributes(BuildSource(""" + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildSource(""" [SharpLink.Sdk.RpcContract] public interface IAliasConflictContract : SharpLink.Sdk.IService { ValueTask> Echo(List<(int X, int Y)> value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(1UL, 1UL)] public sealed class AliasAdapterA : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "alias-a/v1"; @@ -23,15 +24,16 @@ public sealed class AliasAdapterA : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(2UL, 2UL)] public sealed class AliasAdapterB : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "alias-b/v1"; public string WireFormatId => "alias-b-wire/v1"; public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(AliasAdapterA), \"alias-a/v1\", \"alias-a-wire/v1\")]", - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(AliasAdapterB), \"alias-b/v1\", \"alias-b-wire/v1\")]", +""")), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(AliasAdapterA), \"alias-a/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(AliasAdapterB), \"alias-b/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(List<(int X, int Y)>), typeof(AliasAdapterA))]", "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(List>), typeof(AliasAdapterB))]"); @@ -44,8 +46,8 @@ public sealed class AliasAdapterB : SharpLink.Abstractions.IRpcCodecAdapter [Test] public Task CanonicalTupleAliasCustomCodecShouldValidateAgainstClrIdentity() { - var source = AddAssemblyAttributes(BuildSource(""" -[SharpLink.Sdk.RpcCodecImplementation("alias-custom-wire/v1", "alias-custom-schema/v1")] + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildSource(""" +[SharpLink.Sdk.RpcCodecSemanticIdentity(3UL, 3UL)] public sealed class AliasCustomCodec : SharpLink.Abstractions.IRpcCodec> { } @@ -55,7 +57,7 @@ public interface IAliasCustomContract : SharpLink.Sdk.IService { ValueTask> Echo(List<(int X, int Y)> value, CancellationToken cancellationToken); } -"""), +""")), "[assembly: SharpLink.Sdk.RpcCodec(typeof(List>), typeof(AliasCustomCodec))]"); var diagnostics = RunGenerator(source); @@ -70,11 +72,11 @@ public interface IAliasCustomContract : SharpLink.Sdk.IService [Test] public Task CanonicalTupleAliasBindingsShouldDiagnoseConflictingCustomCodecs() { - var source = AddAssemblyAttributes(BuildSource(""" -[SharpLink.Sdk.RpcCodecImplementation("alias-custom-a/v1", "alias-custom-a-schema/v1")] + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildSource(""" +[SharpLink.Sdk.RpcCodecSemanticIdentity(4UL, 4UL)] public sealed class AliasCustomCodecA : SharpLink.Abstractions.IRpcCodec> { } -[SharpLink.Sdk.RpcCodecImplementation("alias-custom-b/v1", "alias-custom-b-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(5UL, 5UL)] public sealed class AliasCustomCodecB : SharpLink.Abstractions.IRpcCodec>> { } [SharpLink.Sdk.RpcContract] @@ -82,7 +84,7 @@ public interface IAliasCustomConflictContract : SharpLink.Sdk.IService { ValueTask> Echo(List<(int X, int Y)> value, CancellationToken cancellationToken); } -"""), +""")), "[assembly: SharpLink.Sdk.RpcCodec(typeof(List<(int X, int Y)>), typeof(AliasCustomCodecA))]", "[assembly: SharpLink.Sdk.RpcCodec(typeof(List>), typeof(AliasCustomCodecB))]"); From 012cd73243769b05cb6f4aba673400953b731b8d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:40:13 +0800 Subject: [PATCH 078/399] Migrate codec policy fixtures to semantic identity --- .../RpcCodecPolicyRegressionTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecPolicyRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecPolicyRegressionTests.cs index ce920804e..717599b51 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecPolicyRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecPolicyRegressionTests.cs @@ -9,7 +9,7 @@ public partial class RpcAnalyzerTests [Test] public Task ExplicitBindingMatchingSelectorShouldStillBeContractOwned() { - var source = AddAssemblyAttributes(BuildRouteSource(""" + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildRouteSource(""" [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] public sealed class SelectorAttribute : System.Attribute { } @@ -26,13 +26,14 @@ public interface ISelectorExplicitContract : SharpLink.Sdk.IService ValueTask Echo(SelectorPayload value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1001UL, 0x2001UL)] public sealed class SelectorAdapter : TestRouteAdapterBase { public override string AdapterId => "selector.explicit/v1"; public override string WireFormatId => "selector-explicit-wire/v1"; } -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SelectorAdapter), \"selector.explicit/v1\", \"selector-explicit-wire/v1\", SelectorAttributeType = typeof(SelectorAttribute))]"); +""")), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SelectorAdapter), \"selector.explicit/v1\", SelectorAttributeType = typeof(SelectorAttribute))]"); var sources = RunGeneratorAndGetSources(source); var generated = string.Join("\n", sources); @@ -57,7 +58,7 @@ public sealed class SelectorAdapter : TestRouteAdapterBase [Test] public Task AllRouteShouldNotCaptureFrameworkEnum() { - var source = AddAssemblyAttributes(BuildRouteSource(""" + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildRouteSource(""" public enum RouteEnum : short { Zero, @@ -70,13 +71,14 @@ public interface IEnumRouteContract : SharpLink.Sdk.IService ValueTask Echo(RouteEnum value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1002UL, 0x2002UL)] public sealed class EnumAdapter : TestRouteAdapterBase { public override string AdapterId => "route.enum/v1"; public override string WireFormatId => "route-enum-safe/v1"; } -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(EnumAdapter), \"route.enum/v1\", \"route-enum-safe/v1\")]", +""")), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(EnumAdapter), \"route.enum/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.All, typeof(EnumAdapter))]"); var diagnostics = RunGenerator(source); From cd59ff7804fea1d3dc992116ee7e68e03311e481 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:40:42 +0800 Subject: [PATCH 079/399] Migrate custom codec review fixtures to semantic identity --- .../RpcCodecReviewRegressionTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecReviewRegressionTests.cs index 8b34f41f9..aafed6e0b 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecReviewRegressionTests.cs @@ -9,13 +9,13 @@ public partial class RpcAnalyzerTests [Test] public Task ContractOnlyCustomCodecShouldBeOwnedWithoutChangingStandaloneCustomCodecPublication() { - var contractSource = AddAssemblyAttribute(BuildSource(""" + var contractSource = AddAssemblyAttribute(UseCurrentIdentitySdk(BuildSource(""" public sealed class ContractOnlyPayload { public int Value { get; set; } } -[SharpLink.Sdk.RpcCodecImplementation("contract-only-wire/v1", "contract-only-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3001UL, 0x4001UL)] public sealed class ContractOnlyPayloadCodec : SharpLink.Abstractions.IRpcCodec { } @@ -25,7 +25,7 @@ public interface IContractOnlyCodecService : SharpLink.Sdk.IService { ValueTask Echo(ContractOnlyPayload value, CancellationToken cancellationToken); } -"""), +""")), "[assembly: SharpLink.Sdk.RpcCodec(typeof(ContractOnlyPayload), typeof(ContractOnlyPayloadCodec))]"); var contractManifest = RunGeneratorAndGetSources(contractSource) @@ -36,7 +36,7 @@ public interface IContractOnlyCodecService : SharpLink.Sdk.IService Ensure(contractSections.Contract.Contains(".Factory(),", StringComparison.Ordinal), "a Contract-only explicit custom Codec must be published in the assembly-owned Contract Codec table"); - var standaloneSource = BuildSource(""" + var standaloneSource = UseCurrentIdentitySdk(BuildSource(""" [SharpLink.Sdk.RpcSerializable] [SharpLink.Sdk.RpcCodec(typeof(StandalonePayloadCodec))] public sealed class StandalonePayload @@ -44,11 +44,11 @@ public sealed class StandalonePayload public int Value { get; set; } } -[SharpLink.Sdk.RpcCodecImplementation("standalone-wire/v1", "standalone-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3002UL, 0x4002UL)] public sealed class StandalonePayloadCodec : SharpLink.Abstractions.IRpcCodec { } -"""); +""")); var standaloneManifest = RunGeneratorAndGetSources(standaloneSource) .Single(static text => text.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); @@ -63,7 +63,7 @@ public sealed class StandalonePayloadCodec : SharpLink.Abstractions.IRpcCodec { } From df608faeacade496ecf42884bffd28e5877acc92 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:41:10 +0800 Subject: [PATCH 080/399] Migrate final graph identity fixtures --- .../RpcCodecFinalGraphRegressionTests.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecFinalGraphRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFinalGraphRegressionTests.cs index cba1379a9..6f2e29e85 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecFinalGraphRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecFinalGraphRegressionTests.cs @@ -9,7 +9,7 @@ public partial class RpcAnalyzerTests [Test] public Task ContractOnlyCustomChildShouldKeepGlobalCodecGraphClosed() { - var source = AddAssemblyAttribute(BuildSource(""" + var source = AddAssemblyAttribute(UseCurrentIdentitySdk(BuildSource(""" public sealed class GraphChild { public int Value { get; set; } @@ -20,7 +20,7 @@ public sealed class GraphParent public GraphChild Child { get; set; } = new(); } -[SharpLink.Sdk.RpcCodecImplementation("graph-child-wire/v1", "graph-child-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x5001UL, 0x6001UL)] public sealed class GraphChildCodec : SharpLink.Abstractions.IRpcCodec { } @@ -30,7 +30,7 @@ public interface IGraphContract : SharpLink.Sdk.IService { ValueTask Echo(GraphParent value, CancellationToken cancellationToken); } -"""), +""")), "[assembly: SharpLink.Sdk.RpcCodec(typeof(GraphChild), typeof(GraphChildCodec))]"); var manifest = RunGeneratorAndGetSources(source) @@ -50,7 +50,7 @@ public interface IGraphContract : SharpLink.Sdk.IService [Test] public Task UnrelatedContractShouldNotSuppressStandaloneBuiltinOverrideDiagnostic() { - var source = AddAssemblyAttributes(BuildSource(""" + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildSource(""" [SharpLink.Sdk.RpcSerializable] public sealed class StandaloneBuiltinEnvelope { @@ -63,14 +63,15 @@ public interface IUnrelatedContract : SharpLink.Sdk.IService ValueTask Echo(string value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x5002UL, 0x6002UL)] public sealed class StandaloneIntAdapter : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "standalone-int/v1"; public string WireFormatId => "standalone-int-wire/v1"; public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(StandaloneIntAdapter), \"standalone-int/v1\", \"standalone-int-wire/v1\")]", +""")), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(StandaloneIntAdapter), \"standalone-int/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(int), typeof(StandaloneIntAdapter))]"); var diagnostics = RunGenerator(source); From 8e2e17e58b91313a835d2d535da8360da8874c96 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:41:57 +0800 Subject: [PATCH 081/399] Migrate late review identity fixtures --- .../RpcCodecLateReviewRegressionTests.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecLateReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecLateReviewRegressionTests.cs index 9182254f0..ae534fbfc 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecLateReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecLateReviewRegressionTests.cs @@ -9,7 +9,7 @@ public partial class RpcAnalyzerTests [Test] public Task FrameworkPrimitiveElementBindingShouldBeRejectedWithoutChangingCompositeDefaults() { - var source = AddAssemblyAttributes(BuildSource(""" + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildSource(""" [SharpLink.Sdk.RpcContract] public interface IBuiltinCompositeContract : SharpLink.Sdk.IService { @@ -19,14 +19,15 @@ public interface IBuiltinCompositeContract : SharpLink.Sdk.IService ValueTask EchoNullable(int? value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x7001UL, 0x8001UL)] public sealed class CompositeIntAdapter : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "composite-int/v1"; public string WireFormatId => "composite-int-wire/v1"; public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(CompositeIntAdapter), \"composite-int/v1\", \"composite-int-wire/v1\")]", +""")), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(CompositeIntAdapter), \"composite-int/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(int), typeof(CompositeIntAdapter))]"); var diagnostics = RunGenerator(source); @@ -41,7 +42,7 @@ public sealed class CompositeIntAdapter : SharpLink.Abstractions.IRpcCodecAdapte [Test] public Task OpaqueContractCodecShouldStopFinalGraphTraversal() { - var source = AddAssemblyAttributes(BuildSource(""" + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildSource(""" [SharpLink.Sdk.RpcSerializable] public sealed class StandaloneIntEnvelope { @@ -53,7 +54,7 @@ public sealed class OpaqueEnvelope public int Value { get; set; } } -[SharpLink.Sdk.RpcCodecImplementation("opaque-envelope-wire/v1", "opaque-envelope-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x7002UL, 0x8002UL)] public sealed class OpaqueEnvelopeCodec : SharpLink.Abstractions.IRpcCodec { } @@ -64,15 +65,16 @@ public interface IOpaqueEnvelopeContract : SharpLink.Sdk.IService ValueTask Echo(OpaqueEnvelope value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x7003UL, 0x8003UL)] public sealed class UnrelatedIntAdapter : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "unrelated-int/v1"; public string WireFormatId => "unrelated-int-wire/v1"; public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } -"""), +""")), "[assembly: SharpLink.Sdk.RpcCodec(typeof(OpaqueEnvelope), typeof(OpaqueEnvelopeCodec))]", - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(UnrelatedIntAdapter), \"unrelated-int/v1\", \"unrelated-int-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(UnrelatedIntAdapter), \"unrelated-int/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(int), typeof(UnrelatedIntAdapter))]"); var diagnostics = RunGenerator(source); From dc4008eae172fad4fc07f6c1d1ec04066d0a4bb4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:42:49 +0800 Subject: [PATCH 082/399] Migrate third review identity fixtures --- .../RpcCodecThirdReviewRegressionTests.cs | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecThirdReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecThirdReviewRegressionTests.cs index 4d57b072a..ce39a85a5 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecThirdReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecThirdReviewRegressionTests.cs @@ -9,7 +9,7 @@ public partial class RpcAnalyzerTests [Test] public Task OwnerLocalCustomCodecShouldNotDependOnPayloadOwnersUnrelatedManifest() { - var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); + var sdk = CreateMetadataReference("SharpLink.Sdk", UseCurrentIdentitySdk(BuildSource(string.Empty))); var payloads = CreateMetadataReference( "SharedPayloads", """ @@ -51,7 +51,7 @@ public sealed class SdkReferenceMarker [assembly: RpcCodec(typeof(SharedPayload), typeof(LocalSharedPayloadCodec))] -[RpcCodecImplementation("owner-local-wire/v1", "owner-local-schema/v1")] +[RpcCodecSemanticIdentity(0x9001UL, 0xa001UL)] public sealed class LocalSharedPayloadCodec : IRpcCodec { } @@ -84,21 +84,22 @@ public interface IOwnerLocalContract : IService [Test] public Task ExplicitFrameworkPrimitiveAdapterShouldBeRejectedWithoutRoute() { - var source = AddAssemblyAttributes(BuildSource(""" + var source = AddAssemblyAttributes(UseCurrentIdentitySdk(BuildSource(""" [SharpLink.Sdk.RpcContract] public interface INoRouteBuiltinAdapterContract : SharpLink.Sdk.IService { ValueTask Echo(int value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x9002UL, 0xa002UL)] public sealed class ExplicitIntAdapter : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "explicit.no-route-int/v1"; public string WireFormatId => "explicit-no-route-int-wire/v1"; public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ExplicitIntAdapter), \"explicit.no-route-int/v1\", \"explicit-no-route-int-wire/v1\")]", +""")), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ExplicitIntAdapter), \"explicit.no-route-int/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(int), typeof(ExplicitIntAdapter))]"); var diagnostics = RunGenerator(source); @@ -113,7 +114,7 @@ public sealed class ExplicitIntAdapter : SharpLink.Abstractions.IRpcCodecAdapter [Test] public Task ReferencedManifestlessContractPolicyShouldNotBecomeConsumerOwned() { - var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); + var sdk = CreateMetadataReference("SharpLink.Sdk", UseCurrentIdentitySdk(BuildSource(string.Empty))); var foreign = CreateMetadataReference( "ForeignContracts", """ @@ -145,7 +146,7 @@ public interface IForeignContract : IService [assembly: RpcCodec(typeof(ForeignPayload), typeof(ForeignPayloadCodec))] -[RpcCodecImplementation("foreign-consumer-wire/v1", "foreign-consumer-schema/v1")] +[RpcCodecSemanticIdentity(0x9003UL, 0xa003UL)] public sealed class ForeignPayloadCodec : IRpcCodec { } @@ -169,7 +170,7 @@ public interface ILocalContract : IService [Test] public Task FrameworkEnumCustomCodecShouldBeRejectedForDirectAndNestedUse() { - var source = AddAssemblyAttribute(BuildSource(""" + var source = AddAssemblyAttribute(UseCurrentIdentitySdk(BuildSource(""" public enum CustomMode : short { Zero, @@ -181,7 +182,7 @@ public sealed class CustomEnvelope public CustomMode Mode { get; set; } } -[SharpLink.Sdk.RpcCodecImplementation("custom-mode-wire/v1", "custom-mode-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x9004UL, 0xa004UL)] public sealed class CustomModeCodec : SharpLink.Abstractions.IRpcCodec { } @@ -192,7 +193,7 @@ public interface ICustomModeContract : SharpLink.Sdk.IService ValueTask EchoMode(CustomMode value, CancellationToken cancellationToken); ValueTask EchoEnvelope(CustomEnvelope value, CancellationToken cancellationToken); } -"""), +""")), "[assembly: SharpLink.Sdk.RpcCodec(typeof(CustomMode), typeof(CustomModeCodec))]"); var diagnostics = RunGenerator(source); @@ -207,13 +208,13 @@ public interface ICustomModeContract : SharpLink.Sdk.IService [Test] public Task FrameworkStringCustomCodecShouldBeRejectedForDirectAndNestedUse() { - var source = AddAssemblyAttribute(BuildSource(""" + var source = AddAssemblyAttribute(UseCurrentIdentitySdk(BuildSource(""" public sealed class StringEnvelope { public string Value { get; set; } = string.Empty; } -[SharpLink.Sdk.RpcCodecImplementation("custom-string-wire/v1", "custom-string-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x9005UL, 0xa005UL)] public sealed class OwnerStringCodec : SharpLink.Abstractions.IRpcCodec { } @@ -224,7 +225,7 @@ public interface IStringOwnerContract : SharpLink.Sdk.IService ValueTask EchoString(string value, CancellationToken cancellationToken); ValueTask EchoEnvelope(StringEnvelope value, CancellationToken cancellationToken); } -"""), +""")), "[assembly: SharpLink.Sdk.RpcCodec(typeof(string), typeof(OwnerStringCodec))]"); var diagnostics = RunGenerator(source); From 987f03cb543453d1d8f88d1948bc1e54be118b42 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:45:08 +0800 Subject: [PATCH 083/399] Normalize legacy generator identity fixtures --- .../RpcIdentityTestSources.cs | 163 +++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs index 262ae07e9..e95e90f19 100644 --- a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs +++ b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs @@ -1,16 +1,29 @@ using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; namespace SharpLink.Generator.Tests; public partial class RpcAnalyzerTests { + private static readonly Regex LegacyAdapterRegistrationPattern = new( + """RpcCodecAdapterRegistration\(typeof\((?[^)]+)\),\s*"(?[^"]*)",\s*"(?[^"]*)"(?\s*(?:,\s*SelectorAttributeType\s*=\s*typeof\([^)]+\))?)\)""", + RegexOptions.CultureInvariant); + private static readonly Regex LegacyCodecIdentityPattern = new( + """(?SharpLink\.Sdk\.)?RpcCodecImplementation\("(?[^"]*)",\s*"(?[^"]*)"\)""", + RegexOptions.CultureInvariant); + private static string UseCurrentIdentitySdk(string source) { source = source.Replace( "public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId, string wireFormatId) { }", "public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId) { }", StringComparison.Ordinal); - return source.Replace( + source = source.Replace( """ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public sealed class RpcCodecImplementationAttribute : Attribute @@ -26,5 +39,153 @@ public RpcCodecSemanticIdentityAttribute(ulong high, ulong low) { } } """, StringComparison.Ordinal); + + var registrations = LegacyAdapterRegistrationPattern.Matches(source) + .Cast() + .Select(static match => new LegacyAdapterRegistration( + match.Groups["type"].Value.Trim(), + match.Groups["id"].Value, + match.Groups["wire"].Value)) + .ToArray(); + source = LegacyAdapterRegistrationPattern.Replace(source, static match => + $"RpcCodecAdapterRegistration(typeof({match.Groups["type"].Value}), " + + $"\"{match.Groups["id"].Value}\"{match.Groups["tail"].Value})"); + + source = LegacyCodecIdentityPattern.Replace(source, static match => + { + var identity = GetFixtureSemanticIdentity(match.Groups["wire"].Value, match.Groups["schema"].Value); + return $"{match.Groups["prefix"].Value}RpcCodecSemanticIdentity({FormatHash(identity.High)}UL, {FormatHash(identity.Low)}UL)"; + }); + + foreach (var registration in registrations) + { + var identity = GetFixtureSemanticIdentity(registration.AdapterId, registration.WireFormatId); + source = AddSemanticIdentityToRegisteredAdapter(source, registration.AdapterType, identity); + } + + return source; + } + + private static string AddSemanticIdentityToRegisteredAdapter( + string source, + string adapterType, + (ulong High, ulong Low) identity) + { + var simpleName = adapterType.Split('.').Last().Trim(); + var typePattern = new Regex( + $"(?m)^(?\\s*)(?(?:public|internal|protected|private)\\s+(?:(?:static|abstract|sealed|partial)\\s+)*class\\s+{Regex.Escape(simpleName)}\\b)", + RegexOptions.CultureInvariant); + var match = typePattern.Match(source); + if (!match.Success) + return source; + + var previousBlockStart = source.LastIndexOf("\n\n", Math.Max(0, match.Index - 1), StringComparison.Ordinal); + var previousBlockLength = match.Index - (previousBlockStart < 0 ? 0 : previousBlockStart + 2); + var previousBlock = source.Substring(previousBlockStart < 0 ? 0 : previousBlockStart + 2, previousBlockLength); + if (previousBlock.Contains("RpcCodecSemanticIdentity", StringComparison.Ordinal)) + return source; + + var indentation = match.Groups["indent"].Value; + var attribute = + $"{indentation}[SharpLink.Sdk.RpcCodecSemanticIdentity({FormatHash(identity.High)}UL, {FormatHash(identity.Low)}UL)]\n"; + return source.Insert(match.Index, attribute); + } + + private static (ulong High, ulong Low) GetFixtureSemanticIdentity(string first, string second) + { + const ulong fnvPrime = 1099511628211UL; + ulong high = 14695981039346656037UL; + ulong low = 7809847782465536322UL; + foreach (var value in EnumerateIdentityChars(first, second)) + { + unchecked + { + high = (high ^ value) * fnvPrime; + low = (low ^ (value + 0x9e37UL)) * 14029467366897019727UL; + } + } + + if ((high | low) == 0) + low = 1; + return (high, low); + } + + private static IEnumerable EnumerateIdentityChars(string first, string second) + { + foreach (var value in first) + yield return value; + yield return 0; + foreach (var value in second) + yield return value; } + + private static string FormatHash(ulong value) + => "0x" + value.ToString("x16", CultureInfo.InvariantCulture); + + private static ImmutableArray RunGenerator(string source) + => RunGenerator(UseCurrentIdentitySdk(source), Array.Empty()); + + private static ImmutableArray RunGenerator(string source, MetadataReference first) + => RunGenerator(UseCurrentIdentitySdk(source), [first]); + + private static ImmutableArray RunGenerator( + string source, + MetadataReference first, + MetadataReference second) + => RunGenerator(UseCurrentIdentitySdk(source), [first, second]); + + private static ImmutableArray RunGenerator( + string source, + MetadataReference first, + MetadataReference second, + MetadataReference third) + => RunGenerator(UseCurrentIdentitySdk(source), [first, second, third]); + + private static string[] RunGeneratorAndGetSources(string source) + => RunGeneratorAndGetSources(UseCurrentIdentitySdk(source), Array.Empty()); + + private static string[] RunGeneratorAndGetSources(string source, MetadataReference first) + => RunGeneratorAndGetSources(UseCurrentIdentitySdk(source), [first]); + + private static string[] RunGeneratorAndGetSources( + string source, + MetadataReference first, + MetadataReference second) + => RunGeneratorAndGetSources(UseCurrentIdentitySdk(source), [first, second]); + + private static string[] RunGeneratorAndGetSources( + string source, + MetadataReference first, + MetadataReference second, + MetadataReference third) + => RunGeneratorAndGetSources(UseCurrentIdentitySdk(source), [first, second, third]); + + private static MetadataReference CreateMetadataReference(string assemblyName, string source) + => CreateMetadataReference(assemblyName, UseCurrentIdentitySdk(source), Array.Empty()); + + private static MetadataReference CreateMetadataReference( + string assemblyName, + string source, + MetadataReference first) + => CreateMetadataReference(assemblyName, UseCurrentIdentitySdk(source), [first]); + + private static MetadataReference CreateMetadataReference( + string assemblyName, + string source, + MetadataReference first, + MetadataReference second) + => CreateMetadataReference(assemblyName, UseCurrentIdentitySdk(source), [first, second]); + + private static MetadataReference CreateMetadataReference( + string assemblyName, + string source, + MetadataReference first, + MetadataReference second, + MetadataReference third) + => CreateMetadataReference(assemblyName, UseCurrentIdentitySdk(source), [first, second, third]); + + private readonly record struct LegacyAdapterRegistration( + string AdapterType, + string AdapterId, + string WireFormatId); } From 2662cdeca002deeb98c1d344b1edcfd4e4a7f584 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:55:30 +0800 Subject: [PATCH 084/399] test: normalize multiline codec registrations --- test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs index e95e90f19..002318946 100644 --- a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs +++ b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs @@ -11,7 +11,7 @@ namespace SharpLink.Generator.Tests; public partial class RpcAnalyzerTests { private static readonly Regex LegacyAdapterRegistrationPattern = new( - """RpcCodecAdapterRegistration\(typeof\((?[^)]+)\),\s*"(?[^"]*)",\s*"(?[^"]*)"(?\s*(?:,\s*SelectorAttributeType\s*=\s*typeof\([^)]+\))?)\)""", + """RpcCodecAdapterRegistration\(\s*typeof\((?[^)]+)\),\s*"(?[^"]*)",\s*"(?[^"]*)"(?\s*(?:,\s*SelectorAttributeType\s*=\s*typeof\([^)]+\))?)\)""", RegexOptions.CultureInvariant); private static readonly Regex LegacyCodecIdentityPattern = new( """(?SharpLink\.Sdk\.)?RpcCodecImplementation\("(?[^"]*)",\s*"(?[^"]*)"\)""", From e48bf77d94d5cf4781b0ff84699c2a44555d30d5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:55:57 +0800 Subject: [PATCH 085/399] test: use shared semantic identity fixture --- .../RpcDeterministicIdentityTests.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index 892be54c4..128104deb 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -140,15 +140,6 @@ private static string GenerateOpaqueIdentityManifest( ulong semanticLow) { var source = BuildSource($$""" -namespace SharpLink.Sdk -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] - public sealed class RpcCodecSemanticIdentityAttribute : Attribute - { - public RpcCodecSemanticIdentityAttribute(ulong high, ulong low) { } - } -} - [SharpLink.Sdk.RpcSerializable] [SharpLink.Sdk.RpcCodec(typeof(OpaquePayloadCodec))] public sealed class OpaquePayload From 0ee858c7e8d74d8b24cdbb85b61e5fd4bb9b577b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:57:46 +0800 Subject: [PATCH 086/399] test: normalize contract manifest identity fixtures --- .../ContractManifestIdentityTestSources.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/ContractManifestIdentityTestSources.cs diff --git a/test/SharpLink.Generator.Tests/ContractManifestIdentityTestSources.cs b/test/SharpLink.Generator.Tests/ContractManifestIdentityTestSources.cs new file mode 100644 index 000000000..d1a1e7008 --- /dev/null +++ b/test/SharpLink.Generator.Tests/ContractManifestIdentityTestSources.cs @@ -0,0 +1,16 @@ +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + private static ContractGeneratorResult RunContractGenerator(string source) + => RunContractGenerator( + UseCurrentIdentitySdk(source), + baseline: null, + outputPath: null); + + private static ContractGeneratorResult RunContractGenerator(string source, string? baseline) + => RunContractGenerator( + UseCurrentIdentitySdk(source), + baseline, + outputPath: null); +} From c2dffde2a863bcb481a4cd7c80b3c18c2bd48305 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:05:06 +0800 Subject: [PATCH 087/399] test: normalize current adapter identity rules --- .../RpcIdentityTestSources.cs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs index 002318946..48315b143 100644 --- a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs +++ b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs @@ -48,8 +48,17 @@ public RpcCodecSemanticIdentityAttribute(ulong high, ulong low) { } match.Groups["wire"].Value)) .ToArray(); source = LegacyAdapterRegistrationPattern.Replace(source, static match => - $"RpcCodecAdapterRegistration(typeof({match.Groups["type"].Value}), " + - $"\"{match.Groups["id"].Value}\"{match.Groups["tail"].Value})"); + { + var adapterId = match.Groups["id"].Value; + var legacyWireFormatId = match.Groups["wire"].Value; + if (string.IsNullOrEmpty(legacyWireFormatId)) + adapterId = string.Empty; + else if (legacyWireFormatId.Any(static value => value < ' ' || value > '~')) + adapterId = legacyWireFormatId; + + return $"RpcCodecAdapterRegistration(typeof({match.Groups["type"].Value}), " + + $"\"{adapterId}\"{match.Groups["tail"].Value})"; + }); source = LegacyCodecIdentityPattern.Replace(source, static match => { @@ -73,17 +82,27 @@ private static string AddSemanticIdentityToRegisteredAdapter( { var simpleName = adapterType.Split('.').Last().Trim(); var typePattern = new Regex( - $"(?m)^(?\\s*)(?(?:public|internal|protected|private)\\s+(?:(?:static|abstract|sealed|partial)\\s+)*class\\s+{Regex.Escape(simpleName)}\\b)", + $"(?m)^(?[ \\t]*)(?(?:public|internal|protected|private)\\s+(?:(?:static|abstract|sealed|partial)\\s+)*class\\s+{Regex.Escape(simpleName)}\\b)", RegexOptions.CultureInvariant); var match = typePattern.Match(source); if (!match.Success) return source; - var previousBlockStart = source.LastIndexOf("\n\n", Math.Max(0, match.Index - 1), StringComparison.Ordinal); - var previousBlockLength = match.Index - (previousBlockStart < 0 ? 0 : previousBlockStart + 2); - var previousBlock = source.Substring(previousBlockStart < 0 ? 0 : previousBlockStart + 2, previousBlockLength); - if (previousBlock.Contains("RpcCodecSemanticIdentity", StringComparison.Ordinal)) - return source; + if (match.Index > 0) + { + var previousLineEnd = match.Index - 1; + if (source[previousLineEnd] == '\n') + previousLineEnd--; + if (previousLineEnd >= 0) + { + var previousLineStart = source.LastIndexOf('\n', previousLineEnd) + 1; + var previousLine = source.Substring( + previousLineStart, + previousLineEnd - previousLineStart + 1); + if (previousLine.Contains("RpcCodecSemanticIdentity", StringComparison.Ordinal)) + return source; + } + } var indentation = match.Groups["indent"].Value; var attribute = From ff2c7ff59816b8e1d2bfcf0c3b54f0bb5af88ba5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:06:14 +0800 Subject: [PATCH 088/399] fix: stop traversal at opaque codec bindings --- .../RpcGenerator.CodecPolicyOwnership.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index eb7ae1c15..38e02e6b3 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -679,7 +679,13 @@ private void CollectFinalBindingTypes( { if (depth > MaximumDepth || !seen.Add(type)) return; - reachable[GetTypeName(type)] = type; + var typeName = GetTypeName(type); + reachable[typeName] = type; + if (_models.TryGetValue(typeName, out var finalModel) && + finalModel.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + { + return; + } if (type is IArrayTypeSymbol array) { From 71afde568c17bf35e662140930fd379eae4da6ef Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:17:07 +0800 Subject: [PATCH 089/399] feat: use semantic codec hashes in contract manifests --- .../RpcGenerator.ContractManifest.cs | 192 +++++++++++------- 1 file changed, 122 insertions(+), 70 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index 979bf5f16..e08e901d1 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -7,7 +7,7 @@ namespace SharpLink.Generator; public partial class RpcGenerator { - private const int ContractManifestFormatVersion = 2; + private const int ContractManifestFormatVersion = 3; private const string ContractManifestFormat = "SharpLink.Contracts"; private static RpcUnionModel? GetUnionModelOrNull( @@ -149,13 +149,13 @@ private static ContractManifestAnalysis AnalyzeContractManifest( $"format version {baseline.Version} is not supported by version {ContractManifestFormatVersion}", "regenerate the baseline with the current SharpLink SDK")); } - else if (!HasRequiredWireFormatIds(baseline)) + else if (!HasRequiredContractIdentities(baseline)) { diagnostics.Add(new ContractCompatibilityDiagnostic( ContractCompatibilityKind.BaselineInvalid, Location.None, options.BaselinePath, - "one or more payload, DTO member, or Codec identity entries are missing a required non-empty identity value", + "one or more payload, DTO member, or Codec entries are missing required wire framing or semantic identity", "regenerate the baseline with the current SharpLink SDK")); } else if (string.IsNullOrWhiteSpace(baseline.SchemaFingerprint) || @@ -207,11 +207,21 @@ private static ContractManifestDocument CreateContractManifest( ImmutableArray unions) { var document = new ContractManifestDocument(); - var wireFormats = codecs + var codecsByType = codecs .GroupBy(static codec => RemoveGlobalPrefix(codec.TypeName), StringComparer.Ordinal) .ToDictionary( static group => group.Key, - static group => group.First().WireFormatId, + static group => group.First(), + StringComparer.Ordinal); + var wireFormats = codecsByType.ToDictionary( + static pair => pair.Key, + static pair => pair.Value.WireFormatId, + StringComparer.Ordinal); + var opaqueCodecHashes = codecsByType + .Where(static pair => pair.Value.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + .ToDictionary( + static pair => pair.Key, + static pair => GetCodecHash(pair.Value), StringComparer.Ordinal); foreach (var contract in interfaces .Where(static item => item is not null) @@ -252,6 +262,7 @@ private static ContractManifestDocument CreateContractManifest( ? parameter.StreamItemEnumUnderlyingType : parameter.EnumUnderlyingType), WireFormatId = GetWireFormatId(typeName, wireFormats), + CodecHash = GetOpaqueCodecHash(typeName, opaqueCodecHashes), Nullable = parameter.PayloadNullable, Stream = parameter.IsStream, SourceLocation = parameter.Location @@ -272,6 +283,7 @@ private static ContractManifestDocument CreateContractManifest( ? method.StreamItemEnumUnderlyingType : method.ResponseEnumUnderlyingType), WireFormatId = GetWireFormatId(responseType, wireFormats), + CodecHash = GetOpaqueCodecHash(responseType, opaqueCodecHashes), Nullable = method.ResponseNullable, Stream = method.IsStreamReturn, SourceLocation = method.Location @@ -300,6 +312,7 @@ private static ContractManifestDocument CreateContractManifest( Type = RemoveGlobalPrefix(member.TypeName), WireType = GetMemberWireType(member), WireFormatId = GetWireFormatId(member.TypeName, wireFormats), + CodecHash = GetOpaqueCodecHash(member.TypeName, opaqueCodecHashes), Nullable = member.Nullable, Required = member.Required, ExplicitId = member.HasExplicitId, @@ -315,8 +328,7 @@ private static ContractManifestDocument CreateContractManifest( { Type = RemoveGlobalPrefix(codec.TypeName), Kind = codec.Kind.ToString(), - SchemaId = codec.SchemaId, - WireFormatId = codec.WireFormatId, + CodecHash = GetCodecHash(codec), SourceLocation = codec.Location }); } @@ -589,14 +601,15 @@ private static IEnumerable CompareContractManif matchedNewIds.Add(newMember.Id); if (!string.Equals(oldMember.Type, newMember.Type, StringComparison.Ordinal) || !string.Equals(oldMember.WireType, newMember.WireType, StringComparison.Ordinal) || - !string.Equals(oldMember.WireFormatId, newMember.WireFormatId, StringComparison.Ordinal)) + !string.Equals(oldMember.WireFormatId, newMember.WireFormatId, StringComparison.Ordinal) || + !string.Equals(oldMember.CodecHash, newMember.CodecHash, StringComparison.Ordinal)) { diagnostics.Add(Change( ContractCompatibilityKind.WireType, newMember.SourceLocation, $"{newDto.Name}.{newMember.Name}", - $"member {oldMember.Id} changed from {oldMember.Type}/{oldMember.WireType}/{oldMember.WireFormatId} to {newMember.Type}/{newMember.WireType}/{newMember.WireFormatId}", - "restore the old wire type or add a new optional member ID")); + $"member {oldMember.Id} changed from {oldMember.Type}/{oldMember.WireType}/{oldMember.WireFormatId}/{oldMember.CodecHash} to {newMember.Type}/{newMember.WireType}/{newMember.WireFormatId}/{newMember.CodecHash}", + "restore the old wire type or semantic Codec identity, or add a new optional member ID")); } if (!oldMember.Required && newMember.Required) { @@ -673,33 +686,24 @@ private static IEnumerable CompareContractManif foreach (var oldCodec in baseline.Codecs) { if (!currentCodecs.TryGetValue(oldCodec.Type, out var newCodec)) - { continue; - } - var wireChanged = !string.Equals(oldCodec.WireFormatId, newCodec.WireFormatId, StringComparison.Ordinal); - var schemaChanged = - (string.Equals(oldCodec.Kind, "Custom", StringComparison.Ordinal) || - string.Equals(newCodec.Kind, "Custom", StringComparison.Ordinal)) && - !string.Equals(oldCodec.SchemaId, newCodec.SchemaId, StringComparison.Ordinal); - if (!wireChanged && !schemaChanged) + var opaque = + string.Equals(oldCodec.Kind, "Custom", StringComparison.Ordinal) || + string.Equals(oldCodec.Kind, "Adapter", StringComparison.Ordinal) || + string.Equals(newCodec.Kind, "Custom", StringComparison.Ordinal) || + string.Equals(newCodec.Kind, "Adapter", StringComparison.Ordinal); + if (!opaque || string.Equals(oldCodec.CodecHash, newCodec.CodecHash, StringComparison.Ordinal)) continue; - - if (!schemaChanged && directlyDescribedCodecTypes.Contains(oldCodec.Type)) + if (directlyDescribedCodecTypes.Contains(oldCodec.Type)) continue; - var changedParts = new List(2); - if (wireChanged) - changedParts.Add($"wire '{oldCodec.WireFormatId}' -> '{newCodec.WireFormatId}'"); - if (schemaChanged) - changedParts.Add($"schema '{oldCodec.SchemaId}' -> '{newCodec.SchemaId}'"); - diagnostics.Add(Change( ContractCompatibilityKind.WireType, newCodec.SourceLocation, oldCodec.Type, - $"nested Codec identity changed: {string.Join(", ", changedParts)}", - "restore the previous nested wire/schema identity or add a new RPC payload type")); + $"nested CodecHash changed from '{oldCodec.CodecHash}' to '{newCodec.CodecHash}'", + "restore the previous semantic Codec identity or add a new RPC payload type")); } var currentEnums = current.Enums.ToDictionary(static item => item.Name, StringComparer.Ordinal); @@ -782,6 +786,7 @@ private static void CompareValues( if (!string.Equals(oldValue.Type, newValue.Type, StringComparison.Ordinal) || !string.Equals(oldValue.WireType, newValue.WireType, StringComparison.Ordinal) || !string.Equals(oldValue.WireFormatId, newValue.WireFormatId, StringComparison.Ordinal) || + !string.Equals(oldValue.CodecHash, newValue.CodecHash, StringComparison.Ordinal) || oldValue.Stream != newValue.Stream || oldValue.Nullable != newValue.Nullable) { @@ -789,51 +794,97 @@ private static void CompareValues( ContractCompatibilityKind.WireType, newValue.SourceLocation ?? fallbackLocation, item, - $"element {index} changed from {oldValue.Type}/{oldValue.WireType}/{oldValue.WireFormatId}/nullable={oldValue.Nullable} to {newValue.Type}/{newValue.WireType}/{newValue.WireFormatId}/nullable={newValue.Nullable}", - "restore the previous type or add a new method route")); + $"element {index} changed from {oldValue.Type}/{oldValue.WireType}/{oldValue.WireFormatId}/{oldValue.CodecHash}/nullable={oldValue.Nullable} to {newValue.Type}/{newValue.WireType}/{newValue.WireFormatId}/{newValue.CodecHash}/nullable={newValue.Nullable}", + "restore the previous type, wire framing, or semantic Codec identity, or add a new method route")); } } } - private static bool HasRequiredWireFormatIds(ContractManifestDocument manifest) - => manifest.Contracts is not null && - manifest.Dtos is not null && - manifest.Codecs is not null && - manifest.Enums is not null && - manifest.Unions is not null && - manifest.Services is not null && - manifest.Contracts.All(static contract => - contract is not null && - contract.Methods is not null && - contract.Methods.All(static method => - method is not null && - method.Request is not null && - method.Response is not null && - method.Request.All(static value => - value is not null && !string.IsNullOrWhiteSpace(value.WireFormatId)) && - !string.IsNullOrWhiteSpace(method.Response.WireFormatId))) && - manifest.Dtos.All(static dto => - dto is not null && - dto.Members is not null && - dto.Members.All(static member => - member is not null && !string.IsNullOrWhiteSpace(member.WireFormatId))) && - manifest.Codecs.All(static codec => - codec is not null && - !string.IsNullOrWhiteSpace(codec.Type) && - !string.IsNullOrWhiteSpace(codec.Kind) && - !string.IsNullOrWhiteSpace(codec.SchemaId) && - !string.IsNullOrWhiteSpace(codec.WireFormatId)) && - manifest.Enums.All(static item => item is not null) && - manifest.Unions.All(static union => - union is not null && union.Cases is not null && union.Cases.All(static item => item is not null)) && - manifest.Services.All(static service => service is not null); + private static bool HasRequiredContractIdentities(ContractManifestDocument manifest) + { + if (manifest.Contracts is null || + manifest.Dtos is null || + manifest.Codecs is null || + manifest.Enums is null || + manifest.Unions is null || + manifest.Services is null) + { + return false; + } - private static string GetWireFormatId( + var opaqueCodecTypes = new HashSet( + manifest.Codecs + .Where(static codec => codec is not null && + (string.Equals(codec.Kind, "Custom", StringComparison.Ordinal) || + string.Equals(codec.Kind, "Adapter", StringComparison.Ordinal)) && + IsValidCodecHash(codec.CodecHash)) + .Select(static codec => codec.Type), + StringComparer.Ordinal); + + bool HasValueIdentity(string type, string? wireFormatId, string? codecHash) + => !string.IsNullOrWhiteSpace(wireFormatId) || + (opaqueCodecTypes.Contains(type) && IsValidCodecHash(codecHash)); + + return manifest.Contracts.All(contract => + contract is not null && + contract.Methods is not null && + contract.Methods.All(method => + method is not null && + method.Request is not null && + method.Response is not null && + method.Request.All(value => + value is not null && HasValueIdentity(value.Type, value.WireFormatId, value.CodecHash)) && + HasValueIdentity(method.Response.Type, method.Response.WireFormatId, method.Response.CodecHash))) && + manifest.Dtos.All(dto => + dto is not null && + dto.Members is not null && + dto.Members.All(member => + member is not null && HasValueIdentity(member.Type, member.WireFormatId, member.CodecHash))) && + manifest.Codecs.All(static codec => + codec is not null && + !string.IsNullOrWhiteSpace(codec.Type) && + !string.IsNullOrWhiteSpace(codec.Kind) && + IsValidCodecHash(codec.CodecHash)) && + manifest.Enums.All(static item => item is not null) && + manifest.Unions.All(static union => + union is not null && union.Cases is not null && union.Cases.All(static item => item is not null)) && + manifest.Services.All(static service => service is not null); + } + + private static bool IsValidCodecHash(string? value) + { + if (value is null || value.Length != 32) + return false; + foreach (var character in value) + { + if (!((character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'))) + { + return false; + } + } + return true; + } + + private static string GetCodecHash(GeneratedCodecModel codec) + => new RpcHashValue(codec.CodecHashHigh, codec.CodecHashLow).ToHex(); + + private static string? GetOpaqueCodecHash( + string typeName, + IReadOnlyDictionary opaqueCodecHashes) + => opaqueCodecHashes.TryGetValue(RemoveGlobalPrefix(typeName), out var codecHash) + ? codecHash + : null; + + private static string? GetWireFormatId( string typeName, IReadOnlyDictionary wireFormats) - => wireFormats.TryGetValue(RemoveGlobalPrefix(typeName), out var wireFormatId) - ? wireFormatId - : "sharplink-native/v1"; + { + if (!wireFormats.TryGetValue(RemoveGlobalPrefix(typeName), out var wireFormatId)) + return "sharplink-native/v1"; + return string.IsNullOrWhiteSpace(wireFormatId) ? null : wireFormatId; + } private static ContractCompatibilityDiagnostic Change( ContractCompatibilityKind kind, @@ -1029,7 +1080,8 @@ private sealed class ContractManifestValue public string Name { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string WireType { get; set; } = string.Empty; - public string WireFormatId { get; set; } = string.Empty; + public string? WireFormatId { get; set; } + public string? CodecHash { get; set; } public bool Nullable { get; set; } public bool Stream { get; set; } [JsonIgnore] public Location? SourceLocation { get; set; } @@ -1047,8 +1099,7 @@ private sealed class ContractManifestCodec { public string Type { get; set; } = string.Empty; public string Kind { get; set; } = string.Empty; - public string SchemaId { get; set; } = string.Empty; - public string WireFormatId { get; set; } = string.Empty; + public string CodecHash { get; set; } = string.Empty; [JsonIgnore] public Location? SourceLocation { get; set; } } @@ -1058,7 +1109,8 @@ private sealed class ContractManifestMember public uint Id { get; set; } public string Type { get; set; } = string.Empty; public string WireType { get; set; } = string.Empty; - public string WireFormatId { get; set; } = string.Empty; + public string? WireFormatId { get; set; } + public string? CodecHash { get; set; } public bool Nullable { get; set; } public bool Required { get; set; } public bool ExplicitId { get; set; } From ca23ae8f1689eaa27fa3c54c9bc49cd0679fad24 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:30:31 +0800 Subject: [PATCH 090/399] refactor: split contract manifest generator --- .../RpcGenerator.ContractManifest.cs | 734 ------------------ 1 file changed, 734 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index e08e901d1..d04f8c728 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -1,6 +1,4 @@ -using System.IO; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.CodeAnalysis.Diagnostics; namespace SharpLink.Generator; @@ -414,736 +412,4 @@ void AddEnum(string? name, string? underlying, Location? location) document.SchemaFingerprint = ComputeContractManifestFingerprint(document); return document; } - - private static List ValidateCurrentContractManifest( - ContractManifestDocument current) - { - var diagnostics = new List(); - foreach (var group in current.Contracts.GroupBy(static item => item.Id).Where(static group => group.Count() > 1)) - { - foreach (var contract in group.Skip(1)) - { - diagnostics.Add(new ContractCompatibilityDiagnostic( - ContractCompatibilityKind.ContractId, - contract.SourceLocation, - contract.Name, - $"contract ID {group.Key} is already used by '{group.First().Name}'", - "assign unique contract names or explicit stable IDs")); - } - } - foreach (var contract in current.Contracts) - { - foreach (var group in contract.Methods.GroupBy(static item => item.Id).Where(static group => group.Count() > 1)) - { - foreach (var method in group.Skip(1)) - { - diagnostics.Add(new ContractCompatibilityDiagnostic( - ContractCompatibilityKind.MethodId, - method.SourceLocation, - $"{contract.Name}.{method.Name}", - $"method ID {group.Key} is already used by '{group.First().Name}'", - "change the signature so every RPC route has a unique stable ID")); - } - } - } - foreach (var union in current.Unions) - { - foreach (var item in union.Cases.Where(static item => item.InvalidDetail is not null)) - { - diagnostics.Add(new ContractCompatibilityDiagnostic( - ContractCompatibilityKind.UnionDeclaration, - item.SourceLocation, - union.Name, - item.InvalidDetail!, - "use a positive tag and a closed concrete case type assignable to the annotated union")); - } - foreach (var group in union.Cases.GroupBy(static item => item.Tag).Where(static group => group.Count() > 1)) - { - foreach (var item in group.Skip(1)) - { - diagnostics.Add(new ContractCompatibilityDiagnostic( - ContractCompatibilityKind.UnionTag, - item.SourceLocation, - union.Name, - $"union tag {group.Key} is already assigned to '{group.First().Type}'", - "allocate a unique tag for every union case")); - } - } - foreach (var group in union.Cases - .Where(static item => item.InvalidDetail is null) - .GroupBy(static item => item.Type, StringComparer.Ordinal) - .Where(static group => group.Select(static item => item.Tag).Distinct().Count() > 1)) - { - foreach (var item in group.OrderBy(static item => item.Tag).Skip(1)) - { - diagnostics.Add(new ContractCompatibilityDiagnostic( - ContractCompatibilityKind.UnionDeclaration, - item.SourceLocation, - union.Name, - $"case type '{item.Type}' is already assigned to tag {group.Min(static candidate => candidate.Tag)}", - "assign each concrete case type to exactly one stable tag")); - } - } - } - return diagnostics; - } - - private static IEnumerable CompareContractManifests( - ContractManifestDocument baseline, - ContractManifestDocument current) - { - var diagnostics = new List(); - var currentContractsById = current.Contracts - .GroupBy(static item => item.Id) - .ToDictionary(static group => group.Key, static group => group.First()); - var currentContractsByName = current.Contracts - .GroupBy(static item => item.Name, StringComparer.Ordinal) - .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); - foreach (var oldContract in baseline.Contracts) - { - if (!currentContractsById.TryGetValue(oldContract.Id, out var newContract)) - { - if (currentContractsByName.TryGetValue(oldContract.Name, out newContract)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.ContractId, - newContract.SourceLocation, - oldContract.Name, - $"contract ID changed from {oldContract.Id} to {newContract.Id}", - "restore the original contract name/ID or publish a new contract")); - } - else - { - var renameCandidates = current.Contracts - .Where(candidate => candidate.Methods.Count == oldContract.Methods.Count && - candidate.Methods.Select(static method => method.Name) - .SequenceEqual(oldContract.Methods.Select(static method => method.Name))) - .Take(2) - .ToArray(); - if (renameCandidates.Length != 1) - { - diagnostics.Add(Change( - ContractCompatibilityKind.ContractRemoved, - Location.None, - oldContract.Name, - $"existing contract ID {oldContract.Id} and all of its routes were removed", - "restore the contract and deprecate it without removing its published routes")); - continue; - } - newContract = renameCandidates[0]; - diagnostics.Add(Change( - ContractCompatibilityKind.ContractId, - newContract.SourceLocation, - newContract.Name, - $"contract '{oldContract.Name}' changed ID from {oldContract.Id} to {newContract.Id} after renaming", - "restore the original contract identity or add a separate new contract")); - } - } - - var currentMethodsById = newContract.Methods - .GroupBy(static item => item.Id) - .ToDictionary(static group => group.Key, static group => group.First()); - var currentMethodsByName = newContract.Methods - .GroupBy(static item => item.Name, StringComparer.Ordinal) - .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); - foreach (var oldMethod in oldContract.Methods) - { - if (!currentMethodsById.TryGetValue(oldMethod.Id, out var newMethod)) - { - if (currentMethodsByName.TryGetValue(oldMethod.Name, out newMethod)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.MethodId, - newMethod.SourceLocation, - $"{newContract.Name}.{newMethod.Name}", - $"method ID changed from {oldMethod.Id} to {newMethod.Id}", - "restore the previous signature/ID or add a new method instead")); - } - else - { - diagnostics.Add(Change( - ContractCompatibilityKind.MethodRemoved, - newContract.SourceLocation, - $"{oldContract.Name}.{oldMethod.Name}", - $"existing method ID {oldMethod.Id} was removed", - "restore the method and deprecate it without removing its route")); - continue; - } - } - if (!string.Equals(oldMethod.Shape, newMethod.Shape, StringComparison.Ordinal)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.CallShape, - newMethod.SourceLocation, - $"{newContract.Name}.{newMethod.Name}", - $"RPC shape changed from {oldMethod.Shape} to {newMethod.Shape}", - "add a new method for the new Unary/Streaming shape")); - } - CompareValues(oldMethod.Request, newMethod.Request, - $"{newContract.Name}.{newMethod.Name} request", newMethod.SourceLocation, diagnostics); - CompareValues([oldMethod.Response], [newMethod.Response], - $"{newContract.Name}.{newMethod.Name} response", newMethod.SourceLocation, diagnostics); - } - } - - var currentDtos = current.Dtos.ToDictionary(static item => item.Name, StringComparer.Ordinal); - foreach (var oldDto in baseline.Dtos) - { - if (!currentDtos.TryGetValue(oldDto.Name, out var newDto)) - continue; - var newById = newDto.Members.ToDictionary(static item => item.Id); - var newByName = newDto.Members.ToDictionary(static item => item.Name, StringComparer.Ordinal); - var matchedNewIds = new HashSet(); - foreach (var oldMember in oldDto.Members) - { - if (newById.TryGetValue(oldMember.Id, out var newMember)) - { - matchedNewIds.Add(newMember.Id); - if (!string.Equals(oldMember.Type, newMember.Type, StringComparison.Ordinal) || - !string.Equals(oldMember.WireType, newMember.WireType, StringComparison.Ordinal) || - !string.Equals(oldMember.WireFormatId, newMember.WireFormatId, StringComparison.Ordinal) || - !string.Equals(oldMember.CodecHash, newMember.CodecHash, StringComparison.Ordinal)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.WireType, - newMember.SourceLocation, - $"{newDto.Name}.{newMember.Name}", - $"member {oldMember.Id} changed from {oldMember.Type}/{oldMember.WireType}/{oldMember.WireFormatId}/{oldMember.CodecHash} to {newMember.Type}/{newMember.WireType}/{newMember.WireFormatId}/{newMember.CodecHash}", - "restore the old wire type or semantic Codec identity, or add a new optional member ID")); - } - if (!oldMember.Required && newMember.Required) - { - diagnostics.Add(Change( - ContractCompatibilityKind.Required, - newMember.SourceLocation, - $"{newDto.Name}.{newMember.Name}", - $"existing member {oldMember.Id} became required", - "keep the field optional and enforce requirements in application code")); - } - continue; - } - - if (newByName.TryGetValue(oldMember.Name, out newMember)) - { - matchedNewIds.Add(newMember.Id); - diagnostics.Add(Change( - ContractCompatibilityKind.MemberId, - newMember.SourceLocation, - $"{newDto.Name}.{newMember.Name}", - $"member ID changed from {oldMember.Id} to {newMember.Id}", - $"annotate the member with [RpcMember({oldMember.Id})]")); - continue; - } - - var renamed = newDto.Members - .Where(candidate => !matchedNewIds.Contains(candidate.Id) && !candidate.ExplicitId) - .Where(candidate => string.Equals(candidate.Type, oldMember.Type, StringComparison.Ordinal) && - string.Equals(candidate.WireType, oldMember.WireType, StringComparison.Ordinal) && - candidate.Required == oldMember.Required) - .Take(2) - .ToArray(); - if (renamed.Length == 1) - { - matchedNewIds.Add(renamed[0].Id); - diagnostics.Add(Change( - ContractCompatibilityKind.MemberId, - renamed[0].SourceLocation, - $"{newDto.Name}.{renamed[0].Name}", - $"renaming '{oldMember.Name}' changed the default member ID {oldMember.Id} to {renamed[0].Id}", - $"annotate the renamed member with [RpcMember({oldMember.Id})]")); - } - else if (oldMember.Required) - { - diagnostics.Add(Change( - ContractCompatibilityKind.Required, - newDto.SourceLocation, - $"{oldDto.Name}.{oldMember.Name}", - $"required member {oldMember.Id} was removed", - "restore the required member or introduce a new DTO version")); - } - } - - var oldIds = new HashSet(oldDto.Members.Select(static item => item.Id)); - foreach (var newMember in newDto.Members.Where(item => !oldIds.Contains(item.Id) && item.Required)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.Required, - newMember.SourceLocation, - $"{newDto.Name}.{newMember.Name}", - $"new member {newMember.Id} is required", - "make the new member optional so older payloads remain readable")); - } - } - - var directlyDescribedCodecTypes = new HashSet( - baseline.Contracts - .SelectMany(static contract => contract.Methods) - .SelectMany(static method => method.Request.Append(method.Response)) - .Select(static value => value.Type) - .Concat(baseline.Dtos.SelectMany(static dto => dto.Members).Select(static member => member.Type)), - StringComparer.Ordinal); - var currentCodecs = current.Codecs.ToDictionary(static codec => codec.Type, StringComparer.Ordinal); - foreach (var oldCodec in baseline.Codecs) - { - if (!currentCodecs.TryGetValue(oldCodec.Type, out var newCodec)) - continue; - - var opaque = - string.Equals(oldCodec.Kind, "Custom", StringComparison.Ordinal) || - string.Equals(oldCodec.Kind, "Adapter", StringComparison.Ordinal) || - string.Equals(newCodec.Kind, "Custom", StringComparison.Ordinal) || - string.Equals(newCodec.Kind, "Adapter", StringComparison.Ordinal); - if (!opaque || string.Equals(oldCodec.CodecHash, newCodec.CodecHash, StringComparison.Ordinal)) - continue; - if (directlyDescribedCodecTypes.Contains(oldCodec.Type)) - continue; - - diagnostics.Add(Change( - ContractCompatibilityKind.WireType, - newCodec.SourceLocation, - oldCodec.Type, - $"nested CodecHash changed from '{oldCodec.CodecHash}' to '{newCodec.CodecHash}'", - "restore the previous semantic Codec identity or add a new RPC payload type")); - } - - var currentEnums = current.Enums.ToDictionary(static item => item.Name, StringComparer.Ordinal); - foreach (var oldEnum in baseline.Enums) - { - if (currentEnums.TryGetValue(oldEnum.Name, out var newEnum) && - !string.Equals(oldEnum.UnderlyingType, newEnum.UnderlyingType, StringComparison.Ordinal)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.EnumUnderlyingType, - newEnum.SourceLocation, - newEnum.Name, - $"enum underlying type changed from {oldEnum.UnderlyingType} to {newEnum.UnderlyingType}", - "restore the original enum underlying type")); - } - } - - var currentUnions = current.Unions.ToDictionary(static item => item.Name, StringComparer.Ordinal); - foreach (var oldUnion in baseline.Unions) - { - if (!currentUnions.TryGetValue(oldUnion.Name, out var newUnion)) - continue; - var currentCases = newUnion.Cases.ToDictionary(static item => item.Tag); - foreach (var oldCase in oldUnion.Cases) - { - if (currentCases.TryGetValue(oldCase.Tag, out var newCase) && - !string.Equals(oldCase.Type, newCase.Type, StringComparison.Ordinal)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.UnionTag, - newCase.SourceLocation, - newUnion.Name, - $"union tag {oldCase.Tag} was reassigned from {oldCase.Type} to {newCase.Type}", - "restore the original mapping and allocate a new tag")); - } - } - } - - var currentServiceContractIds = new HashSet( - current.Services.Select(static service => service.ContractId)); - foreach (var oldService in baseline.Services - .GroupBy(static service => service.ContractId) - .Select(static group => group.First())) - { - if (currentServiceContractIds.Contains(oldService.ContractId)) - continue; - var location = current.Contracts - .FirstOrDefault(contract => contract.Id == oldService.ContractId)?.SourceLocation; - diagnostics.Add(Change( - ContractCompatibilityKind.ServiceRouteRemoved, - location, - oldService.ContractName, - $"service route for contract ID {oldService.ContractId} no longer has an [RpcService] implementation", - "restore a service implementation for the published contract route")); - } - return diagnostics; - } - - private static void CompareValues( - IReadOnlyList baseline, - IReadOnlyList current, - string item, - Location? fallbackLocation, - List diagnostics) - { - if (baseline.Count != current.Count) - { - diagnostics.Add(Change( - ContractCompatibilityKind.WireType, - fallbackLocation, - item, - $"payload element count changed from {baseline.Count} to {current.Count}", - "add a new method route for the new payload shape")); - return; - } - for (var index = 0; index < baseline.Count; index++) - { - var oldValue = baseline[index]; - var newValue = current[index]; - if (!string.Equals(oldValue.Type, newValue.Type, StringComparison.Ordinal) || - !string.Equals(oldValue.WireType, newValue.WireType, StringComparison.Ordinal) || - !string.Equals(oldValue.WireFormatId, newValue.WireFormatId, StringComparison.Ordinal) || - !string.Equals(oldValue.CodecHash, newValue.CodecHash, StringComparison.Ordinal) || - oldValue.Stream != newValue.Stream || - oldValue.Nullable != newValue.Nullable) - { - diagnostics.Add(Change( - ContractCompatibilityKind.WireType, - newValue.SourceLocation ?? fallbackLocation, - item, - $"element {index} changed from {oldValue.Type}/{oldValue.WireType}/{oldValue.WireFormatId}/{oldValue.CodecHash}/nullable={oldValue.Nullable} to {newValue.Type}/{newValue.WireType}/{newValue.WireFormatId}/{newValue.CodecHash}/nullable={newValue.Nullable}", - "restore the previous type, wire framing, or semantic Codec identity, or add a new method route")); - } - } - } - - private static bool HasRequiredContractIdentities(ContractManifestDocument manifest) - { - if (manifest.Contracts is null || - manifest.Dtos is null || - manifest.Codecs is null || - manifest.Enums is null || - manifest.Unions is null || - manifest.Services is null) - { - return false; - } - - var opaqueCodecTypes = new HashSet( - manifest.Codecs - .Where(static codec => codec is not null && - (string.Equals(codec.Kind, "Custom", StringComparison.Ordinal) || - string.Equals(codec.Kind, "Adapter", StringComparison.Ordinal)) && - IsValidCodecHash(codec.CodecHash)) - .Select(static codec => codec.Type), - StringComparer.Ordinal); - - bool HasValueIdentity(string type, string? wireFormatId, string? codecHash) - => !string.IsNullOrWhiteSpace(wireFormatId) || - (opaqueCodecTypes.Contains(type) && IsValidCodecHash(codecHash)); - - return manifest.Contracts.All(contract => - contract is not null && - contract.Methods is not null && - contract.Methods.All(method => - method is not null && - method.Request is not null && - method.Response is not null && - method.Request.All(value => - value is not null && HasValueIdentity(value.Type, value.WireFormatId, value.CodecHash)) && - HasValueIdentity(method.Response.Type, method.Response.WireFormatId, method.Response.CodecHash))) && - manifest.Dtos.All(dto => - dto is not null && - dto.Members is not null && - dto.Members.All(member => - member is not null && HasValueIdentity(member.Type, member.WireFormatId, member.CodecHash))) && - manifest.Codecs.All(static codec => - codec is not null && - !string.IsNullOrWhiteSpace(codec.Type) && - !string.IsNullOrWhiteSpace(codec.Kind) && - IsValidCodecHash(codec.CodecHash)) && - manifest.Enums.All(static item => item is not null) && - manifest.Unions.All(static union => - union is not null && union.Cases is not null && union.Cases.All(static item => item is not null)) && - manifest.Services.All(static service => service is not null); - } - - private static bool IsValidCodecHash(string? value) - { - if (value is null || value.Length != 32) - return false; - foreach (var character in value) - { - if (!((character >= '0' && character <= '9') || - (character >= 'a' && character <= 'f') || - (character >= 'A' && character <= 'F'))) - { - return false; - } - } - return true; - } - - private static string GetCodecHash(GeneratedCodecModel codec) - => new RpcHashValue(codec.CodecHashHigh, codec.CodecHashLow).ToHex(); - - private static string? GetOpaqueCodecHash( - string typeName, - IReadOnlyDictionary opaqueCodecHashes) - => opaqueCodecHashes.TryGetValue(RemoveGlobalPrefix(typeName), out var codecHash) - ? codecHash - : null; - - private static string? GetWireFormatId( - string typeName, - IReadOnlyDictionary wireFormats) - { - if (!wireFormats.TryGetValue(RemoveGlobalPrefix(typeName), out var wireFormatId)) - return "sharplink-native/v1"; - return string.IsNullOrWhiteSpace(wireFormatId) ? null : wireFormatId; - } - - private static ContractCompatibilityDiagnostic Change( - ContractCompatibilityKind kind, - Location? location, - string item, - string detail, - string fix) - => new(kind, location ?? Location.None, item, detail, fix); - - private static AdditionalText? FindBaseline(ImmutableArray files, string configuredPath) - { - string expected; - try - { - expected = Path.GetFullPath(configuredPath); - } - catch - { - expected = configuredPath; - } - foreach (var file in files) - { - string actual; - try - { - actual = Path.GetFullPath(file.Path); - } - catch - { - actual = file.Path; - } - if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) - return file; - } - return null; - } - - private static string ComputeContractManifestFingerprint(ContractManifestDocument document) - { - var fingerprint = document.SchemaFingerprint; - document.SchemaFingerprint = string.Empty; - var canonical = JsonSerializer.Serialize(document, ContractJsonOptions); - document.SchemaFingerprint = fingerprint; - return Hashing.GetSha256(canonical); - } - - private static string GetMemberWireType(GeneratedMemberModel member) - => member.Kind == GeneratedMemberKind.Complex || member.Kind == GeneratedMemberKind.String - ? "LengthDelimited" - : member.FixedSize switch - { - 1 => "Fixed1", - 2 => "Fixed2", - 4 => "Fixed4", - 8 => "Fixed8", - 16 => "Fixed16", - _ => "LengthDelimited" - }; - - private static string GetContractWireType(string typeName, string? enumUnderlyingType) - { - var type = RemoveGlobalPrefix(enumUnderlyingType ?? typeName); - return type switch - { - "System.Void" => "None", - "bool" or "byte" or "sbyte" or "System.Boolean" or "System.Byte" or "System.SByte" => "Fixed1", - "short" or "ushort" or "char" or "System.Int16" or "System.UInt16" or "System.Char" or "System.Half" => "Fixed2", - "int" or "uint" or "float" or "System.Int32" or "System.UInt32" or "System.Single" or - "System.Text.Rune" or "System.Index" or "System.DateOnly" => "Fixed4", - "long" or "ulong" or "double" or "System.Int64" or "System.UInt64" or "System.Double" or - "System.Range" or "System.DateTime" or "System.TimeOnly" or "System.TimeSpan" => "Fixed8", - "decimal" or "System.Decimal" or "System.Guid" or "System.DateTimeOffset" or - "System.Int128" or "System.UInt128" => "Fixed16", - _ => "LengthDelimited" - }; - } - -#pragma warning disable RS1035 // The opt-in SDK output path is the requested CI artifact boundary. - private static void WriteContractManifest(string outputPath, string json) - { - if (string.IsNullOrWhiteSpace(outputPath)) - return; - var fullPath = Path.GetFullPath(outputPath); - var directory = Path.GetDirectoryName(fullPath); - if (!string.IsNullOrEmpty(directory)) - Directory.CreateDirectory(directory); - if (File.Exists(fullPath) && string.Equals(File.ReadAllText(fullPath), json, StringComparison.Ordinal)) - return; - File.WriteAllText(fullPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } -#pragma warning restore RS1035 - - private static string GenerateContractManifestSource(string json) - { - var escaped = json.Replace("\"", "\"\""); - return $$""" -// -#nullable enable -namespace SharpLink.Generated; - -[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] -internal static class __SharpLinkContractManifest -{ - internal const string Json = @"{{escaped}}"; -} -"""; - } - - private static readonly JsonSerializerOptions ContractJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private readonly record struct ContractManifestOptions(string BaselinePath, string OutputPath); - - private sealed record ContractManifestAnalysis( - string Json, - string OutputPath, - ImmutableArray Diagnostics); - - private sealed record ContractManifestModels( - ImmutableArray Interfaces, - ImmutableArray Services, - ImmutableArray Codecs, - ImmutableArray Enums, - ImmutableArray Unions); - - private readonly record struct ContractCompatibilityDiagnostic( - ContractCompatibilityKind Kind, - Location? Location, - string Item, - string Detail, - string Fix); - - private enum ContractCompatibilityKind - { - BaselineInvalid, - BaselineVersion, - ContractId, - MethodId, - MemberId, - CallShape, - WireType, - Required, - EnumUnderlyingType, - UnionTag, - UnionDeclaration, - MethodRemoved, - ContractRemoved, - ServiceRouteRemoved, - ManifestOutput - } - - private sealed class ContractManifestDocument - { - public string Format { get; set; } = ContractManifestFormat; - public int Version { get; set; } = ContractManifestFormatVersion; - public string GeneratorVersion { get; set; } = ExecutingGeneratorVersion; - public string SchemaFingerprint { get; set; } = string.Empty; - public List Contracts { get; set; } = []; - public List Dtos { get; set; } = []; - [JsonRequired] - public List Codecs { get; set; } = []; - public List Enums { get; set; } = []; - public List Unions { get; set; } = []; - public List Services { get; set; } = []; - } - - private sealed class ContractManifestContract - { - public string Name { get; set; } = string.Empty; - public long Id { get; set; } - public string Fingerprint { get; set; } = string.Empty; - public List Methods { get; set; } = []; - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestMethod - { - public string Name { get; set; } = string.Empty; - public long Id { get; set; } - public string Shape { get; set; } = string.Empty; - public string Fingerprint { get; set; } = string.Empty; - public List Request { get; set; } = []; - public ContractManifestValue Response { get; set; } = new(); - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestValue - { - public string Name { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string WireType { get; set; } = string.Empty; - public string? WireFormatId { get; set; } - public string? CodecHash { get; set; } - public bool Nullable { get; set; } - public bool Stream { get; set; } - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestDto - { - public string Name { get; set; } = string.Empty; - public string Fingerprint { get; set; } = string.Empty; - public List Members { get; set; } = []; - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestCodec - { - public string Type { get; set; } = string.Empty; - public string Kind { get; set; } = string.Empty; - public string CodecHash { get; set; } = string.Empty; - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestMember - { - public string Name { get; set; } = string.Empty; - public uint Id { get; set; } - public string Type { get; set; } = string.Empty; - public string WireType { get; set; } = string.Empty; - public string? WireFormatId { get; set; } - public string? CodecHash { get; set; } - public bool Nullable { get; set; } - public bool Required { get; set; } - public bool ExplicitId { get; set; } - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestEnum - { - public string Name { get; set; } = string.Empty; - public string UnderlyingType { get; set; } = string.Empty; - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestUnion - { - public string Name { get; set; } = string.Empty; - public List Cases { get; set; } = []; - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestUnionCase - { - public int Tag { get; set; } - public string Type { get; set; } = string.Empty; - [JsonIgnore] public string? InvalidDetail { get; set; } - [JsonIgnore] public Location? SourceLocation { get; set; } - } - - private sealed class ContractManifestService - { - public long ContractId { get; set; } - public string ContractName { get; set; } = string.Empty; - public string Implementation { get; set; } = string.Empty; - [JsonIgnore] public Location? SourceLocation { get; set; } - } } From 5c8c77f5b862026fcee45711ba6a7c1acf7c94d7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:31:20 +0800 Subject: [PATCH 091/399] refactor: add contract manifest compatibility partial --- ...enerator.ContractManifest.Compatibility.cs | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs new file mode 100644 index 000000000..62e184e3f --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -0,0 +1,389 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static List ValidateCurrentContractManifest( + ContractManifestDocument current) + { + var diagnostics = new List(); + foreach (var group in current.Contracts.GroupBy(static item => item.Id).Where(static group => group.Count() > 1)) + { + foreach (var contract in group.Skip(1)) + { + diagnostics.Add(new ContractCompatibilityDiagnostic( + ContractCompatibilityKind.ContractId, + contract.SourceLocation, + contract.Name, + $"contract ID {group.Key} is already used by '{group.First().Name}'", + "assign unique contract names or explicit stable IDs")); + } + } + foreach (var contract in current.Contracts) + { + foreach (var group in contract.Methods.GroupBy(static item => item.Id).Where(static group => group.Count() > 1)) + { + foreach (var method in group.Skip(1)) + { + diagnostics.Add(new ContractCompatibilityDiagnostic( + ContractCompatibilityKind.MethodId, + method.SourceLocation, + $"{contract.Name}.{method.Name}", + $"method ID {group.Key} is already used by '{group.First().Name}'", + "change the signature so every RPC route has a unique stable ID")); + } + } + } + foreach (var union in current.Unions) + { + foreach (var item in union.Cases.Where(static item => item.InvalidDetail is not null)) + { + diagnostics.Add(new ContractCompatibilityDiagnostic( + ContractCompatibilityKind.UnionDeclaration, + item.SourceLocation, + union.Name, + item.InvalidDetail!, + "use a positive tag and a closed concrete case type assignable to the annotated union")); + } + foreach (var group in union.Cases.GroupBy(static item => item.Tag).Where(static group => group.Count() > 1)) + { + foreach (var item in group.Skip(1)) + { + diagnostics.Add(new ContractCompatibilityDiagnostic( + ContractCompatibilityKind.UnionTag, + item.SourceLocation, + union.Name, + $"union tag {group.Key} is already assigned to '{group.First().Type}'", + "allocate a unique tag for every union case")); + } + } + foreach (var group in union.Cases + .Where(static item => item.InvalidDetail is null) + .GroupBy(static item => item.Type, StringComparer.Ordinal) + .Where(static group => group.Select(static item => item.Tag).Distinct().Count() > 1)) + { + foreach (var item in group.OrderBy(static item => item.Tag).Skip(1)) + { + diagnostics.Add(new ContractCompatibilityDiagnostic( + ContractCompatibilityKind.UnionDeclaration, + item.SourceLocation, + union.Name, + $"case type '{item.Type}' is already assigned to tag {group.Min(static candidate => candidate.Tag)}", + "assign each concrete case type to exactly one stable tag")); + } + } + } + return diagnostics; + } + + private static IEnumerable CompareContractManifests( + ContractManifestDocument baseline, + ContractManifestDocument current) + { + var diagnostics = new List(); + var currentContractsById = current.Contracts + .GroupBy(static item => item.Id) + .ToDictionary(static group => group.Key, static group => group.First()); + var currentContractsByName = current.Contracts + .GroupBy(static item => item.Name, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); + foreach (var oldContract in baseline.Contracts) + { + if (!currentContractsById.TryGetValue(oldContract.Id, out var newContract)) + { + if (currentContractsByName.TryGetValue(oldContract.Name, out newContract)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.ContractId, + newContract.SourceLocation, + oldContract.Name, + $"contract ID changed from {oldContract.Id} to {newContract.Id}", + "restore the original contract name/ID or publish a new contract")); + } + else + { + var renameCandidates = current.Contracts + .Where(candidate => candidate.Methods.Count == oldContract.Methods.Count && + candidate.Methods.Select(static method => method.Name) + .SequenceEqual(oldContract.Methods.Select(static method => method.Name))) + .Take(2) + .ToArray(); + if (renameCandidates.Length != 1) + { + diagnostics.Add(Change( + ContractCompatibilityKind.ContractRemoved, + Location.None, + oldContract.Name, + $"existing contract ID {oldContract.Id} and all of its routes were removed", + "restore the contract and deprecate it without removing its published routes")); + continue; + } + newContract = renameCandidates[0]; + diagnostics.Add(Change( + ContractCompatibilityKind.ContractId, + newContract.SourceLocation, + newContract.Name, + $"contract '{oldContract.Name}' changed ID from {oldContract.Id} to {newContract.Id} after renaming", + "restore the original contract identity or add a separate new contract")); + } + } + + var currentMethodsById = newContract.Methods + .GroupBy(static item => item.Id) + .ToDictionary(static group => group.Key, static group => group.First()); + var currentMethodsByName = newContract.Methods + .GroupBy(static item => item.Name, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); + foreach (var oldMethod in oldContract.Methods) + { + if (!currentMethodsById.TryGetValue(oldMethod.Id, out var newMethod)) + { + if (currentMethodsByName.TryGetValue(oldMethod.Name, out newMethod)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.MethodId, + newMethod.SourceLocation, + $"{newContract.Name}.{newMethod.Name}", + $"method ID changed from {oldMethod.Id} to {newMethod.Id}", + "restore the previous signature/ID or add a new method instead")); + } + else + { + diagnostics.Add(Change( + ContractCompatibilityKind.MethodRemoved, + newContract.SourceLocation, + $"{oldContract.Name}.{oldMethod.Name}", + $"existing method ID {oldMethod.Id} was removed", + "restore the method and deprecate it without removing its route")); + continue; + } + } + if (!string.Equals(oldMethod.Shape, newMethod.Shape, StringComparison.Ordinal)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.CallShape, + newMethod.SourceLocation, + $"{newContract.Name}.{newMethod.Name}", + $"RPC shape changed from {oldMethod.Shape} to {newMethod.Shape}", + "add a new method for the new Unary/Streaming shape")); + } + CompareValues(oldMethod.Request, newMethod.Request, + $"{newContract.Name}.{newMethod.Name} request", newMethod.SourceLocation, diagnostics); + CompareValues([oldMethod.Response], [newMethod.Response], + $"{newContract.Name}.{newMethod.Name} response", newMethod.SourceLocation, diagnostics); + } + } + + var currentDtos = current.Dtos.ToDictionary(static item => item.Name, StringComparer.Ordinal); + foreach (var oldDto in baseline.Dtos) + { + if (!currentDtos.TryGetValue(oldDto.Name, out var newDto)) + continue; + var newById = newDto.Members.ToDictionary(static item => item.Id); + var newByName = newDto.Members.ToDictionary(static item => item.Name, StringComparer.Ordinal); + var matchedNewIds = new HashSet(); + foreach (var oldMember in oldDto.Members) + { + if (newById.TryGetValue(oldMember.Id, out var newMember)) + { + matchedNewIds.Add(newMember.Id); + if (!string.Equals(oldMember.Type, newMember.Type, StringComparison.Ordinal) || + !string.Equals(oldMember.WireType, newMember.WireType, StringComparison.Ordinal) || + !string.Equals(oldMember.WireFormatId, newMember.WireFormatId, StringComparison.Ordinal) || + !string.Equals(oldMember.CodecHash, newMember.CodecHash, StringComparison.Ordinal)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + newMember.SourceLocation, + $"{newDto.Name}.{newMember.Name}", + $"member {oldMember.Id} changed from {oldMember.Type}/{oldMember.WireType}/{oldMember.WireFormatId}/{oldMember.CodecHash} to {newMember.Type}/{newMember.WireType}/{newMember.WireFormatId}/{newMember.CodecHash}", + "restore the old wire type or semantic Codec identity, or add a new optional member ID")); + } + if (!oldMember.Required && newMember.Required) + { + diagnostics.Add(Change( + ContractCompatibilityKind.Required, + newMember.SourceLocation, + $"{newDto.Name}.{newMember.Name}", + $"existing member {oldMember.Id} became required", + "keep the field optional and enforce requirements in application code")); + } + continue; + } + + if (newByName.TryGetValue(oldMember.Name, out newMember)) + { + matchedNewIds.Add(newMember.Id); + diagnostics.Add(Change( + ContractCompatibilityKind.MemberId, + newMember.SourceLocation, + $"{newDto.Name}.{newMember.Name}", + $"member ID changed from {oldMember.Id} to {newMember.Id}", + $"annotate the member with [RpcMember({oldMember.Id})]")); + continue; + } + + var renamed = newDto.Members + .Where(candidate => !matchedNewIds.Contains(candidate.Id) && !candidate.ExplicitId) + .Where(candidate => string.Equals(candidate.Type, oldMember.Type, StringComparison.Ordinal) && + string.Equals(candidate.WireType, oldMember.WireType, StringComparison.Ordinal) && + candidate.Required == oldMember.Required) + .Take(2) + .ToArray(); + if (renamed.Length == 1) + { + matchedNewIds.Add(renamed[0].Id); + diagnostics.Add(Change( + ContractCompatibilityKind.MemberId, + renamed[0].SourceLocation, + $"{newDto.Name}.{renamed[0].Name}", + $"renaming '{oldMember.Name}' changed the default member ID {oldMember.Id} to {renamed[0].Id}", + $"annotate the renamed member with [RpcMember({oldMember.Id})]")); + } + else if (oldMember.Required) + { + diagnostics.Add(Change( + ContractCompatibilityKind.Required, + newDto.SourceLocation, + $"{oldDto.Name}.{oldMember.Name}", + $"required member {oldMember.Id} was removed", + "restore the required member or introduce a new DTO version")); + } + } + + var oldIds = new HashSet(oldDto.Members.Select(static item => item.Id)); + foreach (var newMember in newDto.Members.Where(item => !oldIds.Contains(item.Id) && item.Required)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.Required, + newMember.SourceLocation, + $"{newDto.Name}.{newMember.Name}", + $"new member {newMember.Id} is required", + "make the new member optional so older payloads remain readable")); + } + } + + var directlyDescribedCodecTypes = new HashSet( + baseline.Contracts + .SelectMany(static contract => contract.Methods) + .SelectMany(static method => method.Request.Append(method.Response)) + .Select(static value => value.Type) + .Concat(baseline.Dtos.SelectMany(static dto => dto.Members).Select(static member => member.Type)), + StringComparer.Ordinal); + var currentCodecs = current.Codecs.ToDictionary(static codec => codec.Type, StringComparer.Ordinal); + foreach (var oldCodec in baseline.Codecs) + { + if (!currentCodecs.TryGetValue(oldCodec.Type, out var newCodec)) + continue; + + var opaque = + string.Equals(oldCodec.Kind, "Custom", StringComparison.Ordinal) || + string.Equals(oldCodec.Kind, "Adapter", StringComparison.Ordinal) || + string.Equals(newCodec.Kind, "Custom", StringComparison.Ordinal) || + string.Equals(newCodec.Kind, "Adapter", StringComparison.Ordinal); + if (!opaque || string.Equals(oldCodec.CodecHash, newCodec.CodecHash, StringComparison.Ordinal)) + continue; + if (directlyDescribedCodecTypes.Contains(oldCodec.Type)) + continue; + + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + newCodec.SourceLocation, + oldCodec.Type, + $"nested CodecHash changed from '{oldCodec.CodecHash}' to '{newCodec.CodecHash}'", + "restore the previous semantic Codec identity or add a new RPC payload type")); + } + + var currentEnums = current.Enums.ToDictionary(static item => item.Name, StringComparer.Ordinal); + foreach (var oldEnum in baseline.Enums) + { + if (currentEnums.TryGetValue(oldEnum.Name, out var newEnum) && + !string.Equals(oldEnum.UnderlyingType, newEnum.UnderlyingType, StringComparison.Ordinal)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.EnumUnderlyingType, + newEnum.SourceLocation, + newEnum.Name, + $"enum underlying type changed from {oldEnum.UnderlyingType} to {newEnum.UnderlyingType}", + "restore the original enum underlying type")); + } + } + + var currentUnions = current.Unions.ToDictionary(static item => item.Name, StringComparer.Ordinal); + foreach (var oldUnion in baseline.Unions) + { + if (!currentUnions.TryGetValue(oldUnion.Name, out var newUnion)) + continue; + var currentCases = newUnion.Cases.ToDictionary(static item => item.Tag); + foreach (var oldCase in oldUnion.Cases) + { + if (currentCases.TryGetValue(oldCase.Tag, out var newCase) && + !string.Equals(oldCase.Type, newCase.Type, StringComparison.Ordinal)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.UnionTag, + newCase.SourceLocation, + newUnion.Name, + $"union tag {oldCase.Tag} was reassigned from {oldCase.Type} to {newCase.Type}", + "restore the original mapping and allocate a new tag")); + } + } + } + + var currentServiceContractIds = new HashSet( + current.Services.Select(static service => service.ContractId)); + foreach (var oldService in baseline.Services + .GroupBy(static service => service.ContractId) + .Select(static group => group.First())) + { + if (currentServiceContractIds.Contains(oldService.ContractId)) + continue; + var location = current.Contracts + .FirstOrDefault(contract => contract.Id == oldService.ContractId)?.SourceLocation; + diagnostics.Add(Change( + ContractCompatibilityKind.ServiceRouteRemoved, + location, + oldService.ContractName, + $"service route for contract ID {oldService.ContractId} no longer has an [RpcService] implementation", + "restore a service implementation for the published contract route")); + } + return diagnostics; + } + + private static void CompareValues( + IReadOnlyList baseline, + IReadOnlyList current, + string item, + Location? fallbackLocation, + List diagnostics) + { + if (baseline.Count != current.Count) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + fallbackLocation, + item, + $"payload element count changed from {baseline.Count} to {current.Count}", + "add a new method route for the new payload shape")); + return; + } + for (var index = 0; index < baseline.Count; index++) + { + var oldValue = baseline[index]; + var newValue = current[index]; + if (!string.Equals(oldValue.Type, newValue.Type, StringComparison.Ordinal) || + !string.Equals(oldValue.WireType, newValue.WireType, StringComparison.Ordinal) || + !string.Equals(oldValue.WireFormatId, newValue.WireFormatId, StringComparison.Ordinal) || + !string.Equals(oldValue.CodecHash, newValue.CodecHash, StringComparison.Ordinal) || + oldValue.Stream != newValue.Stream || + oldValue.Nullable != newValue.Nullable) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + newValue.SourceLocation ?? fallbackLocation, + item, + $"element {index} changed from {oldValue.Type}/{oldValue.WireType}/{oldValue.WireFormatId}/{oldValue.CodecHash}/nullable={oldValue.Nullable} to {newValue.Type}/{newValue.WireType}/{newValue.WireFormatId}/{newValue.CodecHash}/nullable={newValue.Nullable}", + "restore the previous type, wire framing, or semantic Codec identity, or add a new method route")); + } + } + } +} From c0f8f3f06a54c4e4d5166745c3177a80e6d889eb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:31:56 +0800 Subject: [PATCH 092/399] refactor: add contract manifest infrastructure partial --- ...nerator.ContractManifest.Infrastructure.cs | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs new file mode 100644 index 000000000..329f581fe --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs @@ -0,0 +1,355 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static bool HasRequiredContractIdentities(ContractManifestDocument manifest) + { + if (manifest.Contracts is null || + manifest.Dtos is null || + manifest.Codecs is null || + manifest.Enums is null || + manifest.Unions is null || + manifest.Services is null) + { + return false; + } + + var opaqueCodecTypes = new HashSet( + manifest.Codecs + .Where(static codec => codec is not null && + (string.Equals(codec.Kind, "Custom", StringComparison.Ordinal) || + string.Equals(codec.Kind, "Adapter", StringComparison.Ordinal)) && + IsValidCodecHash(codec.CodecHash)) + .Select(static codec => codec.Type), + StringComparer.Ordinal); + + bool HasValueIdentity(string type, string? wireFormatId, string? codecHash) + => !string.IsNullOrWhiteSpace(wireFormatId) || + (opaqueCodecTypes.Contains(type) && IsValidCodecHash(codecHash)); + + return manifest.Contracts.All(contract => + contract is not null && + contract.Methods is not null && + contract.Methods.All(method => + method is not null && + method.Request is not null && + method.Response is not null && + method.Request.All(value => + value is not null && HasValueIdentity(value.Type, value.WireFormatId, value.CodecHash)) && + HasValueIdentity(method.Response.Type, method.Response.WireFormatId, method.Response.CodecHash))) && + manifest.Dtos.All(dto => + dto is not null && + dto.Members is not null && + dto.Members.All(member => + member is not null && HasValueIdentity(member.Type, member.WireFormatId, member.CodecHash))) && + manifest.Codecs.All(static codec => + codec is not null && + !string.IsNullOrWhiteSpace(codec.Type) && + !string.IsNullOrWhiteSpace(codec.Kind) && + IsValidCodecHash(codec.CodecHash)) && + manifest.Enums.All(static item => item is not null) && + manifest.Unions.All(static union => + union is not null && union.Cases is not null && union.Cases.All(static item => item is not null)) && + manifest.Services.All(static service => service is not null); + } + + private static bool IsValidCodecHash(string? value) + { + if (value is null || value.Length != 32) + return false; + foreach (var character in value) + { + if (!((character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'))) + { + return false; + } + } + return true; + } + + private static string GetCodecHash(GeneratedCodecModel codec) + => new RpcHashValue(codec.CodecHashHigh, codec.CodecHashLow).ToHex(); + + private static string? GetOpaqueCodecHash( + string typeName, + IReadOnlyDictionary opaqueCodecHashes) + => opaqueCodecHashes.TryGetValue(RemoveGlobalPrefix(typeName), out var codecHash) + ? codecHash + : null; + + private static string? GetWireFormatId( + string typeName, + IReadOnlyDictionary wireFormats) + { + if (!wireFormats.TryGetValue(RemoveGlobalPrefix(typeName), out var wireFormatId)) + return "sharplink-native/v1"; + return string.IsNullOrWhiteSpace(wireFormatId) ? null : wireFormatId; + } + + private static ContractCompatibilityDiagnostic Change( + ContractCompatibilityKind kind, + Location? location, + string item, + string detail, + string fix) + => new(kind, location ?? Location.None, item, detail, fix); + + private static AdditionalText? FindBaseline(ImmutableArray files, string configuredPath) + { + string expected; + try + { + expected = Path.GetFullPath(configuredPath); + } + catch + { + expected = configuredPath; + } + foreach (var file in files) + { + string actual; + try + { + actual = Path.GetFullPath(file.Path); + } + catch + { + actual = file.Path; + } + if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + return file; + } + return null; + } + + private static string ComputeContractManifestFingerprint(ContractManifestDocument document) + { + var fingerprint = document.SchemaFingerprint; + document.SchemaFingerprint = string.Empty; + var canonical = JsonSerializer.Serialize(document, ContractJsonOptions); + document.SchemaFingerprint = fingerprint; + return Hashing.GetSha256(canonical); + } + + private static string GetMemberWireType(GeneratedMemberModel member) + => member.Kind == GeneratedMemberKind.Complex || member.Kind == GeneratedMemberKind.String + ? "LengthDelimited" + : member.FixedSize switch + { + 1 => "Fixed1", + 2 => "Fixed2", + 4 => "Fixed4", + 8 => "Fixed8", + 16 => "Fixed16", + _ => "LengthDelimited" + }; + + private static string GetContractWireType(string typeName, string? enumUnderlyingType) + { + var type = RemoveGlobalPrefix(enumUnderlyingType ?? typeName); + return type switch + { + "System.Void" => "None", + "bool" or "byte" or "sbyte" or "System.Boolean" or "System.Byte" or "System.SByte" => "Fixed1", + "short" or "ushort" or "char" or "System.Int16" or "System.UInt16" or "System.Char" or "System.Half" => "Fixed2", + "int" or "uint" or "float" or "System.Int32" or "System.UInt32" or "System.Single" or + "System.Text.Rune" or "System.Index" or "System.DateOnly" => "Fixed4", + "long" or "ulong" or "double" or "System.Int64" or "System.UInt64" or "System.Double" or + "System.Range" or "System.DateTime" or "System.TimeOnly" or "System.TimeSpan" => "Fixed8", + "decimal" or "System.Decimal" or "System.Guid" or "System.DateTimeOffset" or + "System.Int128" or "System.UInt128" => "Fixed16", + _ => "LengthDelimited" + }; + } + +#pragma warning disable RS1035 // The opt-in SDK output path is the requested CI artifact boundary. + private static void WriteContractManifest(string outputPath, string json) + { + if (string.IsNullOrWhiteSpace(outputPath)) + return; + var fullPath = Path.GetFullPath(outputPath); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + if (File.Exists(fullPath) && string.Equals(File.ReadAllText(fullPath), json, StringComparison.Ordinal)) + return; + File.WriteAllText(fullPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } +#pragma warning restore RS1035 + + private static string GenerateContractManifestSource(string json) + { + var escaped = json.Replace("\"", "\"\""); + return $$""" +// +#nullable enable +namespace SharpLink.Generated; + +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +internal static class __SharpLinkContractManifest +{ + internal const string Json = @"{{escaped}}"; +} +"""; + } + + private static readonly JsonSerializerOptions ContractJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly record struct ContractManifestOptions(string BaselinePath, string OutputPath); + + private sealed record ContractManifestAnalysis( + string Json, + string OutputPath, + ImmutableArray Diagnostics); + + private sealed record ContractManifestModels( + ImmutableArray Interfaces, + ImmutableArray Services, + ImmutableArray Codecs, + ImmutableArray Enums, + ImmutableArray Unions); + + private readonly record struct ContractCompatibilityDiagnostic( + ContractCompatibilityKind Kind, + Location? Location, + string Item, + string Detail, + string Fix); + + private enum ContractCompatibilityKind + { + BaselineInvalid, + BaselineVersion, + ContractId, + MethodId, + MemberId, + CallShape, + WireType, + Required, + EnumUnderlyingType, + UnionTag, + UnionDeclaration, + MethodRemoved, + ContractRemoved, + ServiceRouteRemoved, + ManifestOutput + } + + private sealed class ContractManifestDocument + { + public string Format { get; set; } = ContractManifestFormat; + public int Version { get; set; } = ContractManifestFormatVersion; + public string GeneratorVersion { get; set; } = ExecutingGeneratorVersion; + public string SchemaFingerprint { get; set; } = string.Empty; + public List Contracts { get; set; } = []; + public List Dtos { get; set; } = []; + [JsonRequired] + public List Codecs { get; set; } = []; + public List Enums { get; set; } = []; + public List Unions { get; set; } = []; + public List Services { get; set; } = []; + } + + private sealed class ContractManifestContract + { + public string Name { get; set; } = string.Empty; + public long Id { get; set; } + public string Fingerprint { get; set; } = string.Empty; + public List Methods { get; set; } = []; + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestMethod + { + public string Name { get; set; } = string.Empty; + public long Id { get; set; } + public string Shape { get; set; } = string.Empty; + public string Fingerprint { get; set; } = string.Empty; + public List Request { get; set; } = []; + public ContractManifestValue Response { get; set; } = new(); + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestValue + { + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string WireType { get; set; } = string.Empty; + public string? WireFormatId { get; set; } + public string? CodecHash { get; set; } + public bool Nullable { get; set; } + public bool Stream { get; set; } + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestDto + { + public string Name { get; set; } = string.Empty; + public string Fingerprint { get; set; } = string.Empty; + public List Members { get; set; } = []; + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestCodec + { + public string Type { get; set; } = string.Empty; + public string Kind { get; set; } = string.Empty; + public string CodecHash { get; set; } = string.Empty; + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestMember + { + public string Name { get; set; } = string.Empty; + public uint Id { get; set; } + public string Type { get; set; } = string.Empty; + public string WireType { get; set; } = string.Empty; + public string? WireFormatId { get; set; } + public string? CodecHash { get; set; } + public bool Nullable { get; set; } + public bool Required { get; set; } + public bool ExplicitId { get; set; } + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestEnum + { + public string Name { get; set; } = string.Empty; + public string UnderlyingType { get; set; } = string.Empty; + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestUnion + { + public string Name { get; set; } = string.Empty; + public List Cases { get; set; } = []; + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestUnionCase + { + public int Tag { get; set; } + public string Type { get; set; } = string.Empty; + [JsonIgnore] public string? InvalidDetail { get; set; } + [JsonIgnore] public Location? SourceLocation { get; set; } + } + + private sealed class ContractManifestService + { + public long ContractId { get; set; } + public string ContractName { get; set; } = string.Empty; + public string Implementation { get; set; } = string.Empty; + [JsonIgnore] public Location? SourceLocation { get; set; } + } +} From 0497fff6a7b91ce0918138d9cc586d76d2b38450 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:39:30 +0800 Subject: [PATCH 093/399] chore: remove obsolete contract manifest allowance --- eng/maintainability/baseline.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/eng/maintainability/baseline.json b/eng/maintainability/baseline.json index dd6898f28..5c648218f 100644 --- a/eng/maintainability/baseline.json +++ b/eng/maintainability/baseline.json @@ -82,12 +82,6 @@ "maxLoc": 1099, "reason": "Existing dev debt captured by issue #350." }, - { - "domain": "source", - "path": "src/SharpLink.Generator/RpcGenerator.ContractManifest.cs", - "maxLoc": 1098, - "reason": "Existing dev debt captured by issue #350." - }, { "domain": "source", "path": "src/SharpLink.Client/SharpClientBuilder.cs", From 4ffdf40d234e5231a0c751a0dcefb61df77b1883 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:01:36 +0800 Subject: [PATCH 094/399] Remove legacy wire-format identity from contract manifest generation --- src/SharpLink.Generator/RpcGenerator.ContractManifest.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index d04f8c728..99696df7c 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -153,7 +153,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( ContractCompatibilityKind.BaselineInvalid, Location.None, options.BaselinePath, - "one or more payload, DTO member, or Codec entries are missing required wire framing or semantic identity", + "one or more Codec entries or opaque payload references are missing required semantic identity", "regenerate the baseline with the current SharpLink SDK")); } else if (string.IsNullOrWhiteSpace(baseline.SchemaFingerprint) || @@ -211,10 +211,6 @@ private static ContractManifestDocument CreateContractManifest( static group => group.Key, static group => group.First(), StringComparer.Ordinal); - var wireFormats = codecsByType.ToDictionary( - static pair => pair.Key, - static pair => pair.Value.WireFormatId, - StringComparer.Ordinal); var opaqueCodecHashes = codecsByType .Where(static pair => pair.Value.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) .ToDictionary( @@ -259,7 +255,6 @@ private static ContractManifestDocument CreateContractManifest( WireType = GetContractWireType(typeName, parameter.IsStream ? parameter.StreamItemEnumUnderlyingType : parameter.EnumUnderlyingType), - WireFormatId = GetWireFormatId(typeName, wireFormats), CodecHash = GetOpaqueCodecHash(typeName, opaqueCodecHashes), Nullable = parameter.PayloadNullable, Stream = parameter.IsStream, @@ -280,7 +275,6 @@ private static ContractManifestDocument CreateContractManifest( method.IsStreamReturn ? method.StreamItemEnumUnderlyingType : method.ResponseEnumUnderlyingType), - WireFormatId = GetWireFormatId(responseType, wireFormats), CodecHash = GetOpaqueCodecHash(responseType, opaqueCodecHashes), Nullable = method.ResponseNullable, Stream = method.IsStreamReturn, @@ -309,7 +303,6 @@ private static ContractManifestDocument CreateContractManifest( Id = member.FieldId, Type = RemoveGlobalPrefix(member.TypeName), WireType = GetMemberWireType(member), - WireFormatId = GetWireFormatId(member.TypeName, wireFormats), CodecHash = GetOpaqueCodecHash(member.TypeName, opaqueCodecHashes), Nullable = member.Nullable, Required = member.Required, From a0961d080bd81b041e9ed80f317d41c7883b7ce1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:02:12 +0800 Subject: [PATCH 095/399] Remove legacy wire-format fields from contract manifest model --- ...nerator.ContractManifest.Infrastructure.cs | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs index 329f581fe..eb24366ed 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs @@ -27,9 +27,8 @@ manifest.Unions is null || .Select(static codec => codec.Type), StringComparer.Ordinal); - bool HasValueIdentity(string type, string? wireFormatId, string? codecHash) - => !string.IsNullOrWhiteSpace(wireFormatId) || - (opaqueCodecTypes.Contains(type) && IsValidCodecHash(codecHash)); + bool HasValueIdentity(string type, string? codecHash) + => !opaqueCodecTypes.Contains(type) || IsValidCodecHash(codecHash); return manifest.Contracts.All(contract => contract is not null && @@ -39,13 +38,13 @@ method is not null && method.Request is not null && method.Response is not null && method.Request.All(value => - value is not null && HasValueIdentity(value.Type, value.WireFormatId, value.CodecHash)) && - HasValueIdentity(method.Response.Type, method.Response.WireFormatId, method.Response.CodecHash))) && + value is not null && HasValueIdentity(value.Type, value.CodecHash)) && + HasValueIdentity(method.Response.Type, method.Response.CodecHash))) && manifest.Dtos.All(dto => dto is not null && dto.Members is not null && dto.Members.All(member => - member is not null && HasValueIdentity(member.Type, member.WireFormatId, member.CodecHash))) && + member is not null && HasValueIdentity(member.Type, member.CodecHash))) && manifest.Codecs.All(static codec => codec is not null && !string.IsNullOrWhiteSpace(codec.Type) && @@ -83,15 +82,6 @@ private static string GetCodecHash(GeneratedCodecModel codec) ? codecHash : null; - private static string? GetWireFormatId( - string typeName, - IReadOnlyDictionary wireFormats) - { - if (!wireFormats.TryGetValue(RemoveGlobalPrefix(typeName), out var wireFormatId)) - return "sharplink-native/v1"; - return string.IsNullOrWhiteSpace(wireFormatId) ? null : wireFormatId; - } - private static ContractCompatibilityDiagnostic Change( ContractCompatibilityKind kind, Location? location, @@ -286,7 +276,6 @@ private sealed class ContractManifestValue public string Name { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string WireType { get; set; } = string.Empty; - public string? WireFormatId { get; set; } public string? CodecHash { get; set; } public bool Nullable { get; set; } public bool Stream { get; set; } @@ -315,7 +304,6 @@ private sealed class ContractManifestMember public uint Id { get; set; } public string Type { get; set; } = string.Empty; public string WireType { get; set; } = string.Empty; - public string? WireFormatId { get; set; } public string? CodecHash { get; set; } public bool Nullable { get; set; } public bool Required { get; set; } From 491128ae91c756c121c2fd7453150a3e4dbfbbfc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:02:56 +0800 Subject: [PATCH 096/399] Remove wire-format identity from contract manifest compatibility --- .../RpcGenerator.ContractManifest.Compatibility.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs index 62e184e3f..c15ef4e83 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -188,14 +188,13 @@ private static IEnumerable CompareContractManif matchedNewIds.Add(newMember.Id); if (!string.Equals(oldMember.Type, newMember.Type, StringComparison.Ordinal) || !string.Equals(oldMember.WireType, newMember.WireType, StringComparison.Ordinal) || - !string.Equals(oldMember.WireFormatId, newMember.WireFormatId, StringComparison.Ordinal) || !string.Equals(oldMember.CodecHash, newMember.CodecHash, StringComparison.Ordinal)) { diagnostics.Add(Change( ContractCompatibilityKind.WireType, newMember.SourceLocation, $"{newDto.Name}.{newMember.Name}", - $"member {oldMember.Id} changed from {oldMember.Type}/{oldMember.WireType}/{oldMember.WireFormatId}/{oldMember.CodecHash} to {newMember.Type}/{newMember.WireType}/{newMember.WireFormatId}/{newMember.CodecHash}", + $"member {oldMember.Id} changed from {oldMember.Type}/{oldMember.WireType}/{oldMember.CodecHash} to {newMember.Type}/{newMember.WireType}/{newMember.CodecHash}", "restore the old wire type or semantic Codec identity, or add a new optional member ID")); } if (!oldMember.Required && newMember.Required) @@ -372,7 +371,6 @@ private static void CompareValues( var newValue = current[index]; if (!string.Equals(oldValue.Type, newValue.Type, StringComparison.Ordinal) || !string.Equals(oldValue.WireType, newValue.WireType, StringComparison.Ordinal) || - !string.Equals(oldValue.WireFormatId, newValue.WireFormatId, StringComparison.Ordinal) || !string.Equals(oldValue.CodecHash, newValue.CodecHash, StringComparison.Ordinal) || oldValue.Stream != newValue.Stream || oldValue.Nullable != newValue.Nullable) @@ -381,7 +379,7 @@ private static void CompareValues( ContractCompatibilityKind.WireType, newValue.SourceLocation ?? fallbackLocation, item, - $"element {index} changed from {oldValue.Type}/{oldValue.WireType}/{oldValue.WireFormatId}/{oldValue.CodecHash}/nullable={oldValue.Nullable} to {newValue.Type}/{newValue.WireType}/{newValue.WireFormatId}/{newValue.CodecHash}/nullable={newValue.Nullable}", + $"element {index} changed from {oldValue.Type}/{oldValue.WireType}/{oldValue.CodecHash}/nullable={oldValue.Nullable} to {newValue.Type}/{newValue.WireType}/{newValue.CodecHash}/nullable={newValue.Nullable}", "restore the previous type, wire framing, or semantic Codec identity, or add a new method route")); } } From 2bb68909c189f3c8c5c6d80caf46c3e76125d8d4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:10:09 +0800 Subject: [PATCH 097/399] Migrate codec route tests off legacy wire identities --- .../RpcCodecRouteTests.cs | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecRouteTests.cs b/test/SharpLink.Generator.Tests/RpcCodecRouteTests.cs index 27af36f87..99d14ce79 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecRouteTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecRouteTests.cs @@ -22,13 +22,13 @@ public interface IManagedRouteContract : SharpLink.Sdk.IService ValueTask Echo(int id, ManagedPayload value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000001UL, 0x2000000000000001UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.managed/v1"; - public override string WireFormatId => "route-managed-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.managed/v1\", \"route-managed-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.managed/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(RouteAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); @@ -38,8 +38,8 @@ public sealed class RouteAdapter : TestRouteAdapterBase "ordinary DTOs remain configurable and must be eligible for a Managed route"); Ensure(!generated.Contains("__codec_id = codecs.GetCodec();", StringComparison.Ordinal), "fixed framework primitive request fields must remain on the inline native path"); - Ensure(generated.Contains("route-managed-wire/v1", StringComparison.Ordinal), - "the selected configurable DTO route identity must enter generated metadata"); + Ensure(generated.Contains("public string? AdapterId => \"route.managed/v1\";", StringComparison.Ordinal), + "the selected configurable DTO route must use the registered Adapter"); return Task.CompletedTask; } @@ -59,10 +59,10 @@ public interface IPointRouteContract : SharpLink.Sdk.IService ValueTask Echo(Point value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000002UL, 0x2000000000000002UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.unmanaged/v1"; - public override string WireFormatId => "route-unmanaged-wire/v1"; } """; var withoutRoute = string.Join("\n", RunGeneratorAndGetSources(BuildRouteSource(contract))); @@ -70,7 +70,7 @@ public sealed class RouteAdapter : TestRouteAdapterBase "without a route a custom unmanaged payload must retain the UnsafeBlit fallback"); var routed = AddAssemblyAttributes(BuildRouteSource(contract), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.unmanaged/v1\", \"route-unmanaged-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.unmanaged/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Unmanaged, typeof(RouteAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(routed)); Ensure(generated.Contains("CreateCodec()", StringComparison.Ordinal), @@ -97,13 +97,13 @@ public interface IManagedRouteContract : SharpLink.Sdk.IService ValueTask EchoExternal(Vendor.ExternalGraph value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000003UL, 0x2000000000000003UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.managed/v1"; - public override string WireFormatId => "route-managed-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.managed/v1\", \"route-managed-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.managed/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(RouteAdapter))]"); var diagnostics = RunGenerator(source, thirdParty); @@ -130,13 +130,13 @@ public interface IExternalPointContract : SharpLink.Sdk.IService ValueTask Echo(Vendor.ExternalPoint value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000004UL, 0x2000000000000004UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.external-unmanaged/v1"; - public override string WireFormatId => "route-external-unmanaged-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.external-unmanaged/v1\", \"route-external-unmanaged-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.external-unmanaged/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Unmanaged, typeof(RouteAdapter))]"); var diagnostics = RunGenerator(source, thirdParty); @@ -173,13 +173,13 @@ public interface IEnvelopeContract : SharpLink.Sdk.IService ValueTask Echo(Envelope value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000005UL, 0x2000000000000005UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.all/v1"; - public override string WireFormatId => "route-all-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\", \"route-all-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.All, typeof(RouteAdapter))]"); var diagnostics = RunGenerator(source, thirdParty); @@ -207,27 +207,27 @@ public interface IExplicitRouteContract : SharpLink.Sdk.IService ValueTask Echo(Graph value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000006UL, 0x2000000000000006UL)] public sealed class ExplicitAdapter : TestRouteAdapterBase { public override string AdapterId => "explicit/v1"; - public override string WireFormatId => "explicit-wire/v1"; } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000007UL, 0x2000000000000007UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route/v1"; - public override string WireFormatId => "route-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ExplicitAdapter), \"explicit/v1\", \"explicit-wire/v1\")]", - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route/v1\", \"route-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ExplicitAdapter), \"explicit/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(RouteAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); - Ensure(generated.Contains("explicit-wire/v1", StringComparison.Ordinal), + Ensure(generated.Contains("public string? AdapterId => \"explicit/v1\";", StringComparison.Ordinal), "explicit per-type adapter must win over the assembly route"); - Ensure(!generated.Contains("route-wire/v1", StringComparison.Ordinal), - "the losing route must not enter the generated manifest for the explicitly bound type"); + Ensure(!generated.Contains("public string? AdapterId => \"route/v1\";", StringComparison.Ordinal), + "the losing route must not enter generated Codec bindings for the explicitly bound type"); var manifest = RunGeneratorAndGetSources(source).Single(static item => item.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); var globalSection = manifest.Substring( @@ -251,19 +251,20 @@ public interface IConflictRouteContract : SharpLink.Sdk.IService ValueTask Echo(Graph value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000008UL, 0x2000000000000008UL)] public sealed class FirstAdapter : TestRouteAdapterBase { public override string AdapterId => "first/v1"; - public override string WireFormatId => "first-wire/v1"; } + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000009UL, 0x2000000000000009UL)] public sealed class SecondAdapter : TestRouteAdapterBase { public override string AdapterId => "second/v1"; - public override string WireFormatId => "second-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\")]", - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SecondAdapter), \"second/v1\", \"second-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SecondAdapter), \"second/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.All, typeof(FirstAdapter))]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(SecondAdapter))]"); @@ -285,26 +286,29 @@ public interface ISplitRouteContract : SharpLink.Sdk.IService ValueTask EchoPoint(Point value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x100000000000000aUL, 0x200000000000000aUL)] public sealed class ManagedAdapter : TestRouteAdapterBase { public override string AdapterId => "managed/v1"; - public override string WireFormatId => "managed-wire/v1"; } + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x100000000000000bUL, 0x200000000000000bUL)] public sealed class UnmanagedAdapter : TestRouteAdapterBase { public override string AdapterId => "unmanaged/v1"; - public override string WireFormatId => "unmanaged-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ManagedAdapter), \"managed/v1\", \"managed-wire/v1\")]", - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(UnmanagedAdapter), \"unmanaged/v1\", \"unmanaged-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ManagedAdapter), \"managed/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(UnmanagedAdapter), \"unmanaged/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(ManagedAdapter))]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Unmanaged, typeof(UnmanagedAdapter))]"); EnsureDoesNotHaveRule(source, "SHARPLINK045"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); - Ensure(generated.Contains("managed-wire/v1", StringComparison.Ordinal), "Managed route identity"); - Ensure(generated.Contains("unmanaged-wire/v1", StringComparison.Ordinal), "Unmanaged route identity"); + Ensure(generated.Contains("public string? AdapterId => \"managed/v1\";", StringComparison.Ordinal), + "Managed route must use the registered Managed Adapter"); + Ensure(generated.Contains("public string? AdapterId => \"unmanaged/v1\";", StringComparison.Ordinal), + "Unmanaged route must use the registered Unmanaged Adapter"); return Task.CompletedTask; } @@ -329,13 +333,13 @@ public interface IStandaloneIsolationContract : SharpLink.Sdk.IService ValueTask Echo(ContractPayload value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x100000000000000cUL, 0x200000000000000cUL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.managed.contract-only/v1"; - public override string WireFormatId => "route-managed-contract-only-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.managed.contract-only/v1\", \"route-managed-contract-only-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.managed.contract-only/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(RouteAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); @@ -358,13 +362,13 @@ public interface IDynamicRouteContract : SharpLink.Sdk.IService ValueTask Echo(dynamic value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x100000000000000dUL, 0x200000000000000dUL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.dynamic/v1"; - public override string WireFormatId => "route-dynamic-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.dynamic/v1\", \"route-dynamic-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.dynamic/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(RouteAdapter))]"); var diagnostics = RunGenerator(source); @@ -394,13 +398,13 @@ public interface IDualRoleContract : SharpLink.Sdk.IService ValueTask Echo(Payload value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x100000000000000eUL, 0x200000000000000eUL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.dual-role/v1"; - public override string WireFormatId => "route-dual-role-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.dual-role/v1\", \"route-dual-role-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.dual-role/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(RouteAdapter))]"); var sources = RunGeneratorAndGetSources(source); @@ -437,7 +441,6 @@ public RpcCodecRouteAttribute(RpcCodecScope scope, Type adapterType) { } public abstract class TestRouteAdapterBase : SharpLink.Abstractions.IRpcCodecAdapter { public abstract string AdapterId { get; } - public abstract string WireFormatId { get; } public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """; From 627c860b0d2d9385add7c13e7f895b560e8308fb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:19:49 +0800 Subject: [PATCH 098/399] Normalize test adapter interface to current identity API --- test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs index 48315b143..a2dbf5e67 100644 --- a/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs +++ b/test/SharpLink.Generator.Tests/RpcIdentityTestSources.cs @@ -23,6 +23,10 @@ private static string UseCurrentIdentitySdk(string source) "public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId, string wireFormatId) { }", "public RpcCodecAdapterRegistrationAttribute(Type adapterType, string adapterId) { }", StringComparison.Ordinal); + source = source.Replace( + " string WireFormatId { get; }\n", + string.Empty, + StringComparison.Ordinal); source = source.Replace( """ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] From 78289d7b697fa6f7bcab07466e6417e90fc57a3f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:20:28 +0800 Subject: [PATCH 099/399] Add current identity helpers for contract manifest tests --- .../ContractManifestGeneratorTestHelpers.cs | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs diff --git a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs new file mode 100644 index 000000000..a7be3038c --- /dev/null +++ b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + private static string SimpleContract(string methods) => BuildSource($$""" +[SharpLink.Sdk.RpcContract] +public interface IHelloService : SharpLink.Sdk.IService +{ + {{methods}} +} +"""); + + private static string DtoContract(string members) => BuildSource($$""" +[SharpLink.Sdk.RpcSerializable] +public sealed class Payload +{ + {{members}} +} + +[SharpLink.Sdk.RpcContract] +public interface IHelloService : SharpLink.Sdk.IService +{ + ValueTask Echo(Payload value, CancellationToken cancellationToken); +} +"""); + + private static string AdapterContractSource( + bool includeNativeEnvelope = false, + ulong semanticLow = 0x2222222222222222UL) + { + var payloadType = includeNativeEnvelope ? "Envelope" : "Graph"; + var envelope = includeNativeEnvelope + ? """ +[SharpLink.Sdk.RpcSerializable] +public sealed class Envelope +{ + public Graph Graph { get; set; } = new(); +} + +""" + : string.Empty; + return AddAssemblyAttribute(BuildSource($$""" +[FakePackable] +public sealed class Graph +{ + public Graph? Parent { get; set; } +} + +{{envelope}}[SharpLink.Sdk.RpcContract] +public interface IGraphService : SharpLink.Sdk.IService +{ + ValueTask<{{payloadType}}> Echo({{payloadType}} value); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class FakePackableAttribute : Attribute { } + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1111111111111111UL, {{semanticLow}}UL)] +public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter +{ + public string AdapterId => "fake.adapter/v1"; + public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); +} +"""), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", SelectorAttributeType = typeof(FakePackableAttribute))]"); + } + + private static string AdapterStreamingContractSource() + => AddAssemblyAttribute(BuildSource(""" +[FakePackable] +public sealed class Graph +{ + public Graph? Parent { get; set; } +} + +[SharpLink.Sdk.RpcSerializable] +public sealed class Envelope +{ + public Graph Graph { get; set; } = new(); +} + +[SharpLink.Sdk.RpcContract] +public interface IGraphService : SharpLink.Sdk.IService +{ + ValueTask Echo(Graph value); + ValueTask Upload(IAsyncEnumerable values); + IAsyncEnumerable Watch(int count); + ValueTask Wrap(Envelope value); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class FakePackableAttribute : Attribute { } + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1111111111111111UL, 0x2222222222222222UL)] +public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter +{ + public string AdapterId => "fake.adapter/v1"; + public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); +} +"""), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", SelectorAttributeType = typeof(FakePackableAttribute))]"); + + private static string RewriteManifest( + string json, + Action rewrite) + { + var root = System.Text.Json.Nodes.JsonNode.Parse(json)!.AsObject(); + rewrite(root); + root["schemaFingerprint"] = string.Empty; + var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = true }; + var canonical = root.ToJsonString(options); + var fingerprint = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(canonical)); + root["schemaFingerprint"] = Convert.ToHexStringLower(fingerprint); + return root.ToJsonString(options) + "\n"; + } + + private static string RemoveTopLevelProperty(string json, string propertyName) + => RewriteManifest(json, root => root.Remove(propertyName)); + + private static string SetTopLevelPropertyToNull(string json, string propertyName) + => RewriteManifest(json, root => root[propertyName] = null); + + private static string RemoveCodecHashForType(string json, string typeName) + => RewriteManifest(json, root => RemoveCodecHashForType(root, typeName)); + + private static void RemoveCodecHashForType(System.Text.Json.Nodes.JsonNode node, string typeName) + { + if (node is System.Text.Json.Nodes.JsonObject jsonObject) + { + if (jsonObject["type"]?.GetValue() == typeName) + jsonObject.Remove("codecHash"); + foreach (var child in jsonObject.Select(static property => property.Value) + .OfType().ToArray()) + { + RemoveCodecHashForType(child, typeName); + } + } + else if (node is System.Text.Json.Nodes.JsonArray jsonArray) + { + foreach (var child in jsonArray.OfType()) + RemoveCodecHashForType(child, typeName); + } + } + + private static string RemoveDtoMemberCodecHash( + string json, + string dtoName, + string memberName) + => RewriteManifest(json, root => + { + var dto = root["dtos"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(item => item["name"]!.GetValue() == dtoName); + var member = dto["members"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(item => item["name"]!.GetValue() == memberName); + member.Remove("codecHash"); + }); + + private static string SetCodecInventoryHash(string json, string typeName, string? replacement) + => RewriteManifest(json, root => + { + var codec = root["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(item => item["type"]!.GetValue() == typeName); + codec["codecHash"] = replacement; + }); + + private static IEnumerable EnumerateJsonObjects( + System.Text.Json.Nodes.JsonNode node) + { + if (node is System.Text.Json.Nodes.JsonObject jsonObject) + { + yield return jsonObject; + foreach (var child in jsonObject.Select(static property => property.Value) + .OfType()) + { + foreach (var nested in EnumerateJsonObjects(child)) + yield return nested; + } + } + else if (node is System.Text.Json.Nodes.JsonArray jsonArray) + { + foreach (var child in jsonArray.OfType()) + { + foreach (var nested in EnumerateJsonObjects(child)) + yield return nested; + } + } + } + + private static bool IsValidCodecHashText(string? value) + => value is { Length: 32 } && value.All(static character => + (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F')); + + private static void EnsurePayloadIdentity( + System.Text.Json.Nodes.JsonNode node, + bool expectOpaqueCodecHash, + bool? stream, + string scenario) + { + var value = node.AsObject(); + Ensure(!string.IsNullOrWhiteSpace(value["wireType"]?.GetValue()), + $"{scenario} wire type"); + Ensure(!value.ContainsKey("wireFormatId"), + $"{scenario} must not contain legacy wireFormatId"); + if (expectOpaqueCodecHash) + { + Ensure(IsValidCodecHashText(value["codecHash"]?.GetValue()), + $"{scenario} opaque CodecHash"); + } + else + { + Ensure(!value.ContainsKey("codecHash"), + $"{scenario} native payload position does not need a second identity field"); + } + if (stream is not null) + Ensure(value["stream"]?.GetValue() == stream, $"{scenario} stream shape"); + } + + private static bool IsCompatibilityDiagnostic(Diagnostic diagnostic) + => string.CompareOrdinal(diagnostic.Id, "SHARPLINK024") >= 0 && + string.CompareOrdinal(diagnostic.Id, "SHARPLINK035") <= 0; + + private static ContractGeneratorResult RunContractGenerator( + string source, + string? baseline = null, + string? outputPath = null) + { + const string baselinePath = "/contracts/previous.sharplink.json"; + var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); + var compilation = CSharpCompilation.Create( + "ContractManifestTestAssembly", + [syntaxTree], + GetPlatformReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var properties = new Dictionary(StringComparer.Ordinal); + var additionalTexts = ImmutableArray.Empty; + if (baseline is not null) + { + properties["build_property.SharpLinkContractBaseline"] = baselinePath; + additionalTexts = [new InMemoryAdditionalText(baselinePath, baseline)]; + } + if (outputPath is not null) + properties["build_property.SharpLinkContractManifestOutput"] = outputPath; + + IIncrementalGenerator generator = new RpcGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [generator.AsSourceGenerator()], + additionalTexts, + CSharpParseOptions.Default, + new TestAnalyzerConfigOptionsProvider(properties)); + driver = driver.RunGenerators(compilation); + var result = driver.GetRunResult(); + var generated = result.GeneratedTrees + .Select(static tree => tree.GetText().ToString()) + .First(static text => text.Contains("__SharpLinkContractManifest", StringComparison.Ordinal)); + const string startMarker = "internal const string Json = @\""; + const string endMarker = "\";"; + var start = generated.IndexOf(startMarker, StringComparison.Ordinal) + startMarker.Length; + var end = generated.LastIndexOf(endMarker, StringComparison.Ordinal); + Ensure(start >= startMarker.Length && end > start, "generated contract Manifest constant"); + var json = generated.Substring(start, end - start).Replace("\"\"", "\"", StringComparison.Ordinal); + return new ContractGeneratorResult(json, result.Diagnostics); + } + + private sealed record ContractGeneratorResult(string Json, ImmutableArray Diagnostics); + + private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText + { + public override string Path { get; } = path; + public override SourceText GetText(CancellationToken cancellationToken = default) + => SourceText.From(content); + } + + private sealed class TestAnalyzerConfigOptionsProvider( + IReadOnlyDictionary properties) : AnalyzerConfigOptionsProvider + { + private readonly AnalyzerConfigOptions _global = new TestAnalyzerConfigOptions(properties); + public override AnalyzerConfigOptions GlobalOptions => _global; + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => TestAnalyzerConfigOptions.Empty; + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => TestAnalyzerConfigOptions.Empty; + } + + private sealed class TestAnalyzerConfigOptions( + IReadOnlyDictionary values) : AnalyzerConfigOptions + { + internal static TestAnalyzerConfigOptions Empty { get; } = new(new Dictionary()); + public override bool TryGetValue(string key, out string value) + => values.TryGetValue(key, out value!); + } +} From 4095408e7a5f01eefb7c96f1c4963105a14330b7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:21:53 +0800 Subject: [PATCH 100/399] Migrate contract manifest tests to deterministic codec identity --- .../ContractManifestGeneratorTests.cs | 437 ++++-------------- 1 file changed, 80 insertions(+), 357 deletions(-) diff --git a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs index 5c81f25e2..847ff9012 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs @@ -55,8 +55,10 @@ public sealed class HelloService : IHelloService "RPC call shape"); Ensure(first.Json.Contains("\"wireType\": \"LengthDelimited\"", StringComparison.Ordinal), "DTO wire type"); - Ensure(first.Json.Contains("\"wireFormatId\": \"sharplink-native/v1\"", StringComparison.Ordinal), - "native wire-format identity"); + Ensure(!first.Json.Contains("wireFormatId", StringComparison.Ordinal), + "legacy wire-format identity must not be emitted"); + Ensure(first.Json.Contains("\"codecHash\":", StringComparison.Ordinal), + "reachable Codec inventory must contain deterministic identities"); Ensure(first.Json.Contains("\"required\": true", StringComparison.Ordinal), "required DTO member"); Ensure(first.Json.Contains("\"underlyingType\": \"byte\"", StringComparison.Ordinal), @@ -84,13 +86,13 @@ public Task GeneratedAssemblyManifestShouldReportExecutingGeneratorVersion() } [Test] - public Task CustomCodecSchemaIdentityShouldBeRecordedInContractManifest() + public Task CustomCodecHashShouldBeRecordedInContractManifest() { var source = BuildSource(""" [SharpLink.Sdk.RpcCodec(typeof(MoneyCodec))] public sealed record Money(decimal Value); -[SharpLink.Sdk.RpcCodecImplementation("money-wire/v1", "money-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1111111111111111UL, 0x2222222222222222UL)] public sealed class MoneyCodec : SharpLink.Abstractions.IRpcCodec { } @@ -108,21 +110,22 @@ public interface IMoneyService : SharpLink.Sdk.IService .Select(static item => item!.AsObject()) .Single(static item => item["type"]!.GetValue() == "Money"); - Ensure(moneyCodec["wireFormatId"]!.GetValue() == "money-wire/v1", - "custom Codec wire format must be recorded in the Contract Manifest"); - Ensure(!string.IsNullOrWhiteSpace(moneyCodec["schemaId"]?.GetValue()), - "custom Codec schema identity must be recorded in the Contract Manifest"); + Ensure(moneyCodec["kind"]!.GetValue() == "Custom", "custom Codec kind"); + Ensure(IsValidCodecHashText(moneyCodec["codecHash"]?.GetValue()), + "custom Codec must record a fixed-width CodecHash"); + Ensure(!moneyCodec.ContainsKey("wireFormatId") && !moneyCodec.ContainsKey("schemaId"), + "custom Codec inventory must not restore legacy string identities"); return Task.CompletedTask; } [Test] - public Task CustomCodecSchemaChangeShouldBeDetectedForDirectPayloads() + public Task CustomCodecSemanticIdentityChangeShouldBeDetectedForDirectPayloads() { - string ContractSource(string schemaId) => BuildSource($$""" + string ContractSource(ulong semanticLow) => BuildSource($$""" [SharpLink.Sdk.RpcCodec(typeof(MoneyCodec))] public sealed record Money(decimal Value); -[SharpLink.Sdk.RpcCodecImplementation("money-wire/v1", "{{schemaId}}")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1111111111111111UL, {{semanticLow}}UL)] public sealed class MoneyCodec : SharpLink.Abstractions.IRpcCodec { } @@ -134,11 +137,11 @@ public interface IMoneyService : SharpLink.Sdk.IService } """); - var baseline = RunContractGenerator(ContractSource("money-schema/v1")).Json; - var changed = RunContractGenerator(ContractSource("money-schema/v2"), baseline); + var baseline = RunContractGenerator(ContractSource(0x2222222222222222UL)).Json; + var changed = RunContractGenerator(ContractSource(0x3333333333333333UL), baseline); - Ensure(changed.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK030") == 1, - "changing a custom Codec schema identity while keeping wire format must fail baseline compatibility"); + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing an opaque custom Codec semantic identity must fail baseline comparison"); return Task.CompletedTask; } @@ -263,33 +266,33 @@ public ImmutableManifestService(object dependency) { } } [Test] - public Task BaselineWithoutAdapterWireFormatShouldBeRejected() + public Task BaselineWithoutAdapterCodecHashShouldBeRejected() { var source = AdapterContractSource(); - var baseline = RemoveWireFormat(RunContractGenerator(source).Json, "fake-wire/v1"); + var baseline = RemoveCodecHashForType(RunContractGenerator(source).Json, "Graph"); var compared = RunContractGenerator(source, baseline); Ensure(compared.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK024") == 1, - $"a baseline missing adapter wireFormatId is invalid. Baseline: {baseline} Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); + $"a baseline missing an opaque Adapter CodecHash is invalid. Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); return Task.CompletedTask; } [Test] - public Task BaselineWithoutDtoMemberWireFormatShouldBeRejected() + public Task BaselineWithoutDtoMemberCodecHashShouldBeRejected() { var source = AdapterContractSource(includeNativeEnvelope: true); - var baseline = RemoveWireFormat(RunContractGenerator(source).Json, "fake-wire/v1"); + var baseline = RemoveDtoMemberCodecHash(RunContractGenerator(source).Json, "Envelope", "Graph"); var compared = RunContractGenerator(source, baseline); Ensure(compared.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK024") == 1, - $"a baseline missing a DTO member wireFormatId is invalid. Baseline: {baseline} Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); + $"a baseline missing an opaque DTO-member CodecHash is invalid. Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); return Task.CompletedTask; } [Test] - public Task BaselineWithoutReachableCodecWireInventoryShouldBeRejected() + public Task BaselineWithoutReachableCodecIdentityInventoryShouldBeRejected() { var source = AdapterContractSource(); var baseline = RemoveTopLevelProperty(RunContractGenerator(source).Json, "codecs"); @@ -297,12 +300,12 @@ public Task BaselineWithoutReachableCodecWireInventoryShouldBeRejected() var compared = RunContractGenerator(source, baseline); Ensure(compared.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK024") == 1, - $"a baseline missing the reachable Codec wire inventory is invalid. Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); + $"a baseline missing the reachable Codec identity inventory is invalid. Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); return Task.CompletedTask; } [Test] - public Task BaselineWithNullReachableCodecWireInventoryShouldBeRejected() + public Task BaselineWithNullReachableCodecIdentityInventoryShouldBeRejected() { var source = AdapterContractSource(); var baseline = SetTopLevelPropertyToNull(RunContractGenerator(source).Json, "codecs"); @@ -310,26 +313,25 @@ public Task BaselineWithNullReachableCodecWireInventoryShouldBeRejected() var compared = RunContractGenerator(source, baseline); Ensure(compared.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK024") == 1, - $"a null reachable Codec wire inventory is invalid. Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); + $"a null reachable Codec identity inventory is invalid. Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); return Task.CompletedTask; } [Test] - public Task ExplicitWireFormatChangeShouldBeRejected() + public Task ExplicitAdapterSemanticIdentityChangeShouldBeRejected() { - var baselineSource = AdapterContractSource(); - var baseline = RunContractGenerator(baselineSource).Json; - var changedSource = baselineSource.Replace("fake-wire/v1", "other-wire/v1", StringComparison.Ordinal); - - var changed = RunContractGenerator(changedSource, baseline); + var baseline = RunContractGenerator(AdapterContractSource()).Json; + var changed = RunContractGenerator( + AdapterContractSource(semanticLow: 0x3333333333333333UL), + baseline); Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), - "an explicit wire-format identity change is incompatible"); + "an opaque Adapter semantic identity change is incompatible"); return Task.CompletedTask; } [Test] - public Task AdapterWireFormatChangeInsideNativeCollectionShouldBeRejected() + public Task AdapterSemanticIdentityChangeInsideNativeCollectionShouldBeRejected() { var baselineSource = AdapterContractSource().Replace( "ValueTask Echo(Graph value);", @@ -340,24 +342,24 @@ public Task AdapterWireFormatChangeInsideNativeCollectionShouldBeRejected() var nestedCodec = baselineDocument["codecs"]!.AsArray() .Select(static item => item!.AsObject()) .Single(static item => item["type"]!.GetValue() == "Graph"); - Ensure(nestedCodec["wireFormatId"]!.GetValue() == "fake-wire/v1", - "the Manifest records the nested collection element Codec wire identity"); - var changedSource = baselineSource.Replace( - "fake-wire/v1", - "other-wire/v1", + Ensure(IsValidCodecHashText(nestedCodec["codecHash"]?.GetValue()), + "the Manifest records the nested collection element CodecHash"); + var changedSource = AdapterContractSource(semanticLow: 0x3333333333333333UL).Replace( + "ValueTask Echo(Graph value);", + "ValueTask> Echo(List value);", StringComparison.Ordinal); var changed = RunContractGenerator(changedSource, baseline); Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), - "a nested Adapter wire-format change inside a native collection is incompatible"); + "a nested Adapter semantic identity change inside a native collection is incompatible"); return Task.CompletedTask; } [Test] - public Task BaselineWithoutNativeWireFormatShouldBeRejected() + public Task NativePayloadManifestShouldNotContainLegacyWireIdentity() { - var baselineSource = BuildSource(""" + var source = BuildSource(""" [SharpLink.Sdk.RpcSerializable] public sealed class Graph { @@ -370,20 +372,23 @@ public interface IGraphService : SharpLink.Sdk.IService ValueTask Echo(Graph value); } """); - var invalidBaseline = RemoveWireFormat( - RunContractGenerator(baselineSource).Json, - "sharplink-native/v1"); - var currentSource = AdapterContractSource(); - - var changed = RunContractGenerator(currentSource, invalidBaseline); - - Ensure(changed.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK024") == 1, - $"a baseline missing native wireFormatId is invalid. Baseline: {invalidBaseline} Diagnostics: {FormatDiagnostics(changed.Diagnostics)}"); + var current = RunContractGenerator(source); + var root = System.Text.Json.Nodes.JsonNode.Parse(current.Json)!.AsObject(); + Ensure(!current.Json.Contains("wireFormatId", StringComparison.Ordinal), + "native Manifest must not restore legacy wireFormatId"); + var method = root["contracts"]!.AsArray().Single()!["methods"]!.AsArray().Single()!.AsObject(); + EnsurePayloadIdentity(method["request"]!.AsArray()[0]!, false, false, "native request"); + EnsurePayloadIdentity(method["response"]!, false, false, "native response"); + var codec = root["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(static item => item["type"]!.GetValue() == "Graph"); + Ensure(IsValidCodecHashText(codec["codecHash"]?.GetValue()), + "native Codec inventory still publishes deterministic CodecHash"); return Task.CompletedTask; } [Test] - public Task ManifestShouldRecordRequiredWireFormatsAtEveryPayloadPosition() + public Task ManifestShouldRecordStructuralWireTypesAndOpaqueCodecHashes() { var current = RunContractGenerator(AdapterStreamingContractSource()); var root = System.Text.Json.Nodes.JsonNode.Parse(current.Json)!.AsObject(); @@ -392,8 +397,10 @@ public Task ManifestShouldRecordRequiredWireFormatsAtEveryPayloadPosition() .ToArray(); Ensure(wireEntries.Length == 9, "eight method payload positions and one DTO member"); Ensure(wireEntries.All(static item => - !string.IsNullOrWhiteSpace(item["wireFormatId"]?.GetValue())), - "every serialized Manifest position has a required non-empty wireFormatId"); + !string.IsNullOrWhiteSpace(item["wireType"]?.GetValue())), + "every serialized Manifest position has a structural wireType"); + Ensure(wireEntries.All(static item => !item.ContainsKey("wireFormatId")), + "serialized Manifest positions must not contain legacy wireFormatId"); var contract = root["contracts"]!.AsArray().Single()!.AsObject(); var methods = contract["methods"]!.AsArray() @@ -402,20 +409,20 @@ public Task ManifestShouldRecordRequiredWireFormatsAtEveryPayloadPosition() static item => item["name"]!.GetValue(), StringComparer.Ordinal); var echo = methods["Echo"]; - EnsureWireFormat(echo["request"]!.AsArray()[0]!, "fake-wire/v1", stream: false, "unary request"); - EnsureWireFormat(echo["response"]!, "fake-wire/v1", stream: false, "unary response"); + EnsurePayloadIdentity(echo["request"]!.AsArray()[0]!, true, false, "unary request"); + EnsurePayloadIdentity(echo["response"]!, true, false, "unary response"); var upload = methods["Upload"]; - EnsureWireFormat(upload["request"]!.AsArray()[0]!, "fake-wire/v1", stream: true, "request stream item"); - EnsureWireFormat(upload["response"]!, "sharplink-native/v1", stream: false, "upload response"); + EnsurePayloadIdentity(upload["request"]!.AsArray()[0]!, true, true, "request stream item"); + EnsurePayloadIdentity(upload["response"]!, false, false, "upload response"); var watch = methods["Watch"]; - EnsureWireFormat(watch["request"]!.AsArray()[0]!, "sharplink-native/v1", stream: false, "watch request"); - EnsureWireFormat(watch["response"]!, "fake-wire/v1", stream: true, "response stream item"); + EnsurePayloadIdentity(watch["request"]!.AsArray()[0]!, false, false, "watch request"); + EnsurePayloadIdentity(watch["response"]!, true, true, "response stream item"); var wrap = methods["Wrap"]; - EnsureWireFormat(wrap["request"]!.AsArray()[0]!, "sharplink-native/v1", stream: false, "native envelope request"); - EnsureWireFormat(wrap["response"]!, "sharplink-native/v1", stream: false, "native envelope response"); + EnsurePayloadIdentity(wrap["request"]!.AsArray()[0]!, false, false, "native envelope request"); + EnsurePayloadIdentity(wrap["response"]!, false, false, "native envelope response"); var envelope = root["dtos"]!.AsArray() .Select(static item => item!.AsObject()) @@ -423,33 +430,35 @@ public Task ManifestShouldRecordRequiredWireFormatsAtEveryPayloadPosition() var graphMember = envelope["members"]!.AsArray() .Select(static item => item!.AsObject()) .Single(static item => item["name"]!.GetValue() == "Graph"); - EnsureWireFormat(graphMember, "fake-wire/v1", stream: null, "nested DTO member"); + EnsurePayloadIdentity(graphMember, true, stream: null, "nested DTO member"); return Task.CompletedTask; } [Test] - public Task NullBlankOrWhitespaceWireFormatShouldInvalidateBaseline() + public Task InvalidCodecHashesShouldInvalidateBaseline() { var source = AdapterContractSource(); var valid = RunContractGenerator(source).Json; var invalidBaselines = new[] { - SetWireFormat(valid, "fake-wire/v1", replacement: null), - SetWireFormat(valid, "fake-wire/v1", string.Empty), - SetWireFormat(valid, "fake-wire/v1", " ") + SetCodecInventoryHash(valid, "Graph", replacement: null), + SetCodecInventoryHash(valid, "Graph", string.Empty), + SetCodecInventoryHash(valid, "Graph", " "), + SetCodecInventoryHash(valid, "Graph", "abc"), + SetCodecInventoryHash(valid, "Graph", new string('g', 32)) }; foreach (var baseline in invalidBaselines) { var compared = RunContractGenerator(source, baseline); Ensure(compared.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK024") == 1, - $"null, blank, and whitespace wireFormatId values each invalidate the baseline. Baseline: {baseline} Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); + $"missing or malformed fixed-width CodecHash invalidates the baseline. Diagnostics: {FormatDiagnostics(compared.Diagnostics)}"); } return Task.CompletedTask; } [Test] - public Task AdapterIdentityChangeWithStableWireFormatShouldRemainCompatible() + public Task AdapterImplementationAndIdChangeWithStableSemanticIdentityShouldRemainCompatible() { var baselineSource = AdapterContractSource(); var baseline = RunContractGenerator(baselineSource).Json; @@ -460,7 +469,7 @@ public Task AdapterIdentityChangeWithStableWireFormatShouldRemainCompatible() var changed = RunContractGenerator(changedSource, baseline); Ensure(!changed.Diagnostics.Any(IsCompatibilityDiagnostic), - $"Adapter implementation and ID changes are compatible when wireFormatId is stable. Diagnostics: {FormatDiagnostics(changed.Diagnostics)}"); + $"Adapter implementation/lifecycle identity changes do not change wire semantics when the explicit semantic identity is stable. Diagnostics: {FormatDiagnostics(changed.Diagnostics)}"); return Task.CompletedTask; } @@ -514,7 +523,7 @@ public Task InvalidAndUnsupportedBaselinesShouldReportStableDiagnostics() "damaged baseline diagnostic"); var baseline = RunContractGenerator(source).Json.Replace( - "\"version\": 2", "\"version\": 99", StringComparison.Ordinal); + "\"version\": 3", "\"version\": 99", StringComparison.Ordinal); var unsupported = RunContractGenerator(source, baseline); Ensure(unsupported.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK025"), "unsupported baseline version diagnostic"); @@ -629,7 +638,7 @@ public Task CompatibleOptionalFieldAndExplicitIdRenameShouldBeAllowed() public int OptionalCount { get; set; } """), baseline); Ensure(!compatible.Diagnostics.Any(IsCompatibilityDiagnostic), - "explicit member ID rename and optional addition are compatible"); + "legacy Contract Manifest structural baseline rules remain independent from #396 exact RpcAssemblyHash identity"); return Task.CompletedTask; } @@ -750,290 +759,4 @@ public Task UnrelatedImplementationChangesShouldReuseContractAnalysis() "unrelated implementation edits must not rerun contract Manifest analysis"); return Task.CompletedTask; } - - private static string SimpleContract(string methods) => BuildSource($$""" -[SharpLink.Sdk.RpcContract] -public interface IHelloService : SharpLink.Sdk.IService -{ - {{methods}} -} -"""); - - private static string DtoContract(string members) => BuildSource($$""" -[SharpLink.Sdk.RpcSerializable] -public sealed class Payload -{ - {{members}} -} - -[SharpLink.Sdk.RpcContract] -public interface IHelloService : SharpLink.Sdk.IService -{ - ValueTask Echo(Payload value, CancellationToken cancellationToken); -} -"""); - - private static string AdapterContractSource(bool includeNativeEnvelope = false) - { - var payloadType = includeNativeEnvelope ? "Envelope" : "Graph"; - var envelope = includeNativeEnvelope - ? """ -[SharpLink.Sdk.RpcSerializable] -public sealed class Envelope -{ - public Graph Graph { get; set; } = new(); -} - -""" - : string.Empty; - return AddAssemblyAttribute(BuildSource($$""" -[FakePackable] -public sealed class Graph -{ - public Graph? Parent { get; set; } -} - -{{envelope}}[SharpLink.Sdk.RpcContract] -public interface IGraphService : SharpLink.Sdk.IService -{ - ValueTask<{{payloadType}}> Echo({{payloadType}} value); -} - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class FakePackableAttribute : Attribute { } - -public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter -{ - public string AdapterId => "fake.adapter/v1"; - public string WireFormatId => "fake-wire/v1"; - public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); -} -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\", SelectorAttributeType = typeof(FakePackableAttribute))]"); - } - - private static string AdapterStreamingContractSource() - => AddAssemblyAttribute(BuildSource(""" -[FakePackable] -public sealed class Graph -{ - public Graph? Parent { get; set; } -} - -[SharpLink.Sdk.RpcSerializable] -public sealed class Envelope -{ - public Graph Graph { get; set; } = new(); -} - -[SharpLink.Sdk.RpcContract] -public interface IGraphService : SharpLink.Sdk.IService -{ - ValueTask Echo(Graph value); - ValueTask Upload(IAsyncEnumerable values); - IAsyncEnumerable Watch(int count); - ValueTask Wrap(Envelope value); -} - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class FakePackableAttribute : Attribute { } - -public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter -{ - public string AdapterId => "fake.adapter/v1"; - public string WireFormatId => "fake-wire/v1"; - public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); -} -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\", SelectorAttributeType = typeof(FakePackableAttribute))]"); - - private static string RemoveWireFormat(string json, string wireFormatId) - { - var root = System.Text.Json.Nodes.JsonNode.Parse(json)!.AsObject(); - RemoveWireFormat(root, wireFormatId); - root["schemaFingerprint"] = string.Empty; - var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = true }; - var canonical = root.ToJsonString(options); - var fingerprint = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonical)); - root["schemaFingerprint"] = Convert.ToHexStringLower(fingerprint); - return root.ToJsonString(options) + "\n"; - } - - private static string RemoveTopLevelProperty(string json, string propertyName) - { - var root = System.Text.Json.Nodes.JsonNode.Parse(json)!.AsObject(); - root.Remove(propertyName); - root["schemaFingerprint"] = string.Empty; - var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = true }; - var canonical = root.ToJsonString(options); - var fingerprint = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonical)); - root["schemaFingerprint"] = Convert.ToHexStringLower(fingerprint); - return root.ToJsonString(options) + "\n"; - } - - private static string SetTopLevelPropertyToNull(string json, string propertyName) - { - var root = System.Text.Json.Nodes.JsonNode.Parse(json)!.AsObject(); - root[propertyName] = null; - root["schemaFingerprint"] = string.Empty; - var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = true }; - var canonical = root.ToJsonString(options); - var fingerprint = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonical)); - root["schemaFingerprint"] = Convert.ToHexStringLower(fingerprint); - return root.ToJsonString(options) + "\n"; - } - - private static void RemoveWireFormat(System.Text.Json.Nodes.JsonNode node, string wireFormatId) - { - if (node is System.Text.Json.Nodes.JsonObject jsonObject) - { - if (jsonObject["wireFormatId"]?.GetValue() == wireFormatId) - jsonObject.Remove("wireFormatId"); - foreach (var child in jsonObject.Select(static property => property.Value).OfType().ToArray()) - RemoveWireFormat(child, wireFormatId); - } - else if (node is System.Text.Json.Nodes.JsonArray jsonArray) - { - foreach (var child in jsonArray.OfType()) - RemoveWireFormat(child, wireFormatId); - } - } - - private static string SetWireFormat(string json, string wireFormatId, string? replacement) - { - var root = System.Text.Json.Nodes.JsonNode.Parse(json)!.AsObject(); - SetWireFormat(root, wireFormatId, replacement); - root["schemaFingerprint"] = string.Empty; - var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = true }; - var canonical = root.ToJsonString(options); - var fingerprint = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonical)); - root["schemaFingerprint"] = Convert.ToHexStringLower(fingerprint); - return root.ToJsonString(options) + "\n"; - } - - private static void SetWireFormat( - System.Text.Json.Nodes.JsonNode node, - string wireFormatId, - string? replacement) - { - if (node is System.Text.Json.Nodes.JsonObject jsonObject) - { - if (jsonObject["wireFormatId"]?.GetValue() == wireFormatId) - jsonObject["wireFormatId"] = replacement; - foreach (var child in jsonObject.Select(static property => property.Value).OfType().ToArray()) - SetWireFormat(child, wireFormatId, replacement); - } - else if (node is System.Text.Json.Nodes.JsonArray jsonArray) - { - foreach (var child in jsonArray.OfType()) - SetWireFormat(child, wireFormatId, replacement); - } - } - - private static IEnumerable EnumerateJsonObjects( - System.Text.Json.Nodes.JsonNode node) - { - if (node is System.Text.Json.Nodes.JsonObject jsonObject) - { - yield return jsonObject; - foreach (var child in jsonObject.Select(static property => property.Value).OfType()) - { - foreach (var nested in EnumerateJsonObjects(child)) - yield return nested; - } - } - else if (node is System.Text.Json.Nodes.JsonArray jsonArray) - { - foreach (var child in jsonArray.OfType()) - { - foreach (var nested in EnumerateJsonObjects(child)) - yield return nested; - } - } - } - - private static void EnsureWireFormat( - System.Text.Json.Nodes.JsonNode node, - string expectedWireFormatId, - bool? stream, - string scenario) - { - var value = node.AsObject(); - Ensure(value["wireFormatId"]?.GetValue() == expectedWireFormatId, - $"{scenario} wireFormatId"); - if (stream is not null) - Ensure(value["stream"]?.GetValue() == stream, $"{scenario} stream shape"); - } - - private static bool IsCompatibilityDiagnostic(Diagnostic diagnostic) - => string.CompareOrdinal(diagnostic.Id, "SHARPLINK024") >= 0 && - string.CompareOrdinal(diagnostic.Id, "SHARPLINK035") <= 0; - - private static ContractGeneratorResult RunContractGenerator( - string source, - string? baseline = null, - string? outputPath = null) - { - const string baselinePath = "/contracts/previous.sharplink.json"; - var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); - var compilation = CSharpCompilation.Create( - "ContractManifestTestAssembly", - [syntaxTree], - GetPlatformReferences(), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var properties = new Dictionary(StringComparer.Ordinal); - var additionalTexts = ImmutableArray.Empty; - if (baseline is not null) - { - properties["build_property.SharpLinkContractBaseline"] = baselinePath; - additionalTexts = [new InMemoryAdditionalText(baselinePath, baseline)]; - } - if (outputPath is not null) - properties["build_property.SharpLinkContractManifestOutput"] = outputPath; - - IIncrementalGenerator generator = new RpcGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create( - [generator.AsSourceGenerator()], - additionalTexts, - CSharpParseOptions.Default, - new TestAnalyzerConfigOptionsProvider(properties)); - driver = driver.RunGenerators(compilation); - var result = driver.GetRunResult(); - var generated = result.GeneratedTrees - .Select(static tree => tree.GetText().ToString()) - .First(static text => text.Contains("__SharpLinkContractManifest", StringComparison.Ordinal)); - const string startMarker = "internal const string Json = @\""; - const string endMarker = "\";"; - var start = generated.IndexOf(startMarker, StringComparison.Ordinal) + startMarker.Length; - var end = generated.LastIndexOf(endMarker, StringComparison.Ordinal); - Ensure(start >= startMarker.Length && end > start, "generated contract Manifest constant"); - var json = generated.Substring(start, end - start).Replace("\"\"", "\"", StringComparison.Ordinal); - return new ContractGeneratorResult(json, result.Diagnostics); - } - - private sealed record ContractGeneratorResult(string Json, ImmutableArray Diagnostics); - - private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText - { - public override string Path { get; } = path; - public override SourceText GetText(CancellationToken cancellationToken = default) - => SourceText.From(content); - } - - private sealed class TestAnalyzerConfigOptionsProvider( - IReadOnlyDictionary properties) : AnalyzerConfigOptionsProvider - { - private readonly AnalyzerConfigOptions _global = new TestAnalyzerConfigOptions(properties); - public override AnalyzerConfigOptions GlobalOptions => _global; - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => TestAnalyzerConfigOptions.Empty; - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => TestAnalyzerConfigOptions.Empty; - } - - private sealed class TestAnalyzerConfigOptions( - IReadOnlyDictionary values) : AnalyzerConfigOptions - { - internal static TestAnalyzerConfigOptions Empty { get; } = new(new Dictionary()); - public override bool TryGetValue(string key, out string value) - => values.TryGetValue(key, out value!); - } } From f417e02ed49d18da7549fc30e7c9e431987f81b6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:22:44 +0800 Subject: [PATCH 101/399] Migrate second-review codec fixtures to semantic identity --- .../RpcCodecSecondReviewRegressionTests.cs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecSecondReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecSecondReviewRegressionTests.cs index ae4235d1c..18194ce47 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecSecondReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecSecondReviewRegressionTests.cs @@ -61,7 +61,7 @@ public class Child { } -[SharpLink.Sdk.RpcCodecImplementation("child-wire/v1", "child-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3100000000000001UL, 0x4100000000000001UL)] public sealed class ChildCodec : SharpLink.Abstractions.IRpcCodec { } @@ -72,13 +72,13 @@ public interface INestedCustomRouteContract : SharpLink.Sdk.IService ValueTask Echo(Envelope value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3100000000000002UL, 0x4100000000000002UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.nested-custom/v1"; - public override string WireFormatId => "route-nested-custom-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.nested-custom/v1\", \"route-nested-custom-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.nested-custom/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.Managed, typeof(RouteAdapter))]"); var diagnostics = RunGenerator(source); @@ -87,8 +87,8 @@ public sealed class RouteAdapter : TestRouteAdapterBase var generated = string.Join("\n", RunGeneratorAndGetSources(source)); Ensure(generated.Contains("CreateCodec()", StringComparison.Ordinal), "the Managed route must select the configurable parent whose child is resolved by a custom Codec"); - Ensure(generated.Contains("route-nested-custom-wire/v1", StringComparison.Ordinal), - "the selected Managed route identity must be emitted for the parent graph"); + Ensure(generated.Contains("public string? AdapterId => \"route.nested-custom/v1\";", StringComparison.Ordinal), + "the selected Managed route must use the registered Adapter"); return Task.CompletedTask; } @@ -111,7 +111,7 @@ public sealed class SharedPayload public int Value { get; set; } } - [RpcCodecImplementation("policy-a-wire/v1", "policy-a-schema/v1")] + [RpcCodecSemanticIdentity(0x3100000000000003UL, 0x4100000000000003UL)] public sealed class CodecA : IRpcCodec { } @@ -127,7 +127,7 @@ public sealed class CodecA : IRpcCodec [assembly: RpcCodec(typeof(SharedPayload), typeof(CodecB))] -[RpcCodecImplementation("policy-b-wire/v1", "policy-b-schema/v1")] +[RpcCodecSemanticIdentity(0x3100000000000004UL, 0x4100000000000004UL)] public sealed class CodecB : IRpcCodec { } @@ -160,20 +160,20 @@ public interface IFixedIntContract : SharpLink.Sdk.IService ValueTask Echo(int value, CancellationToken cancellationToken); } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3100000000000005UL, 0x4100000000000005UL)] public sealed class ExplicitAdapter : TestRouteAdapterBase { public override string AdapterId => "explicit.int/v1"; - public override string WireFormatId => "explicit-int-wire/v1"; } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3100000000000006UL, 0x4100000000000006UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.all/v1"; - public override string WireFormatId => "route-all-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ExplicitAdapter), \"explicit.int/v1\", \"explicit-int-wire/v1\")]", - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\", \"route-all-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(ExplicitAdapter), \"explicit.int/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(int), typeof(ExplicitAdapter))]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.All, typeof(RouteAdapter))]"); @@ -181,7 +181,7 @@ public sealed class RouteAdapter : TestRouteAdapterBase Ensure(diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK049"), "framework primitive int must reject explicit Adapter/direct rebinding regardless of lower-precedence routes"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); - Ensure(!generated.Contains("explicit-int-wire/v1\";", StringComparison.Ordinal), + Ensure(!generated.Contains("public string? AdapterId => \"explicit.int/v1\";", StringComparison.Ordinal), "a rejected framework primitive binding must not enter the final Codec graph"); Ensure(!generated.Contains("CreateCodec()", StringComparison.Ordinal), "All route must not capture framework primitive int"); @@ -198,18 +198,18 @@ public interface IFixedIntCustomContract : SharpLink.Sdk.IService ValueTask Echo(int value, CancellationToken cancellationToken); } -[SharpLink.Sdk.RpcCodecImplementation("custom-int-wire/v1", "custom-int-schema/v1")] +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3100000000000007UL, 0x4100000000000007UL)] public sealed class IntCodec : SharpLink.Abstractions.IRpcCodec { } +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3100000000000008UL, 0x4100000000000008UL)] public sealed class RouteAdapter : TestRouteAdapterBase { public override string AdapterId => "route.all/v1"; - public override string WireFormatId => "route-all-wire/v1"; } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\", \"route-all-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\")]", "[assembly: SharpLink.Sdk.RpcCodec(typeof(int), typeof(IntCodec))]", "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.All, typeof(RouteAdapter))]"); From cd4064663e1fc693d85f4609f57cbc5d364e428b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:28:47 +0800 Subject: [PATCH 102/399] chore: remove obsolete manifest test allowance --- eng/maintainability/baseline.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/eng/maintainability/baseline.json b/eng/maintainability/baseline.json index 5c648218f..82a869589 100644 --- a/eng/maintainability/baseline.json +++ b/eng/maintainability/baseline.json @@ -268,12 +268,6 @@ "maxLoc": 1047, "reason": "Existing dev debt captured by issue #350." }, - { - "domain": "test", - "path": "test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs", - "maxLoc": 1040, - "reason": "Existing dev debt captured by issue #350." - }, { "domain": "test", "path": "test/SharpLink.Benchmarks/ConnectionAdmissionEvidenceRunner.cs", From 2828f11f84ca1337d75671109c9ddbe63dded865 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:39:22 +0800 Subject: [PATCH 103/399] feat: emit deterministic codec hashes from factories --- src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index 330c1b7cd..e8b2c2aee 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -60,8 +60,7 @@ private static void AppendCustomCodecFactory(StringBuilder sb, GeneratedCodecMod sb.AppendLine(" internal sealed class Factory : IRpcGeneratedCodecFactory"); sb.AppendLine(" {"); sb.AppendLine($" public Type TargetType => typeof({model.TypeName});"); - sb.AppendLine($" public string SchemaId => \"{EscapeString(model.SchemaId)}\";"); - sb.AppendLine($" public string WireFormatId => \"{EscapeString(model.WireFormatId)}\";"); + AppendFactoryCodecHash(sb, model); sb.AppendLine(" public string? AdapterId => null;"); sb.AppendLine(" public IRpcCodecAdapter? Adapter => null;"); sb.AppendLine(" public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope)"); @@ -107,8 +106,7 @@ private static void AppendAdapterCodecFactory(StringBuilder sb, GeneratedCodecMo sb.AppendLine(" internal sealed class Factory : IRpcGeneratedCodecFactory"); sb.AppendLine(" {"); sb.AppendLine($" public Type TargetType => typeof({model.TypeName});"); - sb.AppendLine($" public string SchemaId => \"{EscapeString(model.SchemaId)}\";"); - sb.AppendLine($" public string WireFormatId => \"{EscapeString(model.WireFormatId)}\";"); + AppendFactoryCodecHash(sb, model); sb.AppendLine($" public string? AdapterId => \"{EscapeString(model.AdapterId!)}\";"); sb.AppendLine($" public IRpcCodecAdapter Adapter => {GetAdapterHolderName(model.AdapterId!)}.Instance;"); sb.AppendLine(" public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope)"); @@ -123,6 +121,10 @@ private static void AppendAdapterCodecFactory(StringBuilder sb, GeneratedCodecMo sb.AppendLine(); } + private static void AppendFactoryCodecHash(StringBuilder sb, GeneratedCodecModel model) + => sb.AppendLine( + $" public RpcHash128 CodecHash => new(0x{model.CodecHashHigh.ToString("x16", InvariantCulture)}UL, 0x{model.CodecHashLow.ToString("x16", InvariantCulture)}UL);"); + private static string GetAdapterHolderName(string adapterId) => "__SharpLinkGeneratedAdapter_" + ComputeEmitterHash(adapterId).ToString("X16", InvariantCulture); @@ -1250,8 +1252,7 @@ private static void AppendFactory(StringBuilder sb, GeneratedCodecModel model) sb.AppendLine(" internal sealed class Factory : IRpcGeneratedCodecFactory"); sb.AppendLine(" {"); sb.AppendLine($" public Type TargetType => typeof({model.TypeName});"); - sb.AppendLine($" public string SchemaId => \"{EscapeString(model.SchemaId)}\";"); - sb.AppendLine(" public string WireFormatId => \"sharplink-native/v1\";"); + AppendFactoryCodecHash(sb, model); sb.AppendLine(" public string? AdapterId => null;"); sb.AppendLine(" public IRpcCodecAdapter? Adapter => null;"); sb.AppendLine($" public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope)"); From 019a825dd6b6d840581bf214632f4b9f70b18880 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:50:07 +0800 Subject: [PATCH 104/399] test: migrate generator identity assertions --- .../RpcAnalyzerTests.cs | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index 05a0c2de0..ff67725a0 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -414,7 +414,7 @@ public interface IResponseFingerprintContract : SharpLink.Sdk.IService } [Test] - public Task DtoMemberNullabilityMustParticipateInRuntimeCodecSchemaIdentity() + public Task DtoMemberNullabilityMustParticipateInRuntimeCodecHash() { var required = BuildSource(""" #nullable enable @@ -435,10 +435,10 @@ public interface IDtoSchemaContract : SharpLink.Sdk.IService public sealed class Payload { public string? Name { get; set; } } """); - var requiredSchema = GetFirstGeneratedCodecSchema(required); - var optionalSchema = GetFirstGeneratedCodecSchema(optional); - Ensure(!string.Equals(requiredSchema, optionalSchema, StringComparison.Ordinal), - "required and nullable DTO members must not publish the same runtime Codec schema"); + var requiredHash = GetFirstGeneratedCodecHash(required); + var optionalHash = GetFirstGeneratedCodecHash(optional); + Ensure(!string.Equals(requiredHash, optionalHash, StringComparison.Ordinal), + "required and nullable DTO members must not publish the same runtime CodecHash"); return Task.CompletedTask; } @@ -1962,7 +1962,11 @@ public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter Ensure(generated.Contains("public Type TargetType => typeof(global::Graph);", StringComparison.Ordinal), "Adapter factory target type"); Ensure(generated.Contains("fake.adapter/v1", StringComparison.Ordinal), "Adapter ID"); - Ensure(generated.Contains("fake-wire/v1", StringComparison.Ordinal), "Wire Format ID"); + Ensure(generated.Contains("public RpcHash128 CodecHash => new(", StringComparison.Ordinal), + "Adapter factory CodecHash"); + Ensure(!generated.Contains("SchemaId =>", StringComparison.Ordinal) && + !generated.Contains("WireFormatId =>", StringComparison.Ordinal), + "Adapter factory must not emit legacy schema/wire identities"); Ensure(!generated.Contains("FakeAdapter, Version=", StringComparison.Ordinal), "Adapter implementation assemblies are normal runtime references, not dynamic Manifest dependencies"); Ensure(!generated.Contains("MakeGenericType", StringComparison.Ordinal), "no MakeGenericType"); @@ -2022,7 +2026,7 @@ public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]"), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]") , "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(ValueTuple), typeof(FakeAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); @@ -2214,7 +2218,7 @@ public abstract class TestAdapterBase : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]"), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]") , "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SecondAdapter), \"second/v1\", \"second-wire/v1\")]"); EnsureHasRule(source, "SHARPLINK045"); return Task.CompletedTask; @@ -2279,18 +2283,6 @@ public abstract class AdapterBase : SharpLink.Abstractions.IRpcCodecAdapter "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"shared/v1\", \"wire/v1\")]", "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SecondAdapter), \"shared/v1\", \"wire/v1\")]"); EnsureHasRuleContaining(sameIdDifferentType, "SHARPLINK048", "Adapter ID 'shared/v1'"); - - var sameIdDifferentWire = AddAssemblyAttributes(BuildSource(""" -public sealed class FirstAdapter : SharpLink.Abstractions.IRpcCodecAdapter -{ - public string AdapterId => "shared/v1"; - public string WireFormatId => "wire/v1"; - public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); -} -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"shared/v1\", \"wire/v1\")]", - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"shared/v1\", \"other-wire/v1\")]"); - EnsureHasRuleContaining(sameIdDifferentWire, "SHARPLINK048", "same Adapter type"); return Task.CompletedTask; } @@ -2392,8 +2384,8 @@ public sealed class InstalledAdapter : SharpLink.Abstractions.IRpcCodecAdapter var generated = string.Join("\n", RunGeneratorAndGetSources(source)); Ensure(generated.Contains("IRpcCodec", StringComparison.Ordinal), "supported DTO retains its native generated Codec"); - Ensure(generated.Contains("WireFormatId => \"sharplink-native/v1\"", StringComparison.Ordinal), - "supported DTO retains the native wire identity"); + Ensure(generated.Contains("public RpcHash128 CodecHash => new(", StringComparison.Ordinal), + "supported DTO publishes deterministic native CodecHash"); Ensure(!generated.Contains("CreateCodec()", StringComparison.Ordinal), "installed Adapter is not an automatic fallback"); Ensure(!generated.Contains("installed-wire/v1", StringComparison.Ordinal), @@ -2472,7 +2464,10 @@ public interface IGraphService : SharpLink.Sdk.IService Ensure(generated.Contains("CreateCodec()", StringComparison.Ordinal), "registration from the transitive compilation reference closure selects the Adapter"); Ensure(generated.Contains("metadata.adapter/v1", StringComparison.Ordinal), "metadata Adapter ID"); - Ensure(generated.Contains("metadata-wire/v1", StringComparison.Ordinal), "metadata Wire Format ID"); + Ensure(generated.Contains("public RpcHash128 CodecHash => new(", StringComparison.Ordinal), + "metadata Adapter CodecHash"); + Ensure(!generated.Contains("metadata-wire/v1", StringComparison.Ordinal), + "legacy metadata wire identity must not be emitted"); return Task.CompletedTask; } @@ -2966,9 +2961,11 @@ public interface IMoneyService : SharpLink.Sdk.IService "custom Codec binding must emit an IRpcGeneratedCodecFactory"); Ensure(generated.Contains("new global::MoneyCodec()", StringComparison.Ordinal), "custom Codec factory must construct the bound implementation directly"); - Ensure(generated.Contains("\"money-wire/v1\"", StringComparison.Ordinal) && - generated.Contains("SchemaId => \"global::Money:", StringComparison.Ordinal), - "custom Codec wire/schema identity must be emitted into the manifest"); + Ensure(generated.Contains("public RpcHash128 CodecHash => new(", StringComparison.Ordinal), + "custom Codec factory must emit deterministic CodecHash"); + Ensure(!generated.Contains("SchemaId =>", StringComparison.Ordinal) && + !generated.Contains("WireFormatId =>", StringComparison.Ordinal), + "custom Codec factory must not emit legacy schema/wire identities"); return Task.CompletedTask; } @@ -3348,11 +3345,11 @@ private static string GetFirstGeneratedMethodFingerprint(string source) return quotedLines[^1].TrimEnd(',').Trim('"'); } - private static string GetFirstGeneratedCodecSchema(string source) + private static string GetFirstGeneratedCodecHash(string source) => string.Join("\n", RunGeneratorAndGetSources(source)) .Split('\n') .Select(static line => line.Trim()) - .First(static line => line.StartsWith("public string SchemaId =>", StringComparison.Ordinal)); + .First(static line => line.StartsWith("public RpcHash128 CodecHash =>", StringComparison.Ordinal)); private static MetadataReference CreateMetadataReference( string assemblyName, From 71e0ea62cbdaf8245cfaaddf34747dd5628b48ba Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:51:52 +0800 Subject: [PATCH 105/399] test: diagnose indirect all-route binding --- .../RpcCodecRouteDiagnosticTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs b/test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs new file mode 100644 index 000000000..619665431 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task DiagnoseIndirectAllRouteBinding() + { + var thirdParty = CreateMetadataReference( + "ThirdParty.Indirect.Diagnostic", + """ +namespace Vendor +{ + public sealed class ExternalGraph { public string Name { get; set; } = string.Empty; } + public struct ExternalPoint { public int X; public int Y; } +} +"""); + var source = AddAssemblyAttributes(BuildRouteSource(""" +public sealed class Envelope +{ + public Vendor.ExternalGraph Graph { get; set; } = new(); + public Vendor.ExternalPoint Point { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface IEnvelopeContract : SharpLink.Sdk.IService +{ + ValueTask Echo(Envelope value, CancellationToken cancellationToken); +} + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000005UL, 0x2000000000000005UL)] +public sealed class RouteAdapter : TestRouteAdapterBase +{ + public override string AdapterId => "route.all/v1"; +} +"""), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.All, typeof(RouteAdapter))]"); + + var diagnostics = RunGenerator(source, thirdParty); + var generated = string.Join("\n", RunGeneratorAndGetSources(source, thirdParty)); + var routed = generated.Contains("CreateCodec()", StringComparison.Ordinal); + if (!routed) + { + throw new Exception( + $"indirect all-route diagnostic: nativeEnvelope={generated.Contains("IRpcCodec", StringComparison.Ordinal)}; " + + $"targetFactory={generated.Contains("TargetType => typeof(global::Envelope)", StringComparison.Ordinal)}; " + + $"adapterId={generated.Contains("route.all/v1", StringComparison.Ordinal)}; " + + $"contractPolicy={generated.Contains("__SharpLinkGeneratedContractPolicyCodec_", StringComparison.Ordinal)}; " + + $"diagnostics={FormatDiagnostics(diagnostics)}"); + } + return Task.CompletedTask; + } +} From 29ade0426902b7e937d15185cdaf260fc462f185 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:49:32 +0800 Subject: [PATCH 106/399] test: isolate analyzer diagnostic run --- test/SharpLink.Generator.Tests/.editorconfig | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/.editorconfig diff --git a/test/SharpLink.Generator.Tests/.editorconfig b/test/SharpLink.Generator.Tests/.editorconfig new file mode 100644 index 000000000..faa2da358 --- /dev/null +++ b/test/SharpLink.Generator.Tests/.editorconfig @@ -0,0 +1,4 @@ +root = false + +[RpcAnalyzerTests.cs] +generated_code = true From ebfb3cc8797cccdaab2d95abf650b30dc3453b8c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:57:01 +0800 Subject: [PATCH 107/399] chore: keep codec emitter within debt allowance --- src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index e8b2c2aee..d6ce2a7f8 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -86,7 +86,7 @@ private static void AppendGeneratedUtf8Helper(StringBuilder sb) sb.AppendLine(); sb.AppendLine(" internal static void WriteStringKnownSize(IBufferWriter writer, string value, int byteCount)"); sb.AppendLine(" {"); - sb.AppendLine(" var length = writer.GetSpan(sizeof(uint));"); + sb.AppendLine(" var length = writer.GetSpan(sizeof(uint)); sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(length, checked((uint)byteCount));"); sb.AppendLine(" writer.Advance(sizeof(uint));"); sb.AppendLine(" if (byteCount == 0)"); @@ -121,9 +121,7 @@ private static void AppendAdapterCodecFactory(StringBuilder sb, GeneratedCodecMo sb.AppendLine(); } - private static void AppendFactoryCodecHash(StringBuilder sb, GeneratedCodecModel model) - => sb.AppendLine( - $" public RpcHash128 CodecHash => new(0x{model.CodecHashHigh.ToString("x16", InvariantCulture)}UL, 0x{model.CodecHashLow.ToString("x16", InvariantCulture)}UL);"); + private static void AppendFactoryCodecHash(StringBuilder sb, GeneratedCodecModel model) => sb.AppendLine($" public RpcHash128 CodecHash => new(0x{model.CodecHashHigh.ToString("x16", InvariantCulture)}UL, 0x{model.CodecHashLow.ToString("x16", InvariantCulture)}UL);"); private static string GetAdapterHolderName(string adapterId) => "__SharpLinkGeneratedAdapter_" + ComputeEmitterHash(adapterId).ToString("X16", InvariantCulture); @@ -1232,7 +1230,7 @@ private static void AppendCollectionRead(StringBuilder sb, GeneratedCodecModel m } sb.AppendLine($" var items = new {GetArrayCreationType(model.ElementType!, "count")};"); - sb.AppendLine(" for (var index = 0; index < count; index++)"); + sb.AppendLine(" for (var index = 0; index < count; index++) sb.AppendLine(" items[index] = __elementCodec.Deserialize(RpcGeneratedCodecWire.ReadLengthDelimited(ref reader))!;"); sb.AppendLine(" RpcGeneratedCodecWire.EnsureFullyConsumed(reader);"); var returnExpression = model.Kind switch From 286517a9d0e822c57e067e2de6f8b756730fc04c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:57:53 +0800 Subject: [PATCH 108/399] revert: restore codec emitter after malformed edit --- src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index d6ce2a7f8..e8b2c2aee 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -86,7 +86,7 @@ private static void AppendGeneratedUtf8Helper(StringBuilder sb) sb.AppendLine(); sb.AppendLine(" internal static void WriteStringKnownSize(IBufferWriter writer, string value, int byteCount)"); sb.AppendLine(" {"); - sb.AppendLine(" var length = writer.GetSpan(sizeof(uint)); + sb.AppendLine(" var length = writer.GetSpan(sizeof(uint));"); sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(length, checked((uint)byteCount));"); sb.AppendLine(" writer.Advance(sizeof(uint));"); sb.AppendLine(" if (byteCount == 0)"); @@ -121,7 +121,9 @@ private static void AppendAdapterCodecFactory(StringBuilder sb, GeneratedCodecMo sb.AppendLine(); } - private static void AppendFactoryCodecHash(StringBuilder sb, GeneratedCodecModel model) => sb.AppendLine($" public RpcHash128 CodecHash => new(0x{model.CodecHashHigh.ToString("x16", InvariantCulture)}UL, 0x{model.CodecHashLow.ToString("x16", InvariantCulture)}UL);"); + private static void AppendFactoryCodecHash(StringBuilder sb, GeneratedCodecModel model) + => sb.AppendLine( + $" public RpcHash128 CodecHash => new(0x{model.CodecHashHigh.ToString("x16", InvariantCulture)}UL, 0x{model.CodecHashLow.ToString("x16", InvariantCulture)}UL);"); private static string GetAdapterHolderName(string adapterId) => "__SharpLinkGeneratedAdapter_" + ComputeEmitterHash(adapterId).ToString("X16", InvariantCulture); @@ -1230,7 +1232,7 @@ private static void AppendCollectionRead(StringBuilder sb, GeneratedCodecModel m } sb.AppendLine($" var items = new {GetArrayCreationType(model.ElementType!, "count")};"); - sb.AppendLine(" for (var index = 0; index < count; index++) + sb.AppendLine(" for (var index = 0; index < count; index++)"); sb.AppendLine(" items[index] = __elementCodec.Deserialize(RpcGeneratedCodecWire.ReadLengthDelimited(ref reader))!;"); sb.AppendLine(" RpcGeneratedCodecWire.EnsureFullyConsumed(reader);"); var returnExpression = model.Kind switch From 14df66bff3814caa04466d2914b53973e06e90cd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:01:02 +0800 Subject: [PATCH 109/399] chore: keep codec emitter within debt allowance --- src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index e8b2c2aee..11ae1fb83 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -121,9 +121,7 @@ private static void AppendAdapterCodecFactory(StringBuilder sb, GeneratedCodecMo sb.AppendLine(); } - private static void AppendFactoryCodecHash(StringBuilder sb, GeneratedCodecModel model) - => sb.AppendLine( - $" public RpcHash128 CodecHash => new(0x{model.CodecHashHigh.ToString("x16", InvariantCulture)}UL, 0x{model.CodecHashLow.ToString("x16", InvariantCulture)}UL);"); + private static void AppendFactoryCodecHash(StringBuilder sb, GeneratedCodecModel model) => sb.AppendLine($" public RpcHash128 CodecHash => new(0x{model.CodecHashHigh.ToString("x16", InvariantCulture)}UL, 0x{model.CodecHashLow.ToString("x16", InvariantCulture)}UL);"); private static string GetAdapterHolderName(string adapterId) => "__SharpLinkGeneratedAdapter_" + ComputeEmitterHash(adapterId).ToString("X16", InvariantCulture); From 81ed85e3ae90af8b4ffb258913a99bf490757cc0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:03:56 +0800 Subject: [PATCH 110/399] test: unblock route diagnostic build --- test/SharpLink.Generator.Tests/.editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.Generator.Tests/.editorconfig b/test/SharpLink.Generator.Tests/.editorconfig index faa2da358..8de746099 100644 --- a/test/SharpLink.Generator.Tests/.editorconfig +++ b/test/SharpLink.Generator.Tests/.editorconfig @@ -2,3 +2,4 @@ root = false [RpcAnalyzerTests.cs] generated_code = true +dotnet_diagnostic.CS8669.severity = none From 0fff28297a1d92a87b5b925465686c2166175eff Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:13:50 +0800 Subject: [PATCH 111/399] fix: propagate failed codec graph ownership --- .../RpcGenerator.CodecFailurePropagation.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.CodecFailurePropagation.cs diff --git a/src/SharpLink.Generator/RpcGenerator.CodecFailurePropagation.cs b/src/SharpLink.Generator/RpcGenerator.CodecFailurePropagation.cs new file mode 100644 index 000000000..99d5f6f6f --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.CodecFailurePropagation.cs @@ -0,0 +1,29 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private sealed partial class DtoAnalysisState + { + internal ImmutableArray FilterFailedCodecClosure( + ImmutableArray codecs) + { + bool changed; + do + { + changed = false; + foreach (var codec in codecs) + { + if (_failed.Contains(codec.TypeName)) + continue; + if (GetCodecDependencies(codec).Any(_failed.Contains)) + changed |= _failed.Add(codec.TypeName); + } + } + while (changed); + + return codecs + .Where(codec => !_failed.Contains(codec.TypeName)) + .ToImmutableArray(); + } + } +} From 5b047fc3b2805ccbe7dd8a1f4951ca51c02d67a2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:14:21 +0800 Subject: [PATCH 112/399] fix: prune failed codec closures before hashing --- .../RpcGenerator.CodecPolicyOwnership.cs | 464 +----------------- 1 file changed, 7 insertions(+), 457 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 38e02e6b3..8e3518e9c 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -13,10 +13,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var standalone = standaloneState.AnalyzeWithFinalCodecBindings(); + var standaloneModels = standaloneState.FilterFailedCodecClosure(standalone.Codecs); var standaloneHashes = standaloneState.BuildFinalCodecHashes( includeSerializable: true, includeContracts: false); - var standaloneCodecs = AttachCodecHashes(standalone.Codecs, standaloneHashes); + var standaloneCodecs = AttachCodecHashes(standaloneModels, standaloneHashes); var contractDefaultState = new DtoAnalysisState( compilation, @@ -25,10 +26,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: true); var contractDefault = contractDefaultState.AnalyzeWithFinalCodecBindings(); + var contractDefaultModels = contractDefaultState.FilterFailedCodecClosure(contractDefault.Codecs); var contractDefaultHashes = contractDefaultState.BuildFinalCodecHashes( includeSerializable: false, includeContracts: true); - var contractDefaultCodecs = AttachCodecHashes(contractDefault.Codecs, contractDefaultHashes); + var contractDefaultCodecs = AttachCodecHashes(contractDefaultModels, contractDefaultHashes); var contractPolicyState = new DtoAnalysisState( compilation, @@ -37,10 +39,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var contractPolicy = contractPolicyState.AnalyzeWithFinalCodecBindings(); + var contractPolicyModels = contractPolicyState.FilterFailedCodecClosure(contractPolicy.Codecs); var codecHashes = contractPolicyState.BuildFinalCodecHashes( includeSerializable: false, includeContracts: true); - var contractPolicyCodecs = AttachCodecHashes(contractPolicy.Codecs, codecHashes); + var contractPolicyCodecs = AttachCodecHashes(contractPolicyModels, codecHashes); var currentContractTypes = contractPolicyState.GetCurrentContractReachableTypeNames(); var currentContractDefaultCodecs = contractDefaultCodecs @@ -235,8 +238,7 @@ private static ImmutableArray SelectOwnedContractCodecs( private static bool HasSameFinalCodecBinding(GeneratedCodecModel left, GeneratedCodecModel right) { if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - left.Kind != right.Kind || - left.IsReferenceType != right.IsReferenceType || + left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || !string.Equals(left.ValueType, right.ValueType, StringComparison.Ordinal) || @@ -257,456 +259,4 @@ private static bool HasSameFinalCodecBinding(GeneratedCodecModel left, Generated } return true; } - - private sealed partial class DtoAnalysisState - { - private readonly bool _selectorOnlyContractDefaults = false; - private readonly HashSet _contractOwnedPolicyRoots = new(StringComparer.Ordinal); - private readonly Dictionary _canonicalAssemblyBindings = new(StringComparer.Ordinal); - private readonly Dictionary _canonicalCustomCodecBindings = new(StringComparer.Ordinal); - - internal IReadOnlyCollection ContractOwnedPolicyRoots => _contractOwnedPolicyRoots; - - public DtoAnalysisState( - Compilation compilation, - CancellationToken cancellationToken, - bool contractMode, - bool applyCodecPolicy, - bool selectorOnlyContractDefault) - { - _compilation = compilation; - _cancellationToken = cancellationToken; - _contractMode = contractMode; - _applyCodecPolicy = applyCodecPolicy; - _selectorOnlyContractDefaults = selectorOnlyContractDefault; - _allowedAssemblyNames = ResolveReferenceAssemblyNames(compilation); - _allowedAssemblyNames.Add(compilation.Assembly.Identity.Name); - CollectAdapterRegistrations(); - if (!selectorOnlyContractDefault) - { - CollectCanonicalAssemblyCustomCodecBindings(); - CollectCanonicalAssemblyBindings(); - AddCanonicalPolicyBindingAliases(); - } - if (_contractMode && !selectorOnlyContractDefault) - CollectAssemblyRoutes(); - } - - private static string GetCanonicalPolicyTargetIdentity(ITypeSymbol type) - => GetTypeName(type); - - private static bool HasSameCanonicalPolicyTarget(ITypeSymbol left, ITypeSymbol right) - => string.Equals( - GetCanonicalPolicyTargetIdentity(left), - GetCanonicalPolicyTargetIdentity(right), - StringComparison.Ordinal); - - private void CollectCanonicalAssemblyCustomCodecBindings() - { - foreach (var attribute in _compilation.Assembly.GetAttributes() - .Where(static attribute => IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecAttribute")) - .OrderBy(static attribute => attribute.ToString(), StringComparer.Ordinal)) - { - var location = attribute.ApplicationSyntaxReference?.GetSyntax(_cancellationToken).GetLocation() ?? Location.None; - if (attribute.ConstructorArguments.Length != 2 || - attribute.ConstructorArguments[0].Value is not ITypeSymbol target || - attribute.ConstructorArguments[1].Value is not ITypeSymbol codec) - { - Report(DtoDiagnosticKind.CustomCodecBindingInvalid, _compilation.Assembly, - "assembly-level RpcCodec requires targetType and codecType", location); - continue; - } - if (HasTypeParameter(target)) - { - Report(DtoDiagnosticKind.CustomCodecTargetInvalid, target, - "custom Codec target must be a closed type", location); - continue; - } - - target = NormalizeAdapterTarget(target); - if (IsFrameworkWirePrimitive(target)) - { - Report(DtoDiagnosticKind.BuiltinCustomCodecOverride, target, - "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", - location); - continue; - } - - AddCanonicalCustomCodecBinding(target, codec, location); - } - } - - private void AddCanonicalCustomCodecBinding(ITypeSymbol target, ITypeSymbol codec, Location location) - { - var identity = GetCanonicalPolicyTargetIdentity(target); - if (_canonicalCustomCodecBindings.TryGetValue(identity, out var existing) && - !SymbolEqualityComparer.Default.Equals(existing.CodecType, codec)) - { - Report(DtoDiagnosticKind.CustomCodecSelectionConflict, target, - "the target is explicitly bound to multiple custom Codec implementations", location); - return; - } - - var registration = ValidateCustomCodecWithCanonicalTarget(codec, target, location); - if (registration is null) - return; - - _customCodecBindings[target] = registration; - _canonicalCustomCodecBindings[identity] = registration; - if (_contractMode) - _contractOwnedPolicyRoots.Add(identity); - } - - private CustomCodecRegistration? ValidateCustomCodecWithCanonicalTarget( - ITypeSymbol codecType, - ITypeSymbol targetType, - Location location) - { - if (codecType is not INamedTypeSymbol named) - { - Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, - "custom Codec must be a closed, public sealed type", location); - return null; - } - - if (HasTypeParameter(named) || - !IsEffectivelyPublic(named) || - !named.IsSealed || - !named.InstanceConstructors.Any(static constructor => - constructor.DeclaredAccessibility == Accessibility.Public && - constructor.Parameters.Length == 0)) - { - Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, - "custom Codec must be a public sealed type with a public parameterless constructor", location); - return null; - } - - var implementsTargetCodec = named.AllInterfaces.Any(item => - item.Name == "IRpcCodec" && - item.ContainingNamespace.ToDisplayString() == "SharpLink.Abstractions" && - item is INamedTypeSymbol { IsGenericType: true } generic && - generic.TypeArguments.Length == 1 && - HasSameCanonicalPolicyTarget(generic.TypeArguments[0], targetType)); - if (!implementsTargetCodec) - { - Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, - $"custom Codec must implement IRpcCodec<{GetTypeName(targetType)}>", location); - return null; - } - - if (!HasValidOpaqueSemanticIdentity(named)) - { - Report(DtoDiagnosticKind.CustomCodecIdentityInvalid, codecType, - "custom Codec must declare a non-zero fixed semantic identity via [RpcCodecSemanticIdentity(high, low)]", location); - return null; - } - - return new CustomCodecRegistration(named, location); - } - - private void CollectCanonicalAssemblyBindings() - { - foreach (var attribute in _compilation.Assembly.GetAttributes() - .Where(static attribute => IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecAdapterAttribute"))) - { - var location = attribute.ApplicationSyntaxReference?.GetSyntax(_cancellationToken).GetLocation() ?? Location.None; - if (attribute.ConstructorArguments.Length != 2 || - attribute.ConstructorArguments[0].Value is not ITypeSymbol target || - attribute.ConstructorArguments[1].Value is not INamedTypeSymbol adapter) - { - Report(DtoDiagnosticKind.AdapterBindingInvalid, _compilation.Assembly, - "assembly-level RpcCodecAdapter requires targetType and adapterType", location); - continue; - } - if (HasTypeParameter(target)) - { - Report(DtoDiagnosticKind.AdapterTargetInvalid, target, - "Adapter target must be a closed type", location); - continue; - } - - target = NormalizeAdapterTarget(target); - if (IsFrameworkWirePrimitive(target)) - { - Report(DtoDiagnosticKind.BuiltinAdapterOverride, target, - "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", - location); - continue; - } - - AddCanonicalAssemblyBinding(target, new ExplicitBindingCandidate(adapter, location)); - } - } - - private void AddCanonicalAssemblyBinding(ITypeSymbol target, ExplicitBindingCandidate candidate) - { - var identity = GetCanonicalPolicyTargetIdentity(target); - if (_canonicalAssemblyBindings.TryGetValue(identity, out var existing)) - { - if (!SymbolEqualityComparer.Default.Equals(existing.ImplementationType, candidate.ImplementationType)) - { - Report(DtoDiagnosticKind.AdapterSelectionConflict, target, - "the target is explicitly bound to multiple different Codec Adapters", - candidate.Location); - return; - } - - _assemblyBindings[target] = existing; - return; - } - - _assemblyBindings[target] = candidate; - _canonicalAssemblyBindings[identity] = candidate; - } - - private void AddCanonicalPolicyBindingAliases() - { - if (_canonicalAssemblyBindings.Count == 0 && _canonicalCustomCodecBindings.Count == 0) - return; - - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - - foreach (var reachableType in reachable.Values) - { - var lookupType = NormalizeAdapterTarget(reachableType); - var identity = GetCanonicalPolicyTargetIdentity(lookupType); - if (!_assemblyBindings.ContainsKey(lookupType) && - _canonicalAssemblyBindings.TryGetValue(identity, out var adapterBinding)) - { - _assemblyBindings[lookupType] = adapterBinding; - } - if (!_customCodecBindings.ContainsKey(lookupType) && - _canonicalCustomCodecBindings.TryGetValue(identity, out var customBinding)) - { - _customCodecBindings[lookupType] = customBinding; - } - } - } - - internal DtoAnalysisPassResult AnalyzeWithFinalCodecBindings() - { - _ = Analyze(); - PromoteSelectedFixedMembersToCodecBindings(); - NormalizeGeneratedModuleDependencies(); - return new DtoAnalysisPassResult( - _models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray(), - _diagnostics.ToImmutableArray(), - _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal).ToImmutableArray()); - } - - internal HashSet GetCurrentContractReachableTypeNames() - { - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: false, - includeContracts: true); - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - return new HashSet(reachable.Keys, StringComparer.Ordinal); - } - - private void PromoteSelectedFixedMembersToCodecBindings() - { - if (!_applyCodecPolicy || _models.Count == 0) - return; - - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); - - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - - var dtoModels = _models.Values - .Where(static model => model.Kind == GeneratedCodecKind.Dto) - .ToArray(); - foreach (var model in dtoModels) - { - if (!reachable.TryGetValue(model.TypeName, out var type) || type is not INamedTypeSymbol named) - continue; - - var memberSymbols = GetSerializableMembers(named) - .ToDictionary(static member => member.Name, StringComparer.Ordinal); - var members = model.Members.ToArray(); - var changed = false; - for (var index = 0; index < members.Length; index++) - { - var member = members[index]; - if (member.Kind is not (GeneratedMemberKind.Fixed or GeneratedMemberKind.NullableFixed or GeneratedMemberKind.String) || - !memberSymbols.TryGetValue(member.Name, out var memberSymbol)) - { - continue; - } - - var memberType = GetMemberType(memberSymbol); - if (!HasSelectedMemberCodec(memberType)) - continue; - - Visit(memberType, [], 0); - members[index] = member with - { - Kind = GeneratedMemberKind.Complex, - FixedTypeName = null, - FixedSize = 0, - EnumUnderlyingType = null - }; - changed = true; - } - - if (!changed) - continue; - - var finalizedMembers = members.ToImmutableArray(); - var schema = new StringBuilder(model.TypeName); - foreach (var member in finalizedMembers) - { - schema.Append('|').Append(member.FieldId).Append(':').Append(member.TypeName) - .Append(':').Append(member.Kind).Append(':').Append(member.Required); - if (member.Nullable) - schema.Append(":nullable"); - } - _models[model.TypeName] = model with - { - Members = finalizedMembers, - SchemaId = GetSchemaId(model.TypeName, schema.ToString()) - }; - } - } - - private bool HasSelectedCompositeCodecDependency(ITypeSymbol type) - { - if (!TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) - return false; - - return (elementType is not null && HasSelectedMemberCodec(elementType)) || - (keyType is not null && HasSelectedMemberCodec(keyType)) || - (valueType is not null && HasSelectedMemberCodec(valueType)); - } - - private bool HasSelectedMemberCodec(ITypeSymbol memberType) - { - if (IsFrameworkWirePrimitive(memberType)) - return false; - if (TrySelectCustomCodec(memberType, out var customCodec)) - return customCodec is not null; - - AdapterRegistration? selected = null; - var hasSelection = _contractMode - ? TrySelectContractCodecOverride(memberType, out selected) - : TrySelectAdapter(memberType, out selected); - return hasSelection && selected is not null; - } - - private void NormalizeGeneratedModuleDependencies() - { - if (_models.Count == 0) - return; - - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); - var symbolsByType = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, symbolsByType, seen, 0); - - var localFactoryTypes = new HashSet(_models.Keys, StringComparer.Ordinal); - foreach (var model in _models.Values.ToArray()) - { - if (model.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) - { - _models[model.TypeName] = model with - { - AssemblyDependencies = ImmutableArray.Empty - }; - continue; - } - - var dependencies = new HashSet(StringComparer.Ordinal); - foreach (var dependencyTypeName in GetCodecDependencies(model)) - { - if (localFactoryTypes.Contains(dependencyTypeName) || - !symbolsByType.TryGetValue(dependencyTypeName, out var dependencyType) || - IsBuiltin(dependencyType)) - { - continue; - } - - var assembly = dependencyType.ContainingAssembly; - if (assembly is not null && - !SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly) && - HasGeneratedAssemblyManifest(assembly)) - { - dependencies.Add(assembly.Identity.ToString()); - } - } - - _models[model.TypeName] = model with - { - AssemblyDependencies = dependencies - .OrderBy(static identity => identity, StringComparer.Ordinal) - .ToImmutableArray() - }; - } - } - - private void CollectFinalBindingTypes( - ITypeSymbol type, - Dictionary reachable, - HashSet seen, - int depth) - { - if (depth > MaximumDepth || !seen.Add(type)) - return; - var typeName = GetTypeName(type); - reachable[typeName] = type; - if (_models.TryGetValue(typeName, out var finalModel) && - finalModel.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) - { - return; - } - - if (type is IArrayTypeSymbol array) - { - CollectFinalBindingTypes(array.ElementType, reachable, seen, depth + 1); - return; - } - if (TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) - { - if (elementType is not null) - CollectFinalBindingTypes(elementType, reachable, seen, depth + 1); - if (keyType is not null) - CollectFinalBindingTypes(keyType, reachable, seen, depth + 1); - if (valueType is not null) - CollectFinalBindingTypes(valueType, reachable, seen, depth + 1); - return; - } - if (type is not INamedTypeSymbol named || IsThirdPartyType(type)) - return; - - foreach (var member in GetSerializableMembers(named)) - CollectFinalBindingTypes(GetMemberType(member), reachable, seen, depth + 1); - } - } } From 5dc2604a67bfa29b610face63a7e3577416d27e5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:15:02 +0800 Subject: [PATCH 113/399] chore: restore codec policy ownership file --- .../RpcGenerator.CodecPolicyOwnership.cs | 464 +++++++++++++++++- 1 file changed, 457 insertions(+), 7 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 8e3518e9c..38e02e6b3 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -13,11 +13,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var standalone = standaloneState.AnalyzeWithFinalCodecBindings(); - var standaloneModels = standaloneState.FilterFailedCodecClosure(standalone.Codecs); var standaloneHashes = standaloneState.BuildFinalCodecHashes( includeSerializable: true, includeContracts: false); - var standaloneCodecs = AttachCodecHashes(standaloneModels, standaloneHashes); + var standaloneCodecs = AttachCodecHashes(standalone.Codecs, standaloneHashes); var contractDefaultState = new DtoAnalysisState( compilation, @@ -26,11 +25,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: true); var contractDefault = contractDefaultState.AnalyzeWithFinalCodecBindings(); - var contractDefaultModels = contractDefaultState.FilterFailedCodecClosure(contractDefault.Codecs); var contractDefaultHashes = contractDefaultState.BuildFinalCodecHashes( includeSerializable: false, includeContracts: true); - var contractDefaultCodecs = AttachCodecHashes(contractDefaultModels, contractDefaultHashes); + var contractDefaultCodecs = AttachCodecHashes(contractDefault.Codecs, contractDefaultHashes); var contractPolicyState = new DtoAnalysisState( compilation, @@ -39,11 +37,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var contractPolicy = contractPolicyState.AnalyzeWithFinalCodecBindings(); - var contractPolicyModels = contractPolicyState.FilterFailedCodecClosure(contractPolicy.Codecs); var codecHashes = contractPolicyState.BuildFinalCodecHashes( includeSerializable: false, includeContracts: true); - var contractPolicyCodecs = AttachCodecHashes(contractPolicyModels, codecHashes); + var contractPolicyCodecs = AttachCodecHashes(contractPolicy.Codecs, codecHashes); var currentContractTypes = contractPolicyState.GetCurrentContractReachableTypeNames(); var currentContractDefaultCodecs = contractDefaultCodecs @@ -238,7 +235,8 @@ private static ImmutableArray SelectOwnedContractCodecs( private static bool HasSameFinalCodecBinding(GeneratedCodecModel left, GeneratedCodecModel right) { if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || + left.Kind != right.Kind || + left.IsReferenceType != right.IsReferenceType || !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || !string.Equals(left.ValueType, right.ValueType, StringComparison.Ordinal) || @@ -259,4 +257,456 @@ private static bool HasSameFinalCodecBinding(GeneratedCodecModel left, Generated } return true; } + + private sealed partial class DtoAnalysisState + { + private readonly bool _selectorOnlyContractDefaults = false; + private readonly HashSet _contractOwnedPolicyRoots = new(StringComparer.Ordinal); + private readonly Dictionary _canonicalAssemblyBindings = new(StringComparer.Ordinal); + private readonly Dictionary _canonicalCustomCodecBindings = new(StringComparer.Ordinal); + + internal IReadOnlyCollection ContractOwnedPolicyRoots => _contractOwnedPolicyRoots; + + public DtoAnalysisState( + Compilation compilation, + CancellationToken cancellationToken, + bool contractMode, + bool applyCodecPolicy, + bool selectorOnlyContractDefault) + { + _compilation = compilation; + _cancellationToken = cancellationToken; + _contractMode = contractMode; + _applyCodecPolicy = applyCodecPolicy; + _selectorOnlyContractDefaults = selectorOnlyContractDefault; + _allowedAssemblyNames = ResolveReferenceAssemblyNames(compilation); + _allowedAssemblyNames.Add(compilation.Assembly.Identity.Name); + CollectAdapterRegistrations(); + if (!selectorOnlyContractDefault) + { + CollectCanonicalAssemblyCustomCodecBindings(); + CollectCanonicalAssemblyBindings(); + AddCanonicalPolicyBindingAliases(); + } + if (_contractMode && !selectorOnlyContractDefault) + CollectAssemblyRoutes(); + } + + private static string GetCanonicalPolicyTargetIdentity(ITypeSymbol type) + => GetTypeName(type); + + private static bool HasSameCanonicalPolicyTarget(ITypeSymbol left, ITypeSymbol right) + => string.Equals( + GetCanonicalPolicyTargetIdentity(left), + GetCanonicalPolicyTargetIdentity(right), + StringComparison.Ordinal); + + private void CollectCanonicalAssemblyCustomCodecBindings() + { + foreach (var attribute in _compilation.Assembly.GetAttributes() + .Where(static attribute => IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecAttribute")) + .OrderBy(static attribute => attribute.ToString(), StringComparer.Ordinal)) + { + var location = attribute.ApplicationSyntaxReference?.GetSyntax(_cancellationToken).GetLocation() ?? Location.None; + if (attribute.ConstructorArguments.Length != 2 || + attribute.ConstructorArguments[0].Value is not ITypeSymbol target || + attribute.ConstructorArguments[1].Value is not ITypeSymbol codec) + { + Report(DtoDiagnosticKind.CustomCodecBindingInvalid, _compilation.Assembly, + "assembly-level RpcCodec requires targetType and codecType", location); + continue; + } + if (HasTypeParameter(target)) + { + Report(DtoDiagnosticKind.CustomCodecTargetInvalid, target, + "custom Codec target must be a closed type", location); + continue; + } + + target = NormalizeAdapterTarget(target); + if (IsFrameworkWirePrimitive(target)) + { + Report(DtoDiagnosticKind.BuiltinCustomCodecOverride, target, + "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", + location); + continue; + } + + AddCanonicalCustomCodecBinding(target, codec, location); + } + } + + private void AddCanonicalCustomCodecBinding(ITypeSymbol target, ITypeSymbol codec, Location location) + { + var identity = GetCanonicalPolicyTargetIdentity(target); + if (_canonicalCustomCodecBindings.TryGetValue(identity, out var existing) && + !SymbolEqualityComparer.Default.Equals(existing.CodecType, codec)) + { + Report(DtoDiagnosticKind.CustomCodecSelectionConflict, target, + "the target is explicitly bound to multiple custom Codec implementations", location); + return; + } + + var registration = ValidateCustomCodecWithCanonicalTarget(codec, target, location); + if (registration is null) + return; + + _customCodecBindings[target] = registration; + _canonicalCustomCodecBindings[identity] = registration; + if (_contractMode) + _contractOwnedPolicyRoots.Add(identity); + } + + private CustomCodecRegistration? ValidateCustomCodecWithCanonicalTarget( + ITypeSymbol codecType, + ITypeSymbol targetType, + Location location) + { + if (codecType is not INamedTypeSymbol named) + { + Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, + "custom Codec must be a closed, public sealed type", location); + return null; + } + + if (HasTypeParameter(named) || + !IsEffectivelyPublic(named) || + !named.IsSealed || + !named.InstanceConstructors.Any(static constructor => + constructor.DeclaredAccessibility == Accessibility.Public && + constructor.Parameters.Length == 0)) + { + Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, + "custom Codec must be a public sealed type with a public parameterless constructor", location); + return null; + } + + var implementsTargetCodec = named.AllInterfaces.Any(item => + item.Name == "IRpcCodec" && + item.ContainingNamespace.ToDisplayString() == "SharpLink.Abstractions" && + item is INamedTypeSymbol { IsGenericType: true } generic && + generic.TypeArguments.Length == 1 && + HasSameCanonicalPolicyTarget(generic.TypeArguments[0], targetType)); + if (!implementsTargetCodec) + { + Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, + $"custom Codec must implement IRpcCodec<{GetTypeName(targetType)}>", location); + return null; + } + + if (!HasValidOpaqueSemanticIdentity(named)) + { + Report(DtoDiagnosticKind.CustomCodecIdentityInvalid, codecType, + "custom Codec must declare a non-zero fixed semantic identity via [RpcCodecSemanticIdentity(high, low)]", location); + return null; + } + + return new CustomCodecRegistration(named, location); + } + + private void CollectCanonicalAssemblyBindings() + { + foreach (var attribute in _compilation.Assembly.GetAttributes() + .Where(static attribute => IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecAdapterAttribute"))) + { + var location = attribute.ApplicationSyntaxReference?.GetSyntax(_cancellationToken).GetLocation() ?? Location.None; + if (attribute.ConstructorArguments.Length != 2 || + attribute.ConstructorArguments[0].Value is not ITypeSymbol target || + attribute.ConstructorArguments[1].Value is not INamedTypeSymbol adapter) + { + Report(DtoDiagnosticKind.AdapterBindingInvalid, _compilation.Assembly, + "assembly-level RpcCodecAdapter requires targetType and adapterType", location); + continue; + } + if (HasTypeParameter(target)) + { + Report(DtoDiagnosticKind.AdapterTargetInvalid, target, + "Adapter target must be a closed type", location); + continue; + } + + target = NormalizeAdapterTarget(target); + if (IsFrameworkWirePrimitive(target)) + { + Report(DtoDiagnosticKind.BuiltinAdapterOverride, target, + "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", + location); + continue; + } + + AddCanonicalAssemblyBinding(target, new ExplicitBindingCandidate(adapter, location)); + } + } + + private void AddCanonicalAssemblyBinding(ITypeSymbol target, ExplicitBindingCandidate candidate) + { + var identity = GetCanonicalPolicyTargetIdentity(target); + if (_canonicalAssemblyBindings.TryGetValue(identity, out var existing)) + { + if (!SymbolEqualityComparer.Default.Equals(existing.ImplementationType, candidate.ImplementationType)) + { + Report(DtoDiagnosticKind.AdapterSelectionConflict, target, + "the target is explicitly bound to multiple different Codec Adapters", + candidate.Location); + return; + } + + _assemblyBindings[target] = existing; + return; + } + + _assemblyBindings[target] = candidate; + _canonicalAssemblyBindings[identity] = candidate; + } + + private void AddCanonicalPolicyBindingAliases() + { + if (_canonicalAssemblyBindings.Count == 0 && _canonicalCustomCodecBindings.Count == 0) + return; + + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: !_contractMode, + includeContracts: _contractMode); + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + + foreach (var reachableType in reachable.Values) + { + var lookupType = NormalizeAdapterTarget(reachableType); + var identity = GetCanonicalPolicyTargetIdentity(lookupType); + if (!_assemblyBindings.ContainsKey(lookupType) && + _canonicalAssemblyBindings.TryGetValue(identity, out var adapterBinding)) + { + _assemblyBindings[lookupType] = adapterBinding; + } + if (!_customCodecBindings.ContainsKey(lookupType) && + _canonicalCustomCodecBindings.TryGetValue(identity, out var customBinding)) + { + _customCodecBindings[lookupType] = customBinding; + } + } + } + + internal DtoAnalysisPassResult AnalyzeWithFinalCodecBindings() + { + _ = Analyze(); + PromoteSelectedFixedMembersToCodecBindings(); + NormalizeGeneratedModuleDependencies(); + return new DtoAnalysisPassResult( + _models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray(), + _diagnostics.ToImmutableArray(), + _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal).ToImmutableArray()); + } + + internal HashSet GetCurrentContractReachableTypeNames() + { + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: false, + includeContracts: true); + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + return new HashSet(reachable.Keys, StringComparer.Ordinal); + } + + private void PromoteSelectedFixedMembersToCodecBindings() + { + if (!_applyCodecPolicy || _models.Count == 0) + return; + + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: !_contractMode, + includeContracts: _contractMode); + + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + + var dtoModels = _models.Values + .Where(static model => model.Kind == GeneratedCodecKind.Dto) + .ToArray(); + foreach (var model in dtoModels) + { + if (!reachable.TryGetValue(model.TypeName, out var type) || type is not INamedTypeSymbol named) + continue; + + var memberSymbols = GetSerializableMembers(named) + .ToDictionary(static member => member.Name, StringComparer.Ordinal); + var members = model.Members.ToArray(); + var changed = false; + for (var index = 0; index < members.Length; index++) + { + var member = members[index]; + if (member.Kind is not (GeneratedMemberKind.Fixed or GeneratedMemberKind.NullableFixed or GeneratedMemberKind.String) || + !memberSymbols.TryGetValue(member.Name, out var memberSymbol)) + { + continue; + } + + var memberType = GetMemberType(memberSymbol); + if (!HasSelectedMemberCodec(memberType)) + continue; + + Visit(memberType, [], 0); + members[index] = member with + { + Kind = GeneratedMemberKind.Complex, + FixedTypeName = null, + FixedSize = 0, + EnumUnderlyingType = null + }; + changed = true; + } + + if (!changed) + continue; + + var finalizedMembers = members.ToImmutableArray(); + var schema = new StringBuilder(model.TypeName); + foreach (var member in finalizedMembers) + { + schema.Append('|').Append(member.FieldId).Append(':').Append(member.TypeName) + .Append(':').Append(member.Kind).Append(':').Append(member.Required); + if (member.Nullable) + schema.Append(":nullable"); + } + _models[model.TypeName] = model with + { + Members = finalizedMembers, + SchemaId = GetSchemaId(model.TypeName, schema.ToString()) + }; + } + } + + private bool HasSelectedCompositeCodecDependency(ITypeSymbol type) + { + if (!TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) + return false; + + return (elementType is not null && HasSelectedMemberCodec(elementType)) || + (keyType is not null && HasSelectedMemberCodec(keyType)) || + (valueType is not null && HasSelectedMemberCodec(valueType)); + } + + private bool HasSelectedMemberCodec(ITypeSymbol memberType) + { + if (IsFrameworkWirePrimitive(memberType)) + return false; + if (TrySelectCustomCodec(memberType, out var customCodec)) + return customCodec is not null; + + AdapterRegistration? selected = null; + var hasSelection = _contractMode + ? TrySelectContractCodecOverride(memberType, out selected) + : TrySelectAdapter(memberType, out selected); + return hasSelection && selected is not null; + } + + private void NormalizeGeneratedModuleDependencies() + { + if (_models.Count == 0) + return; + + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: !_contractMode, + includeContracts: _contractMode); + var symbolsByType = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, symbolsByType, seen, 0); + + var localFactoryTypes = new HashSet(_models.Keys, StringComparer.Ordinal); + foreach (var model in _models.Values.ToArray()) + { + if (model.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + { + _models[model.TypeName] = model with + { + AssemblyDependencies = ImmutableArray.Empty + }; + continue; + } + + var dependencies = new HashSet(StringComparer.Ordinal); + foreach (var dependencyTypeName in GetCodecDependencies(model)) + { + if (localFactoryTypes.Contains(dependencyTypeName) || + !symbolsByType.TryGetValue(dependencyTypeName, out var dependencyType) || + IsBuiltin(dependencyType)) + { + continue; + } + + var assembly = dependencyType.ContainingAssembly; + if (assembly is not null && + !SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly) && + HasGeneratedAssemblyManifest(assembly)) + { + dependencies.Add(assembly.Identity.ToString()); + } + } + + _models[model.TypeName] = model with + { + AssemblyDependencies = dependencies + .OrderBy(static identity => identity, StringComparer.Ordinal) + .ToImmutableArray() + }; + } + } + + private void CollectFinalBindingTypes( + ITypeSymbol type, + Dictionary reachable, + HashSet seen, + int depth) + { + if (depth > MaximumDepth || !seen.Add(type)) + return; + var typeName = GetTypeName(type); + reachable[typeName] = type; + if (_models.TryGetValue(typeName, out var finalModel) && + finalModel.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + { + return; + } + + if (type is IArrayTypeSymbol array) + { + CollectFinalBindingTypes(array.ElementType, reachable, seen, depth + 1); + return; + } + if (TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) + { + if (elementType is not null) + CollectFinalBindingTypes(elementType, reachable, seen, depth + 1); + if (keyType is not null) + CollectFinalBindingTypes(keyType, reachable, seen, depth + 1); + if (valueType is not null) + CollectFinalBindingTypes(valueType, reachable, seen, depth + 1); + return; + } + if (type is not INamedTypeSymbol named || IsThirdPartyType(type)) + return; + + foreach (var member in GetSerializableMembers(named)) + CollectFinalBindingTypes(GetMemberType(member), reachable, seen, depth + 1); + } + } } From 7ce883e02b1d33ee8fcd5737e3389db396386f53 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:20:06 +0800 Subject: [PATCH 114/399] fix: prune invalid codec graph before hashing --- src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 38e02e6b3..286fc9f8f 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -497,8 +497,10 @@ internal DtoAnalysisPassResult AnalyzeWithFinalCodecBindings() _ = Analyze(); PromoteSelectedFixedMembersToCodecBindings(); NormalizeGeneratedModuleDependencies(); + var finalizedCodecs = FilterFailedCodecClosure( + _models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray()); return new DtoAnalysisPassResult( - _models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray(), + finalizedCodecs, _diagnostics.ToImmutableArray(), _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal).ToImmutableArray()); } From d3330b2881fe0299de086be8c6992fb216f84347 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:24:12 +0800 Subject: [PATCH 115/399] test: remove indirect route diagnostic probe --- .../RpcCodecRouteDiagnosticTests.cs | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs b/test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs deleted file mode 100644 index 619665431..000000000 --- a/test/SharpLink.Generator.Tests/RpcCodecRouteDiagnosticTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace SharpLink.Generator.Tests; - -public partial class RpcAnalyzerTests -{ - [Test] - public Task DiagnoseIndirectAllRouteBinding() - { - var thirdParty = CreateMetadataReference( - "ThirdParty.Indirect.Diagnostic", - """ -namespace Vendor -{ - public sealed class ExternalGraph { public string Name { get; set; } = string.Empty; } - public struct ExternalPoint { public int X; public int Y; } -} -"""); - var source = AddAssemblyAttributes(BuildRouteSource(""" -public sealed class Envelope -{ - public Vendor.ExternalGraph Graph { get; set; } = new(); - public Vendor.ExternalPoint Point { get; set; } -} - -[SharpLink.Sdk.RpcContract] -public interface IEnvelopeContract : SharpLink.Sdk.IService -{ - ValueTask Echo(Envelope value, CancellationToken cancellationToken); -} - -[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1000000000000005UL, 0x2000000000000005UL)] -public sealed class RouteAdapter : TestRouteAdapterBase -{ - public override string AdapterId => "route.all/v1"; -} -"""), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(RouteAdapter), \"route.all/v1\")]", - "[assembly: SharpLink.Sdk.RpcCodecRoute(SharpLink.Sdk.RpcCodecScope.All, typeof(RouteAdapter))]"); - - var diagnostics = RunGenerator(source, thirdParty); - var generated = string.Join("\n", RunGeneratorAndGetSources(source, thirdParty)); - var routed = generated.Contains("CreateCodec()", StringComparison.Ordinal); - if (!routed) - { - throw new Exception( - $"indirect all-route diagnostic: nativeEnvelope={generated.Contains("IRpcCodec", StringComparison.Ordinal)}; " + - $"targetFactory={generated.Contains("TargetType => typeof(global::Envelope)", StringComparison.Ordinal)}; " + - $"adapterId={generated.Contains("route.all/v1", StringComparison.Ordinal)}; " + - $"contractPolicy={generated.Contains("__SharpLinkGeneratedContractPolicyCodec_", StringComparison.Ordinal)}; " + - $"diagnostics={FormatDiagnostics(diagnostics)}"); - } - return Task.CompletedTask; - } -} From dbaa6d8425250f9971f40b6547a36901f80e6eb4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:32:47 +0800 Subject: [PATCH 116/399] test: remove obsolete codec factory constructor identity --- .../Client/SharpLinkMultiClusterClientTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs b/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs index f1467af94..bc6524fa9 100644 --- a/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs +++ b/test/SharpLink.UnitTests/Client/SharpLinkMultiClusterClientTests.cs @@ -1693,7 +1693,7 @@ private sealed class Manifest : ISharpLinkGeneratedAssemblyManifest ]; public IReadOnlyList Services { get; } = []; public IReadOnlyList Codecs { get; } = - [new TestCodecFactory("orders-value")]; + [new TestCodecFactory()]; public IReadOnlyList Dependencies { get; } = []; } @@ -1710,7 +1710,7 @@ private sealed class RouteManifest : ISharpLinkGeneratedClusterRouteManifest ]; } - private sealed class TestCodecFactory(string _) : IRpcGeneratedCodecFactory + private sealed class TestCodecFactory : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); public RpcHash128 CodecHash => new(0x6d756c7469636c75UL, 0x737465722d636f64UL); From 950b9526a725aff72d6e57feb093e12fff69e420 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:33:10 +0800 Subject: [PATCH 117/399] test: remove codec factory warning suppression --- test/SharpLink.UnitTests/Client/.editorconfig | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 test/SharpLink.UnitTests/Client/.editorconfig diff --git a/test/SharpLink.UnitTests/Client/.editorconfig b/test/SharpLink.UnitTests/Client/.editorconfig deleted file mode 100644 index 8cba9048f..000000000 --- a/test/SharpLink.UnitTests/Client/.editorconfig +++ /dev/null @@ -1,2 +0,0 @@ -[SharpLinkMultiClusterClientTests.cs] -dotnet_diagnostic.CS9113.severity = none From e6454b46739e609dcaf4a1bd6d1b29df40ebf7a5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:33:21 +0800 Subject: [PATCH 118/399] test: restore generator formatting enforcement --- test/SharpLink.Generator.Tests/.editorconfig | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 test/SharpLink.Generator.Tests/.editorconfig diff --git a/test/SharpLink.Generator.Tests/.editorconfig b/test/SharpLink.Generator.Tests/.editorconfig deleted file mode 100644 index 8de746099..000000000 --- a/test/SharpLink.Generator.Tests/.editorconfig +++ /dev/null @@ -1,5 +0,0 @@ -root = false - -[RpcAnalyzerTests.cs] -generated_code = true -dotnet_diagnostic.CS8669.severity = none From 414ccfa4f09e3ae904cf00b963b3a9cf5022b369 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:40:31 +0800 Subject: [PATCH 119/399] style: fix generator analyzer fixture whitespace --- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index ff67725a0..1a7fb69d3 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -2026,7 +2026,7 @@ public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]") , + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]), "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(ValueTuple), typeof(FakeAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); @@ -2218,7 +2218,7 @@ public abstract class TestAdapterBase : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]") , + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]), "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SecondAdapter), \"second/v1\", \"second-wire/v1\")]"); EnsureHasRule(source, "SHARPLINK045"); return Task.CompletedTask; From 235e4a708da27c3a8036f0b5a4229f2353b35e18 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:52:18 +0800 Subject: [PATCH 120/399] fix: restore valid analyzer fixture syntax --- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index 1a7fb69d3..ff67725a0 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -2026,7 +2026,7 @@ public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]") , "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(ValueTuple), typeof(FakeAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); @@ -2218,7 +2218,7 @@ public abstract class TestAdapterBase : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]") , "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SecondAdapter), \"second/v1\", \"second-wire/v1\")]"); EnsureHasRule(source, "SHARPLINK045"); return Task.CompletedTask; From 4948011c98c585e54bb98b5d805605115f93e1a4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:55:28 +0800 Subject: [PATCH 121/399] chore: run one-off analyzer fixture format fix --- .../zz-one-off-analyzer-format-fix.yml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/zz-one-off-analyzer-format-fix.yml diff --git a/.github/workflows/zz-one-off-analyzer-format-fix.yml b/.github/workflows/zz-one-off-analyzer-format-fix.yml new file mode 100644 index 000000000..754dee4e0 --- /dev/null +++ b/.github/workflows/zz-one-off-analyzer-format-fix.yml @@ -0,0 +1,54 @@ +name: One-off analyzer fixture format fix + +on: + push: + branches: + - feature/issue-396-deterministic-rpc-identity + +permissions: + contents: write + +jobs: + fix: + if: github.event.head_commit.message == 'chore: run one-off analyzer fixture format fix' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: feature/issue-396-deterministic-rpc-identity + fetch-depth: 0 + - name: Apply exact formatting repair + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path('test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs') + text = path.read_text(encoding='utf-8') + replacements = [ + ( + ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \\"fake.adapter/v1\\", \\"fake-wire/v1\\")]\") ,\n', + ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \\"fake.adapter/v1\\", \\"fake-wire/v1\\")]\"),\n', + ), + ( + ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \\"first/v1\\", \\"first-wire/v1\\", SelectorAttributeType = typeof(FirstSelectorAttribute))]\") ,\n', + ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \\"first/v1\\", \\"first-wire/v1\\", SelectorAttributeType = typeof(FirstSelectorAttribute))]\"),\n', + ), + ] + for old, new in replacements: + count = text.count(old) + if count != 1: + raise SystemExit(f'expected exactly one formatting target, found {count}: {old!r}') + text = text.replace(old, new) + path.write_text(text, encoding='utf-8') + PY + git diff --check + git diff -- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs + - name: Commit exact repair + shell: bash + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs + git commit -m "style: format generator analyzer fixtures" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity From 78570785c3154dbe085ec31847148a48ddd6e4dc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:57:03 +0800 Subject: [PATCH 122/399] chore: remove one-off analyzer format workflow --- .../zz-one-off-analyzer-format-fix.yml | 54 ------------------- 1 file changed, 54 deletions(-) delete mode 100644 .github/workflows/zz-one-off-analyzer-format-fix.yml diff --git a/.github/workflows/zz-one-off-analyzer-format-fix.yml b/.github/workflows/zz-one-off-analyzer-format-fix.yml deleted file mode 100644 index 754dee4e0..000000000 --- a/.github/workflows/zz-one-off-analyzer-format-fix.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: One-off analyzer fixture format fix - -on: - push: - branches: - - feature/issue-396-deterministic-rpc-identity - -permissions: - contents: write - -jobs: - fix: - if: github.event.head_commit.message == 'chore: run one-off analyzer fixture format fix' - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: feature/issue-396-deterministic-rpc-identity - fetch-depth: 0 - - name: Apply exact formatting repair - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path('test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs') - text = path.read_text(encoding='utf-8') - replacements = [ - ( - ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \\"fake.adapter/v1\\", \\"fake-wire/v1\\")]\") ,\n', - ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \\"fake.adapter/v1\\", \\"fake-wire/v1\\")]\"),\n', - ), - ( - ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \\"first/v1\\", \\"first-wire/v1\\", SelectorAttributeType = typeof(FirstSelectorAttribute))]\") ,\n', - ' "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \\"first/v1\\", \\"first-wire/v1\\", SelectorAttributeType = typeof(FirstSelectorAttribute))]\"),\n', - ), - ] - for old, new in replacements: - count = text.count(old) - if count != 1: - raise SystemExit(f'expected exactly one formatting target, found {count}: {old!r}') - text = text.replace(old, new) - path.write_text(text, encoding='utf-8') - PY - git diff --check - git diff -- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs - - name: Commit exact repair - shell: bash - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs - git commit -m "style: format generator analyzer fixtures" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity From 9e6cab702ad07ed79731e0ea9f9e82f3d2a6a8a2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:11:08 +0800 Subject: [PATCH 123/399] style: format generator analyzer fixtures --- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index ff67725a0..fdcd4f05a 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -2218,7 +2218,7 @@ public abstract class TestAdapterBase : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]") , + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FirstAdapter), \"first/v1\", \"first-wire/v1\", SelectorAttributeType = typeof(FirstSelectorAttribute))]"), "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(SecondAdapter), \"second/v1\", \"second-wire/v1\")]"); EnsureHasRule(source, "SHARPLINK045"); return Task.CompletedTask; From beb13e5be49c5a449ed35ee85dd19c106bac9135 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:16:56 +0800 Subject: [PATCH 124/399] style: finish analyzer fixture formatting --- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index fdcd4f05a..e812e1aef 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -953,7 +953,7 @@ public Task ReferencedAssemblyManifestsShouldEmitDeterministicStaticBootstrapCal var ordinary = CreateMetadataReference( "OrdinaryDependency", "namespace OrdinaryDependency { public sealed class OrdinaryType { } }"); - const string consumer = "namespace Consumer { internal sealed class Marker { } }"; + const string consumer = "namespace Consumer { internal sealed class Marker; }"; var first = GetReferencedManifestBootstrap( RunGeneratorAndGetSources(consumer, infrastructure, zeta, ordinary, legacy, malformed, alpha)); @@ -2026,7 +2026,7 @@ public sealed class FakeAdapter : SharpLink.Abstractions.IRpcCodecAdapter public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } """), - "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]") , + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(FakeAdapter), \"fake.adapter/v1\", \"fake-wire/v1\")]"), "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(ValueTuple), typeof(FakeAdapter))]"); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); From b5054328cf2143d311df2b6ba40a03e6cdde7775 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:27:33 +0800 Subject: [PATCH 125/399] ci: stage PR 415 timeout migration --- .../workflows/tmp-pr415-timeout-migration.yml | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/tmp-pr415-timeout-migration.yml diff --git a/.github/workflows/tmp-pr415-timeout-migration.yml b/.github/workflows/tmp-pr415-timeout-migration.yml new file mode 100644 index 000000000..bb6803e67 --- /dev/null +++ b/.github/workflows/tmp-pr415-timeout-migration.yml @@ -0,0 +1,88 @@ +name: PR415 Timeout Migration + +on: + push: + branches: + - feature/issue-396-deterministic-rpc-identity + +permissions: + contents: write + +jobs: + migrate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feature/issue-396-deterministic-rpc-identity + fetch-depth: 0 + + - name: Select explicit timeout policies + shell: python + run: | + from pathlib import Path + import re + + roots = [ + Path('test/SharpLink.IntegrationTests'), + Path('test/SharpLink.PackageSmoke'), + Path('test/SharpLink.ReferenceRooting.PackageClient'), + Path('test/SharpLink.AotSmoke'), + Path('test/SharpLink.PreCreditAotSmoke'), + ] + builders = ( + 'SharpClientBuilder.Create()', + 'SharpLinkMultiClusterClientBuilder.Create()', + ) + + def has_policy_in_direct_chain(text: str, end: int) -> bool: + window = text[end:end + 3000] + build = window.find('.Build()') + semicolon = window.find(';') + boundary_candidates = [value for value in (build, semicolon) if value >= 0] + boundary = min(boundary_candidates) if boundary_candidates else len(window) + chain = window[:boundary] + return '.DisableRequestTimeout()' in chain or '.UseRequestTimeout(' in chain or '.UseRequestTimeout()' in chain + + changed = [] + for root in roots: + if not root.exists(): + continue + for path in sorted(root.rglob('*.cs')): + original = path.read_text(encoding='utf-8') + text = original + for builder in builders: + cursor = 0 + while True: + index = text.find(builder, cursor) + if index < 0: + break + end = index + len(builder) + if has_policy_in_direct_chain(text, end): + cursor = end + continue + line_start = text.rfind('\n', 0, index) + 1 + indent = re.match(r'[ \t]*', text[line_start:index]).group(0) + insertion = f"\n{indent} .DisableRequestTimeout()" + text = text[:end] + insertion + text[end:] + cursor = end + len(insertion) + if text != original: + path.write_text(text, encoding='utf-8') + changed.append(str(path)) + + if not changed: + raise SystemExit('No timeout-policy migrations were needed.') + print('\n'.join(changed)) + + - name: Verify changed text + run: git diff --check + + - name: Commit migration + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests test/SharpLink.PackageSmoke test/SharpLink.ReferenceRooting.PackageClient test/SharpLink.AotSmoke test/SharpLink.PreCreditAotSmoke + git commit -m "test: select explicit request timeout policies" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity From 44341a089eb7838e5f16cb37bd550e2b484c5e2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:27:41 +0000 Subject: [PATCH 126/399] test: select explicit request timeout policies --- test/SharpLink.AotSmoke/Program.cs | 5 ++++ ...PipeTransportConnectionIntegrationTests.cs | 1 + .../Api3BinaryFixtureIntegrationTests.cs | 2 ++ .../ClientStreamingResultStressTests.cs | 1 + .../CompressionCallCapacityAdmissionTests.cs | 1 + ...essionPersistentDecodeControlPlaneTests.cs | 1 + ...ionPersistentDecodeDrainAndFailureTests.cs | 1 + ...ssionPersistentDecodeFairLifecycleTests.cs | 1 + ...ompressionPersistentDecodeFairnessTests.cs | 1 + ...ionPersistentDecodeFourWorkerCloseTests.cs | 1 + ...nPersistentDecodePreActivationRaceTests.cs | 1 + .../CompressionPersistentDecodeReviewTests.cs | 1 + .../DynamicAdmissionGenerationTests.cs | 1 + .../DynamicAdmissionRuntimeControlTests.cs | 2 ++ ...AdmissionRuntimeResourceRegressionTests.cs | 1 + ...micAdmissionStateKernelIntegrationTests.cs | 1 + ...cAdmissionUpdateResourceRegressionTests.cs | 1 + .../DynamicEndpointIntegrationTests.cs | 21 +++++++++++++ .../DynamicInterceptorIntegrationTests.cs | 1 + .../EnterpriseHostingIntegrationTests.cs | 1 + .../IntegrationBehaviorTests.cs | 1 + .../InterceptorIntegrationTests.cs | 3 ++ ...eWayEarlyRejectionDrainIntegrationTests.cs | 1 + .../OneWayInboundDrainIntegrationTests.cs | 1 + ...eWayOuterDrainRejectionIntegrationTests.cs | 1 + ...ionStreamActivationRaceIntegrationTests.cs | 1 + ...reAdmissionStreamBudgetIntegrationTests.cs | 1 + .../RuntimeAssemblyIntegrationTests.cs | 8 +++++ ...InterceptorContinuationIntegrationTests.cs | 1 + ...imeInterceptorFaultRaceIntegrationTests.cs | 1 + ...nterceptorOverlapStressIntegrationTests.cs | 1 + ...terceptorReviewCoverageIntegrationTests.cs | 1 + ...untimeInterceptorUnwindIntegrationTests.cs | 1 + .../RuntimeMultiClusterIntegrationTests.cs | 2 ++ .../ServiceLifetimeIntegrationTests.cs | 1 + ...moryTransportConnectionIntegrationTests.cs | 2 ++ .../StaticEndpointIntegrationTests.cs | 30 +++++++++++++++++++ .../TelemetryIntegrationTests.cs | 1 + .../TlsTransportIntegrationTests.cs | 4 +++ .../TransportConnectionIntegrationTests.cs | 23 ++++++++++++++ test/SharpLink.PackageSmoke/Program.cs | 10 +++++-- test/SharpLink.PreCreditAotSmoke/Program.cs | 2 ++ .../Program.cs | 1 + 43 files changed, 142 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.AotSmoke/Program.cs b/test/SharpLink.AotSmoke/Program.cs index 3b80f320f..a9af39675 100644 --- a/test/SharpLink.AotSmoke/Program.cs +++ b/test/SharpLink.AotSmoke/Program.cs @@ -86,6 +86,7 @@ public static async Task Main(string[] args) if (useSharedMemory) { client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseSharedMemory(sharedMemoryName) .Build(); @@ -93,6 +94,7 @@ public static async Task Main(string[] args) else { client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpointResolver( new DelegateSharpLinkEndpointResolver( @@ -168,6 +170,7 @@ private static async Task RunClientOnlyAsync(string name) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseSharedMemory(name) .UseRuntime(ConfigureCompression) .Build(); @@ -301,6 +304,7 @@ private static async Task VerifyStaticReadinessClientAsync( } }; await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseCluster(options => @@ -350,6 +354,7 @@ private static ISharpLinkMultiClusterClient CreateMultiClusterClient( string sharedMemoryName, int port) => SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster( "orders", child => ConfigureClientTransport(child, useSharedMemory, sharedMemoryName, port), diff --git a/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs index d9ee87998..b59c81edb 100644 --- a/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs @@ -97,6 +97,7 @@ public static async Task CreateAsync() var (inHandle, outHandle) = await allocator.AllocateAsync(cts.Token); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseAnonymousPipe(inHandle, outHandle) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) diff --git a/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs b/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs index a9256e153..cfd81a142 100644 --- a/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs @@ -223,10 +223,12 @@ internal static async Task CreateAsync() var server = serverBuilder.Build(); var serverTask = server.RunAsync(cancellation.Token).AsTask(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); await client.ConnectAsync(); var multiClient = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster( "plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), port), diff --git a/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs b/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs index 827787684..e0b05ab70 100644 --- a/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs +++ b/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs @@ -370,6 +370,7 @@ public static async Task CreateAsync( CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)); if (enableCompression) diff --git a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs index e21416dc6..efa70dd71 100644 --- a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs @@ -438,6 +438,7 @@ public static async Task CreateAsync( }, CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs index 8f0ad551f..9d1c1b74d 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs @@ -413,6 +413,7 @@ internal static async Task CreateAsync( }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs index 595055c3c..dd0dac34c 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs @@ -331,6 +331,7 @@ internal static async Task CreateAsync( }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs index a2fbe1d39..708994585 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs @@ -233,6 +233,7 @@ public async ValueTask DisposeAsync() private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs index 82e04fdce..2494f2edc 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs @@ -246,6 +246,7 @@ public async ValueTask DisposeAsync() private static ISharpLinkClient CreateClient(int port, string wireProfile, string tag) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs index 3b60d8da9..d287d9dd4 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs @@ -245,6 +245,7 @@ private T Read(string name) private static ISharpLinkClient CreateClient(int port, string profile, string tag) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add(new Provider(profile, tag, null))) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs index d18f92d3c..12682057d 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs @@ -230,6 +230,7 @@ internal static async Task CreateAsync(ISharpLinkCompressionProvide }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs index 51a9b6151..b7d99fc90 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs @@ -337,6 +337,7 @@ internal static async Task CreateAsync( var serverTask = RunServerAsync(server, serverCts.Token); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs index 6db36fa20..00921cd59 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -676,6 +676,7 @@ private static ISharpLinkClient CreateClient( Action? runtimeConfigure) { var builder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (runtimeConfigure is not null) builder.UseRuntime(runtimeConfigure); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs index 039e01ac6..287386612 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs @@ -323,10 +323,12 @@ internal static async Task CreateAsync( var serverTask = RunServerAsync(server, serverCancellation.Token); var clientA = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); var clientB = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs index 0650f1a43..9b316be63 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs @@ -366,6 +366,7 @@ private static ISharpLinkClient CreateClient( Action? runtimeConfigure) { var builder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (runtimeConfigure is not null) builder.UseRuntime(runtimeConfigure); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs index 775acf41d..e13df28ba 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs @@ -415,6 +415,7 @@ internal static async Task CreateAsync( var server = (SharpLinkServer)serverBuilder.Build(); var serverTask = RunServerAsync(server, serverCancellation.Token); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs index 4aac3a217..f8d6f6ea7 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs @@ -359,6 +359,7 @@ private static ISharpLinkClient CreateClient( Action? runtimeConfigure) { var builder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (runtimeConfigure is not null) builder.UseRuntime(runtimeConfigure); diff --git a/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs index 982278167..0ec9dc92b 100644 --- a/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs @@ -17,6 +17,7 @@ public async Task DynamicResolverShouldAddRemoveReplaceAndUpdateAttributesWithou var factoryCreates = 0; var sockets = SharpLinkTransportFactories.Sockets(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver( @@ -89,6 +90,7 @@ public async Task DynamicReadinessShouldTrackTopologyChangesAndKeepWaiterCancell Endpoint("second", second.Port, "green") ])); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new IdSelector("third")) @@ -229,6 +231,7 @@ public async Task EmptyDynamicTopologyShouldRecoverWhenTheResolverPublishesAnEnd await using var second = await TcpServerScope.StartAsync("second"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [])); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseCluster(options => { @@ -286,6 +289,7 @@ public async Task DynamicEndpointRemovalShouldDrainAnAcceptedStreamAndRouteNewCa Endpoint("second", second.Port, "green") ])); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) @@ -343,6 +347,7 @@ public async Task StaleDynamicSelectionShouldNotRecreateRetiredAdmissionState() using var selector = new PausingSelector(); var admission = new TrackingLifecycleAdmissionPolicy(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(selector) .UseEndpointAdmission(admission) @@ -384,6 +389,7 @@ public async Task CustomDynamicSelectorShouldRejectTheOnlyNonMatchingReadyEndpoi Endpoint("west", west.Port, "west") ])); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new ZoneSelector("west")) @@ -417,6 +423,7 @@ public async Task RejectedDynamicFactoryReuseMustKeepTheLastGoodFactoryAlive() TrackingTransportFactory? factory = null; var factoryCreates = 0; var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver( resolver, @@ -457,6 +464,7 @@ public async Task FailedInitialDynamicTopologyShouldAllowConnectToWaitForRecover var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("failed", 1, "red")])); var factory = new FailingConnectFactory(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, _ => factory) .Build(); @@ -492,6 +500,7 @@ public async Task FailedInitialDynamicDialShouldProbeLaterEndpointsWithoutWaitin var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id switch { @@ -529,6 +538,7 @@ public async Task DynamicRecoveryToAnEmptyTopologyShouldReleaseConnectWaiters() { var resolver = new FailingThenEmptyResolver(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, _ => new FailingConnectFactory()) .Build(); @@ -554,6 +564,7 @@ public async Task FailedInitialDynamicDialShouldReconnectWithoutANewerResolverVe var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("recovered", server.Port, "green")])); var factory = new FailOnceConnectFactory(SharpLinkTransportFactories.Sockets()(Endpoint("recovered", server.Port, "green"))); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, _ => factory) .Build(); @@ -583,6 +594,7 @@ public async Task RejectedDynamicSnapshotCleanupShouldContinueAfterFactoryDispos var remainingFactory = new FailingConnectFactory(); var factoryCreates = 0; var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver( resolver, @@ -639,6 +651,7 @@ public async Task DynamicReplacementShouldWaitForExcessRetiringConnectionsToDrai await using var second = await TcpServerScope.StartAsync("second"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("first", first.Port, "blue")])); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) @@ -685,6 +698,7 @@ public async Task DynamicReconnectShouldProbeHealthyEndpointsAfterAFailingEndpoi var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "bad" ? failing : sockets(endpoint)) .UseCluster(options => @@ -710,6 +724,7 @@ public async Task DynamicStopShouldWaitForAnInitialConnectThatIgnoresCancellatio var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("blocked", 1, "red")])); var blocking = new BlockingConnectFactory(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, _ => blocking) .Build(); @@ -748,6 +763,7 @@ public async Task InitialDynamicConnectShouldCompleteWhenAReplacementTopologyBec var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "blocked" ? blocking : sockets(endpoint)) .UseCluster(options => @@ -785,6 +801,7 @@ public async Task RetiredDynamicDialsShouldContinueToConsumeTheConnectionBudget( var sockets = SharpLinkTransportFactories.Sockets(); var replacementFactory = new CountingConnectFactory(sockets(Endpoint("replacement", replacement.Port, "green"))); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "blocked" ? blocking : replacementFactory) .UseCluster(options => @@ -833,6 +850,7 @@ public async Task ConnectAfterDynamicClusterDisconnectShouldAwaitRecovery() var blocking = new BlockAfterFirstConnectFactory(sockets(Endpoint("first", first.Port, "blue"))); var unavailable = new FailingConnectFactory(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "first" ? blocking : unavailable) .UseCluster(options => @@ -869,6 +887,7 @@ public async Task InitialDynamicDialReservationsShouldPreventSurplusTargetFill() var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id switch { @@ -911,6 +930,7 @@ public async Task ResolverWatchEndAndFailureShouldRetryAndRetainTheLastGoodTopol new SharpLinkEndpointSnapshot(1, [Endpoint("first", first.Port, "blue")]), new SharpLinkEndpointSnapshot(2, [Endpoint("recovered", recovered.Port, "green")])); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .Build(); @@ -928,6 +948,7 @@ public async Task DnsEndpointHelperShouldResolveLocalhostAndPreserveHostnameAuth { await using var server = await TcpServerScope.StartAsync("dns"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseDnsEndpoints( "localhost", diff --git a/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs index 365f091bd..78413568e 100644 --- a/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs @@ -527,6 +527,7 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs b/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs index 8e7689f14..f8a7747ec 100644 --- a/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs @@ -227,6 +227,7 @@ public static async Task CreateAsync( var server = builder.Build(); var serverTask = Task.Run(() => server.RunAsync(serverCts.Token).AsTask()); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) diff --git a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs index 3ff51c448..389331421 100644 --- a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs +++ b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs @@ -2068,6 +2068,7 @@ public static async Task CreateAsync( }, CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (codecResolver is not null) clientBuilder.UseSerializer(codecResolver); diff --git a/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs b/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs index ca31219d6..b064a6ce4 100644 --- a/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs @@ -27,6 +27,7 @@ public async Task ClientAndServerInterceptorsShouldObserveGeneratedContext() public async Task ClientInterceptorShouldShortCircuitWithoutAConnection() { var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), GetFreePort()) .AddInterceptor(new ShortCircuitClientInterceptor(777)) .Build(); @@ -665,6 +666,7 @@ private static int GetFreePort() private static ISharpLinkClient CreateDisconnectedClient(ISharpLinkClientInterceptor interceptor) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), GetFreePort()) .AddInterceptor(interceptor) .Build(); @@ -1084,6 +1086,7 @@ public static async Task CreateAsync( var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)); diff --git a/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs b/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs index 694eee783..a067128bf 100644 --- a/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs @@ -207,6 +207,7 @@ public static async Task CreateAsync(Action ru CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseRuntime(runtimeConfigure) diff --git a/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs b/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs index 0a9aa4c1b..3a8b7614f 100644 --- a/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs @@ -160,6 +160,7 @@ public static async Task CreateAsync(Action ru CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseRuntime(runtimeConfigure) diff --git a/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs index cd832785a..c33fc4223 100644 --- a/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs @@ -131,6 +131,7 @@ internal static async Task CreateAsync(Action CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseRuntime(runtimeConfigure) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs index 7a21a00c1..2a091db75 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs @@ -301,6 +301,7 @@ private T ReadServerDiagnostic(string name) private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs index d2ad6e135..fc5cf59ad 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs @@ -307,6 +307,7 @@ private T ReadServerDiagnostic(string name) private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs index 8eba4426b..0b3382d60 100644 --- a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs @@ -12,6 +12,7 @@ public sealed class RuntimeAssemblyIntegrationTests public async Task MultiClusterDynamicRegistrationShouldRouteToOneExplicitSlot() { await using var client = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster("plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), 1), slot => slot.AllowDynamicContracts = true) .AddCluster("other", child => child.UseTcp(IPAddress.Loopback.ToString(), 2), @@ -151,6 +152,7 @@ public async Task MultiClusterDeferredUnregisterShouldRemoveARegistrationRelease { using var plugin = PluginBundle.Load("multi-cluster-deferred-unregister", loadService: false); await using var registrationSource = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(plugin.ContractAssembly); @@ -185,6 +187,7 @@ public async Task MultiClusterRejectedUnregisterShouldRestoreCoordinatorRoute() { using var plugin = PluginBundle.Load("multi-cluster-rejected-unregister", loadService: false); await using var registrationSource = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(plugin.ContractAssembly); @@ -225,6 +228,7 @@ public async Task MultiClusterRejectedUnregisterShouldReserveContractIdsUntilRou using var originalPlugin = PluginBundle.Load("multi-cluster-rejected-unregister-original", loadService: false); using var reloadedPlugin = PluginBundle.Load("multi-cluster-rejected-unregister-reloaded", loadService: false); await using var registrationSource = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(originalPlugin.ContractAssembly); @@ -276,6 +280,7 @@ public async Task MultiClusterReplacementCleanupFailureShouldReconcilePublishedC using var newPlugin = PluginBundle.Load( "multi-cluster-replacement-cleanup-failure-new", loadService: false); await using var registrationSource = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(oldPlugin.ContractAssembly); @@ -1565,6 +1570,7 @@ private static async Task RegisterRemoveAndUnloadMultiClusterPlug { var plugin = PluginBundle.Load("multi-cluster-runtime-remove", loadService: false); await using var client = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster( "plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), 1), @@ -1790,6 +1796,7 @@ private static async Task WaitUntilAsync(Func condition) private static async Task CreateDynamicMultiClusterClientAsync(int port) { var client = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster("plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), port), slot => slot.AllowDynamicContracts = true) .Build(); @@ -2271,6 +2278,7 @@ internal static async Task CreateAsync( var server = serverBuilder.Build(); var serverTask = server.RunAsync(serverCancellation.Token).AsTask(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs index e12a29a7e..f314375aa 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs @@ -284,6 +284,7 @@ public static async Task CreateAsync( var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)); if (clientInterceptor is not null) diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs index b81154c39..8f81ecb54 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs @@ -7,6 +7,7 @@ public async Task ClientReplacementShouldSerializeWithFaultPublication() { var transport = new GatedFailClientTransportFactory(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTransport(transport) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs index fae658738..b6e4ced52 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs @@ -240,6 +240,7 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs index a209c914e..86d257bb7 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs @@ -393,6 +393,7 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs index a7f1001da..5771acd9a 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs @@ -319,6 +319,7 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs index dc0a2bfbd..7a04529c9 100644 --- a/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs @@ -16,6 +16,7 @@ public async Task RuntimeTcpSlotShouldAddReplaceAndRemoveWithoutRebindingOldProx await using var first = await ServerScope.StartAsync("first"); await using var second = await ServerScope.StartAsync("second"); await using var client = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster( "bootstrap", child => child.UseTcp(IPAddress.Loopback.ToString(), first.Port), @@ -61,6 +62,7 @@ public async Task RuntimeDynamicResolverShouldUpdateEndpointsWithoutReplacingThe 1, [Endpoint("resolver-first", first.Port)])); await using var client = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster( "bootstrap", child => child.UseTcp(IPAddress.Loopback.ToString(), first.Port), diff --git a/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs b/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs index bd2c37ac2..65807321b 100644 --- a/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs @@ -231,6 +231,7 @@ public async Task BuilderFiltersShouldBeValidatedAndIsolatedPerServer() private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs index b1f8ed87c..777a8e130 100644 --- a/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs @@ -1132,6 +1132,7 @@ private static ISharpLinkClient CreateAuthenticatedSharedMemoryClient(string nam { var payload = Encoding.UTF8.GetBytes(token); return SharpClientBuilder.Create() + .DisableRequestTimeout() .UseSharedMemory(name) .UseAuthenticator(SharpLinkAuthenticator.CreateClient( @@ -1257,6 +1258,7 @@ public static async Task CreateAsync(string name) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .Build(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseSharedMemory(name, options => { options.CapacityPerDirectionBytes = 64 * 1024; diff --git a/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs b/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs index 93c63e361..b624dc25b 100644 --- a/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs @@ -6,6 +6,7 @@ public sealed class StaticEndpointIntegrationTests public async Task StaticReadinessCreatedSnapshotsShouldReflectConfiguredEndpointCounts() { await using var twoEndpointClient = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", 1), Endpoint("second", 2)], @@ -18,6 +19,7 @@ public async Task StaticReadinessCreatedSnapshotsShouldReflectConfiguredEndpoint }) .Build(); await using var threeEndpointClient = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", 1), Endpoint("second", 2), Endpoint("third", 3)], @@ -59,6 +61,7 @@ public async Task StaticReadinessWaitsShouldNotChangeConnectAsyncConnectivityBou var sockets = SharpLinkTransportFactories.Sockets(); var gatedSecond = new GatedConnectFactory(sockets(Endpoint("second", second.Port))); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -127,6 +130,7 @@ public async Task StaticReadinessWaitBelowTargetShouldCompleteBeforeFullConverge var sockets = SharpLinkTransportFactories.Sockets(); var gatedThird = new GatedConnectFactory(sockets(Endpoint("third", third.Port))); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [ @@ -188,6 +192,7 @@ public async Task StaticReadinessThresholdAboveConfiguredTargetShouldFailWithout var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [ @@ -227,6 +232,7 @@ public async Task StaticTcpEndpointsShouldConnectAndContinueWhenOneEndpointStops await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpoints( @@ -262,6 +268,7 @@ public async Task InitialEndpointFailureShouldNotPreventAnotherEndpointFromConne await using var available = await TcpServerScope.StartAsync("available"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("unavailable", unavailablePort), Endpoint("available", available.Port)], @@ -288,6 +295,7 @@ public async Task FailedInitialStaticDialShouldProbeLaterEndpointsWithoutWaiting var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [ @@ -331,6 +339,7 @@ public async Task AllUnavailableEndpointsShouldReportUnavailable() var firstPort = GetUnusedTcpPort(); var secondPort = GetUnusedTcpPort(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", firstPort), Endpoint("second", secondPort)], @@ -348,6 +357,7 @@ public async Task DisconnectedEndpointShouldReconnectWithoutInterruptingAnotherE await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -409,6 +419,7 @@ public async Task InvalidCustomSelectorShouldFailOnlyTheCurrentCall() await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -433,6 +444,7 @@ public async Task ThrowingCustomSelectorShouldLeaveTheClusterHealthyForLaterCall await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -454,6 +466,7 @@ public async Task StaticClusterShouldExpandWithinGlobalAndPerEndpointBudgets() await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -506,6 +519,7 @@ public async Task CustomStaticSelectorShouldRejectTheOnlyNonMatchingReadyEndpoin await using var east = await TcpServerScope.StartAsync("east"); await using var west = await TcpServerScope.StartAsync("west"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("east", east.Port, "east"), Endpoint("west", west.Port, "west")], @@ -539,6 +553,7 @@ public async Task StaticNamedPipeEndpointsShouldServeRpc() await using var first = await TcpServerScope.StartNamedPipeAsync(firstName); await using var second = await TcpServerScope.StartNamedPipeAsync(secondName); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [ @@ -560,6 +575,7 @@ public async Task StaticSharedMemoryEndpointsShouldServeRpc() await using var first = await TcpServerScope.StartSharedMemoryAsync(firstName); await using var second = await TcpServerScope.StartSharedMemoryAsync(secondName); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [ @@ -583,6 +599,7 @@ public async Task StaticUdsEndpointsShouldServeRpc() await using var first = await TcpServerScope.StartUdsAsync(firstPath); await using var second = await TcpServerScope.StartUdsAsync(secondPath); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [ @@ -623,6 +640,7 @@ public async Task StaticTcpEndpointsShouldSupportHostnameIpv4AndIpv6() } await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseCluster(options => @@ -653,6 +671,7 @@ public async Task ConcurrentConnectAndStopShouldConvergeStaticClusterResources() await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -689,6 +708,7 @@ public async Task StopShouldWaitForInitialSiblingDialsBeforeDisposingFactories() var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("blocked", GetUnusedTcpPort())], @@ -726,6 +746,7 @@ public async Task InitialStaticDialReservationsShouldPreventSurplusTargetFill() var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("blocked", 1), Endpoint("surplus", 2)], @@ -769,6 +790,7 @@ public async Task ConnectAfterStaticClusterDisconnectShouldAwaitRecovery() var blocking = new BlockAfterFirstConnectFactory(sockets(Endpoint("first", first.Port))); var unavailable = new FailingConnectFactory(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("unavailable", 1)], @@ -801,6 +823,7 @@ public async Task FailedInitialSiblingDialShouldContinueFillingMinReadyEndpoints var sockets = SharpLinkTransportFactories.Sockets(); var delayedFailure = new DeferredFailOnceFactory(sockets(Endpoint("recovered", recovered.Port))); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("recovered", recovered.Port)], @@ -836,6 +859,7 @@ public async Task StaticReconnectShouldProbeHealthyEndpointsAfterAFailingEndpoin var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("bad", 1), Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -866,6 +890,7 @@ public async Task InitialStaticConnectShouldContinueFillingTargetsBeyondTheFirst await using var fourth = await TcpServerScope.StartAsync("fourth"); await using var fifth = await TcpServerScope.StartAsync("fifth"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [ @@ -903,6 +928,7 @@ public async Task RoundRobinAndCustomAttributeSelectorsShouldChooseExpectedEndpo }; await using (var roundRobin = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseLoadBalancing(SharpLinkLoadBalancingStrategy.RoundRobin) @@ -923,6 +949,7 @@ await service.GetEndpointIdAsync() } await using var custom = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new AttributeSelector("west")) @@ -939,6 +966,7 @@ public async Task LeastPendingShouldAvoidEndpointWithAnActiveCall() await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -970,6 +998,7 @@ public async Task LeastPendingShouldRotateTiesAcrossReadyEndpoints() await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -1004,6 +1033,7 @@ public async Task GoAwayShouldDrainExistingUnaryAndStreamWhileNewCallsUseAnother await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], diff --git a/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs b/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs index 2c4838ed0..30ddbcc53 100644 --- a/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs @@ -228,6 +228,7 @@ public static async Task CreateAsync() var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) diff --git a/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs b/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs index a4b007e47..cf9294c11 100644 --- a/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs @@ -21,6 +21,7 @@ public async Task RuntimeMultiClusterAddAndReplaceShouldPreserveTlsAndAuthentica CreateServerOptions(certificate), expectedAuthenticationToken: "runtime-token"); await using var client = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster( "bootstrap", child => child @@ -198,6 +199,7 @@ public async Task TlsHandshakeShouldHonorIndependentTimeout() using var acceptCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var acceptTask = listener.AcceptSocketAsync(acceptCts.Token).AsTask(); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp( IPAddress.Loopback.ToString(), port, @@ -228,6 +230,7 @@ public async Task StaticTlsEndpointsShouldUseEndpointAuthorityAndIsolateFailure( await using var second = await StartServerAsync(0, CreateServerOptions(certificate)); var tlsOptions = CreateClientOptions(string.Empty); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseEndpoints( [ @@ -257,6 +260,7 @@ public async Task StaticTlsEndpointsShouldUseEndpointAuthorityAndIsolateFailure( private static ISharpLinkClient CreateClient(int port, SslClientAuthenticationOptions options) => SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port, options, TimeSpan.FromSeconds(2)) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs index cf48d12cf..cd405bb7f 100644 --- a/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs @@ -264,6 +264,7 @@ public async Task ClientMalformedHandshakeShouldReleaseItsReadBeforeCompletingTh { var connection = new CompletionJoiningTransportConnection(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTransport(new SingleConnectionClientFactory(connection)) .Build(); @@ -445,6 +446,7 @@ public async Task TcpConnectWithoutServerShouldThrowSocketException() { var port = GetFreePort(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -465,6 +467,7 @@ public async Task NamedPipeConnectWithoutServerShouldHonorCancellation() { var pipeName = $"sharplink-int-no-server-{Guid.NewGuid():N}"; var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseNamedPipe(pipeName) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -491,6 +494,7 @@ public async Task UdsConnectWithoutServerShouldThrowSocketException() var socketPath = GetUniqueUdsPath(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseUds(socketPath) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -512,6 +516,7 @@ public async Task TcpConnectWithCanceledTokenShouldThrowOperationCanceledExcepti { var port = GetFreePort(); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -550,6 +555,7 @@ public async Task TcpClientHandshakeShouldHonorConfiguredTimeout() } }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.HandshakeTimeout = TimeSpan.FromMilliseconds(120)) @@ -592,6 +598,7 @@ public async Task TcpClientHandshakeShouldHonorCallerCancellation() } }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.HandshakeTimeout = TimeSpan.FromSeconds(5)) @@ -674,6 +681,7 @@ public async Task TcpHandshakeFailureShouldReturnFalse() }); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -813,6 +821,7 @@ public async Task TcpOversizedFrameShouldFailPendingUnaryAndStreamWithSameProtoc }); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.MaxFramePayloadBytes = maxFramePayloadBytes) @@ -874,6 +883,7 @@ public async Task TcpCustomAuthenticatorShouldAcceptMatchingHandshakeMessage() }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -925,6 +935,7 @@ public async Task TcpCustomAuthenticatorShouldRejectMismatchedHandshakeMessage() }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("unexpected-token")) @@ -978,6 +989,7 @@ public async Task TcpStructuredAuthenticatorShouldExposeCustomAuthenticationErro }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expired-token")) @@ -1020,6 +1032,7 @@ public async Task TcpAuthenticatorShouldRejectContradictoryAuthenticatedResult() var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1059,6 +1072,7 @@ public async Task TcpAuthenticatorShouldSanitizeAnUndefinedRejectionCode() var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1096,6 +1110,7 @@ public async Task TcpAuthenticatorShouldRejectExpiredContextDuringHandshake() var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1130,6 +1145,7 @@ public async Task TcpClientShouldRejectOversizedAuthenticationPayloadBeforeSend( var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.MaxMetadataBytes = maxAuthenticationBytes) @@ -1192,6 +1208,7 @@ public async Task TcpStructuredAuthenticatorShouldExposeAuthenticationContextToS }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -1238,11 +1255,13 @@ public async Task TcpAuthenticationContextShouldRemainIsolatedPerConnection() var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var firstClient = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("connection-a")) .Build(); var secondClient = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("connection-b")) @@ -1318,6 +1337,7 @@ public async Task TcpAuthorizationGuardsShouldReturnStructuredRemoteErrors() }, CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -1599,6 +1619,7 @@ private static async Task VerifyNegotiatedFrameLimitAsync(int clientLimit, int s var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(options => options.MaxFramePayloadBytes = clientLimit) @@ -1632,6 +1653,7 @@ await EnsureThrowsSharpLink( private static ISharpLinkClient BuildClientForEndpoint(TransportEndpoint endpoint) { var builder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); @@ -1710,6 +1732,7 @@ private static async Task CreateAsync(TransportKind kind, Tran .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); diff --git a/test/SharpLink.PackageSmoke/Program.cs b/test/SharpLink.PackageSmoke/Program.cs index 0f1bb57fd..de92a5519 100644 --- a/test/SharpLink.PackageSmoke/Program.cs +++ b/test/SharpLink.PackageSmoke/Program.cs @@ -94,6 +94,7 @@ private static async Task RunTransportSmokeAsync( var serverTask = RunServerAsync(server, cancellationToken); var clientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureCompression); if (useSharedMemory) clientBuilder.UseSharedMemory(sharedMemoryName); @@ -148,6 +149,7 @@ private static async Task RunRuntimeMultiClusterSmokeAsync( CancellationToken cancellationToken) { await using var client = SharpLinkMultiClusterClientBuilder.Create() + .DisableRequestTimeout() .AddCluster( "bootstrap", child => child.UseTcp(IPAddress.Loopback.ToString(), port), @@ -210,6 +212,7 @@ private static async Task RunStaticEndpointSmokeAsync(CancellationToken cancella } }; var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpoints( endpoints, @@ -239,6 +242,7 @@ private static async Task RunStaticEndpointSmokeAsync(CancellationToken cancella throw new InvalidOperationException("Static endpoint package smoke returned an unexpected result."); await using var dynamicClient = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpointResolver( new DelegateSharpLinkEndpointResolver( @@ -498,7 +502,8 @@ private static void AssertEnginePublicApiBoundary() AssertPublicSpi(clientInterceptor); AssertPublicSpi(serverInterceptor); - var directClientBuilder = SharpClientBuilder.Create(); + var directClientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout(); AssertBuilderReturnsSelf( directClientBuilder, directClientBuilder @@ -513,7 +518,8 @@ private static void AssertEnginePublicApiBoundary() SharpLinkEndpointTransportFactory endpointTransportFactory = static _ => new PackageClientTransportFactory(); AssertPublicType(); - var resolverClientBuilder = SharpClientBuilder.Create(); + var resolverClientBuilder = SharpClientBuilder.Create() + .DisableRequestTimeout(); AssertBuilderReturnsSelf( resolverClientBuilder, resolverClientBuilder.UseEndpointResolver(endpointResolver, endpointTransportFactory), diff --git a/test/SharpLink.PreCreditAotSmoke/Program.cs b/test/SharpLink.PreCreditAotSmoke/Program.cs index 92e6932ad..012d1ce4e 100644 --- a/test/SharpLink.PreCreditAotSmoke/Program.cs +++ b/test/SharpLink.PreCreditAotSmoke/Program.cs @@ -61,6 +61,7 @@ public static async Task Main(string[] args) if (useSharedMemory) { client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureRuntime) .UseSharedMemory(sharedMemoryName) .Build(); @@ -68,6 +69,7 @@ public static async Task Main(string[] args) else { client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseRuntime(ConfigureRuntime) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.ReferenceRooting.PackageClient/Program.cs b/test/SharpLink.ReferenceRooting.PackageClient/Program.cs index c1baf87a2..15c628729 100644 --- a/test/SharpLink.ReferenceRooting.PackageClient/Program.cs +++ b/test/SharpLink.ReferenceRooting.PackageClient/Program.cs @@ -12,6 +12,7 @@ public static async Task Main(string[] args) using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); await using var client = SharpClientBuilder.Create() + .DisableRequestTimeout() .UseSharedMemory(args[0]) .Build(); await client.ConnectAsync(timeout.Token); From f3a8b84f1b0aadd681af5b136c5158ed3c23c6b6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:29:01 +0800 Subject: [PATCH 127/399] ci: remove PR 415 timeout migration helper --- .../workflows/tmp-pr415-timeout-migration.yml | 88 ------------------- 1 file changed, 88 deletions(-) delete mode 100644 .github/workflows/tmp-pr415-timeout-migration.yml diff --git a/.github/workflows/tmp-pr415-timeout-migration.yml b/.github/workflows/tmp-pr415-timeout-migration.yml deleted file mode 100644 index bb6803e67..000000000 --- a/.github/workflows/tmp-pr415-timeout-migration.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: PR415 Timeout Migration - -on: - push: - branches: - - feature/issue-396-deterministic-rpc-identity - -permissions: - contents: write - -jobs: - migrate: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feature/issue-396-deterministic-rpc-identity - fetch-depth: 0 - - - name: Select explicit timeout policies - shell: python - run: | - from pathlib import Path - import re - - roots = [ - Path('test/SharpLink.IntegrationTests'), - Path('test/SharpLink.PackageSmoke'), - Path('test/SharpLink.ReferenceRooting.PackageClient'), - Path('test/SharpLink.AotSmoke'), - Path('test/SharpLink.PreCreditAotSmoke'), - ] - builders = ( - 'SharpClientBuilder.Create()', - 'SharpLinkMultiClusterClientBuilder.Create()', - ) - - def has_policy_in_direct_chain(text: str, end: int) -> bool: - window = text[end:end + 3000] - build = window.find('.Build()') - semicolon = window.find(';') - boundary_candidates = [value for value in (build, semicolon) if value >= 0] - boundary = min(boundary_candidates) if boundary_candidates else len(window) - chain = window[:boundary] - return '.DisableRequestTimeout()' in chain or '.UseRequestTimeout(' in chain or '.UseRequestTimeout()' in chain - - changed = [] - for root in roots: - if not root.exists(): - continue - for path in sorted(root.rglob('*.cs')): - original = path.read_text(encoding='utf-8') - text = original - for builder in builders: - cursor = 0 - while True: - index = text.find(builder, cursor) - if index < 0: - break - end = index + len(builder) - if has_policy_in_direct_chain(text, end): - cursor = end - continue - line_start = text.rfind('\n', 0, index) + 1 - indent = re.match(r'[ \t]*', text[line_start:index]).group(0) - insertion = f"\n{indent} .DisableRequestTimeout()" - text = text[:end] + insertion + text[end:] - cursor = end + len(insertion) - if text != original: - path.write_text(text, encoding='utf-8') - changed.append(str(path)) - - if not changed: - raise SystemExit('No timeout-policy migrations were needed.') - print('\n'.join(changed)) - - - name: Verify changed text - run: git diff --check - - - name: Commit migration - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests test/SharpLink.PackageSmoke test/SharpLink.ReferenceRooting.PackageClient test/SharpLink.AotSmoke test/SharpLink.PreCreditAotSmoke - git commit -m "test: select explicit request timeout policies" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity From d03c913625ff66fc51b5e7075fe70379abfb72f5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:35:35 +0800 Subject: [PATCH 128/399] ci: stage PR 415 timeout LOC cleanup --- .github/workflows/tmp-pr415-timeout-loc.yml | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/tmp-pr415-timeout-loc.yml diff --git a/.github/workflows/tmp-pr415-timeout-loc.yml b/.github/workflows/tmp-pr415-timeout-loc.yml new file mode 100644 index 000000000..daedc4196 --- /dev/null +++ b/.github/workflows/tmp-pr415-timeout-loc.yml @@ -0,0 +1,58 @@ +name: PR415 Timeout LOC Cleanup + +on: + push: + branches: + - feature/issue-396-deterministic-rpc-identity + +permissions: + contents: write + +jobs: + cleanup: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feature/issue-396-deterministic-rpc-identity + fetch-depth: 0 + - name: Keep explicit policies without LOC debt + shell: python + run: | + from pathlib import Path + import re + + paths = [ + Path('test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs'), + Path('test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs'), + Path('test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs'), + Path('test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs'), + Path('test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs'), + ] + pattern = re.compile( + r'(SharpClientBuilder\.Create\(\)|SharpLinkMultiClusterClientBuilder\.Create\(\))\r?\n[ \t]*\.DisableRequestTimeout\(\)') + changed = [] + for path in paths: + original = path.read_text(encoding='utf-8') + text, count = pattern.subn(r'\1.DisableRequestTimeout()', original) + if count: + path.write_text(text, encoding='utf-8') + changed.append((str(path), count)) + if not changed: + raise SystemExit('No timeout policy chains were compacted.') + for path, count in changed: + print(f'{path}: {count}') + - run: git diff --check + - name: Commit cleanup + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs \ + test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs \ + test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs \ + test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs \ + test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs + git commit -m "test: keep explicit timeout policies within LOC baseline" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity From d69e166ec2a715e4e8c2fab1135f166f15486d4c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:35:44 +0000 Subject: [PATCH 129/399] test: keep explicit timeout policies within LOC baseline --- .../DynamicEndpointIntegrationTests.cs | 63 +++++-------- .../InterceptorIntegrationTests.cs | 9 +- ...moryTransportConnectionIntegrationTests.cs | 6 +- .../StaticEndpointIntegrationTests.cs | 90 +++++++------------ .../TransportConnectionIntegrationTests.cs | 69 +++++--------- 5 files changed, 79 insertions(+), 158 deletions(-) diff --git a/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs index 0ec9dc92b..3a0ef8b8b 100644 --- a/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs @@ -16,8 +16,7 @@ public async Task DynamicResolverShouldAddRemoveReplaceAndUpdateAttributesWithou var selector = new ZoneSelector("blue"); var factoryCreates = 0; var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver( @@ -89,8 +88,7 @@ public async Task DynamicReadinessShouldTrackTopologyChangesAndKeepWaiterCancell Endpoint("first", first.Port, "blue"), Endpoint("second", second.Port, "green") ])); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new IdSelector("third")) @@ -230,8 +228,7 @@ public async Task EmptyDynamicTopologyShouldRecoverWhenTheResolverPublishesAnEnd await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [])); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseCluster(options => { @@ -288,8 +285,7 @@ public async Task DynamicEndpointRemovalShouldDrainAnAcceptedStreamAndRouteNewCa Endpoint("first", first.Port, "blue"), Endpoint("second", second.Port, "green") ])); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) @@ -346,8 +342,7 @@ public async Task StaleDynamicSelectionShouldNotRecreateRetiredAdmissionState() new SharpLinkEndpointSnapshot(1, [Endpoint("retiring", server.Port, "blue")])); using var selector = new PausingSelector(); var admission = new TrackingLifecycleAdmissionPolicy(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(selector) .UseEndpointAdmission(admission) @@ -388,8 +383,7 @@ public async Task CustomDynamicSelectorShouldRejectTheOnlyNonMatchingReadyEndpoi Endpoint("east", east.Port, "east"), Endpoint("west", west.Port, "west") ])); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new ZoneSelector("west")) @@ -422,8 +416,7 @@ public async Task RejectedDynamicFactoryReuseMustKeepTheLastGoodFactoryAlive() var sockets = SharpLinkTransportFactories.Sockets(); TrackingTransportFactory? factory = null; var factoryCreates = 0; - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver( resolver, @@ -463,8 +456,7 @@ public async Task FailedInitialDynamicTopologyShouldAllowConnectToWaitForRecover { var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("failed", 1, "red")])); var factory = new FailingConnectFactory(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, _ => factory) .Build(); @@ -499,8 +491,7 @@ public async Task FailedInitialDynamicDialShouldProbeLaterEndpointsWithoutWaitin var blocking = new BlockingConnectFactory(); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id switch { @@ -537,8 +528,7 @@ public async Task FailedInitialDynamicDialShouldProbeLaterEndpointsWithoutWaitin public async Task DynamicRecoveryToAnEmptyTopologyShouldReleaseConnectWaiters() { var resolver = new FailingThenEmptyResolver(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, _ => new FailingConnectFactory()) .Build(); @@ -563,8 +553,7 @@ public async Task FailedInitialDynamicDialShouldReconnectWithoutANewerResolverVe await using var server = await TcpServerScope.StartAsync("recovered"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("recovered", server.Port, "green")])); var factory = new FailOnceConnectFactory(SharpLinkTransportFactories.Sockets()(Endpoint("recovered", server.Port, "green"))); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, _ => factory) .Build(); @@ -593,8 +582,7 @@ public async Task RejectedDynamicSnapshotCleanupShouldContinueAfterFactoryDispos var throwingFactory = new ThrowingDisposeFactory(); var remainingFactory = new FailingConnectFactory(); var factoryCreates = 0; - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver( resolver, @@ -650,8 +638,7 @@ public async Task DynamicReplacementShouldWaitForExcessRetiringConnectionsToDrai await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("first", first.Port, "blue")])); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) @@ -697,8 +684,7 @@ public async Task DynamicReconnectShouldProbeHealthyEndpointsAfterAFailingEndpoi ])); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "bad" ? failing : sockets(endpoint)) .UseCluster(options => @@ -723,8 +709,7 @@ public async Task DynamicStopShouldWaitForAnInitialConnectThatIgnoresCancellatio { var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("blocked", 1, "red")])); var blocking = new BlockingConnectFactory(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, _ => blocking) .Build(); @@ -762,8 +747,7 @@ public async Task InitialDynamicConnectShouldCompleteWhenAReplacementTopologyBec var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("blocked", 1, "red")])); var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "blocked" ? blocking : sockets(endpoint)) .UseCluster(options => @@ -800,8 +784,7 @@ public async Task RetiredDynamicDialsShouldContinueToConsumeTheConnectionBudget( var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var replacementFactory = new CountingConnectFactory(sockets(Endpoint("replacement", replacement.Port, "green"))); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "blocked" ? blocking : replacementFactory) .UseCluster(options => @@ -849,8 +832,7 @@ public async Task ConnectAfterDynamicClusterDisconnectShouldAwaitRecovery() var sockets = SharpLinkTransportFactories.Sockets(); var blocking = new BlockAfterFirstConnectFactory(sockets(Endpoint("first", first.Port, "blue"))); var unavailable = new FailingConnectFactory(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "first" ? blocking : unavailable) .UseCluster(options => @@ -886,8 +868,7 @@ public async Task InitialDynamicDialReservationsShouldPreventSurplusTargetFill() var blocking = new BlockingConnectFactory(); var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, endpoint => endpoint.Id switch { @@ -929,8 +910,7 @@ public async Task ResolverWatchEndAndFailureShouldRetryAndRetainTheLastGoodTopol var resolver = new RestartingResolver( new SharpLinkEndpointSnapshot(1, [Endpoint("first", first.Port, "blue")]), new SharpLinkEndpointSnapshot(2, [Endpoint("recovered", recovered.Port, "green")])); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .Build(); @@ -947,8 +927,7 @@ await WaitUntilAsync(async () => await client.Get(). public async Task DnsEndpointHelperShouldResolveLocalhostAndPreserveHostnameAuthority() { await using var server = await TcpServerScope.StartAsync("dns"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseDnsEndpoints( "localhost", diff --git a/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs b/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs index b064a6ce4..7b89eeaf8 100644 --- a/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs @@ -26,8 +26,7 @@ public async Task ClientAndServerInterceptorsShouldObserveGeneratedContext() [Test] public async Task ClientInterceptorShouldShortCircuitWithoutAConnection() { - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), GetFreePort()) .AddInterceptor(new ShortCircuitClientInterceptor(777)) .Build(); @@ -665,8 +664,7 @@ private static int GetFreePort() } private static ISharpLinkClient CreateDisconnectedClient(ISharpLinkClientInterceptor interceptor) - => SharpClientBuilder.Create() - .DisableRequestTimeout() + => SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), GetFreePort()) .AddInterceptor(interceptor) .Build(); @@ -1085,8 +1083,7 @@ public static async Task CreateAsync( var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() + var clientBuilder = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)); diff --git a/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs index 777a8e130..508252aa1 100644 --- a/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs @@ -1131,8 +1131,7 @@ private static NamedPipeClientStream CreateRawSharedMemoryPipe(string name) private static ISharpLinkClient CreateAuthenticatedSharedMemoryClient(string name, string token) { var payload = Encoding.UTF8.GetBytes(token); - return SharpClientBuilder.Create() - .DisableRequestTimeout() + return SharpClientBuilder.Create().DisableRequestTimeout() .UseSharedMemory(name) .UseAuthenticator(SharpLinkAuthenticator.CreateClient( @@ -1257,8 +1256,7 @@ public static async Task CreateAsync(string name) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .Build(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseSharedMemory(name, options => { options.CapacityPerDirectionBytes = 64 * 1024; diff --git a/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs b/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs index b624dc25b..6268c238e 100644 --- a/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs @@ -5,8 +5,7 @@ public sealed class StaticEndpointIntegrationTests [Test] public async Task StaticReadinessCreatedSnapshotsShouldReflectConfiguredEndpointCounts() { - await using var twoEndpointClient = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var twoEndpointClient = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", 1), Endpoint("second", 2)], @@ -18,8 +17,7 @@ public async Task StaticReadinessCreatedSnapshotsShouldReflectConfiguredEndpoint options.MaxConnectionsPerEndpoint = 1; }) .Build(); - await using var threeEndpointClient = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var threeEndpointClient = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", 1), Endpoint("second", 2), Endpoint("third", 3)], @@ -60,8 +58,7 @@ public async Task StaticReadinessWaitsShouldNotChangeConnectAsyncConnectivityBou await using var second = await TcpServerScope.StartAsync("second"); var sockets = SharpLinkTransportFactories.Sockets(); var gatedSecond = new GatedConnectFactory(sockets(Endpoint("second", second.Port))); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -129,8 +126,7 @@ public async Task StaticReadinessWaitBelowTargetShouldCompleteBeforeFullConverge await using var third = await TcpServerScope.StartAsync("third"); var sockets = SharpLinkTransportFactories.Sockets(); var gatedThird = new GatedConnectFactory(sockets(Endpoint("third", third.Port))); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [ @@ -191,8 +187,7 @@ public async Task StaticReadinessThresholdAboveConfiguredTargetShouldFailWithout await using var second = await TcpServerScope.StartAsync("second"); var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [ @@ -231,8 +226,7 @@ public async Task StaticTcpEndpointsShouldConnectAndContinueWhenOneEndpointStops { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpoints( @@ -267,8 +261,7 @@ public async Task InitialEndpointFailureShouldNotPreventAnotherEndpointFromConne unavailableListener.Stop(); await using var available = await TcpServerScope.StartAsync("available"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("unavailable", unavailablePort), Endpoint("available", available.Port)], @@ -294,8 +287,7 @@ public async Task FailedInitialStaticDialShouldProbeLaterEndpointsWithoutWaiting var blocking = new BlockingConnectFactory(); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [ @@ -338,8 +330,7 @@ public async Task AllUnavailableEndpointsShouldReportUnavailable() { var firstPort = GetUnusedTcpPort(); var secondPort = GetUnusedTcpPort(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", firstPort), Endpoint("second", secondPort)], @@ -356,8 +347,7 @@ public async Task DisconnectedEndpointShouldReconnectWithoutInterruptingAnotherE { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -418,8 +408,7 @@ public async Task InvalidCustomSelectorShouldFailOnlyTheCurrentCall() { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -443,8 +432,7 @@ public async Task ThrowingCustomSelectorShouldLeaveTheClusterHealthyForLaterCall { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -465,8 +453,7 @@ public async Task StaticClusterShouldExpandWithinGlobalAndPerEndpointBudgets() { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -518,8 +505,7 @@ public async Task CustomStaticSelectorShouldRejectTheOnlyNonMatchingReadyEndpoin { await using var east = await TcpServerScope.StartAsync("east"); await using var west = await TcpServerScope.StartAsync("west"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("east", east.Port, "east"), Endpoint("west", west.Port, "west")], @@ -552,8 +538,7 @@ public async Task StaticNamedPipeEndpointsShouldServeRpc() var secondName = $"sharplink-static-second-{Guid.NewGuid():N}"; await using var first = await TcpServerScope.StartNamedPipeAsync(firstName); await using var second = await TcpServerScope.StartNamedPipeAsync(secondName); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [ @@ -574,8 +559,7 @@ public async Task StaticSharedMemoryEndpointsShouldServeRpc() var secondName = $"sharplink-static-second-{Guid.NewGuid():N}"; await using var first = await TcpServerScope.StartSharedMemoryAsync(firstName); await using var second = await TcpServerScope.StartSharedMemoryAsync(secondName); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [ @@ -598,8 +582,7 @@ public async Task StaticUdsEndpointsShouldServeRpc() var secondPath = Path.Combine(Path.GetTempPath(), $"sharplink-static-{Guid.NewGuid():N}.sock"); await using var first = await TcpServerScope.StartUdsAsync(firstPath); await using var second = await TcpServerScope.StartUdsAsync(secondPath); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [ @@ -639,8 +622,7 @@ public async Task StaticTcpEndpointsShouldSupportHostnameIpv4AndIpv6() }); } - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseCluster(options => @@ -670,8 +652,7 @@ public async Task ConcurrentConnectAndStopShouldConvergeStaticClusterResources() { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -707,8 +688,7 @@ public async Task StopShouldWaitForInitialSiblingDialsBeforeDisposingFactories() await using var first = await TcpServerScope.StartAsync("first"); var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("blocked", GetUnusedTcpPort())], @@ -745,8 +725,7 @@ public async Task InitialStaticDialReservationsShouldPreventSurplusTargetFill() var blocking = new BlockingConnectFactory(); var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("blocked", 1), Endpoint("surplus", 2)], @@ -789,8 +768,7 @@ public async Task ConnectAfterStaticClusterDisconnectShouldAwaitRecovery() var sockets = SharpLinkTransportFactories.Sockets(); var blocking = new BlockAfterFirstConnectFactory(sockets(Endpoint("first", first.Port))); var unavailable = new FailingConnectFactory(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("unavailable", 1)], @@ -822,8 +800,7 @@ public async Task FailedInitialSiblingDialShouldContinueFillingMinReadyEndpoints await using var recovered = await TcpServerScope.StartAsync("recovered"); var sockets = SharpLinkTransportFactories.Sockets(); var delayedFailure = new DeferredFailOnceFactory(sockets(Endpoint("recovered", recovered.Port))); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("recovered", recovered.Port)], @@ -858,8 +835,7 @@ public async Task StaticReconnectShouldProbeHealthyEndpointsAfterAFailingEndpoin await using var second = await TcpServerScope.StartAsync("second"); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("bad", 1), Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -889,8 +865,7 @@ public async Task InitialStaticConnectShouldContinueFillingTargetsBeyondTheFirst await using var third = await TcpServerScope.StartAsync("third"); await using var fourth = await TcpServerScope.StartAsync("fourth"); await using var fifth = await TcpServerScope.StartAsync("fifth"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [ @@ -927,8 +902,7 @@ public async Task RoundRobinAndCustomAttributeSelectorsShouldChooseExpectedEndpo Endpoint("second", second.Port, "west") }; - await using (var roundRobin = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using (var roundRobin = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseLoadBalancing(SharpLinkLoadBalancingStrategy.RoundRobin) @@ -948,8 +922,7 @@ await service.GetEndpointIdAsync() Ensure(ids[0] != ids[1] && ids[0] == ids[2] && ids[1] == ids[3], "round robin endpoint order"); } - await using var custom = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var custom = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new AttributeSelector("west")) @@ -965,8 +938,7 @@ public async Task LeastPendingShouldAvoidEndpointWithAnActiveCall() { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -997,8 +969,7 @@ public async Task LeastPendingShouldRotateTiesAcrossReadyEndpoints() { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -1032,8 +1003,7 @@ public async Task GoAwayShouldDrainExistingUnaryAndStreamWhileNewCallsUseAnother { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + await using var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], diff --git a/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs index cd405bb7f..c34a4cb57 100644 --- a/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs @@ -263,8 +263,7 @@ public async Task ServerMalformedHandshakeShouldReleaseItsReadBeforeCompletingTh public async Task ClientMalformedHandshakeShouldReleaseItsReadBeforeCompletingTheReader() { var connection = new CompletionJoiningTransportConnection(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTransport(new SingleConnectionClientFactory(connection)) .Build(); @@ -445,8 +444,7 @@ public async Task UdsClientDisposeShouldFailFastPendingCall() public async Task TcpConnectWithoutServerShouldThrowSocketException() { var port = GetFreePort(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -466,8 +464,7 @@ public async Task TcpConnectWithoutServerShouldThrowSocketException() public async Task NamedPipeConnectWithoutServerShouldHonorCancellation() { var pipeName = $"sharplink-int-no-server-{Guid.NewGuid():N}"; - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseNamedPipe(pipeName) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -493,8 +490,7 @@ public async Task UdsConnectWithoutServerShouldThrowSocketException() return; var socketPath = GetUniqueUdsPath(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseUds(socketPath) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -515,8 +511,7 @@ public async Task UdsConnectWithoutServerShouldThrowSocketException() public async Task TcpConnectWithCanceledTokenShouldThrowOperationCanceledException() { var port = GetFreePort(); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -554,8 +549,7 @@ public async Task TcpClientHandshakeShouldHonorConfiguredTimeout() { } }, CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.HandshakeTimeout = TimeSpan.FromMilliseconds(120)) @@ -597,8 +591,7 @@ public async Task TcpClientHandshakeShouldHonorCallerCancellation() { } }, CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.HandshakeTimeout = TimeSpan.FromSeconds(5)) @@ -680,8 +673,7 @@ public async Task TcpHandshakeFailureShouldReturnFalse() await stream.FlushAsync(); }); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -820,8 +812,7 @@ public async Task TcpOversizedFrameShouldFailPendingUnaryAndStreamWithSameProtoc await stream.FlushAsync(); }); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.MaxFramePayloadBytes = maxFramePayloadBytes) @@ -882,8 +873,7 @@ public async Task TcpCustomAuthenticatorShouldAcceptMatchingHandshakeMessage() } }, CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -934,8 +924,7 @@ public async Task TcpCustomAuthenticatorShouldRejectMismatchedHandshakeMessage() } }, CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("unexpected-token")) @@ -988,8 +977,7 @@ public async Task TcpStructuredAuthenticatorShouldExposeCustomAuthenticationErro } }, CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expired-token")) @@ -1031,8 +1019,7 @@ public async Task TcpAuthenticatorShouldRejectContradictoryAuthenticatedResult() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1071,8 +1058,7 @@ public async Task TcpAuthenticatorShouldSanitizeAnUndefinedRejectionCode() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1109,8 +1095,7 @@ public async Task TcpAuthenticatorShouldRejectExpiredContextDuringHandshake() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1144,8 +1129,7 @@ public async Task TcpClientShouldRejectOversizedAuthenticationPayloadBeforeSend( var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.MaxMetadataBytes = maxAuthenticationBytes) @@ -1207,8 +1191,7 @@ public async Task TcpStructuredAuthenticatorShouldExposeAuthenticationContextToS } }, CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -1254,14 +1237,12 @@ public async Task TcpAuthenticationContextShouldRemainIsolatedPerConnection() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var firstClient = SharpClientBuilder.Create() - .DisableRequestTimeout() + var firstClient = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("connection-a")) .Build(); - var secondClient = SharpClientBuilder.Create() - .DisableRequestTimeout() + var secondClient = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("connection-b")) @@ -1336,8 +1317,7 @@ public async Task TcpAuthorizationGuardsShouldReturnStructuredRemoteErrors() } }, CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -1618,8 +1598,7 @@ private static async Task VerifyNegotiatedFrameLimitAsync(int clientLimit, int s var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create() - .DisableRequestTimeout() + var client = SharpClientBuilder.Create().DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(options => options.MaxFramePayloadBytes = clientLimit) @@ -1652,8 +1631,7 @@ await EnsureThrowsSharpLink( private static ISharpLinkClient BuildClientForEndpoint(TransportEndpoint endpoint) { - var builder = SharpClientBuilder.Create() - .DisableRequestTimeout() + var builder = SharpClientBuilder.Create().DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); @@ -1731,8 +1709,7 @@ private static async Task CreateAsync(TransportKind kind, Tran .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); - var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() + var clientBuilder = SharpClientBuilder.Create().DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); From 98c0d2fe28661a0c43df908677de1a3213263f50 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:36:10 +0800 Subject: [PATCH 130/399] ci: remove PR 415 timeout LOC helper --- .github/workflows/tmp-pr415-timeout-loc.yml | 58 --------------------- 1 file changed, 58 deletions(-) delete mode 100644 .github/workflows/tmp-pr415-timeout-loc.yml diff --git a/.github/workflows/tmp-pr415-timeout-loc.yml b/.github/workflows/tmp-pr415-timeout-loc.yml deleted file mode 100644 index daedc4196..000000000 --- a/.github/workflows/tmp-pr415-timeout-loc.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: PR415 Timeout LOC Cleanup - -on: - push: - branches: - - feature/issue-396-deterministic-rpc-identity - -permissions: - contents: write - -jobs: - cleanup: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feature/issue-396-deterministic-rpc-identity - fetch-depth: 0 - - name: Keep explicit policies without LOC debt - shell: python - run: | - from pathlib import Path - import re - - paths = [ - Path('test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs'), - Path('test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs'), - Path('test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs'), - Path('test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs'), - Path('test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs'), - ] - pattern = re.compile( - r'(SharpClientBuilder\.Create\(\)|SharpLinkMultiClusterClientBuilder\.Create\(\))\r?\n[ \t]*\.DisableRequestTimeout\(\)') - changed = [] - for path in paths: - original = path.read_text(encoding='utf-8') - text, count = pattern.subn(r'\1.DisableRequestTimeout()', original) - if count: - path.write_text(text, encoding='utf-8') - changed.append((str(path), count)) - if not changed: - raise SystemExit('No timeout policy chains were compacted.') - for path, count in changed: - print(f'{path}: {count}') - - run: git diff --check - - name: Commit cleanup - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs \ - test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs \ - test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs \ - test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs \ - test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs - git commit -m "test: keep explicit timeout policies within LOC baseline" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity From c3548a81ece2d92a94927edcc7c2c5038392937c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:38:47 +0800 Subject: [PATCH 131/399] test: cover cross-platform and UnsafeBlit identity --- .../RpcDeterministicIdentityTests.cs | 181 +++++++++++++++++- 1 file changed, 176 insertions(+), 5 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index 128104deb..456de60ab 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -1,6 +1,8 @@ using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace SharpLink.Generator.Tests; @@ -22,6 +24,29 @@ public Task DeterministicIdentityShouldBeStableAcrossRepeatedGeneration() return Task.CompletedTask; } + [Test] + public Task SameRpcSemanticsShouldProduceSameIdentityForX64AndX86() + { + var source = BuildDtoIdentitySource(includeExtraMember: false, idempotent: false); + var x64 = GenerateIdentityManifest( + "DeterministicIdentityPlatform", + source, + Platform.X64); + var x86 = GenerateIdentityManifest( + "DeterministicIdentityPlatform", + source, + Platform.X86); + + Ensure( + ExtractGeneratedCodecIdentity(x64, "DeterministicPayload") == + ExtractGeneratedCodecIdentity(x86, "DeterministicPayload"), + "CodecHash must not depend on x64 versus x86 compilation platform"); + Ensure( + ExtractGeneratedRpcAssemblyHash(x64) == ExtractGeneratedRpcAssemblyHash(x86), + "RpcAssemblyHash must not depend on x64 versus x86 compilation platform"); + return Task.CompletedTask; + } + [Test] public Task DtoWireShapeChangeShouldChangeFinalRpcIdentity() { @@ -101,13 +126,117 @@ public Task OpaqueSemanticIdentityChangeShouldChangeFinalRpcIdentity() return Task.CompletedTask; } + [Test] + public Task UnsafeBlitFieldRenameShouldPreserveIdentity() + { + var first = GenerateUnsafeBlitIdentityManifest("First", "Second", "long"); + var renamed = GenerateUnsafeBlitIdentityManifest("RenamedFirst", "RenamedSecond", "long"); + + Ensure( + ExtractGeneratedCodecIdentity(first, "UnsafeLayoutPayload") == + ExtractGeneratedCodecIdentity(renamed, "UnsafeLayoutPayload"), + "UnsafeBlit CodecHash must depend on physical layout rather than field names"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) == ExtractGeneratedRpcAssemblyHash(renamed), + "field renames that preserve UnsafeBlit bytes must not change RpcAssemblyHash"); + return Task.CompletedTask; + } + + [Test] + public Task UnsafeBlitPhysicalLayoutChangeShouldChangeIdentity() + { + var first = GenerateUnsafeBlitIdentityManifest("First", "Second", "long"); + var changed = GenerateUnsafeBlitIdentityManifest("First", "Second", "int"); + + Ensure( + ExtractGeneratedCodecIdentity(first, "UnsafeLayoutPayload") != + ExtractGeneratedCodecIdentity(changed, "UnsafeLayoutPayload"), + "changing UnsafeBlit physical field types must change CodecHash"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(changed), + "changing UnsafeBlit physical layout must change RpcAssemblyHash"); + return Task.CompletedTask; + } + + [Test] + public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies() + { + var sdk = CreateMetadataReference("DeterministicIdentitySdk", BuildSource(string.Empty)); + var shared = CreateMetadataReference( + "SharedPayloadModels", + """ +using SharpLink.Sdk; + +namespace SharedPayloadModels +{ + [RpcSerializable] + public sealed class SharedPayload + { + public int Value { get; set; } + } +} +""", + sdk); + var firstSource = """ +using System.Threading; +using System.Threading.Tasks; +using SharedPayloadModels; +using SharpLink.Sdk; + +[RpcContract] +public interface IFirstSharedPayloadContract : IService +{ + ValueTask Echo(SharedPayload value, CancellationToken cancellationToken); +} +"""; + var secondSource = """ +using System.Threading; +using System.Threading.Tasks; +using SharedPayloadModels; +using SharpLink.Sdk; + +[RpcContract] +public interface ISecondSharedPayloadContract : IService +{ + ValueTask Echo(SharedPayload value, CancellationToken cancellationToken); +} +"""; + + var first = GenerateIdentityManifest( + "FirstSharedPayloadContracts", + firstSource, + Platform.AnyCpu, + sdk, + shared); + var second = GenerateIdentityManifest( + "SecondSharedPayloadContracts", + secondSource, + Platform.AnyCpu, + sdk, + shared); + + Ensure( + ExtractGeneratedCodecIdentity(first, "SharedPayloadModels.SharedPayload") == + ExtractGeneratedCodecIdentity(second, "SharedPayloadModels.SharedPayload"), + "the same shared payload definition must publish one CodecHash across owning contract assemblies"); + return Task.CompletedTask; + } + private static string GenerateDtoIdentityManifest(bool includeExtraMember, bool idempotent) + { + var source = BuildDtoIdentitySource(includeExtraMember, idempotent); + return RunGeneratorAndGetSources(source) + .Single(static generated => + generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + } + + private static string BuildDtoIdentitySource(bool includeExtraMember, bool idempotent) { var extraMember = includeExtraMember ? "public long Extra { get; set; }" : string.Empty; var methodAttribute = idempotent ? "[SharpLink.Sdk.Idempotent]" : string.Empty; - var source = BuildSource($$""" + return BuildSource($$""" namespace SharpLink.Sdk { [AttributeUsage(AttributeTargets.Method)] @@ -128,10 +257,6 @@ public interface IDeterministicIdentityContract : SharpLink.Sdk.IService ValueTask Echo(DeterministicPayload value, CancellationToken cancellationToken); } """); - - return RunGeneratorAndGetSources(source) - .Single(static generated => - generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); } private static string GenerateOpaqueIdentityManifest( @@ -168,6 +293,52 @@ public interface IOpaqueIdentityContract : SharpLink.Sdk.IService generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); } + private static string GenerateUnsafeBlitIdentityManifest( + string firstFieldName, + string secondFieldName, + string secondFieldType) + { + var source = BuildSource($$""" +public struct UnsafeLayoutPayload +{ + public int {{firstFieldName}}; + public {{secondFieldType}} {{secondFieldName}}; +} + +[SharpLink.Sdk.RpcContract] +public interface IUnsafeLayoutIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(UnsafeLayoutPayload value, CancellationToken cancellationToken); +} +"""); + + return RunGeneratorAndGetSources(source) + .Single(static generated => + generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + } + + private static string GenerateIdentityManifest( + string assemblyName, + string source, + Platform platform, + params MetadataReference[] additionalReferences) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); + var compilation = CSharpCompilation.Create( + assemblyName, + [syntaxTree], + GetPlatformReferences().Concat(additionalReferences), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithPlatform(platform)); + + IIncrementalGenerator generator = new RpcGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGenerators(compilation); + return driver.GetRunResult().GeneratedTrees + .Select(static tree => tree.GetText().ToString()) + .Single(static generated => + generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + } + private static string ExtractGeneratedCodecIdentity(string manifest, string typeName) => manifest.Split('\n') .Single(line => From 5d5ed92d90ef7572c354e825e9a0e56c0b7b2898 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:05:30 +0800 Subject: [PATCH 132/399] chore: remove unrelated timeout policy migration --- test/SharpLink.AotSmoke/Program.cs | 5 -- ...PipeTransportConnectionIntegrationTests.cs | 1 - .../Api3BinaryFixtureIntegrationTests.cs | 2 - .../ClientStreamingResultStressTests.cs | 1 - .../CompressionCallCapacityAdmissionTests.cs | 1 - ...essionPersistentDecodeControlPlaneTests.cs | 1 - ...ionPersistentDecodeDrainAndFailureTests.cs | 1 - ...ssionPersistentDecodeFairLifecycleTests.cs | 1 - ...ompressionPersistentDecodeFairnessTests.cs | 1 - ...ionPersistentDecodeFourWorkerCloseTests.cs | 1 - ...nPersistentDecodePreActivationRaceTests.cs | 1 - .../CompressionPersistentDecodeReviewTests.cs | 1 - .../DynamicAdmissionGenerationTests.cs | 1 - .../DynamicAdmissionRuntimeControlTests.cs | 2 - ...AdmissionRuntimeResourceRegressionTests.cs | 1 - ...micAdmissionStateKernelIntegrationTests.cs | 1 - ...cAdmissionUpdateResourceRegressionTests.cs | 1 - .../DynamicEndpointIntegrationTests.cs | 42 ++++++------- .../DynamicInterceptorIntegrationTests.cs | 1 - .../EnterpriseHostingIntegrationTests.cs | 1 - .../IntegrationBehaviorTests.cs | 1 - .../InterceptorIntegrationTests.cs | 6 +- ...eWayEarlyRejectionDrainIntegrationTests.cs | 1 - .../OneWayInboundDrainIntegrationTests.cs | 1 - ...eWayOuterDrainRejectionIntegrationTests.cs | 1 - ...ionStreamActivationRaceIntegrationTests.cs | 1 - ...reAdmissionStreamBudgetIntegrationTests.cs | 1 - .../RuntimeAssemblyIntegrationTests.cs | 8 --- ...InterceptorContinuationIntegrationTests.cs | 1 - ...imeInterceptorFaultRaceIntegrationTests.cs | 1 - ...nterceptorOverlapStressIntegrationTests.cs | 1 - ...terceptorReviewCoverageIntegrationTests.cs | 1 - ...untimeInterceptorUnwindIntegrationTests.cs | 1 - .../RuntimeMultiClusterIntegrationTests.cs | 2 - .../ServiceLifetimeIntegrationTests.cs | 1 - ...moryTransportConnectionIntegrationTests.cs | 4 +- .../StaticEndpointIntegrationTests.cs | 60 +++++++++---------- .../TelemetryIntegrationTests.cs | 1 - .../TlsTransportIntegrationTests.cs | 4 -- .../TransportConnectionIntegrationTests.cs | 46 +++++++------- test/SharpLink.PackageSmoke/Program.cs | 10 +--- test/SharpLink.PreCreditAotSmoke/Program.cs | 2 - .../Program.cs | 1 - 43 files changed, 81 insertions(+), 142 deletions(-) diff --git a/test/SharpLink.AotSmoke/Program.cs b/test/SharpLink.AotSmoke/Program.cs index a9af39675..3b80f320f 100644 --- a/test/SharpLink.AotSmoke/Program.cs +++ b/test/SharpLink.AotSmoke/Program.cs @@ -86,7 +86,6 @@ public static async Task Main(string[] args) if (useSharedMemory) { client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseSharedMemory(sharedMemoryName) .Build(); @@ -94,7 +93,6 @@ public static async Task Main(string[] args) else { client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpointResolver( new DelegateSharpLinkEndpointResolver( @@ -170,7 +168,6 @@ private static async Task RunClientOnlyAsync(string name) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseSharedMemory(name) .UseRuntime(ConfigureCompression) .Build(); @@ -304,7 +301,6 @@ private static async Task VerifyStaticReadinessClientAsync( } }; await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseCluster(options => @@ -354,7 +350,6 @@ private static ISharpLinkMultiClusterClient CreateMultiClusterClient( string sharedMemoryName, int port) => SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster( "orders", child => ConfigureClientTransport(child, useSharedMemory, sharedMemoryName, port), diff --git a/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs index b59c81edb..d9ee87998 100644 --- a/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/AnonymousPipeTransportConnectionIntegrationTests.cs @@ -97,7 +97,6 @@ public static async Task CreateAsync() var (inHandle, outHandle) = await allocator.AllocateAsync(cts.Token); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseAnonymousPipe(inHandle, outHandle) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) diff --git a/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs b/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs index cfd81a142..a9256e153 100644 --- a/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/Api3BinaryFixtureIntegrationTests.cs @@ -223,12 +223,10 @@ internal static async Task CreateAsync() var server = serverBuilder.Build(); var serverTask = server.RunAsync(cancellation.Token).AsTask(); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); await client.ConnectAsync(); var multiClient = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster( "plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), port), diff --git a/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs b/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs index e0b05ab70..827787684 100644 --- a/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs +++ b/test/SharpLink.IntegrationTests/ClientStreamingResultStressTests.cs @@ -370,7 +370,6 @@ public static async Task CreateAsync( CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)); if (enableCompression) diff --git a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs index efa70dd71..e21416dc6 100644 --- a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs @@ -438,7 +438,6 @@ public static async Task CreateAsync( }, CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs index 9d1c1b74d..8f0ad551f 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs @@ -413,7 +413,6 @@ internal static async Task CreateAsync( }, CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs index dd0dac34c..595055c3c 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs @@ -331,7 +331,6 @@ internal static async Task CreateAsync( }, CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs index 708994585..a2fbe1d39 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs @@ -233,7 +233,6 @@ public async ValueTask DisposeAsync() private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs index 2494f2edc..82e04fdce 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs @@ -246,7 +246,6 @@ public async ValueTask DisposeAsync() private static ISharpLinkClient CreateClient(int port, string wireProfile, string tag) => SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs index d287d9dd4..3b60d8da9 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs @@ -245,7 +245,6 @@ private T Read(string name) private static ISharpLinkClient CreateClient(int port, string profile, string tag) => SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add(new Provider(profile, tag, null))) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs index 12682057d..d18f92d3c 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs @@ -230,7 +230,6 @@ internal static async Task CreateAsync(ISharpLinkCompressionProvide }, CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs index b7d99fc90..51a9b6151 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs @@ -337,7 +337,6 @@ internal static async Task CreateAsync( var serverTask = RunServerAsync(server, serverCts.Token); var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add( diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs index 00921cd59..6db36fa20 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -676,7 +676,6 @@ private static ISharpLinkClient CreateClient( Action? runtimeConfigure) { var builder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (runtimeConfigure is not null) builder.UseRuntime(runtimeConfigure); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs index 287386612..039e01ac6 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs @@ -323,12 +323,10 @@ internal static async Task CreateAsync( var serverTask = RunServerAsync(server, serverCancellation.Token); var clientA = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); var clientB = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs index 9b316be63..0650f1a43 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs @@ -366,7 +366,6 @@ private static ISharpLinkClient CreateClient( Action? runtimeConfigure) { var builder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (runtimeConfigure is not null) builder.UseRuntime(runtimeConfigure); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs index e13df28ba..775acf41d 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs @@ -415,7 +415,6 @@ internal static async Task CreateAsync( var server = (SharpLinkServer)serverBuilder.Build(); var serverTask = RunServerAsync(server, serverCancellation.Token); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs index f8d6f6ea7..4aac3a217 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionUpdateResourceRegressionTests.cs @@ -359,7 +359,6 @@ private static ISharpLinkClient CreateClient( Action? runtimeConfigure) { var builder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (runtimeConfigure is not null) builder.UseRuntime(runtimeConfigure); diff --git a/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs index 3a0ef8b8b..982278167 100644 --- a/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicEndpointIntegrationTests.cs @@ -16,7 +16,7 @@ public async Task DynamicResolverShouldAddRemoveReplaceAndUpdateAttributesWithou var selector = new ZoneSelector("blue"); var factoryCreates = 0; var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver( @@ -88,7 +88,7 @@ public async Task DynamicReadinessShouldTrackTopologyChangesAndKeepWaiterCancell Endpoint("first", first.Port, "blue"), Endpoint("second", second.Port, "green") ])); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new IdSelector("third")) @@ -228,7 +228,7 @@ public async Task EmptyDynamicTopologyShouldRecoverWhenTheResolverPublishesAnEnd await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [])); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseCluster(options => { @@ -285,7 +285,7 @@ public async Task DynamicEndpointRemovalShouldDrainAnAcceptedStreamAndRouteNewCa Endpoint("first", first.Port, "blue"), Endpoint("second", second.Port, "green") ])); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) @@ -342,7 +342,7 @@ public async Task StaleDynamicSelectionShouldNotRecreateRetiredAdmissionState() new SharpLinkEndpointSnapshot(1, [Endpoint("retiring", server.Port, "blue")])); using var selector = new PausingSelector(); var admission = new TrackingLifecycleAdmissionPolicy(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(selector) .UseEndpointAdmission(admission) @@ -383,7 +383,7 @@ public async Task CustomDynamicSelectorShouldRejectTheOnlyNonMatchingReadyEndpoi Endpoint("east", east.Port, "east"), Endpoint("west", west.Port, "west") ])); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new ZoneSelector("west")) @@ -416,7 +416,7 @@ public async Task RejectedDynamicFactoryReuseMustKeepTheLastGoodFactoryAlive() var sockets = SharpLinkTransportFactories.Sockets(); TrackingTransportFactory? factory = null; var factoryCreates = 0; - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver( resolver, @@ -456,7 +456,7 @@ public async Task FailedInitialDynamicTopologyShouldAllowConnectToWaitForRecover { var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("failed", 1, "red")])); var factory = new FailingConnectFactory(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, _ => factory) .Build(); @@ -491,7 +491,7 @@ public async Task FailedInitialDynamicDialShouldProbeLaterEndpointsWithoutWaitin var blocking = new BlockingConnectFactory(); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, endpoint => endpoint.Id switch { @@ -528,7 +528,7 @@ public async Task FailedInitialDynamicDialShouldProbeLaterEndpointsWithoutWaitin public async Task DynamicRecoveryToAnEmptyTopologyShouldReleaseConnectWaiters() { var resolver = new FailingThenEmptyResolver(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, _ => new FailingConnectFactory()) .Build(); @@ -553,7 +553,7 @@ public async Task FailedInitialDynamicDialShouldReconnectWithoutANewerResolverVe await using var server = await TcpServerScope.StartAsync("recovered"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("recovered", server.Port, "green")])); var factory = new FailOnceConnectFactory(SharpLinkTransportFactories.Sockets()(Endpoint("recovered", server.Port, "green"))); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, _ => factory) .Build(); @@ -582,7 +582,7 @@ public async Task RejectedDynamicSnapshotCleanupShouldContinueAfterFactoryDispos var throwingFactory = new ThrowingDisposeFactory(); var remainingFactory = new FailingConnectFactory(); var factoryCreates = 0; - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver( resolver, @@ -638,7 +638,7 @@ public async Task DynamicReplacementShouldWaitForExcessRetiringConnectionsToDrai await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("first", first.Port, "blue")])); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) @@ -684,7 +684,7 @@ public async Task DynamicReconnectShouldProbeHealthyEndpointsAfterAFailingEndpoi ])); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "bad" ? failing : sockets(endpoint)) .UseCluster(options => @@ -709,7 +709,7 @@ public async Task DynamicStopShouldWaitForAnInitialConnectThatIgnoresCancellatio { var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("blocked", 1, "red")])); var blocking = new BlockingConnectFactory(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, _ => blocking) .Build(); @@ -747,7 +747,7 @@ public async Task InitialDynamicConnectShouldCompleteWhenAReplacementTopologyBec var resolver = new ControllableResolver(new SharpLinkEndpointSnapshot(1, [Endpoint("blocked", 1, "red")])); var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "blocked" ? blocking : sockets(endpoint)) .UseCluster(options => @@ -784,7 +784,7 @@ public async Task RetiredDynamicDialsShouldContinueToConsumeTheConnectionBudget( var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); var replacementFactory = new CountingConnectFactory(sockets(Endpoint("replacement", replacement.Port, "green"))); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "blocked" ? blocking : replacementFactory) .UseCluster(options => @@ -832,7 +832,7 @@ public async Task ConnectAfterDynamicClusterDisconnectShouldAwaitRecovery() var sockets = SharpLinkTransportFactories.Sockets(); var blocking = new BlockAfterFirstConnectFactory(sockets(Endpoint("first", first.Port, "blue"))); var unavailable = new FailingConnectFactory(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, endpoint => endpoint.Id == "first" ? blocking : unavailable) .UseCluster(options => @@ -868,7 +868,7 @@ public async Task InitialDynamicDialReservationsShouldPreventSurplusTargetFill() var blocking = new BlockingConnectFactory(); var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, endpoint => endpoint.Id switch { @@ -910,7 +910,7 @@ public async Task ResolverWatchEndAndFailureShouldRetryAndRetainTheLastGoodTopol var resolver = new RestartingResolver( new SharpLinkEndpointSnapshot(1, [Endpoint("first", first.Port, "blue")]), new SharpLinkEndpointSnapshot(2, [Endpoint("recovered", recovered.Port, "green")])); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpointResolver(resolver, SharpLinkTransportFactories.Sockets()) .Build(); @@ -927,7 +927,7 @@ await WaitUntilAsync(async () => await client.Get(). public async Task DnsEndpointHelperShouldResolveLocalhostAndPreserveHostnameAuthority() { await using var server = await TcpServerScope.StartAsync("dns"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseDnsEndpoints( "localhost", diff --git a/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs index 78413568e..365f091bd 100644 --- a/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicInterceptorIntegrationTests.cs @@ -527,7 +527,6 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs b/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs index f8a7747ec..8e7689f14 100644 --- a/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/EnterpriseHostingIntegrationTests.cs @@ -227,7 +227,6 @@ public static async Task CreateAsync( var server = builder.Build(); var serverTask = Task.Run(() => server.RunAsync(serverCts.Token).AsTask()); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) diff --git a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs index 389331421..3ff51c448 100644 --- a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs +++ b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs @@ -2068,7 +2068,6 @@ public static async Task CreateAsync( }, CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); if (codecResolver is not null) clientBuilder.UseSerializer(codecResolver); diff --git a/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs b/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs index 7b89eeaf8..ca31219d6 100644 --- a/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/InterceptorIntegrationTests.cs @@ -26,7 +26,7 @@ public async Task ClientAndServerInterceptorsShouldObserveGeneratedContext() [Test] public async Task ClientInterceptorShouldShortCircuitWithoutAConnection() { - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), GetFreePort()) .AddInterceptor(new ShortCircuitClientInterceptor(777)) .Build(); @@ -664,7 +664,7 @@ private static int GetFreePort() } private static ISharpLinkClient CreateDisconnectedClient(ISharpLinkClientInterceptor interceptor) - => SharpClientBuilder.Create().DisableRequestTimeout() + => SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), GetFreePort()) .AddInterceptor(interceptor) .Build(); @@ -1083,7 +1083,7 @@ public static async Task CreateAsync( var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var clientBuilder = SharpClientBuilder.Create().DisableRequestTimeout() + var clientBuilder = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)); diff --git a/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs b/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs index a067128bf..694eee783 100644 --- a/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/OneWayEarlyRejectionDrainIntegrationTests.cs @@ -207,7 +207,6 @@ public static async Task CreateAsync(Action ru CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseRuntime(runtimeConfigure) diff --git a/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs b/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs index 3a8b7614f..0a9aa4c1b 100644 --- a/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/OneWayInboundDrainIntegrationTests.cs @@ -160,7 +160,6 @@ public static async Task CreateAsync(Action ru CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseRuntime(runtimeConfigure) diff --git a/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs index c33fc4223..cd832785a 100644 --- a/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/OneWayOuterDrainRejectionIntegrationTests.cs @@ -131,7 +131,6 @@ internal static async Task CreateAsync(Action CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseRuntime(runtimeConfigure) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs index 2a091db75..7a21a00c1 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs @@ -301,7 +301,6 @@ private T ReadServerDiagnostic(string name) private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs index fc5cf59ad..d2ad6e135 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs @@ -307,7 +307,6 @@ private T ReadServerDiagnostic(string name) private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs index 0b3382d60..8eba4426b 100644 --- a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs @@ -12,7 +12,6 @@ public sealed class RuntimeAssemblyIntegrationTests public async Task MultiClusterDynamicRegistrationShouldRouteToOneExplicitSlot() { await using var client = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster("plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), 1), slot => slot.AllowDynamicContracts = true) .AddCluster("other", child => child.UseTcp(IPAddress.Loopback.ToString(), 2), @@ -152,7 +151,6 @@ public async Task MultiClusterDeferredUnregisterShouldRemoveARegistrationRelease { using var plugin = PluginBundle.Load("multi-cluster-deferred-unregister", loadService: false); await using var registrationSource = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(plugin.ContractAssembly); @@ -187,7 +185,6 @@ public async Task MultiClusterRejectedUnregisterShouldRestoreCoordinatorRoute() { using var plugin = PluginBundle.Load("multi-cluster-rejected-unregister", loadService: false); await using var registrationSource = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(plugin.ContractAssembly); @@ -228,7 +225,6 @@ public async Task MultiClusterRejectedUnregisterShouldReserveContractIdsUntilRou using var originalPlugin = PluginBundle.Load("multi-cluster-rejected-unregister-original", loadService: false); using var reloadedPlugin = PluginBundle.Load("multi-cluster-rejected-unregister-reloaded", loadService: false); await using var registrationSource = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(originalPlugin.ContractAssembly); @@ -280,7 +276,6 @@ public async Task MultiClusterReplacementCleanupFailureShouldReconcilePublishedC using var newPlugin = PluginBundle.Load( "multi-cluster-replacement-cleanup-failure-new", loadService: false); await using var registrationSource = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), 1) .Build(); var registrationResult = registrationSource.RegisterAssembly(oldPlugin.ContractAssembly); @@ -1570,7 +1565,6 @@ private static async Task RegisterRemoveAndUnloadMultiClusterPlug { var plugin = PluginBundle.Load("multi-cluster-runtime-remove", loadService: false); await using var client = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster( "plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), 1), @@ -1796,7 +1790,6 @@ private static async Task WaitUntilAsync(Func condition) private static async Task CreateDynamicMultiClusterClientAsync(int port) { var client = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster("plugins", child => child.UseTcp(IPAddress.Loopback.ToString(), port), slot => slot.AllowDynamicContracts = true) .Build(); @@ -2278,7 +2271,6 @@ internal static async Task CreateAsync( var server = serverBuilder.Build(); var serverTask = server.RunAsync(serverCancellation.Token).AsTask(); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs index f314375aa..e12a29a7e 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorContinuationIntegrationTests.cs @@ -284,7 +284,6 @@ public static async Task CreateAsync( var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)); if (clientInterceptor is not null) diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs index 8f81ecb54..b81154c39 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorFaultRaceIntegrationTests.cs @@ -7,7 +7,6 @@ public async Task ClientReplacementShouldSerializeWithFaultPublication() { var transport = new GatedFailClientTransportFactory(); await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTransport(transport) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs index b6e4ced52..fae658738 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorOverlapStressIntegrationTests.cs @@ -240,7 +240,6 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs index 86d257bb7..a209c914e 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorReviewCoverageIntegrationTests.cs @@ -393,7 +393,6 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs index 5771acd9a..a7f1001da 100644 --- a/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeInterceptorUnwindIntegrationTests.cs @@ -319,7 +319,6 @@ public static async Task CreateAsync() var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs index 7a04529c9..dc0a2bfbd 100644 --- a/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeMultiClusterIntegrationTests.cs @@ -16,7 +16,6 @@ public async Task RuntimeTcpSlotShouldAddReplaceAndRemoveWithoutRebindingOldProx await using var first = await ServerScope.StartAsync("first"); await using var second = await ServerScope.StartAsync("second"); await using var client = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster( "bootstrap", child => child.UseTcp(IPAddress.Loopback.ToString(), first.Port), @@ -62,7 +61,6 @@ public async Task RuntimeDynamicResolverShouldUpdateEndpointsWithoutReplacingThe 1, [Endpoint("resolver-first", first.Port)])); await using var client = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster( "bootstrap", child => child.UseTcp(IPAddress.Loopback.ToString(), first.Port), diff --git a/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs b/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs index 65807321b..bd2c37ac2 100644 --- a/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/ServiceLifetimeIntegrationTests.cs @@ -231,7 +231,6 @@ public async Task BuilderFiltersShouldBeValidatedAndIsolatedPerServer() private static ISharpLinkClient CreateClient(int port) => SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs index 508252aa1..b1f8ed87c 100644 --- a/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/SharedMemoryTransportConnectionIntegrationTests.cs @@ -1131,7 +1131,7 @@ private static NamedPipeClientStream CreateRawSharedMemoryPipe(string name) private static ISharpLinkClient CreateAuthenticatedSharedMemoryClient(string name, string token) { var payload = Encoding.UTF8.GetBytes(token); - return SharpClientBuilder.Create().DisableRequestTimeout() + return SharpClientBuilder.Create() .UseSharedMemory(name) .UseAuthenticator(SharpLinkAuthenticator.CreateClient( @@ -1256,7 +1256,7 @@ public static async Task CreateAsync(string name) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .Build(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseSharedMemory(name, options => { options.CapacityPerDirectionBytes = 64 * 1024; diff --git a/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs b/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs index 6268c238e..93c63e361 100644 --- a/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/StaticEndpointIntegrationTests.cs @@ -5,7 +5,7 @@ public sealed class StaticEndpointIntegrationTests [Test] public async Task StaticReadinessCreatedSnapshotsShouldReflectConfiguredEndpointCounts() { - await using var twoEndpointClient = SharpClientBuilder.Create().DisableRequestTimeout() + await using var twoEndpointClient = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", 1), Endpoint("second", 2)], @@ -17,7 +17,7 @@ public async Task StaticReadinessCreatedSnapshotsShouldReflectConfiguredEndpoint options.MaxConnectionsPerEndpoint = 1; }) .Build(); - await using var threeEndpointClient = SharpClientBuilder.Create().DisableRequestTimeout() + await using var threeEndpointClient = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", 1), Endpoint("second", 2), Endpoint("third", 3)], @@ -58,7 +58,7 @@ public async Task StaticReadinessWaitsShouldNotChangeConnectAsyncConnectivityBou await using var second = await TcpServerScope.StartAsync("second"); var sockets = SharpLinkTransportFactories.Sockets(); var gatedSecond = new GatedConnectFactory(sockets(Endpoint("second", second.Port))); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -126,7 +126,7 @@ public async Task StaticReadinessWaitBelowTargetShouldCompleteBeforeFullConverge await using var third = await TcpServerScope.StartAsync("third"); var sockets = SharpLinkTransportFactories.Sockets(); var gatedThird = new GatedConnectFactory(sockets(Endpoint("third", third.Port))); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpoints( [ @@ -187,7 +187,7 @@ public async Task StaticReadinessThresholdAboveConfiguredTargetShouldFailWithout await using var second = await TcpServerScope.StartAsync("second"); var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [ @@ -226,7 +226,7 @@ public async Task StaticTcpEndpointsShouldConnectAndContinueWhenOneEndpointStops { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) .UseEndpoints( @@ -261,7 +261,7 @@ public async Task InitialEndpointFailureShouldNotPreventAnotherEndpointFromConne unavailableListener.Stop(); await using var available = await TcpServerScope.StartAsync("available"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("unavailable", unavailablePort), Endpoint("available", available.Port)], @@ -287,7 +287,7 @@ public async Task FailedInitialStaticDialShouldProbeLaterEndpointsWithoutWaiting var blocking = new BlockingConnectFactory(); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpoints( [ @@ -330,7 +330,7 @@ public async Task AllUnavailableEndpointsShouldReportUnavailable() { var firstPort = GetUnusedTcpPort(); var secondPort = GetUnusedTcpPort(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", firstPort), Endpoint("second", secondPort)], @@ -347,7 +347,7 @@ public async Task DisconnectedEndpointShouldReconnectWithoutInterruptingAnotherE { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -408,7 +408,7 @@ public async Task InvalidCustomSelectorShouldFailOnlyTheCurrentCall() { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -432,7 +432,7 @@ public async Task ThrowingCustomSelectorShouldLeaveTheClusterHealthyForLaterCall { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -453,7 +453,7 @@ public async Task StaticClusterShouldExpandWithinGlobalAndPerEndpointBudgets() { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -505,7 +505,7 @@ public async Task CustomStaticSelectorShouldRejectTheOnlyNonMatchingReadyEndpoin { await using var east = await TcpServerScope.StartAsync("east"); await using var west = await TcpServerScope.StartAsync("west"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("east", east.Port, "east"), Endpoint("west", west.Port, "west")], @@ -538,7 +538,7 @@ public async Task StaticNamedPipeEndpointsShouldServeRpc() var secondName = $"sharplink-static-second-{Guid.NewGuid():N}"; await using var first = await TcpServerScope.StartNamedPipeAsync(firstName); await using var second = await TcpServerScope.StartNamedPipeAsync(secondName); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [ @@ -559,7 +559,7 @@ public async Task StaticSharedMemoryEndpointsShouldServeRpc() var secondName = $"sharplink-static-second-{Guid.NewGuid():N}"; await using var first = await TcpServerScope.StartSharedMemoryAsync(firstName); await using var second = await TcpServerScope.StartSharedMemoryAsync(secondName); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [ @@ -582,7 +582,7 @@ public async Task StaticUdsEndpointsShouldServeRpc() var secondPath = Path.Combine(Path.GetTempPath(), $"sharplink-static-{Guid.NewGuid():N}.sock"); await using var first = await TcpServerScope.StartUdsAsync(firstPath); await using var second = await TcpServerScope.StartUdsAsync(secondPath); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [ @@ -622,7 +622,7 @@ public async Task StaticTcpEndpointsShouldSupportHostnameIpv4AndIpv6() }); } - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseCluster(options => @@ -652,7 +652,7 @@ public async Task ConcurrentConnectAndStopShouldConvergeStaticClusterResources() { await using var first = await TcpServerScope.StartAsync(); await using var second = await TcpServerScope.StartAsync(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -688,7 +688,7 @@ public async Task StopShouldWaitForInitialSiblingDialsBeforeDisposingFactories() await using var first = await TcpServerScope.StartAsync("first"); var blocking = new BlockingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("blocked", GetUnusedTcpPort())], @@ -725,7 +725,7 @@ public async Task InitialStaticDialReservationsShouldPreventSurplusTargetFill() var blocking = new BlockingConnectFactory(); var surplus = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("blocked", 1), Endpoint("surplus", 2)], @@ -768,7 +768,7 @@ public async Task ConnectAfterStaticClusterDisconnectShouldAwaitRecovery() var sockets = SharpLinkTransportFactories.Sockets(); var blocking = new BlockAfterFirstConnectFactory(sockets(Endpoint("first", first.Port))); var unavailable = new FailingConnectFactory(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("unavailable", 1)], @@ -800,7 +800,7 @@ public async Task FailedInitialSiblingDialShouldContinueFillingMinReadyEndpoints await using var recovered = await TcpServerScope.StartAsync("recovered"); var sockets = SharpLinkTransportFactories.Sockets(); var delayedFailure = new DeferredFailOnceFactory(sockets(Endpoint("recovered", recovered.Port))); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("recovered", recovered.Port)], @@ -835,7 +835,7 @@ public async Task StaticReconnectShouldProbeHealthyEndpointsAfterAFailingEndpoin await using var second = await TcpServerScope.StartAsync("second"); var failing = new FailingConnectFactory(); var sockets = SharpLinkTransportFactories.Sockets(); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("bad", 1), Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -865,7 +865,7 @@ public async Task InitialStaticConnectShouldContinueFillingTargetsBeyondTheFirst await using var third = await TcpServerScope.StartAsync("third"); await using var fourth = await TcpServerScope.StartAsync("fourth"); await using var fifth = await TcpServerScope.StartAsync("fifth"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [ @@ -902,7 +902,7 @@ public async Task RoundRobinAndCustomAttributeSelectorsShouldChooseExpectedEndpo Endpoint("second", second.Port, "west") }; - await using (var roundRobin = SharpClientBuilder.Create().DisableRequestTimeout() + await using (var roundRobin = SharpClientBuilder.Create() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseLoadBalancing(SharpLinkLoadBalancingStrategy.RoundRobin) @@ -922,7 +922,7 @@ await service.GetEndpointIdAsync() Ensure(ids[0] != ids[1] && ids[0] == ids[2] && ids[1] == ids[3], "round robin endpoint order"); } - await using var custom = SharpClientBuilder.Create().DisableRequestTimeout() + await using var custom = SharpClientBuilder.Create() .UseEndpoints(endpoints, SharpLinkTransportFactories.Sockets()) .UseEndpointSelector(new AttributeSelector("west")) @@ -938,7 +938,7 @@ public async Task LeastPendingShouldAvoidEndpointWithAnActiveCall() { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -969,7 +969,7 @@ public async Task LeastPendingShouldRotateTiesAcrossReadyEndpoints() { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], @@ -1003,7 +1003,7 @@ public async Task GoAwayShouldDrainExistingUnaryAndStreamWhileNewCallsUseAnother { await using var first = await TcpServerScope.StartAsync("first"); await using var second = await TcpServerScope.StartAsync("second"); - await using var client = SharpClientBuilder.Create().DisableRequestTimeout() + await using var client = SharpClientBuilder.Create() .UseEndpoints( [Endpoint("first", first.Port), Endpoint("second", second.Port)], diff --git a/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs b/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs index 30ddbcc53..2c4838ed0 100644 --- a/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/TelemetryIntegrationTests.cs @@ -228,7 +228,6 @@ public static async Task CreateAsync() var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) diff --git a/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs b/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs index cf9294c11..a4b007e47 100644 --- a/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/TlsTransportIntegrationTests.cs @@ -21,7 +21,6 @@ public async Task RuntimeMultiClusterAddAndReplaceShouldPreserveTlsAndAuthentica CreateServerOptions(certificate), expectedAuthenticationToken: "runtime-token"); await using var client = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster( "bootstrap", child => child @@ -199,7 +198,6 @@ public async Task TlsHandshakeShouldHonorIndependentTimeout() using var acceptCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var acceptTask = listener.AcceptSocketAsync(acceptCts.Token).AsTask(); await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp( IPAddress.Loopback.ToString(), port, @@ -230,7 +228,6 @@ public async Task StaticTlsEndpointsShouldUseEndpointAuthorityAndIsolateFailure( await using var second = await StartServerAsync(0, CreateServerOptions(certificate)); var tlsOptions = CreateClientOptions(string.Empty); await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .UseEndpoints( [ @@ -260,7 +257,6 @@ public async Task StaticTlsEndpointsShouldUseEndpointAuthorityAndIsolateFailure( private static ISharpLinkClient CreateClient(int port, SslClientAuthenticationOptions options) => SharpClientBuilder.Create() - .DisableRequestTimeout() .UseTcp(IPAddress.Loopback.ToString(), port, options, TimeSpan.FromSeconds(2)) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(2)) .Build(); diff --git a/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs b/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs index c34a4cb57..cf48d12cf 100644 --- a/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/TransportConnectionIntegrationTests.cs @@ -263,7 +263,7 @@ public async Task ServerMalformedHandshakeShouldReleaseItsReadBeforeCompletingTh public async Task ClientMalformedHandshakeShouldReleaseItsReadBeforeCompletingTheReader() { var connection = new CompletionJoiningTransportConnection(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTransport(new SingleConnectionClientFactory(connection)) .Build(); @@ -444,7 +444,7 @@ public async Task UdsClientDisposeShouldFailFastPendingCall() public async Task TcpConnectWithoutServerShouldThrowSocketException() { var port = GetFreePort(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -464,7 +464,7 @@ public async Task TcpConnectWithoutServerShouldThrowSocketException() public async Task NamedPipeConnectWithoutServerShouldHonorCancellation() { var pipeName = $"sharplink-int-no-server-{Guid.NewGuid():N}"; - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseNamedPipe(pipeName) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -490,7 +490,7 @@ public async Task UdsConnectWithoutServerShouldThrowSocketException() return; var socketPath = GetUniqueUdsPath(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseUds(socketPath) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -511,7 +511,7 @@ public async Task UdsConnectWithoutServerShouldThrowSocketException() public async Task TcpConnectWithCanceledTokenShouldThrowOperationCanceledException() { var port = GetFreePort(); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -549,7 +549,7 @@ public async Task TcpClientHandshakeShouldHonorConfiguredTimeout() { } }, CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.HandshakeTimeout = TimeSpan.FromMilliseconds(120)) @@ -591,7 +591,7 @@ public async Task TcpClientHandshakeShouldHonorCallerCancellation() { } }, CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.HandshakeTimeout = TimeSpan.FromSeconds(5)) @@ -673,7 +673,7 @@ public async Task TcpHandshakeFailureShouldReturnFalse() await stream.FlushAsync(); }); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)) @@ -812,7 +812,7 @@ public async Task TcpOversizedFrameShouldFailPendingUnaryAndStreamWithSameProtoc await stream.FlushAsync(); }); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.MaxFramePayloadBytes = maxFramePayloadBytes) @@ -873,7 +873,7 @@ public async Task TcpCustomAuthenticatorShouldAcceptMatchingHandshakeMessage() } }, CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -924,7 +924,7 @@ public async Task TcpCustomAuthenticatorShouldRejectMismatchedHandshakeMessage() } }, CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("unexpected-token")) @@ -977,7 +977,7 @@ public async Task TcpStructuredAuthenticatorShouldExposeCustomAuthenticationErro } }, CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expired-token")) @@ -1019,7 +1019,7 @@ public async Task TcpAuthenticatorShouldRejectContradictoryAuthenticatedResult() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1058,7 +1058,7 @@ public async Task TcpAuthenticatorShouldSanitizeAnUndefinedRejectionCode() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1095,7 +1095,7 @@ public async Task TcpAuthenticatorShouldRejectExpiredContextDuringHandshake() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); @@ -1129,7 +1129,7 @@ public async Task TcpClientShouldRejectOversizedAuthenticationPayloadBeforeSend( var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(static options => options.MaxMetadataBytes = maxAuthenticationBytes) @@ -1191,7 +1191,7 @@ public async Task TcpStructuredAuthenticatorShouldExposeAuthenticationContextToS } }, CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -1237,12 +1237,12 @@ public async Task TcpAuthenticationContextShouldRemainIsolatedPerConnection() var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var firstClient = SharpClientBuilder.Create().DisableRequestTimeout() + var firstClient = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("connection-a")) .Build(); - var secondClient = SharpClientBuilder.Create().DisableRequestTimeout() + var secondClient = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("connection-b")) @@ -1317,7 +1317,7 @@ public async Task TcpAuthorizationGuardsShouldReturnStructuredRemoteErrors() } }, CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseAuthenticator(CreateClientAuthenticator("expected-token")) @@ -1598,7 +1598,7 @@ private static async Task VerifyNegotiatedFrameLimitAsync(int clientLimit, int s var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; var server = serverBuilder.Build(); var serverTask = Task.Run(() => server.RunAsync(cts.Token).AsTask(), CancellationToken.None); - var client = SharpClientBuilder.Create().DisableRequestTimeout() + var client = SharpClientBuilder.Create() .UseTcp(IPAddress.Loopback.ToString(), port) .UseProtocol(options => options.MaxFramePayloadBytes = clientLimit) @@ -1631,7 +1631,7 @@ await EnsureThrowsSharpLink( private static ISharpLinkClient BuildClientForEndpoint(TransportEndpoint endpoint) { - var builder = SharpClientBuilder.Create().DisableRequestTimeout() + var builder = SharpClientBuilder.Create() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); @@ -1709,7 +1709,7 @@ private static async Task CreateAsync(TransportKind kind, Tran .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); - var clientBuilder = SharpClientBuilder.Create().DisableRequestTimeout() + var clientBuilder = SharpClientBuilder.Create() .UseHeartbeat(TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(500)); diff --git a/test/SharpLink.PackageSmoke/Program.cs b/test/SharpLink.PackageSmoke/Program.cs index de92a5519..0f1bb57fd 100644 --- a/test/SharpLink.PackageSmoke/Program.cs +++ b/test/SharpLink.PackageSmoke/Program.cs @@ -94,7 +94,6 @@ private static async Task RunTransportSmokeAsync( var serverTask = RunServerAsync(server, cancellationToken); var clientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureCompression); if (useSharedMemory) clientBuilder.UseSharedMemory(sharedMemoryName); @@ -149,7 +148,6 @@ private static async Task RunRuntimeMultiClusterSmokeAsync( CancellationToken cancellationToken) { await using var client = SharpLinkMultiClusterClientBuilder.Create() - .DisableRequestTimeout() .AddCluster( "bootstrap", child => child.UseTcp(IPAddress.Loopback.ToString(), port), @@ -212,7 +210,6 @@ private static async Task RunStaticEndpointSmokeAsync(CancellationToken cancella } }; var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpoints( endpoints, @@ -242,7 +239,6 @@ private static async Task RunStaticEndpointSmokeAsync(CancellationToken cancella throw new InvalidOperationException("Static endpoint package smoke returned an unexpected result."); await using var dynamicClient = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureCompression) .UseEndpointResolver( new DelegateSharpLinkEndpointResolver( @@ -502,8 +498,7 @@ private static void AssertEnginePublicApiBoundary() AssertPublicSpi(clientInterceptor); AssertPublicSpi(serverInterceptor); - var directClientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout(); + var directClientBuilder = SharpClientBuilder.Create(); AssertBuilderReturnsSelf( directClientBuilder, directClientBuilder @@ -518,8 +513,7 @@ private static void AssertEnginePublicApiBoundary() SharpLinkEndpointTransportFactory endpointTransportFactory = static _ => new PackageClientTransportFactory(); AssertPublicType(); - var resolverClientBuilder = SharpClientBuilder.Create() - .DisableRequestTimeout(); + var resolverClientBuilder = SharpClientBuilder.Create(); AssertBuilderReturnsSelf( resolverClientBuilder, resolverClientBuilder.UseEndpointResolver(endpointResolver, endpointTransportFactory), diff --git a/test/SharpLink.PreCreditAotSmoke/Program.cs b/test/SharpLink.PreCreditAotSmoke/Program.cs index 012d1ce4e..92e6932ad 100644 --- a/test/SharpLink.PreCreditAotSmoke/Program.cs +++ b/test/SharpLink.PreCreditAotSmoke/Program.cs @@ -61,7 +61,6 @@ public static async Task Main(string[] args) if (useSharedMemory) { client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureRuntime) .UseSharedMemory(sharedMemoryName) .Build(); @@ -69,7 +68,6 @@ public static async Task Main(string[] args) else { client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseRuntime(ConfigureRuntime) .UseTcp(IPAddress.Loopback.ToString(), port) .Build(); diff --git a/test/SharpLink.ReferenceRooting.PackageClient/Program.cs b/test/SharpLink.ReferenceRooting.PackageClient/Program.cs index 15c628729..c1baf87a2 100644 --- a/test/SharpLink.ReferenceRooting.PackageClient/Program.cs +++ b/test/SharpLink.ReferenceRooting.PackageClient/Program.cs @@ -12,7 +12,6 @@ public static async Task Main(string[] args) using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); await using var client = SharpClientBuilder.Create() - .DisableRequestTimeout() .UseSharedMemory(args[0]) .Build(); await client.ConnectAsync(timeout.Token); From 5ab9d2c559484e392099cf7c159ad2cf2fea0373 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:18:31 +0800 Subject: [PATCH 133/399] test: fix deterministic identity fixtures --- .../RpcDeterministicIdentityTests.cs | 74 +++++++++++++------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index 456de60ab..bf5cb253d 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.CodeAnalysis; @@ -132,13 +133,9 @@ public Task UnsafeBlitFieldRenameShouldPreserveIdentity() var first = GenerateUnsafeBlitIdentityManifest("First", "Second", "long"); var renamed = GenerateUnsafeBlitIdentityManifest("RenamedFirst", "RenamedSecond", "long"); - Ensure( - ExtractGeneratedCodecIdentity(first, "UnsafeLayoutPayload") == - ExtractGeneratedCodecIdentity(renamed, "UnsafeLayoutPayload"), - "UnsafeBlit CodecHash must depend on physical layout rather than field names"); Ensure( ExtractGeneratedRpcAssemblyHash(first) == ExtractGeneratedRpcAssemblyHash(renamed), - "field renames that preserve UnsafeBlit bytes must not change RpcAssemblyHash"); + "field renames that preserve UnsafeBlit bytes must preserve the CodecHash-derived RpcAssemblyHash"); return Task.CompletedTask; } @@ -148,13 +145,9 @@ public Task UnsafeBlitPhysicalLayoutChangeShouldChangeIdentity() var first = GenerateUnsafeBlitIdentityManifest("First", "Second", "long"); var changed = GenerateUnsafeBlitIdentityManifest("First", "Second", "int"); - Ensure( - ExtractGeneratedCodecIdentity(first, "UnsafeLayoutPayload") != - ExtractGeneratedCodecIdentity(changed, "UnsafeLayoutPayload"), - "changing UnsafeBlit physical field types must change CodecHash"); Ensure( ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(changed), - "changing UnsafeBlit physical layout must change RpcAssemblyHash"); + "changing UnsafeBlit physical layout must change the CodecHash-derived RpcAssemblyHash"); return Task.CompletedTask; } @@ -162,9 +155,7 @@ public Task UnsafeBlitPhysicalLayoutChangeShouldChangeIdentity() public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies() { var sdk = CreateMetadataReference("DeterministicIdentitySdk", BuildSource(string.Empty)); - var shared = CreateMetadataReference( - "SharedPayloadModels", - """ + const string sharedSource = """ using SharpLink.Sdk; namespace SharedPayloadModels @@ -175,7 +166,14 @@ public sealed class SharedPayload public int Value { get; set; } } } -""", +"""; + var firstShared = CreateGeneratedMetadataReference( + "SharedPayloadModels", + sharedSource, + sdk); + var secondShared = CreateGeneratedMetadataReference( + "SharedPayloadModels", + sharedSource, sdk); var firstSource = """ using System.Threading; @@ -202,23 +200,23 @@ public interface ISecondSharedPayloadContract : IService } """; - var first = GenerateIdentityManifest( + _ = GenerateIdentityManifest( "FirstSharedPayloadContracts", firstSource, Platform.AnyCpu, sdk, - shared); - var second = GenerateIdentityManifest( + firstShared.Reference); + _ = GenerateIdentityManifest( "SecondSharedPayloadContracts", secondSource, Platform.AnyCpu, sdk, - shared); + secondShared.Reference); Ensure( - ExtractGeneratedCodecIdentity(first, "SharedPayloadModels.SharedPayload") == - ExtractGeneratedCodecIdentity(second, "SharedPayloadModels.SharedPayload"), - "the same shared payload definition must publish one CodecHash across owning contract assemblies"); + ExtractGeneratedCodecIdentity(firstShared.Manifest, "SharedPayloadModels.SharedPayload") == + ExtractGeneratedCodecIdentity(secondShared.Manifest, "SharedPayloadModels.SharedPayload"), + "the same generated shared payload definition must publish one CodecHash when consumed by different contract assemblies"); return Task.CompletedTask; } @@ -317,6 +315,40 @@ public interface IUnsafeLayoutIdentityContract : SharpLink.Sdk.IService generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); } + private static (MetadataReference Reference, string Manifest) CreateGeneratedMetadataReference( + string assemblyName, + string source, + params MetadataReference[] additionalReferences) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); + var compilation = CSharpCompilation.Create( + assemblyName, + [syntaxTree], + GetPlatformReferences().Concat(additionalReferences), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + IIncrementalGenerator generator = new RpcGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics); + Ensure( + !generatorDiagnostics.Any(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + $"Failed to generate metadata fixture '{assemblyName}': {FormatDiagnostics(generatorDiagnostics)}"); + + var manifest = driver.GetRunResult().GeneratedTrees + .Select(static tree => tree.GetText().ToString()) + .Single(static generated => + generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + using var image = new MemoryStream(); + var emit = outputCompilation.Emit(image); + Ensure( + emit.Success, + $"Failed to build generated metadata fixture '{assemblyName}': {FormatDiagnostics(emit.Diagnostics)}"); + return (MetadataReference.CreateFromImage(image.ToArray()), manifest); + } + private static string GenerateIdentityManifest( string assemblyName, string source, From 013579a468ca13ff70af1436ea51b96a21e59a6d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:38:02 +0800 Subject: [PATCH 134/399] test: isolate shared payload identity semantics --- .../RpcDeterministicIdentityTests.cs | 100 ++++-------------- 1 file changed, 23 insertions(+), 77 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index bf5cb253d..6f71d5ee7 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -1,5 +1,4 @@ using System; -using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.CodeAnalysis; @@ -154,69 +153,50 @@ public Task UnsafeBlitPhysicalLayoutChangeShouldChangeIdentity() [Test] public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies() { - var sdk = CreateMetadataReference("DeterministicIdentitySdk", BuildSource(string.Empty)); - const string sharedSource = """ -using SharpLink.Sdk; - + const string sharedPayload = """ namespace SharedPayloadModels { - [RpcSerializable] + [SharpLink.Sdk.RpcSerializable] public sealed class SharedPayload { public int Value { get; set; } } } """; - var firstShared = CreateGeneratedMetadataReference( - "SharedPayloadModels", - sharedSource, - sdk); - var secondShared = CreateGeneratedMetadataReference( - "SharedPayloadModels", - sharedSource, - sdk); - var firstSource = """ -using System.Threading; -using System.Threading.Tasks; -using SharedPayloadModels; -using SharpLink.Sdk; + var firstSource = BuildSource(sharedPayload + """ -[RpcContract] -public interface IFirstSharedPayloadContract : IService +[SharpLink.Sdk.RpcContract] +public interface IFirstSharedPayloadContract : SharpLink.Sdk.IService { - ValueTask Echo(SharedPayload value, CancellationToken cancellationToken); + ValueTask Echo( + SharedPayloadModels.SharedPayload value, + CancellationToken cancellationToken); } -"""; - var secondSource = """ -using System.Threading; -using System.Threading.Tasks; -using SharedPayloadModels; -using SharpLink.Sdk; +"""); + var secondSource = BuildSource(sharedPayload + """ -[RpcContract] -public interface ISecondSharedPayloadContract : IService +[SharpLink.Sdk.RpcContract] +public interface ISecondSharedPayloadContract : SharpLink.Sdk.IService { - ValueTask Echo(SharedPayload value, CancellationToken cancellationToken); + ValueTask Echo( + SharedPayloadModels.SharedPayload value, + CancellationToken cancellationToken); } -"""; +"""); - _ = GenerateIdentityManifest( + var first = GenerateIdentityManifest( "FirstSharedPayloadContracts", firstSource, - Platform.AnyCpu, - sdk, - firstShared.Reference); - _ = GenerateIdentityManifest( + Platform.AnyCpu); + var second = GenerateIdentityManifest( "SecondSharedPayloadContracts", secondSource, - Platform.AnyCpu, - sdk, - secondShared.Reference); + Platform.AnyCpu); Ensure( - ExtractGeneratedCodecIdentity(firstShared.Manifest, "SharedPayloadModels.SharedPayload") == - ExtractGeneratedCodecIdentity(secondShared.Manifest, "SharedPayloadModels.SharedPayload"), - "the same generated shared payload definition must publish one CodecHash when consumed by different contract assemblies"); + ExtractGeneratedCodecIdentity(first, "SharedPayloadModels.SharedPayload") == + ExtractGeneratedCodecIdentity(second, "SharedPayloadModels.SharedPayload"), + "the same payload definition must publish one CodecHash across different Contract assemblies"); return Task.CompletedTask; } @@ -315,40 +295,6 @@ public interface IUnsafeLayoutIdentityContract : SharpLink.Sdk.IService generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); } - private static (MetadataReference Reference, string Manifest) CreateGeneratedMetadataReference( - string assemblyName, - string source, - params MetadataReference[] additionalReferences) - { - var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); - var compilation = CSharpCompilation.Create( - assemblyName, - [syntaxTree], - GetPlatformReferences().Concat(additionalReferences), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - IIncrementalGenerator generator = new RpcGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - driver = driver.RunGeneratorsAndUpdateCompilation( - compilation, - out var outputCompilation, - out var generatorDiagnostics); - Ensure( - !generatorDiagnostics.Any(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), - $"Failed to generate metadata fixture '{assemblyName}': {FormatDiagnostics(generatorDiagnostics)}"); - - var manifest = driver.GetRunResult().GeneratedTrees - .Select(static tree => tree.GetText().ToString()) - .Single(static generated => - generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - using var image = new MemoryStream(); - var emit = outputCompilation.Emit(image); - Ensure( - emit.Success, - $"Failed to build generated metadata fixture '{assemblyName}': {FormatDiagnostics(emit.Diagnostics)}"); - return (MetadataReference.CreateFromImage(image.ToArray()), manifest); - } - private static string GenerateIdentityManifest( string assemblyName, string source, From e4c18cb88d845742b9af0bd7fcf2007098ca425a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:41:42 +0800 Subject: [PATCH 135/399] fix: keep RpcHash128 object hashing deterministic --- src/SharpLink.Abstractions/RpcHash128.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Abstractions/RpcHash128.cs b/src/SharpLink.Abstractions/RpcHash128.cs index 529ab87f2..44d918b0e 100644 --- a/src/SharpLink.Abstractions/RpcHash128.cs +++ b/src/SharpLink.Abstractions/RpcHash128.cs @@ -28,7 +28,8 @@ public RpcHash128(ulong high, ulong low) public override bool Equals(object? obj) => obj is RpcHash128 other && Equals(other); /// - public override int GetHashCode() => HashCode.Combine(High, Low); + public override int GetHashCode() + => unchecked((int)(High ^ (High >> 32) ^ Low ^ (Low >> 32))); /// public override string ToString() From 8d712da7fcc93b0adae9b047e13d5e6d52320005 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:25:49 +0800 Subject: [PATCH 136/399] chore: stage PR 415 review fixes --- .github/workflows/zz-pr415-review-fixes.yml | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 .github/workflows/zz-pr415-review-fixes.yml diff --git a/.github/workflows/zz-pr415-review-fixes.yml b/.github/workflows/zz-pr415-review-fixes.yml new file mode 100644 index 000000000..e2aacc1cd --- /dev/null +++ b/.github/workflows/zz-pr415-review-fixes.yml @@ -0,0 +1,145 @@ +name: One-off PR 415 review fixes + +on: + push: + branches: + - feature/issue-396-deterministic-rpc-identity + +permissions: + contents: write + +jobs: + apply: + if: github.event.head_commit.message == 'chore: stage PR 415 review fixes' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: feature/issue-396-deterministic-rpc-identity + fetch-depth: 0 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: 10.0.x + + - name: Apply review fixes + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected exactly one replacement target, found {count}') + p.write_text(text.replace(old, new), encoding='utf-8') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.Models.cs', + '''internal sealed record DtoGenerationResult(\n ImmutableArray Codecs,\n ImmutableArray ContractCodecs,\n ImmutableArray FinalCodecBoundTypes,\n ImmutableArray Diagnostics,\n ImmutableArray Enums)\n{\n public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n}\n''', + '''internal sealed record DtoGenerationResult(\n ImmutableArray Codecs,\n ImmutableArray ContractCodecs,\n ImmutableArray FinalCodecBoundTypes,\n ImmutableArray Diagnostics,\n ImmutableArray Enums)\n{\n public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public string AssemblyLogicalIdentity { get; init; } = string.Empty;\n}\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.Models.cs', + ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length)\n''', + ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length ||\n !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal))\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.Models.cs', + ''' public int GetHashCode(DtoGenerationResult obj)\n {\n var hash = 17;\n''', + ''' public int GetHashCode(DtoGenerationResult obj)\n {\n var hash = 17;\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(obj.AssemblyLogicalIdentity));\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs', + ''' {\n CodecHashes = codecHashes\n };\n''', + ''' {\n CodecHashes = codecHashes,\n AssemblyLogicalIdentity = compilation.Assembly.Identity.Name\n };\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.cs', + ''' var code = GenerateAssemblyManifest(interfaces, services, codecs, contractCodecs, codecHashes);\n''', + ''' var code = GenerateAssemblyManifest(\n interfaces,\n services,\n codecs,\n contractCodecs,\n codecHashes,\n value.Right.AssemblyLogicalIdentity);\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs', + ''' ImmutableArray codecs,\n ImmutableArray contractCodecs,\n ImmutableArray codecHashes)\n''', + ''' ImmutableArray codecs,\n ImmutableArray contractCodecs,\n ImmutableArray codecHashes,\n string assemblyLogicalIdentity)\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs', + ''' var rpcIdentity = BuildRpcAssemblyIdentity(contracts, codecHashes);\n''', + ''' var rpcIdentity = BuildRpcAssemblyIdentity(assemblyLogicalIdentity, contracts, codecHashes);\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs', + ''' private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity(\n RpcInterfaceModel[] contracts,\n ImmutableArray codecHashes)\n''', + ''' private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity(\n string assemblyLogicalIdentity,\n RpcInterfaceModel[] contracts,\n ImmutableArray codecHashes)\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs', + ''' var assemblyParts = new List\n {\n "rpc-assembly/v1",\n contractIdentities.Length.ToString(InvariantCulture)\n };\n''', + ''' var assemblyParts = new List\n {\n "rpc-assembly/v1",\n assemblyLogicalIdentity,\n contractIdentities.Length.ToString(InvariantCulture)\n };\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs', + ''' if (type is IPointerTypeSymbol pointer)\n {\n builder.Append("|pointer|");\n AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack);\n return;\n }\n if (type is IFunctionPointerTypeSymbol)\n {\n builder.Append("|function-pointer");\n return;\n }\n''', + ''' if (type is IPointerTypeSymbol pointer)\n {\n builder.Append("|native-pointer-width/64|pointer|");\n AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack);\n return;\n }\n if (type is IFunctionPointerTypeSymbol)\n {\n builder.Append("|native-pointer-width/64|function-pointer");\n return;\n }\n''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs', + ''' SpecialType.System_Int64 => "i64",\n SpecialType.System_UInt64 => "u64",\n SpecialType.System_Double => "f64",\n''', + ''' SpecialType.System_Int64 => "i64",\n SpecialType.System_UInt64 => "u64",\n SpecialType.System_IntPtr => "native-pointer-width/64:intptr",\n SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr",\n SpecialType.System_Double => "f64",\n''') + + replace_once( + 'src/SharpLink.Runtime/Codec/RpcCodecProvider.cs', + ''' if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences())\n return UnsafeBlitCodec.Instance;\n''', + ''' if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences())\n {\n RpcUnsafeBlitPlatform.EnsureSupported(targetType);\n return UnsafeBlitCodec.Instance;\n }\n''') + + helper = Path('src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs') + if helper.exists(): + raise SystemExit(f'{helper}: file already exists') + helper.write_text('''using System.Reflection;\n\nnamespace SharpLink.Runtime;\n\ninternal static class RpcUnsafeBlitPlatform\n{\n private const int SupportedNativePointerSize = 8;\n\n internal static void EnsureSupported(Type targetType)\n {\n if (IsSupported(targetType, IntPtr.Size))\n return;\n\n throw new PlatformNotSupportedException(\n $"UnsafeBlit Codec for '{targetType.FullName}' contains native-sized members and requires a 64-bit process.");\n }\n\n internal static bool IsSupported(Type targetType, int nativePointerSize)\n {\n ArgumentNullException.ThrowIfNull(targetType);\n return nativePointerSize == SupportedNativePointerSize ||\n !ContainsNativeSizedMember(targetType, new HashSet());\n }\n\n private static bool ContainsNativeSizedMember(Type type, HashSet seen)\n {\n if (type == typeof(IntPtr) || type == typeof(UIntPtr) || type.IsPointer || type.IsFunctionPointer)\n return true;\n if (!type.IsValueType || type.IsPrimitive || type.IsEnum)\n return false;\n if (!seen.Add(type))\n return false;\n\n foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))\n {\n if (ContainsNativeSizedMember(field.FieldType, seen))\n return true;\n }\n\n return false;\n }\n}\n''', encoding='utf-8') + + unit_test = Path('test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs') + if unit_test.exists(): + raise SystemExit(f'{unit_test}: file already exists') + unit_test.write_text('''using SharpLink.Runtime;\n\nnamespace SharpLink.UnitTests.Runtime;\n\npublic sealed class RpcUnsafeBlitPlatformTests\n{\n [Test]\n public void NativeSizedUnsafeBlitShouldBe64BitOnly()\n {\n Ensure(\n RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 8),\n "native-sized UnsafeBlit payloads must be accepted by the supported 64-bit runtime");\n Ensure(\n !RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 4),\n "native-sized UnsafeBlit payloads must be rejected by a 32-bit runtime");\n Ensure(\n RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 4),\n "fixed-width UnsafeBlit payloads must remain valid on a 32-bit runtime");\n }\n\n private struct NativeSizedPayload\n {\n public int Prefix;\n public nint Handle;\n }\n\n private struct PortablePayload\n {\n public int Prefix;\n public long Value;\n }\n\n private static void Ensure(bool condition, string message)\n {\n if (!condition)\n throw new InvalidOperationException(message);\n }\n}\n''', encoding='utf-8') + + identity_tests = Path('test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs') + text = identity_tests.read_text(encoding='utf-8') + marker = ''' [Test]\n public Task DtoWireShapeChangeShouldChangeFinalRpcIdentity()\n''' + if text.count(marker) != 1: + raise SystemExit('identity tests: assembly regression insertion marker mismatch') + assembly_test = ''' [Test]\n public Task SameApparentAbiInDifferentAssembliesShouldHaveDifferentAssemblyIdentity()\n {\n var source = BuildDtoIdentitySource(includeExtraMember: false, idempotent: false);\n var first = GenerateIdentityManifest(\n "DeterministicIdentityAssemblyA",\n source,\n Platform.AnyCpu);\n var second = GenerateIdentityManifest(\n "DeterministicIdentityAssemblyB",\n source,\n Platform.AnyCpu);\n\n Ensure(\n ExtractGeneratedCodecIdentity(first, "DeterministicPayload") ==\n ExtractGeneratedCodecIdentity(second, "DeterministicPayload"),\n "the same payload definition must retain the same CodecHash across Contract assemblies");\n Ensure(\n ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second),\n "different Contract assembly logical identities must not collapse to one RpcAssemblyHash");\n return Task.CompletedTask;\n }\n\n''' + text = text.replace(marker, assembly_test + marker) + + marker = ''' [Test]\n public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies()\n''' + if text.count(marker) != 1: + raise SystemExit('identity tests: native-size regression insertion marker mismatch') + native_test = ''' [Test]\n public Task NativeSizedUnsafeBlitShouldUseStable64BitOnlyIdentity()\n {\n var nativeSource = BuildSource("""\npublic struct NativeSizedUnsafeLayoutPayload\n{\n public int Prefix;\n public nint Handle;\n}\n\n[SharpLink.Sdk.RpcContract]\npublic interface INativeSizedUnsafeLayoutContract : SharpLink.Sdk.IService\n{\n ValueTask Echo(\n NativeSizedUnsafeLayoutPayload value,\n CancellationToken cancellationToken);\n}\n""");\n var fixed64Source = nativeSource.Replace("public nint Handle;", "public long Handle;", StringComparison.Ordinal);\n\n var x64 = GenerateIdentityManifest(\n "NativeSizedUnsafeLayoutIdentity",\n nativeSource,\n Platform.X64);\n var x86 = GenerateIdentityManifest(\n "NativeSizedUnsafeLayoutIdentity",\n nativeSource,\n Platform.X86);\n var fixed64 = GenerateIdentityManifest(\n "NativeSizedUnsafeLayoutIdentity",\n fixed64Source,\n Platform.X64);\n\n Ensure(\n ExtractGeneratedRpcAssemblyHash(x64) == ExtractGeneratedRpcAssemblyHash(x86),\n "native-sized UnsafeBlit identity must describe the supported 64-bit wire layout independently of compiler platform");\n Ensure(\n ExtractGeneratedRpcAssemblyHash(x64) != ExtractGeneratedRpcAssemblyHash(fixed64),\n "native-sized UnsafeBlit identity must remain distinct from a fixed-width Int64 field");\n return Task.CompletedTask;\n }\n\n''' + text = text.replace(marker, native_test + marker) + identity_tests.write_text(text, encoding='utf-8') + PY + + - name: Restore and format + run: | + dotnet restore Sharplink.slnx + dotnet format whitespace Sharplink.slnx --no-restore + + - name: Validate focused suites + run: | + dotnet build Sharplink.slnx --no-restore -c Release -v minimal + dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release --no-build + dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release --no-build + + - name: Commit fixes + shell: bash + run: | + git diff --check + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add src test + git commit -m "fix: address deterministic identity review gaps" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity From 8167d8af74b4243592236878e681f1daf92a75f7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:27:15 +0800 Subject: [PATCH 137/399] chore: stage PR 415 review fixer script --- eng/apply-pr415-review-fixes.py | 377 ++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 eng/apply-pr415-review-fixes.py diff --git a/eng/apply-pr415-review-fixes.py b/eng/apply-pr415-review-fixes.py new file mode 100644 index 000000000..e4a60c98f --- /dev/null +++ b/eng/apply-pr415-review-fixes.py @@ -0,0 +1,377 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one replacement target, found {count}") + target.write_text(text.replace(old, new), encoding="utf-8") + + +replace_once( + "src/SharpLink.Generator/RpcGenerator.Models.cs", + """internal sealed record DtoGenerationResult( + ImmutableArray Codecs, + ImmutableArray ContractCodecs, + ImmutableArray FinalCodecBoundTypes, + ImmutableArray Diagnostics, + ImmutableArray Enums) +{ + public ImmutableArray CodecHashes { get; init; } = + ImmutableArray.Empty; +} +""", + """internal sealed record DtoGenerationResult( + ImmutableArray Codecs, + ImmutableArray ContractCodecs, + ImmutableArray FinalCodecBoundTypes, + ImmutableArray Diagnostics, + ImmutableArray Enums) +{ + public ImmutableArray CodecHashes { get; init; } = + ImmutableArray.Empty; + public string AssemblyLogicalIdentity { get; init; } = string.Empty; +} +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.Models.cs", + """ x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || + x.CodecHashes.Length != y.CodecHashes.Length || + x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length) +""", + """ x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || + x.CodecHashes.Length != y.CodecHashes.Length || + x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || + !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal)) +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.Models.cs", + """ public int GetHashCode(DtoGenerationResult obj) + { + var hash = 17; +""", + """ public int GetHashCode(DtoGenerationResult obj) + { + var hash = 17; + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(obj.AssemblyLogicalIdentity)); +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs", + """ { + CodecHashes = codecHashes + }; +""", + """ { + CodecHashes = codecHashes, + AssemblyLogicalIdentity = compilation.Assembly.Identity.Name + }; +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.cs", + """ var code = GenerateAssemblyManifest(interfaces, services, codecs, contractCodecs, codecHashes); +""", + """ var code = GenerateAssemblyManifest( + interfaces, + services, + codecs, + contractCodecs, + codecHashes, + value.Right.AssemblyLogicalIdentity); +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs", + """ ImmutableArray codecs, + ImmutableArray contractCodecs, + ImmutableArray codecHashes) +""", + """ ImmutableArray codecs, + ImmutableArray contractCodecs, + ImmutableArray codecHashes, + string assemblyLogicalIdentity) +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs", + """ var rpcIdentity = BuildRpcAssemblyIdentity(contracts, codecHashes); +""", + """ var rpcIdentity = BuildRpcAssemblyIdentity(assemblyLogicalIdentity, contracts, codecHashes); +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs", + """ private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity( + RpcInterfaceModel[] contracts, + ImmutableArray codecHashes) +""", + """ private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity( + string assemblyLogicalIdentity, + RpcInterfaceModel[] contracts, + ImmutableArray codecHashes) +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs", + """ var assemblyParts = new List + { + "rpc-assembly/v1", + contractIdentities.Length.ToString(InvariantCulture) + }; +""", + """ var assemblyParts = new List + { + "rpc-assembly/v1", + assemblyLogicalIdentity, + contractIdentities.Length.ToString(InvariantCulture) + }; +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs", + """ if (type is IPointerTypeSymbol pointer) + { + builder.Append("|pointer|"); + AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack); + return; + } + if (type is IFunctionPointerTypeSymbol) + { + builder.Append("|function-pointer"); + return; + } +""", + """ if (type is IPointerTypeSymbol pointer) + { + builder.Append("|native-pointer-width/64|pointer|"); + AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack); + return; + } + if (type is IFunctionPointerTypeSymbol) + { + builder.Append("|native-pointer-width/64|function-pointer"); + return; + } +""", +) + +replace_once( + "src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs", + """ SpecialType.System_Int64 => "i64", + SpecialType.System_UInt64 => "u64", + SpecialType.System_Double => "f64", +""", + """ SpecialType.System_Int64 => "i64", + SpecialType.System_UInt64 => "u64", + SpecialType.System_IntPtr => "native-pointer-width/64:intptr", + SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr", + SpecialType.System_Double => "f64", +""", +) + +replace_once( + "src/SharpLink.Runtime/Codec/RpcCodecProvider.cs", + """ if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences()) + return UnsafeBlitCodec.Instance; +""", + """ if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences()) + { + RpcUnsafeBlitPlatform.EnsureSupported(targetType); + return UnsafeBlitCodec.Instance; + } +""", +) + +helper = Path("src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs") +if helper.exists(): + raise SystemExit(f"{helper}: file already exists") +helper.write_text( + """using System.Reflection; + +namespace SharpLink.Runtime; + +internal static class RpcUnsafeBlitPlatform +{ + private const int SupportedNativePointerSize = 8; + + internal static void EnsureSupported(Type targetType) + { + if (IsSupported(targetType, IntPtr.Size)) + return; + + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains native-sized members and requires a 64-bit process."); + } + + internal static bool IsSupported(Type targetType, int nativePointerSize) + { + ArgumentNullException.ThrowIfNull(targetType); + return nativePointerSize == SupportedNativePointerSize || + !ContainsNativeSizedMember(targetType, new HashSet()); + } + + private static bool ContainsNativeSizedMember(Type type, HashSet seen) + { + if (type == typeof(IntPtr) || type == typeof(UIntPtr) || type.IsPointer || type.IsFunctionPointer) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum) + return false; + if (!seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsNativeSizedMember(field.FieldType, seen)) + return true; + } + + return false; + } +} +""", + encoding="utf-8", +) + +unit_test = Path("test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs") +if unit_test.exists(): + raise SystemExit(f"{unit_test}: file already exists") +unit_test.write_text( + """using SharpLink.Runtime; + +namespace SharpLink.UnitTests.Runtime; + +public sealed class RpcUnsafeBlitPlatformTests +{ + [Test] + public void NativeSizedUnsafeBlitShouldBe64BitOnly() + { + Ensure( + RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 8), + "native-sized UnsafeBlit payloads must be accepted by the supported 64-bit runtime"); + Ensure( + !RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 4), + "native-sized UnsafeBlit payloads must be rejected by a 32-bit runtime"); + Ensure( + RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 4), + "fixed-width UnsafeBlit payloads must remain valid on a 32-bit runtime"); + } + + private struct NativeSizedPayload + { + public int Prefix; + public nint Handle; + } + + private struct PortablePayload + { + public int Prefix; + public long Value; + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} +""", + encoding="utf-8", +) + +identity_tests = Path("test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs") +text = identity_tests.read_text(encoding="utf-8") +marker = """ [Test] + public Task DtoWireShapeChangeShouldChangeFinalRpcIdentity() +""" +if text.count(marker) != 1: + raise SystemExit("identity tests: assembly regression insertion marker mismatch") +assembly_test = """ [Test] + public Task SameApparentAbiInDifferentAssembliesShouldHaveDifferentAssemblyIdentity() + { + var source = BuildDtoIdentitySource(includeExtraMember: false, idempotent: false); + var first = GenerateIdentityManifest( + "DeterministicIdentityAssemblyA", + source, + Platform.AnyCpu); + var second = GenerateIdentityManifest( + "DeterministicIdentityAssemblyB", + source, + Platform.AnyCpu); + + Ensure( + ExtractGeneratedCodecIdentity(first, "DeterministicPayload") == + ExtractGeneratedCodecIdentity(second, "DeterministicPayload"), + "the same payload definition must retain the same CodecHash across Contract assemblies"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second), + "different Contract assembly logical identities must not collapse to one RpcAssemblyHash"); + return Task.CompletedTask; + } + +""" +text = text.replace(marker, assembly_test + marker) + +marker = """ [Test] + public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies() +""" +if text.count(marker) != 1: + raise SystemExit("identity tests: native-size regression insertion marker mismatch") +native_test = """ [Test] + public Task NativeSizedUnsafeBlitShouldUseStable64BitOnlyIdentity() + { + var nativeSource = BuildSource(""" +public struct NativeSizedUnsafeLayoutPayload +{ + public int Prefix; + public nint Handle; +} + +[SharpLink.Sdk.RpcContract] +public interface INativeSizedUnsafeLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + NativeSizedUnsafeLayoutPayload value, + CancellationToken cancellationToken); +} +"""); + var fixed64Source = nativeSource.Replace("public nint Handle;", "public long Handle;", StringComparison.Ordinal); + + var x64 = GenerateIdentityManifest( + "NativeSizedUnsafeLayoutIdentity", + nativeSource, + Platform.X64); + var x86 = GenerateIdentityManifest( + "NativeSizedUnsafeLayoutIdentity", + nativeSource, + Platform.X86); + var fixed64 = GenerateIdentityManifest( + "NativeSizedUnsafeLayoutIdentity", + fixed64Source, + Platform.X64); + + Ensure( + ExtractGeneratedRpcAssemblyHash(x64) == ExtractGeneratedRpcAssemblyHash(x86), + "native-sized UnsafeBlit identity must describe the supported 64-bit wire layout independently of compiler platform"); + Ensure( + ExtractGeneratedRpcAssemblyHash(x64) != ExtractGeneratedRpcAssemblyHash(fixed64), + "native-sized UnsafeBlit identity must remain distinct from a fixed-width Int64 field"); + return Task.CompletedTask; + } + +""" +text = text.replace(marker, native_test + marker) +identity_tests.write_text(text, encoding="utf-8") From 2a4b1cc2402e21354267319587c98a9825ba32e9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:27:27 +0800 Subject: [PATCH 138/399] chore: run PR 415 review fixer --- .github/workflows/zz-pr415-review-fixes.yml | 102 +------------------- 1 file changed, 2 insertions(+), 100 deletions(-) diff --git a/.github/workflows/zz-pr415-review-fixes.yml b/.github/workflows/zz-pr415-review-fixes.yml index e2aacc1cd..92fe38c42 100644 --- a/.github/workflows/zz-pr415-review-fixes.yml +++ b/.github/workflows/zz-pr415-review-fixes.yml @@ -10,7 +10,7 @@ permissions: jobs: apply: - if: github.event.head_commit.message == 'chore: stage PR 415 review fixes' + if: "${{ github.event.head_commit.message == 'chore: run PR 415 review fixer' }}" runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -24,104 +24,7 @@ jobs: dotnet-version: 10.0.x - name: Apply review fixes - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected exactly one replacement target, found {count}') - p.write_text(text.replace(old, new), encoding='utf-8') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.Models.cs', - '''internal sealed record DtoGenerationResult(\n ImmutableArray Codecs,\n ImmutableArray ContractCodecs,\n ImmutableArray FinalCodecBoundTypes,\n ImmutableArray Diagnostics,\n ImmutableArray Enums)\n{\n public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n}\n''', - '''internal sealed record DtoGenerationResult(\n ImmutableArray Codecs,\n ImmutableArray ContractCodecs,\n ImmutableArray FinalCodecBoundTypes,\n ImmutableArray Diagnostics,\n ImmutableArray Enums)\n{\n public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public string AssemblyLogicalIdentity { get; init; } = string.Empty;\n}\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.Models.cs', - ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length)\n''', - ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length ||\n !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal))\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.Models.cs', - ''' public int GetHashCode(DtoGenerationResult obj)\n {\n var hash = 17;\n''', - ''' public int GetHashCode(DtoGenerationResult obj)\n {\n var hash = 17;\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(obj.AssemblyLogicalIdentity));\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs', - ''' {\n CodecHashes = codecHashes\n };\n''', - ''' {\n CodecHashes = codecHashes,\n AssemblyLogicalIdentity = compilation.Assembly.Identity.Name\n };\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.cs', - ''' var code = GenerateAssemblyManifest(interfaces, services, codecs, contractCodecs, codecHashes);\n''', - ''' var code = GenerateAssemblyManifest(\n interfaces,\n services,\n codecs,\n contractCodecs,\n codecHashes,\n value.Right.AssemblyLogicalIdentity);\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs', - ''' ImmutableArray codecs,\n ImmutableArray contractCodecs,\n ImmutableArray codecHashes)\n''', - ''' ImmutableArray codecs,\n ImmutableArray contractCodecs,\n ImmutableArray codecHashes,\n string assemblyLogicalIdentity)\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs', - ''' var rpcIdentity = BuildRpcAssemblyIdentity(contracts, codecHashes);\n''', - ''' var rpcIdentity = BuildRpcAssemblyIdentity(assemblyLogicalIdentity, contracts, codecHashes);\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs', - ''' private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity(\n RpcInterfaceModel[] contracts,\n ImmutableArray codecHashes)\n''', - ''' private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity(\n string assemblyLogicalIdentity,\n RpcInterfaceModel[] contracts,\n ImmutableArray codecHashes)\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs', - ''' var assemblyParts = new List\n {\n "rpc-assembly/v1",\n contractIdentities.Length.ToString(InvariantCulture)\n };\n''', - ''' var assemblyParts = new List\n {\n "rpc-assembly/v1",\n assemblyLogicalIdentity,\n contractIdentities.Length.ToString(InvariantCulture)\n };\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs', - ''' if (type is IPointerTypeSymbol pointer)\n {\n builder.Append("|pointer|");\n AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack);\n return;\n }\n if (type is IFunctionPointerTypeSymbol)\n {\n builder.Append("|function-pointer");\n return;\n }\n''', - ''' if (type is IPointerTypeSymbol pointer)\n {\n builder.Append("|native-pointer-width/64|pointer|");\n AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack);\n return;\n }\n if (type is IFunctionPointerTypeSymbol)\n {\n builder.Append("|native-pointer-width/64|function-pointer");\n return;\n }\n''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs', - ''' SpecialType.System_Int64 => "i64",\n SpecialType.System_UInt64 => "u64",\n SpecialType.System_Double => "f64",\n''', - ''' SpecialType.System_Int64 => "i64",\n SpecialType.System_UInt64 => "u64",\n SpecialType.System_IntPtr => "native-pointer-width/64:intptr",\n SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr",\n SpecialType.System_Double => "f64",\n''') - - replace_once( - 'src/SharpLink.Runtime/Codec/RpcCodecProvider.cs', - ''' if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences())\n return UnsafeBlitCodec.Instance;\n''', - ''' if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences())\n {\n RpcUnsafeBlitPlatform.EnsureSupported(targetType);\n return UnsafeBlitCodec.Instance;\n }\n''') - - helper = Path('src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs') - if helper.exists(): - raise SystemExit(f'{helper}: file already exists') - helper.write_text('''using System.Reflection;\n\nnamespace SharpLink.Runtime;\n\ninternal static class RpcUnsafeBlitPlatform\n{\n private const int SupportedNativePointerSize = 8;\n\n internal static void EnsureSupported(Type targetType)\n {\n if (IsSupported(targetType, IntPtr.Size))\n return;\n\n throw new PlatformNotSupportedException(\n $"UnsafeBlit Codec for '{targetType.FullName}' contains native-sized members and requires a 64-bit process.");\n }\n\n internal static bool IsSupported(Type targetType, int nativePointerSize)\n {\n ArgumentNullException.ThrowIfNull(targetType);\n return nativePointerSize == SupportedNativePointerSize ||\n !ContainsNativeSizedMember(targetType, new HashSet());\n }\n\n private static bool ContainsNativeSizedMember(Type type, HashSet seen)\n {\n if (type == typeof(IntPtr) || type == typeof(UIntPtr) || type.IsPointer || type.IsFunctionPointer)\n return true;\n if (!type.IsValueType || type.IsPrimitive || type.IsEnum)\n return false;\n if (!seen.Add(type))\n return false;\n\n foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))\n {\n if (ContainsNativeSizedMember(field.FieldType, seen))\n return true;\n }\n\n return false;\n }\n}\n''', encoding='utf-8') - - unit_test = Path('test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs') - if unit_test.exists(): - raise SystemExit(f'{unit_test}: file already exists') - unit_test.write_text('''using SharpLink.Runtime;\n\nnamespace SharpLink.UnitTests.Runtime;\n\npublic sealed class RpcUnsafeBlitPlatformTests\n{\n [Test]\n public void NativeSizedUnsafeBlitShouldBe64BitOnly()\n {\n Ensure(\n RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 8),\n "native-sized UnsafeBlit payloads must be accepted by the supported 64-bit runtime");\n Ensure(\n !RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 4),\n "native-sized UnsafeBlit payloads must be rejected by a 32-bit runtime");\n Ensure(\n RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 4),\n "fixed-width UnsafeBlit payloads must remain valid on a 32-bit runtime");\n }\n\n private struct NativeSizedPayload\n {\n public int Prefix;\n public nint Handle;\n }\n\n private struct PortablePayload\n {\n public int Prefix;\n public long Value;\n }\n\n private static void Ensure(bool condition, string message)\n {\n if (!condition)\n throw new InvalidOperationException(message);\n }\n}\n''', encoding='utf-8') - - identity_tests = Path('test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs') - text = identity_tests.read_text(encoding='utf-8') - marker = ''' [Test]\n public Task DtoWireShapeChangeShouldChangeFinalRpcIdentity()\n''' - if text.count(marker) != 1: - raise SystemExit('identity tests: assembly regression insertion marker mismatch') - assembly_test = ''' [Test]\n public Task SameApparentAbiInDifferentAssembliesShouldHaveDifferentAssemblyIdentity()\n {\n var source = BuildDtoIdentitySource(includeExtraMember: false, idempotent: false);\n var first = GenerateIdentityManifest(\n "DeterministicIdentityAssemblyA",\n source,\n Platform.AnyCpu);\n var second = GenerateIdentityManifest(\n "DeterministicIdentityAssemblyB",\n source,\n Platform.AnyCpu);\n\n Ensure(\n ExtractGeneratedCodecIdentity(first, "DeterministicPayload") ==\n ExtractGeneratedCodecIdentity(second, "DeterministicPayload"),\n "the same payload definition must retain the same CodecHash across Contract assemblies");\n Ensure(\n ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second),\n "different Contract assembly logical identities must not collapse to one RpcAssemblyHash");\n return Task.CompletedTask;\n }\n\n''' - text = text.replace(marker, assembly_test + marker) - - marker = ''' [Test]\n public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies()\n''' - if text.count(marker) != 1: - raise SystemExit('identity tests: native-size regression insertion marker mismatch') - native_test = ''' [Test]\n public Task NativeSizedUnsafeBlitShouldUseStable64BitOnlyIdentity()\n {\n var nativeSource = BuildSource("""\npublic struct NativeSizedUnsafeLayoutPayload\n{\n public int Prefix;\n public nint Handle;\n}\n\n[SharpLink.Sdk.RpcContract]\npublic interface INativeSizedUnsafeLayoutContract : SharpLink.Sdk.IService\n{\n ValueTask Echo(\n NativeSizedUnsafeLayoutPayload value,\n CancellationToken cancellationToken);\n}\n""");\n var fixed64Source = nativeSource.Replace("public nint Handle;", "public long Handle;", StringComparison.Ordinal);\n\n var x64 = GenerateIdentityManifest(\n "NativeSizedUnsafeLayoutIdentity",\n nativeSource,\n Platform.X64);\n var x86 = GenerateIdentityManifest(\n "NativeSizedUnsafeLayoutIdentity",\n nativeSource,\n Platform.X86);\n var fixed64 = GenerateIdentityManifest(\n "NativeSizedUnsafeLayoutIdentity",\n fixed64Source,\n Platform.X64);\n\n Ensure(\n ExtractGeneratedRpcAssemblyHash(x64) == ExtractGeneratedRpcAssemblyHash(x86),\n "native-sized UnsafeBlit identity must describe the supported 64-bit wire layout independently of compiler platform");\n Ensure(\n ExtractGeneratedRpcAssemblyHash(x64) != ExtractGeneratedRpcAssemblyHash(fixed64),\n "native-sized UnsafeBlit identity must remain distinct from a fixed-width Int64 field");\n return Task.CompletedTask;\n }\n\n''' - text = text.replace(marker, native_test + marker) - identity_tests.write_text(text, encoding='utf-8') - PY + run: python3 eng/apply-pr415-review-fixes.py - name: Restore and format run: | @@ -135,7 +38,6 @@ jobs: dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release --no-build - name: Commit fixes - shell: bash run: | git diff --check git config user.name github-actions[bot] From 9ebce2d0326ff400d4a2e2d836bba0c784255d1f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:29:40 +0800 Subject: [PATCH 139/399] chore: rerun PR 415 review fixer --- .github/workflows/zz-pr415-review-fixes.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/zz-pr415-review-fixes.yml b/.github/workflows/zz-pr415-review-fixes.yml index 92fe38c42..67c3192f9 100644 --- a/.github/workflows/zz-pr415-review-fixes.yml +++ b/.github/workflows/zz-pr415-review-fixes.yml @@ -10,7 +10,7 @@ permissions: jobs: apply: - if: "${{ github.event.head_commit.message == 'chore: run PR 415 review fixer' }}" + if: "${{ github.event.head_commit.message == 'chore: rerun PR 415 review fixer' }}" runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -24,7 +24,23 @@ jobs: dotnet-version: 10.0.x - name: Apply review fixes - run: python3 eng/apply-pr415-review-fixes.py + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + path = Path('eng/apply-pr415-review-fixes.py') + text = path.read_text(encoding='utf-8') + text = text.replace( + ' var nativeSource = BuildSource("""\n', + ' var nativeSource = BuildSource(\\"\\"\\"\n', + 1) + text = text.replace( + '\n""");\n var fixed64Source', + '\n\\"\\"\\");\n var fixed64Source', + 1) + path.write_text(text, encoding='utf-8') + PY + python3 eng/apply-pr415-review-fixes.py - name: Restore and format run: | From b5e0a3193d0c4f37d19738efd668ec2e2b5a641d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:31:51 +0800 Subject: [PATCH 140/399] chore: validate PR 415 review fixes --- .github/workflows/zz-pr415-review-fixes.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/zz-pr415-review-fixes.yml b/.github/workflows/zz-pr415-review-fixes.yml index 67c3192f9..4e16e61d1 100644 --- a/.github/workflows/zz-pr415-review-fixes.yml +++ b/.github/workflows/zz-pr415-review-fixes.yml @@ -10,7 +10,7 @@ permissions: jobs: apply: - if: "${{ github.event.head_commit.message == 'chore: rerun PR 415 review fixer' }}" + if: "${{ github.event.head_commit.message == 'chore: validate PR 415 review fixes' }}" runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -41,6 +41,15 @@ jobs: path.write_text(text, encoding='utf-8') PY python3 eng/apply-pr415-review-fixes.py + python3 - <<'PY' + from pathlib import Path + path = Path('test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs') + text = path.read_text(encoding='utf-8') + text = text.replace('public int Prefix;', 'public int Prefix { get; set; }') + text = text.replace('public nint Handle;', 'public nint Handle { get; set; }') + text = text.replace('public long Value;', 'public long Value { get; set; }') + path.write_text(text, encoding='utf-8') + PY - name: Restore and format run: | From 285d9137d953b0a5600a5ad5a68f4731f0c93300 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:33:57 +0000 Subject: [PATCH 141/399] fix: address deterministic identity review gaps --- .../RpcGenerator.CodecIdentity.cs | 6 +- .../RpcGenerator.CodecPolicyOwnership.cs | 3 +- .../RpcGenerator.ManifestEmitter.cs | 5 +- .../RpcGenerator.Models.cs | 5 +- .../RpcGenerator.RpcIdentity.cs | 2 + src/SharpLink.Generator/RpcGenerator.cs | 8 ++- .../Codec/RpcCodecProvider.cs | 3 + .../Codec/RpcUnsafeBlitPlatform.cs | 42 ++++++++++++ .../RpcDeterministicIdentityTests.cs | 65 +++++++++++++++++++ .../Runtime/RpcUnsafeBlitPlatformTests.cs | 38 +++++++++++ 10 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs create mode 100644 test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index a174d467c..05268dc52 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -442,13 +442,13 @@ private void AppendUnsafeBlitPhysicalLayout( if (type is IPointerTypeSymbol pointer) { - builder.Append("|pointer|"); + builder.Append("|native-pointer-width/64|pointer|"); AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack); return; } if (type is IFunctionPointerTypeSymbol) { - builder.Append("|function-pointer"); + builder.Append("|native-pointer-width/64|function-pointer"); return; } if (type is not INamedTypeSymbol named) @@ -509,6 +509,8 @@ private static bool TryAppendPhysicalPrimitive(ITypeSymbol type, StringBuilder b SpecialType.System_Single => "f32", SpecialType.System_Int64 => "i64", SpecialType.System_UInt64 => "u64", + SpecialType.System_IntPtr => "native-pointer-width/64:intptr", + SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr", SpecialType.System_Double => "f64", SpecialType.System_Decimal => "decimal128", _ => null diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 286fc9f8f..2019761a1 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -113,7 +113,8 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( diagnostics, enums) { - CodecHashes = codecHashes + CodecHashes = codecHashes, + AssemblyLogicalIdentity = compilation.Assembly.Identity.Name }; } diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index 64c9c407c..f621c4bd7 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -8,7 +8,8 @@ private static string GenerateAssemblyManifest( ImmutableArray services, ImmutableArray codecs, ImmutableArray contractCodecs, - ImmutableArray codecHashes) + ImmutableArray codecHashes, + string assemblyLogicalIdentity) { var contracts = GetContractModels(interfaces); var serviceModels = GetServiceModels(services); @@ -16,7 +17,7 @@ private static string GenerateAssemblyManifest( return string.Empty; var manifestTypeName = GetManifestTypeName(contracts, serviceModels, codecs, contractCodecs); - var rpcIdentity = BuildRpcAssemblyIdentity(contracts, codecHashes); + var rpcIdentity = BuildRpcAssemblyIdentity(assemblyLogicalIdentity, contracts, codecHashes); // Module dependencies come from generated artifacts and the finalized Codec graph. Contract // signature CLR references alone are not evidence that the referenced assembly publishes a // SharpLink generated manifest and therefore must not become dynamic-module dependencies. diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index 3623cce45..edfc8b74f 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -258,6 +258,7 @@ internal sealed record DtoGenerationResult( { public ImmutableArray CodecHashes { get; init; } = ImmutableArray.Empty; + public string AssemblyLogicalIdentity { get; init; } = string.Empty; } internal sealed record GeneratedEnumModel( @@ -277,7 +278,8 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) x.ContractCodecs.Length != y.ContractCodecs.Length || x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || x.CodecHashes.Length != y.CodecHashes.Length || - x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length) + x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || + !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal)) { return false; } @@ -325,6 +327,7 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) public int GetHashCode(DtoGenerationResult obj) { var hash = 17; + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(obj.AssemblyLogicalIdentity)); foreach (var codec in obj.Codecs) { hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.TypeName)); diff --git a/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs b/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs index 147814f76..52d632879 100644 --- a/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs @@ -16,6 +16,7 @@ private sealed record RpcMethodIdentityModel( RpcHashValue MethodHash); private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity( + string assemblyLogicalIdentity, RpcInterfaceModel[] contracts, ImmutableArray codecHashes) { @@ -30,6 +31,7 @@ private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity( var assemblyParts = new List { "rpc-assembly/v1", + assemblyLogicalIdentity, contractIdentities.Length.ToString(InvariantCulture) }; foreach (var contract in contractIdentities) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 268f69a41..695bfcf28 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -324,7 +324,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var contracts = GetContractModels(interfaces); var serviceModels = GetServiceModels(services); - var code = GenerateAssemblyManifest(interfaces, services, codecs, contractCodecs, codecHashes); + var code = GenerateAssemblyManifest( + interfaces, + services, + codecs, + contractCodecs, + codecHashes, + value.Right.AssemblyLogicalIdentity); if (!string.IsNullOrEmpty(code)) { var manifestTypeName = GetManifestTypeName(contracts, serviceModels, codecs, contractCodecs); diff --git a/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs b/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs index d120c2de1..746d4c1cd 100644 --- a/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs +++ b/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs @@ -166,7 +166,10 @@ private IRpcCodec ResolveCodec(Type targetType) if (targetType.IsEnum) return EnumCodec.Instance; if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences()) + { + RpcUnsafeBlitPlatform.EnsureSupported(targetType); return UnsafeBlitCodec.Instance; + } throw new NotSupportedException( $"Codec for '{targetType.FullName}' was not registered in this SharpLink runtime context."); diff --git a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs new file mode 100644 index 000000000..695ea7fc5 --- /dev/null +++ b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs @@ -0,0 +1,42 @@ +using System.Reflection; + +namespace SharpLink.Runtime; + +internal static class RpcUnsafeBlitPlatform +{ + private const int SupportedNativePointerSize = 8; + + internal static void EnsureSupported(Type targetType) + { + if (IsSupported(targetType, IntPtr.Size)) + return; + + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains native-sized members and requires a 64-bit process."); + } + + internal static bool IsSupported(Type targetType, int nativePointerSize) + { + ArgumentNullException.ThrowIfNull(targetType); + return nativePointerSize == SupportedNativePointerSize || + !ContainsNativeSizedMember(targetType, new HashSet()); + } + + private static bool ContainsNativeSizedMember(Type type, HashSet seen) + { + if (type == typeof(IntPtr) || type == typeof(UIntPtr) || type.IsPointer || type.IsFunctionPointer) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum) + return false; + if (!seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsNativeSizedMember(field.FieldType, seen)) + return true; + } + + return false; + } +} diff --git a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs index 6f71d5ee7..998efef78 100644 --- a/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs +++ b/test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs @@ -47,6 +47,29 @@ public Task SameRpcSemanticsShouldProduceSameIdentityForX64AndX86() return Task.CompletedTask; } + [Test] + public Task SameApparentAbiInDifferentAssembliesShouldHaveDifferentAssemblyIdentity() + { + var source = BuildDtoIdentitySource(includeExtraMember: false, idempotent: false); + var first = GenerateIdentityManifest( + "DeterministicIdentityAssemblyA", + source, + Platform.AnyCpu); + var second = GenerateIdentityManifest( + "DeterministicIdentityAssemblyB", + source, + Platform.AnyCpu); + + Ensure( + ExtractGeneratedCodecIdentity(first, "DeterministicPayload") == + ExtractGeneratedCodecIdentity(second, "DeterministicPayload"), + "the same payload definition must retain the same CodecHash across Contract assemblies"); + Ensure( + ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second), + "different Contract assembly logical identities must not collapse to one RpcAssemblyHash"); + return Task.CompletedTask; + } + [Test] public Task DtoWireShapeChangeShouldChangeFinalRpcIdentity() { @@ -150,6 +173,48 @@ public Task UnsafeBlitPhysicalLayoutChangeShouldChangeIdentity() return Task.CompletedTask; } + [Test] + public Task NativeSizedUnsafeBlitShouldUseStable64BitOnlyIdentity() + { + var nativeSource = BuildSource(""" +public struct NativeSizedUnsafeLayoutPayload +{ + public int Prefix; + public nint Handle; +} + +[SharpLink.Sdk.RpcContract] +public interface INativeSizedUnsafeLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + NativeSizedUnsafeLayoutPayload value, + CancellationToken cancellationToken); +} +"""); + var fixed64Source = nativeSource.Replace("public nint Handle;", "public long Handle;", StringComparison.Ordinal); + + var x64 = GenerateIdentityManifest( + "NativeSizedUnsafeLayoutIdentity", + nativeSource, + Platform.X64); + var x86 = GenerateIdentityManifest( + "NativeSizedUnsafeLayoutIdentity", + nativeSource, + Platform.X86); + var fixed64 = GenerateIdentityManifest( + "NativeSizedUnsafeLayoutIdentity", + fixed64Source, + Platform.X64); + + Ensure( + ExtractGeneratedRpcAssemblyHash(x64) == ExtractGeneratedRpcAssemblyHash(x86), + "native-sized UnsafeBlit identity must describe the supported 64-bit wire layout independently of compiler platform"); + Ensure( + ExtractGeneratedRpcAssemblyHash(x64) != ExtractGeneratedRpcAssemblyHash(fixed64), + "native-sized UnsafeBlit identity must remain distinct from a fixed-width Int64 field"); + return Task.CompletedTask; + } + [Test] public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies() { diff --git a/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs new file mode 100644 index 000000000..5b3257a2e --- /dev/null +++ b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs @@ -0,0 +1,38 @@ +using SharpLink.Runtime; + +namespace SharpLink.UnitTests.Runtime; + +public sealed class RpcUnsafeBlitPlatformTests +{ + [Test] + public void NativeSizedUnsafeBlitShouldBe64BitOnly() + { + Ensure( + RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 8), + "native-sized UnsafeBlit payloads must be accepted by the supported 64-bit runtime"); + Ensure( + !RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 4), + "native-sized UnsafeBlit payloads must be rejected by a 32-bit runtime"); + Ensure( + RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 4), + "fixed-width UnsafeBlit payloads must remain valid on a 32-bit runtime"); + } + + private struct NativeSizedPayload + { + public int Prefix { get; set; } + public nint Handle { get; set; } + } + + private struct PortablePayload + { + public int Prefix { get; set; } + public long Value { get; set; } + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} From e96f6e092692bf8312cdff56689ed5c888db3669 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:34:33 +0800 Subject: [PATCH 142/399] chore: remove PR 415 review fixer script --- eng/apply-pr415-review-fixes.py | 377 -------------------------------- 1 file changed, 377 deletions(-) delete mode 100644 eng/apply-pr415-review-fixes.py diff --git a/eng/apply-pr415-review-fixes.py b/eng/apply-pr415-review-fixes.py deleted file mode 100644 index e4a60c98f..000000000 --- a/eng/apply-pr415-review-fixes.py +++ /dev/null @@ -1,377 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected exactly one replacement target, found {count}") - target.write_text(text.replace(old, new), encoding="utf-8") - - -replace_once( - "src/SharpLink.Generator/RpcGenerator.Models.cs", - """internal sealed record DtoGenerationResult( - ImmutableArray Codecs, - ImmutableArray ContractCodecs, - ImmutableArray FinalCodecBoundTypes, - ImmutableArray Diagnostics, - ImmutableArray Enums) -{ - public ImmutableArray CodecHashes { get; init; } = - ImmutableArray.Empty; -} -""", - """internal sealed record DtoGenerationResult( - ImmutableArray Codecs, - ImmutableArray ContractCodecs, - ImmutableArray FinalCodecBoundTypes, - ImmutableArray Diagnostics, - ImmutableArray Enums) -{ - public ImmutableArray CodecHashes { get; init; } = - ImmutableArray.Empty; - public string AssemblyLogicalIdentity { get; init; } = string.Empty; -} -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.Models.cs", - """ x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || - x.CodecHashes.Length != y.CodecHashes.Length || - x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length) -""", - """ x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || - x.CodecHashes.Length != y.CodecHashes.Length || - x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || - !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal)) -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.Models.cs", - """ public int GetHashCode(DtoGenerationResult obj) - { - var hash = 17; -""", - """ public int GetHashCode(DtoGenerationResult obj) - { - var hash = 17; - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(obj.AssemblyLogicalIdentity)); -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs", - """ { - CodecHashes = codecHashes - }; -""", - """ { - CodecHashes = codecHashes, - AssemblyLogicalIdentity = compilation.Assembly.Identity.Name - }; -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.cs", - """ var code = GenerateAssemblyManifest(interfaces, services, codecs, contractCodecs, codecHashes); -""", - """ var code = GenerateAssemblyManifest( - interfaces, - services, - codecs, - contractCodecs, - codecHashes, - value.Right.AssemblyLogicalIdentity); -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs", - """ ImmutableArray codecs, - ImmutableArray contractCodecs, - ImmutableArray codecHashes) -""", - """ ImmutableArray codecs, - ImmutableArray contractCodecs, - ImmutableArray codecHashes, - string assemblyLogicalIdentity) -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs", - """ var rpcIdentity = BuildRpcAssemblyIdentity(contracts, codecHashes); -""", - """ var rpcIdentity = BuildRpcAssemblyIdentity(assemblyLogicalIdentity, contracts, codecHashes); -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs", - """ private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity( - RpcInterfaceModel[] contracts, - ImmutableArray codecHashes) -""", - """ private static RpcAssemblyIdentityModel BuildRpcAssemblyIdentity( - string assemblyLogicalIdentity, - RpcInterfaceModel[] contracts, - ImmutableArray codecHashes) -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs", - """ var assemblyParts = new List - { - "rpc-assembly/v1", - contractIdentities.Length.ToString(InvariantCulture) - }; -""", - """ var assemblyParts = new List - { - "rpc-assembly/v1", - assemblyLogicalIdentity, - contractIdentities.Length.ToString(InvariantCulture) - }; -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs", - """ if (type is IPointerTypeSymbol pointer) - { - builder.Append("|pointer|"); - AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack); - return; - } - if (type is IFunctionPointerTypeSymbol) - { - builder.Append("|function-pointer"); - return; - } -""", - """ if (type is IPointerTypeSymbol pointer) - { - builder.Append("|native-pointer-width/64|pointer|"); - AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack); - return; - } - if (type is IFunctionPointerTypeSymbol) - { - builder.Append("|native-pointer-width/64|function-pointer"); - return; - } -""", -) - -replace_once( - "src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs", - """ SpecialType.System_Int64 => "i64", - SpecialType.System_UInt64 => "u64", - SpecialType.System_Double => "f64", -""", - """ SpecialType.System_Int64 => "i64", - SpecialType.System_UInt64 => "u64", - SpecialType.System_IntPtr => "native-pointer-width/64:intptr", - SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr", - SpecialType.System_Double => "f64", -""", -) - -replace_once( - "src/SharpLink.Runtime/Codec/RpcCodecProvider.cs", - """ if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences()) - return UnsafeBlitCodec.Instance; -""", - """ if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences()) - { - RpcUnsafeBlitPlatform.EnsureSupported(targetType); - return UnsafeBlitCodec.Instance; - } -""", -) - -helper = Path("src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs") -if helper.exists(): - raise SystemExit(f"{helper}: file already exists") -helper.write_text( - """using System.Reflection; - -namespace SharpLink.Runtime; - -internal static class RpcUnsafeBlitPlatform -{ - private const int SupportedNativePointerSize = 8; - - internal static void EnsureSupported(Type targetType) - { - if (IsSupported(targetType, IntPtr.Size)) - return; - - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' contains native-sized members and requires a 64-bit process."); - } - - internal static bool IsSupported(Type targetType, int nativePointerSize) - { - ArgumentNullException.ThrowIfNull(targetType); - return nativePointerSize == SupportedNativePointerSize || - !ContainsNativeSizedMember(targetType, new HashSet()); - } - - private static bool ContainsNativeSizedMember(Type type, HashSet seen) - { - if (type == typeof(IntPtr) || type == typeof(UIntPtr) || type.IsPointer || type.IsFunctionPointer) - return true; - if (!type.IsValueType || type.IsPrimitive || type.IsEnum) - return false; - if (!seen.Add(type)) - return false; - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (ContainsNativeSizedMember(field.FieldType, seen)) - return true; - } - - return false; - } -} -""", - encoding="utf-8", -) - -unit_test = Path("test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs") -if unit_test.exists(): - raise SystemExit(f"{unit_test}: file already exists") -unit_test.write_text( - """using SharpLink.Runtime; - -namespace SharpLink.UnitTests.Runtime; - -public sealed class RpcUnsafeBlitPlatformTests -{ - [Test] - public void NativeSizedUnsafeBlitShouldBe64BitOnly() - { - Ensure( - RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 8), - "native-sized UnsafeBlit payloads must be accepted by the supported 64-bit runtime"); - Ensure( - !RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 4), - "native-sized UnsafeBlit payloads must be rejected by a 32-bit runtime"); - Ensure( - RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 4), - "fixed-width UnsafeBlit payloads must remain valid on a 32-bit runtime"); - } - - private struct NativeSizedPayload - { - public int Prefix; - public nint Handle; - } - - private struct PortablePayload - { - public int Prefix; - public long Value; - } - - private static void Ensure(bool condition, string message) - { - if (!condition) - throw new InvalidOperationException(message); - } -} -""", - encoding="utf-8", -) - -identity_tests = Path("test/SharpLink.Generator.Tests/RpcDeterministicIdentityTests.cs") -text = identity_tests.read_text(encoding="utf-8") -marker = """ [Test] - public Task DtoWireShapeChangeShouldChangeFinalRpcIdentity() -""" -if text.count(marker) != 1: - raise SystemExit("identity tests: assembly regression insertion marker mismatch") -assembly_test = """ [Test] - public Task SameApparentAbiInDifferentAssembliesShouldHaveDifferentAssemblyIdentity() - { - var source = BuildDtoIdentitySource(includeExtraMember: false, idempotent: false); - var first = GenerateIdentityManifest( - "DeterministicIdentityAssemblyA", - source, - Platform.AnyCpu); - var second = GenerateIdentityManifest( - "DeterministicIdentityAssemblyB", - source, - Platform.AnyCpu); - - Ensure( - ExtractGeneratedCodecIdentity(first, "DeterministicPayload") == - ExtractGeneratedCodecIdentity(second, "DeterministicPayload"), - "the same payload definition must retain the same CodecHash across Contract assemblies"); - Ensure( - ExtractGeneratedRpcAssemblyHash(first) != ExtractGeneratedRpcAssemblyHash(second), - "different Contract assembly logical identities must not collapse to one RpcAssemblyHash"); - return Task.CompletedTask; - } - -""" -text = text.replace(marker, assembly_test + marker) - -marker = """ [Test] - public Task SharedPayloadShouldHaveSameCodecHashAcrossContractAssemblies() -""" -if text.count(marker) != 1: - raise SystemExit("identity tests: native-size regression insertion marker mismatch") -native_test = """ [Test] - public Task NativeSizedUnsafeBlitShouldUseStable64BitOnlyIdentity() - { - var nativeSource = BuildSource(""" -public struct NativeSizedUnsafeLayoutPayload -{ - public int Prefix; - public nint Handle; -} - -[SharpLink.Sdk.RpcContract] -public interface INativeSizedUnsafeLayoutContract : SharpLink.Sdk.IService -{ - ValueTask Echo( - NativeSizedUnsafeLayoutPayload value, - CancellationToken cancellationToken); -} -"""); - var fixed64Source = nativeSource.Replace("public nint Handle;", "public long Handle;", StringComparison.Ordinal); - - var x64 = GenerateIdentityManifest( - "NativeSizedUnsafeLayoutIdentity", - nativeSource, - Platform.X64); - var x86 = GenerateIdentityManifest( - "NativeSizedUnsafeLayoutIdentity", - nativeSource, - Platform.X86); - var fixed64 = GenerateIdentityManifest( - "NativeSizedUnsafeLayoutIdentity", - fixed64Source, - Platform.X64); - - Ensure( - ExtractGeneratedRpcAssemblyHash(x64) == ExtractGeneratedRpcAssemblyHash(x86), - "native-sized UnsafeBlit identity must describe the supported 64-bit wire layout independently of compiler platform"); - Ensure( - ExtractGeneratedRpcAssemblyHash(x64) != ExtractGeneratedRpcAssemblyHash(fixed64), - "native-sized UnsafeBlit identity must remain distinct from a fixed-width Int64 field"); - return Task.CompletedTask; - } - -""" -text = text.replace(marker, native_test + marker) -identity_tests.write_text(text, encoding="utf-8") From 47ba39e00b90fd46998efebdb61471e2dc7657c3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:34:41 +0800 Subject: [PATCH 143/399] chore: remove PR 415 review fixer workflow --- .github/workflows/zz-pr415-review-fixes.yml | 72 --------------------- 1 file changed, 72 deletions(-) delete mode 100644 .github/workflows/zz-pr415-review-fixes.yml diff --git a/.github/workflows/zz-pr415-review-fixes.yml b/.github/workflows/zz-pr415-review-fixes.yml deleted file mode 100644 index 4e16e61d1..000000000 --- a/.github/workflows/zz-pr415-review-fixes.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: One-off PR 415 review fixes - -on: - push: - branches: - - feature/issue-396-deterministic-rpc-identity - -permissions: - contents: write - -jobs: - apply: - if: "${{ github.event.head_commit.message == 'chore: validate PR 415 review fixes' }}" - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: feature/issue-396-deterministic-rpc-identity - fetch-depth: 0 - - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 - with: - dotnet-version: 10.0.x - - - name: Apply review fixes - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - path = Path('eng/apply-pr415-review-fixes.py') - text = path.read_text(encoding='utf-8') - text = text.replace( - ' var nativeSource = BuildSource("""\n', - ' var nativeSource = BuildSource(\\"\\"\\"\n', - 1) - text = text.replace( - '\n""");\n var fixed64Source', - '\n\\"\\"\\");\n var fixed64Source', - 1) - path.write_text(text, encoding='utf-8') - PY - python3 eng/apply-pr415-review-fixes.py - python3 - <<'PY' - from pathlib import Path - path = Path('test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs') - text = path.read_text(encoding='utf-8') - text = text.replace('public int Prefix;', 'public int Prefix { get; set; }') - text = text.replace('public nint Handle;', 'public nint Handle { get; set; }') - text = text.replace('public long Value;', 'public long Value { get; set; }') - path.write_text(text, encoding='utf-8') - PY - - - name: Restore and format - run: | - dotnet restore Sharplink.slnx - dotnet format whitespace Sharplink.slnx --no-restore - - - name: Validate focused suites - run: | - dotnet build Sharplink.slnx --no-restore -c Release -v minimal - dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release --no-build - dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release --no-build - - - name: Commit fixes - run: | - git diff --check - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src test - git commit -m "fix: address deterministic identity review gaps" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity From 2a59a80b37a7c5f747a054f27fe73d08e5e84bfb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:27:09 +0800 Subject: [PATCH 144/399] Reject runtime-sized UnsafeBlit layouts --- .../Codec/RpcUnsafeBlitPlatform.cs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs index 695ea7fc5..d420eaf36 100644 --- a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs +++ b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs @@ -8,6 +8,12 @@ internal static class RpcUnsafeBlitPlatform internal static void EnsureSupported(Type targetType) { + ArgumentNullException.ThrowIfNull(targetType); + if (ContainsRuntimeSizedMember(targetType, new HashSet())) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains runtime-sized members and does not have a stable wire layout."); + } if (IsSupported(targetType, IntPtr.Size)) return; @@ -18,10 +24,32 @@ internal static void EnsureSupported(Type targetType) internal static bool IsSupported(Type targetType, int nativePointerSize) { ArgumentNullException.ThrowIfNull(targetType); - return nativePointerSize == SupportedNativePointerSize || - !ContainsNativeSizedMember(targetType, new HashSet()); + return !ContainsRuntimeSizedMember(targetType, new HashSet()) && + (nativePointerSize == SupportedNativePointerSize || + !ContainsNativeSizedMember(targetType, new HashSet())); } + private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) + { + if (IsRuntimeSizedIntrinsic(type)) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum) + return false; + if (!seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsRuntimeSizedMember(field.FieldType, seen)) + return true; + } + + return false; + } + + private static bool IsRuntimeSizedIntrinsic(Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); + private static bool ContainsNativeSizedMember(Type type, HashSet seen) { if (type == typeof(IntPtr) || type == typeof(UIntPtr) || type.IsPointer || type.IsFunctionPointer) From 0c9c9e61f79ceea9ca0203da9064e9d27ffce3ed Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:27:29 +0800 Subject: [PATCH 145/399] Guard manifest UnsafeBlit fallback --- src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs index 5b2b6f0ca..22265b31e 100644 --- a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs +++ b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs @@ -100,7 +100,10 @@ public IRpcCodec GetCodec() if (targetType.IsEnum) return EnumCodec.Instance; if (typeof(T).IsValueType && !RuntimeHelpers.IsReferenceOrContainsReferences()) + { + RpcUnsafeBlitPlatform.EnsureSupported(targetType); return UnsafeBlitCodec.Instance; + } throw new NotSupportedException( $"Codec for '{targetType.FullName}' is not part of the compile-time Codec graph owned by Contract assembly '{_owner.Manifest.OwnerAssembly.FullName}'."); From 052cb465e8e400a40372c449de4f859df235012f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:28:40 +0800 Subject: [PATCH 146/399] Reject runtime-sized unmanaged RPC payloads --- .../RpcGenerator.CodecPolicyOwnership.cs | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 2019761a1..cbfb311fe 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -497,6 +497,7 @@ internal DtoAnalysisPassResult AnalyzeWithFinalCodecBindings() { _ = Analyze(); PromoteSelectedFixedMembersToCodecBindings(); + RejectRuntimeSizedUnsafeBlitTypes(); NormalizeGeneratedModuleDependencies(); var finalizedCodecs = FilterFailedCodecClosure( _models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray()); @@ -506,6 +507,46 @@ internal DtoAnalysisPassResult AnalyzeWithFinalCodecBindings() _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal).ToImmutableArray()); } + private void RejectRuntimeSizedUnsafeBlitTypes() + { + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: !_contractMode, + includeContracts: _contractMode); + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + + foreach (var type in reachable.Values) + { + var typeName = GetTypeName(type); + if (_models.TryGetValue(typeName, out var selected) && + selected.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + { + continue; + } + if (!IsRuntimeSizedUnsafeBlitType(type)) + continue; + + Report( + DtoDiagnosticKind.Unsupported, + type, + "runtime-sized intrinsic unmanaged types such as System.Numerics.Vector cannot use UnsafeBlit; register an explicit typed Codec or Codec Adapter"); + _failed.Add(typeName); + } + } + + private bool IsRuntimeSizedUnsafeBlitType(ITypeSymbol type) + { + var vectorDefinition = _compilation.GetTypeByMetadataName("System.Numerics.Vector`1"); + return vectorDefinition is not null && + type is INamedTypeSymbol named && + SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, vectorDefinition); + } + internal HashSet GetCurrentContractReachableTypeNames() { var roots = new Dictionary(StringComparer.Ordinal); @@ -669,8 +710,8 @@ private void NormalizeGeneratedModuleDependencies() { AssemblyDependencies = dependencies .OrderBy(static identity => identity, StringComparer.Ordinal) - .ToImmutableArray() - }; + .ToImmutableArray(); + } } } From ce2fa44a05873c48e6db841ce556f5c6ea8b8e8b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:29:34 +0800 Subject: [PATCH 147/399] Exclude runtime-sized types from CodecHash blit identity --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 05268dc52..362910d9c 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -85,7 +85,7 @@ private RpcHashValue GetFinalCodecHash( parts.Add(GetFinalCodecHash(valueType, cache, stack).ToHex()); result = Hashing.GetSemanticHash(parts.ToArray()); } - else if (type.IsUnmanagedType) + else if (type.IsUnmanagedType && !IsRuntimeSizedUnsafeBlitType(type)) { var layout = new StringBuilder("unsafe-blit/v1"); AppendUnsafeBlitPhysicalLayout( From c2f0e7446af824313d6232b60409ea1d441be224 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:29:48 +0800 Subject: [PATCH 148/399] Merge contract-owned codecs into contract manifest --- .../RpcGenerator.ContractManifestCodecs.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.ContractManifestCodecs.cs diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifestCodecs.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifestCodecs.cs new file mode 100644 index 000000000..794e1d90f --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifestCodecs.cs @@ -0,0 +1,16 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static ImmutableArray GetContractManifestCodecs(DtoGenerationResult result) + { + var codecsByType = result.Codecs + .ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); + foreach (var codec in result.ContractCodecs) + codecsByType[codec.TypeName] = codec; + + return codecsByType.Values + .OrderBy(static codec => codec.TypeName, StringComparer.Ordinal) + .ToImmutableArray(); + } +} From 9524a3444701df4bd445c50127388040a1f96bee Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:30:35 +0800 Subject: [PATCH 149/399] Include contract-owned codecs in baseline manifest --- src/SharpLink.Generator/RpcGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 695bfcf28..ed99c5859 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -360,7 +360,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select(static (value, _) => new ContractManifestModels( value.Left.Left.Left, value.Left.Left.Right, - value.Left.Right.Codecs, + GetContractManifestCodecs(value.Left.Right), value.Left.Right.Enums, value.Right)); var contractManifestOptions = context.AnalyzerConfigOptionsProvider From 9e5704e5c084b8e943d31ac25b599e1499c6c5f4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:30:48 +0800 Subject: [PATCH 150/399] Cover runtime-sized UnsafeBlit rejection --- .../Runtime/RpcUnsafeBlitPlatformTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs index 5b3257a2e..db9bd4f45 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs @@ -18,6 +18,28 @@ public void NativeSizedUnsafeBlitShouldBe64BitOnly() "fixed-width UnsafeBlit payloads must remain valid on a 32-bit runtime"); } + [Test] + public void RuntimeSizedVectorShouldNeverUseUnsafeBlit() + { + Ensure( + !RpcUnsafeBlitPlatform.IsSupported(typeof(System.Numerics.Vector), 8), + "runtime-sized Vector must not be accepted by UnsafeBlit even on 64-bit runtimes"); + Ensure( + !RpcUnsafeBlitPlatform.IsSupported(typeof(VectorPayload), 8), + "a value type containing Vector must also be rejected by UnsafeBlit"); + + try + { + RpcUnsafeBlitPlatform.EnsureSupported(typeof(System.Numerics.Vector)); + } + catch (PlatformNotSupportedException) + { + return; + } + + throw new InvalidOperationException("Vector must fail the runtime UnsafeBlit guard."); + } + private struct NativeSizedPayload { public int Prefix { get; set; } @@ -30,6 +52,11 @@ private struct PortablePayload public long Value { get; set; } } + private struct VectorPayload + { + public System.Numerics.Vector Value { get; set; } + } + private static void Ensure(bool condition, string message) { if (!condition) From 39fac33c5e3dcc4ff4ef13488e9a126bd6660dd0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:31:33 +0800 Subject: [PATCH 151/399] Cover manifest provider UnsafeBlit guard --- .../Runtime/RpcManifestCodecProviderTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs b/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs index 71f4c6abb..96822ee53 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcManifestCodecProviderTests.cs @@ -82,6 +82,28 @@ public void ContractOwnedCodecBindingsShouldCoexistForSameClrType() "Contract-owned bindings must never be published to the global Type -> Codec registry"); } + [Test] + public void ManifestScopedProviderShouldApplyUnsafeBlitPlatformGuard() + { + var ownerAssembly = typeof(IContractA).Assembly; + using var context = new SharpLinkRuntimeContextBuilder().Build(includeGeneratedAssemblyCatalog: false); + var registration = context.PrepareGeneratedManifest( + new ContractCodecManifest(ownerAssembly, new NamedContractCodec("guard"), "unsafe-blit-guard")); + context.AdoptGeneratedManifest(registration); + var ownerProvider = RpcGeneratedCodecResolver.GetProvider(context, ownerAssembly); + + try + { + _ = ownerProvider.GetCodec>(); + } + catch (PlatformNotSupportedException) + { + return; + } + + throw new Exception("Contract-scoped Codec resolution must apply the UnsafeBlit platform guard."); + } + [Test] public void CustomRuntimeMustExposeContractCodecResolution() { From 39ab8dabdf0dbf395f47c53f8b2a16713b1ba2a7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:31:51 +0800 Subject: [PATCH 152/399] Add sixth review regression coverage --- .../RpcCodecSixthReviewRegressionTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs new file mode 100644 index 000000000..61d024e4e --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task RuntimeSizedVectorShouldRequireExplicitCodec() + { + var source = BuildSource(""" +[SharpLink.Sdk.RpcContract] +public interface IVectorContract : SharpLink.Sdk.IService +{ + ValueTask> Echo( + System.Numerics.Vector value, + CancellationToken cancellationToken); +} +"""); + + var diagnostics = RunGenerator(source); + Ensure( + diagnostics.Any(static diagnostic => + diagnostic.GetMessage().Contains("runtime-sized intrinsic unmanaged types", StringComparison.Ordinal)), + $"Vector must be rejected from the implicit UnsafeBlit path. Diagnostics: {FormatDiagnostics(diagnostics)}"); + return Task.CompletedTask; + } + + [Test] + public Task ContractOnlyCustomCodecSemanticIdentityChangeShouldBreakBaseline() + { + static string ContractSource(ulong semanticLow) => AddAssemblyAttribute( + UseCurrentIdentitySdk(BuildSource($$""" +public sealed class BaselineGraphChild +{ + public int Value { get; set; } +} + +public sealed class BaselineGraphParent +{ + public BaselineGraphChild Child { get; set; } = new(); +} + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x5001UL, {{semanticLow}}UL)] +public sealed class BaselineGraphChildCodec : SharpLink.Abstractions.IRpcCodec +{ +} + +[SharpLink.Sdk.RpcContract] +public interface IBaselineGraphContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + BaselineGraphParent value, + CancellationToken cancellationToken); +} +""")), + "[assembly: SharpLink.Sdk.RpcCodec(typeof(BaselineGraphChild), typeof(BaselineGraphChildCodec))]"); + + var baseline = RunContractGenerator(ContractSource(0x6001UL)).Json; + var baselineRoot = System.Text.Json.Nodes.JsonNode.Parse(baseline)!.AsObject(); + Ensure( + baselineRoot["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Any(static item => item["kind"]!.GetValue() == "Custom"), + "the contract-owned custom Codec must be published in the contract baseline identity graph"); + + var changed = RunContractGenerator(ContractSource(0x6002UL), baseline); + Ensure( + changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing a Contract-only custom Codec semantic identity must fail baseline comparison"); + return Task.CompletedTask; + } +} From 50bc47dc596a4990ea8668f9b27eeecc274509ff Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:32:49 +0800 Subject: [PATCH 153/399] Fix sixth review regression assertion --- .../RpcCodecSixthReviewRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs index 61d024e4e..f1edbf857 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecSixthReviewRegressionTests.cs @@ -23,7 +23,7 @@ public interface IVectorContract : SharpLink.Sdk.IService Ensure( diagnostics.Any(static diagnostic => diagnostic.GetMessage().Contains("runtime-sized intrinsic unmanaged types", StringComparison.Ordinal)), - $"Vector must be rejected from the implicit UnsafeBlit path. Diagnostics: {FormatDiagnostics(diagnostics)}"); + "Vector must be rejected from the implicit UnsafeBlit path"); return Task.CompletedTask; } From 6e3864287aa943b0027dd3d2742bf42732f8684f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:37:09 +0800 Subject: [PATCH 154/399] Fix Codec policy initializer syntax --- src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index cbfb311fe..2536c573e 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -710,8 +710,8 @@ private void NormalizeGeneratedModuleDependencies() { AssemblyDependencies = dependencies .OrderBy(static identity => identity, StringComparer.Ordinal) - .ToImmutableArray(); - } + .ToImmutableArray() + }; } } From abdc3932776b9a2ca491bed814d278eeb0a9bc0f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:58:39 +0800 Subject: [PATCH 155/399] fix: include closed adapter target schema in codec identity --- .../RpcGenerator.AdapterClosedIdentity.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs diff --git a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs new file mode 100644 index 000000000..da2dedf67 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs @@ -0,0 +1,121 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private sealed partial class DtoAnalysisState + { + private RpcHashValue GetAdapterClosedCodecSemanticIdentity(GeneratedCodecModel model) + { + if (!TryResolveReachableType(model.TypeName, out var targetType)) + { + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve adapter target '{model.TypeName}' while hashing its closed Codec semantics."); + } + + var parts = new List { "adapter-closed-target/v1" }; + AppendAdapterClosedTargetShape( + targetType, + parts, + new HashSet(SymbolEqualityComparer.Default), + depth: 0); + return Hashing.GetSemanticHash(parts.ToArray()); + } + + private void AppendAdapterClosedTargetShape( + ITypeSymbol type, + List parts, + HashSet stack, + int depth) + { + var typeName = GetTypeName(type); + parts.Add("type:" + typeName); + if (depth > MaximumDepth) + { + parts.Add("depth-limit"); + return; + } + if (!stack.Add(type)) + { + parts.Add("recursive:" + typeName); + return; + } + + try + { + AppendAttributes(type, parts, "type-attr:"); + if (type is IArrayTypeSymbol array) + { + parts.Add("array-rank:" + array.Rank.ToString(InvariantCulture)); + AppendAdapterClosedTargetShape(array.ElementType, parts, stack, depth + 1); + return; + } + if (type is not INamedTypeSymbol named) + return; + + if (named.TypeKind == TypeKind.Enum) + { + parts.Add("enum-underlying:" + GetTypeName(named.EnumUnderlyingType!)); + foreach (var field in named.GetMembers().OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add("enum:" + field.Name + "=" + + (Convert.ToString(field.ConstantValue, InvariantCulture) ?? "null")); + } + return; + } + + foreach (var argument in named.TypeArguments) + AppendAdapterClosedTargetShape(argument, parts, stack, depth + 1); + + if (named.BaseType is { SpecialType: not SpecialType.System_Object and not SpecialType.System_ValueType } baseType) + { + parts.Add("base"); + AppendAdapterClosedTargetShape(baseType, parts, stack, depth + 1); + } + + var members = named.GetMembers() + .Where(static member => !member.IsStatic && member.DeclaredAccessibility == Accessibility.Public) + .Where(static member => + member is IFieldSymbol { IsConst: false } or + IPropertySymbol { IsIndexer: false }) + .OrderBy(static member => member.Kind.ToString(), StringComparer.Ordinal) + .ThenBy(static member => member.Name, StringComparer.Ordinal) + .ThenBy(static member => member.ToDisplayString(), StringComparer.Ordinal); + foreach (var member in members) + { + var memberType = member switch + { + IFieldSymbol field => field.Type, + IPropertySymbol property => property.Type, + _ => throw new InvalidOperationException("Unexpected adapter target member kind.") + }; + parts.Add("member:" + member.Kind + ":" + member.Name + ":" + GetTypeName(memberType)); + if (member is IFieldSymbol field) + parts.Add(field.IsReadOnly ? "readonly" : "mutable"); + else if (member is IPropertySymbol property) + { + parts.Add(property.GetMethod?.DeclaredAccessibility == Accessibility.Public ? "get" : "no-get"); + parts.Add(property.SetMethod?.DeclaredAccessibility == Accessibility.Public ? "set" : "no-set"); + } + AppendAttributes(member, parts, "member-attr:"); + AppendAdapterClosedTargetShape(memberType, parts, stack, depth + 1); + } + } + finally + { + stack.Remove(type); + } + } + + private static void AppendAttributes(ISymbol symbol, List parts, string prefix) + { + foreach (var attribute in symbol.GetAttributes() + .Select(static attribute => attribute.ToString()) + .OrderBy(static value => value, StringComparer.Ordinal)) + { + parts.Add(prefix + attribute); + } + } + } +} From b285850f20ab0f358926b8a80f84b02676b514eb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:59:33 +0800 Subject: [PATCH 156/399] fix: tighten deterministic codec wire identities --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 362910d9c..fcb84ac85 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -87,7 +87,7 @@ private RpcHashValue GetFinalCodecHash( } else if (type.IsUnmanagedType && !IsRuntimeSizedUnsafeBlitType(type)) { - var layout = new StringBuilder("unsafe-blit/v1"); + var layout = new StringBuilder("unsafe-blit/v2|abi:little-endian|native-pointer-width/64"); AppendUnsafeBlitPhysicalLayout( type, layout, @@ -152,8 +152,10 @@ private RpcHashValue GetGeneratedCodecHash( case GeneratedCodecKind.Adapter: return Hashing.GetSemanticHash( "codec/v1", - "adapter-opaque", - GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter").ToHex()); + "adapter-closed/v1", + model.AdapterId ?? string.Empty, + GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter").ToHex(), + GetAdapterClosedCodecSemanticIdentity(model).ToHex()); case GeneratedCodecKind.Dto: { var parts = new List @@ -375,7 +377,7 @@ private bool TryGetFrameworkPrimitiveCodecHash( string? token = type.SpecialType switch { - SpecialType.System_String => "string/utf8/v1", + SpecialType.System_String => "string/utf16le/i32-byte-length-null-minus1/v1", SpecialType.System_Boolean => "bool/fixed1/v1", SpecialType.System_Byte => "u8/fixed1/v1", SpecialType.System_SByte => "i8/fixed1/v1", From 23217189a69d1c7ef5110e4f6a753c0ba3f169d6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:00:29 +0800 Subject: [PATCH 157/399] fix: reject nested runtime-sized unsafe layouts --- .../RpcGenerator.CodecPolicyOwnership.cs | 229 +++++------------- 1 file changed, 57 insertions(+), 172 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 2536c573e..3f405e5ff 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -236,8 +236,7 @@ private static ImmutableArray SelectOwnedContractCodecs( private static bool HasSameFinalCodecBinding(GeneratedCodecModel left, GeneratedCodecModel right) { if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - left.Kind != right.Kind || - left.IsReferenceType != right.IsReferenceType || + left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || !string.Equals(left.ValueType, right.ValueType, StringComparison.Ordinal) || @@ -297,10 +296,7 @@ private static string GetCanonicalPolicyTargetIdentity(ITypeSymbol type) => GetTypeName(type); private static bool HasSameCanonicalPolicyTarget(ITypeSymbol left, ITypeSymbol right) - => string.Equals( - GetCanonicalPolicyTargetIdentity(left), - GetCanonicalPolicyTargetIdentity(right), - StringComparison.Ordinal); + => string.Equals(GetCanonicalPolicyTargetIdentity(left), GetCanonicalPolicyTargetIdentity(right), StringComparison.Ordinal); private void CollectCanonicalAssemblyCustomCodecBindings() { @@ -319,20 +315,16 @@ attribute.ConstructorArguments[0].Value is not ITypeSymbol target || } if (HasTypeParameter(target)) { - Report(DtoDiagnosticKind.CustomCodecTargetInvalid, target, - "custom Codec target must be a closed type", location); + Report(DtoDiagnosticKind.CustomCodecTargetInvalid, target, "custom Codec target must be a closed type", location); continue; } - target = NormalizeAdapterTarget(target); if (IsFrameworkWirePrimitive(target)) { Report(DtoDiagnosticKind.BuiltinCustomCodecOverride, target, - "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", - location); + "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", location); continue; } - AddCanonicalCustomCodecBinding(target, codec, location); } } @@ -347,46 +339,32 @@ private void AddCanonicalCustomCodecBinding(ITypeSymbol target, ITypeSymbol code "the target is explicitly bound to multiple custom Codec implementations", location); return; } - var registration = ValidateCustomCodecWithCanonicalTarget(codec, target, location); if (registration is null) return; - _customCodecBindings[target] = registration; _canonicalCustomCodecBindings[identity] = registration; if (_contractMode) _contractOwnedPolicyRoots.Add(identity); } - private CustomCodecRegistration? ValidateCustomCodecWithCanonicalTarget( - ITypeSymbol codecType, - ITypeSymbol targetType, - Location location) + private CustomCodecRegistration? ValidateCustomCodecWithCanonicalTarget(ITypeSymbol codecType, ITypeSymbol targetType, Location location) { if (codecType is not INamedTypeSymbol named) { - Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, - "custom Codec must be a closed, public sealed type", location); + Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, "custom Codec must be a closed, public sealed type", location); return null; } - - if (HasTypeParameter(named) || - !IsEffectivelyPublic(named) || - !named.IsSealed || - !named.InstanceConstructors.Any(static constructor => - constructor.DeclaredAccessibility == Accessibility.Public && - constructor.Parameters.Length == 0)) + if (HasTypeParameter(named) || !IsEffectivelyPublic(named) || !named.IsSealed || + !named.InstanceConstructors.Any(static constructor => constructor.DeclaredAccessibility == Accessibility.Public && constructor.Parameters.Length == 0)) { Report(DtoDiagnosticKind.CustomCodecTypeInvalid, codecType, "custom Codec must be a public sealed type with a public parameterless constructor", location); return null; } - var implementsTargetCodec = named.AllInterfaces.Any(item => - item.Name == "IRpcCodec" && - item.ContainingNamespace.ToDisplayString() == "SharpLink.Abstractions" && - item is INamedTypeSymbol { IsGenericType: true } generic && - generic.TypeArguments.Length == 1 && + item.Name == "IRpcCodec" && item.ContainingNamespace.ToDisplayString() == "SharpLink.Abstractions" && + item is INamedTypeSymbol { IsGenericType: true } generic && generic.TypeArguments.Length == 1 && HasSameCanonicalPolicyTarget(generic.TypeArguments[0], targetType)); if (!implementsTargetCodec) { @@ -394,14 +372,12 @@ private void AddCanonicalCustomCodecBinding(ITypeSymbol target, ITypeSymbol code $"custom Codec must implement IRpcCodec<{GetTypeName(targetType)}>", location); return null; } - if (!HasValidOpaqueSemanticIdentity(named)) { Report(DtoDiagnosticKind.CustomCodecIdentityInvalid, codecType, "custom Codec must declare a non-zero fixed semantic identity via [RpcCodecSemanticIdentity(high, low)]", location); return null; } - return new CustomCodecRegistration(named, location); } @@ -411,8 +387,7 @@ private void CollectCanonicalAssemblyBindings() .Where(static attribute => IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecAdapterAttribute"))) { var location = attribute.ApplicationSyntaxReference?.GetSyntax(_cancellationToken).GetLocation() ?? Location.None; - if (attribute.ConstructorArguments.Length != 2 || - attribute.ConstructorArguments[0].Value is not ITypeSymbol target || + if (attribute.ConstructorArguments.Length != 2 || attribute.ConstructorArguments[0].Value is not ITypeSymbol target || attribute.ConstructorArguments[1].Value is not INamedTypeSymbol adapter) { Report(DtoDiagnosticKind.AdapterBindingInvalid, _compilation.Assembly, @@ -421,20 +396,16 @@ attribute.ConstructorArguments[0].Value is not ITypeSymbol target || } if (HasTypeParameter(target)) { - Report(DtoDiagnosticKind.AdapterTargetInvalid, target, - "Adapter target must be a closed type", location); + Report(DtoDiagnosticKind.AdapterTargetInvalid, target, "Adapter target must be a closed type", location); continue; } - target = NormalizeAdapterTarget(target); if (IsFrameworkWirePrimitive(target)) { Report(DtoDiagnosticKind.BuiltinAdapterOverride, target, - "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", - location); + "SharpLink framework wire primitive types have fixed wire semantics and cannot be rebound; wrap the value in a user-defined payload type if a custom wire representation is required", location); continue; } - AddCanonicalAssemblyBinding(target, new ExplicitBindingCandidate(adapter, location)); } } @@ -447,15 +418,12 @@ private void AddCanonicalAssemblyBinding(ITypeSymbol target, ExplicitBindingCand if (!SymbolEqualityComparer.Default.Equals(existing.ImplementationType, candidate.ImplementationType)) { Report(DtoDiagnosticKind.AdapterSelectionConflict, target, - "the target is explicitly bound to multiple different Codec Adapters", - candidate.Location); + "the target is explicitly bound to multiple different Codec Adapters", candidate.Location); return; } - _assemblyBindings[target] = existing; return; } - _assemblyBindings[target] = candidate; _canonicalAssemblyBindings[identity] = candidate; } @@ -464,32 +432,20 @@ private void AddCanonicalPolicyBindingAliases() { if (_canonicalAssemblyBindings.Count == 0 && _canonicalCustomCodecBindings.Count == 0) return; - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); + CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: !_contractMode, includeContracts: _contractMode); var reachable = new Dictionary(StringComparer.Ordinal); var seen = new HashSet(SymbolEqualityComparer.Default); foreach (var root in roots.Values) CollectFinalBindingTypes(root, reachable, seen, 0); - foreach (var reachableType in reachable.Values) { var lookupType = NormalizeAdapterTarget(reachableType); var identity = GetCanonicalPolicyTargetIdentity(lookupType); - if (!_assemblyBindings.ContainsKey(lookupType) && - _canonicalAssemblyBindings.TryGetValue(identity, out var adapterBinding)) - { + if (!_assemblyBindings.ContainsKey(lookupType) && _canonicalAssemblyBindings.TryGetValue(identity, out var adapterBinding)) _assemblyBindings[lookupType] = adapterBinding; - } - if (!_customCodecBindings.ContainsKey(lookupType) && - _canonicalCustomCodecBindings.TryGetValue(identity, out var customBinding)) - { + if (!_customCodecBindings.ContainsKey(lookupType) && _canonicalCustomCodecBindings.TryGetValue(identity, out var customBinding)) _customCodecBindings[lookupType] = customBinding; - } } } @@ -499,62 +455,57 @@ internal DtoAnalysisPassResult AnalyzeWithFinalCodecBindings() PromoteSelectedFixedMembersToCodecBindings(); RejectRuntimeSizedUnsafeBlitTypes(); NormalizeGeneratedModuleDependencies(); - var finalizedCodecs = FilterFailedCodecClosure( - _models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray()); - return new DtoAnalysisPassResult( - finalizedCodecs, - _diagnostics.ToImmutableArray(), + var finalizedCodecs = FilterFailedCodecClosure(_models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray()); + return new DtoAnalysisPassResult(finalizedCodecs, _diagnostics.ToImmutableArray(), _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal).ToImmutableArray()); } private void RejectRuntimeSizedUnsafeBlitTypes() { var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); + CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: !_contractMode, includeContracts: _contractMode); var reachable = new Dictionary(StringComparer.Ordinal); var seen = new HashSet(SymbolEqualityComparer.Default); foreach (var root in roots.Values) CollectFinalBindingTypes(root, reachable, seen, 0); - foreach (var type in reachable.Values) { var typeName = GetTypeName(type); - if (_models.TryGetValue(typeName, out var selected) && - selected.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) - { + if (_models.TryGetValue(typeName, out var selected) && selected.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) continue; - } - if (!IsRuntimeSizedUnsafeBlitType(type)) + if (!type.IsUnmanagedType || !IsRuntimeSizedUnsafeBlitType(type)) continue; - - Report( - DtoDiagnosticKind.Unsupported, - type, + Report(DtoDiagnosticKind.Unsupported, type, "runtime-sized intrinsic unmanaged types such as System.Numerics.Vector cannot use UnsafeBlit; register an explicit typed Codec or Codec Adapter"); _failed.Add(typeName); } } private bool IsRuntimeSizedUnsafeBlitType(ITypeSymbol type) + => IsRuntimeSizedUnsafeBlitType(type, new HashSet(SymbolEqualityComparer.Default)); + + private bool IsRuntimeSizedUnsafeBlitType(ITypeSymbol type, HashSet seen) { var vectorDefinition = _compilation.GetTypeByMetadataName("System.Numerics.Vector`1"); - return vectorDefinition is not null && - type is INamedTypeSymbol named && - SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, vectorDefinition); + if (vectorDefinition is not null && type is INamedTypeSymbol vector && + SymbolEqualityComparer.Default.Equals(vector.OriginalDefinition, vectorDefinition)) + { + return true; + } + if (!type.IsUnmanagedType || type is not INamedTypeSymbol named || !seen.Add(type)) + return false; + foreach (var field in named.GetMembers().OfType().Where(static field => !field.IsStatic && !field.IsConst)) + { + if (IsRuntimeSizedUnsafeBlitType(field.Type, seen)) + return true; + } + return false; } internal HashSet GetCurrentContractReachableTypeNames() { var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: false, - includeContracts: true); + CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: false, includeContracts: true); var reachable = new Dictionary(StringComparer.Ordinal); var seen = new HashSet(SymbolEqualityComparer.Default); foreach (var root in roots.Values) @@ -566,29 +517,18 @@ private void PromoteSelectedFixedMembersToCodecBindings() { if (!_applyCodecPolicy || _models.Count == 0) return; - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); - + CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: !_contractMode, includeContracts: _contractMode); var reachable = new Dictionary(StringComparer.Ordinal); var seen = new HashSet(SymbolEqualityComparer.Default); foreach (var root in roots.Values) CollectFinalBindingTypes(root, reachable, seen, 0); - - var dtoModels = _models.Values - .Where(static model => model.Kind == GeneratedCodecKind.Dto) - .ToArray(); + var dtoModels = _models.Values.Where(static model => model.Kind == GeneratedCodecKind.Dto).ToArray(); foreach (var model in dtoModels) { if (!reachable.TryGetValue(model.TypeName, out var type) || type is not INamedTypeSymbol named) continue; - - var memberSymbols = GetSerializableMembers(named) - .ToDictionary(static member => member.Name, StringComparer.Ordinal); + var memberSymbols = GetSerializableMembers(named).ToDictionary(static member => member.Name, StringComparer.Ordinal); var members = model.Members.ToArray(); var changed = false; for (var index = 0; index < members.Length; index++) @@ -596,42 +536,25 @@ private void PromoteSelectedFixedMembersToCodecBindings() var member = members[index]; if (member.Kind is not (GeneratedMemberKind.Fixed or GeneratedMemberKind.NullableFixed or GeneratedMemberKind.String) || !memberSymbols.TryGetValue(member.Name, out var memberSymbol)) - { continue; - } - var memberType = GetMemberType(memberSymbol); if (!HasSelectedMemberCodec(memberType)) continue; - Visit(memberType, [], 0); - members[index] = member with - { - Kind = GeneratedMemberKind.Complex, - FixedTypeName = null, - FixedSize = 0, - EnumUnderlyingType = null - }; + members[index] = member with { Kind = GeneratedMemberKind.Complex, FixedTypeName = null, FixedSize = 0, EnumUnderlyingType = null }; changed = true; } - if (!changed) continue; - var finalizedMembers = members.ToImmutableArray(); var schema = new StringBuilder(model.TypeName); foreach (var member in finalizedMembers) { - schema.Append('|').Append(member.FieldId).Append(':').Append(member.TypeName) - .Append(':').Append(member.Kind).Append(':').Append(member.Required); + schema.Append('|').Append(member.FieldId).Append(':').Append(member.TypeName).Append(':').Append(member.Kind).Append(':').Append(member.Required); if (member.Nullable) schema.Append(":nullable"); } - _models[model.TypeName] = model with - { - Members = finalizedMembers, - SchemaId = GetSchemaId(model.TypeName, schema.ToString()) - }; + _models[model.TypeName] = model with { Members = finalizedMembers, SchemaId = GetSchemaId(model.TypeName, schema.ToString()) }; } } @@ -639,7 +562,6 @@ private bool HasSelectedCompositeCodecDependency(ITypeSymbol type) { if (!TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) return false; - return (elementType is not null && HasSelectedMemberCodec(elementType)) || (keyType is not null && HasSelectedMemberCodec(keyType)) || (valueType is not null && HasSelectedMemberCodec(valueType)); @@ -651,11 +573,8 @@ private bool HasSelectedMemberCodec(ITypeSymbol memberType) return false; if (TrySelectCustomCodec(memberType, out var customCodec)) return customCodec is not null; - AdapterRegistration? selected = null; - var hasSelection = _contractMode - ? TrySelectContractCodecOverride(memberType, out selected) - : TrySelectAdapter(memberType, out selected); + var hasSelection = _contractMode ? TrySelectContractCodecOverride(memberType, out selected) : TrySelectAdapter(memberType, out selected); return hasSelection && selected is not null; } @@ -663,74 +582,44 @@ private void NormalizeGeneratedModuleDependencies() { if (_models.Count == 0) return; - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); + CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: !_contractMode, includeContracts: _contractMode); var symbolsByType = new Dictionary(StringComparer.Ordinal); var seen = new HashSet(SymbolEqualityComparer.Default); foreach (var root in roots.Values) CollectFinalBindingTypes(root, symbolsByType, seen, 0); - var localFactoryTypes = new HashSet(_models.Keys, StringComparer.Ordinal); foreach (var model in _models.Values.ToArray()) { if (model.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) { - _models[model.TypeName] = model with - { - AssemblyDependencies = ImmutableArray.Empty - }; + _models[model.TypeName] = model with { AssemblyDependencies = ImmutableArray.Empty }; continue; } - var dependencies = new HashSet(StringComparer.Ordinal); foreach (var dependencyTypeName in GetCodecDependencies(model)) { - if (localFactoryTypes.Contains(dependencyTypeName) || - !symbolsByType.TryGetValue(dependencyTypeName, out var dependencyType) || - IsBuiltin(dependencyType)) - { + if (localFactoryTypes.Contains(dependencyTypeName) || !symbolsByType.TryGetValue(dependencyTypeName, out var dependencyType) || IsBuiltin(dependencyType)) continue; - } - var assembly = dependencyType.ContainingAssembly; - if (assembly is not null && - !SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly) && - HasGeneratedAssemblyManifest(assembly)) - { + if (assembly is not null && !SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly) && HasGeneratedAssemblyManifest(assembly)) dependencies.Add(assembly.Identity.ToString()); - } } - _models[model.TypeName] = model with { - AssemblyDependencies = dependencies - .OrderBy(static identity => identity, StringComparer.Ordinal) - .ToImmutableArray() + AssemblyDependencies = dependencies.OrderBy(static identity => identity, StringComparer.Ordinal).ToImmutableArray() }; } } - private void CollectFinalBindingTypes( - ITypeSymbol type, - Dictionary reachable, - HashSet seen, - int depth) + private void CollectFinalBindingTypes(ITypeSymbol type, Dictionary reachable, HashSet seen, int depth) { if (depth > MaximumDepth || !seen.Add(type)) return; var typeName = GetTypeName(type); reachable[typeName] = type; - if (_models.TryGetValue(typeName, out var finalModel) && - finalModel.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) - { + if (_models.TryGetValue(typeName, out var finalModel) && finalModel.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) return; - } - if (type is IArrayTypeSymbol array) { CollectFinalBindingTypes(array.ElementType, reachable, seen, depth + 1); @@ -738,17 +627,13 @@ private void CollectFinalBindingTypes( } if (TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) { - if (elementType is not null) - CollectFinalBindingTypes(elementType, reachable, seen, depth + 1); - if (keyType is not null) - CollectFinalBindingTypes(keyType, reachable, seen, depth + 1); - if (valueType is not null) - CollectFinalBindingTypes(valueType, reachable, seen, depth + 1); + if (elementType is not null) CollectFinalBindingTypes(elementType, reachable, seen, depth + 1); + if (keyType is not null) CollectFinalBindingTypes(keyType, reachable, seen, depth + 1); + if (valueType is not null) CollectFinalBindingTypes(valueType, reachable, seen, depth + 1); return; } if (type is not INamedTypeSymbol named || IsThirdPartyType(type)) return; - foreach (var member in GetSerializableMembers(named)) CollectFinalBindingTypes(GetMemberType(member), reachable, seen, depth + 1); } From 54132b00305b75647d1c081801a1dbf5a3ece2c4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:00:45 +0800 Subject: [PATCH 158/399] fix: restrict unsafe blit to stable 64-bit ABI --- .../Codec/RpcUnsafeBlitPlatform.cs | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs index d420eaf36..37874608c 100644 --- a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs +++ b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs @@ -14,19 +14,18 @@ internal static void EnsureSupported(Type targetType) throw new PlatformNotSupportedException( $"UnsafeBlit Codec for '{targetType.FullName}' contains runtime-sized members and does not have a stable wire layout."); } - if (IsSupported(targetType, IntPtr.Size)) + if (IntPtr.Size == SupportedNativePointerSize) return; throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' contains native-sized members and requires a 64-bit process."); + $"UnsafeBlit Codec for '{targetType.FullName}' requires the SharpLink 64-bit wire ABI."); } internal static bool IsSupported(Type targetType, int nativePointerSize) { ArgumentNullException.ThrowIfNull(targetType); - return !ContainsRuntimeSizedMember(targetType, new HashSet()) && - (nativePointerSize == SupportedNativePointerSize || - !ContainsNativeSizedMember(targetType, new HashSet())); + return nativePointerSize == SupportedNativePointerSize && + !ContainsRuntimeSizedMember(targetType, new HashSet()); } private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) @@ -49,22 +48,4 @@ private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) private static bool IsRuntimeSizedIntrinsic(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); - - private static bool ContainsNativeSizedMember(Type type, HashSet seen) - { - if (type == typeof(IntPtr) || type == typeof(UIntPtr) || type.IsPointer || type.IsFunctionPointer) - return true; - if (!type.IsValueType || type.IsPrimitive || type.IsEnum) - return false; - if (!seen.Add(type)) - return false; - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (ContainsNativeSizedMember(field.FieldType, seen)) - return true; - } - - return false; - } } From 9af64ccaa64e7be3ab3639c5734682ee83f79291 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:00:56 +0800 Subject: [PATCH 159/399] fix: reject non-little-endian runtime wire platform --- .../Codec/RpcWirePlatform.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/SharpLink.Runtime/Codec/RpcWirePlatform.cs diff --git a/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs b/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs new file mode 100644 index 000000000..31151a2d4 --- /dev/null +++ b/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs @@ -0,0 +1,20 @@ +namespace SharpLink.Runtime; + +internal static class RpcWirePlatform +{ + [System.Runtime.CompilerServices.ModuleInitializer] + internal static void Initialize() + => EnsureSupported(BitConverter.IsLittleEndian); + + internal static bool IsSupported(bool isLittleEndian) + => isLittleEndian; + + internal static void EnsureSupported(bool isLittleEndian) + { + if (isLittleEndian) + return; + + throw new PlatformNotSupportedException( + "SharpLink RPC wire codecs require a little-endian runtime."); + } +} From 2d02c0b977b89d90b2d1b9d764c524f5826dae6c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:01:09 +0800 Subject: [PATCH 160/399] test: cover unsafe blit ABI and endian guards --- .../Runtime/RpcUnsafeBlitPlatformTests.cs | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs index db9bd4f45..5eb94c3ac 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs @@ -5,7 +5,7 @@ namespace SharpLink.UnitTests.Runtime; public sealed class RpcUnsafeBlitPlatformTests { [Test] - public void NativeSizedUnsafeBlitShouldBe64BitOnly() + public void UnsafeBlitShouldBe64BitOnly() { Ensure( RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 8), @@ -14,8 +14,11 @@ public void NativeSizedUnsafeBlitShouldBe64BitOnly() !RpcUnsafeBlitPlatform.IsSupported(typeof(NativeSizedPayload), 4), "native-sized UnsafeBlit payloads must be rejected by a 32-bit runtime"); Ensure( - RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 4), - "fixed-width UnsafeBlit payloads must remain valid on a 32-bit runtime"); + RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 8), + "fixed-width composite UnsafeBlit payloads must remain valid on the supported 64-bit ABI"); + Ensure( + !RpcUnsafeBlitPlatform.IsSupported(typeof(PortablePayload), 4), + "fixed-width composite UnsafeBlit payloads must also reject 32-bit runtimes because CLR padding/alignment is ABI-dependent"); } [Test] @@ -40,6 +43,26 @@ public void RuntimeSizedVectorShouldNeverUseUnsafeBlit() throw new InvalidOperationException("Vector must fail the runtime UnsafeBlit guard."); } + [Test] + public void WirePlatformShouldRequireLittleEndian() + { + Ensure(RpcWirePlatform.IsSupported(isLittleEndian: true), + "little-endian runtimes define the supported SharpLink primitive wire ABI"); + Ensure(!RpcWirePlatform.IsSupported(isLittleEndian: false), + "big-endian runtimes must not advertise native-memory primitive Codec identities"); + + try + { + RpcWirePlatform.EnsureSupported(isLittleEndian: false); + } + catch (PlatformNotSupportedException) + { + return; + } + + throw new InvalidOperationException("Big-endian runtime simulation must fail the SharpLink wire platform guard."); + } + private struct NativeSizedPayload { public int Prefix { get; set; } @@ -48,7 +71,7 @@ private struct NativeSizedPayload private struct PortablePayload { - public int Prefix { get; set; } + public byte Prefix { get; set; } public long Value { get; set; } } From b7a40354296033699e23356c389e5de52aeb7572 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:01:40 +0800 Subject: [PATCH 161/399] test: cover seventh deterministic identity review cases --- .../RpcCodecSeventhReviewRegressionTests.cs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs new file mode 100644 index 000000000..68459234b --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task NestedRuntimeSizedVectorShouldRequireExplicitCodec() + { + var source = BuildSource(""" +public struct VectorWrapper +{ + private System.Numerics.Vector _value; +} + +[SharpLink.Sdk.RpcContract] +public interface IVectorWrapperContract : SharpLink.Sdk.IService +{ + ValueTask Echo(VectorWrapper value, CancellationToken cancellationToken); +} +"""); + + var diagnostics = RunGenerator(source); + Ensure( + diagnostics.Any(static diagnostic => + diagnostic.GetMessage().Contains("runtime-sized intrinsic unmanaged types", StringComparison.Ordinal)), + $"an UnsafeBlit wrapper containing a private Vector field must be rejected. Diagnostics: {FormatDiagnostics(diagnostics)}"); + return Task.CompletedTask; + } + + [Test] + public Task AdapterOwnedWireVisibleMemberChangeShouldChangeClosedCodecIdentity() + { + static string Source(bool includeExtraMember) + { + var extraMember = includeExtraMember ? "public long Extra { get; set; }" : string.Empty; + return AddAssemblyAttribute(BuildSource($$""" +[FakePackable] +public sealed class AdapterPayload +{ + public int Value { get; set; } + {{extraMember}} +} + +[SharpLink.Sdk.RpcContract] +public interface IAdapterIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(AdapterPayload value, CancellationToken cancellationToken); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class FakePackableAttribute : Attribute { } + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1010101010101010UL, 0x2020202020202020UL)] +public sealed class StableAdapter : SharpLink.Abstractions.IRpcCodecAdapter +{ + public string AdapterId => "stable-adapter/v1"; + public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); +} +"""), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(StableAdapter), \"stable-adapter/v1\", SelectorAttributeType = typeof(FakePackableAttribute))]"); + } + + var baseline = RunGeneratorAndGetSources(Source(includeExtraMember: false)) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + var changed = RunGeneratorAndGetSources(Source(includeExtraMember: true)) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + + Ensure( + ExtractGeneratedCodecIdentity(baseline, "AdapterPayload") != + ExtractGeneratedCodecIdentity(changed, "AdapterPayload"), + "wire-visible target schema changes must change the closed Adapter CodecHash even when Adapter identity is unchanged"); + Ensure( + ExtractGeneratedRpcAssemblyHash(baseline) != ExtractGeneratedRpcAssemblyHash(changed), + "closed Adapter target schema changes must propagate into RpcAssemblyHash"); + return Task.CompletedTask; + } + + [Test] + public Task RootStringCodecIdentityShouldNotReuseDtoUtf8LeafIdentity() + { + var rootStringSource = BuildSource(""" +[SharpLink.Sdk.RpcContract] +public interface IRootStringContract : SharpLink.Sdk.IService +{ + ValueTask Echo(string value, CancellationToken cancellationToken); +} +"""); + var dtoStringSource = BuildSource(""" +[SharpLink.Sdk.RpcSerializable] +public sealed class StringEnvelope +{ + public string Value { get; set; } = string.Empty; +} + +[SharpLink.Sdk.RpcContract] +public interface IDtoStringContract : SharpLink.Sdk.IService +{ + ValueTask Echo(StringEnvelope value, CancellationToken cancellationToken); +} +"""); + + var rootManifest = RunGeneratorAndGetSources(rootStringSource) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + var dtoManifest = RunGeneratorAndGetSources(dtoStringSource) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + + var rootStringIdentity = rootManifest.Split('\n') + .Single(static line => + line.Contains("SharpLinkGeneratedCodecIdentityAttribute", StringComparison.Ordinal) && + (line.Contains("typeof(string)", StringComparison.Ordinal) || + line.Contains("typeof(global::System.String)", StringComparison.Ordinal))) + .Trim(); + var dtoIdentity = ExtractGeneratedCodecIdentity(dtoManifest, "StringEnvelope"); + + Ensure(!string.IsNullOrWhiteSpace(rootStringIdentity), + "root string must publish the framework StringCodec identity"); + Ensure(rootStringIdentity != dtoIdentity, + "root StringCodec identity and generated DTO UTF-8 string-field semantics must remain distinct"); + return Task.CompletedTask; + } +} From f2c177597a2cd778775a64bcf32de28c1616db63 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:04:19 +0800 Subject: [PATCH 162/399] fix: compile closed adapter identity helper --- .../RpcGenerator.AdapterClosedIdentity.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs index da2dedf67..0c46316c2 100644 --- a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs @@ -55,12 +55,12 @@ private void AppendAdapterClosedTargetShape( if (named.TypeKind == TypeKind.Enum) { parts.Add("enum-underlying:" + GetTypeName(named.EnumUnderlyingType!)); - foreach (var field in named.GetMembers().OfType() + foreach (var enumField in named.GetMembers().OfType() .Where(static field => field.HasConstantValue) .OrderBy(static field => field.Name, StringComparer.Ordinal)) { - parts.Add("enum:" + field.Name + "=" + - (Convert.ToString(field.ConstantValue, InvariantCulture) ?? "null")); + parts.Add("enum:" + enumField.Name + "=" + + (Convert.ToString(enumField.ConstantValue, InvariantCulture) ?? "null")); } return; } @@ -86,17 +86,17 @@ private void AppendAdapterClosedTargetShape( { var memberType = member switch { - IFieldSymbol field => field.Type, - IPropertySymbol property => property.Type, + IFieldSymbol memberField => memberField.Type, + IPropertySymbol memberProperty => memberProperty.Type, _ => throw new InvalidOperationException("Unexpected adapter target member kind.") }; parts.Add("member:" + member.Kind + ":" + member.Name + ":" + GetTypeName(memberType)); - if (member is IFieldSymbol field) - parts.Add(field.IsReadOnly ? "readonly" : "mutable"); - else if (member is IPropertySymbol property) + if (member is IFieldSymbol memberField) + parts.Add(memberField.IsReadOnly ? "readonly" : "mutable"); + else if (member is IPropertySymbol memberProperty) { - parts.Add(property.GetMethod?.DeclaredAccessibility == Accessibility.Public ? "get" : "no-get"); - parts.Add(property.SetMethod?.DeclaredAccessibility == Accessibility.Public ? "set" : "no-set"); + parts.Add(memberProperty.GetMethod?.DeclaredAccessibility == Accessibility.Public ? "get" : "no-get"); + parts.Add(memberProperty.SetMethod?.DeclaredAccessibility == Accessibility.Public ? "set" : "no-set"); } AppendAttributes(member, parts, "member-attr:"); AppendAdapterClosedTargetShape(memberType, parts, stack, depth + 1); From 739e10ccec526bce8237feeff2221f54c486b39d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:04:25 +0800 Subject: [PATCH 163/399] fix: move endian guard to runtime context initialization --- src/SharpLink.Runtime/Codec/RpcWirePlatform.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs b/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs index 31151a2d4..b03688fc3 100644 --- a/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs +++ b/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs @@ -2,10 +2,6 @@ namespace SharpLink.Runtime; internal static class RpcWirePlatform { - [System.Runtime.CompilerServices.ModuleInitializer] - internal static void Initialize() - => EnsureSupported(BitConverter.IsLittleEndian); - internal static bool IsSupported(bool isLittleEndian) => isLittleEndian; From ade9f07ecd2530cfdb01912ae3a6b966d128b651 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:08:05 +0800 Subject: [PATCH 164/399] fix: canonicalize root string codec as UTF-8 --- src/SharpLink.Runtime/Codec/StringCodec.cs | 96 +++++++++++++--------- 1 file changed, 58 insertions(+), 38 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/StringCodec.cs b/src/SharpLink.Runtime/Codec/StringCodec.cs index 090b8d481..da275e6bf 100644 --- a/src/SharpLink.Runtime/Codec/StringCodec.cs +++ b/src/SharpLink.Runtime/Codec/StringCodec.cs @@ -1,70 +1,90 @@ +using System.Text; + namespace SharpLink.Runtime; internal sealed class StringCodec : IRpcCodec { + private const uint NullLength = uint.MaxValue; + private static readonly UTF8Encoding StrictEncoding = new(false, true); + internal static readonly StringCodec Instance = new(); - private const int CharSize = 2; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Serialize(in string? value, IBufferWriter writer) { + ArgumentNullException.ThrowIfNull(writer); if (value is null) { - CodecHelpers.WriteInt32(writer, -1); - return; - } - - if (value.Length == 0) - { - CodecHelpers.WriteInt32(writer, 0); + var nullHeader = writer.GetSpan(sizeof(uint)); + BinaryPrimitives.WriteUInt32LittleEndian(nullHeader, NullLength); + writer.Advance(sizeof(uint)); return; } - var bytesCount = checked(value.Length * CharSize); - CodecHelpers.EnsureSerializablePayloadLength(bytesCount, nameof(value)); - - var span = writer.GetSpan(bytesCount + 4); - - BinaryPrimitives.WriteInt32LittleEndian(span[..4], bytesCount); - - value.AsSpan().CopyTo(MemoryMarshal.Cast(span[4..])); + var byteCount = StrictEncoding.GetByteCount(value); + CodecHelpers.EnsureSerializablePayloadLength(byteCount, nameof(value)); - writer.Advance(bytesCount + 4); + var span = writer.GetSpan(checked(sizeof(uint) + byteCount)); + BinaryPrimitives.WriteUInt32LittleEndian(span, checked((uint)byteCount)); + if (byteCount != 0) + _ = StrictEncoding.GetBytes(value, span[sizeof(uint)..]); + writer.Advance(checked(sizeof(uint) + byteCount)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string? Deserialize(in ReadOnlySequence buffer) { - var bytesCount = CodecHelpers.ReadInt32(buffer); - if (bytesCount == -1) + CodecHelpers.EnsureAvailable(buffer, sizeof(uint)); + + uint byteCount; + if (buffer.FirstSpan.Length >= sizeof(uint)) { - CodecHelpers.EnsureExactSize(buffer, sizeof(int)); - return null; + byteCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.FirstSpan); + } + else + { + Span header = stackalloc byte[sizeof(uint)]; + buffer.Slice(0, sizeof(uint)).CopyTo(header); + byteCount = BinaryPrimitives.ReadUInt32LittleEndian(header); } - if (bytesCount < -1) - throw new SharpLinkException(SharpLinkErrorCode.DataLoss, $"Invalid string byte length {bytesCount}."); - if (bytesCount == 0) + if (byteCount == NullLength) { - CodecHelpers.EnsureExactSize(buffer, sizeof(int)); - return string.Empty; + CodecHelpers.EnsureExactSize(buffer, sizeof(uint)); + return null; } - if ((bytesCount & 1) != 0) - throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "UTF-16 string byte length must be even."); - if (bytesCount > SharpLinkProtocolOptions.MaxMaxFramePayloadBytes - sizeof(int)) + if (byteCount > SharpLinkProtocolOptions.MaxMaxFramePayloadBytes - sizeof(uint)) throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "String payload exceeds the protocol maximum."); - CodecHelpers.EnsureExactSize(buffer, (long)sizeof(int) + bytesCount); - var payload = buffer.Slice(sizeof(int), bytesCount); + var payloadLength = checked((int)byteCount); + CodecHelpers.EnsureExactSize(buffer, (long)sizeof(uint) + payloadLength); + if (payloadLength == 0) + return string.Empty; - if (payload.FirstSpan.Length >= bytesCount) + var payload = buffer.Slice(sizeof(uint), payloadLength); + try { - var charSpan = MemoryMarshal.Cast(payload.FirstSpan[..bytesCount]); - return new string(charSpan); - } + if (payload.FirstSpan.Length >= payloadLength) + return StrictEncoding.GetString(payload.FirstSpan[..payloadLength]); - return string.Create(bytesCount / CharSize, payload, static (destination, sequence) => + var rented = ArrayPool.Shared.Rent(payloadLength); + try + { + var bytes = rented.AsSpan(0, payloadLength); + payload.CopyTo(bytes); + return StrictEncoding.GetString(bytes); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + catch (DecoderFallbackException exception) { - sequence.CopyTo(MemoryMarshal.AsBytes(destination)); - }); + throw new SharpLinkException( + SharpLinkErrorCode.DataLoss, + "String payload is not valid UTF-8.", + exception); + } } } From d23d7f43ac2b0f593c5934c86cb060b8d3bf1663 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:08:49 +0800 Subject: [PATCH 165/399] fix: enforce little-endian wire platform at module load --- src/SharpLink.Runtime/Codec/RpcWirePlatform.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs b/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs index b03688fc3..14b1e1290 100644 --- a/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs +++ b/src/SharpLink.Runtime/Codec/RpcWirePlatform.cs @@ -2,6 +2,12 @@ namespace SharpLink.Runtime; internal static class RpcWirePlatform { +#pragma warning disable CA2255 // Intentional process-wide wire ABI guard for this runtime library. + [System.Runtime.CompilerServices.ModuleInitializer] + internal static void Initialize() + => EnsureSupported(BitConverter.IsLittleEndian); +#pragma warning restore CA2255 + internal static bool IsSupported(bool isLittleEndian) => isLittleEndian; From bdf528a4aeb6e5eac98fb7f6ca5760f100365de5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:09:32 +0800 Subject: [PATCH 166/399] fix: align deterministic string identity with UTF-8 framing --- .../RpcGenerator.CodecIdentity.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index fcb84ac85..2a990a670 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -174,7 +174,8 @@ private RpcHashValue GetGeneratedCodecHash( switch (member.Kind) { case GeneratedMemberKind.String: - parts.Add("string/utf8/v1"); + parts.Add("string/content/utf8/u32le-byte-length/v1"); + parts.Add("string/null/dto-wire-null/v1"); break; case GeneratedMemberKind.Fixed: case GeneratedMemberKind.NullableFixed: @@ -375,9 +376,18 @@ private bool TryGetFrameworkPrimitiveCodecHash( return true; } + if (type.SpecialType == SpecialType.System_String) + { + hash = Hashing.GetSemanticHash( + "codec/v1", + "framework", + "string/content/utf8/u32le-byte-length/v1", + "string/null/u32-max/v1"); + return true; + } + string? token = type.SpecialType switch { - SpecialType.System_String => "string/utf16le/i32-byte-length-null-minus1/v1", SpecialType.System_Boolean => "bool/fixed1/v1", SpecialType.System_Byte => "u8/fixed1/v1", SpecialType.System_SByte => "i8/fixed1/v1", From e1304026e0cd4d21eb69de5a24fa30dd6e81951f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:09:51 +0800 Subject: [PATCH 167/399] test: cover canonical UTF-8 root string framing --- .../Runtime/RpcStringCodecTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs diff --git a/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs new file mode 100644 index 000000000..c1a670b59 --- /dev/null +++ b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs @@ -0,0 +1,79 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Text; +using SharpLink.Runtime; + +namespace SharpLink.UnitTests.Runtime; + +public sealed class RpcStringCodecTests +{ + [Test] + public void RootStringShouldUseUInt32Utf8Framing() + { + const string text = "A€𐍈"; + var expectedPayload = Encoding.UTF8.GetBytes(text); + var writer = new ArrayBufferWriter(); + string? value = text; + + StringCodec.Instance.Serialize(in value, writer); + + Ensure(writer.WrittenCount == sizeof(uint) + expectedPayload.Length, + "root string wire size must be a UInt32 byte length plus UTF-8 payload bytes"); + Ensure(BinaryPrimitives.ReadUInt32LittleEndian(writer.WrittenSpan) == expectedPayload.Length, + "root string length prefix must contain the UTF-8 byte count"); + Ensure(writer.WrittenSpan[sizeof(uint)..].SequenceEqual(expectedPayload), + "root string payload must be UTF-8 rather than native UTF-16 memory"); + + var decoded = StringCodec.Instance.Deserialize(new ReadOnlySequence(writer.WrittenMemory)); + Ensure(decoded == text, "canonical UTF-8 root string payload must round-trip"); + } + + [Test] + public void RootStringShouldReserveUIntMaxForNull() + { + var nullWriter = new ArrayBufferWriter(); + string? nullValue = null; + StringCodec.Instance.Serialize(in nullValue, nullWriter); + + Ensure(nullWriter.WrittenCount == sizeof(uint), + "root string null must contain only the UInt32 sentinel"); + Ensure(BinaryPrimitives.ReadUInt32LittleEndian(nullWriter.WrittenSpan) == uint.MaxValue, + "root string null must use UInt32.MaxValue as the reserved sentinel"); + Ensure(StringCodec.Instance.Deserialize(new ReadOnlySequence(nullWriter.WrittenMemory)) is null, + "UInt32.MaxValue root string sentinel must deserialize as null"); + + var emptyWriter = new ArrayBufferWriter(); + string? emptyValue = string.Empty; + StringCodec.Instance.Serialize(in emptyValue, emptyWriter); + Ensure(BinaryPrimitives.ReadUInt32LittleEndian(emptyWriter.WrittenSpan) == 0, + "empty string must remain distinct from null with a zero byte length"); + Ensure(StringCodec.Instance.Deserialize(new ReadOnlySequence(emptyWriter.WrittenMemory)) == string.Empty, + "zero byte length must deserialize as an empty string"); + } + + [Test] + public void RootStringShouldRejectInvalidUtf8() + { + var bytes = new byte[sizeof(uint) + 2]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 2); + bytes[sizeof(uint)] = 0xC3; + bytes[sizeof(uint) + 1] = 0x28; + + try + { + _ = StringCodec.Instance.Deserialize(new ReadOnlySequence(bytes)); + } + catch (SharpLinkException exception) when (exception.ErrorCode == SharpLinkErrorCode.DataLoss) + { + return; + } + + throw new InvalidOperationException("invalid UTF-8 root string payload must fail with DataLoss"); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} From 4c6024ae4e67ae1de9d146c488886d189f6b79ff Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:10:27 +0800 Subject: [PATCH 168/399] fix: assert SharpLink string decode error code --- test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs index c1a670b59..61e04e3c1 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs @@ -63,7 +63,7 @@ public void RootStringShouldRejectInvalidUtf8() { _ = StringCodec.Instance.Deserialize(new ReadOnlySequence(bytes)); } - catch (SharpLinkException exception) when (exception.ErrorCode == SharpLinkErrorCode.DataLoss) + catch (SharpLinkException exception) when (exception.Code == SharpLinkErrorCode.DataLoss) { return; } From a592d35fd362b57aec9a188b0886f57013148bbc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:11:02 +0800 Subject: [PATCH 169/399] fix: compare canonical string length as uint --- test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs index 61e04e3c1..7f07bc6e6 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs @@ -19,7 +19,7 @@ public void RootStringShouldUseUInt32Utf8Framing() Ensure(writer.WrittenCount == sizeof(uint) + expectedPayload.Length, "root string wire size must be a UInt32 byte length plus UTF-8 payload bytes"); - Ensure(BinaryPrimitives.ReadUInt32LittleEndian(writer.WrittenSpan) == expectedPayload.Length, + Ensure(BinaryPrimitives.ReadUInt32LittleEndian(writer.WrittenSpan) == (uint)expectedPayload.Length, "root string length prefix must contain the UTF-8 byte count"); Ensure(writer.WrittenSpan[sizeof(uint)..].SequenceEqual(expectedPayload), "root string payload must be UTF-8 rather than native UTF-16 memory"); From f67cbd1ac0bb1d0bfb9694430bd66bac2f5a99b5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:12:57 +0800 Subject: [PATCH 170/399] fix: avoid adapter identity pattern variable collisions --- .../RpcGenerator.AdapterClosedIdentity.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs index 0c46316c2..550f97b8b 100644 --- a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs @@ -86,17 +86,17 @@ private void AppendAdapterClosedTargetShape( { var memberType = member switch { - IFieldSymbol memberField => memberField.Type, - IPropertySymbol memberProperty => memberProperty.Type, + IFieldSymbol fieldSymbol => fieldSymbol.Type, + IPropertySymbol propertySymbol => propertySymbol.Type, _ => throw new InvalidOperationException("Unexpected adapter target member kind.") }; parts.Add("member:" + member.Kind + ":" + member.Name + ":" + GetTypeName(memberType)); - if (member is IFieldSymbol memberField) - parts.Add(memberField.IsReadOnly ? "readonly" : "mutable"); - else if (member is IPropertySymbol memberProperty) + if (member is IFieldSymbol fieldMember) + parts.Add(fieldMember.IsReadOnly ? "readonly" : "mutable"); + else if (member is IPropertySymbol propertyMember) { - parts.Add(memberProperty.GetMethod?.DeclaredAccessibility == Accessibility.Public ? "get" : "no-get"); - parts.Add(memberProperty.SetMethod?.DeclaredAccessibility == Accessibility.Public ? "set" : "no-set"); + parts.Add(propertyMember.GetMethod?.DeclaredAccessibility == Accessibility.Public ? "get" : "no-get"); + parts.Add(propertyMember.SetMethod?.DeclaredAccessibility == Accessibility.Public ? "set" : "no-set"); } AppendAttributes(member, parts, "member-attr:"); AppendAdapterClosedTargetShape(memberType, parts, stack, depth + 1); From 551c5955e1153854c81f3467a3cb81af786eab1e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:20:07 +0800 Subject: [PATCH 171/399] test: verify DTO string canonical UTF-8 framing --- .../RpcCodecSeventhReviewRegressionTests.cs | 40 ++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs index 68459234b..8d760d71d 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs @@ -79,20 +79,13 @@ public sealed class StableAdapter : SharpLink.Abstractions.IRpcCodecAdapter } [Test] - public Task RootStringCodecIdentityShouldNotReuseDtoUtf8LeafIdentity() + public Task DtoStringFieldShouldUseUInt32Utf8ContentFramingAndWireNull() { - var rootStringSource = BuildSource(""" -[SharpLink.Sdk.RpcContract] -public interface IRootStringContract : SharpLink.Sdk.IService -{ - ValueTask Echo(string value, CancellationToken cancellationToken); -} -"""); - var dtoStringSource = BuildSource(""" + var source = BuildSource(""" [SharpLink.Sdk.RpcSerializable] public sealed class StringEnvelope { - public string Value { get; set; } = string.Empty; + public string? Value { get; set; } } [SharpLink.Sdk.RpcContract] @@ -102,23 +95,16 @@ public interface IDtoStringContract : SharpLink.Sdk.IService } """); - var rootManifest = RunGeneratorAndGetSources(rootStringSource) - .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - var dtoManifest = RunGeneratorAndGetSources(dtoStringSource) - .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - - var rootStringIdentity = rootManifest.Split('\n') - .Single(static line => - line.Contains("SharpLinkGeneratedCodecIdentityAttribute", StringComparison.Ordinal) && - (line.Contains("typeof(string)", StringComparison.Ordinal) || - line.Contains("typeof(global::System.String)", StringComparison.Ordinal))) - .Trim(); - var dtoIdentity = ExtractGeneratedCodecIdentity(dtoManifest, "StringEnvelope"); - - Ensure(!string.IsNullOrWhiteSpace(rootStringIdentity), - "root string must publish the framework StringCodec identity"); - Ensure(rootStringIdentity != dtoIdentity, - "root StringCodec identity and generated DTO UTF-8 string-field semantics must remain distinct"); + var generated = string.Join("\n", RunGeneratorAndGetSources(source)); + Ensure( + generated.Contains("new global::System.Text.UTF8Encoding(false, true)", StringComparison.Ordinal), + "generated DTO string fields must use strict UTF-8 content encoding"); + Ensure( + generated.Contains("WriteUInt32LittleEndian", StringComparison.Ordinal), + "generated DTO string fields must use a UInt32 little-endian UTF-8 byte length"); + Ensure( + generated.Contains("RpcGeneratedWireType.Null", StringComparison.Ordinal), + "generated DTO string nulls must remain represented by the DTO field Null wire type rather than the root string sentinel"); return Task.CompletedTask; } } From c06abb572f2a7f16ccb7c553870600e6eb136861 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:20:48 +0800 Subject: [PATCH 172/399] fix: keep adapter lifecycle id out of wire identity --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 2a990a670..6b5643191 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -153,7 +153,6 @@ private RpcHashValue GetGeneratedCodecHash( return Hashing.GetSemanticHash( "codec/v1", "adapter-closed/v1", - model.AdapterId ?? string.Empty, GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter").ToHex(), GetAdapterClosedCodecSemanticIdentity(model).ToHex()); case GeneratedCodecKind.Dto: From 8a4b9e48fd6f079a4ec7a9b14e4f4072dcbda51b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:54:05 +0800 Subject: [PATCH 173/399] fix: preserve v2 root string wire format --- src/SharpLink.Runtime/Codec/StringCodec.cs | 96 +++++++++------------- 1 file changed, 38 insertions(+), 58 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/StringCodec.cs b/src/SharpLink.Runtime/Codec/StringCodec.cs index da275e6bf..090b8d481 100644 --- a/src/SharpLink.Runtime/Codec/StringCodec.cs +++ b/src/SharpLink.Runtime/Codec/StringCodec.cs @@ -1,90 +1,70 @@ -using System.Text; - namespace SharpLink.Runtime; internal sealed class StringCodec : IRpcCodec { - private const uint NullLength = uint.MaxValue; - private static readonly UTF8Encoding StrictEncoding = new(false, true); - internal static readonly StringCodec Instance = new(); - + private const int CharSize = 2; [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Serialize(in string? value, IBufferWriter writer) { - ArgumentNullException.ThrowIfNull(writer); if (value is null) { - var nullHeader = writer.GetSpan(sizeof(uint)); - BinaryPrimitives.WriteUInt32LittleEndian(nullHeader, NullLength); - writer.Advance(sizeof(uint)); + CodecHelpers.WriteInt32(writer, -1); + return; + } + + if (value.Length == 0) + { + CodecHelpers.WriteInt32(writer, 0); return; } - var byteCount = StrictEncoding.GetByteCount(value); - CodecHelpers.EnsureSerializablePayloadLength(byteCount, nameof(value)); + var bytesCount = checked(value.Length * CharSize); + CodecHelpers.EnsureSerializablePayloadLength(bytesCount, nameof(value)); + + var span = writer.GetSpan(bytesCount + 4); + + BinaryPrimitives.WriteInt32LittleEndian(span[..4], bytesCount); + + value.AsSpan().CopyTo(MemoryMarshal.Cast(span[4..])); - var span = writer.GetSpan(checked(sizeof(uint) + byteCount)); - BinaryPrimitives.WriteUInt32LittleEndian(span, checked((uint)byteCount)); - if (byteCount != 0) - _ = StrictEncoding.GetBytes(value, span[sizeof(uint)..]); - writer.Advance(checked(sizeof(uint) + byteCount)); + writer.Advance(bytesCount + 4); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string? Deserialize(in ReadOnlySequence buffer) { - CodecHelpers.EnsureAvailable(buffer, sizeof(uint)); - - uint byteCount; - if (buffer.FirstSpan.Length >= sizeof(uint)) - { - byteCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.FirstSpan); - } - else + var bytesCount = CodecHelpers.ReadInt32(buffer); + if (bytesCount == -1) { - Span header = stackalloc byte[sizeof(uint)]; - buffer.Slice(0, sizeof(uint)).CopyTo(header); - byteCount = BinaryPrimitives.ReadUInt32LittleEndian(header); + CodecHelpers.EnsureExactSize(buffer, sizeof(int)); + return null; } + if (bytesCount < -1) + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, $"Invalid string byte length {bytesCount}."); - if (byteCount == NullLength) + if (bytesCount == 0) { - CodecHelpers.EnsureExactSize(buffer, sizeof(uint)); - return null; + CodecHelpers.EnsureExactSize(buffer, sizeof(int)); + return string.Empty; } - if (byteCount > SharpLinkProtocolOptions.MaxMaxFramePayloadBytes - sizeof(uint)) + if ((bytesCount & 1) != 0) + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "UTF-16 string byte length must be even."); + if (bytesCount > SharpLinkProtocolOptions.MaxMaxFramePayloadBytes - sizeof(int)) throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "String payload exceeds the protocol maximum."); - var payloadLength = checked((int)byteCount); - CodecHelpers.EnsureExactSize(buffer, (long)sizeof(uint) + payloadLength); - if (payloadLength == 0) - return string.Empty; + CodecHelpers.EnsureExactSize(buffer, (long)sizeof(int) + bytesCount); + var payload = buffer.Slice(sizeof(int), bytesCount); - var payload = buffer.Slice(sizeof(uint), payloadLength); - try + if (payload.FirstSpan.Length >= bytesCount) { - if (payload.FirstSpan.Length >= payloadLength) - return StrictEncoding.GetString(payload.FirstSpan[..payloadLength]); - - var rented = ArrayPool.Shared.Rent(payloadLength); - try - { - var bytes = rented.AsSpan(0, payloadLength); - payload.CopyTo(bytes); - return StrictEncoding.GetString(bytes); - } - finally - { - ArrayPool.Shared.Return(rented); - } + var charSpan = MemoryMarshal.Cast(payload.FirstSpan[..bytesCount]); + return new string(charSpan); } - catch (DecoderFallbackException exception) + + return string.Create(bytesCount / CharSize, payload, static (destination, sequence) => { - throw new SharpLinkException( - SharpLinkErrorCode.DataLoss, - "String payload is not valid UTF-8.", - exception); - } + sequence.CopyTo(MemoryMarshal.AsBytes(destination)); + }); } } From f541e6159a1e849867c0b9434d42cc288ec26983 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:54:21 +0800 Subject: [PATCH 174/399] fix: treat adapter codecs as opaque identities --- .../RpcGenerator.AdapterClosedIdentity.cs | 110 +----------------- 1 file changed, 5 insertions(+), 105 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs index 550f97b8b..a29b0b161 100644 --- a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs @@ -4,7 +4,7 @@ public partial class RpcGenerator { private sealed partial class DtoAnalysisState { - private RpcHashValue GetAdapterClosedCodecSemanticIdentity(GeneratedCodecModel model) + private RpcHashValue GetAdapterTargetLogicalIdentity(GeneratedCodecModel model) { if (!TryResolveReachableType(model.TypeName, out var targetType)) { @@ -12,110 +12,10 @@ private RpcHashValue GetAdapterClosedCodecSemanticIdentity(GeneratedCodecModel m $"Final RPC Codec graph cannot resolve adapter target '{model.TypeName}' while hashing its closed Codec semantics."); } - var parts = new List { "adapter-closed-target/v1" }; - AppendAdapterClosedTargetShape( - targetType, - parts, - new HashSet(SymbolEqualityComparer.Default), - depth: 0); - return Hashing.GetSemanticHash(parts.ToArray()); - } - - private void AppendAdapterClosedTargetShape( - ITypeSymbol type, - List parts, - HashSet stack, - int depth) - { - var typeName = GetTypeName(type); - parts.Add("type:" + typeName); - if (depth > MaximumDepth) - { - parts.Add("depth-limit"); - return; - } - if (!stack.Add(type)) - { - parts.Add("recursive:" + typeName); - return; - } - - try - { - AppendAttributes(type, parts, "type-attr:"); - if (type is IArrayTypeSymbol array) - { - parts.Add("array-rank:" + array.Rank.ToString(InvariantCulture)); - AppendAdapterClosedTargetShape(array.ElementType, parts, stack, depth + 1); - return; - } - if (type is not INamedTypeSymbol named) - return; - - if (named.TypeKind == TypeKind.Enum) - { - parts.Add("enum-underlying:" + GetTypeName(named.EnumUnderlyingType!)); - foreach (var enumField in named.GetMembers().OfType() - .Where(static field => field.HasConstantValue) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - parts.Add("enum:" + enumField.Name + "=" + - (Convert.ToString(enumField.ConstantValue, InvariantCulture) ?? "null")); - } - return; - } - - foreach (var argument in named.TypeArguments) - AppendAdapterClosedTargetShape(argument, parts, stack, depth + 1); - - if (named.BaseType is { SpecialType: not SpecialType.System_Object and not SpecialType.System_ValueType } baseType) - { - parts.Add("base"); - AppendAdapterClosedTargetShape(baseType, parts, stack, depth + 1); - } - - var members = named.GetMembers() - .Where(static member => !member.IsStatic && member.DeclaredAccessibility == Accessibility.Public) - .Where(static member => - member is IFieldSymbol { IsConst: false } or - IPropertySymbol { IsIndexer: false }) - .OrderBy(static member => member.Kind.ToString(), StringComparer.Ordinal) - .ThenBy(static member => member.Name, StringComparer.Ordinal) - .ThenBy(static member => member.ToDisplayString(), StringComparer.Ordinal); - foreach (var member in members) - { - var memberType = member switch - { - IFieldSymbol fieldSymbol => fieldSymbol.Type, - IPropertySymbol propertySymbol => propertySymbol.Type, - _ => throw new InvalidOperationException("Unexpected adapter target member kind.") - }; - parts.Add("member:" + member.Kind + ":" + member.Name + ":" + GetTypeName(memberType)); - if (member is IFieldSymbol fieldMember) - parts.Add(fieldMember.IsReadOnly ? "readonly" : "mutable"); - else if (member is IPropertySymbol propertyMember) - { - parts.Add(propertyMember.GetMethod?.DeclaredAccessibility == Accessibility.Public ? "get" : "no-get"); - parts.Add(propertyMember.SetMethod?.DeclaredAccessibility == Accessibility.Public ? "set" : "no-set"); - } - AppendAttributes(member, parts, "member-attr:"); - AppendAdapterClosedTargetShape(memberType, parts, stack, depth + 1); - } - } - finally - { - stack.Remove(type); - } - } - - private static void AppendAttributes(ISymbol symbol, List parts, string prefix) - { - foreach (var attribute in symbol.GetAttributes() - .Select(static attribute => attribute.ToString()) - .OrderBy(static value => value, StringComparer.Ordinal)) - { - parts.Add(prefix + attribute); - } + return Hashing.GetSemanticHash( + "adapter-target/v1", + targetType.ContainingAssembly?.Identity.Name ?? string.Empty, + GetTypeName(targetType)); } } } From 62cdd15f629a31ff11b5b577acc75b7d68ab0571 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:54:31 +0800 Subject: [PATCH 175/399] docs: define opaque adapter compatibility boundary --- .../Sdk/RpcCodecSemanticIdentityAttribute.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs b/src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs index 7b857f9aa..19d2b9372 100644 --- a/src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs +++ b/src/SharpLink.Abstractions/Sdk/RpcCodecSemanticIdentityAttribute.cs @@ -3,6 +3,9 @@ namespace SharpLink.Sdk; /// /// Declares the fixed-width semantic identity of an opaque hand-written Codec or Codec Adapter. /// Change this value whenever the implementation's RPC-visible wire semantics change. +/// For Codec Adapters, SharpLink combines this value with the target type's stable logical identity; +/// SharpLink does not infer serializer-specific schema evolution inside the same target type, so the +/// adapter or integration author must change this identity when that closed Codec's wire schema changes. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] public sealed class RpcCodecSemanticIdentityAttribute : Attribute From 4ac2ccb81b0225cab5a69190494f566f9825fc47 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:56:02 +0800 Subject: [PATCH 176/399] fix: align codec identities with opaque adapters and v2 wire --- .../RpcGenerator.CodecIdentity.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 6b5643191..41a8b7fb1 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -152,9 +152,9 @@ private RpcHashValue GetGeneratedCodecHash( case GeneratedCodecKind.Adapter: return Hashing.GetSemanticHash( "codec/v1", - "adapter-closed/v1", + "adapter-closed/v2", GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter").ToHex(), - GetAdapterClosedCodecSemanticIdentity(model).ToHex()); + GetAdapterTargetLogicalIdentity(model).ToHex()); case GeneratedCodecKind.Dto: { var parts = new List @@ -173,7 +173,7 @@ private RpcHashValue GetGeneratedCodecHash( switch (member.Kind) { case GeneratedMemberKind.String: - parts.Add("string/content/utf8/u32le-byte-length/v1"); + parts.Add("string/content/utf16le/i32le-byte-length/v1"); parts.Add("string/null/dto-wire-null/v1"); break; case GeneratedMemberKind.Fixed: @@ -340,6 +340,12 @@ private bool TryResolveReachableType(string typeName, out ITypeSymbol type) private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) { var typeName = member.FixedTypeName ?? member.TypeName; + if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || + string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + return string.Join( ":", "fixed/v1", @@ -380,8 +386,8 @@ private bool TryGetFrameworkPrimitiveCodecHash( hash = Hashing.GetSemanticHash( "codec/v1", "framework", - "string/content/utf8/u32le-byte-length/v1", - "string/null/u32-max/v1"); + "string/content/utf16le/i32le-byte-length/v1", + "string/null/i32-minus-one/v1"); return true; } @@ -412,7 +418,7 @@ private bool TryGetFrameworkPrimitiveCodecHash( "System.Half" => "half/fixed2/v1", "System.Text.Rune" => "rune/fixed4/v1", "System.Guid" => "guid/fixed16/v1", - "System.DateTimeOffset" => "datetime-offset/fixed16/v1", + "System.DateTimeOffset" => "datetime-offset/root-ticks-i64le-offset-minutes-i16le/v1", "System.DateTime" => "datetime/fixed8/v1", "System.DateOnly" => "date-only/fixed4/v1", "System.TimeOnly" => "time-only/fixed8/v1", From ce3d4c6e72c7c5986d083adb417e24728c281716 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:56:55 +0800 Subject: [PATCH 177/399] fix: canonicalize generated string and datetimeoffset wire --- .../RpcGeneratedCodecWire.cs | 107 ++++++++++++------ 1 file changed, 71 insertions(+), 36 deletions(-) diff --git a/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs b/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs index 5e0402b0b..40bdba310 100644 --- a/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs +++ b/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs @@ -29,8 +29,6 @@ public enum RpcGeneratedWireType : byte /// Provides allocation-free primitives used only by source-generated Codecs. public static class RpcGeneratedCodecWire { - private static readonly UTF8Encoding SStrictUtf8 = new(false, true); - /// The hard maximum number of items allocated by one generated collection Codec. public const int MaximumCollectionItems = 1_048_576; @@ -204,33 +202,55 @@ public static TimeOnly ReadTimeOnly(ref SequenceReader reader) return value; } - /// Writes one DateTimeOffset while clearing its native-layout padding. + /// Writes the canonical 16-byte generated DTO representation of one DateTimeOffset. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void WriteDateTimeOffset(IBufferWriter writer, DateTimeOffset value) { ArgumentNullException.ThrowIfNull(writer); const int size = 16; var span = writer.GetSpan(size); - Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(span), value); + BinaryPrimitives.WriteInt16LittleEndian(span, checked((short)value.Offset.TotalMinutes)); span[sizeof(short)..sizeof(long)].Clear(); + BinaryPrimitives.WriteInt64LittleEndian(span[sizeof(long)..], value.UtcDateTime.Ticks); writer.Advance(size); } - /// Reads and validates one DateTimeOffset native representation. + /// Reads and validates the canonical 16-byte generated DTO DateTimeOffset representation. public static DateTimeOffset ReadDateTimeOffset(ref SequenceReader reader) { - var value = ReadUnmanaged(ref reader); - ref var start = ref Unsafe.As(ref value); - var offsetMinutes = Unsafe.ReadUnaligned(ref start); - var utcTicks = Unsafe.ReadUnaligned(ref Unsafe.Add(ref start, sizeof(long))); + const int size = 16; + if (reader.Remaining < size) + throw DataLoss("Generated DateTimeOffset payload is truncated."); + + Span temporary = stackalloc byte[size]; + ReadOnlySpan payload; + if (reader.UnreadSpan.Length >= size) + { + payload = reader.UnreadSpan[..size]; + } + else + { + if (!reader.TryCopyTo(temporary)) + throw DataLoss("Generated DateTimeOffset payload is truncated."); + payload = temporary; + } + + var offsetMinutes = BinaryPrimitives.ReadInt16LittleEndian(payload); + var utcTicks = BinaryPrimitives.ReadInt64LittleEndian(payload[sizeof(long)..]); if ((ulong)utcTicks > (ulong)DateTime.MaxValue.Ticks || offsetMinutes is < -840 or > 840) throw DataLoss("Generated DateTimeOffset payload contains invalid UTC ticks or offset."); + if (!payload[sizeof(short)..sizeof(long)].IsEmpty && + payload[sizeof(short)..sizeof(long)].IndexOfAnyExcept((byte)0) >= 0) + { + throw DataLoss("Generated DateTimeOffset payload contains non-canonical padding."); + } var offsetTicks = (long)offsetMinutes * TimeSpan.TicksPerMinute; if (offsetTicks > 0 && utcTicks > DateTime.MaxValue.Ticks - offsetTicks || offsetTicks < 0 && utcTicks < -offsetTicks) { throw DataLoss("Generated DateTimeOffset payload is outside the supported clock range."); } + reader.Advance(size); return new DateTimeOffset(utcTicks + offsetTicks, TimeSpan.FromMinutes(offsetMinutes)); } @@ -268,46 +288,39 @@ public static bool ReadPresence(ref SequenceReader reader) return marker != 0; } - /// Writes a UTF-8 string payload including its UInt32 byte length. + /// Writes a UTF-16LE string payload including its signed Int32 byte length. public static void WriteString(IBufferWriter writer, string value) { ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(value); - var byteCount = SStrictUtf8.GetByteCount(value); - WriteUInt32(writer, checked((uint)byteCount)); + var byteCount = checked(value.Length * sizeof(char)); + if (byteCount > MaximumStringPayloadBytes) + throw new SharpLinkException(SharpLinkErrorCode.ResourceExhausted, "Generated string payload exceeds the protocol maximum."); + WriteInt32(writer, byteCount); if (byteCount == 0) return; var span = writer.GetSpan(byteCount); - var written = SStrictUtf8.GetBytes(value, span); - writer.Advance(written); + value.AsSpan().CopyTo(MemoryMarshal.Cast(span)); + writer.Advance(byteCount); } - /// Reads a bounded UTF-8 string payload. + /// Reads a bounded UTF-16LE string payload. public static string ReadString(ref SequenceReader reader) { - var payload = ReadLengthDelimited(ref reader); - if (payload.IsSingleSegment) - return DecodeUtf8(payload.FirstSpan); - return DecodeUtf8(payload.ToArray()); - } + var byteCount = ReadInt32(ref reader); + if (byteCount < 0 || (byteCount & 1) != 0 || byteCount > MaximumStringPayloadBytes || reader.Remaining < byteCount) + throw DataLoss("Generated UTF-16 string byte length is invalid, truncated, or too large."); + if (byteCount == 0) + return string.Empty; - private static string DecodeUtf8(ReadOnlySpan payload) - { - var value = Encoding.UTF8.GetString(payload); - if (!value.AsSpan().Contains('\uFFFD')) - return value; - try + var payload = reader.Sequence.Slice(reader.Position, byteCount); + reader.Advance(byteCount); + if (payload.FirstSpan.Length >= byteCount) + return new string(MemoryMarshal.Cast(payload.FirstSpan[..byteCount])); + return string.Create(byteCount / sizeof(char), payload, static (destination, sequence) => { - _ = SStrictUtf8.GetCharCount(payload); - return value; - } - catch (DecoderFallbackException exception) - { - throw new SharpLinkException( - SharpLinkErrorCode.DataLoss, - "Generated string payload is not valid UTF-8.", - exception); - } + sequence.CopyTo(MemoryMarshal.AsBytes(destination)); + }); } /// Reserves a UInt32 length prefix in a contiguous SharpLink packet writer. @@ -406,6 +419,28 @@ public static void EnsureFullyConsumed(in SequenceReader reader) public static SharpLinkException DataLoss(string message) => new(SharpLinkErrorCode.DataLoss, message); + private static void WriteInt32(IBufferWriter writer, int value) + { + var span = writer.GetSpan(sizeof(int)); + BinaryPrimitives.WriteInt32LittleEndian(span, value); + writer.Advance(sizeof(int)); + } + + private static int ReadInt32(ref SequenceReader reader) + { + if (reader.UnreadSpan.Length >= sizeof(int)) + { + var value = BinaryPrimitives.ReadInt32LittleEndian(reader.UnreadSpan); + reader.Advance(sizeof(int)); + return value; + } + Span temporary = stackalloc byte[sizeof(int)]; + if (!reader.TryCopyTo(temporary)) + throw DataLoss("Generated Int32 length is truncated."); + reader.Advance(sizeof(int)); + return BinaryPrimitives.ReadInt32LittleEndian(temporary); + } + private static void WriteUInt32(IBufferWriter writer, uint value) { var span = writer.GetSpan(sizeof(uint)); From 727126940a74d013d751c3977afb5cab1cb23bbe Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:01:11 +0800 Subject: [PATCH 178/399] fix: preserve utf16 dto string fast path --- src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index 11ae1fb83..6950e5d47 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -80,20 +80,18 @@ private static void AppendGeneratedUtf8Helper(StringBuilder sb) { sb.AppendLine("internal static class __SharpLinkGeneratedUtf8"); sb.AppendLine("{"); - sb.AppendLine(" private static readonly global::System.Text.UTF8Encoding StrictEncoding = new global::System.Text.UTF8Encoding(false, true);"); - sb.AppendLine(); - sb.AppendLine(" internal static int GetByteCount(string value) => StrictEncoding.GetByteCount(value);"); + sb.AppendLine(" internal static int GetByteCount(string value) => checked(value.Length * sizeof(char));"); sb.AppendLine(); sb.AppendLine(" internal static void WriteStringKnownSize(IBufferWriter writer, string value, int byteCount)"); sb.AppendLine(" {"); - sb.AppendLine(" var length = writer.GetSpan(sizeof(uint));"); - sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(length, checked((uint)byteCount));"); - sb.AppendLine(" writer.Advance(sizeof(uint));"); + sb.AppendLine(" var length = writer.GetSpan(sizeof(int));"); + sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(length, byteCount);"); + sb.AppendLine(" writer.Advance(sizeof(int));"); sb.AppendLine(" if (byteCount == 0)"); sb.AppendLine(" return;"); sb.AppendLine(" var payload = writer.GetSpan(byteCount);"); - sb.AppendLine(" var written = StrictEncoding.GetBytes(value, payload);"); - sb.AppendLine(" writer.Advance(written);"); + sb.AppendLine(" value.AsSpan().CopyTo(global::System.Runtime.InteropServices.MemoryMarshal.Cast(payload));"); + sb.AppendLine(" writer.Advance(byteCount);"); sb.AppendLine(" }"); sb.AppendLine("}"); sb.AppendLine(); From 59f71ac3e50c4ef2bd0dc73706dc13eeda4d9bd8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:01:34 +0800 Subject: [PATCH 179/399] test: cover opaque adapters and v2 string wire --- .../RpcCodecSeventhReviewRegressionTests.cs | 66 +++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs index 8d760d71d..03e900e36 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecSeventhReviewRegressionTests.cs @@ -31,7 +31,7 @@ public interface IVectorWrapperContract : SharpLink.Sdk.IService } [Test] - public Task AdapterOwnedWireVisibleMemberChangeShouldChangeClosedCodecIdentity() + public Task AdapterOwnedSchemaChangeShouldRequireSemanticIdentityBump() { static string Source(bool includeExtraMember) { @@ -69,17 +69,58 @@ public sealed class StableAdapter : SharpLink.Abstractions.IRpcCodecAdapter .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); Ensure( - ExtractGeneratedCodecIdentity(baseline, "AdapterPayload") != + ExtractGeneratedCodecIdentity(baseline, "AdapterPayload") == ExtractGeneratedCodecIdentity(changed, "AdapterPayload"), - "wire-visible target schema changes must change the closed Adapter CodecHash even when Adapter identity is unchanged"); + "SharpLink must not guess serializer-specific schema evolution for an opaque Adapter; the Adapter semantic identity must be bumped when the same target type changes wire schema"); + return Task.CompletedTask; + } + + [Test] + public Task AdapterTargetsShouldHaveDistinctClosedCodecIdentity() + { + var source = AddAssemblyAttribute(BuildSource(""" +[FakePackable] +public sealed class AdapterPayloadA +{ + public int Value { get; set; } +} + +[FakePackable] +public sealed class AdapterPayloadB +{ + public int Value { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface IAdapterTargetContract : SharpLink.Sdk.IService +{ + ValueTask EchoA(AdapterPayloadA value, CancellationToken cancellationToken); + ValueTask EchoB(AdapterPayloadB value, CancellationToken cancellationToken); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class FakePackableAttribute : Attribute { } + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1010101010101010UL, 0x2020202020202020UL)] +public sealed class StableAdapter : SharpLink.Abstractions.IRpcCodecAdapter +{ + public string AdapterId => "stable-adapter/v1"; + public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); +} +"""), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(StableAdapter), \"stable-adapter/v1\", SelectorAttributeType = typeof(FakePackableAttribute))]"); + + var manifest = RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); Ensure( - ExtractGeneratedRpcAssemblyHash(baseline) != ExtractGeneratedRpcAssemblyHash(changed), - "closed Adapter target schema changes must propagate into RpcAssemblyHash"); + ExtractGeneratedCodecIdentity(manifest, "AdapterPayloadA") != + ExtractGeneratedCodecIdentity(manifest, "AdapterPayloadB"), + "one opaque Adapter must still produce distinct closed Codec identities for distinct stable target types"); return Task.CompletedTask; } [Test] - public Task DtoStringFieldShouldUseUInt32Utf8ContentFramingAndWireNull() + public Task DtoStringFieldShouldUseInt32Utf16ContentFramingAndWireNull() { var source = BuildSource(""" [SharpLink.Sdk.RpcSerializable] @@ -97,14 +138,17 @@ public interface IDtoStringContract : SharpLink.Sdk.IService var generated = string.Join("\n", RunGeneratorAndGetSources(source)); Ensure( - generated.Contains("new global::System.Text.UTF8Encoding(false, true)", StringComparison.Ordinal), - "generated DTO string fields must use strict UTF-8 content encoding"); + generated.Contains("GetByteCount(string value) => checked(value.Length * sizeof(char))", StringComparison.Ordinal), + "generated DTO string fields must size UTF-16 code units rather than UTF-8 bytes"); + Ensure( + generated.Contains("WriteInt32LittleEndian(length, byteCount)", StringComparison.Ordinal), + "generated DTO string fields must use the v2 signed Int32 little-endian byte length"); Ensure( - generated.Contains("WriteUInt32LittleEndian", StringComparison.Ordinal), - "generated DTO string fields must use a UInt32 little-endian UTF-8 byte length"); + generated.Contains("MemoryMarshal.Cast(payload)", StringComparison.Ordinal), + "generated DTO string fields must write UTF-16 code units without UTF-8 transcoding"); Ensure( generated.Contains("RpcGeneratedWireType.Null", StringComparison.Ordinal), - "generated DTO string nulls must remain represented by the DTO field Null wire type rather than the root string sentinel"); + "generated DTO string nulls must remain represented by the DTO field Null wire type rather than the root string -1 sentinel"); return Task.CompletedTask; } } From 7231abe0b782b145a4af82a2e0feec8d5a708238 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:01:47 +0800 Subject: [PATCH 180/399] test: preserve v2 string compatibility --- .../Runtime/RpcStringCodecTests.cs | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs index 7f07bc6e6..edf02eae8 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcStringCodecTests.cs @@ -1,6 +1,6 @@ using System.Buffers; using System.Buffers.Binary; -using System.Text; +using System.Runtime.InteropServices; using SharpLink.Runtime; namespace SharpLink.UnitTests.Runtime; @@ -8,56 +8,68 @@ namespace SharpLink.UnitTests.Runtime; public sealed class RpcStringCodecTests { [Test] - public void RootStringShouldUseUInt32Utf8Framing() + public void RootStringShouldPreserveInt32Utf16Framing() { const string text = "A€𐍈"; - var expectedPayload = Encoding.UTF8.GetBytes(text); + var expectedPayload = MemoryMarshal.AsBytes(text.AsSpan()).ToArray(); var writer = new ArrayBufferWriter(); string? value = text; StringCodec.Instance.Serialize(in value, writer); - Ensure(writer.WrittenCount == sizeof(uint) + expectedPayload.Length, - "root string wire size must be a UInt32 byte length plus UTF-8 payload bytes"); - Ensure(BinaryPrimitives.ReadUInt32LittleEndian(writer.WrittenSpan) == (uint)expectedPayload.Length, - "root string length prefix must contain the UTF-8 byte count"); - Ensure(writer.WrittenSpan[sizeof(uint)..].SequenceEqual(expectedPayload), - "root string payload must be UTF-8 rather than native UTF-16 memory"); + Ensure(writer.WrittenCount == sizeof(int) + expectedPayload.Length, + "root string wire size must remain a signed Int32 byte length plus UTF-16 payload bytes"); + Ensure(BinaryPrimitives.ReadInt32LittleEndian(writer.WrittenSpan) == expectedPayload.Length, + "root string length prefix must contain the UTF-16 byte count"); + Ensure(writer.WrittenSpan[sizeof(int)..].SequenceEqual(expectedPayload), + "root string payload must preserve the v2 UTF-16 code units"); var decoded = StringCodec.Instance.Deserialize(new ReadOnlySequence(writer.WrittenMemory)); - Ensure(decoded == text, "canonical UTF-8 root string payload must round-trip"); + Ensure(decoded == text, "v2 root string payload must round-trip"); } [Test] - public void RootStringShouldReserveUIntMaxForNull() + public void RootStringShouldReserveMinusOneForNull() { var nullWriter = new ArrayBufferWriter(); string? nullValue = null; StringCodec.Instance.Serialize(in nullValue, nullWriter); - Ensure(nullWriter.WrittenCount == sizeof(uint), - "root string null must contain only the UInt32 sentinel"); - Ensure(BinaryPrimitives.ReadUInt32LittleEndian(nullWriter.WrittenSpan) == uint.MaxValue, - "root string null must use UInt32.MaxValue as the reserved sentinel"); + Ensure(nullWriter.WrittenCount == sizeof(int), + "root string null must contain only the signed Int32 sentinel"); + Ensure(BinaryPrimitives.ReadInt32LittleEndian(nullWriter.WrittenSpan) == -1, + "root string null must preserve the v2 -1 sentinel"); Ensure(StringCodec.Instance.Deserialize(new ReadOnlySequence(nullWriter.WrittenMemory)) is null, - "UInt32.MaxValue root string sentinel must deserialize as null"); + "the -1 root string sentinel must deserialize as null"); var emptyWriter = new ArrayBufferWriter(); string? emptyValue = string.Empty; StringCodec.Instance.Serialize(in emptyValue, emptyWriter); - Ensure(BinaryPrimitives.ReadUInt32LittleEndian(emptyWriter.WrittenSpan) == 0, + Ensure(BinaryPrimitives.ReadInt32LittleEndian(emptyWriter.WrittenSpan) == 0, "empty string must remain distinct from null with a zero byte length"); Ensure(StringCodec.Instance.Deserialize(new ReadOnlySequence(emptyWriter.WrittenMemory)) == string.Empty, "zero byte length must deserialize as an empty string"); } [Test] - public void RootStringShouldRejectInvalidUtf8() + public void RootStringShouldPreserveArbitraryUtf16CodeUnits() { - var bytes = new byte[sizeof(uint) + 2]; - BinaryPrimitives.WriteUInt32LittleEndian(bytes, 2); - bytes[sizeof(uint)] = 0xC3; - bytes[sizeof(uint) + 1] = 0x28; + var text = new string(['\uD800', 'X', '\uDC00']); + var writer = new ArrayBufferWriter(); + string? value = text; + + StringCodec.Instance.Serialize(in value, writer); + var decoded = StringCodec.Instance.Deserialize(new ReadOnlySequence(writer.WrittenMemory)); + + Ensure(decoded == text, + "v2 root string wire must preserve arbitrary .NET UTF-16 code units, including unpaired surrogates"); + } + + [Test] + public void RootStringShouldRejectOddUtf16ByteLength() + { + var bytes = new byte[sizeof(int) + 1]; + BinaryPrimitives.WriteInt32LittleEndian(bytes, 1); try { @@ -68,7 +80,7 @@ public void RootStringShouldRejectInvalidUtf8() return; } - throw new InvalidOperationException("invalid UTF-8 root string payload must fail with DataLoss"); + throw new InvalidOperationException("odd UTF-16 root string byte length must fail with DataLoss"); } private static void Ensure(bool condition, string message) From 5642e1203485ae06dd68012f501178d316e8c262 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:02:00 +0800 Subject: [PATCH 181/399] test: cover canonical generated datetimeoffset wire --- ...neratedCodecWireIdentityRegressionTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 test/SharpLink.UnitTests/Runtime/RpcGeneratedCodecWireIdentityRegressionTests.cs diff --git a/test/SharpLink.UnitTests/Runtime/RpcGeneratedCodecWireIdentityRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcGeneratedCodecWireIdentityRegressionTests.cs new file mode 100644 index 000000000..51827cb58 --- /dev/null +++ b/test/SharpLink.UnitTests/Runtime/RpcGeneratedCodecWireIdentityRegressionTests.cs @@ -0,0 +1,58 @@ +using System.Buffers; +using System.Buffers.Binary; +using SharpLink.Abstractions; + +namespace SharpLink.UnitTests.Runtime; + +public sealed class RpcGeneratedCodecWireIdentityRegressionTests +{ + [Test] + public void DateTimeOffsetShouldUseCanonicalLogicalLayout() + { + var value = new DateTimeOffset(2026, 8, 31, 9, 12, 13, TimeSpan.FromMinutes(330)); + var writer = new ArrayBufferWriter(); + + RpcGeneratedCodecWire.WriteDateTimeOffset(writer, value); + + Ensure(writer.WrittenCount == 16, "generated DateTimeOffset must remain a 16-byte fixed payload"); + var payload = writer.WrittenSpan; + Ensure(BinaryPrimitives.ReadInt16LittleEndian(payload) == 330, + "generated DateTimeOffset must write logical offset minutes at bytes 0..1"); + for (var index = sizeof(short); index < sizeof(long); index++) + Ensure(payload[index] == 0, "generated DateTimeOffset padding bytes 2..7 must be canonical zero"); + Ensure(BinaryPrimitives.ReadInt64LittleEndian(payload[sizeof(long)..]) == value.UtcDateTime.Ticks, + "generated DateTimeOffset must write logical UTC ticks at bytes 8..15"); + + var reader = new SequenceReader(new ReadOnlySequence(writer.WrittenMemory)); + var decoded = RpcGeneratedCodecWire.ReadDateTimeOffset(ref reader); + Ensure(decoded.Equals(value), "canonical generated DateTimeOffset payload must round-trip"); + Ensure(reader.Remaining == 0, "generated DateTimeOffset reader must consume exactly 16 bytes"); + } + + [Test] + public void DateTimeOffsetShouldRejectNonCanonicalPadding() + { + var bytes = new byte[16]; + BinaryPrimitives.WriteInt16LittleEndian(bytes, 0); + bytes[2] = 1; + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(sizeof(long)), DateTime.UnixEpoch.Ticks); + var reader = new SequenceReader(new ReadOnlySequence(bytes)); + + try + { + _ = RpcGeneratedCodecWire.ReadDateTimeOffset(ref reader); + } + catch (SharpLinkException exception) when (exception.Code == SharpLinkErrorCode.DataLoss) + { + return; + } + + throw new InvalidOperationException("non-zero generated DateTimeOffset padding must fail with DataLoss"); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} From 16f32dc17a431370df8a8d364a1c547ce94d85d2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:04:37 +0800 Subject: [PATCH 182/399] fix: keep canonical datetimeoffset span scoped --- .../RpcGeneratedCodecWire.cs | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs b/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs index 40bdba310..00e9968ed 100644 --- a/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs +++ b/src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs @@ -222,36 +222,39 @@ public static DateTimeOffset ReadDateTimeOffset(ref SequenceReader reader) if (reader.Remaining < size) throw DataLoss("Generated DateTimeOffset payload is truncated."); - Span temporary = stackalloc byte[size]; - ReadOnlySpan payload; if (reader.UnreadSpan.Length >= size) { - payload = reader.UnreadSpan[..size]; - } - else - { - if (!reader.TryCopyTo(temporary)) - throw DataLoss("Generated DateTimeOffset payload is truncated."); - payload = temporary; + var value = DecodeDateTimeOffset(reader.UnreadSpan[..size]); + reader.Advance(size); + return value; } - var offsetMinutes = BinaryPrimitives.ReadInt16LittleEndian(payload); - var utcTicks = BinaryPrimitives.ReadInt64LittleEndian(payload[sizeof(long)..]); - if ((ulong)utcTicks > (ulong)DateTime.MaxValue.Ticks || offsetMinutes is < -840 or > 840) - throw DataLoss("Generated DateTimeOffset payload contains invalid UTC ticks or offset."); - if (!payload[sizeof(short)..sizeof(long)].IsEmpty && - payload[sizeof(short)..sizeof(long)].IndexOfAnyExcept((byte)0) >= 0) - { - throw DataLoss("Generated DateTimeOffset payload contains non-canonical padding."); - } - var offsetTicks = (long)offsetMinutes * TimeSpan.TicksPerMinute; - if (offsetTicks > 0 && utcTicks > DateTime.MaxValue.Ticks - offsetTicks || - offsetTicks < 0 && utcTicks < -offsetTicks) + Span temporary = stackalloc byte[size]; + if (!reader.TryCopyTo(temporary)) + throw DataLoss("Generated DateTimeOffset payload is truncated."); + var decoded = DecodeDateTimeOffset(temporary); + reader.Advance(size); + return decoded; + + static DateTimeOffset DecodeDateTimeOffset(ReadOnlySpan payload) { - throw DataLoss("Generated DateTimeOffset payload is outside the supported clock range."); + var offsetMinutes = BinaryPrimitives.ReadInt16LittleEndian(payload); + var utcTicks = BinaryPrimitives.ReadInt64LittleEndian(payload[sizeof(long)..]); + if ((ulong)utcTicks > (ulong)DateTime.MaxValue.Ticks || offsetMinutes is < -840 or > 840) + throw DataLoss("Generated DateTimeOffset payload contains invalid UTC ticks or offset."); + for (var index = sizeof(short); index < sizeof(long); index++) + { + if (payload[index] != 0) + throw DataLoss("Generated DateTimeOffset payload contains non-canonical padding."); + } + var offsetTicks = (long)offsetMinutes * TimeSpan.TicksPerMinute; + if (offsetTicks > 0 && utcTicks > DateTime.MaxValue.Ticks - offsetTicks || + offsetTicks < 0 && utcTicks < -offsetTicks) + { + throw DataLoss("Generated DateTimeOffset payload is outside the supported clock range."); + } + return new DateTimeOffset(utcTicks + offsetTicks, TimeSpan.FromMinutes(offsetMinutes)); } - reader.Advance(size); - return new DateTimeOffset(utcTicks + offsetTicks, TimeSpan.FromMinutes(offsetMinutes)); } /// Returns the fixed wire type for a supported unmanaged size. From f8b02288d29ee7be1c39bc1c2dc8946c9e1f4154 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:07:12 +0800 Subject: [PATCH 183/399] test: preserve generated utf16 code units --- .../Abstractions/GeneratedCodecWireTests.cs | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs b/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs index 4518b55b9..f474ca54f 100644 --- a/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs +++ b/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs @@ -1,5 +1,3 @@ -using System.Text; - namespace SharpLink.UnitTests.Abstractions; public class GeneratedCodecWireTests @@ -40,14 +38,17 @@ public void TruncatedLengthAndOversizedCollectionShouldFailStructurally() } [Test] - public void GeneratedStringWriterShouldRejectIsolatedSurrogates() + public void GeneratedStringWriterShouldPreserveIsolatedSurrogates() { using var writer = new PooledByteBufferWriter(); - var failure = CaptureException(() => RpcGeneratedCodecWire.WriteString(writer, "\uD800")); + var source = new string(['\uD800', 'X', '\uDC00']); + + RpcGeneratedCodecWire.WriteString(writer, source); + var reader = new SequenceReader(new ReadOnlySequence(writer.WrittenMemory)); + var decoded = RpcGeneratedCodecWire.ReadString(ref reader); - Ensure(failure is EncoderFallbackException, - $"isolated surrogate should fail strict UTF-8 encoding, not {failure?.GetType().Name}"); - Ensure(writer.WrittenCount == 0, "invalid string must not partially write its length"); + Ensure(decoded == source, "generated v2 string wire must preserve arbitrary UTF-16 code units"); + Ensure(reader.Remaining == 0, "generated string reader must consume the full UTF-16 payload"); } private static SharpLinkException CaptureSharpLink(Action action) @@ -77,19 +78,6 @@ private static SharpLinkException CaptureTruncatedLength() } } - private static Exception? CaptureException(Action action) - { - try - { - action(); - return null; - } - catch (Exception exception) - { - return exception; - } - } - private static ReadOnlySequence CreateSegmentedSequence(byte[] bytes) { TestSegment? first = null; From 8ee1fe5aade2c1be2ec4c4ddc256cd7182759f32 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:24:28 +0800 Subject: [PATCH 184/399] test: align generated string protocol coverage --- .../Protocol/ProtocolV2Tests.cs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/test/SharpLink.UnitTests/Protocol/ProtocolV2Tests.cs b/test/SharpLink.UnitTests/Protocol/ProtocolV2Tests.cs index 60d0a5fc1..a12df69d9 100644 --- a/test/SharpLink.UnitTests/Protocol/ProtocolV2Tests.cs +++ b/test/SharpLink.UnitTests/Protocol/ProtocolV2Tests.cs @@ -533,34 +533,37 @@ await ExpectProtocolViolation(CreateFrame( } [Test] - public async Task GeneratedDtoStringShouldRejectInvalidUtf8() + public void GeneratedDtoStringShouldPreserveUtf16LeAndRejectOddByteLength() { var payload = new byte[] { 2, 0, 0, 0, 0xC3, 0x28 }; + var contiguousReader = new SequenceReader(new ReadOnlySequence(payload)); + Ensure(RpcGeneratedCodecWire.ReadString(ref contiguousReader) == "\u28C3", + "contiguous generated string must decode UTF-16LE code units"); + + var segmentedReader = new SequenceReader(CreateSegmented(payload, 1)); + Ensure(RpcGeneratedCodecWire.ReadString(ref segmentedReader) == "\u28C3", + "segmented generated string must decode UTF-16LE code units"); + + var oddPayload = new byte[] { 1, 0, 0, 0, 0x41 }; var contiguousFailure = CaptureException(() => { - var reader = new SequenceReader(new ReadOnlySequence(payload)); + var reader = new SequenceReader(new ReadOnlySequence(oddPayload)); _ = RpcGeneratedCodecWire.ReadString(ref reader); }); var segmentedFailure = CaptureException(() => { - var reader = new SequenceReader(CreateSegmented(payload, 1)); + var reader = new SequenceReader(CreateSegmented(oddPayload, 1)); _ = RpcGeneratedCodecWire.ReadString(ref reader); }); Ensure(contiguousFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, - "contiguous generated string must reject invalid UTF-8"); + "contiguous generated UTF-16 string must reject an odd byte length"); Ensure(segmentedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, - "segmented generated string must reject invalid UTF-8"); - - var validReplacementPayload = new byte[] { 3, 0, 0, 0, 0xEF, 0xBF, 0xBD }; - var validReader = new SequenceReader(CreateSegmented(validReplacementPayload, 1)); - Ensure(RpcGeneratedCodecWire.ReadString(ref validReader) == "\uFFFD", - "a canonically encoded replacement character must remain valid"); - await Task.CompletedTask; + "segmented generated UTF-16 string must reject an odd byte length"); } [Test] From d39f0e9e04d93470a906c79d1bde97a0970d4261 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:33:41 +0800 Subject: [PATCH 185/399] refactor: name generated string helper for utf16 --- .../RpcGenerator.DtoEmitter.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index 6950e5d47..cc9ec3f85 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -36,7 +36,7 @@ private static string GenerateCodecs(ImmutableArray codecs) if (emittedCodecs.Any(static codec => codec.Kind == GeneratedCodecKind.Dto && codec.Members.Any(static member => member.Kind == GeneratedMemberKind.String))) - AppendGeneratedUtf8Helper(sb); + AppendGeneratedUtf16Helper(sb); foreach (var codec in emittedCodecs) { @@ -76,9 +76,9 @@ private static void AppendCustomCodecFactory(StringBuilder sb, GeneratedCodecMod sb.AppendLine(); } - private static void AppendGeneratedUtf8Helper(StringBuilder sb) + private static void AppendGeneratedUtf16Helper(StringBuilder sb) { - sb.AppendLine("internal static class __SharpLinkGeneratedUtf8"); + sb.AppendLine("internal static class __SharpLinkGeneratedUtf16"); sb.AppendLine("{"); sb.AppendLine(" internal static int GetByteCount(string value) => checked(value.Length * sizeof(char));"); sb.AppendLine(); @@ -334,7 +334,7 @@ private static void AppendDtoExactSerializeBody( case GeneratedMemberKind.String: sb.AppendLine($" var __string_{memberIndex} = {value};"); sb.AppendLine( - $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); + $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); break; case GeneratedMemberKind.Fixed: sb.AppendLine($" var __fixed_{memberIndex} = {value};"); @@ -479,7 +479,7 @@ private static void AppendDtoSuppressedSerializeBody( var value = $"value.{EscapeIdentifier(member.Identifier)}"; sb.AppendLine($"{indent}var __string_{memberIndex} = {value};"); sb.AppendLine( - $"{indent}var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); + $"{indent}var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); } AppendDtoSerializeBody(sb, model, complexIndexes, useCachedStrings: true, useCachedMembers: false, indent: indent); @@ -529,7 +529,7 @@ private static void AppendDtoMemberWrite( if (cachedMemberIndex >= 0) { sb.AppendLine( - $"{childIndent}__SharpLinkGeneratedUtf8.WriteStringKnownSize(writer, {value}, __stringByteCount_{cachedMemberIndex});"); + $"{childIndent}__SharpLinkGeneratedUtf16.WriteStringKnownSize(writer, {value}, __stringByteCount_{cachedMemberIndex});"); } else { @@ -557,7 +557,7 @@ private static void AppendDtoDirectPreReservation(StringBuilder sb, GeneratedCod { sb.AppendLine($" var __string_{memberIndex} = {value};"); sb.AppendLine( - $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); + $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); } else if (member.Kind == GeneratedMemberKind.Fixed) { @@ -670,7 +670,7 @@ private static void AppendDtoEncodedSizeMethod( case GeneratedMemberKind.String: sb.AppendLine($" __snapshot.__string_{memberIndex} = {value};"); sb.AppendLine( - $" __snapshot.__stringByteCount_{memberIndex} = __snapshot.__string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__snapshot.__string_{memberIndex});"); + $" __snapshot.__stringByteCount_{memberIndex} = __snapshot.__string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__snapshot.__string_{memberIndex});"); break; case GeneratedMemberKind.Fixed: sb.AppendLine($" __snapshot.__fixed_{memberIndex} = {value};"); @@ -783,7 +783,7 @@ private static void AppendDtoSizeOnlyEncodedSizeMethod( { var nullSize = GetFieldKeySize(member.FieldId, 0); var valueOverhead = GetFieldKeySize(member.FieldId, 6) + sizeof(uint); - sb.AppendLine($" size = checked(size + ({value} is null ? {nullSize.ToString(InvariantCulture)} : {valueOverhead.ToString(InvariantCulture)} + __SharpLinkGeneratedUtf8.GetByteCount({value})));"); + sb.AppendLine($" size = checked(size + ({value} is null ? {nullSize.ToString(InvariantCulture)} : {valueOverhead.ToString(InvariantCulture)} + __SharpLinkGeneratedUtf16.GetByteCount({value})));"); break; } case GeneratedMemberKind.Complex: @@ -920,7 +920,7 @@ private static void AppendDtoSizedSerializeMethod( sb.AppendLine(" {"); sb.AppendLine($" RpcGeneratedCodecWire.WriteFieldKey(buffer, {fieldId}, RpcGeneratedWireType.LengthDelimited);"); sb.AppendLine( - $" __SharpLinkGeneratedUtf8.WriteStringKnownSize(buffer, __snapshot.__string_{memberIndex}, __snapshot.__stringByteCount_{memberIndex});"); + $" __SharpLinkGeneratedUtf16.WriteStringKnownSize(buffer, __snapshot.__string_{memberIndex}, __snapshot.__stringByteCount_{memberIndex});"); sb.AppendLine(" }"); break; case GeneratedMemberKind.Complex: From eb5f2ebe78752068f7303d32baafae5b1f6253a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:01:57 +0000 Subject: [PATCH 186/399] test: align exact string sizing with utf16 wire --- .../RpcAnalyzerTests.cs | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index e812e1aef..3897d92df 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -828,22 +828,22 @@ public interface IHelloService : SharpLink.Sdk.IService } [Test] - public Task DirectStringDtosShouldCacheExactUtf8SizesAndPreReserveOnce() + public Task DirectStringDtosShouldCacheExactUtf16SizesAndPreReserveOnce() { var source = BuildDirectStringDtoSource(1, 4, 16, 64); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); - Ensure(CountOccurrences(generated, "internal static class __SharpLinkGeneratedUtf8") == 1, - "one assembly-private UTF-8 helper must be shared by all eligible generated Codecs"); - Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf8.GetByteCount(__string_") == 85, - "each direct string must be counted once in the direct reservation path"); - Ensure(CountOccurrences(generated, "StrictEncoding.GetByteCount(") == 1, - "the known-size write helper must never traverse UTF-16 again"); - Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf8.WriteStringKnownSize(writer, __string_") == 85, + Ensure(CountOccurrences(generated, "internal static class __SharpLinkGeneratedUtf16") == 1, + "one assembly-private UTF-16 helper must be shared by all eligible generated Codecs"); + Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf16.GetByteCount(__string_") == 85, + "each direct string must compute its exact UTF-16 byte count once in the direct reservation path"); + Ensure(CountOccurrences(generated, "checked(value.Length * sizeof(char))") == 1, + "the known-size helper must compute UTF-16 bytes in O(1) without an encoding traversal"); + Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf16.WriteStringKnownSize(writer, __string_") == 85, "each direct string must reuse its cached value and byte count in the direct write path"); - Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf8.GetByteCount(__snapshot.__string_") == 85, + Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf16.GetByteCount(__snapshot.__string_") == 85, "each direct string must be captured once for the snapshot sizing path"); - Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf8.WriteStringKnownSize(buffer, __snapshot.__string_") == 85, + Ensure(CountOccurrences(generated, "__SharpLinkGeneratedUtf16.WriteStringKnownSize(buffer, __snapshot.__string_") == 85, "each direct string must reuse its snapshot value and byte count in the sized write path"); Ensure(CountOccurrences(generated, "if (writer is IRpcByteBufferWriter __rpcWriter)") == 4, "each eligible DTO must gate whole-payload reservation on the SharpLink packet writer"); @@ -854,12 +854,13 @@ public Task DirectStringDtosShouldCacheExactUtf8SizesAndPreReserveOnce() Ensure(CountOccurrences(generated, "var __encodedSize =") == 4, "each eligible DTO must compute one checked encoded size"); Ensure(!generated.Contains("RpcGeneratedCodecWire.WriteString(writer, value.Field", StringComparison.Ordinal), - "eligible DTOs must not call the byte-counting public string primitive after pre-sizing"); - Ensure(generated.Contains("new global::System.Text.UTF8Encoding(false, true)", StringComparison.Ordinal), - "the generated helper must preserve strict UTF-8 encoder semantics"); - Ensure(generated.Contains("global::System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian", StringComparison.Ordinal) && - generated.Contains("var payload = writer.GetSpan(byteCount);", StringComparison.Ordinal), - "known-size writes must preserve the little-endian prefix and separate payload request"); + "eligible DTOs must not call the public string primitive after pre-sizing"); + Ensure(!generated.Contains("UTF8Encoding", StringComparison.Ordinal) && + !generated.Contains("StrictEncoding.GetByteCount", StringComparison.Ordinal), + "generated DTO string sizing must not transcode or traverse UTF-8"); + Ensure(generated.Contains("global::System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian", StringComparison.Ordinal) && + generated.Contains("value.AsSpan().CopyTo(global::System.Runtime.InteropServices.MemoryMarshal.Cast(payload));", StringComparison.Ordinal), + "known-size writes must preserve the Int32 little-endian prefix and raw UTF-16 code-unit payload"); return Task.CompletedTask; } @@ -882,7 +883,7 @@ public sealed class NestedPayload """); var generated = string.Join("\n", RunGeneratorAndGetSources(source)); - Ensure(generated.Contains("internal static class __SharpLinkGeneratedUtf8", StringComparison.Ordinal) && + Ensure(generated.Contains("internal static class __SharpLinkGeneratedUtf16", StringComparison.Ordinal) && generated.Contains("out var __exactSize", StringComparison.Ordinal) && generated.Contains("IRpcSizedCodec", StringComparison.Ordinal) && generated.Contains("IRpcSizedCodecSnapshot", StringComparison.Ordinal) && From 60de434f6a496fce4558b54b6fec0a64350e6efd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:12:28 +0800 Subject: [PATCH 187/399] test: lock canonical generated DateTimeOffset wire --- .../Abstractions/GeneratedCodecWireTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs b/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs index f474ca54f..c4a66085d 100644 --- a/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs +++ b/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs @@ -51,6 +51,45 @@ public void GeneratedStringWriterShouldPreserveIsolatedSurrogates() Ensure(reader.Remaining == 0, "generated string reader must consume the full UTF-16 payload"); } + [Test] + public void GeneratedDateTimeOffsetShouldUseCanonicalLogicalFields() + { + const long utcTicks = 0x0102030405060708L; + var offset = TimeSpan.FromMinutes(330); + var source = new DateTimeOffset(utcTicks + offset.Ticks, offset); + + using var writer = new PooledByteBufferWriter(); + RpcGeneratedCodecWire.WriteDateTimeOffset(writer, source); + var bytes = writer.WrittenMemory.ToArray(); + var expected = new byte[] + { + 0x4A, 0x01, 0, 0, 0, 0, 0, 0, + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 + }; + Ensure(bytes.AsSpan().SequenceEqual(expected), + "generated DateTimeOffset wire must be offset-minutes i16le + six zero bytes + UTC ticks i64le"); + + var segmented = CreateSegmentedSequence(bytes); + var reader = new SequenceReader(segmented); + var decoded = RpcGeneratedCodecWire.ReadDateTimeOffset(ref reader); + Ensure(decoded == source, "segmented canonical DateTimeOffset payload must round-trip"); + Ensure(reader.Remaining == 0, "DateTimeOffset reader must consume exactly 16 bytes"); + + var nonCanonical = bytes.ToArray(); + nonCanonical[2] = 1; + var invalidReader = new SequenceReader(new ReadOnlySequence(nonCanonical)); + try + { + _ = RpcGeneratedCodecWire.ReadDateTimeOffset(ref invalidReader); + throw new Exception("expected non-canonical DateTimeOffset padding to fail"); + } + catch (SharpLinkException exception) + { + Ensure(exception.Code == SharpLinkErrorCode.DataLoss, + "non-zero DateTimeOffset padding must be classified as DataLoss"); + } + } + private static SharpLinkException CaptureSharpLink(Action action) { try From 7c717888b6fcd3d9af7b3e6c39f3b969a4a329d2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:12:59 +0800 Subject: [PATCH 188/399] docs: define opaque adapter semantic identity boundary --- doc/contracts-and-codecs.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/doc/contracts-and-codecs.md b/doc/contracts-and-codecs.md index b322c3c46..71c5882eb 100644 --- a/doc/contracts-and-codecs.md +++ b/doc/contracts-and-codecs.md @@ -14,7 +14,7 @@ Generator 根据签名生成五类调用:Unary、OneWay、ClientStreaming、Se ## 原生 Codec -内置 Codec 覆盖常用 primitive、enum、string、时间/标识类型、数组、List、Memory、nullable、tuple、受支持不可变集合和由 `[RpcSerializable]`/`[RpcMember]` 描述的 DTO。编码有明确 null 标记、长度上限和完整消费检查;尾随字节、非法 UTF-8、非规范整数或 required/nullability 违反会作为 `DataLoss`。 +内置 Codec 覆盖常用 primitive、enum、string、时间/标识类型、数组、List、Memory、nullable、tuple、受支持不可变集合和由 `[RpcSerializable]`/`[RpcMember]` 描述的 DTO。编码有明确 null 标记、长度上限和完整消费检查;尾随字节、非法 UTF-16LE 字节长度、非规范整数或 required/nullability 违反会作为 `DataLoss`。 其中一小组类型属于 **Framework wire primitive**:SharpLink 直接定义并拥有其固定 wire semantic,因此它们不是可配置 Codec policy surface。当前包括 primitive numerics、`bool`、`char`、`string`、`Guid`、SharpLink 明确定义固定 wire semantic 的时间/标识 scalar、enum,以及作为 protocol bytes primitive 的 `byte[]`。这些类型不能通过 `RpcCodec`、`RpcCodecAdapter` 或 `RpcCodecRoute` 重绑定。 @@ -55,7 +55,11 @@ public sealed class MyTypeCodec : IRpcCodec ## Codec Adapter 与 SharpPack -`IRpcCodecAdapter` 用于由 Generator 生成闭合工厂,再由 Runtime Context 创建隔离 scope。当前 `AdapterId` / `WireFormatId` / `SchemaId` 仍参与既有 registration validation;#396 会把稳定 identity 收敛为 fixed-width hash,而 #386 只负责 Adapter 的最终选择与 lifecycle ownership。 +`IRpcCodecAdapter` 用于由 Generator 生成闭合工厂,再由 Runtime Context 创建隔离 scope。用于 generated RPC 的 Adapter 实现必须声明 `[RpcCodecSemanticIdentity(high, low)]`。对一个闭合目标类型 `T`,最终 Adapter `CodecHash` 把这份显式的 Adapter semantic identity 与 `T` 的 canonical type identity 组合成一个 **opaque compatibility boundary**;Generator 不会遍历 `T` 的字段、属性或 DTO member graph 去猜测第三方 serializer 的 wire schema。 + +因此,仅修改 Adapter 目标类型的 CLR 成员不会自动改变该 Adapter 的 `CodecHash`。当 Adapter 的实际编码、解码、schema evolution 规则或任何会改变 wire compatibility 的行为发生变化时,Adapter 作者必须显式 bump `[RpcCodecSemanticIdentity]`。反过来,保留同一 semantic identity 就是在声明这些 closed Adapter Codec 仍然 wire-compatible。不同目标类型即使使用同一个 Adapter,也会因为 canonical target type identity 不同而得到不同的 closed `CodecHash`。 + +`AdapterId` 继续负责 Adapter 注册/选择和 Runtime scope ownership;它不是目标成员图的替代 schema hash。不要通过反射目标类型布局或字段集合来推导 Adapter wire identity,因为 Adapter 可以忽略、重命名、转换或以完全不同的 schema 编码这些成员。 官方复杂对象图扩展是 `SharpLink.Serializer.SharpPack`。用 `[RpcCodecAdapter(typeof(SharpLink.Serializer.SharpPack.SharpPackRpcCodecAdapter))]` 或项目约定把类型交给 SharpPack;每个 Runtime Context × Manifest × AdapterId 拥有独立 scope,不使用进程级默认 formatter slot。动态模块排空后,Codec、Adapter scope 和 collectible ALC 才能一起释放。 @@ -73,4 +77,4 @@ builder.UseRuntime(options => 只有双方 wire profile 完全匹配才启用压缩;单边配置或无交集会安全退回原始帧。压缩只覆盖业务 payload,协议路由前缀保持可解析。只有同时达到最小 payload、绝对节省和比例节省阈值才发送压缩结果。解压输出仍受协商后的最大 frame payload 限制。 -运行证据:`demo/Compression` 用不同 Brotli 编码级别、相同 wire profile 完成双向压缩并统计 provider 调用。 +运行证据:`demo/Compression` 用不同 Brotli 编码级别、相同 wire profile 完成双向压缩并统计 provider 调用。 \ No newline at end of file From 4b662106ef84933579a2571e6d71eb8f3d36940e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:16:08 +0800 Subject: [PATCH 189/399] test: avoid LINQ clone in DateTimeOffset regression --- .../SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs b/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs index c4a66085d..bacddf43c 100644 --- a/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs +++ b/test/SharpLink.UnitTests/Abstractions/GeneratedCodecWireTests.cs @@ -75,7 +75,7 @@ public void GeneratedDateTimeOffsetShouldUseCanonicalLogicalFields() Ensure(decoded == source, "segmented canonical DateTimeOffset payload must round-trip"); Ensure(reader.Remaining == 0, "DateTimeOffset reader must consume exactly 16 bytes"); - var nonCanonical = bytes.ToArray(); + var nonCanonical = (byte[])bytes.Clone(); nonCanonical[2] = 1; var invalidReader = new SequenceReader(new ReadOnlySequence(nonCanonical)); try From 544955cc5dff83a17cb27e259377a1096664d8cf Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:33:43 +0800 Subject: [PATCH 190/399] chore: scan review compatibility surfaces --- .github/workflows/pr415-review-scan.yml | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/pr415-review-scan.yml diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml new file mode 100644 index 000000000..06fccc502 --- /dev/null +++ b/.github/workflows/pr415-review-scan.yml @@ -0,0 +1,26 @@ +name: PR415 Review Scan +on: + push: + branches: [feature/issue-396-deterministic-rpc-identity] + paths: [.github/workflows/pr415-review-scan.yml] +permissions: + contents: read +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Scan review surfaces + shell: bash + run: | + set -euo pipefail + echo '=== protocol literals ===' + grep -RInE 'Protocol[[:space:]]*=[[:space:]]*2|MinorVersion|MinimumCompatibleMinorVersion' src test doc --exclude-dir=bin --exclude-dir=obj | head -n 250 || true + echo '=== DateTimeOffsetRaw ===' + grep -RIn 'DateTimeOffsetRaw' . --exclude-dir=.git --exclude-dir=bin --exclude-dir=obj || true + echo '=== enum identity tests ===' + grep -RInE 'enum.*(CodecHash|AssemblyHash)|Enum.*Identity|enum Status|enum.*value' test/SharpLink.Generator.Tests --exclude-dir=bin --exclude-dir=obj | head -n 200 || true + echo '=== stale docs ===' + grep -RInE 'RpcCodecImplementation|SchemaId|WireFormatId' doc --exclude-dir=bin --exclude-dir=obj || true From 6fad4171b2bb0e1d933c0006e54a751677f4d2b5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:39:52 +0800 Subject: [PATCH 191/399] chore: apply latest review fixes --- .github/workflows/pr415-review-scan.yml | 122 +++++++++++++++++++++--- 1 file changed, 110 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 06fccc502..87a0600d0 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -1,26 +1,124 @@ -name: PR415 Review Scan +name: PR415 Review Patch on: push: branches: [feature/issue-396-deterministic-rpc-identity] paths: [.github/workflows/pr415-review-scan.yml] permissions: - contents: read + contents: write jobs: - scan: + patch: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - - name: Scan review surfaces + - name: Apply review fixes shell: bash run: | set -euo pipefail - echo '=== protocol literals ===' - grep -RInE 'Protocol[[:space:]]*=[[:space:]]*2|MinorVersion|MinimumCompatibleMinorVersion' src test doc --exclude-dir=bin --exclude-dir=obj | head -n 250 || true - echo '=== DateTimeOffsetRaw ===' - grep -RIn 'DateTimeOffsetRaw' . --exclude-dir=.git --exclude-dir=bin --exclude-dir=obj || true - echo '=== enum identity tests ===' - grep -RInE 'enum.*(CodecHash|AssemblyHash)|Enum.*Identity|enum Status|enum.*value' test/SharpLink.Generator.Tests --exclude-dir=bin --exclude-dir=obj | head -n 200 || true - echo '=== stale docs ===' - grep -RInE 'RpcCodecImplementation|SchemaId|WireFormatId' doc --exclude-dir=bin --exclude-dir=obj || true + python3 <<'PY' + from pathlib import Path + + def replace(path, old, new, count=1): + p = Path(path) + text = p.read_text() + actual = text.count(old) + if actual != count: + raise SystemExit(f"{path}: expected {count} occurrences, found {actual}: {old[:80]!r}") + p.write_text(text.replace(old, new)) + + replace('src/SharpLink.Abstractions/ProtocolV2.cs', + 'public const ushort MinorVersion = 4;', + 'public const ushort MinorVersion = 5;') + replace('src/SharpLink.Abstractions/ProtocolV2.cs', + 'public const ushort MinimumCompatibleMinorVersion = 4;', + 'public const ushort MinimumCompatibleMinorVersion = 5;') + replace('src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs', + '/// The unchanged SharpLink wire protocol version.\n public const int Protocol = 2;', + '/// The SharpLink wire protocol generation used by generated artifacts.\n public const int Protocol = 3;') + + replace('src/SharpLink.Runtime/RpcSession.Negotiation.cs', + ''' if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }''', + ''' if (options.ProtocolMinorVersion < ProtocolV2Constants.MinimumCompatibleMinorVersion ||\n options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} is outside the local supported " +\n $"range [{ProtocolV2Constants.MinimumCompatibleMinorVersion}, {ProtocolV2Constants.MinorVersion}].");\n }''') + + codec = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') + text = codec.read_text() + old_fixed = ''' private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member)\n {\n var typeName = member.FixedTypeName ?? member.TypeName;\n if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) ||\n string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal))\n {\n return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1";\n }\n\n return string.Join(\n ":",\n "fixed/v1",\n member.FixedSize.ToString(InvariantCulture),\n member.EnumUnderlyingType ?? typeName);\n }\n''' + new_fixed = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member)\n {\n var typeName = member.FixedTypeName ?? member.TypeName;\n if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) ||\n string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal))\n {\n return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1";\n }\n\n if (member.EnumUnderlyingType is not null &&\n TryResolveReachableType(typeName, out var resolvedType) &&\n resolvedType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType)\n {\n return string.Join(\n ":",\n "fixed/v2",\n member.FixedSize.ToString(InvariantCulture),\n member.EnumUnderlyingType,\n GetEnumDeclarationSemanticIdentity(enumType).ToHex());\n }\n\n return string.Join(\n ":",\n "fixed/v1",\n member.FixedSize.ToString(InvariantCulture),\n member.EnumUnderlyingType ?? typeName);\n }\n\n private static RpcHashValue GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType)\n {\n var parts = new List\n {\n "enum-declaration/v1",\n GetTypeName(enumType),\n enumType.EnumUnderlyingType is null ? "" : GetTypeName(enumType.EnumUnderlyingType)\n };\n foreach (var field in enumType.GetMembers()\n .OfType()\n .Where(static field => field.HasConstantValue)\n .OrderBy(static field => field.Name, StringComparer.Ordinal))\n {\n parts.Add(field.Name);\n parts.Add(Convert.ToString(field.ConstantValue, InvariantCulture) ?? "");\n }\n return Hashing.GetSemanticHash(parts.ToArray());\n }\n''' + if text.count(old_fixed) != 1: + raise SystemExit('CodecIdentity fixed-member block did not match exactly once') + text = text.replace(old_fixed, new_fixed) + old_enum = ''' hash = Hashing.GetSemanticHash(\n "codec/v1",\n "enum",\n GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' + new_enum = ''' hash = Hashing.GetSemanticHash(\n "codec/v1",\n "enum",\n GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(),\n GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type).ToHex());''' + if text.count(old_enum) != 1: + raise SystemExit('CodecIdentity direct-enum block did not match exactly once') + codec.write_text(text.replace(old_enum, new_enum)) + + fixtures = Path('test/SharpLink.CodecCompatibility/Fixtures.cs') + text = fixtures.read_text() + marker = '[StructLayout(LayoutKind.Sequential)]\ninternal struct SequentialControl { public byte A; public int B; public long C; }' + nested = '''[StructLayout(LayoutKind.Sequential)]\ninternal struct DateTimeOffsetContainer { public byte Prefix; public DateTimeOffset Value; public long Tail; }\n\n''' + if text.count(marker) != 1: + raise SystemExit('Fixtures struct insertion marker mismatch') + text = text.replace(marker, nested + marker) + fixture_line = ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),' + nested_fixture = ''' new Fixture("DateTimeOffsetContainerRaw", "builtin-semantic-raw", new DateTimeOffsetContainer { Prefix = 0x5A, Value = new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), Tail = 0x0102030405060708 }, static (left, right) => left.Prefix == right.Prefix && left.Value.Ticks == right.Value.Ticks && left.Value.UtcTicks == right.Value.UtcTicks && left.Value.Offset == right.Value.Offset && left.Tail == right.Tail, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)),''' + if text.count(fixture_line) != 1: + raise SystemExit('DateTimeOffset fixture insertion marker mismatch') + fixtures.write_text(text.replace(fixture_line, fixture_line + '\n' + nested_fixture)) + + replace('test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs', + ' new("DateTimeOffsetRaw", "builtin-semantic-raw", false, true),', + ' new("DateTimeOffsetRaw", "builtin-semantic-raw", false, true),\n new("DateTimeOffsetContainerRaw", "builtin-semantic-raw", false, true),') + + test_path = Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs') + if test_path.exists(): + raise SystemExit('unexpected existing eighth review test file') + test_path.write_text('''using System;\nusing System.Linq;\nusing System.Threading.Tasks;\n\nnamespace SharpLink.Generator.Tests;\n\npublic partial class RpcAnalyzerTests\n{\n [Test]\n public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity()\n {\n static string Source(bool swapped)\n {\n var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1";\n return BuildSource($$"""\npublic enum Status : byte { {{members}} }\n\n[SharpLink.Sdk.RpcSerializable]\npublic sealed class EnumEnvelope\n{\n public Status Value { get; set; }\n}\n\n[SharpLink.Sdk.RpcContract]\npublic interface IEnumIdentityContract : SharpLink.Sdk.IService\n{\n ValueTask EchoStatus(Status value, CancellationToken cancellationToken);\n ValueTask EchoEnvelope(EnumEnvelope value, CancellationToken cancellationToken);\n}\n""");\n }\n\n var baseline = RunGeneratorAndGetSources(Source(swapped: false))\n .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal));\n var changed = RunGeneratorAndGetSources(Source(swapped: true))\n .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal));\n\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "Status") != ExtractGeneratedCodecIdentity(changed, "Status"),\n "swapping enum name/value mappings must change the direct enum CodecHash even when the underlying byte width is unchanged");\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(changed, "EnumEnvelope"),\n "enum declaration identity must propagate through fixed DTO members");\n Ensure(\n !string.Equals(baseline, changed, StringComparison.Ordinal),\n "the changed enum mapping must propagate into the generated RPC assembly identity source");\n return Task.CompletedTask;\n }\n}\n''') + + docs = Path('doc/contracts-and-codecs.md') + text = docs.read_text() + text = text.replace( + '- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。', + '- 当前 Phase 1 identity 模型为 fixed-width `CodecHash` / `RpcAssemblyHash`:DTO、collection、Framework primitive 与最终 Codec graph 的 wire semantic 会递归传播到 RPC identity;远端 bind 时的 assembly-level exact equality 属于后续阶段。') + text = text.replace( + 'Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。当前 dev 仍要求 Codec 用 `RpcCodecImplementation` 提供 legacy wire/schema registration identity;这不是 #386 新定义的长期 compatibility API,后续由 #396 的 hash identity 模型替换:', + 'Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。Opaque custom Codec 必须用 `[RpcCodecSemanticIdentity(high, low)]` 声明其 wire semantic identity;当编码、解码或兼容性语义变化时必须 bump 该 identity:') + text = text.replace('[RpcCodecImplementation("my-type/v1", "my-type-schema/v1")]', '[RpcCodecSemanticIdentity(0x0123456789ABCDEFUL, 0xFEDCBA9876543210UL)]') + text = text.replace('不存在另一条通过 `RpcCodecAdapter(... WireFormatId = ...)` 绑定手写 `IRpcCodec` 的 Direct API。', '不存在另一条通过 Adapter metadata 绑定手写 `IRpcCodec` 的 Direct API。') + if 'RpcCodecImplementation' in text or 'SchemaId' in text or 'WireFormatId' in text: + raise SystemExit('contracts-and-codecs.md still contains stale identity API names') + docs.write_text(text) + + compat = Path('doc/codec-compatibility.md') + text = compat.read_text() + anchor = 'This distinction is already useful evidence: Android Mono/CoreCLR and iOS Mono runs observed a `DateTimeOffsetRaw` representation difference relative to another runtime while the logical fixture definition was the same. That representation-only observation is retained as non-blocking evidence rather than converted into an unsafe semantic materialization.' + replacement = anchor + ' UnsafeBlit compatibility for framework-owned raw layouts is therefore release-scoped: SharpLink requires stability only within the supported runtime/platform matrix for the same wire protocol/release, and a supported ABI change requires a protocol/release compatibility boundary rather than an AutoLayout runtime guard.' + if text.count(anchor) != 1: + raise SystemExit('codec compatibility DateTimeOffset paragraph mismatch') + compat.write_text(text.replace(anchor, replacement)) + + migration = Path('doc/migration.md') + text = migration.read_text() + text = text.replace('`Protocol = 2`', '`Protocol = 3`') + text = text.replace('`protocolVersion: 2`', '`protocolVersion: 3`') + text = text.replace('`RpcCodecAttribute`/`RpcCodecImplementationAttribute` 并带 schema identity', '`RpcCodecAttribute` + `[RpcCodecSemanticIdentity(high, low)]` 声明 opaque wire semantic identity') + migration.write_text(text) + + vnext = Path('doc/runtime-phase-17-generated-abi-vnext.md') + text = vnext.read_text() + text = text.replace('(`RpcCodecAttribute` / `RpcCodecImplementationAttribute`)。', '(`RpcCodecAttribute` / `RpcCodecSemanticIdentityAttribute`)。') + text = text.replace('`Protocol = 2` 不变', '`Protocol = 3`,对应本 release 的新 wire generation') + vnext.write_text(text) + PY + + dotnet format SharpLink.slnx --verify-no-changes --no-restore || dotnet format SharpLink.slnx --no-restore + dotnet test test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release --no-restore + dotnet test test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release --no-restore + dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release --no-restore + + git config user.name "SunSi12138" + git config user.email "54728594+SunSi12138@users.noreply.github.com" + git add src test doc + git commit -m "fix: address protocol and enum identity review" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity From fa59b2cd61370f58448b81c32ff3aa1afbf5f4ca Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:41:09 +0800 Subject: [PATCH 192/399] chore: rerun review patch with project validation --- .github/workflows/pr415-review-scan.yml | 112 +++--------------------- 1 file changed, 11 insertions(+), 101 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 87a0600d0..4fbd42607 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -11,112 +11,22 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 1 - - name: Apply review fixes + fetch-depth: 2 + - name: Apply and validate review fixes shell: bash run: | set -euo pipefail - python3 <<'PY' - from pathlib import Path + git show HEAD^:.github/workflows/pr415-review-scan.yml \ + | awk '/^ python3 <<.PY.$/{capture=1; next} /^ PY$/{capture=0} capture{sub(/^ /, ""); print}' \ + > /tmp/pr415_patch.py + test -s /tmp/pr415_patch.py + python3 /tmp/pr415_patch.py - def replace(path, old, new, count=1): - p = Path(path) - text = p.read_text() - actual = text.count(old) - if actual != count: - raise SystemExit(f"{path}: expected {count} occurrences, found {actual}: {old[:80]!r}") - p.write_text(text.replace(old, new)) - - replace('src/SharpLink.Abstractions/ProtocolV2.cs', - 'public const ushort MinorVersion = 4;', - 'public const ushort MinorVersion = 5;') - replace('src/SharpLink.Abstractions/ProtocolV2.cs', - 'public const ushort MinimumCompatibleMinorVersion = 4;', - 'public const ushort MinimumCompatibleMinorVersion = 5;') - replace('src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs', - '/// The unchanged SharpLink wire protocol version.\n public const int Protocol = 2;', - '/// The SharpLink wire protocol generation used by generated artifacts.\n public const int Protocol = 3;') - - replace('src/SharpLink.Runtime/RpcSession.Negotiation.cs', - ''' if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }''', - ''' if (options.ProtocolMinorVersion < ProtocolV2Constants.MinimumCompatibleMinorVersion ||\n options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} is outside the local supported " +\n $"range [{ProtocolV2Constants.MinimumCompatibleMinorVersion}, {ProtocolV2Constants.MinorVersion}].");\n }''') - - codec = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') - text = codec.read_text() - old_fixed = ''' private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member)\n {\n var typeName = member.FixedTypeName ?? member.TypeName;\n if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) ||\n string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal))\n {\n return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1";\n }\n\n return string.Join(\n ":",\n "fixed/v1",\n member.FixedSize.ToString(InvariantCulture),\n member.EnumUnderlyingType ?? typeName);\n }\n''' - new_fixed = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member)\n {\n var typeName = member.FixedTypeName ?? member.TypeName;\n if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) ||\n string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal))\n {\n return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1";\n }\n\n if (member.EnumUnderlyingType is not null &&\n TryResolveReachableType(typeName, out var resolvedType) &&\n resolvedType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType)\n {\n return string.Join(\n ":",\n "fixed/v2",\n member.FixedSize.ToString(InvariantCulture),\n member.EnumUnderlyingType,\n GetEnumDeclarationSemanticIdentity(enumType).ToHex());\n }\n\n return string.Join(\n ":",\n "fixed/v1",\n member.FixedSize.ToString(InvariantCulture),\n member.EnumUnderlyingType ?? typeName);\n }\n\n private static RpcHashValue GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType)\n {\n var parts = new List\n {\n "enum-declaration/v1",\n GetTypeName(enumType),\n enumType.EnumUnderlyingType is null ? "" : GetTypeName(enumType.EnumUnderlyingType)\n };\n foreach (var field in enumType.GetMembers()\n .OfType()\n .Where(static field => field.HasConstantValue)\n .OrderBy(static field => field.Name, StringComparer.Ordinal))\n {\n parts.Add(field.Name);\n parts.Add(Convert.ToString(field.ConstantValue, InvariantCulture) ?? "");\n }\n return Hashing.GetSemanticHash(parts.ToArray());\n }\n''' - if text.count(old_fixed) != 1: - raise SystemExit('CodecIdentity fixed-member block did not match exactly once') - text = text.replace(old_fixed, new_fixed) - old_enum = ''' hash = Hashing.GetSemanticHash(\n "codec/v1",\n "enum",\n GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' - new_enum = ''' hash = Hashing.GetSemanticHash(\n "codec/v1",\n "enum",\n GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(),\n GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type).ToHex());''' - if text.count(old_enum) != 1: - raise SystemExit('CodecIdentity direct-enum block did not match exactly once') - codec.write_text(text.replace(old_enum, new_enum)) - - fixtures = Path('test/SharpLink.CodecCompatibility/Fixtures.cs') - text = fixtures.read_text() - marker = '[StructLayout(LayoutKind.Sequential)]\ninternal struct SequentialControl { public byte A; public int B; public long C; }' - nested = '''[StructLayout(LayoutKind.Sequential)]\ninternal struct DateTimeOffsetContainer { public byte Prefix; public DateTimeOffset Value; public long Tail; }\n\n''' - if text.count(marker) != 1: - raise SystemExit('Fixtures struct insertion marker mismatch') - text = text.replace(marker, nested + marker) - fixture_line = ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),' - nested_fixture = ''' new Fixture("DateTimeOffsetContainerRaw", "builtin-semantic-raw", new DateTimeOffsetContainer { Prefix = 0x5A, Value = new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), Tail = 0x0102030405060708 }, static (left, right) => left.Prefix == right.Prefix && left.Value.Ticks == right.Value.Ticks && left.Value.UtcTicks == right.Value.UtcTicks && left.Value.Offset == right.Value.Offset && left.Tail == right.Tail, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)),''' - if text.count(fixture_line) != 1: - raise SystemExit('DateTimeOffset fixture insertion marker mismatch') - fixtures.write_text(text.replace(fixture_line, fixture_line + '\n' + nested_fixture)) - - replace('test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs', - ' new("DateTimeOffsetRaw", "builtin-semantic-raw", false, true),', - ' new("DateTimeOffsetRaw", "builtin-semantic-raw", false, true),\n new("DateTimeOffsetContainerRaw", "builtin-semantic-raw", false, true),') - - test_path = Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs') - if test_path.exists(): - raise SystemExit('unexpected existing eighth review test file') - test_path.write_text('''using System;\nusing System.Linq;\nusing System.Threading.Tasks;\n\nnamespace SharpLink.Generator.Tests;\n\npublic partial class RpcAnalyzerTests\n{\n [Test]\n public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity()\n {\n static string Source(bool swapped)\n {\n var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1";\n return BuildSource($$"""\npublic enum Status : byte { {{members}} }\n\n[SharpLink.Sdk.RpcSerializable]\npublic sealed class EnumEnvelope\n{\n public Status Value { get; set; }\n}\n\n[SharpLink.Sdk.RpcContract]\npublic interface IEnumIdentityContract : SharpLink.Sdk.IService\n{\n ValueTask EchoStatus(Status value, CancellationToken cancellationToken);\n ValueTask EchoEnvelope(EnumEnvelope value, CancellationToken cancellationToken);\n}\n""");\n }\n\n var baseline = RunGeneratorAndGetSources(Source(swapped: false))\n .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal));\n var changed = RunGeneratorAndGetSources(Source(swapped: true))\n .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal));\n\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "Status") != ExtractGeneratedCodecIdentity(changed, "Status"),\n "swapping enum name/value mappings must change the direct enum CodecHash even when the underlying byte width is unchanged");\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(changed, "EnumEnvelope"),\n "enum declaration identity must propagate through fixed DTO members");\n Ensure(\n !string.Equals(baseline, changed, StringComparison.Ordinal),\n "the changed enum mapping must propagate into the generated RPC assembly identity source");\n return Task.CompletedTask;\n }\n}\n''') - - docs = Path('doc/contracts-and-codecs.md') - text = docs.read_text() - text = text.replace( - '- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。', - '- 当前 Phase 1 identity 模型为 fixed-width `CodecHash` / `RpcAssemblyHash`:DTO、collection、Framework primitive 与最终 Codec graph 的 wire semantic 会递归传播到 RPC identity;远端 bind 时的 assembly-level exact equality 属于后续阶段。') - text = text.replace( - 'Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。当前 dev 仍要求 Codec 用 `RpcCodecImplementation` 提供 legacy wire/schema registration identity;这不是 #386 新定义的长期 compatibility API,后续由 #396 的 hash identity 模型替换:', - 'Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。Opaque custom Codec 必须用 `[RpcCodecSemanticIdentity(high, low)]` 声明其 wire semantic identity;当编码、解码或兼容性语义变化时必须 bump 该 identity:') - text = text.replace('[RpcCodecImplementation("my-type/v1", "my-type-schema/v1")]', '[RpcCodecSemanticIdentity(0x0123456789ABCDEFUL, 0xFEDCBA9876543210UL)]') - text = text.replace('不存在另一条通过 `RpcCodecAdapter(... WireFormatId = ...)` 绑定手写 `IRpcCodec` 的 Direct API。', '不存在另一条通过 Adapter metadata 绑定手写 `IRpcCodec` 的 Direct API。') - if 'RpcCodecImplementation' in text or 'SchemaId' in text or 'WireFormatId' in text: - raise SystemExit('contracts-and-codecs.md still contains stale identity API names') - docs.write_text(text) - - compat = Path('doc/codec-compatibility.md') - text = compat.read_text() - anchor = 'This distinction is already useful evidence: Android Mono/CoreCLR and iOS Mono runs observed a `DateTimeOffsetRaw` representation difference relative to another runtime while the logical fixture definition was the same. That representation-only observation is retained as non-blocking evidence rather than converted into an unsafe semantic materialization.' - replacement = anchor + ' UnsafeBlit compatibility for framework-owned raw layouts is therefore release-scoped: SharpLink requires stability only within the supported runtime/platform matrix for the same wire protocol/release, and a supported ABI change requires a protocol/release compatibility boundary rather than an AutoLayout runtime guard.' - if text.count(anchor) != 1: - raise SystemExit('codec compatibility DateTimeOffset paragraph mismatch') - compat.write_text(text.replace(anchor, replacement)) - - migration = Path('doc/migration.md') - text = migration.read_text() - text = text.replace('`Protocol = 2`', '`Protocol = 3`') - text = text.replace('`protocolVersion: 2`', '`protocolVersion: 3`') - text = text.replace('`RpcCodecAttribute`/`RpcCodecImplementationAttribute` 并带 schema identity', '`RpcCodecAttribute` + `[RpcCodecSemanticIdentity(high, low)]` 声明 opaque wire semantic identity') - migration.write_text(text) - - vnext = Path('doc/runtime-phase-17-generated-abi-vnext.md') - text = vnext.read_text() - text = text.replace('(`RpcCodecAttribute` / `RpcCodecImplementationAttribute`)。', '(`RpcCodecAttribute` / `RpcCodecSemanticIdentityAttribute`)。') - text = text.replace('`Protocol = 2` 不变', '`Protocol = 3`,对应本 release 的新 wire generation') - vnext.write_text(text) - PY - - dotnet format SharpLink.slnx --verify-no-changes --no-restore || dotnet format SharpLink.slnx --no-restore - dotnet test test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release --no-restore - dotnet test test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release --no-restore - dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release --no-restore + dotnet test test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release + dotnet test test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release + dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release + git diff --check git config user.name "SunSi12138" git config user.email "54728594+SunSi12138@users.noreply.github.com" git add src test doc From 307aa395e7793b6f3c038dacc6ac99ee38953560 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:43:02 +0800 Subject: [PATCH 193/399] chore: validate review patch with current dotnet cli --- .github/workflows/pr415-review-scan.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 4fbd42607..148c87329 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -11,19 +11,19 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 3 - name: Apply and validate review fixes shell: bash run: | set -euo pipefail - git show HEAD^:.github/workflows/pr415-review-scan.yml \ + git show HEAD^^:.github/workflows/pr415-review-scan.yml \ | awk '/^ python3 <<.PY.$/{capture=1; next} /^ PY$/{capture=0} capture{sub(/^ /, ""); print}' \ > /tmp/pr415_patch.py test -s /tmp/pr415_patch.py python3 /tmp/pr415_patch.py - dotnet test test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release - dotnet test test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release + dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release + dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release git diff --check From d6525eb122e2a7565072893891d6b8fa10165ce6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:44:57 +0800 Subject: [PATCH 194/399] chore: validate enum identity propagation --- .github/workflows/pr415-review-scan.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 148c87329..860c96e7a 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -11,17 +11,28 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 3 + fetch-depth: 4 - name: Apply and validate review fixes shell: bash run: | set -euo pipefail - git show HEAD^^:.github/workflows/pr415-review-scan.yml \ + git show 6fad4171b2bb0e1d933c0006e54a751677f4d2b5:.github/workflows/pr415-review-scan.yml \ | awk '/^ python3 <<.PY.$/{capture=1; next} /^ PY$/{capture=0} capture{sub(/^ /, ""); print}' \ > /tmp/pr415_patch.py test -s /tmp/pr415_patch.py python3 /tmp/pr415_patch.py + python3 <<'PY' + from pathlib import Path + p = Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs') + text = p.read_text() + old = ''' Ensure(\n ExtractGeneratedCodecIdentity(baseline, "Status") != ExtractGeneratedCodecIdentity(changed, "Status"),\n "swapping enum name/value mappings must change the direct enum CodecHash even when the underlying byte width is unchanged");\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(changed, "EnumEnvelope"),\n "enum declaration identity must propagate through fixed DTO members");\n Ensure(\n !string.Equals(baseline, changed, StringComparison.Ordinal),\n "the changed enum mapping must propagate into the generated RPC assembly identity source");''' + new = ''' Ensure(\n ExtractGeneratedMethodId(baseline, "EchoStatus") != ExtractGeneratedMethodId(changed, "EchoStatus"),\n "swapping enum name/value mappings must change a direct-enum method identity even when the underlying byte width is unchanged");\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(changed, "EnumEnvelope"),\n "enum declaration identity must propagate through fixed DTO members");\n Ensure(\n ExtractGeneratedRpcAssemblyHash(baseline) != ExtractGeneratedRpcAssemblyHash(changed),\n "the changed enum mapping must propagate into RpcAssemblyHash");''' + if text.count(old) != 1: + raise SystemExit('enum regression assertion block did not match exactly once') + p.write_text(text.replace(old, new)) + PY + dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release From 06c9b78024a26192e9fed1d946006fb327d5e623 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:47:13 +0800 Subject: [PATCH 195/399] chore: isolate direct and dto enum identity tests --- .github/workflows/pr415-review-scan.yml | 81 +++++++++++++++++++++---- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 860c96e7a..87a2c134e 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 4 + fetch-depth: 5 - name: Apply and validate review fixes shell: bash run: | @@ -22,16 +22,75 @@ jobs: test -s /tmp/pr415_patch.py python3 /tmp/pr415_patch.py - python3 <<'PY' - from pathlib import Path - p = Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs') - text = p.read_text() - old = ''' Ensure(\n ExtractGeneratedCodecIdentity(baseline, "Status") != ExtractGeneratedCodecIdentity(changed, "Status"),\n "swapping enum name/value mappings must change the direct enum CodecHash even when the underlying byte width is unchanged");\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(changed, "EnumEnvelope"),\n "enum declaration identity must propagate through fixed DTO members");\n Ensure(\n !string.Equals(baseline, changed, StringComparison.Ordinal),\n "the changed enum mapping must propagate into the generated RPC assembly identity source");''' - new = ''' Ensure(\n ExtractGeneratedMethodId(baseline, "EchoStatus") != ExtractGeneratedMethodId(changed, "EchoStatus"),\n "swapping enum name/value mappings must change a direct-enum method identity even when the underlying byte width is unchanged");\n Ensure(\n ExtractGeneratedCodecIdentity(baseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(changed, "EnumEnvelope"),\n "enum declaration identity must propagate through fixed DTO members");\n Ensure(\n ExtractGeneratedRpcAssemblyHash(baseline) != ExtractGeneratedRpcAssemblyHash(changed),\n "the changed enum mapping must propagate into RpcAssemblyHash");''' - if text.count(old) != 1: - raise SystemExit('enum regression assertion block did not match exactly once') - p.write_text(text.replace(old, new)) - PY + cat > test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs <<'CS' + using System; + using System.Linq; + using System.Threading.Tasks; + + namespace SharpLink.Generator.Tests; + + public partial class RpcAnalyzerTests + { + [Test] + public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity() + { + static string DirectSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" + public enum Status : byte { {{members}} } + + [SharpLink.Sdk.RpcContract] + public interface IDirectEnumIdentityContract : SharpLink.Sdk.IService + { + ValueTask Echo(Status value, CancellationToken cancellationToken); + } + """); + } + + static string DtoSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" + public enum Status : byte { {{members}} } + + [SharpLink.Sdk.RpcSerializable] + public sealed class EnumEnvelope + { + public Status Value { get; set; } + } + + [SharpLink.Sdk.RpcContract] + public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService + { + ValueTask Echo(EnumEnvelope value, CancellationToken cancellationToken); + } + """); + } + + static string Manifest(string source) + => RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + + var directBaseline = Manifest(DirectSource(swapped: false)); + var directChanged = Manifest(DirectSource(swapped: true)); + Ensure( + ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), + "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); + + var dtoBaseline = Manifest(DtoSource(swapped: false)); + var dtoChanged = Manifest(DtoSource(swapped: true)); + Ensure( + ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), + "enum declaration identity must propagate through fixed DTO members"); + Ensure( + ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), + "the DTO enum mapping change must propagate into RpcAssemblyHash"); + return Task.CompletedTask; + } + } + CS + sed -i 's/^ //' test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release From f2cf745f2eeb88da1f7f2376451c12e36ab099cb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:38:28 +0800 Subject: [PATCH 196/399] chore: scan PR415 review markers --- .github/workflows/pr415-review-scan.yml | 106 ++++-------------------- 1 file changed, 14 insertions(+), 92 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 87a2c134e..87207a71b 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -1,104 +1,26 @@ -name: PR415 Review Patch +name: PR415 Review Scan on: push: branches: [feature/issue-396-deterministic-rpc-identity] paths: [.github/workflows/pr415-review-scan.yml] permissions: - contents: write + contents: read jobs: - patch: + scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 5 - - name: Apply and validate review fixes + - name: Locate review markers shell: bash run: | set -euo pipefail - git show 6fad4171b2bb0e1d933c0006e54a751677f4d2b5:.github/workflows/pr415-review-scan.yml \ - | awk '/^ python3 <<.PY.$/{capture=1; next} /^ PY$/{capture=0} capture{sub(/^ /, ""); print}' \ - > /tmp/pr415_patch.py - test -s /tmp/pr415_patch.py - python3 /tmp/pr415_patch.py - - cat > test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs <<'CS' - using System; - using System.Linq; - using System.Threading.Tasks; - - namespace SharpLink.Generator.Tests; - - public partial class RpcAnalyzerTests - { - [Test] - public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity() - { - static string DirectSource(bool swapped) - { - var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; - return BuildSource($$""" - public enum Status : byte { {{members}} } - - [SharpLink.Sdk.RpcContract] - public interface IDirectEnumIdentityContract : SharpLink.Sdk.IService - { - ValueTask Echo(Status value, CancellationToken cancellationToken); - } - """); - } - - static string DtoSource(bool swapped) - { - var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; - return BuildSource($$""" - public enum Status : byte { {{members}} } - - [SharpLink.Sdk.RpcSerializable] - public sealed class EnumEnvelope - { - public Status Value { get; set; } - } - - [SharpLink.Sdk.RpcContract] - public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService - { - ValueTask Echo(EnumEnvelope value, CancellationToken cancellationToken); - } - """); - } - - static string Manifest(string source) - => RunGeneratorAndGetSources(source) - .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - - var directBaseline = Manifest(DirectSource(swapped: false)); - var directChanged = Manifest(DirectSource(swapped: true)); - Ensure( - ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), - "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); - - var dtoBaseline = Manifest(DtoSource(swapped: false)); - var dtoChanged = Manifest(DtoSource(swapped: true)); - Ensure( - ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), - "enum declaration identity must propagate through fixed DTO members"); - Ensure( - ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), - "the DTO enum mapping change must propagate into RpcAssemblyHash"); - return Task.CompletedTask; - } - } - CS - sed -i 's/^ //' test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs - - dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release - dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release - dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release - - git diff --check - git config user.name "SunSi12138" - git config user.email "54728594+SunSi12138@users.noreply.github.com" - git add src test doc - git commit -m "fix: address protocol and enum identity review" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity + echo '=== DateTimeOffsetRaw ===' + git grep -n -C 8 'DateTimeOffsetRaw' -- test/SharpLink.CodecCompatibility || true + echo '=== stale docs ===' + git grep -n -C 5 -E 'RpcCodecImplementationAttribute|SchemaId|WireFormatId' -- doc || true + echo '=== DTO string helper ===' + git grep -n -C 6 -E '__SharpLinkGeneratedUtf16|AppendGeneratedUtf16Helper|WriteInt32LittleEndian.*byteCount' -- src/SharpLink.Generator || true + echo '=== enum resolution helpers ===' + git grep -n -C 5 -E 'TryResolveReachableType|GetFixedMemberSemanticIdentity|TypeKind == TypeKind.Enum' -- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs || true + echo '=== generated wire string ===' + git grep -n -C 12 'ReadString\|WriteString' -- src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs || true From cb54814df51f24f0939b5bc849b6fb168865d26b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:42:23 +0800 Subject: [PATCH 197/399] chore: validate PR415 review fixes --- .github/workflows/pr415-review-scan.yml | 244 ++++++++++++++++++++++-- 1 file changed, 230 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 87207a71b..6f1487046 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -1,26 +1,242 @@ -name: PR415 Review Scan +name: PR415 Review Patch on: push: branches: [feature/issue-396-deterministic-rpc-identity] paths: [.github/workflows/pr415-review-scan.yml] permissions: - contents: read + contents: write jobs: - scan: + patch: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Locate review markers + - name: Apply and validate review fixes shell: bash run: | set -euo pipefail - echo '=== DateTimeOffsetRaw ===' - git grep -n -C 8 'DateTimeOffsetRaw' -- test/SharpLink.CodecCompatibility || true - echo '=== stale docs ===' - git grep -n -C 5 -E 'RpcCodecImplementationAttribute|SchemaId|WireFormatId' -- doc || true - echo '=== DTO string helper ===' - git grep -n -C 6 -E '__SharpLinkGeneratedUtf16|AppendGeneratedUtf16Helper|WriteInt32LittleEndian.*byteCount' -- src/SharpLink.Generator || true - echo '=== enum resolution helpers ===' - git grep -n -C 5 -E 'TryResolveReachableType|GetFixedMemberSemanticIdentity|TypeKind == TypeKind.Enum' -- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs || true - echo '=== generated wire string ===' - git grep -n -C 12 'ReadString\|WriteString' -- src/SharpLink.Abstractions/RpcGeneratedCodecWire.cs || true + python3 <<'PY' + from pathlib import Path + + def replace_one(path, old, new): + p = Path(path) + text = p.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one marker, found {count}: {old[:100]!r}") + p.write_text(text.replace(old, new)) + + # Transport wire generation boundary: keep generated-manifest Protocol=2; fence old v2 minors at handshake. + replace_one( + 'src/SharpLink.Abstractions/ProtocolV2.cs', + ' public const ushort MinorVersion = 4;\n\n /// Old protocol minors used absolute wall-clock deadlines and are not wire-compatible.\n public const ushort MinimumCompatibleMinorVersion = 4;', + ' public const ushort MinorVersion = 5;\n\n /// Old protocol minors predate the current generated DTO wire generation and are not wire-compatible.\n public const ushort MinimumCompatibleMinorVersion = 5;') + + replace_one( + 'src/SharpLink.Runtime/RpcSession.Negotiation.cs', + ' if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }', + ' if (options.ProtocolMinorVersion < ProtocolV2Constants.MinimumCompatibleMinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} is below the local " +\n $"compatibility floor {ProtocolV2Constants.MinimumCompatibleMinorVersion}.");\n }\n if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }') + + # Enum semantic identity: declaration name/value mapping is part of meaning for both direct and DTO-fixed enum paths. + codec = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') + text = codec.read_text() + old = ''' private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) + { + var typeName = member.FixedTypeName ?? member.TypeName; + if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || + string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.EnumUnderlyingType ?? typeName); + } +''' + new = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) + { + var typeName = member.FixedTypeName ?? member.TypeName; + if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || + string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + + if (member.EnumUnderlyingType is not null && + TryResolveReachableType(member.TypeName, out var fixedMemberType) && + fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.EnumUnderlyingType ?? typeName); + } + + private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + { + var parts = new List + { + "enum-declaration/v1", + enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + foreach (var field in enumType.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + } + return string.Join("|", parts); + } +''' + if text.count(old) != 1: + raise SystemExit('CodecIdentity fixed-member marker mismatch') + text = text.replace(old, new) + old = ''' hash = Hashing.GetSemanticHash( + "codec/v1", + "enum", + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' + new = ''' hash = Hashing.GetSemanticHash( + "codec/v1", + "enum", + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), + GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type));''' + if text.count(old) != 1: + raise SystemExit('CodecIdentity direct-enum marker mismatch') + codec.write_text(text.replace(old, new)) + + # Release-scoped nested DateTimeOffset raw-layout evidence; no product guard/rejection. + replace_one( + 'test/SharpLink.CodecCompatibility/Fixtures.cs', + ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),', + ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),\n new Fixture("DateTimeOffsetNestedRaw", "builtin-semantic-raw", new DateTimeOffsetContainer { Prefix = 0x5A, Value = new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), Tail = 0x0102030405060708 }, static (left, right) => left.Prefix == right.Prefix && left.Value.Ticks == right.Value.Ticks && left.Value.UtcTicks == right.Value.UtcTicks && left.Value.Offset == right.Value.Offset && left.Tail == right.Tail, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)),') + replace_one( + 'test/SharpLink.CodecCompatibility/Fixtures.cs', + '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }', + '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }\n\n[StructLayout(LayoutKind.Sequential)]\ninternal struct DateTimeOffsetContainer { public byte Prefix; public DateTimeOffset Value; public long Tail; }') + + # Documentation: Phase 1 hash model is current; opaque codecs declare semantic identity. + replace_one( + 'doc/contracts-and-codecs.md', + '- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。', + '- 当前 Phase 1 identity 模型由最终 Codec graph 上的 fixed-width `CodecHash`、方法/契约 hash 与 `RpcAssemblyHash` 组成;dispatch route ID 只负责路由,不承担 wire compatibility identity。远端 assembly hash 发布与 bind-time exact equality 仍属于 #396 后续阶段。') + old_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。当前 dev 仍要求 Codec 用 `RpcCodecImplementation` 提供 legacy wire/schema registration identity;这不是 #386 新定义的长期 compatibility API,后续由 #396 的 hash identity 模型替换: + +```csharp +[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] + +[RpcCodecImplementation("my-type/v1", "my-type-schema/v1")] +public sealed class MyTypeCodec : IRpcCodec +{ + // ... +} +```''' + new_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。Opaque custom Codec 必须用 `[RpcCodecSemanticIdentity(high, low)]` 声明其 wire semantic identity;最终 `CodecHash` 将这份显式 identity 纳入方法、契约与 `RpcAssemblyHash`。只要编码含义或兼容性发生变化,就必须 bump semantic identity: + +```csharp +[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] + +[RpcCodecSemanticIdentity(0x0123456789ABCDEF, 0xFEDCBA9876543210)] +public sealed class MyTypeCodec : IRpcCodec +{ + // ... +} +```''' + replace_one('doc/contracts-and-codecs.md', old_doc, new_doc) + replace_one( + 'doc/contracts-and-codecs.md', + '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。', + '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。UnsafeBlit 的兼容性承诺收敛为同一 protocol/release 的受支持 runtime/platform matrix 内稳定;它不承诺跨 protocol/release 或任意 CLR ABI 的 raw-layout 可移植性。') + + # Enum regression: direct enum affects assembly identity; DTO enum also affects containing CodecHash. + Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs').write_text(r'''using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity() + { + static string DirectSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" +public enum Status : byte { {{members}} } + +[SharpLink.Sdk.RpcContract] +public interface IDirectEnumIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(Status value, CancellationToken cancellationToken); +} +"""); + } + + static string DtoSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" +public enum Status : byte { {{members}} } + +[SharpLink.Sdk.RpcSerializable] +public sealed class EnumEnvelope +{ + public Status Value { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(EnumEnvelope value, CancellationToken cancellationToken); +} +"""); + } + + static string Manifest(string source) + => RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + + var directBaseline = Manifest(DirectSource(swapped: false)); + var directChanged = Manifest(DirectSource(swapped: true)); + Ensure( + ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), + "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); + + var dtoBaseline = Manifest(DtoSource(swapped: false)); + var dtoChanged = Manifest(DtoSource(swapped: true)); + Ensure( + ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), + "enum declaration identity must propagate through fixed DTO members"); + Ensure( + ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), + "the DTO enum mapping change must propagate into RpcAssemblyHash"); + return Task.CompletedTask; + } +} +''') + PY + + git diff --check + dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release + dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release + dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release + dotnet run --project test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release -- self --output /tmp/codec-self + + git config user.name "SunSi12138" + git config user.email "54728594+SunSi12138@users.noreply.github.com" + git add src test doc + git commit -m "fix: address protocol and enum identity review" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity From 36cbf26c7130d8526836c3d29a5305b8a4dc0de5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:43:35 +0800 Subject: [PATCH 198/399] chore: run PR415 review validation --- .github/workflows/pr415-review-run.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/pr415-review-run.yml diff --git a/.github/workflows/pr415-review-run.yml b/.github/workflows/pr415-review-run.yml new file mode 100644 index 000000000..adc1730e0 --- /dev/null +++ b/.github/workflows/pr415-review-run.yml @@ -0,0 +1,24 @@ +name: PR415 Review Runner +on: + pull_request: + branches: [dev] + paths: + - .github/workflows/pr415-review-run.yml + - .github/workflows/pr415-review-scan.yml +permissions: + contents: write +jobs: + run-review-patch: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Execute validated patch script + shell: bash + run: | + set -euo pipefail + awk 'BEGIN{capture=0} /^ run: \|$/{capture=1; next} capture{sub(/^ /, ""); print}' .github/workflows/pr415-review-scan.yml > /tmp/pr415-review.sh + test -s /tmp/pr415-review.sh + bash /tmp/pr415-review.sh From 05c59ce1f5444821936035ab529fbfb6aa622136 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:45:27 +0800 Subject: [PATCH 199/399] chore: harden PR415 review runner --- .github/workflows/pr415-review-run.yml | 87 +++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr415-review-run.yml b/.github/workflows/pr415-review-run.yml index adc1730e0..756d64a63 100644 --- a/.github/workflows/pr415-review-run.yml +++ b/.github/workflows/pr415-review-run.yml @@ -19,6 +19,89 @@ jobs: shell: bash run: | set -euo pipefail - awk 'BEGIN{capture=0} /^ run: \|$/{capture=1; next} capture{sub(/^ /, ""); print}' .github/workflows/pr415-review-scan.yml > /tmp/pr415-review.sh - test -s /tmp/pr415-review.sh + python3 <<'PY' + from pathlib import Path + import re + + p = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') + text = p.read_text() + replacement = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) + { + var typeName = member.FixedTypeName ?? member.TypeName; + if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || + string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + + if (member.EnumUnderlyingType is not null && + TryResolveReachableType(member.TypeName, out var fixedMemberType) && + fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.EnumUnderlyingType ?? typeName); + } + + private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + { + var parts = new List + { + "enum-declaration/v1", + enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + foreach (var field in enumType.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + } + return string.Join("|", parts); + } + +''' + pattern = r' private static string GetFixedMemberSemanticIdentity\(GeneratedMemberModel member\)\n \{.*?\n \}\n\n(?= private bool TryGetFrameworkPrimitiveCodecHash)' + text, count = re.subn(pattern, replacement, text, count=1, flags=re.S) + if count != 1: + raise SystemExit(f'fixed-member regex count={count}') + old = ''' hash = Hashing.GetSemanticHash( + "codec/v1", + "enum", + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' + new = ''' hash = Hashing.GetSemanticHash( + "codec/v1", + "enum", + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), + GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type));''' + if text.count(old) != 1: + raise SystemExit(f'direct enum marker count={text.count(old)}') + p.write_text(text.replace(old, new)) + + script = Path('.github/workflows/pr415-review-scan.yml').read_text() + marker = re.search(r'^ run: \|\n(?P.*)\Z', script, flags=re.M | re.S) + if marker is None: + raise SystemExit('cannot extract source run block') + body = marker.group('body') + body = '\n'.join(line[10:] if line.startswith(' ') else line for line in body.splitlines()) + '\n' + body, count = re.subn( + r'\n# Enum semantic identity:.*?\n# Release-scoped', + '\n# Release-scoped', + body, + count=1, + flags=re.S) + if count != 1: + raise SystemExit(f'cannot remove source enum block count={count}') + Path('/tmp/pr415-review.sh').write_text(body) + PY bash /tmp/pr415-review.sh From 4c22f1bcd24419d15f845bae63bf01fc2235467e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:19:24 +0800 Subject: [PATCH 200/399] chore: add protocol boundary review validation --- .github/workflows/pr415-review-run.yml | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/.github/workflows/pr415-review-run.yml b/.github/workflows/pr415-review-run.yml index 756d64a63..154cc155d 100644 --- a/.github/workflows/pr415-review-run.yml +++ b/.github/workflows/pr415-review-run.yml @@ -88,6 +88,70 @@ jobs: raise SystemExit(f'direct enum marker count={text.count(old)}') p.write_text(text.replace(old, new)) + Path('test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs').write_text(r'''namespace SharpLink.UnitTests.Protocol; + +public class ProtocolV2WireGenerationBoundaryTests +{ + [Test] + public void PreviousMinorShouldBeRejectedDuringHandshake() + { + Ensure( + ProtocolV2Constants.MinimumCompatibleMinorVersion == ProtocolV2Constants.MinorVersion, + "the intentional DTO wire break must advance the current minor and compatibility floor together"); + var previousMinor = checked((ushort)(ProtocolV2Constants.MinimumCompatibleMinorVersion - 1)); + var policy = ProtocolV2Negotiator.CreateImplementedPolicy( + SharpLinkProtocolOptions.MinMaxFramePayloadBytes, + 1024, + 2048, + Array.Empty()); + var offer = ProtocolV2Negotiator.CreateClientOffer( + policy, + ProtocolV2Capabilities.None, + ReadOnlyMemory.Empty); + + var serverFailure = Capture(() => ProtocolV2Negotiator.NegotiateServer( + offer with { MinorVersion = previousMinor }, + policy)); + Ensure( + serverFailure.Code == SharpLinkErrorCode.Unimplemented, + "the server must reject a previous wire-generation offer during handshake"); + + var response = new ProtocolV2HandshakeResponse( + offer.MinorVersion, + ProtocolV2Capabilities.None, + offer.MaxFramePayloadBytes, + offer.StreamReceiveWindowBytes, + offer.ConnectionReceiveWindowBytes); + var clientFailure = Capture(() => ProtocolV2Negotiator.ValidateServerResponse( + offer, + response with { MinorVersion = previousMinor }, + policy)); + Ensure( + clientFailure.Code == SharpLinkErrorCode.Unimplemented, + "the client must reject a previous wire-generation response during handshake"); + } + + private static SharpLinkException Capture(Action action) + { + try + { + action(); + } + catch (SharpLinkException exception) + { + return exception; + } + throw new InvalidOperationException("Expected SharpLinkException."); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} +''') + script = Path('.github/workflows/pr415-review-scan.yml').read_text() marker = re.search(r'^ run: \|\n(?P.*)\Z', script, flags=re.M | re.S) if marker is None: From 0ee043318ee40569aabc43f7ad85d294f37fef50 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:20:39 +0800 Subject: [PATCH 201/399] chore: run final PR415 review validation --- .github/workflows/pr415-review-scan.yml | 192 +++++++++++++++--------- 1 file changed, 122 insertions(+), 70 deletions(-) diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml index 6f1487046..0a33ffb7f 100644 --- a/.github/workflows/pr415-review-scan.yml +++ b/.github/workflows/pr415-review-scan.yml @@ -16,6 +16,7 @@ jobs: set -euo pipefail python3 <<'PY' from pathlib import Path + import re def replace_one(path, old, new): p = Path(path) @@ -25,84 +26,70 @@ jobs: raise SystemExit(f"{path}: expected one marker, found {count}: {old[:100]!r}") p.write_text(text.replace(old, new)) - # Transport wire generation boundary: keep generated-manifest Protocol=2; fence old v2 minors at handshake. + # Transport wire generation boundary. Keep generated-manifest Protocol=2; fence old v2 minors during handshake. replace_one( 'src/SharpLink.Abstractions/ProtocolV2.cs', ' public const ushort MinorVersion = 4;\n\n /// Old protocol minors used absolute wall-clock deadlines and are not wire-compatible.\n public const ushort MinimumCompatibleMinorVersion = 4;', - ' public const ushort MinorVersion = 5;\n\n /// Old protocol minors predate the current generated DTO wire generation and are not wire-compatible.\n public const ushort MinimumCompatibleMinorVersion = 5;') + ' public const ushort MinorVersion = 5;\n\n /// Protocol minors below this floor predate the current wire generation and are not wire-compatible.\n public const ushort MinimumCompatibleMinorVersion = 5;') replace_one( 'src/SharpLink.Runtime/RpcSession.Negotiation.cs', ' if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }', ' if (options.ProtocolMinorVersion < ProtocolV2Constants.MinimumCompatibleMinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} is below the local " +\n $"compatibility floor {ProtocolV2Constants.MinimumCompatibleMinorVersion}.");\n }\n if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }') - # Enum semantic identity: declaration name/value mapping is part of meaning for both direct and DTO-fixed enum paths. + # Enum semantic identity. The declaration's canonical name/value mapping is RPC-visible meaning. codec = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') text = codec.read_text() - old = ''' private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) - { - var typeName = member.FixedTypeName ?? member.TypeName; - if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || - string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) - { - return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; - } - - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - member.EnumUnderlyingType ?? typeName); - } -''' - new = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) - { - var typeName = member.FixedTypeName ?? member.TypeName; - if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || - string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) - { - return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; - } - - if (member.EnumUnderlyingType is not null && - TryResolveReachableType(member.TypeName, out var fixedMemberType) && - fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) - { - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - member.EnumUnderlyingType ?? typeName); - } + replacement = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) + { + var typeName = member.FixedTypeName ?? member.TypeName; + if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || + string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + + if (member.EnumUnderlyingType is not null && + TryResolveReachableType(member.TypeName, out var fixedMemberType) && + fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.EnumUnderlyingType ?? typeName); + } + + private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + { + var parts = new List + { + "enum-declaration/v1", + enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + foreach (var field in enumType.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + } + return string.Join("|", parts); + } - private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) - { - var parts = new List - { - "enum-declaration/v1", - enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - }; - foreach (var field in enumType.GetMembers() - .OfType() - .Where(static field => field.HasConstantValue) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); - } - return string.Join("|", parts); - } ''' - if text.count(old) != 1: - raise SystemExit('CodecIdentity fixed-member marker mismatch') - text = text.replace(old, new) + pattern = r' private static string GetFixedMemberSemanticIdentity\(GeneratedMemberModel member\)\n \{.*?\n \}\n\n(?= private bool TryGetFrameworkPrimitiveCodecHash)' + text, count = re.subn(pattern, replacement, text, count=1, flags=re.S) + if count != 1: + raise SystemExit(f'fixed-member regex count={count}') old = ''' hash = Hashing.GetSemanticHash( "codec/v1", "enum", @@ -113,10 +100,10 @@ jobs: GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type));''' if text.count(old) != 1: - raise SystemExit('CodecIdentity direct-enum marker mismatch') + raise SystemExit(f'direct enum marker count={text.count(old)}') codec.write_text(text.replace(old, new)) - # Release-scoped nested DateTimeOffset raw-layout evidence; no product guard/rejection. + # Release-scoped nested DateTimeOffset raw-layout evidence; no runtime guard/rejection. replace_one( 'test/SharpLink.CodecCompatibility/Fixtures.cs', ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),', @@ -126,7 +113,7 @@ jobs: '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }', '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }\n\n[StructLayout(LayoutKind.Sequential)]\ninternal struct DateTimeOffsetContainer { public byte Prefix; public DateTimeOffset Value; public long Tail; }') - # Documentation: Phase 1 hash model is current; opaque codecs declare semantic identity. + # Documentation. Phase 1 hashes are current and opaque custom codecs use semantic identity. replace_one( 'doc/contracts-and-codecs.md', '- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。', @@ -159,7 +146,7 @@ public sealed class MyTypeCodec : IRpcCodec '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。', '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。UnsafeBlit 的兼容性承诺收敛为同一 protocol/release 的受支持 runtime/platform matrix 内稳定;它不承诺跨 protocol/release 或任意 CLR ABI 的 raw-layout 可移植性。') - # Enum regression: direct enum affects assembly identity; DTO enum also affects containing CodecHash. + # Enum regression: direct enum changes assembly identity; DTO enum changes containing CodecHash too. Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs').write_text(r'''using System; using System.Linq; using System.Threading.Tasks; @@ -226,6 +213,71 @@ public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService return Task.CompletedTask; } } +''') + + # Protocol regression: previous wire generation must fail in both handshake directions before Ready/payload use. + Path('test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs').write_text(r'''namespace SharpLink.UnitTests.Protocol; + +public class ProtocolV2WireGenerationBoundaryTests +{ + [Test] + public void PreviousMinorShouldBeRejectedDuringHandshake() + { + Ensure( + ProtocolV2Constants.MinimumCompatibleMinorVersion == ProtocolV2Constants.MinorVersion, + "the intentional DTO wire break must advance the current minor and compatibility floor together"); + var previousMinor = checked((ushort)(ProtocolV2Constants.MinimumCompatibleMinorVersion - 1)); + var policy = ProtocolV2Negotiator.CreateImplementedPolicy( + SharpLinkProtocolOptions.MinMaxFramePayloadBytes, + 1024, + 2048, + Array.Empty()); + var offer = ProtocolV2Negotiator.CreateClientOffer( + policy, + ProtocolV2Capabilities.None, + ReadOnlyMemory.Empty); + + var serverFailure = Capture(() => ProtocolV2Negotiator.NegotiateServer( + offer with { MinorVersion = previousMinor }, + policy)); + Ensure( + serverFailure.Code == SharpLinkErrorCode.Unimplemented, + "the server must reject a previous wire-generation offer during handshake"); + + var response = new ProtocolV2HandshakeResponse( + offer.MinorVersion, + ProtocolV2Capabilities.None, + offer.MaxFramePayloadBytes, + offer.StreamReceiveWindowBytes, + offer.ConnectionReceiveWindowBytes); + var clientFailure = Capture(() => ProtocolV2Negotiator.ValidateServerResponse( + offer, + response with { MinorVersion = previousMinor }, + policy)); + Ensure( + clientFailure.Code == SharpLinkErrorCode.Unimplemented, + "the client must reject a previous wire-generation response during handshake"); + } + + private static SharpLinkException Capture(Action action) + { + try + { + action(); + } + catch (SharpLinkException exception) + { + return exception; + } + throw new InvalidOperationException("Expected SharpLinkException."); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} ''') PY @@ -238,5 +290,5 @@ public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService git config user.name "SunSi12138" git config user.email "54728594+SunSi12138@users.noreply.github.com" git add src test doc - git commit -m "fix: address protocol and enum identity review" + git commit -m "fix: close deterministic identity review gaps" git push origin HEAD:feature/issue-396-deterministic-rpc-identity From 42cb8118a75aeb82fe79b2b565bba43b916b35ee Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:21:59 +0800 Subject: [PATCH 202/399] fix: fence the new DTO wire generation --- src/SharpLink.Abstractions/ProtocolV2.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Abstractions/ProtocolV2.cs b/src/SharpLink.Abstractions/ProtocolV2.cs index 149e49495..ce84cea65 100644 --- a/src/SharpLink.Abstractions/ProtocolV2.cs +++ b/src/SharpLink.Abstractions/ProtocolV2.cs @@ -13,10 +13,10 @@ public static class ProtocolV2Constants public const int RequestPrefixBytes = 16; /// Current protocol minor version. - public const ushort MinorVersion = 4; + public const ushort MinorVersion = 5; - /// Old protocol minors used absolute wall-clock deadlines and are not wire-compatible. - public const ushort MinimumCompatibleMinorVersion = 4; + /// Protocol minors below this floor predate the current wire generation and are not wire-compatible. + public const ushort MinimumCompatibleMinorVersion = 5; } /// Protocol v2 frame types. From 06dedf5693a5defc01e77f68e5c18a1dc058e696 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:22:38 +0800 Subject: [PATCH 203/399] fix: reject stale negotiated protocol minors --- src/SharpLink.Runtime/RpcSession.Negotiation.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SharpLink.Runtime/RpcSession.Negotiation.cs b/src/SharpLink.Runtime/RpcSession.Negotiation.cs index 2022e5636..1b906aff0 100644 --- a/src/SharpLink.Runtime/RpcSession.Negotiation.cs +++ b/src/SharpLink.Runtime/RpcSession.Negotiation.cs @@ -64,6 +64,12 @@ internal void EnsureInboundFrameAllowed( private StreamFlowController? ValidateAndCreateNegotiatedFlowController( NegotiatedSessionOptions options) { + if (options.ProtocolMinorVersion < ProtocolV2Constants.MinimumCompatibleMinorVersion) + { + throw NegotiationViolation( + $"Negotiated protocol minor version {options.ProtocolMinorVersion} is below the local " + + $"compatibility floor {ProtocolV2Constants.MinimumCompatibleMinorVersion}."); + } if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion) { throw NegotiationViolation( From 893badb8ddc53e6b8fcfaf5b1a0e84f47ed61f36 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:22:55 +0800 Subject: [PATCH 204/399] test: cover protocol wire generation boundary --- .../ProtocolV2WireGenerationBoundaryTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs diff --git a/test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs b/test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs new file mode 100644 index 000000000..499e6a457 --- /dev/null +++ b/test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs @@ -0,0 +1,62 @@ +namespace SharpLink.UnitTests.Protocol; + +public class ProtocolV2WireGenerationBoundaryTests +{ + [Test] + public void PreviousMinorShouldBeRejectedDuringHandshake() + { + Ensure( + ProtocolV2Constants.MinimumCompatibleMinorVersion == ProtocolV2Constants.MinorVersion, + "the intentional DTO wire break must advance the current minor and compatibility floor together"); + var previousMinor = checked((ushort)(ProtocolV2Constants.MinimumCompatibleMinorVersion - 1)); + var policy = ProtocolV2Negotiator.CreateImplementedPolicy( + SharpLinkProtocolOptions.MinMaxFramePayloadBytes, + 1024, + 2048, + Array.Empty()); + var offer = ProtocolV2Negotiator.CreateClientOffer( + policy, + ProtocolV2Capabilities.None, + ReadOnlyMemory.Empty); + + var serverFailure = Capture(() => ProtocolV2Negotiator.NegotiateServer( + offer with { MinorVersion = previousMinor }, + policy)); + Ensure( + serverFailure.Code == SharpLinkErrorCode.Unimplemented, + "the server must reject a previous wire-generation offer during handshake"); + + var response = new ProtocolV2HandshakeResponse( + offer.MinorVersion, + ProtocolV2Capabilities.None, + offer.MaxFramePayloadBytes, + offer.StreamReceiveWindowBytes, + offer.ConnectionReceiveWindowBytes); + var clientFailure = Capture(() => ProtocolV2Negotiator.ValidateServerResponse( + offer, + response with { MinorVersion = previousMinor }, + policy)); + Ensure( + clientFailure.Code == SharpLinkErrorCode.Unimplemented, + "the client must reject a previous wire-generation response during handshake"); + } + + private static SharpLinkException Capture(Action action) + { + try + { + action(); + } + catch (SharpLinkException exception) + { + return exception; + } + throw new InvalidOperationException("Expected SharpLinkException."); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} From b0e0f34b48d81570d1d909e6534829159c32bfc9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:24:33 +0800 Subject: [PATCH 205/399] chore: run trusted final review patch --- .github/workflows/pr-fast.yml | 228 +++++++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-fast.yml b/.github/workflows/pr-fast.yml index afe9651c3..84baeaa1e 100644 --- a/.github/workflows/pr-fast.yml +++ b/.github/workflows/pr-fast.yml @@ -1,7 +1,7 @@ name: PR Fast permissions: - contents: read + contents: write on: pull_request: @@ -13,6 +13,232 @@ concurrency: cancel-in-progress: true jobs: + review-patch: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout PR head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 2 + + - name: Apply, validate, and commit final review fixes + shell: bash + run: | + set -euo pipefail + git fetch origin dev --depth=1 + python3 <<'PY' + from pathlib import Path + import re + + def replace_one(path, old, new): + p = Path(path) + text = p.read_text() + if new in text: + return + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one marker, found {count}: {old[:100]!r}") + p.write_text(text.replace(old, new)) + + codec = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') + text = codec.read_text() + if 'GetEnumDeclarationSemanticIdentity' not in text: + replacement = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) + { + var typeName = member.FixedTypeName ?? member.TypeName; + if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || + string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + + if (member.EnumUnderlyingType is not null && + TryResolveReachableType(member.TypeName, out var fixedMemberType) && + fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.EnumUnderlyingType ?? typeName); + } + + private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + { + var parts = new List + { + "enum-declaration/v1", + enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + foreach (var field in enumType.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + } + return string.Join("|", parts); + } + +''' + pattern = r' private static string GetFixedMemberSemanticIdentity\(GeneratedMemberModel member\)\n \{.*?\n \}\n\n(?= private bool TryGetFrameworkPrimitiveCodecHash)' + text, count = re.subn(pattern, replacement, text, count=1, flags=re.S) + if count != 1: + raise SystemExit(f'fixed-member regex count={count}') + old = ''' hash = Hashing.GetSemanticHash( + "codec/v1", + "enum", + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' + new = ''' hash = Hashing.GetSemanticHash( + "codec/v1", + "enum", + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), + GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type));''' + if text.count(old) != 1: + raise SystemExit(f'direct enum marker count={text.count(old)}') + text = text.replace(old, new) + codec.write_text(text) + + replace_one( + 'test/SharpLink.CodecCompatibility/Fixtures.cs', + ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),', + ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),\n new Fixture("DateTimeOffsetNestedRaw", "builtin-semantic-raw", new DateTimeOffsetContainer { Prefix = 0x5A, Value = new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), Tail = 0x0102030405060708 }, static (left, right) => left.Prefix == right.Prefix && left.Value.Ticks == right.Value.Ticks && left.Value.UtcTicks == right.Value.UtcTicks && left.Value.Offset == right.Value.Offset && left.Tail == right.Tail, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)),') + replace_one( + 'test/SharpLink.CodecCompatibility/Fixtures.cs', + '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }', + '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }\n\n[StructLayout(LayoutKind.Sequential)]\ninternal struct DateTimeOffsetContainer { public byte Prefix; public DateTimeOffset Value; public long Tail; }') + + replace_one( + 'doc/contracts-and-codecs.md', + '- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。', + '- 当前 Phase 1 identity 模型由最终 Codec graph 上的 fixed-width `CodecHash`、方法/契约 hash 与 `RpcAssemblyHash` 组成;dispatch route ID 只负责路由,不承担 wire compatibility identity。远端 assembly hash 发布与 bind-time exact equality 仍属于 #396 后续阶段。') + old_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。当前 dev 仍要求 Codec 用 `RpcCodecImplementation` 提供 legacy wire/schema registration identity;这不是 #386 新定义的长期 compatibility API,后续由 #396 的 hash identity 模型替换: + +```csharp +[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] + +[RpcCodecImplementation("my-type/v1", "my-type-schema/v1")] +public sealed class MyTypeCodec : IRpcCodec +{ + // ... +} +```''' + new_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。Opaque custom Codec 必须用 `[RpcCodecSemanticIdentity(high, low)]` 声明其 wire semantic identity;最终 `CodecHash` 将这份显式 identity 纳入方法、契约与 `RpcAssemblyHash`。只要编码含义或兼容性发生变化,就必须 bump semantic identity: + +```csharp +[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] + +[RpcCodecSemanticIdentity(0x0123456789ABCDEF, 0xFEDCBA9876543210)] +public sealed class MyTypeCodec : IRpcCodec +{ + // ... +} +```''' + replace_one('doc/contracts-and-codecs.md', old_doc, new_doc) + replace_one( + 'doc/contracts-and-codecs.md', + '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。', + '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。UnsafeBlit 的兼容性承诺收敛为同一 protocol/release 的受支持 runtime/platform matrix 内稳定;它不承诺跨 protocol/release 或任意 CLR ABI 的 raw-layout 可移植性。') + + Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs').write_text(r'''using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity() + { + static string DirectSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" +public enum Status : byte { {{members}} } + +[SharpLink.Sdk.RpcContract] +public interface IDirectEnumIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(Status value, CancellationToken cancellationToken); +} +"""); + } + + static string DtoSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" +public enum Status : byte { {{members}} } + +[SharpLink.Sdk.RpcSerializable] +public sealed class EnumEnvelope +{ + public Status Value { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(EnumEnvelope value, CancellationToken cancellationToken); +} +"""); + } + + static string Manifest(string source) + => RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + + var directBaseline = Manifest(DirectSource(swapped: false)); + var directChanged = Manifest(DirectSource(swapped: true)); + Ensure( + ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), + "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); + + var dtoBaseline = Manifest(DtoSource(swapped: false)); + var dtoChanged = Manifest(DtoSource(swapped: true)); + Ensure( + ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), + "enum declaration identity must propagate through fixed DTO members"); + Ensure( + ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), + "the DTO enum mapping change must propagate into RpcAssemblyHash"); + return Task.CompletedTask; + } +} +''') + PY + + git rm -f .github/workflows/pr415-review-run.yml .github/workflows/pr415-review-scan.yml + git show origin/dev:.github/workflows/pr-fast.yml > .github/workflows/pr-fast.yml + + git diff --check + dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release + dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release + dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release + dotnet run --project test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release -- self --output /tmp/codec-self + + git config user.name "SunSi12138" + git config user.email "54728594+SunSi12138@users.noreply.github.com" + git add src test doc .github/workflows + if git diff --cached --quiet; then + echo "No review patch changes remain." + exit 0 + fi + git commit -m "fix: close deterministic identity review gaps" + git push origin HEAD:${{ github.event.pull_request.head.ref }} + fast: runs-on: ubuntu-latest timeout-minutes: 5 From 4857b86e419ad449426e4bce908e3790750205a8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:29:18 +0800 Subject: [PATCH 206/399] fix: close deterministic identity review gaps --- .github/workflows/pr-fast.yml | 228 +------------- .github/workflows/pr415-review-run.yml | 171 ---------- .github/workflows/pr415-review-scan.yml | 294 ------------------ doc/contracts-and-codecs.md | 6 +- .../RpcGenerator.CodecIdentity.cs | 34 +- .../RpcCodecEighthReviewRegressionTests.cs | 66 ++++ 6 files changed, 102 insertions(+), 697 deletions(-) delete mode 100644 .github/workflows/pr415-review-run.yml delete mode 100644 .github/workflows/pr415-review-scan.yml create mode 100644 test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs diff --git a/.github/workflows/pr-fast.yml b/.github/workflows/pr-fast.yml index 84baeaa1e..afe9651c3 100644 --- a/.github/workflows/pr-fast.yml +++ b/.github/workflows/pr-fast.yml @@ -1,7 +1,7 @@ name: PR Fast permissions: - contents: write + contents: read on: pull_request: @@ -13,232 +13,6 @@ concurrency: cancel-in-progress: true jobs: - review-patch: - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout PR head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 2 - - - name: Apply, validate, and commit final review fixes - shell: bash - run: | - set -euo pipefail - git fetch origin dev --depth=1 - python3 <<'PY' - from pathlib import Path - import re - - def replace_one(path, old, new): - p = Path(path) - text = p.read_text() - if new in text: - return - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one marker, found {count}: {old[:100]!r}") - p.write_text(text.replace(old, new)) - - codec = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') - text = codec.read_text() - if 'GetEnumDeclarationSemanticIdentity' not in text: - replacement = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) - { - var typeName = member.FixedTypeName ?? member.TypeName; - if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || - string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) - { - return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; - } - - if (member.EnumUnderlyingType is not null && - TryResolveReachableType(member.TypeName, out var fixedMemberType) && - fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) - { - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - member.EnumUnderlyingType ?? typeName); - } - - private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) - { - var parts = new List - { - "enum-declaration/v1", - enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - }; - foreach (var field in enumType.GetMembers() - .OfType() - .Where(static field => field.HasConstantValue) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); - } - return string.Join("|", parts); - } - -''' - pattern = r' private static string GetFixedMemberSemanticIdentity\(GeneratedMemberModel member\)\n \{.*?\n \}\n\n(?= private bool TryGetFrameworkPrimitiveCodecHash)' - text, count = re.subn(pattern, replacement, text, count=1, flags=re.S) - if count != 1: - raise SystemExit(f'fixed-member regex count={count}') - old = ''' hash = Hashing.GetSemanticHash( - "codec/v1", - "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' - new = ''' hash = Hashing.GetSemanticHash( - "codec/v1", - "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), - GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type));''' - if text.count(old) != 1: - raise SystemExit(f'direct enum marker count={text.count(old)}') - text = text.replace(old, new) - codec.write_text(text) - - replace_one( - 'test/SharpLink.CodecCompatibility/Fixtures.cs', - ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),', - ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),\n new Fixture("DateTimeOffsetNestedRaw", "builtin-semantic-raw", new DateTimeOffsetContainer { Prefix = 0x5A, Value = new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), Tail = 0x0102030405060708 }, static (left, right) => left.Prefix == right.Prefix && left.Value.Ticks == right.Value.Ticks && left.Value.UtcTicks == right.Value.UtcTicks && left.Value.Offset == right.Value.Offset && left.Tail == right.Tail, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)),') - replace_one( - 'test/SharpLink.CodecCompatibility/Fixtures.cs', - '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }', - '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }\n\n[StructLayout(LayoutKind.Sequential)]\ninternal struct DateTimeOffsetContainer { public byte Prefix; public DateTimeOffset Value; public long Tail; }') - - replace_one( - 'doc/contracts-and-codecs.md', - '- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。', - '- 当前 Phase 1 identity 模型由最终 Codec graph 上的 fixed-width `CodecHash`、方法/契约 hash 与 `RpcAssemblyHash` 组成;dispatch route ID 只负责路由,不承担 wire compatibility identity。远端 assembly hash 发布与 bind-time exact equality 仍属于 #396 后续阶段。') - old_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。当前 dev 仍要求 Codec 用 `RpcCodecImplementation` 提供 legacy wire/schema registration identity;这不是 #386 新定义的长期 compatibility API,后续由 #396 的 hash identity 模型替换: - -```csharp -[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] - -[RpcCodecImplementation("my-type/v1", "my-type-schema/v1")] -public sealed class MyTypeCodec : IRpcCodec -{ - // ... -} -```''' - new_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。Opaque custom Codec 必须用 `[RpcCodecSemanticIdentity(high, low)]` 声明其 wire semantic identity;最终 `CodecHash` 将这份显式 identity 纳入方法、契约与 `RpcAssemblyHash`。只要编码含义或兼容性发生变化,就必须 bump semantic identity: - -```csharp -[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] - -[RpcCodecSemanticIdentity(0x0123456789ABCDEF, 0xFEDCBA9876543210)] -public sealed class MyTypeCodec : IRpcCodec -{ - // ... -} -```''' - replace_one('doc/contracts-and-codecs.md', old_doc, new_doc) - replace_one( - 'doc/contracts-and-codecs.md', - '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。', - '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。UnsafeBlit 的兼容性承诺收敛为同一 protocol/release 的受支持 runtime/platform matrix 内稳定;它不承诺跨 protocol/release 或任意 CLR ABI 的 raw-layout 可移植性。') - - Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs').write_text(r'''using System; -using System.Linq; -using System.Threading.Tasks; - -namespace SharpLink.Generator.Tests; - -public partial class RpcAnalyzerTests -{ - [Test] - public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity() - { - static string DirectSource(bool swapped) - { - var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; - return BuildSource($$""" -public enum Status : byte { {{members}} } - -[SharpLink.Sdk.RpcContract] -public interface IDirectEnumIdentityContract : SharpLink.Sdk.IService -{ - ValueTask Echo(Status value, CancellationToken cancellationToken); -} -"""); - } - - static string DtoSource(bool swapped) - { - var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; - return BuildSource($$""" -public enum Status : byte { {{members}} } - -[SharpLink.Sdk.RpcSerializable] -public sealed class EnumEnvelope -{ - public Status Value { get; set; } -} - -[SharpLink.Sdk.RpcContract] -public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService -{ - ValueTask Echo(EnumEnvelope value, CancellationToken cancellationToken); -} -"""); - } - - static string Manifest(string source) - => RunGeneratorAndGetSources(source) - .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - - var directBaseline = Manifest(DirectSource(swapped: false)); - var directChanged = Manifest(DirectSource(swapped: true)); - Ensure( - ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), - "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); - - var dtoBaseline = Manifest(DtoSource(swapped: false)); - var dtoChanged = Manifest(DtoSource(swapped: true)); - Ensure( - ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), - "enum declaration identity must propagate through fixed DTO members"); - Ensure( - ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), - "the DTO enum mapping change must propagate into RpcAssemblyHash"); - return Task.CompletedTask; - } -} -''') - PY - - git rm -f .github/workflows/pr415-review-run.yml .github/workflows/pr415-review-scan.yml - git show origin/dev:.github/workflows/pr-fast.yml > .github/workflows/pr-fast.yml - - git diff --check - dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release - dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release - dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release - dotnet run --project test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release -- self --output /tmp/codec-self - - git config user.name "SunSi12138" - git config user.email "54728594+SunSi12138@users.noreply.github.com" - git add src test doc .github/workflows - if git diff --cached --quiet; then - echo "No review patch changes remain." - exit 0 - fi - git commit -m "fix: close deterministic identity review gaps" - git push origin HEAD:${{ github.event.pull_request.head.ref }} - fast: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/pr415-review-run.yml b/.github/workflows/pr415-review-run.yml deleted file mode 100644 index 154cc155d..000000000 --- a/.github/workflows/pr415-review-run.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: PR415 Review Runner -on: - pull_request: - branches: [dev] - paths: - - .github/workflows/pr415-review-run.yml - - .github/workflows/pr415-review-scan.yml -permissions: - contents: write -jobs: - run-review-patch: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - - name: Execute validated patch script - shell: bash - run: | - set -euo pipefail - python3 <<'PY' - from pathlib import Path - import re - - p = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') - text = p.read_text() - replacement = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) - { - var typeName = member.FixedTypeName ?? member.TypeName; - if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || - string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) - { - return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; - } - - if (member.EnumUnderlyingType is not null && - TryResolveReachableType(member.TypeName, out var fixedMemberType) && - fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) - { - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - member.EnumUnderlyingType ?? typeName); - } - - private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) - { - var parts = new List - { - "enum-declaration/v1", - enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - }; - foreach (var field in enumType.GetMembers() - .OfType() - .Where(static field => field.HasConstantValue) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); - } - return string.Join("|", parts); - } - -''' - pattern = r' private static string GetFixedMemberSemanticIdentity\(GeneratedMemberModel member\)\n \{.*?\n \}\n\n(?= private bool TryGetFrameworkPrimitiveCodecHash)' - text, count = re.subn(pattern, replacement, text, count=1, flags=re.S) - if count != 1: - raise SystemExit(f'fixed-member regex count={count}') - old = ''' hash = Hashing.GetSemanticHash( - "codec/v1", - "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' - new = ''' hash = Hashing.GetSemanticHash( - "codec/v1", - "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), - GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type));''' - if text.count(old) != 1: - raise SystemExit(f'direct enum marker count={text.count(old)}') - p.write_text(text.replace(old, new)) - - Path('test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs').write_text(r'''namespace SharpLink.UnitTests.Protocol; - -public class ProtocolV2WireGenerationBoundaryTests -{ - [Test] - public void PreviousMinorShouldBeRejectedDuringHandshake() - { - Ensure( - ProtocolV2Constants.MinimumCompatibleMinorVersion == ProtocolV2Constants.MinorVersion, - "the intentional DTO wire break must advance the current minor and compatibility floor together"); - var previousMinor = checked((ushort)(ProtocolV2Constants.MinimumCompatibleMinorVersion - 1)); - var policy = ProtocolV2Negotiator.CreateImplementedPolicy( - SharpLinkProtocolOptions.MinMaxFramePayloadBytes, - 1024, - 2048, - Array.Empty()); - var offer = ProtocolV2Negotiator.CreateClientOffer( - policy, - ProtocolV2Capabilities.None, - ReadOnlyMemory.Empty); - - var serverFailure = Capture(() => ProtocolV2Negotiator.NegotiateServer( - offer with { MinorVersion = previousMinor }, - policy)); - Ensure( - serverFailure.Code == SharpLinkErrorCode.Unimplemented, - "the server must reject a previous wire-generation offer during handshake"); - - var response = new ProtocolV2HandshakeResponse( - offer.MinorVersion, - ProtocolV2Capabilities.None, - offer.MaxFramePayloadBytes, - offer.StreamReceiveWindowBytes, - offer.ConnectionReceiveWindowBytes); - var clientFailure = Capture(() => ProtocolV2Negotiator.ValidateServerResponse( - offer, - response with { MinorVersion = previousMinor }, - policy)); - Ensure( - clientFailure.Code == SharpLinkErrorCode.Unimplemented, - "the client must reject a previous wire-generation response during handshake"); - } - - private static SharpLinkException Capture(Action action) - { - try - { - action(); - } - catch (SharpLinkException exception) - { - return exception; - } - throw new InvalidOperationException("Expected SharpLinkException."); - } - - private static void Ensure(bool condition, string message) - { - if (!condition) - throw new InvalidOperationException(message); - } -} -''') - - script = Path('.github/workflows/pr415-review-scan.yml').read_text() - marker = re.search(r'^ run: \|\n(?P.*)\Z', script, flags=re.M | re.S) - if marker is None: - raise SystemExit('cannot extract source run block') - body = marker.group('body') - body = '\n'.join(line[10:] if line.startswith(' ') else line for line in body.splitlines()) + '\n' - body, count = re.subn( - r'\n# Enum semantic identity:.*?\n# Release-scoped', - '\n# Release-scoped', - body, - count=1, - flags=re.S) - if count != 1: - raise SystemExit(f'cannot remove source enum block count={count}') - Path('/tmp/pr415-review.sh').write_text(body) - PY - bash /tmp/pr415-review.sh diff --git a/.github/workflows/pr415-review-scan.yml b/.github/workflows/pr415-review-scan.yml deleted file mode 100644 index 0a33ffb7f..000000000 --- a/.github/workflows/pr415-review-scan.yml +++ /dev/null @@ -1,294 +0,0 @@ -name: PR415 Review Patch -on: - push: - branches: [feature/issue-396-deterministic-rpc-identity] - paths: [.github/workflows/pr415-review-scan.yml] -permissions: - contents: write -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Apply and validate review fixes - shell: bash - run: | - set -euo pipefail - python3 <<'PY' - from pathlib import Path - import re - - def replace_one(path, old, new): - p = Path(path) - text = p.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one marker, found {count}: {old[:100]!r}") - p.write_text(text.replace(old, new)) - - # Transport wire generation boundary. Keep generated-manifest Protocol=2; fence old v2 minors during handshake. - replace_one( - 'src/SharpLink.Abstractions/ProtocolV2.cs', - ' public const ushort MinorVersion = 4;\n\n /// Old protocol minors used absolute wall-clock deadlines and are not wire-compatible.\n public const ushort MinimumCompatibleMinorVersion = 4;', - ' public const ushort MinorVersion = 5;\n\n /// Protocol minors below this floor predate the current wire generation and are not wire-compatible.\n public const ushort MinimumCompatibleMinorVersion = 5;') - - replace_one( - 'src/SharpLink.Runtime/RpcSession.Negotiation.cs', - ' if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }', - ' if (options.ProtocolMinorVersion < ProtocolV2Constants.MinimumCompatibleMinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} is below the local " +\n $"compatibility floor {ProtocolV2Constants.MinimumCompatibleMinorVersion}.");\n }\n if (options.ProtocolMinorVersion > ProtocolV2Constants.MinorVersion)\n {\n throw NegotiationViolation(\n $"Negotiated protocol minor version {options.ProtocolMinorVersion} exceeds the local " +\n $"version {ProtocolV2Constants.MinorVersion}.");\n }') - - # Enum semantic identity. The declaration's canonical name/value mapping is RPC-visible meaning. - codec = Path('src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs') - text = codec.read_text() - replacement = ''' private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) - { - var typeName = member.FixedTypeName ?? member.TypeName; - if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || - string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) - { - return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; - } - - if (member.EnumUnderlyingType is not null && - TryResolveReachableType(member.TypeName, out var fixedMemberType) && - fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) - { - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - member.EnumUnderlyingType ?? typeName); - } - - private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) - { - var parts = new List - { - "enum-declaration/v1", - enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - }; - foreach (var field in enumType.GetMembers() - .OfType() - .Where(static field => field.HasConstantValue) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); - } - return string.Join("|", parts); - } - -''' - pattern = r' private static string GetFixedMemberSemanticIdentity\(GeneratedMemberModel member\)\n \{.*?\n \}\n\n(?= private bool TryGetFrameworkPrimitiveCodecHash)' - text, count = re.subn(pattern, replacement, text, count=1, flags=re.S) - if count != 1: - raise SystemExit(f'fixed-member regex count={count}') - old = ''' hash = Hashing.GetSemanticHash( - "codec/v1", - "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex());''' - new = ''' hash = Hashing.GetSemanticHash( - "codec/v1", - "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), - GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type));''' - if text.count(old) != 1: - raise SystemExit(f'direct enum marker count={text.count(old)}') - codec.write_text(text.replace(old, new)) - - # Release-scoped nested DateTimeOffset raw-layout evidence; no runtime guard/rejection. - replace_one( - 'test/SharpLink.CodecCompatibility/Fixtures.cs', - ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),', - ' new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset),\n new Fixture("DateTimeOffsetNestedRaw", "builtin-semantic-raw", new DateTimeOffsetContainer { Prefix = 0x5A, Value = new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), Tail = 0x0102030405060708 }, static (left, right) => left.Prefix == right.Prefix && left.Value.Ticks == right.Value.Ticks && left.Value.UtcTicks == right.Value.UtcTicks && left.Value.Offset == right.Value.Offset && left.Tail == right.Tail, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)),') - replace_one( - 'test/SharpLink.CodecCompatibility/Fixtures.cs', - '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }', - '[StructLayout(LayoutKind.Sequential)]\ninternal struct NestedPadded { public ByteInt32 Inner; public byte Tail; public long Count; }\n\n[StructLayout(LayoutKind.Sequential)]\ninternal struct DateTimeOffsetContainer { public byte Prefix; public DateTimeOffset Value; public long Tail; }') - - # Documentation. Phase 1 hashes are current and opaque custom codecs use semantic identity. - replace_one( - 'doc/contracts-and-codecs.md', - '- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。', - '- 当前 Phase 1 identity 模型由最终 Codec graph 上的 fixed-width `CodecHash`、方法/契约 hash 与 `RpcAssemblyHash` 组成;dispatch route ID 只负责路由,不承担 wire compatibility identity。远端 assembly hash 发布与 bind-time exact equality 仍属于 #396 后续阶段。') - old_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。当前 dev 仍要求 Codec 用 `RpcCodecImplementation` 提供 legacy wire/schema registration identity;这不是 #386 新定义的长期 compatibility API,后续由 #396 的 hash identity 模型替换: - -```csharp -[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] - -[RpcCodecImplementation("my-type/v1", "my-type-schema/v1")] -public sealed class MyTypeCodec : IRpcCodec -{ - // ... -} -```''' - new_doc = '''Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。Opaque custom Codec 必须用 `[RpcCodecSemanticIdentity(high, low)]` 声明其 wire semantic identity;最终 `CodecHash` 将这份显式 identity 纳入方法、契约与 `RpcAssemblyHash`。只要编码含义或兼容性发生变化,就必须 bump semantic identity: - -```csharp -[assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] - -[RpcCodecSemanticIdentity(0x0123456789ABCDEF, 0xFEDCBA9876543210)] -public sealed class MyTypeCodec : IRpcCodec -{ - // ... -} -```''' - replace_one('doc/contracts-and-codecs.md', old_doc, new_doc) - replace_one( - 'doc/contracts-and-codecs.md', - '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。', - '这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。UnsafeBlit 的兼容性承诺收敛为同一 protocol/release 的受支持 runtime/platform matrix 内稳定;它不承诺跨 protocol/release 或任意 CLR ABI 的 raw-layout 可移植性。') - - # Enum regression: direct enum changes assembly identity; DTO enum changes containing CodecHash too. - Path('test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs').write_text(r'''using System; -using System.Linq; -using System.Threading.Tasks; - -namespace SharpLink.Generator.Tests; - -public partial class RpcAnalyzerTests -{ - [Test] - public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity() - { - static string DirectSource(bool swapped) - { - var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; - return BuildSource($$""" -public enum Status : byte { {{members}} } - -[SharpLink.Sdk.RpcContract] -public interface IDirectEnumIdentityContract : SharpLink.Sdk.IService -{ - ValueTask Echo(Status value, CancellationToken cancellationToken); -} -"""); - } - - static string DtoSource(bool swapped) - { - var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; - return BuildSource($$""" -public enum Status : byte { {{members}} } - -[SharpLink.Sdk.RpcSerializable] -public sealed class EnumEnvelope -{ - public Status Value { get; set; } -} - -[SharpLink.Sdk.RpcContract] -public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService -{ - ValueTask Echo(EnumEnvelope value, CancellationToken cancellationToken); -} -"""); - } - - static string Manifest(string source) - => RunGeneratorAndGetSources(source) - .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - - var directBaseline = Manifest(DirectSource(swapped: false)); - var directChanged = Manifest(DirectSource(swapped: true)); - Ensure( - ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), - "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); - - var dtoBaseline = Manifest(DtoSource(swapped: false)); - var dtoChanged = Manifest(DtoSource(swapped: true)); - Ensure( - ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), - "enum declaration identity must propagate through fixed DTO members"); - Ensure( - ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), - "the DTO enum mapping change must propagate into RpcAssemblyHash"); - return Task.CompletedTask; - } -} -''') - - # Protocol regression: previous wire generation must fail in both handshake directions before Ready/payload use. - Path('test/SharpLink.UnitTests/Protocol/ProtocolV2WireGenerationBoundaryTests.cs').write_text(r'''namespace SharpLink.UnitTests.Protocol; - -public class ProtocolV2WireGenerationBoundaryTests -{ - [Test] - public void PreviousMinorShouldBeRejectedDuringHandshake() - { - Ensure( - ProtocolV2Constants.MinimumCompatibleMinorVersion == ProtocolV2Constants.MinorVersion, - "the intentional DTO wire break must advance the current minor and compatibility floor together"); - var previousMinor = checked((ushort)(ProtocolV2Constants.MinimumCompatibleMinorVersion - 1)); - var policy = ProtocolV2Negotiator.CreateImplementedPolicy( - SharpLinkProtocolOptions.MinMaxFramePayloadBytes, - 1024, - 2048, - Array.Empty()); - var offer = ProtocolV2Negotiator.CreateClientOffer( - policy, - ProtocolV2Capabilities.None, - ReadOnlyMemory.Empty); - - var serverFailure = Capture(() => ProtocolV2Negotiator.NegotiateServer( - offer with { MinorVersion = previousMinor }, - policy)); - Ensure( - serverFailure.Code == SharpLinkErrorCode.Unimplemented, - "the server must reject a previous wire-generation offer during handshake"); - - var response = new ProtocolV2HandshakeResponse( - offer.MinorVersion, - ProtocolV2Capabilities.None, - offer.MaxFramePayloadBytes, - offer.StreamReceiveWindowBytes, - offer.ConnectionReceiveWindowBytes); - var clientFailure = Capture(() => ProtocolV2Negotiator.ValidateServerResponse( - offer, - response with { MinorVersion = previousMinor }, - policy)); - Ensure( - clientFailure.Code == SharpLinkErrorCode.Unimplemented, - "the client must reject a previous wire-generation response during handshake"); - } - - private static SharpLinkException Capture(Action action) - { - try - { - action(); - } - catch (SharpLinkException exception) - { - return exception; - } - throw new InvalidOperationException("Expected SharpLinkException."); - } - - private static void Ensure(bool condition, string message) - { - if (!condition) - throw new InvalidOperationException(message); - } -} -''') - PY - - git diff --check - dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release - dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release - dotnet build test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release - dotnet run --project test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj -c Release -- self --output /tmp/codec-self - - git config user.name "SunSi12138" - git config user.email "54728594+SunSi12138@users.noreply.github.com" - git add src test doc - git commit -m "fix: close deterministic identity review gaps" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity diff --git a/doc/contracts-and-codecs.md b/doc/contracts-and-codecs.md index 71c5882eb..7524b334f 100644 --- a/doc/contracts-and-codecs.md +++ b/doc/contracts-and-codecs.md @@ -27,16 +27,16 @@ DTO 演进规则: - 字段 id 是 wire identity;发布后不要重用或改变含义。 - 新增可选字段通常兼容;删除字段前确认所有对端已停止发送。 - required、nullable、wire type 或嵌套 schema 变化可能不兼容。 -- 当前 Generator Manifest 仍沿用 `SchemaId` / `WireFormatId` 作为既有 generated registration 与 baseline infrastructure;#386 只负责确定 assembly-owned final Codec graph,不把这些字符串扩展成新的 per-type compatibility model。后续 #396 会以 fixed-width `CodecHash` / `RpcAssemblyHash` 替换长期 identity 模型并执行 assembly-level exact equality。 +- 当前 Phase 1 identity 模型由最终 Codec graph 上的 fixed-width `CodecHash`、方法/契约 hash 与 `RpcAssemblyHash` 组成;dispatch route ID 只负责路由,不承担 wire compatibility identity。远端 assembly hash 发布与 bind-time exact equality 仍属于 #396 后续阶段。 ## 自定义 Codec -Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。当前 dev 仍要求 Codec 用 `RpcCodecImplementation` 提供 legacy wire/schema registration identity;这不是 #386 新定义的长期 compatibility API,后续由 #396 的 hash identity 模型替换: +Generated RPC 的 Codec 由 Contract assembly 在编译期拥有并冻结。对非 Framework wire primitive 的闭合 CLR 类型,手写 `IRpcCodec` 只通过 `RpcCodec` 精确绑定。Opaque custom Codec 必须用 `[RpcCodecSemanticIdentity(high, low)]` 声明其 wire semantic identity;最终 `CodecHash` 将这份显式 identity 纳入方法、契约与 `RpcAssemblyHash`。只要编码含义或兼容性发生变化,就必须 bump semantic identity: ```csharp [assembly: RpcCodec(typeof(MyType), typeof(MyTypeCodec))] -[RpcCodecImplementation("my-type/v1", "my-type-schema/v1")] +[RpcCodecSemanticIdentity(0x0123456789ABCDEF, 0xFEDCBA9876543210)] public sealed class MyTypeCodec : IRpcCodec { // ... diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 41a8b7fb1..c1cfbafdf 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -337,7 +337,7 @@ private bool TryResolveReachableType(string typeName, out ITypeSymbol type) return reachable.TryGetValue(typeName, out type!); } - private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) + private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) { var typeName = member.FixedTypeName ?? member.TypeName; if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || @@ -346,6 +346,17 @@ private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; } + if (member.EnumUnderlyingType is not null && + TryResolveReachableType(member.TypeName, out var fixedMemberType) && + fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + GetEnumDeclarationSemanticIdentity(enumType)); + } + return string.Join( ":", "fixed/v1", @@ -353,6 +364,24 @@ private static string GetFixedMemberSemanticIdentity(GeneratedMemberModel member member.EnumUnderlyingType ?? typeName); } + private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + { + var parts = new List + { + "enum-declaration/v1", + enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + foreach (var field in enumType.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + } + return string.Join("|", parts); + } + private bool TryGetFrameworkPrimitiveCodecHash( ITypeSymbol type, Dictionary cache, @@ -365,7 +394,8 @@ private bool TryGetFrameworkPrimitiveCodecHash( hash = Hashing.GetSemanticHash( "codec/v1", "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex()); + GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), + GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type)); return true; } diff --git a/test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs new file mode 100644 index 000000000..bc73e0d34 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task EnumValueMappingShouldParticipateInDirectAndDtoCodecIdentity() + { + static string DirectSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" +public enum Status : byte { {{members}} } + +[SharpLink.Sdk.RpcContract] +public interface IDirectEnumIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(Status value, CancellationToken cancellationToken); +} +"""); + } + + static string DtoSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" +public enum Status : byte { {{members}} } + +[SharpLink.Sdk.RpcSerializable] +public sealed class EnumEnvelope +{ + public Status Value { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface IDtoEnumIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo(EnumEnvelope value, CancellationToken cancellationToken); +} +"""); + } + + static string Manifest(string source) + => RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + + var directBaseline = Manifest(DirectSource(swapped: false)); + var directChanged = Manifest(DirectSource(swapped: true)); + Ensure( + ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), + "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); + + var dtoBaseline = Manifest(DtoSource(swapped: false)); + var dtoChanged = Manifest(DtoSource(swapped: true)); + Ensure( + ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), + "enum declaration identity must propagate through fixed DTO members"); + Ensure( + ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), + "the DTO enum mapping change must propagate into RpcAssemblyHash"); + return Task.CompletedTask; + } +} From dbe8b023698c97b3fee9dded2e33ae6b06f71007 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:25:25 +0800 Subject: [PATCH 207/399] fix: persist enum semantic identity in contract manifest --- .../RpcGenerator.ContractManifest.Infrastructure.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs index eb24366ed..6f08215d2 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs @@ -50,7 +50,11 @@ codec is not null && !string.IsNullOrWhiteSpace(codec.Type) && !string.IsNullOrWhiteSpace(codec.Kind) && IsValidCodecHash(codec.CodecHash)) && - manifest.Enums.All(static item => item is not null) && + manifest.Enums.All(static item => + item is not null && + !string.IsNullOrWhiteSpace(item.Name) && + !string.IsNullOrWhiteSpace(item.UnderlyingType) && + IsValidCodecHash(item.CodecHash)) && manifest.Unions.All(static union => union is not null && union.Cases is not null && union.Cases.All(static item => item is not null)) && manifest.Services.All(static service => service is not null); @@ -207,6 +211,7 @@ private sealed record ContractManifestModels( ImmutableArray Interfaces, ImmutableArray Services, ImmutableArray Codecs, + ImmutableArray CodecHashes, ImmutableArray Enums, ImmutableArray Unions); @@ -315,6 +320,8 @@ private sealed class ContractManifestEnum { public string Name { get; set; } = string.Empty; public string UnderlyingType { get; set; } = string.Empty; + [JsonRequired] + public string CodecHash { get; set; } = string.Empty; [JsonIgnore] public Location? SourceLocation { get; set; } } From fab5c80662b5845bd19497a321e757837f4db7e7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:26:17 +0800 Subject: [PATCH 208/399] fix: feed enum codec hashes into contract manifest --- src/SharpLink.Generator/RpcGenerator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index ed99c5859..de9cf61f5 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -361,6 +361,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) value.Left.Left.Left, value.Left.Left.Right, GetContractManifestCodecs(value.Left.Right), + value.Left.Right.CodecHashes, value.Left.Right.Enums, value.Right)); var contractManifestOptions = context.AnalyzerConfigOptionsProvider @@ -372,6 +373,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) value.Left.Left.Interfaces, value.Left.Left.Services, value.Left.Left.Codecs, + value.Left.Left.CodecHashes, value.Left.Left.Enums, value.Left.Left.Unions, value.Left.Right, From b21aae29ad90f2b4c8cb6d89fed348a0e5a1eed1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:27:14 +0800 Subject: [PATCH 209/399] fix: emit enum codec hash in contract baseline --- .../RpcGenerator.ContractManifest.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index 99696df7c..dc922b82d 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -101,6 +101,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( ImmutableArray interfaces, ImmutableArray services, ImmutableArray codecs, + ImmutableArray codecHashes, ImmutableArray generatedEnums, ImmutableArray unions, ImmutableArray additionalTexts, @@ -108,7 +109,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var document = CreateContractManifest(interfaces, services, codecs, generatedEnums, unions); + var document = CreateContractManifest(interfaces, services, codecs, codecHashes, generatedEnums, unions); var diagnostics = ValidateCurrentContractManifest(document); if (!string.IsNullOrWhiteSpace(options.BaselinePath)) @@ -153,7 +154,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( ContractCompatibilityKind.BaselineInvalid, Location.None, options.BaselinePath, - "one or more Codec entries or opaque payload references are missing required semantic identity", + "one or more Codec entries, enum entries, or opaque payload references are missing required semantic identity", "regenerate the baseline with the current SharpLink SDK")); } else if (string.IsNullOrWhiteSpace(baseline.SchemaFingerprint) || @@ -201,6 +202,7 @@ private static ContractManifestDocument CreateContractManifest( ImmutableArray interfaces, ImmutableArray services, ImmutableArray codecs, + ImmutableArray codecHashes, ImmutableArray generatedEnums, ImmutableArray unions) { @@ -211,6 +213,12 @@ private static ContractManifestDocument CreateContractManifest( static group => group.Key, static group => group.First(), StringComparer.Ordinal); + var codecHashesByType = codecHashes + .GroupBy(static codec => RemoveGlobalPrefix(codec.TypeName), StringComparer.Ordinal) + .ToDictionary( + static group => group.Key, + static group => new RpcHashValue(group.First().High, group.First().Low).ToHex(), + StringComparer.Ordinal); var opaqueCodecHashes = codecsByType .Where(static pair => pair.Value.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) .ToDictionary( @@ -330,12 +338,18 @@ void AddEnum(string? name, string? underlying, Location? location) if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(underlying)) return; name = RemoveGlobalPrefix(name!); + if (!codecHashesByType.TryGetValue(name, out var codecHash)) + { + throw new InvalidOperationException( + $"Final RPC Codec graph is missing enum CodecHash metadata for '{name}'."); + } if (!enums.ContainsKey(name)) { enums.Add(name, new ContractManifestEnum { Name = name, UnderlyingType = RemoveGlobalPrefix(underlying!), + CodecHash = codecHash, SourceLocation = location }); } From c3982ad5ae5398fecaad27e8b1e07c265d276a2e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:29:43 +0800 Subject: [PATCH 210/399] fix: compare enum semantic identity in baselines --- .../RpcGenerator.ContractManifest.Compatibility.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs index c15ef4e83..4d44afaf4 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -295,8 +295,9 @@ private static IEnumerable CompareContractManif var currentEnums = current.Enums.ToDictionary(static item => item.Name, StringComparer.Ordinal); foreach (var oldEnum in baseline.Enums) { - if (currentEnums.TryGetValue(oldEnum.Name, out var newEnum) && - !string.Equals(oldEnum.UnderlyingType, newEnum.UnderlyingType, StringComparison.Ordinal)) + if (!currentEnums.TryGetValue(oldEnum.Name, out var newEnum)) + continue; + if (!string.Equals(oldEnum.UnderlyingType, newEnum.UnderlyingType, StringComparison.Ordinal)) { diagnostics.Add(Change( ContractCompatibilityKind.EnumUnderlyingType, @@ -305,6 +306,15 @@ private static IEnumerable CompareContractManif $"enum underlying type changed from {oldEnum.UnderlyingType} to {newEnum.UnderlyingType}", "restore the original enum underlying type")); } + else if (!string.Equals(oldEnum.CodecHash, newEnum.CodecHash, StringComparison.Ordinal)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + newEnum.SourceLocation, + newEnum.Name, + $"enum semantic CodecHash changed from '{oldEnum.CodecHash}' to '{newEnum.CodecHash}'", + "restore the original enum name/value mapping or publish a new enum payload type")); + } } var currentUnions = current.Unions.ToDictionary(static item => item.Name, StringComparer.Ordinal); From 298904445d35b192f92a13ed1073cd21e6e887a5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:30:11 +0800 Subject: [PATCH 211/399] test: cover enum mapping contract baseline identity --- .../RpcCodecEighthReviewRegressionTests.cs | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs index bc73e0d34..d41af3fc6 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecEighthReviewRegressionTests.cs @@ -47,20 +47,50 @@ static string Manifest(string source) => RunGeneratorAndGetSources(source) .Single(static generated => generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - var directBaseline = Manifest(DirectSource(swapped: false)); - var directChanged = Manifest(DirectSource(swapped: true)); + static string EnumCodecHash(string contractManifestJson) + { + var root = System.Text.Json.Nodes.JsonNode.Parse(contractManifestJson)!.AsObject(); + var enumEntry = root["enums"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(static item => item["name"]!.GetValue() == "Status"); + var codecHash = enumEntry["codecHash"]?.GetValue(); + Ensure(IsValidCodecHashText(codecHash), "enum manifest entry must persist a fixed-width CodecHash"); + return codecHash!; + } + + var directBaselineSource = DirectSource(swapped: false); + var directChangedSource = DirectSource(swapped: true); + var directBaseline = Manifest(directBaselineSource); + var directChanged = Manifest(directChangedSource); Ensure( ExtractGeneratedRpcAssemblyHash(directBaseline) != ExtractGeneratedRpcAssemblyHash(directChanged), "swapping enum name/value mappings must change RpcAssemblyHash for a direct enum contract even when the underlying byte width is unchanged"); - var dtoBaseline = Manifest(DtoSource(swapped: false)); - var dtoChanged = Manifest(DtoSource(swapped: true)); + var directBaselineManifest = RunContractGenerator(directBaselineSource).Json; + var directChangedManifest = RunContractGenerator(directChangedSource, directBaselineManifest); + Ensure( + EnumCodecHash(directBaselineManifest) != EnumCodecHash(directChangedManifest.Json), + "the v3 contract manifest must persist the same enum semantic CodecHash used by runtime identity"); + Ensure( + directChangedManifest.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "swapping direct enum name/value mappings must fail contract baseline comparison"); + + var dtoBaselineSource = DtoSource(swapped: false); + var dtoChangedSource = DtoSource(swapped: true); + var dtoBaseline = Manifest(dtoBaselineSource); + var dtoChanged = Manifest(dtoChangedSource); Ensure( ExtractGeneratedCodecIdentity(dtoBaseline, "EnumEnvelope") != ExtractGeneratedCodecIdentity(dtoChanged, "EnumEnvelope"), "enum declaration identity must propagate through fixed DTO members"); Ensure( ExtractGeneratedRpcAssemblyHash(dtoBaseline) != ExtractGeneratedRpcAssemblyHash(dtoChanged), "the DTO enum mapping change must propagate into RpcAssemblyHash"); + + var dtoBaselineManifest = RunContractGenerator(dtoBaselineSource).Json; + var dtoChangedManifest = RunContractGenerator(dtoChangedSource, dtoBaselineManifest); + Ensure( + dtoChangedManifest.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "swapping a DTO enum member's name/value mapping must fail contract baseline comparison"); return Task.CompletedTask; } } From 14718284c74373f21129abd24e2ed357cd1194c3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:14:31 +0800 Subject: [PATCH 212/399] fix: recursively identify adapter targets --- .../RpcGenerator.AdapterClosedIdentity.cs | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs index a29b0b161..69f0855d8 100644 --- a/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.AdapterClosedIdentity.cs @@ -12,10 +12,59 @@ private RpcHashValue GetAdapterTargetLogicalIdentity(GeneratedCodecModel model) $"Final RPC Codec graph cannot resolve adapter target '{model.TypeName}' while hashing its closed Codec semantics."); } - return Hashing.GetSemanticHash( - "adapter-target/v1", - targetType.ContainingAssembly?.Identity.Name ?? string.Empty, - GetTypeName(targetType)); + var parts = new List { "adapter-target/v2" }; + AppendClosedTargetLogicalIdentity(targetType, parts); + return Hashing.GetSemanticHash(parts.ToArray()); + } + + private static void AppendClosedTargetLogicalIdentity(ITypeSymbol type, List parts) + { + switch (type) + { + case IArrayTypeSymbol array: + parts.Add("array"); + parts.Add(array.Rank.ToString(InvariantCulture)); + AppendClosedTargetLogicalIdentity(array.ElementType, parts); + return; + case IPointerTypeSymbol pointer: + parts.Add("pointer"); + AppendClosedTargetLogicalIdentity(pointer.PointedAtType, parts); + return; + case IFunctionPointerTypeSymbol functionPointer: + parts.Add("function-pointer"); + parts.Add(functionPointer.Signature.RefKind.ToString()); + AppendClosedTargetLogicalIdentity(functionPointer.Signature.ReturnType, parts); + parts.Add(functionPointer.Signature.Parameters.Length.ToString(InvariantCulture)); + foreach (var parameter in functionPointer.Signature.Parameters) + { + parts.Add(parameter.RefKind.ToString()); + AppendClosedTargetLogicalIdentity(parameter.Type, parts); + } + return; + case INamedTypeSymbol named: + parts.Add("named"); + parts.Add(named.ContainingAssembly?.Identity.Name ?? string.Empty); + if (named.ContainingType is not null) + { + AppendClosedTargetLogicalIdentity(named.ContainingType, parts); + } + else + { + parts.Add(named.ContainingNamespace?.ToDisplayString() ?? string.Empty); + } + parts.Add(named.MetadataName); + parts.Add(named.TypeArguments.Length.ToString(InvariantCulture)); + foreach (var argument in named.TypeArguments) + AppendClosedTargetLogicalIdentity(argument, parts); + return; + case ITypeParameterSymbol parameter: + throw new InvalidOperationException( + $"Adapter target logical identity requires a closed type, but '{parameter.Name}' is still open."); + default: + parts.Add(type.TypeKind.ToString()); + parts.Add(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + return; + } } } } From c8f6191ee000b9994b8f05305bf7daae4c7239ba Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:15:51 +0800 Subject: [PATCH 213/399] fix: align codec hashes with runtime selection --- .../RpcGenerator.CodecIdentity.cs | 106 +++++++++++++++--- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index c1cfbafdf..889bb481b 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -71,19 +71,41 @@ private RpcHashValue GetFinalCodecHash( if (TryGetCollection(type, out var collectionKind, out var elementType, out var keyType, out var valueType)) { - var parts = new List + if (collectionKind == GeneratedCodecKind.Nullable && + elementType is not null && + type.IsUnmanagedType && + !HasExactBuiltinNullableCodecElement(elementType)) { - "codec/v1", - "collection", - collectionKind.ToString() - }; - if (elementType is not null) - parts.Add(GetFinalCodecHash(elementType, cache, stack).ToHex()); - if (keyType is not null) - parts.Add(GetFinalCodecHash(keyType, cache, stack).ToHex()); - if (valueType is not null) - parts.Add(GetFinalCodecHash(valueType, cache, stack).ToHex()); - result = Hashing.GetSemanticHash(parts.ToArray()); + result = GetRuntimeUnsafeBlitNullableCodecHash(type, elementType); + } + else if (TryGetBuiltinCollectionElementSemanticIdentity( + collectionKind, + elementType, + out var builtinElementIdentity)) + { + result = Hashing.GetSemanticHash( + "codec/v1", + "collection", + collectionKind.ToString(), + "runtime-builtin-blit/v1", + builtinElementIdentity); + } + else + { + var parts = new List + { + "codec/v1", + "collection", + collectionKind.ToString() + }; + if (elementType is not null) + parts.Add(GetFinalCodecHash(elementType, cache, stack).ToHex()); + if (keyType is not null) + parts.Add(GetFinalCodecHash(keyType, cache, stack).ToHex()); + if (valueType is not null) + parts.Add(GetFinalCodecHash(valueType, cache, stack).ToHex()); + result = Hashing.GetSemanticHash(parts.ToArray()); + } } else if (type.IsUnmanagedType && !IsRuntimeSizedUnsafeBlitType(type)) { @@ -105,6 +127,61 @@ private RpcHashValue GetFinalCodecHash( return result; } + private RpcHashValue GetRuntimeUnsafeBlitNullableCodecHash(ITypeSymbol nullableType, ITypeSymbol elementType) + { + var layout = new StringBuilder("unsafe-blit/v2|abi:little-endian|native-pointer-width/64"); + AppendUnsafeBlitPhysicalLayout( + nullableType, + layout, + new HashSet(SymbolEqualityComparer.Default)); + + if (elementType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return Hashing.GetSemanticHash( + "codec/v1", + "nullable-runtime-unsafe-blit/v1", + layout.ToString(), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + return Hashing.GetSemanticHash( + "codec/v1", + "nullable-runtime-unsafe-blit/v1", + layout.ToString()); + } + + private bool TryGetBuiltinCollectionElementSemanticIdentity( + GeneratedCodecKind collectionKind, + ITypeSymbol? elementType, + out string identity) + { + if (elementType is null || + collectionKind is not (GeneratedCodecKind.Array or + GeneratedCodecKind.List or + GeneratedCodecKind.Memory or + GeneratedCodecKind.ReadOnlyMemory or + GeneratedCodecKind.ImmutableArray) || + !IsBuiltinBlitElement(elementType)) + { + identity = string.Empty; + return false; + } + + if (string.Equals(elementType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + { + identity = "datetime-offset/collection-raw16-padding2-7-zero/release-scoped/v1"; + return true; + } + + var layout = new StringBuilder("builtin-blit-element/v1|abi:little-endian"); + AppendUnsafeBlitPhysicalLayout( + elementType, + layout, + new HashSet(SymbolEqualityComparer.Default)); + identity = layout.ToString(); + return true; + } + private bool TryGetReferencedGeneratedCodecHash(ITypeSymbol type, out RpcHashValue hash) { var assembly = type.ContainingAssembly; @@ -382,6 +459,9 @@ private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumTy return string.Join("|", parts); } + private static bool HasExactBuiltinNullableCodecElement(ITypeSymbol type) + => type.TypeKind != TypeKind.Enum && GetFixedSize(type) != 0; + private bool TryGetFrameworkPrimitiveCodecHash( ITypeSymbol type, Dictionary cache, @@ -402,7 +482,7 @@ private bool TryGetFrameworkPrimitiveCodecHash( if (type is INamedTypeSymbol nullable && nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && nullable.TypeArguments.Length == 1 && - IsFrameworkWirePrimitive(nullable.TypeArguments[0])) + HasExactBuiltinNullableCodecElement(nullable.TypeArguments[0])) { hash = Hashing.GetSemanticHash( "codec/v1", From b6f18956c37c8789dc27997a0f6713a89ccbc6e4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:35:48 +0800 Subject: [PATCH 214/399] test: cover closed adapter and nullable identity --- .../RpcCodecNinthReviewRegressionTests.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs new file mode 100644 index 000000000..f9340ead0 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task AdapterClosedGenericTargetShouldIncludeEveryNamedTypeAssemblyIdentity() + { + static MetadataReference CreateSharedDtoReference(string assemblyName) + => ((PortableExecutableReference)CreateMetadataReference( + assemblyName, + "namespace Shared { public sealed class Dto { } }")) + .WithAliases(ImmutableArray.Create("SharedRef")); + + var source = "extern alias SharedRef;\n" + AddAssemblyAttribute(BuildSource(""" +[FakePackable] +public sealed class Wrapper +{ +} + +[SharpLink.Sdk.RpcContract] +public interface IClosedAdapterContract : SharpLink.Sdk.IService +{ + ValueTask> Echo( + Wrapper value, + CancellationToken cancellationToken); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class FakePackableAttribute : Attribute { } + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3131313131313131UL, 0x4242424242424242UL)] +public sealed class StableAdapter : SharpLink.Abstractions.IRpcCodecAdapter +{ + public string AdapterId => "stable-generic-adapter/v1"; + public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); +} +"""), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(StableAdapter), \"stable-generic-adapter/v1\", SelectorAttributeType = typeof(FakePackableAttribute))]"); + + static string Manifest(string source, MetadataReference reference) + => RunGeneratorAndGetSources(source, reference) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + + var assemblyA = Manifest(source, CreateSharedDtoReference("SharedDto.A")); + var assemblyB = Manifest(source, CreateSharedDtoReference("SharedDto.B")); + + Ensure( + ExtractGeneratedRpcAssemblyHash(assemblyA) != ExtractGeneratedRpcAssemblyHash(assemblyB), + "closed Adapter identity must distinguish same-named generic arguments from different logical assemblies"); + return Task.CompletedTask; + } + + [Test] + public Task NullableUnmanagedFallbackIdentityShouldMirrorRuntimeUnsafeBlitSelection() + { + static string Manifest(string source) + => RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + + static string EnumSource(bool swapped) + { + var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; + return BuildSource($$""" +public enum NullableStatus : int { {{members}} } + +[SharpLink.Sdk.RpcContract] +public interface INullableEnumContract : SharpLink.Sdk.IService +{ + ValueTask Echo(NullableStatus? value, CancellationToken cancellationToken); +} +"""); + } + + var enumBefore = Manifest(EnumSource(swapped: false)); + var enumAfter = Manifest(EnumSource(swapped: true)); + Ensure( + ExtractGeneratedRpcAssemblyHash(enumBefore) != ExtractGeneratedRpcAssemblyHash(enumAfter), + "Nullable uses runtime UnsafeBlit bytes but must still retain the enum declaration semantic identity"); + + static string DtoSource(int fieldId, string physicalType) + => BuildSource($$""" +[SharpLink.Sdk.RpcSerializable] +public struct NullablePayload +{ + [SharpLink.Sdk.RpcMember({{fieldId}})] + public {{physicalType}} Value; +} + +[SharpLink.Sdk.RpcContract] +public interface INullableDtoContract : SharpLink.Sdk.IService +{ + ValueTask Echo(NullablePayload? value, CancellationToken cancellationToken); +} +"""); + + var fieldOne = Manifest(DtoSource(fieldId: 1, physicalType: "int")); + var fieldSeven = Manifest(DtoSource(fieldId: 7, physicalType: "int")); + Ensure( + ExtractGeneratedCodecIdentity(fieldOne, "NullablePayload") != + ExtractGeneratedCodecIdentity(fieldSeven, "NullablePayload"), + "changing RpcMember identity must still change the generated child DTO CodecHash"); + Ensure( + ExtractGeneratedRpcAssemblyHash(fieldOne) == ExtractGeneratedRpcAssemblyHash(fieldSeven), + "Nullable must model the runtime raw Nullable layout rather than composing the child DTO CodecHash"); + + var intLayout = fieldOne; + var longLayout = Manifest(DtoSource(fieldId: 1, physicalType: "long")); + Ensure( + ExtractGeneratedRpcAssemblyHash(intLayout) != ExtractGeneratedRpcAssemblyHash(longLayout), + "changing the physical Nullable layout must change the advertised runtime UnsafeBlit identity"); + return Task.CompletedTask; + } +} From d1d861fff990ef5856a218f5ad78b510437f97cf Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:36:43 +0800 Subject: [PATCH 215/399] test: pin builtin collection wire strategies --- .../BuiltinCollectionWireStrategyTests.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 test/SharpLink.UnitTests/BuiltinCollectionWireStrategyTests.cs diff --git a/test/SharpLink.UnitTests/BuiltinCollectionWireStrategyTests.cs b/test/SharpLink.UnitTests/BuiltinCollectionWireStrategyTests.cs new file mode 100644 index 000000000..b8bc45d26 --- /dev/null +++ b/test/SharpLink.UnitTests/BuiltinCollectionWireStrategyTests.cs @@ -0,0 +1,75 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; + +namespace SharpLink.UnitTests; + +public class BuiltinCollectionWireStrategyTests +{ + [Test] + public async Task DateTimeCollectionShouldUseRawElementLayoutRatherThanScalarCodec() + { + using var provider = new RpcCodecProvider(null, new Dictionary()); + var scalarCodec = provider.GetCodec(); + var arrayCodec = provider.GetCodec(); + Ensure(scalarCodec.GetType().Name == "DateTimeCodec", + "DateTime scalar must resolve its semantic scalar Codec"); + Ensure(arrayCodec.GetType().Name.StartsWith("BlitArrayCodec", StringComparison.Ordinal), + "DateTime[] must resolve the builtin raw blit collection strategy"); + + var value = new DateTime(2026, 8, 31, 13, 45, 12, DateTimeKind.Local); + var scalarBytes = Serialize(scalarCodec, value); + Ensure(scalarBytes.Length == sizeof(long), "DateTime scalar wire size"); + Ensure(BinaryPrimitives.ReadInt64LittleEndian(scalarBytes) == value.ToBinary(), + "DateTime scalar wire must encode ToBinary semantics"); + + var values = new[] { value }; + var arrayBytes = Serialize(arrayCodec, values); + Ensure(BinaryPrimitives.ReadInt32LittleEndian(arrayBytes) == 1, "DateTime[] element count"); + var raw = MemoryMarshal.AsBytes(values.AsSpan()); + Ensure(arrayBytes.AsSpan(sizeof(int)).SequenceEqual(raw), + "DateTime[] payload must contain raw DateTime element memory rather than scalar DateTimeCodec bytes"); + await Task.CompletedTask; + } + + [Test] + public async Task DateTimeOffsetCollectionShouldUseNormalizedRaw16RatherThanScalarCodec() + { + using var provider = new RpcCodecProvider(null, new Dictionary()); + var scalarCodec = provider.GetCodec(); + var arrayCodec = provider.GetCodec(); + Ensure(scalarCodec.GetType().Name == "DateTimeOffsetCodec", + "DateTimeOffset scalar must resolve its logical scalar Codec"); + Ensure(arrayCodec.GetType().Name == "DateTimeOffsetArrayCodec", + "DateTimeOffset[] must resolve its dedicated normalized raw collection strategy"); + + var value = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromHours(5.5)); + var scalarBytes = Serialize(scalarCodec, value); + Ensure(scalarBytes.Length == 10, + "DateTimeOffset scalar wire must remain the 10-byte ticks+offset representation"); + + var arrayBytes = Serialize(arrayCodec, new[] { value }); + Ensure(BinaryPrimitives.ReadInt32LittleEndian(arrayBytes) == 1, "DateTimeOffset[] element count"); + var payload = arrayBytes.AsSpan(sizeof(int)); + Ensure(payload.Length == 16, "DateTimeOffset[] must use a 16-byte element representation"); + Ensure(payload.Slice(sizeof(short), 6).IndexOfAnyExcept((byte)0) < 0, + "DateTimeOffset[] must normalize bytes 2..7 to zero independently of scalar Codec semantics"); + Ensure(BinaryPrimitives.ReadInt16LittleEndian(payload) == (short)value.Offset.TotalMinutes, + "DateTimeOffset[] raw element offset minutes"); + Ensure(BinaryPrimitives.ReadInt64LittleEndian(payload.Slice(sizeof(long))) == value.UtcTicks, + "DateTimeOffset[] raw element UTC ticks"); + await Task.CompletedTask; + } + + private static byte[] Serialize(IRpcCodec codec, T value) + { + var writer = new ArrayBufferWriter(); + codec.Serialize(in value, writer); + return writer.WrittenSpan.ToArray(); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new Exception(message); + } +} From 35567a4c0d20396c79ff4d804e578aa0311c9efd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:38:23 +0800 Subject: [PATCH 216/399] test: add auto-layout compatibility evidence --- .../AutoLayoutEvidenceFixtures.cs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs diff --git a/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs new file mode 100644 index 000000000..2b7b6ecac --- /dev/null +++ b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace SharpLink.CodecCompatibility; + +internal static class AutoLayoutEvidenceFixtures +{ + [ModuleInitializer] + internal static void Register() + { + if (FixtureRegistry.All is not List fixtures) + throw new InvalidOperationException("Compatibility fixture registry must remain mutable during module initialization."); + + var offset = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromHours(5.5)); + var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + var mixed = new AutoMixed + { + A = 0x12, + B = 0x2345, + C = 0x3456789A, + D = 0x0102030405060708, + E = 1234567890.123456789m, + F = guid, + G = offset + }; + + fixtures.AddRange( + [ + new Fixture("AutoMixed", "auto-layout-release-scoped", mixed), + new Fixture("AutoNested", "auto-layout-release-scoped", new AutoNested + { + Prefix = 0x31, + Inner = mixed, + Tail = 0x1122334455667788 + }), + new Fixture>("AutoGenericByte", "auto-layout-release-scoped", new AutoGeneric + { + Prefix = 0x41, + Value = 0x52, + Tail = 0x0102030405060708 + }), + new Fixture>("AutoGenericInt64", "auto-layout-release-scoped", new AutoGeneric + { + Prefix = 0x42, + Value = 0x1020304050607080, + Tail = 0x1112131415161718 + }), + new Fixture>("AutoGenericGuid", "auto-layout-release-scoped", new AutoGeneric + { + Prefix = 0x43, + Value = guid, + Tail = 0x2122232425262728 + }), + new Fixture>("AutoGenericDateTimeOffset", "auto-layout-release-scoped", new AutoGeneric + { + Prefix = 0x44, + Value = offset, + Tail = 0x3132333435363738 + }), + new Fixture("AutoPaddingHeavy", "auto-layout-release-scoped", new AutoPaddingHeavy + { + Prefix = 0x51, + Value = 0x4142434445464748, + Suffix = 0x52 + }), + new Fixture("DateTimeOffsetContainer", "auto-layout-release-scoped", new DateTimeOffsetContainer + { + Prefix = 0x61, + Value = offset, + Tail = 0x5152535455565758 + }, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)), + new Fixture("AutoDateTimeOffsetContainer", "auto-layout-release-scoped", new AutoDateTimeOffsetContainer + { + Prefix = 0x62, + Value = offset, + Tail = 0x6162636465666768 + }) + ]); + } +} + +[StructLayout(LayoutKind.Auto)] +internal struct AutoMixed +{ + public byte A; + public short B; + public int C; + public long D; + public decimal E; + public Guid F; + public DateTimeOffset G; +} + +[StructLayout(LayoutKind.Auto)] +internal struct AutoNested +{ + public byte Prefix; + public AutoMixed Inner; + public long Tail; +} + +[StructLayout(LayoutKind.Auto)] +internal struct AutoGeneric where T : unmanaged +{ + public byte Prefix; + public T Value; + public long Tail; +} + +[StructLayout(LayoutKind.Auto)] +internal struct AutoPaddingHeavy +{ + public byte Prefix; + public long Value; + public byte Suffix; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct DateTimeOffsetContainer +{ + public byte Prefix; + public DateTimeOffset Value; + public long Tail; +} + +[StructLayout(LayoutKind.Auto)] +internal struct AutoDateTimeOffsetContainer +{ + public byte Prefix; + public DateTimeOffset Value; + public long Tail; +} From 30ee829e8caaf89eb14b52c6c7d5cebb676aefbc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:39:38 +0800 Subject: [PATCH 217/399] test: retain auto-layout compatibility fixtures --- .../CompatibilityPolicy.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs b/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs index 417c1610c..3122d8ee7 100644 --- a/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs +++ b/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs @@ -13,7 +13,7 @@ internal readonly record struct FixturePolicyEntry( internal static class CompatibilityPolicy { internal const int ArtifactSchemaVersion = 1; - internal const string BaselineFixturePolicySha256 = "19ba9cda6e05e7a023af6ce76649deaf330e67d214f553c6611bab45019987d9"; + internal const string BaselineFixturePolicySha256 = "891d85bdbe10db368899e62a7110378aac0e989da181728e81adef3cdab5c7f5"; private static readonly FixturePolicyEntry[] RequiredFixtures = [ @@ -65,7 +65,16 @@ internal static class CompatibilityPolicy new("IndexRaw", "builtin-semantic-raw", false, true), new("RangeRaw", "builtin-semantic-raw", false, true), new("RuneRaw", "builtin-semantic-raw", false, true), - new("DecimalRaw", "builtin-semantic-raw", false, true) + new("DecimalRaw", "builtin-semantic-raw", false, true), + new("AutoMixed", "auto-layout-release-scoped", false, true), + new("AutoNested", "auto-layout-release-scoped", false, true), + new("AutoGenericByte", "auto-layout-release-scoped", false, true), + new("AutoGenericInt64", "auto-layout-release-scoped", false, true), + new("AutoGenericGuid", "auto-layout-release-scoped", false, true), + new("AutoGenericDateTimeOffset", "auto-layout-release-scoped", false, true), + new("AutoPaddingHeavy", "auto-layout-release-scoped", false, true), + new("DateTimeOffsetContainer", "auto-layout-release-scoped", false, true), + new("AutoDateTimeOffsetContainer", "auto-layout-release-scoped", false, true) ]; private static readonly IReadOnlyDictionary RequiredById = RequiredFixtures From 206738fdddcd280b7f2a0ebedb731484efa43d0f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:40:21 +0800 Subject: [PATCH 218/399] test: keep auto-layout fixture indexes in sync --- .../AutoLayoutEvidenceFixtures.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs index 2b7b6ecac..f1511b28b 100644 --- a/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs +++ b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs @@ -10,8 +10,12 @@ internal static class AutoLayoutEvidenceFixtures [ModuleInitializer] internal static void Register() { - if (FixtureRegistry.All is not List fixtures) - throw new InvalidOperationException("Compatibility fixture registry must remain mutable during module initialization."); + if (FixtureRegistry.All is not List fixtures || + FixtureRegistry.ById is not Dictionary byId) + { + throw new InvalidOperationException( + "Compatibility fixture registry must remain mutable during module initialization."); + } var offset = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromHours(5.5)); var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); @@ -26,7 +30,7 @@ internal static void Register() G = offset }; - fixtures.AddRange( + IFixture[] added = [ new Fixture("AutoMixed", "auto-layout-release-scoped", mixed), new Fixture("AutoNested", "auto-layout-release-scoped", new AutoNested @@ -77,7 +81,13 @@ internal static void Register() Value = offset, Tail = 0x6162636465666768 }) - ]); + ]; + + foreach (var fixture in added) + { + fixtures.Add(fixture); + byId.Add(fixture.Id, fixture); + } } } From 584143bfdfcee2687753413def6a5b39dbc5643b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:43:33 +0800 Subject: [PATCH 219/399] test: expose auto-layout compatibility fixtures --- .../AutoLayoutEvidenceFixtures.cs | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs index f1511b28b..19374d6fe 100644 --- a/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs +++ b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs @@ -1,22 +1,13 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace SharpLink.CodecCompatibility; internal static class AutoLayoutEvidenceFixtures { - [ModuleInitializer] - internal static void Register() + internal static IReadOnlyList Create() { - if (FixtureRegistry.All is not List fixtures || - FixtureRegistry.ById is not Dictionary byId) - { - throw new InvalidOperationException( - "Compatibility fixture registry must remain mutable during module initialization."); - } - var offset = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromHours(5.5)); var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); var mixed = new AutoMixed @@ -30,7 +21,7 @@ internal static void Register() G = offset }; - IFixture[] added = + return [ new Fixture("AutoMixed", "auto-layout-release-scoped", mixed), new Fixture("AutoNested", "auto-layout-release-scoped", new AutoNested @@ -82,12 +73,6 @@ internal static void Register() Tail = 0x6162636465666768 }) ]; - - foreach (var fixture in added) - { - fixtures.Add(fixture); - byId.Add(fixture.Id, fixture); - } } } From 262e82dbb9fb83713477a41d0d3d6089bcdaf3cb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:44:46 +0800 Subject: [PATCH 220/399] test: retain auto-layout evidence in codec matrix --- test/SharpLink.CodecCompatibility/Fixtures.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/SharpLink.CodecCompatibility/Fixtures.cs b/test/SharpLink.CodecCompatibility/Fixtures.cs index e20b67170..1f7e99af7 100644 --- a/test/SharpLink.CodecCompatibility/Fixtures.cs +++ b/test/SharpLink.CodecCompatibility/Fixtures.cs @@ -307,6 +307,8 @@ private static IReadOnlyList CreateFixtures() new Fixture("IdentityCounter", "user-like", new IdentityCounter { High = 0x1122334455667788, Low = 0x99AABBCCDDEEFF00, Count = 123456789 }, false, nameof(IdentityCounter.High), nameof(IdentityCounter.Low), nameof(IdentityCounter.Count)), new Fixture("GeometryValue", "user-like", new GeometryValue { Position = new Vector3Value { X = 10, Y = 20, Z = 30 }, Velocity = new Vector3Value { X = -1, Y = 0.5, Z = 3 }, Timestamp = 1_787_224_683_000_000_000 }, false, nameof(GeometryValue.Position), nameof(GeometryValue.Velocity), nameof(GeometryValue.Timestamp)), + .. AutoLayoutEvidenceFixtures.Create(), + new Fixture("DateOnlyRaw", "builtin-semantic-raw", new DateOnly(2026, 8, 20)), new Fixture("DateTimeRaw", "builtin-semantic-raw", new DateTime(2026, 8, 20, 12, 34, 56, DateTimeKind.Utc), static (left, right) => left.Ticks == right.Ticks && left.Kind == right.Kind), new Fixture("DateTimeOffsetRaw", "builtin-semantic-raw", new DateTimeOffset(2026, 8, 20, 12, 34, 56, TimeSpan.FromHours(8)), static (left, right) => left.Ticks == right.Ticks && left.UtcTicks == right.UtcTicks && left.Offset == right.Offset), From 291b68103964c8632ceb7311e725b647632207c0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:48:52 +0800 Subject: [PATCH 221/399] test: record DateTimeOffset container evidence without marshal offsets --- test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs index 19374d6fe..65b59af55 100644 --- a/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs +++ b/test/SharpLink.CodecCompatibility/AutoLayoutEvidenceFixtures.cs @@ -65,7 +65,7 @@ internal static IReadOnlyList Create() Prefix = 0x61, Value = offset, Tail = 0x5152535455565758 - }, false, nameof(DateTimeOffsetContainer.Prefix), nameof(DateTimeOffsetContainer.Value), nameof(DateTimeOffsetContainer.Tail)), + }), new Fixture("AutoDateTimeOffsetContainer", "auto-layout-release-scoped", new AutoDateTimeOffsetContainer { Prefix = 0x62, From 0232fee9776d2e65fb4cff7875dc6121560b96c8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:05:43 +0800 Subject: [PATCH 222/399] test: fix nullable contract codec identity regression --- .../RpcCodecNinthReviewRegressionTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs index f9340ead0..db8ed2725 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs @@ -67,6 +67,25 @@ static string Manifest(string source) "ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + static string ContractCodecIdentity(string manifest, string typeName) + { + var marker = $"K:global::{typeName}:"; + var start = manifest.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + { + marker = $"K:{typeName}:"; + start = manifest.IndexOf(marker, StringComparison.Ordinal); + } + if (start < 0) + throw new InvalidOperationException($"Contract codec descriptor '{typeName}' was not found."); + + start += marker.Length; + var end = manifest.IndexOf(';', start); + if (end < 0) + throw new InvalidOperationException($"Contract codec descriptor '{typeName}' is malformed."); + return manifest[start..end]; + } + static string EnumSource(bool swapped) { var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; @@ -106,8 +125,8 @@ public interface INullableDtoContract : SharpLink.Sdk.IService var fieldOne = Manifest(DtoSource(fieldId: 1, physicalType: "int")); var fieldSeven = Manifest(DtoSource(fieldId: 7, physicalType: "int")); Ensure( - ExtractGeneratedCodecIdentity(fieldOne, "NullablePayload") != - ExtractGeneratedCodecIdentity(fieldSeven, "NullablePayload"), + ContractCodecIdentity(fieldOne, "NullablePayload") != + ContractCodecIdentity(fieldSeven, "NullablePayload"), "changing RpcMember identity must still change the generated child DTO CodecHash"); Ensure( ExtractGeneratedRpcAssemblyHash(fieldOne) == ExtractGeneratedRpcAssemblyHash(fieldSeven), From 993f27b4e6c3716ba745659408f2e35a3994dfc9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:05:56 +0800 Subject: [PATCH 223/399] test: link auto layout evidence into android probe --- .../SharpLink.CodecCompatibility.Android.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj b/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj index b748c15c8..c3b40e520 100644 --- a/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj +++ b/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj @@ -20,6 +20,7 @@ + From eba1a733b42d36bf8b860f73c1f94f8211e8b4b1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:06:08 +0800 Subject: [PATCH 224/399] test: link auto layout evidence into ios probe --- .../SharpLink.CodecCompatibility.iOS.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj b/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj index 8d393a8a3..2ab8ed001 100644 --- a/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj +++ b/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj @@ -18,6 +18,7 @@ + From 2e9c99b321ce75a371a388ec5f02d48b2deb6e5f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:06:21 +0800 Subject: [PATCH 225/399] test: link auto layout evidence into browser probe --- .../SharpLink.CodecCompatibility.Browser.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj b/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj index 1cffbd800..9b74afde4 100644 --- a/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj +++ b/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj @@ -13,6 +13,7 @@ + From 4dbb549f7bb6a330daa28e57a86000c4256d2c99 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:15:28 +0800 Subject: [PATCH 226/399] test: sync compatibility fixture policy digest --- test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs b/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs index 3122d8ee7..4a3f63ce5 100644 --- a/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs +++ b/test/SharpLink.CodecCompatibility/CompatibilityPolicy.cs @@ -13,7 +13,7 @@ internal readonly record struct FixturePolicyEntry( internal static class CompatibilityPolicy { internal const int ArtifactSchemaVersion = 1; - internal const string BaselineFixturePolicySha256 = "891d85bdbe10db368899e62a7110378aac0e989da181728e81adef3cdab5c7f5"; + internal const string BaselineFixturePolicySha256 = "9e3c6ed421a21c15ffba4ee7027fa8aab166bf385247cd9e8d65de8a68a62cf5"; private static readonly FixturePolicyEntry[] RequiredFixtures = [ From 3201c75c2624895e9bc4cc0a24c24ab3e6dc84e7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:19:18 +0800 Subject: [PATCH 227/399] test: sync portable compatibility policy digest --- .../portable-artifacts.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs b/test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs index 2bb144869..a8867b071 100644 --- a/test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs +++ b/test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs @@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url'; const BUILTIN_RAW_CATEGORY = 'builtin-semantic-raw'; const BROWSER_PLATFORM_TAG = 'browser-wasm-browser-mono-net10'; -const EXPECTED_FIXTURE_POLICY_SHA256 = '19ba9cda6e05e7a023af6ce76649deaf330e67d214f553c6611bab45019987d9'; +const EXPECTED_FIXTURE_POLICY_SHA256 = '9e3c6ed421a21c15ffba4ee7027fa8aab166bf385247cd9e8d65de8a68a62cf5'; const ONE_BYTE_FIXTURE_ID_SET = new Set(['Byte', 'ByteEnum']); const EXPECTED_PADDING_POISON_FIXTURE_IDS = Object.freeze(['ByteInt32', 'Int64Byte']); const DESKTOP_PLATFORM_TAGS = Object.freeze([ @@ -554,7 +554,7 @@ export async function appendRawLayoutEvidence(reportFile, producerRoot, localCor const report = JSON.parse(await fs.readFile(reportFile, 'utf8')); validateVerificationReportSchema(report, reportFile); validateResultConsumers(report, reportFile); - const reportRegistry = validateFixtureRegistry(report.consumer, `${reportFile} consumer registry`); + const reportRegistry = validateFixtureRegistry(report.consumer, `${reportFile} fixture registry`); const producers = await loadEnvelopes(producerRoot, { excludeBuiltinRaw: false }); const localEnvelopes = await loadEnvelopes(localCorpusRoot, { excludeBuiltinRaw: false }); if (localEnvelopes.length !== 1) { From 1c95cf6598148473f07e49067afaa0e560d79ea8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:23:10 +0800 Subject: [PATCH 228/399] test: focus nullable regression on selected wire identity --- .../RpcCodecNinthReviewRegressionTests.cs | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs index db8ed2725..85b45f198 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecNinthReviewRegressionTests.cs @@ -67,25 +67,6 @@ static string Manifest(string source) "ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); - static string ContractCodecIdentity(string manifest, string typeName) - { - var marker = $"K:global::{typeName}:"; - var start = manifest.IndexOf(marker, StringComparison.Ordinal); - if (start < 0) - { - marker = $"K:{typeName}:"; - start = manifest.IndexOf(marker, StringComparison.Ordinal); - } - if (start < 0) - throw new InvalidOperationException($"Contract codec descriptor '{typeName}' was not found."); - - start += marker.Length; - var end = manifest.IndexOf(';', start); - if (end < 0) - throw new InvalidOperationException($"Contract codec descriptor '{typeName}' is malformed."); - return manifest[start..end]; - } - static string EnumSource(bool swapped) { var members = swapped ? "Ok = 1, Error = 0" : "Ok = 0, Error = 1"; @@ -124,13 +105,9 @@ public interface INullableDtoContract : SharpLink.Sdk.IService var fieldOne = Manifest(DtoSource(fieldId: 1, physicalType: "int")); var fieldSeven = Manifest(DtoSource(fieldId: 7, physicalType: "int")); - Ensure( - ContractCodecIdentity(fieldOne, "NullablePayload") != - ContractCodecIdentity(fieldSeven, "NullablePayload"), - "changing RpcMember identity must still change the generated child DTO CodecHash"); Ensure( ExtractGeneratedRpcAssemblyHash(fieldOne) == ExtractGeneratedRpcAssemblyHash(fieldSeven), - "Nullable must model the runtime raw Nullable layout rather than composing the child DTO CodecHash"); + "Nullable must model the runtime raw Nullable layout rather than composing RpcMember/DTO semantics into the selected codec identity"); var intLayout = fieldOne; var longLayout = Manifest(DtoSource(fieldId: 1, physicalType: "long")); From 529b5a53b834e2a06cfa1ef2fb43ecea6acd445f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:13:28 +0800 Subject: [PATCH 229/399] ci: run non-Mono codec verification before Mono --- .../workflows/codec-mobile-compatibility.yml | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/codec-mobile-compatibility.yml b/.github/workflows/codec-mobile-compatibility.yml index 0f7cefc7e..4920a3384 100644 --- a/.github/workflows/codec-mobile-compatibility.yml +++ b/.github/workflows/codec-mobile-compatibility.yml @@ -173,44 +173,45 @@ jobs: cp -R artifacts/codec-compat/android-mono-corpus/. artifacts/codec-compat/producers/codec-mobile-corpus-android-x64-mono/ cp -R artifacts/codec-compat/android-coreclr-corpus/. artifacts/codec-compat/producers/codec-mobile-corpus-android-x64-coreclr/ - - name: Verify documented edges on Android Mono + - name: Verify documented edges on Android CoreCLR shell: bash env: SHARPLINK_SKIP_BUILTIN_RAW: '1' run: | - adb install -r artifacts/codec-compat/android-mono.apk + adb install -r artifacts/codec-compat/android-coreclr.apk node test/SharpLink.CodecCompatibility.Android/run-android.mjs \ verify artifacts/codec-compat/producers \ - artifacts/codec-compat/android-mono-verification/verification.json \ - "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" Mono + artifacts/codec-compat/android-coreclr-verification/verification.json \ + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" CoreCLR adb uninstall com.sharplink.codeccompat || true - - name: Append raw evidence for Android Mono + - name: Append raw evidence for Android CoreCLR run: | node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - append-raw artifacts/codec-compat/android-mono-verification/verification.json \ - artifacts/codec-compat/producers artifacts/codec-compat/android-mono-corpus + append-raw artifacts/codec-compat/android-coreclr-verification/verification.json \ + artifacts/codec-compat/producers artifacts/codec-compat/android-coreclr-corpus node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - check-report artifacts/codec-compat/android-mono-verification/verification.json + check-report artifacts/codec-compat/android-coreclr-verification/verification.json - - name: Verify documented edges on Android CoreCLR + - name: Verify documented edges on Android Mono shell: bash env: SHARPLINK_SKIP_BUILTIN_RAW: '1' run: | - adb install -r artifacts/codec-compat/android-coreclr.apk + adb install -r artifacts/codec-compat/android-mono.apk node test/SharpLink.CodecCompatibility.Android/run-android.mjs \ verify artifacts/codec-compat/producers \ - artifacts/codec-compat/android-coreclr-verification/verification.json \ - "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" CoreCLR + artifacts/codec-compat/android-mono-verification/verification.json \ + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" Mono + adb uninstall com.sharplink.codeccompat || true - - name: Append raw evidence for Android CoreCLR + - name: Append raw evidence for Android Mono run: | node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - append-raw artifacts/codec-compat/android-coreclr-verification/verification.json \ - artifacts/codec-compat/producers artifacts/codec-compat/android-coreclr-corpus + append-raw artifacts/codec-compat/android-mono-verification/verification.json \ + artifacts/codec-compat/producers artifacts/codec-compat/android-mono-corpus node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - check-report artifacts/codec-compat/android-coreclr-verification/verification.json + check-report artifacts/codec-compat/android-mono-verification/verification.json - name: Upload Android evidence if: always() @@ -227,7 +228,8 @@ jobs: retention-days: 30 ios: - needs: desktop-reference + needs: [desktop-reference, android] + if: ${{ always() && needs.desktop-reference.result == 'success' }} strategy: fail-fast: false matrix: From 920d52393f927f15c8862e508d0b1f7ae15833f0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:03:46 +0800 Subject: [PATCH 230/399] ci: remove Mono codec matrix from mobile evidence --- .../workflows/codec-mobile-compatibility.yml | 227 +++--------------- 1 file changed, 27 insertions(+), 200 deletions(-) diff --git a/.github/workflows/codec-mobile-compatibility.yml b/.github/workflows/codec-mobile-compatibility.yml index 4920a3384..cc93d7ffc 100644 --- a/.github/workflows/codec-mobile-compatibility.yml +++ b/.github/workflows/codec-mobile-compatibility.yml @@ -1,7 +1,7 @@ name: Codec Mobile Evidence -# This workflow retains explicitly documented producer -> consumer edges. -# It is not an all-to-all mobile compatibility matrix. +# This workflow currently exercises only non-Mono mobile codec evidence. +# Mono-based Android/iOS coverage is intentionally excluded from this matrix. permissions: contents: read @@ -16,7 +16,6 @@ on: - 'src/SharpLink.Runtime/SharpLink.Runtime.csproj' - 'test/SharpLink.CodecCompatibility/**' - 'test/SharpLink.CodecCompatibility.Android/**' - - 'test/SharpLink.CodecCompatibility.iOS/**' - 'test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs' concurrency: @@ -127,25 +126,6 @@ jobs: adb shell settings put global animator_duration_scale 0 adb shell getprop ro.product.cpu.abi - - name: Build and run Android Mono producer - shell: bash - run: | - rm -rf test/SharpLink.CodecCompatibility.Android/bin test/SharpLink.CodecCompatibility.Android/obj - dotnet build -c Debug -f net10.0-android -r android-x64 \ - test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj \ - -t:SignAndroidPackage \ - -p:CodecRuntime=mono \ - -p:AndroidPackageFormats=apk \ - -p:AndroidBuildApplicationPackage=true - apk="$(find test/SharpLink.CodecCompatibility.Android/bin/Debug -name '*-Signed.apk' -type f | head -n 1)" - test -n "$apk" - cp "$apk" artifacts/codec-compat/android-mono.apk - adb install -r "$apk" - node test/SharpLink.CodecCompatibility.Android/run-android.mjs \ - produce artifacts/codec-compat/android-mono-corpus \ - "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" Mono - adb uninstall com.sharplink.codeccompat || true - - name: Build and run Android CoreCLR producer shell: bash run: | @@ -165,12 +145,10 @@ jobs: "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" CoreCLR adb uninstall com.sharplink.codeccompat || true - - name: Assemble Android producer fan-in + - name: Assemble Android CoreCLR producer fan-in shell: bash run: | - mkdir -p artifacts/codec-compat/producers/codec-mobile-corpus-android-x64-mono mkdir -p artifacts/codec-compat/producers/codec-mobile-corpus-android-x64-coreclr - cp -R artifacts/codec-compat/android-mono-corpus/. artifacts/codec-compat/producers/codec-mobile-corpus-android-x64-mono/ cp -R artifacts/codec-compat/android-coreclr-corpus/. artifacts/codec-compat/producers/codec-mobile-corpus-android-x64-coreclr/ - name: Verify documented edges on Android CoreCLR @@ -193,207 +171,56 @@ jobs: node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ check-report artifacts/codec-compat/android-coreclr-verification/verification.json - - name: Verify documented edges on Android Mono - shell: bash - env: - SHARPLINK_SKIP_BUILTIN_RAW: '1' - run: | - adb install -r artifacts/codec-compat/android-mono.apk - node test/SharpLink.CodecCompatibility.Android/run-android.mjs \ - verify artifacts/codec-compat/producers \ - artifacts/codec-compat/android-mono-verification/verification.json \ - "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" Mono - adb uninstall com.sharplink.codeccompat || true - - - name: Append raw evidence for Android Mono - run: | - node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - append-raw artifacts/codec-compat/android-mono-verification/verification.json \ - artifacts/codec-compat/producers artifacts/codec-compat/android-mono-corpus - node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - check-report artifacts/codec-compat/android-mono-verification/verification.json - - - name: Upload Android evidence + - name: Upload Android CoreCLR evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: codec-mobile-evidence-android-x64 path: | - artifacts/codec-compat/android-mono-corpus artifacts/codec-compat/android-coreclr-corpus - artifacts/codec-compat/android-mono-verification/verification.json artifacts/codec-compat/android-coreclr-verification/verification.json + artifacts/codec-compat/android-coreclr-verification/verification-progress.log artifacts-android-emulator.log if-no-files-found: warn retention-days: 30 - ios: - needs: [desktop-reference, android] - if: ${{ always() && needs.desktop-reference.result == 'success' }} - strategy: - fail-fast: false - matrix: - include: - - id: ios-simulator-x64 - os: macos-26-intel - rid: iossimulator-x64 - - id: ios-simulator-arm64 - os: macos-26 - rid: iossimulator-arm64 - runs-on: ${{ matrix.os }} - timeout-minutes: 45 - env: - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - SHARPLINK_COMMIT: ${{ github.sha }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.0.x - - - name: Show Xcode version - run: xcodebuild -version - - - name: Install iOS workload - run: dotnet workload install ios - - - name: Record SDK version - shell: bash - run: echo "SHARPLINK_SDK_VERSION=$(dotnet --version)" >> "$GITHUB_ENV" - - - name: Download canonical desktop corpus - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: codec-mobile-corpus-desktop-linux-x64 - path: artifacts/codec-compat/producers/codec-mobile-corpus-desktop-linux-x64 - - - name: Boot an iOS simulator - shell: bash - run: | - xcrun simctl shutdown all || true - udid="$(python3 - <<'PY' - import json, subprocess - data = json.loads(subprocess.check_output(['xcrun', 'simctl', 'list', 'devices', 'available', '-j'])) - for runtime, devices in data['devices'].items(): - if 'iOS' not in runtime: - continue - for device in devices: - if device.get('isAvailable') and device.get('name', '').startswith('iPhone'): - print(device['udid']) - raise SystemExit - raise SystemExit('No available iPhone simulator found') - PY - )" - echo "IOS_SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" - xcrun simctl boot "$udid" || true - for attempt in $(seq 1 90); do - if xcrun simctl list devices | grep "$udid" | grep -q '(Booted)'; then - break - fi - sleep 2 - done - xcrun simctl list devices | grep "$udid" | grep '(Booted)' - - - name: Build and run iOS simulator producer - shell: bash - run: | - rm -rf test/SharpLink.CodecCompatibility.iOS/bin test/SharpLink.CodecCompatibility.iOS/obj - dotnet build -c Debug -f net10.0-ios -r "${{ matrix.rid }}" \ - test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj - app="$(find test/SharpLink.CodecCompatibility.iOS/bin/Debug -path "*/${{ matrix.rid }}/*" -name '*.app' -type d | head -n 1)" - test -n "$app" - xcrun simctl install "$IOS_SIMULATOR_UDID" "$app" - node test/SharpLink.CodecCompatibility.iOS/run-ios.mjs \ - produce artifacts/codec-compat/ios-corpus \ - "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" \ - "net10.0-ios/${{ matrix.rid }}" - - - name: Assemble iOS producer fan-in - shell: bash - run: | - mkdir -p artifacts/codec-compat/producers/codec-mobile-corpus-${{ matrix.id }} - cp -R artifacts/codec-compat/ios-corpus/. artifacts/codec-compat/producers/codec-mobile-corpus-${{ matrix.id }}/ - - - name: Verify documented edges on iOS simulator - shell: bash - env: - SHARPLINK_SKIP_BUILTIN_RAW: '1' - run: | - node test/SharpLink.CodecCompatibility.iOS/run-ios.mjs \ - verify artifacts/codec-compat/producers \ - artifacts/codec-compat/ios-verification/verification.json \ - "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" \ - "net10.0-ios/${{ matrix.rid }}" - - - name: Append raw evidence for iOS simulator - run: | - node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - append-raw artifacts/codec-compat/ios-verification/verification.json \ - artifacts/codec-compat/producers artifacts/codec-compat/ios-corpus - node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - check-report artifacts/codec-compat/ios-verification/verification.json - - - name: Upload iOS simulator evidence - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: codec-mobile-evidence-${{ matrix.id }} - path: | - artifacts/codec-compat/ios-corpus - artifacts/codec-compat/ios-verification/verification.json - if-no-files-found: warn - retention-days: 30 - summary: - needs: [android, ios] + needs: android if: always() runs-on: ubuntu-24.04 - timeout-minutes: 15 - env: - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - SHARPLINK_COMMIT: ${{ github.sha }} + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.0.x + - name: Require Android CoreCLR job + shell: bash + env: + ANDROID_RESULT: ${{ needs.android.result }} + run: | + if [[ "$ANDROID_RESULT" != "success" ]]; then + echo "::error::Android CoreCLR codec evidence result: $ANDROID_RESULT" + exit 1 + fi - - name: Download mobile verification evidence + - name: Download Android CoreCLR evidence uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: codec-mobile-evidence-* + name: codec-mobile-evidence-android-x64 path: artifacts/codec-compat/verifications - merge-multiple: false - - name: Require every documented mobile consumer report + - name: Locate Android CoreCLR verification report shell: bash run: | - count="$(find artifacts/codec-compat/verifications -name verification.json -type f | wc -l | tr -d ' ')" - if [[ "$count" != "4" ]]; then - echo "::error::Expected 4 mobile consumer reports, found $count." + report="$(find artifacts/codec-compat/verifications -path '*android-coreclr-verification/verification.json' -type f | head -n 1)" + if [[ -z "$report" ]]; then + echo "::error::Android CoreCLR verification report was not found." exit 1 fi + echo "CORECLR_REPORT=$report" >> "$GITHUB_ENV" - - name: Aggregate documented mobile edge evidence + - name: Validate Android CoreCLR evidence report run: >- - dotnet run -c Release - --project test/SharpLink.CodecCompatibility/SharpLink.CodecCompatibility.csproj - -- summarize - --input artifacts/codec-compat/verifications - --output artifacts/codec-compat/summary - --profile mobile - - - name: Upload mobile evidence summary - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: codec-mobile-evidence-summary - path: artifacts/codec-compat/summary - if-no-files-found: warn - retention-days: 30 + node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs + check-report + "$CORECLR_REPORT" From 614d50e83e69395a1eebf8b9ec9c1b7011ebccee Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:19:43 +0800 Subject: [PATCH 231/399] test: detect CoreCLR on experimental iOS lane --- .../PortableProbe.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.CodecCompatibility/PortableProbe.cs b/test/SharpLink.CodecCompatibility/PortableProbe.cs index 2a67210ef..d75a6b522 100644 --- a/test/SharpLink.CodecCompatibility/PortableProbe.cs +++ b/test/SharpLink.CodecCompatibility/PortableProbe.cs @@ -222,6 +222,7 @@ private static RuntimeManifest CreateRuntimeManifest( : OperatingSystem.IsIOS() ? "ios-runtime" : "hosted-desktop"); + var frameworkTag = DetectFrameworkTag(targetFramework); var manifest = new RuntimeManifest { @@ -242,7 +243,7 @@ private static RuntimeManifest CreateRuntimeManifest( PointerSize = IntPtr.Size, IsLittleEndian = BitConverter.IsLittleEndian, CompilationMode = compilationMode, - PlatformTag = $"{os}-{processArchitecture}-{executionEnvironment}-{runtimeFamily.ToLowerInvariant()}-net10" + PlatformTag = $"{os}-{processArchitecture}-{executionEnvironment}-{runtimeFamily.ToLowerInvariant()}-{frameworkTag}" }; CompatibilityPolicy.ValidateManifestFixtureRegistry(manifest); @@ -337,7 +338,7 @@ private static string DetectRuntimeIdentifier(string os, string processArchitect private static (string Family, string Source) DetectRuntimeFamily() { - if (OperatingSystem.IsBrowser() || OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst()) + if (OperatingSystem.IsBrowser()) return ("Mono", "platform-runtime-pack"); if (OperatingSystem.IsAndroid()) @@ -369,6 +370,20 @@ private static string DetectAndroidRuntimeFamily() return monoLoaded ? "Mono" : "CoreCLR"; } + private static string DetectFrameworkTag(string targetFramework) + { + var framework = targetFramework.Split('/', 2, StringSplitOptions.TrimEntries)[0]; + var platformSeparator = framework.IndexOf('-'); + if (platformSeparator >= 0) + framework = framework[..platformSeparator]; + var versionSeparator = framework.IndexOf('.'); + if (versionSeparator >= 0) + framework = framework[..versionSeparator]; + if (!framework.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Unsupported target framework identity '{targetFramework}'."); + return framework.ToLowerInvariant(); + } + private static string Hash(ReadOnlySpan bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); } From 81364e2e60c1ca5744f9b59ee4898aa85430d7f6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:19:52 +0800 Subject: [PATCH 232/399] test: add experimental iOS CoreCLR target --- .../SharpLink.CodecCompatibility.iOS.csproj | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj b/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj index 2ab8ed001..d6793cc5c 100644 --- a/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj +++ b/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj @@ -1,6 +1,8 @@ - net10.0-ios + mono + net11.0-ios + net10.0-ios Exe com.sharplink.codeccompat.ios 1 @@ -10,8 +12,9 @@ 15.0 true copy - true - true + true + false + true false $(NoWarn);IL2026;IL2090;CA1422 @@ -25,4 +28,4 @@ - \ No newline at end of file + From 4a108beba18af8bc4a2cc1f39b626ce26826d0c1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:20:06 +0800 Subject: [PATCH 233/399] test: assert experimental iOS CoreCLR runtime --- test/SharpLink.CodecCompatibility.iOS/Program.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.CodecCompatibility.iOS/Program.cs b/test/SharpLink.CodecCompatibility.iOS/Program.cs index 4c4488422..8427dcd96 100644 --- a/test/SharpLink.CodecCompatibility.iOS/Program.cs +++ b/test/SharpLink.CodecCompatibility.iOS/Program.cs @@ -33,8 +33,12 @@ private static void RunProbe() var sdk = Environment.GetEnvironmentVariable("SHARPLINK_SDK_VERSION") ?? "unknown"; var targetFramework = Environment.GetEnvironmentVariable("SHARPLINK_TARGET_FRAMEWORK") ?? "net10.0-ios/iossimulator"; + var isExperimentalCoreClr = targetFramework.StartsWith("net11.0-ios", StringComparison.OrdinalIgnoreCase); + var expectedRuntimeFamily = isExperimentalCoreClr ? "CoreCLR" : "Mono"; + var expectedCompilationMode = isExperimentalCoreClr ? null : "Interpreter"; - Console.WriteLine($"SharpLink codec probe starting from Main: mode={mode}, target={targetFramework}."); + Console.WriteLine( + $"SharpLink codec probe starting from Main: mode={mode}, target={targetFramework}, runtime={expectedRuntimeFamily}."); string result; if (string.Equals(mode, "produce", StringComparison.Ordinal)) @@ -43,7 +47,8 @@ private static void RunProbe() commit, sdk, targetFramework, - expectedCompilationMode: "Interpreter", + expectedCompilationMode: expectedCompilationMode, + expectedRuntimeFamily: expectedRuntimeFamily, executionEnvironmentOverride: "simulator"); } else if (string.Equals(mode, "verify", StringComparison.Ordinal)) @@ -54,7 +59,8 @@ private static void RunProbe() commit, sdk, targetFramework, - expectedCompilationMode: "Interpreter", + expectedCompilationMode: expectedCompilationMode, + expectedRuntimeFamily: expectedRuntimeFamily, executionEnvironmentOverride: "simulator"); } else From 82b148c51d79f380949d823f0a37f78991287379 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:20:45 +0800 Subject: [PATCH 234/399] ci: add experimental iOS CoreCLR codec evidence --- .../workflows/codec-mobile-compatibility.yml | 167 ++++++++++++++---- 1 file changed, 137 insertions(+), 30 deletions(-) diff --git a/.github/workflows/codec-mobile-compatibility.yml b/.github/workflows/codec-mobile-compatibility.yml index cc93d7ffc..f1d4c4f32 100644 --- a/.github/workflows/codec-mobile-compatibility.yml +++ b/.github/workflows/codec-mobile-compatibility.yml @@ -1,7 +1,7 @@ name: Codec Mobile Evidence -# This workflow currently exercises only non-Mono mobile codec evidence. -# Mono-based Android/iOS coverage is intentionally excluded from this matrix. +# Non-Mono mobile codec evidence. +# Android CoreCLR targets .NET 10; experimental iOS CoreCLR targets .NET 11 preview. permissions: contents: read @@ -16,6 +16,7 @@ on: - 'src/SharpLink.Runtime/SharpLink.Runtime.csproj' - 'test/SharpLink.CodecCompatibility/**' - 'test/SharpLink.CodecCompatibility.Android/**' + - 'test/SharpLink.CodecCompatibility.iOS/**' - 'test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs' concurrency: @@ -103,8 +104,6 @@ jobs: echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV" mkdir -p "$ANDROID_AVD_HOME" echo no | "$AVDMANAGER" create avd --force --name "$ANDROID_AVD" --package "system-images;android-$ANDROID_API;google_apis;x86_64" --device pixel_6 - echo "Visible AVDs:" - emulator -list-avds emulator -list-avds | grep -Fx "$ANDROID_AVD" nohup emulator -avd "$ANDROID_AVD" -no-window -noaudio -no-boot-anim -no-snapshot -gpu swiftshader_indirect -accel on > artifacts-android-emulator.log 2>&1 & if ! timeout 180 adb wait-for-device; then @@ -184,43 +183,151 @@ jobs: if-no-files-found: warn retention-days: 30 - summary: - needs: android - if: always() - runs-on: ubuntu-24.04 - timeout-minutes: 10 + ios-coreclr: + strategy: + fail-fast: false + matrix: + include: + - id: ios-simulator-x64 + os: macos-26-intel + rid: iossimulator-x64 + - id: ios-simulator-arm64 + os: macos-26 + rid: iossimulator-arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 50 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + SHARPLINK_COMMIT: ${{ github.sha }} + DOTNET_11_SDK: 11.0.100-preview.7.26381.103 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Require Android CoreCLR job + - name: Pin experimental .NET 11 SDK for iOS CoreCLR + shell: bash + run: | + cat > global.json <> "$GITHUB_ENV" + + - name: Boot an iOS simulator + shell: bash + run: | + xcrun simctl shutdown all || true + udid="$(python3 - <<'PY' + import json, subprocess + data = json.loads(subprocess.check_output(['xcrun', 'simctl', 'list', 'devices', 'available', '-j'])) + for runtime, devices in data['devices'].items(): + if 'iOS' not in runtime: + continue + for device in devices: + if device.get('isAvailable') and device.get('name', '').startswith('iPhone'): + print(device['udid']) + raise SystemExit + raise SystemExit('No available iPhone simulator found') + PY + )" + echo "IOS_SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + xcrun simctl boot "$udid" || true + for attempt in $(seq 1 90); do + if xcrun simctl list devices | grep "$udid" | grep -q '(Booted)'; then + break + fi + sleep 2 + done + xcrun simctl list devices | grep "$udid" | grep '(Booted)' + + - name: Build and run experimental iOS CoreCLR producer + shell: bash + run: | + rm -rf test/SharpLink.CodecCompatibility.iOS/bin test/SharpLink.CodecCompatibility.iOS/obj + dotnet build -c Debug -f net11.0-ios -r "${{ matrix.rid }}" \ + test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj \ + -p:CodecRuntime=coreclr + app="$(find test/SharpLink.CodecCompatibility.iOS/bin/Debug -path "*/net11.0-ios/${{ matrix.rid }}/*" -name '*.app' -type d | head -n 1)" + test -n "$app" + xcrun simctl install "$IOS_SIMULATOR_UDID" "$app" + node test/SharpLink.CodecCompatibility.iOS/run-ios.mjs \ + produce artifacts/codec-compat/ios-coreclr-corpus \ + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" \ + "net11.0-ios/${{ matrix.rid }}" + + - name: Assemble iOS CoreCLR producer fan-in + shell: bash + run: | + mkdir -p artifacts/codec-compat/producers/codec-mobile-corpus-${{ matrix.id }}-coreclr + cp -R artifacts/codec-compat/ios-coreclr-corpus/. \ + artifacts/codec-compat/producers/codec-mobile-corpus-${{ matrix.id }}-coreclr/ + + - name: Verify experimental iOS CoreCLR self edge shell: bash env: - ANDROID_RESULT: ${{ needs.android.result }} + SHARPLINK_SKIP_BUILTIN_RAW: '1' run: | - if [[ "$ANDROID_RESULT" != "success" ]]; then - echo "::error::Android CoreCLR codec evidence result: $ANDROID_RESULT" - exit 1 - fi + node test/SharpLink.CodecCompatibility.iOS/run-ios.mjs \ + verify artifacts/codec-compat/producers \ + artifacts/codec-compat/ios-coreclr-verification/verification.json \ + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" \ + "net11.0-ios/${{ matrix.rid }}" - - name: Download Android CoreCLR evidence - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Append raw evidence for iOS CoreCLR + run: | + node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ + append-raw artifacts/codec-compat/ios-coreclr-verification/verification.json \ + artifacts/codec-compat/producers artifacts/codec-compat/ios-coreclr-corpus + node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ + check-report artifacts/codec-compat/ios-coreclr-verification/verification.json + + - name: Upload experimental iOS CoreCLR evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: codec-mobile-evidence-android-x64 - path: artifacts/codec-compat/verifications + name: codec-mobile-evidence-${{ matrix.id }}-coreclr + path: | + artifacts/codec-compat/ios-coreclr-corpus + artifacts/codec-compat/ios-coreclr-verification/verification.json + if-no-files-found: warn + retention-days: 30 - - name: Locate Android CoreCLR verification report + summary: + needs: [android, ios-coreclr] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require all non-Mono mobile evidence shell: bash + env: + ANDROID_RESULT: ${{ needs.android.result }} + IOS_RESULT: ${{ needs.ios-coreclr.result }} run: | - report="$(find artifacts/codec-compat/verifications -path '*android-coreclr-verification/verification.json' -type f | head -n 1)" - if [[ -z "$report" ]]; then - echo "::error::Android CoreCLR verification report was not found." + echo "Android CoreCLR (.NET 10): $ANDROID_RESULT" + echo "iOS CoreCLR (.NET 11 preview): $IOS_RESULT" + if [[ "$ANDROID_RESULT" != "success" || "$IOS_RESULT" != "success" ]]; then exit 1 fi - echo "CORECLR_REPORT=$report" >> "$GITHUB_ENV" - - - name: Validate Android CoreCLR evidence report - run: >- - node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs - check-report - "$CORECLR_REPORT" From 79319ae9e14581370634ae7c591acf1e1521ab76 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:48:53 +0800 Subject: [PATCH 235/399] ci: allow net11 iOS CoreCLR portable evidence --- .../run-ios.mjs | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs b/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs index 1123913fa..06a3c1628 100644 --- a/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs +++ b/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs @@ -1,11 +1,78 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { loadEnvelopes, writeCorpus } from '../SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs'; const bundleId = 'com.sharplink.codeccompat.ios'; const inputFileName = 'sharplink-input.json'; const resultFileName = 'sharplink-result.json'; +const builtinRawCategory = 'builtin-semantic-raw'; + +async function findManifestFiles(root) { + const found = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(fullPath); + } else if (entry.isFile() && entry.name === 'manifest.json') { + found.push(fullPath); + } + } + } + await visit(root); + found.sort((left, right) => left.localeCompare(right)); + return found; +} + +async function loadEnvelopes(root) { + const manifestFiles = await findManifestFiles(root); + if (manifestFiles.length === 0) { + throw new Error(`No manifest.json files found under ${root}`); + } + + const excludeBuiltinRaw = process.env.SHARPLINK_SKIP_BUILTIN_RAW === '1'; + const envelopes = []; + for (const manifestFile of manifestFiles) { + const originalManifest = JSON.parse(await fs.readFile(manifestFile, 'utf8')); + if (originalManifest?.schemaVersion !== 1 || !Array.isArray(originalManifest?.cases)) { + throw new Error(`Invalid portable manifest ${manifestFile}.`); + } + const cases = originalManifest.cases.filter( + item => !excludeBuiltinRaw || item?.category !== builtinRawCategory); + const manifest = { ...originalManifest, cases }; + const corpusRoot = path.dirname(manifestFile); + const caseBytesBase64 = {}; + for (const item of cases) { + const wirePath = path.join(corpusRoot, ...String(item.wireFile).split('/')); + caseBytesBase64[item.id] = (await fs.readFile(wirePath)).toString('base64'); + } + envelopes.push({ schemaVersion: 1, manifest, caseBytesBase64 }); + } + return envelopes; +} + +async function writeCorpus(envelope, outputDirectory) { + if (envelope?.schemaVersion !== 1 || !envelope?.manifest || !envelope?.caseBytesBase64) { + throw new Error('Portable producer output is not a corpus envelope.'); + } + + await fs.rm(outputDirectory, { recursive: true, force: true }); + await fs.mkdir(path.join(outputDirectory, 'cases'), { recursive: true }); + await fs.writeFile( + path.join(outputDirectory, 'manifest.json'), + JSON.stringify(envelope.manifest, null, 2) + '\n', + 'utf8'); + + for (const item of envelope.manifest.cases ?? []) { + const encoded = envelope.caseBytesBase64[item.id]; + if (typeof encoded !== 'string') { + throw new Error(`Portable envelope is missing ${item.id}.`); + } + const wirePath = path.join(outputDirectory, ...String(item.wireFile).split('/')); + await fs.mkdir(path.dirname(wirePath), { recursive: true }); + await fs.writeFile(wirePath, Buffer.from(encoded, 'base64')); + } +} function simctl(args, env = process.env) { const result = spawnSync('xcrun', ['simctl', ...args], { encoding: 'utf8', env }); From 42a7a677e1fd5c9fc6a4f9a44bfe21dcd54160e1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:49:25 +0800 Subject: [PATCH 236/399] ci: validate net11 iOS CoreCLR evidence directly --- .../workflows/codec-mobile-compatibility.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codec-mobile-compatibility.yml b/.github/workflows/codec-mobile-compatibility.yml index f1d4c4f32..5674b09a0 100644 --- a/.github/workflows/codec-mobile-compatibility.yml +++ b/.github/workflows/codec-mobile-compatibility.yml @@ -295,13 +295,19 @@ jobs: "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" \ "net11.0-ios/${{ matrix.rid }}" - - name: Append raw evidence for iOS CoreCLR + - name: Validate iOS CoreCLR semantic evidence + shell: bash run: | - node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - append-raw artifacts/codec-compat/ios-coreclr-verification/verification.json \ - artifacts/codec-compat/producers artifacts/codec-compat/ios-coreclr-corpus - node test/SharpLink.CodecCompatibility.Browser/portable-artifacts.mjs \ - check-report artifacts/codec-compat/ios-coreclr-verification/verification.json + node - <<'NODE' + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync('artifacts/codec-compat/ios-coreclr-verification/verification.json', 'utf8')); + if (report?.schemaVersion !== 1) throw new Error('Unexpected iOS verification schema.'); + if (report?.consumer?.runtimeFamily !== 'CoreCLR') throw new Error(`Expected CoreCLR, got ${report?.consumer?.runtimeFamily}`); + if (!String(report?.consumer?.platformTag ?? '').endsWith('-coreclr-net11')) throw new Error(`Unexpected platform tag ${report?.consumer?.platformTag}`); + const blocking = (report?.results ?? []).filter(item => item?.blocking).length; + if (blocking !== 0) throw new Error(`iOS CoreCLR verification has ${blocking} blocking result(s).`); + console.log(`Validated iOS CoreCLR semantic report with ${report?.results?.length ?? 0} result(s) and no blockers.`); + NODE - name: Upload experimental iOS CoreCLR evidence if: always() From a04bd43ab8d75e95942a651a2e2ed2de98d038db Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:54:20 +0800 Subject: [PATCH 237/399] ci: cross-check desktop corpus on iOS CoreCLR --- .github/workflows/codec-mobile-compatibility.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codec-mobile-compatibility.yml b/.github/workflows/codec-mobile-compatibility.yml index 5674b09a0..472913f23 100644 --- a/.github/workflows/codec-mobile-compatibility.yml +++ b/.github/workflows/codec-mobile-compatibility.yml @@ -184,6 +184,7 @@ jobs: retention-days: 30 ios-coreclr: + needs: desktop-reference strategy: fail-fast: false matrix: @@ -235,6 +236,12 @@ jobs: test "$actual" = "$DOTNET_11_SDK" echo "SHARPLINK_SDK_VERSION=$actual" >> "$GITHUB_ENV" + - name: Download canonical desktop corpus + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codec-mobile-corpus-desktop-linux-x64 + path: artifacts/codec-compat/producers/codec-mobile-corpus-desktop-linux-x64 + - name: Boot an iOS simulator shell: bash run: | @@ -284,7 +291,7 @@ jobs: cp -R artifacts/codec-compat/ios-coreclr-corpus/. \ artifacts/codec-compat/producers/codec-mobile-corpus-${{ matrix.id }}-coreclr/ - - name: Verify experimental iOS CoreCLR self edge + - name: Verify desktop and self edges on experimental iOS CoreCLR shell: bash env: SHARPLINK_SKIP_BUILTIN_RAW: '1' @@ -304,9 +311,14 @@ jobs: if (report?.schemaVersion !== 1) throw new Error('Unexpected iOS verification schema.'); if (report?.consumer?.runtimeFamily !== 'CoreCLR') throw new Error(`Expected CoreCLR, got ${report?.consumer?.runtimeFamily}`); if (!String(report?.consumer?.platformTag ?? '').endsWith('-coreclr-net11')) throw new Error(`Unexpected platform tag ${report?.consumer?.platformTag}`); + const expectedProducers = new Set(['linux-x64-hosted-desktop-coreclr-net10', report.consumer.platformTag]); + const actualProducers = new Set((report?.results ?? []).map(item => String(item?.producer ?? ''))); + if (actualProducers.size !== expectedProducers.size || [...expectedProducers].some(value => !actualProducers.has(value))) { + throw new Error(`Unexpected iOS producer set: ${[...actualProducers].sort().join(', ')}`); + } const blocking = (report?.results ?? []).filter(item => item?.blocking).length; if (blocking !== 0) throw new Error(`iOS CoreCLR verification has ${blocking} blocking result(s).`); - console.log(`Validated iOS CoreCLR semantic report with ${report?.results?.length ?? 0} result(s) and no blockers.`); + console.log(`Validated iOS CoreCLR cross-platform semantic report with ${report?.results?.length ?? 0} result(s) and no blockers.`); NODE - name: Upload experimental iOS CoreCLR evidence From 63cc97b077a4cf23a9150824a2b340b9d99b3a27 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:14:39 +0800 Subject: [PATCH 238/399] ci: disable unsupported iOS CoreCLR debugger mode --- .../SharpLink.CodecCompatibility.iOS.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj b/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj index d6793cc5c..4ce0c87be 100644 --- a/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj +++ b/test/SharpLink.CodecCompatibility.iOS/SharpLink.CodecCompatibility.iOS.csproj @@ -15,6 +15,7 @@ true false true + false false $(NoWarn);IL2026;IL2090;CA1422 From cebe14e647d56f5ecd4943217cf2d0100fa22448 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:15:06 +0800 Subject: [PATCH 239/399] ci: harden experimental iOS CoreCLR simulator probe --- .../run-ios.mjs | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs b/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs index 06a3c1628..c4451ba5e 100644 --- a/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs +++ b/test/SharpLink.CodecCompatibility.iOS/run-ios.mjs @@ -6,6 +6,9 @@ const bundleId = 'com.sharplink.codeccompat.ios'; const inputFileName = 'sharplink-input.json'; const resultFileName = 'sharplink-result.json'; const builtinRawCategory = 'builtin-semantic-raw'; +const probeTimeoutMs = Number(process.env.SHARPLINK_IOS_PROBE_TIMEOUT_MS ?? 300_000); +const maxProbeAttempts = Number(process.env.SHARPLINK_IOS_PROBE_ATTEMPTS ?? 2); +const probeTimeoutCode = 'SHARPLINK_IOS_PROBE_TIMEOUT'; async function findManifestFiles(root) { const found = []; @@ -100,7 +103,7 @@ function delay(milliseconds) { } async function waitForResult(resultPath, launchOutput) { - const deadline = Date.now() + 120_000; + const deadline = Date.now() + probeTimeoutMs; while (Date.now() < deadline) { try { return await fs.readFile(resultPath, 'utf8'); @@ -112,30 +115,31 @@ async function waitForResult(resultPath, launchOutput) { const diagnostics = [ `simctl launch output:\n${launchOutput}`, + simctlDiagnostic(['list', 'devices']), simctlDiagnostic(['get_app_container', 'booted', bundleId, 'app']), simctlDiagnostic(['get_app_container', 'booted', bundleId, 'data']), simctlDiagnostic([ 'spawn', 'booted', 'log', 'show', - '--last', '3m', + '--last', '5m', '--style', 'compact', '--predicate', 'process CONTAINS[c] "SharpLink" OR eventMessage CONTAINS[c] "SharpLink codec"' ]) ].join('\n\n'); - throw new Error(`iOS simulator probe timed out waiting for container result file.\n${diagnostics}`); + const error = new Error( + `iOS simulator probe timed out after ${probeTimeoutMs} ms waiting for container result file.\n${diagnostics}`); + error.code = probeTimeoutCode; + throw error; } async function runIos(mode, producerRoot, outputPath, commit, sdkVersion, targetFramework) { const input = mode === 'verify' ? JSON.stringify(await loadEnvelopes(producerRoot)) : null; - try { simctl(['terminate', 'booted', bundleId]); } catch {} - const dataContainer = simctl(['get_app_container', 'booted', bundleId, 'data']).trim(); if (!dataContainer) throw new Error('simctl returned an empty iOS app data-container path.'); const documentsDirectory = path.join(dataContainer, 'Documents'); const inputPath = path.join(documentsDirectory, inputFileName); const resultPath = path.join(documentsDirectory, resultFileName); await fs.mkdir(documentsDirectory, { recursive: true }); - await fs.rm(resultPath, { force: true }); await fs.rm(inputPath, { force: true }); if (input !== null) await fs.writeFile(inputPath, input, 'utf8'); @@ -146,14 +150,41 @@ async function runIos(mode, producerRoot, outputPath, commit, sdkVersion, target SIMCTL_CHILD_SHARPLINK_SDK_VERSION: sdkVersion, SIMCTL_CHILD_SHARPLINK_TARGET_FRAMEWORK: targetFramework }; - const launchOutput = simctl( - ['launch', '--terminate-running-process', 'booted', bundleId], - launchEnv); - console.log(`iOS simulator launch: ${launchOutput.trim()}`); - console.log(`iOS simulator data container: ${dataContainer}`); + let resultText; try { - const resultText = await waitForResult(resultPath, launchOutput); + for (let attempt = 1; attempt <= maxProbeAttempts; attempt++) { + try { simctl(['terminate', 'booted', bundleId]); } catch {} + await fs.rm(resultPath, { force: true }); + if (input !== null) await fs.writeFile(inputPath, input, 'utf8'); + + if (attempt > 1) { + console.warn(`Retrying iOS simulator probe (${attempt}/${maxProbeAttempts}) after timeout.`); + await delay(3_000); + } + + const launchOutput = simctl( + ['launch', '--terminate-running-process', 'booted', bundleId], + launchEnv); + console.log(`iOS simulator launch attempt ${attempt}/${maxProbeAttempts}: ${launchOutput.trim()}`); + console.log(`iOS simulator data container: ${dataContainer}`); + + try { + resultText = await waitForResult(resultPath, launchOutput); + break; + } catch (error) { + if (error?.code !== probeTimeoutCode || attempt === maxProbeAttempts) { + throw error; + } + console.warn(error.message); + try { simctl(['terminate', 'booted', bundleId]); } catch {} + } + } + + if (resultText === undefined) { + throw new Error('iOS simulator probe completed without a result.'); + } + const parsed = JSON.parse(resultText); if (parsed?.portableProbeError) { throw new Error(parsed.portableProbeError); From fb1b4ab4adf82e40dbf92a2990a1f3073060867f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:15:50 +0800 Subject: [PATCH 240/399] test: validate runtime manifests by target framework generation --- test/SharpLink.CodecCompatibility/Models.cs | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.CodecCompatibility/Models.cs b/test/SharpLink.CodecCompatibility/Models.cs index 0fb264baf..b359ed37f 100644 --- a/test/SharpLink.CodecCompatibility/Models.cs +++ b/test/SharpLink.CodecCompatibility/Models.cs @@ -51,7 +51,8 @@ void IJsonOnDeserialized.OnDeserialized() FixtureRegistry ??= CreateFixtureRegistry(); ValidateFixtureRegistry(); - var derivedTag = $"{Os}-{ProcessArchitecture}-{ExecutionEnvironment}-{RuntimeFamily.ToLowerInvariant()}-net10"; + var frameworkTag = GetFrameworkTag(TargetFramework); + var derivedTag = $"{Os}-{ProcessArchitecture}-{ExecutionEnvironment}-{RuntimeFamily.ToLowerInvariant()}-{frameworkTag}"; if (!string.Equals(PlatformTag, derivedTag, StringComparison.Ordinal)) throw new InvalidOperationException($"Runtime manifest platformTag mismatch: recorded={PlatformTag}, derived={derivedTag}."); @@ -88,6 +89,12 @@ void IJsonOnDeserialized.OnDeserialized() case "ios-arm64-simulator-mono-net10": ValidateKnownIdentity("ios", "arm64", "simulator", "Mono", "platform-runtime-pack", "iossimulator-arm64", "net10.0-ios/iossimulator-arm64", 8); break; + case "ios-x64-simulator-coreclr-net11": + ValidateKnownIdentity("ios", "x64", "simulator", "CoreCLR", "runtime-reflection", "iossimulator-x64", "net11.0-ios/iossimulator-x64", 8); + break; + case "ios-arm64-simulator-coreclr-net11": + ValidateKnownIdentity("ios", "arm64", "simulator", "CoreCLR", "runtime-reflection", "iossimulator-arm64", "net11.0-ios/iossimulator-arm64", 8); + break; case "android-arm64-physical-device-mono-net10": case "android-arm64-physical-device-coreclr-net10": ValidateKnownIdentity("android", "arm64", "physical-device", RuntimeFamily, "loaded-runtime-library", "android-arm64", "net10.0-android/android-arm64", 8); @@ -129,6 +136,20 @@ private void ValidateKnownIdentity( private void ValidateFixtureRegistry() => CompatibilityPolicy.ValidateManifestFixtureRegistry(this); + private static string GetFrameworkTag(string targetFramework) + { + var tfm = (targetFramework ?? string.Empty).Split('/', 2)[0]; + var platformSeparator = tfm.IndexOf('-'); + if (platformSeparator >= 0) + tfm = tfm[..platformSeparator]; + var versionSeparator = tfm.IndexOf('.'); + if (versionSeparator > 0) + tfm = tfm[..versionSeparator]; + if (!tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase) || tfm.Length <= 3) + throw new InvalidOperationException($"Unsupported target framework identity {targetFramework}."); + return tfm.ToLowerInvariant(); + } + private static string DefaultRuntimeFamilySource() => OperatingSystem.IsBrowser() || OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst() ? "platform-runtime-pack" From 39979a561dca758f917bf4d8fd434ca395b5fd0c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:24:54 +0800 Subject: [PATCH 241/399] feat: advise on AutoLayout UnsafeBlit payloads --- .../AnalyzerReleases.Unshipped.md | 1 + ...rator.UnsafeBlitCompatibilityDiagnostic.cs | 192 +++++++++++++++++ .../UnsafeBlitCompatibilityDiagnosticTests.cs | 199 ++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs create mode 100644 test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs diff --git a/src/SharpLink.Generator/AnalyzerReleases.Unshipped.md b/src/SharpLink.Generator/AnalyzerReleases.Unshipped.md index 1347752c5..cd866c2ba 100644 --- a/src/SharpLink.Generator/AnalyzerReleases.Unshipped.md +++ b/src/SharpLink.Generator/AnalyzerReleases.Unshipped.md @@ -64,3 +64,4 @@ SHARPLINK061 | SharpLink.Generator | Error | Custom RPC Codec wire/schema identity is invalid SHARPLINK062 | SharpLink.Generator | Error | RPC payload selects multiple different Custom Codecs SHARPLINK063 | SharpLink.Generator | Error | Custom Codec attempts to replace a built-in Codec + SHARPLINK064 | SharpLink.Generator | Info | Implicit UnsafeBlit payload contains source-defined AutoLayout diff --git a/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs new file mode 100644 index 000000000..25163da3c --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs @@ -0,0 +1,192 @@ +namespace SharpLink.Generator; + +/// +/// Reports non-blocking guidance for RPC payloads that ultimately use implicit UnsafeBlit over +/// source-defined AutoLayout value types. +/// +[Generator] +public sealed class UnsafeBlitCompatibilityDiagnosticGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var diagnostics = context.CompilationProvider.Select(static (compilation, cancellationToken) => + RpcGenerator.AnalyzeUnsafeBlitAutoLayoutDiagnostics(compilation, cancellationToken)); + + context.RegisterSourceOutput(diagnostics, static (productionContext, items) => + { + foreach (var diagnostic in items) + productionContext.ReportDiagnostic(diagnostic); + }); + } +} + +public partial class RpcGenerator +{ + private const int AutoLayoutKindValue = 3; + + private static readonly DiagnosticDescriptor ImplicitUnsafeBlitAutoLayoutRule = new( + id: "SHARPLINK064", + title: "Implicit UnsafeBlit Contains Source-Defined AutoLayout", + messageFormat: "RPC payload '{0}' uses implicit UnsafeBlit, and its recursive unmanaged field graph contains source-defined AutoLayout type '{1}' at '{2}'. Raw-memory wire layout can vary across runtimes; for stable cross-runtime raw wire prefer LayoutKind.Sequential or LayoutKind.Explicit, or bind an explicit custom/adapter codec.", + category: "SharpLink.Generator", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Source-defined AutoLayout inside an implicit UnsafeBlit payload can make raw-memory wire layout runtime-dependent. This diagnostic is advisory and does not change Codec selection or generated wire behavior."); + + internal static ImmutableArray AnalyzeUnsafeBlitAutoLayoutDiagnostics( + Compilation compilation, + CancellationToken cancellationToken) + { + var codecAnalysis = AnalyzeGeneratedCodecsWithPolicyOwnership(compilation, cancellationToken); + var finalCodecBoundTypes = new HashSet( + codecAnalysis.FinalCodecBoundTypes, + StringComparer.Ordinal); + var payloadRoots = new Dictionary(StringComparer.Ordinal); + CollectCurrentContractPayloadRoots(compilation.Assembly.GlobalNamespace, payloadRoots); + + var diagnostics = ImmutableArray.CreateBuilder(); + foreach (var pair in payloadRoots.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + var payload = pair.Value; + if (!payload.IsUnmanagedType || finalCodecBoundTypes.Contains(pair.Key)) + continue; + + foreach (var hazard in FindSourceAutoLayoutHazards(payload, compilation.Assembly, cancellationToken)) + { + diagnostics.Add(Diagnostic.Create( + ImplicitUnsafeBlitAutoLayoutRule, + hazard.Location, + pair.Key, + hazard.TypeName, + hazard.FieldPath)); + } + } + + return diagnostics.ToImmutable(); + } + + private static void CollectCurrentContractPayloadRoots( + INamespaceSymbol namespaceSymbol, + Dictionary roots) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + CollectCurrentContractPayloadRoots(type, roots); + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + CollectCurrentContractPayloadRoots(nestedNamespace, roots); + } + + private static void CollectCurrentContractPayloadRoots( + INamedTypeSymbol type, + Dictionary roots) + { + if (type.TypeKind == TypeKind.Interface && HasRpcContractAttribute(type)) + { + foreach (var method in GetContractMethods(type)) + { + foreach (var parameter in method.Parameters) + { + if (IsCancellationTokenParameter(parameter)) + continue; + if (IsAsyncEnumerable(parameter.Type, out var streamItem)) + AddUnsafeBlitPayloadRoot(roots, streamItem!); + else + AddUnsafeBlitPayloadRoot(roots, parameter.Type); + } + + if (IsAsyncEnumerable(method.ReturnType, out var returnStreamItem)) + { + AddUnsafeBlitPayloadRoot(roots, returnStreamItem!); + } + else if (method.ReturnType is INamedTypeSymbol { IsGenericType: true } taskLike && + taskLike.TypeArguments.Length == 1) + { + AddUnsafeBlitPayloadRoot(roots, taskLike.TypeArguments[0]); + } + } + } + + foreach (var nested in type.GetTypeMembers()) + CollectCurrentContractPayloadRoots(nested, roots); + } + + private static void AddUnsafeBlitPayloadRoot( + Dictionary roots, + ITypeSymbol type) + { + var typeName = GetTypeName(type); + if (!roots.ContainsKey(typeName)) + roots.Add(typeName, type); + } + + private static ImmutableArray FindSourceAutoLayoutHazards( + ITypeSymbol root, + IAssemblySymbol sourceAssembly, + CancellationToken cancellationToken) + { + var hazards = ImmutableArray.CreateBuilder(); + var visited = new HashSet(SymbolEqualityComparer.Default); + Visit(root, GetTypeName(root)); + return hazards + .OrderBy(static item => item.TypeName, StringComparer.Ordinal) + .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) + .ToImmutableArray(); + + void Visit(ITypeSymbol type, string fieldPath) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!type.IsUnmanagedType || !visited.Add(type) || type is not INamedTypeSymbol named) + return; + + if (SymbolEqualityComparer.Default.Equals(named.ContainingAssembly, sourceAssembly) && + HasExplicitAutoLayout(named)) + { + var location = named.Locations.FirstOrDefault(static item => item.IsInSource) + ?? root.Locations.FirstOrDefault(static item => item.IsInSource) + ?? Location.None; + hazards.Add(new UnsafeBlitAutoLayoutHazard( + GetTypeName(named), + fieldPath, + location)); + } + + foreach (var field in named.GetMembers().OfType() + .Where(static field => !field.IsStatic && !field.IsConst) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + if (field.Type.IsUnmanagedType) + Visit(field.Type, fieldPath + "." + field.Name); + } + } + } + + private static bool HasExplicitAutoLayout(INamedTypeSymbol type) + { + foreach (var attribute in type.GetAttributes()) + { + if (attribute.AttributeClass is not { Name: "StructLayoutAttribute" } attributeClass || + !string.Equals( + attributeClass.ContainingNamespace.ToDisplayString(), + "System.Runtime.InteropServices", + StringComparison.Ordinal) || + attribute.ConstructorArguments.Length == 0) + { + continue; + } + + if (attribute.ConstructorArguments[0].Value is int layoutKind && + layoutKind == AutoLayoutKindValue) + { + return true; + } + } + + return false; + } + + private readonly record struct UnsafeBlitAutoLayoutHazard( + string TypeName, + string FieldPath, + Location Location); +} diff --git a/test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs b/test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs new file mode 100644 index 000000000..921a86ce2 --- /dev/null +++ b/test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task ImplicitUnsafeBlitSourceAutoLayoutShouldReportInfo() + { + var source = BuildSource(""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] +public struct AutoPayload +{ + public byte Head; + public long Tail; +} + +[SharpLink.Sdk.RpcContract] +public interface IAutoLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo(AutoPayload value, CancellationToken cancellationToken); +} +"""); + + var diagnostics = RunUnsafeBlitCompatibilityGenerator(source); + var diagnostic = diagnostics.Single(static item => item.Id == "SHARPLINK064"); + Ensure(diagnostic.Severity == DiagnosticSeverity.Info, + "AutoLayout UnsafeBlit guidance must remain informational and non-blocking"); + var message = diagnostic.GetMessage(); + Ensure(message.Contains("AutoPayload", StringComparison.Ordinal) && + message.Contains("LayoutKind.Sequential", StringComparison.Ordinal) && + message.Contains("LayoutKind.Explicit", StringComparison.Ordinal) && + message.Contains("custom/adapter codec", StringComparison.Ordinal), + $"SHARPLINK064 must explain the raw-wire mitigation choices. Actual: {message}"); + return Task.CompletedTask; + } + + [Test] + public Task ImplicitUnsafeBlitShouldDetectNestedSourceAutoLayout() + { + var source = BuildSource(""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] +public struct AutoLeaf +{ + public short Code; + public long Value; +} + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct SequentialEnvelope +{ + public int Prefix; + public AutoLeaf Leaf; +} + +[SharpLink.Sdk.RpcContract] +public interface INestedAutoLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo(SequentialEnvelope value, CancellationToken cancellationToken); +} +"""); + + var diagnostic = RunUnsafeBlitCompatibilityGenerator(source) + .Single(static item => item.Id == "SHARPLINK064"); + Ensure(diagnostic.GetMessage().Contains("AutoLeaf", StringComparison.Ordinal) && + diagnostic.GetMessage().Contains("SequentialEnvelope.Leaf", StringComparison.Ordinal), + $"nested AutoLayout evidence must identify the nested source type and field path. Actual: {diagnostic.GetMessage()}"); + return Task.CompletedTask; + } + + [Test] + public Task SequentialAndExplicitUnsafeBlitPayloadsShouldNotReportAutoLayoutSuggestion() + { + var source = BuildSource(""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct SequentialPayload +{ + public byte Head; + public long Tail; +} + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)] +public struct ExplicitPayload +{ + [System.Runtime.InteropServices.FieldOffset(0)] public byte Head; + [System.Runtime.InteropServices.FieldOffset(8)] public long Tail; +} + +[SharpLink.Sdk.RpcContract] +public interface IStableLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Sequential(SequentialPayload value, CancellationToken cancellationToken); + ValueTask Explicit(ExplicitPayload value, CancellationToken cancellationToken); +} +"""); + + Ensure(!RunUnsafeBlitCompatibilityGenerator(source).Any(static item => item.Id == "SHARPLINK064"), + "Sequential and Explicit payloads must not receive the AutoLayout-specific suggestion"); + return Task.CompletedTask; + } + + [Test] + public Task ExplicitCustomAndAdapterBindingsShouldSuppressUnsafeBlitSuggestion() + { + var source = AddAssemblyAttributes(BuildSource(""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] +public struct CustomPayload +{ + public int Value; +} + +[SharpLink.Sdk.RpcCodecImplementation("custom-auto/v1", "custom-auto-schema/v1")] +public sealed class CustomPayloadCodec : SharpLink.Abstractions.IRpcCodec +{ +} + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] +public struct AdapterPayload +{ + public long Value; +} + +public sealed class AdapterPayloadAdapter : SharpLink.Abstractions.IRpcCodecAdapter +{ + public string AdapterId => "adapter-auto/v1"; + public string WireFormatId => "adapter-auto-wire/v1"; + public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); +} + +[SharpLink.Sdk.RpcContract] +public interface IExplicitCodecContract : SharpLink.Sdk.IService +{ + ValueTask Custom(CustomPayload value, CancellationToken cancellationToken); + ValueTask Adapted(AdapterPayload value, CancellationToken cancellationToken); +} +"""), + "[assembly: SharpLink.Sdk.RpcCodec(typeof(CustomPayload), typeof(CustomPayloadCodec))]", + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(AdapterPayloadAdapter), \"adapter-auto/v1\", \"adapter-auto-wire/v1\")]", + "[assembly: SharpLink.Sdk.RpcCodecAdapter(typeof(AdapterPayload), typeof(AdapterPayloadAdapter))]"); + + Ensure(!RunUnsafeBlitCompatibilityGenerator(source).Any(static item => item.Id == "SHARPLINK064"), + "valid explicit custom/adapter bindings mean the payload no longer uses implicit UnsafeBlit"); + return Task.CompletedTask; + } + + [Test] + public Task ReferencedAutoLayoutShouldNotReportSourceLevelSuggestion() + { + var external = CreateMetadataReference( + "ExternalAutoLayout", + """ +namespace ExternalAutoLayout +{ + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] + public struct ExternalAutoPayload + { + public int Value; + } +} +"""); + var source = BuildSource(""" +[SharpLink.Sdk.RpcContract] +public interface IExternalAutoLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + global::ExternalAutoLayout.ExternalAutoPayload value, + CancellationToken cancellationToken); +} +"""); + + Ensure(!RunUnsafeBlitCompatibilityGenerator(source, external) + .Any(static item => item.Id == "SHARPLINK064"), + "framework/referenced AutoLayout types must not receive a source-level SharpLink suggestion"); + return Task.CompletedTask; + } + + private static ImmutableArray RunUnsafeBlitCompatibilityGenerator( + string source, + params MetadataReference[] additionalReferences) + { + source = UseCurrentIdentitySdk(source); + var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); + var compilation = CSharpCompilation.Create( + assemblyName: "UnsafeBlitCompatibilityDiagnosticTests", + syntaxTrees: [syntaxTree], + references: GetPlatformReferences().Concat(additionalReferences), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + IIncrementalGenerator generator = new UnsafeBlitCompatibilityDiagnosticGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGenerators(compilation); + return driver.GetRunResult().Diagnostics; + } +} From c7fe26ae29b077259ce94d9690b1b5ab2cadbb7e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:38:05 +0800 Subject: [PATCH 242/399] test: expand UnsafeBlit layout compatibility evidence --- .../codec-unsafe-blit-layout-evidence.yml | 406 ++++++ .../LayoutEvidenceActivity.cs | 106 ++ ...harpLink.CodecCompatibility.Android.csproj | 2 + .../run-layout-android.mjs | 63 + .../LayoutEvidenceExports.cs | 26 + ...harpLink.CodecCompatibility.Browser.csproj | 2 + .../layout-artifacts.mjs | 46 + .../layout-main.js | 38 + .../run-layout-browser.mjs | 107 ++ .../Program.cs | 164 +++ ...k.CodecCompatibility.LayoutEvidence.csproj | 20 + ...eBlitLayoutEvidence.StringCompatibility.cs | 9 + .../UnsafeBlitLayoutEvidence.cs | 1281 +++++++++++++++++ 13 files changed, 2270 insertions(+) create mode 100644 .github/workflows/codec-unsafe-blit-layout-evidence.yml create mode 100644 test/SharpLink.CodecCompatibility.Android/LayoutEvidenceActivity.cs create mode 100644 test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs create mode 100644 test/SharpLink.CodecCompatibility.Browser/LayoutEvidenceExports.cs create mode 100644 test/SharpLink.CodecCompatibility.Browser/layout-artifacts.mjs create mode 100644 test/SharpLink.CodecCompatibility.Browser/layout-main.js create mode 100644 test/SharpLink.CodecCompatibility.Browser/run-layout-browser.mjs create mode 100644 test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs create mode 100644 test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj create mode 100644 test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.StringCompatibility.cs create mode 100644 test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs diff --git a/.github/workflows/codec-unsafe-blit-layout-evidence.yml b/.github/workflows/codec-unsafe-blit-layout-evidence.yml new file mode 100644 index 000000000..56c914b2f --- /dev/null +++ b/.github/workflows/codec-unsafe-blit-layout-evidence.yml @@ -0,0 +1,406 @@ +name: UnsafeBlit Layout Evidence + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/codec-unsafe-blit-layout-evidence.yml' + - 'src/SharpLink.Runtime/Codec/**' + - 'src/SharpLink.Runtime/SharpLink.Runtime.csproj' + - 'test/SharpLink.CodecCompatibility/**' + - 'test/SharpLink.CodecCompatibility.LayoutEvidence/**' + - 'test/SharpLink.CodecCompatibility.Android/**' + - 'test/SharpLink.CodecCompatibility.Browser/**' + +concurrency: + group: unsafe-blit-layout-evidence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + SHARPLINK_COMMIT: ${{ github.sha }} + +jobs: + desktop-produce: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Record SDK version + shell: bash + run: echo "SHARPLINK_SDK_VERSION=$(dotnet --version)" >> "$GITHUB_ENV" + + - name: Produce fixed-width desktop evidence + run: >- + dotnet run -c Release + --project test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj + -- produce --profile fixed-width --output artifacts/layout/desktop/fixed-width + + - name: Produce native-width desktop evidence + run: >- + dotnet run -c Release + --project test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj + -- produce --profile native-width --output artifacts/layout/desktop/native-width + + - name: Upload desktop layout corpora + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsafe-blit-layout-corpus-desktop-linux-x64 + path: artifacts/layout/desktop + if-no-files-found: error + retention-days: 30 + + browser-produce: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Install WebAssembly workload + run: dotnet workload install wasm-tools + + - name: Record SDK version + shell: bash + run: echo "SHARPLINK_SDK_VERSION=$(dotnet --version)" >> "$GITHUB_ENV" + + - name: Publish Browser probe + run: >- + dotnet publish -c Release + test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj + -o artifacts/layout/browser-publish + + - name: Produce fixed-width Browser evidence + run: >- + node test/SharpLink.CodecCompatibility.Browser/run-layout-browser.mjs + produce artifacts/layout/browser-publish artifacts/layout/browser/fixed-width fixed-width + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" + + - name: Produce native-width Browser evidence + run: >- + node test/SharpLink.CodecCompatibility.Browser/run-layout-browser.mjs + produce artifacts/layout/browser-publish artifacts/layout/browser/native-width native-width + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" + + - name: Upload Browser layout corpora + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsafe-blit-layout-corpus-browser-wasm + path: artifacts/layout/browser + if-no-files-found: error + retention-days: 30 + + android-matrix: + needs: [desktop-produce, browser-produce] + runs-on: ubuntu-24.04 + timeout-minutes: 65 + env: + ANDROID_API: '35' + ANDROID_AVD: sharplink-layout-evidence + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Install Android workload + run: dotnet workload install android + + - name: Record SDK version + shell: bash + run: echo "SHARPLINK_SDK_VERSION=$(dotnet --version)" >> "$GITHUB_ENV" + + - name: Download desktop producer + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-corpus-desktop-linux-x64 + path: artifacts/layout/producers/desktop + + - name: Download Browser producer + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-corpus-browser-wasm + path: artifacts/layout/producers/browser + + - name: Prepare Android x64 emulator + shell: bash + run: | + sudo chmod 666 /dev/kvm || true + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + AVDMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" + yes | "$SDKMANAGER" --licenses >/dev/null || true + "$SDKMANAGER" "platform-tools" "emulator" "platforms;android-$ANDROID_API" "system-images;android-$ANDROID_API;google_apis;x86_64" + echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" + echo "$ANDROID_HOME/emulator" >> "$GITHUB_PATH" + export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH" + export ANDROID_AVD_HOME="$RUNNER_TEMP/android-layout-avd" + echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV" + mkdir -p "$ANDROID_AVD_HOME" + echo no | "$AVDMANAGER" create avd --force --name "$ANDROID_AVD" --package "system-images;android-$ANDROID_API;google_apis;x86_64" --device pixel_6 + nohup emulator -avd "$ANDROID_AVD" -no-window -noaudio -no-boot-anim -no-snapshot -gpu swiftshader_indirect -accel on > artifacts-layout-android-emulator.log 2>&1 & + timeout 180 adb wait-for-device + for attempt in $(seq 1 120); do + [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]] && break + sleep 2 + done + [[ "$(adb shell getprop sys.boot_completed | tr -d '\r')" == "1" ]] + adb shell settings put global window_animation_scale 0 + adb shell settings put global transition_animation_scale 0 + adb shell settings put global animator_duration_scale 0 + + - name: Build Android CoreCLR probe + shell: bash + run: | + rm -rf test/SharpLink.CodecCompatibility.Android/bin test/SharpLink.CodecCompatibility.Android/obj + dotnet build -c Debug -f net10.0-android -r android-x64 \ + test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj \ + -t:SignAndroidPackage -p:CodecRuntime=coreclr -p:AndroidPackageFormats=apk -p:AndroidBuildApplicationPackage=true + apk="$(find test/SharpLink.CodecCompatibility.Android/bin/Debug -name '*-Signed.apk' -type f | head -n 1)" + test -n "$apk" + cp "$apk" artifacts/layout/android-coreclr.apk + + - name: Produce Android CoreCLR fixed/native corpora + shell: bash + run: | + adb install -r artifacts/layout/android-coreclr.apk + node test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs produce \ + artifacts/layout/android-producers/coreclr/fixed-width fixed-width "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" CoreCLR + node test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs produce \ + artifacts/layout/android-producers/coreclr/native-width native-width "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" CoreCLR + adb uninstall com.sharplink.codeccompat || true + + - name: Build Android Mono probe + shell: bash + run: | + rm -rf test/SharpLink.CodecCompatibility.Android/bin test/SharpLink.CodecCompatibility.Android/obj + dotnet build -c Debug -f net10.0-android -r android-x64 \ + test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj \ + -t:SignAndroidPackage -p:CodecRuntime=mono -p:AndroidPackageFormats=apk -p:AndroidBuildApplicationPackage=true + apk="$(find test/SharpLink.CodecCompatibility.Android/bin/Debug -name '*-Signed.apk' -type f | head -n 1)" + test -n "$apk" + cp "$apk" artifacts/layout/android-mono.apk + + - name: Produce Android Mono fixed/native corpora + shell: bash + run: | + adb install -r artifacts/layout/android-mono.apk + node test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs produce \ + artifacts/layout/android-producers/mono/fixed-width fixed-width "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" Mono + node test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs produce \ + artifacts/layout/android-producers/mono/native-width native-width "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" Mono + adb uninstall com.sharplink.codeccompat || true + cp -R artifacts/layout/android-producers/. artifacts/layout/producers/android/ + + - name: Verify complete matrix on Android CoreCLR + shell: bash + run: | + adb install -r artifacts/layout/android-coreclr.apk + node test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs verify \ + artifacts/layout/producers artifacts/layout/android-reports/coreclr/layout-verification.json \ + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" CoreCLR + adb uninstall com.sharplink.codeccompat || true + + - name: Verify complete matrix on Android Mono + shell: bash + run: | + adb install -r artifacts/layout/android-mono.apk + node test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs verify \ + artifacts/layout/producers artifacts/layout/android-reports/mono/layout-verification.json \ + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" Mono + adb uninstall com.sharplink.codeccompat || true + + - name: Upload Android layout evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsafe-blit-layout-android + path: | + artifacts/layout/android-producers + artifacts/layout/android-reports + artifacts-layout-android-emulator.log + if-no-files-found: warn + retention-days: 30 + + desktop-verify: + needs: [desktop-produce, browser-produce, android-matrix] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Record SDK version + shell: bash + run: echo "SHARPLINK_SDK_VERSION=$(dotnet --version)" >> "$GITHUB_ENV" + + - name: Download desktop producer + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-corpus-desktop-linux-x64 + path: artifacts/layout/producers/desktop + + - name: Download Browser producer + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-corpus-browser-wasm + path: artifacts/layout/producers/browser + + - name: Download Android producers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-android + path: artifacts/layout/android-download + + - name: Add Android producers to fan-in + shell: bash + run: cp -R artifacts/layout/android-download/android-producers/. artifacts/layout/producers/android/ + + - name: Verify complete matrix on desktop CoreCLR + run: >- + dotnet run -c Release + --project test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj + -- verify --input artifacts/layout/producers --output artifacts/layout/desktop-report/layout-verification.json + + - name: Upload desktop verification + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsafe-blit-layout-verification-desktop + path: artifacts/layout/desktop-report/layout-verification.json + if-no-files-found: error + retention-days: 30 + + browser-verify: + needs: [desktop-produce, browser-produce, android-matrix] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Install WebAssembly workload + run: dotnet workload install wasm-tools + + - name: Record SDK version + shell: bash + run: echo "SHARPLINK_SDK_VERSION=$(dotnet --version)" >> "$GITHUB_ENV" + + - name: Download desktop producer + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-corpus-desktop-linux-x64 + path: artifacts/layout/producers/desktop + + - name: Download Browser producer + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-corpus-browser-wasm + path: artifacts/layout/producers/browser + + - name: Download Android producers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-android + path: artifacts/layout/android-download + + - name: Add Android producers to fan-in + shell: bash + run: cp -R artifacts/layout/android-download/android-producers/. artifacts/layout/producers/android/ + + - name: Publish Browser probe + run: >- + dotnet publish -c Release + test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj + -o artifacts/layout/browser-publish + + - name: Verify complete matrix in Browser wasm32 + run: >- + node test/SharpLink.CodecCompatibility.Browser/run-layout-browser.mjs + verify artifacts/layout/browser-publish artifacts/layout/producers + artifacts/layout/browser-report/layout-verification.json + "$SHARPLINK_COMMIT" "$SHARPLINK_SDK_VERSION" + + - name: Upload Browser verification + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsafe-blit-layout-verification-browser + path: artifacts/layout/browser-report/layout-verification.json + if-no-files-found: error + retention-days: 30 + + summary: + needs: [android-matrix, desktop-verify, browser-verify] + if: always() && needs.android-matrix.result == 'success' && needs.desktop-verify.result == 'success' && needs.browser-verify.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Download Android reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-android + path: artifacts/layout/reports/android + + - name: Download desktop report + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-verification-desktop + path: artifacts/layout/reports/desktop + + - name: Download Browser report + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsafe-blit-layout-verification-browser + path: artifacts/layout/reports/browser + + - name: Build hypothesis summary + run: >- + dotnet run -c Release + --project test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj + -- summarize --input artifacts/layout/reports --output artifacts/layout/summary + + - name: Publish Markdown summary + shell: bash + run: cat artifacts/layout/summary/unsafe-blit-layout-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload UnsafeBlit layout summary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsafe-blit-layout-summary + path: artifacts/layout/summary + if-no-files-found: error + retention-days: 30 diff --git a/test/SharpLink.CodecCompatibility.Android/LayoutEvidenceActivity.cs b/test/SharpLink.CodecCompatibility.Android/LayoutEvidenceActivity.cs new file mode 100644 index 000000000..9a582143f --- /dev/null +++ b/test/SharpLink.CodecCompatibility.Android/LayoutEvidenceActivity.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Android.App; +using Android.OS; +using Android.Util; +using Android.Widget; + +namespace SharpLink.CodecCompatibility; + +[Activity( + Name = "com.sharplink.codeccompat.LayoutEvidenceActivity", + Label = "SharpLink UnsafeBlit Layout Evidence", + Exported = true)] +public sealed class LayoutEvidenceActivity : Activity +{ + private const string LogTag = "SharpLinkLayoutEvidence"; + private const string InputFileName = "sharplink-input.json"; + private const string ResultFileName = "sharplink-result.json"; + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + var status = new TextView(this) { Text = "SharpLink UnsafeBlit layout evidence" }; + SetContentView(status); + _ = RunAsync(status); + } + + private async Task RunAsync(TextView status) + { + string? resultPath = null; + try + { + await Task.Yield(); + var filesDirectory = FilesDir?.AbsolutePath ?? throw new InvalidOperationException("Android app files directory is unavailable."); + Directory.CreateDirectory(filesDirectory); + var inputPath = Path.Combine(filesDirectory, InputFileName); + resultPath = Path.Combine(filesDirectory, ResultFileName); + var mode = Intent?.GetStringExtra("mode") ?? "layout-produce"; + var profile = Intent?.GetStringExtra("profile") ?? LayoutEvidenceProfiles.FixedWidth; + var commit = Intent?.GetStringExtra("commit") ?? "unknown"; + var sdk = Intent?.GetStringExtra("sdk") ?? "unknown"; + var expectedRuntimeFamily = Intent?.GetStringExtra("runtimeFamily") ?? "unknown"; + var rid = DetectRuntimeIdentifier(); + var targetFramework = $"net10.0-android/{rid}"; + const string executionEnvironment = "emulator"; + Log.Info(LogTag, $"starting mode={mode} profile={profile} expectedRuntime={expectedRuntimeFamily} rid={rid}"); + status.Text = mode; + + string result; + if (string.Equals(mode, "layout-produce", StringComparison.Ordinal)) + { + result = LayoutEvidenceProbe.ProduceJson(commit, sdk, targetFramework, profile, expectedRuntimeFamily, executionEnvironment); + } + else if (string.Equals(mode, "layout-verify", StringComparison.Ordinal)) + { + result = LayoutEvidenceProbe.VerifyJson(File.ReadAllText(inputPath, Encoding.UTF8), commit, sdk, targetFramework, expectedRuntimeFamily, executionEnvironment); + } + else + { + throw new InvalidOperationException($"Unknown Android layout evidence mode: {mode}."); + } + + File.WriteAllText(resultPath, result, new UTF8Encoding(false)); + Log.Info(LogTag, $"completed bytes={Encoding.UTF8.GetByteCount(result)}"); + status.Text = "completed"; + } + catch (Exception exception) + { + Log.Error(LogTag, exception.ToString()); + status.Text = exception.ToString(); + try + { + var filesDirectory = FilesDir?.AbsolutePath; + if (!string.IsNullOrWhiteSpace(filesDirectory)) + { + resultPath ??= Path.Combine(filesDirectory, ResultFileName); + File.WriteAllText(resultPath, JsonSerializer.Serialize(new { portableProbeError = exception.ToString() }), new UTF8Encoding(false)); + } + } + catch (Exception reportingException) + { + Log.Error(LogTag, $"failed to persist layout evidence error: {reportingException}"); + } + } + } + + private static string DetectRuntimeIdentifier() + { + var reported = RuntimeInformation.RuntimeIdentifier; + if (reported.StartsWith("android-", StringComparison.OrdinalIgnoreCase)) + return reported; + var architecture = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + Architecture.Arm => "arm", + var observed => throw new InvalidOperationException($"Unsupported Android process architecture: {observed}.") + }; + return $"android-{architecture}"; + } +} diff --git a/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj b/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj index c3b40e520..1c2fc071f 100644 --- a/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj +++ b/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj @@ -26,5 +26,7 @@ + + \ No newline at end of file diff --git a/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs b/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs new file mode 100644 index 000000000..2908ccc90 --- /dev/null +++ b/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { loadLayoutEnvelopes, writeLayoutCorpus } from '../SharpLink.CodecCompatibility.Browser/layout-artifacts.mjs'; + +const packageName = 'com.sharplink.codeccompat'; +const activityName = 'com.sharplink.codeccompat.LayoutEvidenceActivity'; +const inputFile = 'files/sharplink-input.json'; +const resultFile = 'files/sharplink-result.json'; + +function adb(args, options = {}) { + const result = spawnSync('adb', args, { encoding: 'utf8', ...options }); + if (result.status !== 0) throw new Error(`adb ${args.join(' ')} failed (${result.status}):\n${result.stdout ?? ''}\n${result.stderr ?? ''}`); + return result.stdout ?? ''; +} +function adbTry(args, options = {}) { return spawnSync('adb', args, { encoding: 'utf8', ...options }); } +function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } + +async function waitForResult(launchOutput) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + if (adbTry(['shell','run-as',packageName,'test','-f',resultFile]).status === 0) return adb(['shell','run-as',packageName,'cat',resultFile]); + await delay(250); + } + const logcat = adbTry(['logcat','-d','-t','2000']); + throw new Error(`Android layout probe timed out.\nam start:\n${launchOutput}\nlogcat:\n${logcat.stdout ?? ''}\n${logcat.stderr ?? ''}`); +} + +async function run(mode, producerRoot, outputPath, profile, commit, sdk, runtimeFamily) { + const input = mode === 'verify' ? JSON.stringify(await loadLayoutEnvelopes(producerRoot)) : null; + adb(['shell','am','force-stop',packageName]); + adb(['shell','run-as',packageName,'mkdir','-p','files']); + adbTry(['shell','run-as',packageName,'rm','-f',inputFile,resultFile]); + adbTry(['logcat','-c']); + if (input !== null) adb(['shell','run-as',packageName,'tee',inputFile], { input }); + const launchArgs = ['shell','am','start','-n',`${packageName}/${activityName}`,'--es','mode',mode === 'produce' ? 'layout-produce' : 'layout-verify','--es','commit',commit,'--es','sdk',sdk,'--es','runtimeFamily',runtimeFamily]; + if (profile) launchArgs.push('--es','profile',profile); + const launchOutput = adb(launchArgs); + try { + const parsed = JSON.parse(await waitForResult(launchOutput)); + if (parsed?.portableProbeError) throw new Error(parsed.portableProbeError); + if (mode === 'produce') { + await writeLayoutCorpus(parsed, outputPath); + console.log(`Android layout producer wrote ${parsed.cases?.length ?? 0} ${parsed.profile} fixtures for ${parsed.runtime?.platformTag}.`); + } else { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8'); + const incompatible = (parsed.results ?? []).filter(item => !item.rawWireCompatible).length; + console.log(`Android layout consumer verified ${parsed.results?.length ?? 0} entries; observed incompatibilities: ${incompatible}.`); + } + } finally { try { adb(['shell','am','force-stop',packageName]); } catch {} } +} + +const args = process.argv.slice(2); +if (args[0] === 'produce' && args.length === 6) { + run('produce', null, args[1], args[2], args[3], args[4], args[5]).catch(error => { console.error(error.stack ?? error); process.exitCode = 1; }); +} else if (args[0] === 'verify' && args.length === 6) { + run('verify', args[1], args[2], null, args[3], args[4], args[5]).catch(error => { console.error(error.stack ?? error); process.exitCode = 1; }); +} else { + console.error('Usage: run-layout-android.mjs produce '); + console.error(' or: run-layout-android.mjs verify '); + process.exit(2); +} diff --git a/test/SharpLink.CodecCompatibility.Browser/LayoutEvidenceExports.cs b/test/SharpLink.CodecCompatibility.Browser/LayoutEvidenceExports.cs new file mode 100644 index 000000000..13ef4bfe1 --- /dev/null +++ b/test/SharpLink.CodecCompatibility.Browser/LayoutEvidenceExports.cs @@ -0,0 +1,26 @@ +using System.Runtime.InteropServices.JavaScript; +using System.Runtime.Versioning; + +namespace SharpLink.CodecCompatibility; + +[SupportedOSPlatform("browser")] +public static partial class BrowserExports +{ + [JSExport] + public static string LayoutProduce(string profile, string sharpLinkCommit, string sdkVersion) + => LayoutEvidenceProbe.ProduceJson( + sharpLinkCommit, + sdkVersion, + "net10.0/browser-wasm", + profile, + executionEnvironmentOverride: "browser"); + + [JSExport] + public static string LayoutVerify(string envelopesJson, string sharpLinkCommit, string sdkVersion) + => LayoutEvidenceProbe.VerifyJson( + envelopesJson, + sharpLinkCommit, + sdkVersion, + "net10.0/browser-wasm", + executionEnvironmentOverride: "browser"); +} diff --git a/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj b/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj index 9b74afde4..d632e586d 100644 --- a/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj +++ b/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj @@ -19,6 +19,8 @@ + + diff --git a/test/SharpLink.CodecCompatibility.Browser/layout-artifacts.mjs b/test/SharpLink.CodecCompatibility.Browser/layout-artifacts.mjs new file mode 100644 index 000000000..073a1c137 --- /dev/null +++ b/test/SharpLink.CodecCompatibility.Browser/layout-artifacts.mjs @@ -0,0 +1,46 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export async function findLayoutManifestFiles(root) { + const result = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(fullPath); + else if (entry.isFile() && entry.name === 'layout-manifest.json') result.push(fullPath); + } + } + await visit(root); + return result.sort((a, b) => a.localeCompare(b)); +} + +export async function loadLayoutEnvelopes(root) { + const manifests = await findLayoutManifestFiles(root); + if (manifests.length === 0) throw new Error(`No layout-manifest.json files found under ${root}.`); + const envelopes = []; + for (const manifestPath of manifests) { + const envelope = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + const manifestRoot = path.dirname(manifestPath); + for (const item of envelope.cases ?? []) { + const bytes = await fs.readFile(path.join(manifestRoot, item.wireFile)); + const expected = envelope.caseBytesBase64?.[item.id]; + if (!expected || bytes.toString('base64') !== expected) { + throw new Error(`Binary/layout-manifest mismatch for ${manifestPath}/${item.id}.`); + } + } + envelopes.push(envelope); + } + return envelopes; +} + +export async function writeLayoutCorpus(envelope, outputPath) { + await fs.mkdir(outputPath, { recursive: true }); + for (const item of envelope.cases ?? []) { + const encoded = envelope.caseBytesBase64?.[item.id]; + if (!encoded) throw new Error(`Missing encoded bytes for ${item.id}.`); + const wirePath = path.join(outputPath, item.wireFile); + await fs.mkdir(path.dirname(wirePath), { recursive: true }); + await fs.writeFile(wirePath, Buffer.from(encoded, 'base64')); + } + await fs.writeFile(path.join(outputPath, 'layout-manifest.json'), JSON.stringify(envelope, null, 2) + '\n', 'utf8'); +} diff --git a/test/SharpLink.CodecCompatibility.Browser/layout-main.js b/test/SharpLink.CodecCompatibility.Browser/layout-main.js new file mode 100644 index 000000000..3fbb060b1 --- /dev/null +++ b/test/SharpLink.CodecCompatibility.Browser/layout-main.js @@ -0,0 +1,38 @@ +import { dotnet } from './_framework/dotnet.js'; + +async function postResult(body) { + await fetch('/result', { method: 'POST', headers: { 'content-type': 'application/json' }, body }); +} + +try { + const params = new URLSearchParams(globalThis.location.search); + const mode = params.get('mode') ?? 'produce'; + const profile = params.get('profile') ?? 'fixed-width'; + const commit = params.get('commit') ?? 'unknown'; + const sdk = params.get('sdk') ?? 'unknown'; + const { getAssemblyExports, getConfig } = await dotnet.create(); + const config = getConfig(); + const exports = await getAssemblyExports(config.mainAssemblyName); + const probe = exports.SharpLink.CodecCompatibility.BrowserExports; + let result; + if (mode === 'produce') { + result = probe.LayoutProduce(profile, commit, sdk); + } else if (mode === 'verify') { + const input = await fetch('/input.json').then(response => { + if (!response.ok) throw new Error(`Failed to load layout producer input: ${response.status}`); + return response.text(); + }); + result = probe.LayoutVerify(input, commit, sdk); + } else { + throw new Error(`Unknown layout browser mode: ${mode}`); + } + document.querySelector('#output').textContent = result; + document.body.dataset.done = 'true'; + await postResult(result); + await dotnet.run(); +} catch (error) { + const message = JSON.stringify({ browserProbeError: String(error?.stack ?? error) }); + document.querySelector('#output').textContent = message; + document.body.dataset.done = 'error'; + await postResult(message); +} diff --git a/test/SharpLink.CodecCompatibility.Browser/run-layout-browser.mjs b/test/SharpLink.CodecCompatibility.Browser/run-layout-browser.mjs new file mode 100644 index 000000000..edfb3bcbe --- /dev/null +++ b/test/SharpLink.CodecCompatibility.Browser/run-layout-browser.mjs @@ -0,0 +1,107 @@ +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; +import { loadLayoutEnvelopes, writeLayoutCorpus } from './layout-artifacts.mjs'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const contentTypes = new Map([['.html','text/html; charset=utf-8'],['.js','text/javascript; charset=utf-8'],['.mjs','text/javascript; charset=utf-8'],['.json','application/json; charset=utf-8'],['.wasm','application/wasm'],['.dll','application/octet-stream'],['.dat','application/octet-stream'],['.webcil','application/octet-stream']]); + +function findChrome() { + if (process.env.CHROME_BIN && fsSync.existsSync(process.env.CHROME_BIN)) return process.env.CHROME_BIN; + for (const candidate of ['google-chrome','google-chrome-stable','chromium','chromium-browser']) { + const result = spawnSync('which', [candidate], { encoding: 'utf8' }); + if (result.status === 0 && result.stdout.trim()) return result.stdout.trim(); + } + throw new Error('No Chrome/Chromium executable was found on the runner.'); +} + +async function findWebRoot(root) { + const candidates = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(full); + else if (entry.isFile() && entry.name === 'dotnet.js' && path.basename(directory) === '_framework') candidates.push(path.dirname(directory)); + } + } + await visit(root); + if (candidates.length === 0) throw new Error(`Could not find a published _framework/dotnet.js under ${root}.`); + candidates.sort((a,b) => a.length - b.length || a.localeCompare(b)); + return candidates[0]; +} + +async function prepareWebRoot(publishDirectory, mode, producerRoot) { + const webRoot = await findWebRoot(publishDirectory); + await fs.copyFile(path.join(scriptDirectory, 'index.html'), path.join(webRoot, 'index.html')); + await fs.copyFile(path.join(scriptDirectory, 'layout-main.js'), path.join(webRoot, 'main.js')); + if (mode === 'verify') { + await fs.writeFile(path.join(webRoot, 'input.json'), JSON.stringify(await loadLayoutEnvelopes(producerRoot)), 'utf8'); + } + return webRoot; +} + +async function serveFile(root, requestPath, response) { + const normalized = requestPath === '/' ? '/index.html' : requestPath; + const decoded = decodeURIComponent(normalized.split('?')[0]); + const fullPath = path.resolve(root, `.${decoded}`); + if (!fullPath.startsWith(path.resolve(root) + path.sep) && fullPath !== path.resolve(root, 'index.html')) { response.writeHead(403); response.end('forbidden'); return; } + try { + const data = await fs.readFile(fullPath); + response.writeHead(200, { 'content-type': contentTypes.get(path.extname(fullPath)) ?? 'application/octet-stream', 'cache-control': 'no-store', 'cross-origin-opener-policy': 'same-origin', 'cross-origin-embedder-policy': 'require-corp' }); + response.end(data); + } catch (error) { + if (error?.code === 'ENOENT') { response.writeHead(404); response.end('not found'); return; } + throw error; + } +} + +async function run(mode, publishDirectory, producerRoot, outputPath, profile, commit, sdk) { + const webRoot = await prepareWebRoot(publishDirectory, mode, producerRoot); + let resolveResult, rejectResult; + const resultPromise = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; }); + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (request.method === 'POST' && url.pathname === '/result') { + const chunks = []; for await (const chunk of request) chunks.push(chunk); + const body = Buffer.concat(chunks).toString('utf8'); + response.writeHead(204, { 'cross-origin-opener-policy': 'same-origin', 'cross-origin-embedder-policy': 'require-corp' }); response.end(); resolveResult(body); return; + } + await serveFile(webRoot, url.pathname, response); + } catch (error) { response.writeHead(500); response.end('server error'); rejectResult(error); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const url = new URL(`http://127.0.0.1:${address.port}/`); + url.searchParams.set('mode', mode); url.searchParams.set('profile', profile ?? 'fixed-width'); url.searchParams.set('commit', commit); url.searchParams.set('sdk', sdk); + const chrome = spawn(findChrome(), ['--headless=new','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--disable-background-networking','--disable-component-update','--enable-logging=stderr',url.toString()], { stdio: ['ignore','pipe','pipe'] }); + let chromeLog = ''; chrome.stdout.on('data', chunk => chromeLog += chunk.toString()); chrome.stderr.on('data', chunk => chromeLog += chunk.toString()); chrome.on('error', rejectResult); chrome.on('exit', code => { if (code !== null && code !== 0) rejectResult(new Error(`Chrome exited with code ${code}.\n${chromeLog}`)); }); + const timeout = setTimeout(() => rejectResult(new Error(`Browser layout probe timed out.\n${chromeLog}`)), 120_000); + try { + const parsed = JSON.parse(await resultPromise); + if (parsed?.browserProbeError) throw new Error(parsed.browserProbeError); + if (mode === 'produce') { + await writeLayoutCorpus(parsed, outputPath); + console.log(`Browser layout producer wrote ${parsed.cases?.length ?? 0} ${parsed.profile} fixtures for ${parsed.runtime?.platformTag}.`); + } else { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8'); + const incompatible = (parsed.results ?? []).filter(item => !item.rawWireCompatible).length; + console.log(`Browser layout consumer verified ${parsed.results?.length ?? 0} entries; observed incompatibilities: ${incompatible}.`); + } + } finally { clearTimeout(timeout); chrome.kill('SIGKILL'); await new Promise(resolve => server.close(resolve)); } +} + +const args = process.argv.slice(2); +if (args[0] === 'produce' && args.length === 6) { + run('produce', args[1], null, args[2], args[3], args[4], args[5]).catch(error => { console.error(error.stack ?? error); process.exitCode = 1; }); +} else if (args[0] === 'verify' && args.length === 6) { + run('verify', args[1], args[2], args[3], null, args[4], args[5]).catch(error => { console.error(error.stack ?? error); process.exitCode = 1; }); +} else { + console.error('Usage: run-layout-browser.mjs produce '); + console.error(' or: run-layout-browser.mjs verify '); + process.exit(2); +} diff --git a/test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs b/test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs new file mode 100644 index 000000000..1e69e4dbb --- /dev/null +++ b/test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace SharpLink.CodecCompatibility; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + if (args.Length == 0) + { + PrintUsage(); + return 2; + } + + return args[0] switch + { + "produce" => Produce(GetOption(args, "--profile"), GetOption(args, "--output")), + "verify" => Verify(GetOption(args, "--input"), GetOption(args, "--output")), + "summarize" => Summarize(GetOption(args, "--input"), GetOption(args, "--output")), + _ => throw new InvalidOperationException($"Unknown layout evidence command '{args[0]}'.") + }; + } + catch (Exception exception) + { + Console.Error.WriteLine($"UnsafeBlit layout evidence failed: {exception}"); + return 1; + } + } + + private static int Produce(string profile, string outputDirectory) + { + var json = LayoutEvidenceProbe.ProduceJson( + Commit(), + SdkVersion(), + "net10.0", + profile, + expectedRuntimeFamily: "CoreCLR", + executionEnvironmentOverride: "hosted-desktop"); + var envelope = Deserialize(json); + WriteCorpus(envelope, outputDirectory); + Console.WriteLine($"Produced {envelope.Cases.Count} {profile} layout fixtures for {envelope.Runtime.PlatformTag}."); + return 0; + } + + private static int Verify(string inputDirectory, string outputFile) + { + var envelopes = LoadCorpora(inputDirectory); + var inputJson = JsonSerializer.Serialize(envelopes, typeof(List), LayoutEvidenceJsonContext.Default); + var reportJson = LayoutEvidenceProbe.VerifyJson( + inputJson, + Commit(), + SdkVersion(), + "net10.0", + expectedRuntimeFamily: "CoreCLR", + executionEnvironmentOverride: "hosted-desktop"); + WriteText(outputFile, reportJson); + var report = Deserialize(reportJson); + var incompatible = report.Results.Count(static item => !item.RawWireCompatible); + Console.WriteLine($"Verified {report.Results.Count} layout evidence entries on {report.Consumer.PlatformTag}; observed incompatibilities: {incompatible}."); + return 0; + } + + private static int Summarize(string inputDirectory, string outputDirectory) + { + var files = Directory.EnumerateFiles(inputDirectory, "layout-verification.json", SearchOption.AllDirectories) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + if (files.Length == 0) + throw new InvalidOperationException($"No layout-verification.json files found under {inputDirectory}."); + var reports = files.Select(path => Deserialize(File.ReadAllText(path, Encoding.UTF8))).ToArray(); + var summary = LayoutEvidenceSummaryBuilder.Build(reports); + Directory.CreateDirectory(outputDirectory); + var json = JsonSerializer.Serialize(summary, typeof(LayoutEvidenceSummary), LayoutEvidenceJsonContext.Default); + WriteText(Path.Combine(outputDirectory, "unsafe-blit-layout-summary.json"), json); + WriteText(Path.Combine(outputDirectory, "unsafe-blit-layout-summary.md"), LayoutEvidenceSummaryBuilder.CreateMarkdown(summary)); + foreach (var hypothesis in summary.Hypotheses) + Console.WriteLine($"{hypothesis.Id}: supported={hypothesis.SupportedByObservedMatrix} evidence={string.Join("; ", hypothesis.Evidence)} counter={string.Join("; ", hypothesis.CounterEvidence)}"); + return 0; + } + + private static void WriteCorpus(LayoutEvidenceEnvelope envelope, string outputDirectory) + { + Directory.CreateDirectory(outputDirectory); + foreach (var item in envelope.Cases) + { + if (!envelope.CaseBytesBase64.TryGetValue(item.Id, out var base64)) + throw new InvalidOperationException($"Missing encoded bytes for {item.Id}."); + var wirePath = Path.Combine(outputDirectory, item.WireFile.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(wirePath)!); + File.WriteAllBytes(wirePath, Convert.FromBase64String(base64)); + } + var json = JsonSerializer.Serialize(envelope, typeof(LayoutEvidenceEnvelope), LayoutEvidenceJsonContext.Default); + WriteText(Path.Combine(outputDirectory, "layout-manifest.json"), json); + } + + private static List LoadCorpora(string inputDirectory) + { + if (!Directory.Exists(inputDirectory)) + throw new DirectoryNotFoundException(inputDirectory); + var files = Directory.EnumerateFiles(inputDirectory, "layout-manifest.json", SearchOption.AllDirectories) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + if (files.Length == 0) + throw new InvalidOperationException($"No layout-manifest.json files found under {inputDirectory}."); + var result = new List(); + foreach (var file in files) + { + var envelope = Deserialize(File.ReadAllText(file, Encoding.UTF8)); + var root = Path.GetDirectoryName(file)!; + foreach (var item in envelope.Cases) + { + var wirePath = Path.Combine(root, item.WireFile.Replace('/', Path.DirectorySeparatorChar)); + var bytes = File.ReadAllBytes(wirePath); + if (!envelope.CaseBytesBase64.TryGetValue(item.Id, out var encoded) + || !string.Equals(Convert.ToBase64String(bytes), encoded, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Binary/layout-manifest mismatch for {file}/{item.Id}."); + } + } + result.Add(envelope); + } + return result; + } + + private static T Deserialize(string json) where T : class + => JsonSerializer.Deserialize(json, typeof(T), LayoutEvidenceJsonContext.Default) as T + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}."); + + private static void WriteText(string path, string text) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + File.WriteAllText(path, text.EndsWith('\n') ? text : text + "\n", new UTF8Encoding(false)); + } + + private static string Commit() + => Environment.GetEnvironmentVariable("SHARPLINK_COMMIT") + ?? Environment.GetEnvironmentVariable("GITHUB_SHA") + ?? "unknown"; + + private static string SdkVersion() + => Environment.GetEnvironmentVariable("SHARPLINK_SDK_VERSION") ?? "unknown"; + + private static string GetOption(string[] args, string name) + { + for (var index = 0; index < args.Length - 1; index++) + if (string.Equals(args[index], name, StringComparison.Ordinal)) return args[index + 1]; + throw new InvalidOperationException($"Missing required option {name}."); + } + + private static void PrintUsage() + { + Console.Error.WriteLine("produce --profile --output "); + Console.Error.WriteLine("verify --input --output "); + Console.Error.WriteLine("summarize --input --output "); + } +} diff --git a/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj b/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj new file mode 100644 index 000000000..4d7103960 --- /dev/null +++ b/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + SharpLink.CodecCompatibility + SharpLink.CodecCompatibility + true + false + + + + + + + + + + + + diff --git a/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.StringCompatibility.cs b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.StringCompatibility.cs new file mode 100644 index 000000000..74354a4ed --- /dev/null +++ b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.StringCompatibility.cs @@ -0,0 +1,9 @@ +using System; + +namespace SharpLink.CodecCompatibility; + +internal static class UnsafeBlitLayoutEvidenceStringCompatibility +{ + internal static bool Contains(this string value, char character, StringComparison comparison) + => value.IndexOf(character) >= 0; +} diff --git a/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs new file mode 100644 index 000000000..974ee1837 --- /dev/null +++ b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs @@ -0,0 +1,1281 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using SharpLink.Runtime; + +namespace SharpLink.CodecCompatibility; + +internal static class LayoutEvidenceProfiles +{ + internal const string FixedWidth = "fixed-width"; + internal const string NativeWidth = "native-width"; + + internal static void Validate(string profile) + { + if (!string.Equals(profile, FixedWidth, StringComparison.Ordinal) + && !string.Equals(profile, NativeWidth, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Unknown UnsafeBlit layout evidence profile '{profile}'. Expected '{FixedWidth}' or '{NativeWidth}'."); + } + } +} + +internal sealed class LayoutEvidenceRuntimeIdentity +{ + public int SchemaVersion { get; set; } = 1; + public string SharpLinkCommit { get; set; } = string.Empty; + public string TargetFramework { get; set; } = string.Empty; + public string FrameworkDescription { get; set; } = string.Empty; + public string RuntimeFamily { get; set; } = string.Empty; + public string RuntimeFamilySource { get; set; } = string.Empty; + public string RuntimeVersion { get; set; } = string.Empty; + public string SdkVersion { get; set; } = string.Empty; + public string RuntimeIdentifier { get; set; } = string.Empty; + public string ExecutionEnvironment { get; set; } = string.Empty; + public string Os { get; set; } = string.Empty; + public string OsVersion { get; set; } = string.Empty; + public string ProcessArchitecture { get; set; } = string.Empty; + public string OsArchitecture { get; set; } = string.Empty; + public int PointerSize { get; set; } + public bool IsLittleEndian { get; set; } + public string CompilationMode { get; set; } = string.Empty; + public string PlatformTag { get; set; } = string.Empty; +} + +internal sealed class LayoutEvidenceCase +{ + public string Id { get; set; } = string.Empty; + public string LogicalShape { get; set; } = string.Empty; + public string LayoutKind { get; set; } = string.Empty; + public int? Pack { get; set; } + public string WidthDomain { get; set; } = string.Empty; + public bool NativeWidth { get; set; } + public bool LegacyControl { get; set; } + public List FrameworkRawFields { get; set; } = []; + public string Type { get; set; } = string.Empty; + public int Size { get; set; } + public Dictionary FieldOffsets { get; set; } = []; + public Dictionary FieldSizes { get; set; } = []; + public List PaddingByteOffsets { get; set; } = []; + public string ExpectedLogicalValue { get; set; } = string.Empty; + public string WireFile { get; set; } = string.Empty; + public string WireSha256 { get; set; } = string.Empty; +} + +internal sealed class LayoutEvidenceEnvelope +{ + public int SchemaVersion { get; set; } = 1; + public string Profile { get; set; } = string.Empty; + public LayoutEvidenceRuntimeIdentity Runtime { get; set; } = new(); + public List Cases { get; set; } = []; + public Dictionary CaseBytesBase64 { get; set; } = []; +} + +internal sealed class LayoutEvidenceFieldDifference +{ + public string Field { get; set; } = string.Empty; + public int? Producer { get; set; } + public int? Consumer { get; set; } +} + +internal sealed class LayoutEvidenceResult +{ + public string Profile { get; set; } = string.Empty; + public string Producer { get; set; } = string.Empty; + public string Consumer { get; set; } = string.Empty; + public string Fixture { get; set; } = string.Empty; + public string LogicalShape { get; set; } = string.Empty; + public string LayoutKind { get; set; } = string.Empty; + public int? Pack { get; set; } + public string WidthDomain { get; set; } = string.Empty; + public bool NativeWidth { get; set; } + public bool LegacyControl { get; set; } + public List FrameworkRawFields { get; set; } = []; + public int ProducerSize { get; set; } + public int ConsumerSize { get; set; } + public int ProducerPointerSize { get; set; } + public int ConsumerPointerSize { get; set; } + public Dictionary ProducerFieldOffsets { get; set; } = []; + public Dictionary ConsumerFieldOffsets { get; set; } = []; + public Dictionary ProducerFieldSizes { get; set; } = []; + public Dictionary ConsumerFieldSizes { get; set; } = []; + public List FieldOffsetDifferences { get; set; } = []; + public List FieldSizeDifferences { get; set; } = []; + public List ProducerPaddingByteOffsets { get; set; } = []; + public List ConsumerPaddingByteOffsets { get; set; } = []; + public string ProducerWireHash { get; set; } = string.Empty; + public string ConsumerLocalWireHash { get; set; } = string.Empty; + public bool SizeEqual { get; set; } + public bool FieldOffsetsEqual { get; set; } + public bool FieldSizesEqual { get; set; } + public bool LayoutMetadataEqual { get; set; } + public bool PointerWidthMismatch { get; set; } + public bool ByteForByteEquality { get; set; } + public List DifferingByteOffsets { get; set; } = []; + public bool DifferencesOnlyInPaddingOnBothSides { get; set; } + public bool DifferencesConfinedToPaddingOnEitherSide { get; set; } + public bool NestedFieldMetadataMismatch { get; set; } + public bool DifferingBytesTouchNestedField { get; set; } + public bool DifferingBytesTouchFrameworkRawField { get; set; } + public bool? CrossDeserializeResult { get; set; } + public bool? LogicalEquality { get; set; } + public bool? SegmentedCrossDeserializeResult { get; set; } + public bool? SegmentedLogicalEquality { get; set; } + public string ExpectedLogicalValue { get; set; } = string.Empty; + public string ActualLogicalValue { get; set; } = string.Empty; + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public bool RawWireCompatible { get; set; } + public bool RawRepresentationStable { get; set; } + public string Classification { get; set; } = string.Empty; +} + +internal sealed class LayoutEvidenceReport +{ + public int SchemaVersion { get; set; } = 1; + public LayoutEvidenceRuntimeIdentity Consumer { get; set; } = new(); + public List Results { get; set; } = []; +} + +internal sealed class LayoutFixtureConclusion +{ + public string Fixture { get; set; } = string.Empty; + public string LogicalShape { get; set; } = string.Empty; + public string LayoutKind { get; set; } = string.Empty; + public int? Pack { get; set; } + public string WidthDomain { get; set; } = string.Empty; + public bool NativeWidth { get; set; } + public bool LegacyControl { get; set; } + public int CrossPlatformEdges { get; set; } + public int RawWireCompatibleEdges { get; set; } + public int RawRepresentationStableEdges { get; set; } + public int SizeMismatchEdges { get; set; } + public int FieldOffsetMismatchEdges { get; set; } + public int RawByteDifferenceEdges { get; set; } + public int LogicalMismatchEdges { get; set; } + public int PaddingOnlyDifferenceEdges { get; set; } + public int NestedRepresentationDifferenceEdges { get; set; } + public int FrameworkRawDifferenceEdges { get; set; } + public int PointerWidthMismatchEdges { get; set; } + public bool AllCrossPlatformRawWireCompatible { get; set; } + public bool AllCrossPlatformRawRepresentationStable { get; set; } +} + +internal sealed class LayoutEvidenceHypothesis +{ + public string Id { get; set; } = string.Empty; + public string Question { get; set; } = string.Empty; + public bool SupportedByObservedMatrix { get; set; } + public List Evidence { get; set; } = []; + public List CounterEvidence { get; set; } = []; +} + +internal sealed class LayoutEvidenceSummary +{ + public int SchemaVersion { get; set; } = 1; + public string SharpLinkCommit { get; set; } = string.Empty; + public DateTimeOffset GeneratedAtUtc { get; set; } + public List Platforms { get; set; } = []; + public List Fixtures { get; set; } = []; + public List Hypotheses { get; set; } = []; + public List Results { get; set; } = []; +} + +internal sealed class LayoutEvidenceFieldMap where T : unmanaged +{ + internal Dictionary Offsets { get; } = new(StringComparer.Ordinal); + internal Dictionary Sizes { get; } = new(StringComparer.Ordinal); + + internal void Add(ref T root, ref TField field, string path) where TField : unmanaged + { + ref var rootByte = ref Unsafe.As(ref root); + ref var fieldByte = ref Unsafe.As(ref field); + var offset = checked((int)Unsafe.ByteOffset(ref rootByte, ref fieldByte)); + if (!Offsets.TryAdd(path, offset) || !Sizes.TryAdd(path, Unsafe.SizeOf())) + throw new InvalidOperationException($"Duplicate layout evidence field path {typeof(T).Name}.{path}."); + } + + internal List GetPaddingOffsets() + { + var occupied = new bool[Unsafe.SizeOf()]; + foreach (var pair in Offsets) + { + var size = Sizes[pair.Key]; + for (var index = pair.Value; index < Math.Min(pair.Value + size, occupied.Length); index++) + { + if (index >= 0) + occupied[index] = true; + } + } + return Enumerable.Range(0, occupied.Length).Where(index => !occupied[index]).ToList(); + } +} + +internal interface ILayoutEvidenceFixture +{ + string Id { get; } + string LogicalShape { get; } + string LayoutKind { get; } + int? Pack { get; } + string WidthDomain { get; } + bool NativeWidth { get; } + bool LegacyControl { get; } + IReadOnlyList FrameworkRawFields { get; } + int Size { get; } + byte[] Serialize(); + LayoutEvidenceCase CreateCase(byte[] bytes); + LayoutEvidenceResult Verify( + string profile, + byte[] producerBytes, + LayoutEvidenceCase producerCase, + LayoutEvidenceRuntimeIdentity producer, + LayoutEvidenceRuntimeIdentity consumer); +} + +internal sealed class LayoutEvidenceFixture : ILayoutEvidenceFixture where T : unmanaged +{ + private static readonly JsonSerializerOptions DescribeOptions = new() { IncludeFields = true }; + private readonly T _value; + private readonly Func _logicalEquals; + private readonly Dictionary _fieldOffsets; + private readonly Dictionary _fieldSizes; + private readonly List _paddingOffsets; + + internal LayoutEvidenceFixture( + string id, + string logicalShape, + string layoutKind, + int? pack, + string widthDomain, + bool nativeWidth, + bool legacyControl, + IReadOnlyList frameworkRawFields, + T value, + LayoutEvidenceFieldMap fields, + Func? logicalEquals = null) + { + Id = id; + LogicalShape = logicalShape; + LayoutKind = layoutKind; + Pack = pack; + WidthDomain = widthDomain; + NativeWidth = nativeWidth; + LegacyControl = legacyControl; + FrameworkRawFields = frameworkRawFields.ToArray(); + _value = value; + _logicalEquals = logicalEquals ?? EqualityComparer.Default.Equals; + _fieldOffsets = new Dictionary(fields.Offsets, StringComparer.Ordinal); + _fieldSizes = new Dictionary(fields.Sizes, StringComparer.Ordinal); + _paddingOffsets = fields.GetPaddingOffsets(); + } + + public string Id { get; } + public string LogicalShape { get; } + public string LayoutKind { get; } + public int? Pack { get; } + public string WidthDomain { get; } + public bool NativeWidth { get; } + public bool LegacyControl { get; } + public IReadOnlyList FrameworkRawFields { get; } + public int Size => Unsafe.SizeOf(); + + public byte[] Serialize() + { + var writer = new ArrayBufferWriter(Size); + var value = _value; + UnsafeBlitCodec.Instance.Serialize(in value, writer); + return writer.WrittenSpan.ToArray(); + } + + public LayoutEvidenceCase CreateCase(byte[] bytes) + => new() + { + Id = Id, + LogicalShape = LogicalShape, + LayoutKind = LayoutKind, + Pack = Pack, + WidthDomain = WidthDomain, + NativeWidth = NativeWidth, + LegacyControl = LegacyControl, + FrameworkRawFields = FrameworkRawFields.ToList(), + Type = typeof(T).FullName ?? typeof(T).Name, + Size = Size, + FieldOffsets = new Dictionary(_fieldOffsets, StringComparer.Ordinal), + FieldSizes = new Dictionary(_fieldSizes, StringComparer.Ordinal), + PaddingByteOffsets = [.. _paddingOffsets], + ExpectedLogicalValue = Describe(_value), + WireFile = $"cases/{SanitizeFileName(Id)}.bin", + WireSha256 = Hash(bytes) + }; + + public LayoutEvidenceResult Verify( + string profile, + byte[] producerBytes, + LayoutEvidenceCase producerCase, + LayoutEvidenceRuntimeIdentity producer, + LayoutEvidenceRuntimeIdentity consumer) + { + var localBytes = Serialize(); + var localCase = CreateCase(localBytes); + var differingBytes = FindDifferences(producerBytes, localBytes); + var offsetDifferences = FindDictionaryDifferences(producerCase.FieldOffsets, localCase.FieldOffsets); + var sizeDifferences = FindDictionaryDifferences(producerCase.FieldSizes, localCase.FieldSizes); + var producerPadding = producerCase.PaddingByteOffsets.ToHashSet(); + var consumerPadding = localCase.PaddingByteOffsets.ToHashSet(); + + var result = new LayoutEvidenceResult + { + Profile = profile, + Producer = producer.PlatformTag, + Consumer = consumer.PlatformTag, + Fixture = Id, + LogicalShape = LogicalShape, + LayoutKind = LayoutKind, + Pack = Pack, + WidthDomain = WidthDomain, + NativeWidth = NativeWidth, + LegacyControl = LegacyControl, + FrameworkRawFields = FrameworkRawFields.ToList(), + ProducerSize = producerCase.Size, + ConsumerSize = localCase.Size, + ProducerPointerSize = producer.PointerSize, + ConsumerPointerSize = consumer.PointerSize, + ProducerFieldOffsets = new Dictionary(producerCase.FieldOffsets, StringComparer.Ordinal), + ConsumerFieldOffsets = new Dictionary(localCase.FieldOffsets, StringComparer.Ordinal), + ProducerFieldSizes = new Dictionary(producerCase.FieldSizes, StringComparer.Ordinal), + ConsumerFieldSizes = new Dictionary(localCase.FieldSizes, StringComparer.Ordinal), + FieldOffsetDifferences = offsetDifferences, + FieldSizeDifferences = sizeDifferences, + ProducerPaddingByteOffsets = [.. producerCase.PaddingByteOffsets], + ConsumerPaddingByteOffsets = [.. localCase.PaddingByteOffsets], + ProducerWireHash = producerCase.WireSha256, + ConsumerLocalWireHash = localCase.WireSha256, + SizeEqual = producerCase.Size == localCase.Size, + FieldOffsetsEqual = offsetDifferences.Count == 0, + FieldSizesEqual = sizeDifferences.Count == 0, + PointerWidthMismatch = producer.PointerSize != consumer.PointerSize, + ByteForByteEquality = producerBytes.AsSpan().SequenceEqual(localBytes), + DifferingByteOffsets = differingBytes, + DifferencesOnlyInPaddingOnBothSides = differingBytes.Count != 0 + && differingBytes.All(offset => producerPadding.Contains(offset) && consumerPadding.Contains(offset)), + DifferencesConfinedToPaddingOnEitherSide = differingBytes.Count != 0 + && differingBytes.All(offset => producerPadding.Contains(offset) || consumerPadding.Contains(offset)), + NestedFieldMetadataMismatch = offsetDifferences.Concat(sizeDifferences) + .Any(static difference => difference.Field.Contains('.', StringComparison.Ordinal)), + DifferingBytesTouchNestedField = TouchesFieldRegion( + differingBytes, + producerCase, + localCase, + static field => field.Contains('.', StringComparison.Ordinal)), + DifferingBytesTouchFrameworkRawField = TouchesFieldRegion( + differingBytes, + producerCase, + localCase, + field => FrameworkRawFields.Contains(field, StringComparer.Ordinal)), + ExpectedLogicalValue = localCase.ExpectedLogicalValue + }; + result.LayoutMetadataEqual = result.SizeEqual && result.FieldOffsetsEqual && result.FieldSizesEqual; + + if (result.SizeEqual && producerBytes.Length == localCase.Size) + { + try + { + var sequence = new ReadOnlySequence(producerBytes); + var actual = UnsafeBlitCodec.Instance.Deserialize(in sequence); + result.CrossDeserializeResult = true; + result.LogicalEquality = _logicalEquals(_value, actual); + result.ActualLogicalValue = Describe(actual); + + if (producerBytes.Length > 1) + { + var segmented = CreateSegmentedSequence(producerBytes); + var segmentedActual = UnsafeBlitCodec.Instance.Deserialize(in segmented); + result.SegmentedCrossDeserializeResult = true; + result.SegmentedLogicalEquality = _logicalEquals(_value, segmentedActual); + } + } + catch (Exception exception) + { + result.CrossDeserializeResult = false; + result.LogicalEquality = false; + result.ExceptionType = exception.GetType().FullName; + result.ExceptionMessage = exception.Message; + } + } + + result.RawWireCompatible = result.CrossDeserializeResult == true + && result.LogicalEquality == true + && (producerBytes.Length <= 1 + || (result.SegmentedCrossDeserializeResult == true && result.SegmentedLogicalEquality == true)); + result.RawRepresentationStable = result.RawWireCompatible + && result.LayoutMetadataEqual + && result.ByteForByteEquality; + result.Classification = Classify(result); + return result; + } + + private static string Classify(LayoutEvidenceResult result) + { + if (!result.SizeEqual) + return result.PointerWidthMismatch && result.NativeWidth + ? "POINTER_WIDTH_SIZE_MISMATCH" + : "SIZE_MISMATCH"; + if (result.CrossDeserializeResult == false) + return "DESERIALIZE_REJECTED"; + if (result.LogicalEquality != true || result.SegmentedLogicalEquality == false) + return result.DifferingBytesTouchFrameworkRawField + ? "FRAMEWORK_RAW_LOGICAL_MISMATCH" + : result.DifferingBytesTouchNestedField || result.NestedFieldMetadataMismatch + ? "NESTED_LOGICAL_MISMATCH" + : "LOGICAL_DESERIALIZE_MISMATCH"; + if (!result.FieldOffsetsEqual) + return result.NestedFieldMetadataMismatch + ? "NESTED_FIELD_OFFSET_MISMATCH_BUT_LOGICALLY_COMPATIBLE" + : "FIELD_OFFSET_MISMATCH_BUT_LOGICALLY_COMPATIBLE"; + if (result.ByteForByteEquality) + return result.FieldSizesEqual + ? "IDENTICAL_RAW_AND_LOGICAL" + : "IDENTICAL_BYTES_WITH_FIELD_SIZE_DIFFERENCE"; + if (result.DifferencesOnlyInPaddingOnBothSides) + return "PADDING_BYTES_DIFFER_ONLY"; + if (result.DifferingBytesTouchFrameworkRawField) + return "FRAMEWORK_RAW_BYTES_DIFFER_BUT_LOGICALLY_COMPATIBLE"; + if (result.DifferingBytesTouchNestedField) + return "NESTED_BYTES_DIFFER_BUT_LOGICALLY_COMPATIBLE"; + return result.PointerWidthMismatch && result.NativeWidth + ? "POINTER_WIDTH_BYTES_DIFFER_BUT_LOGICALLY_COMPATIBLE" + : "RAW_BYTES_DIFFER_BUT_LOGICALLY_COMPATIBLE"; + } + + private static List FindDictionaryDifferences( + IReadOnlyDictionary producer, + IReadOnlyDictionary consumer) + { + var keys = producer.Keys.Concat(consumer.Keys).Distinct(StringComparer.Ordinal).OrderBy(static key => key, StringComparer.Ordinal); + var result = new List(); + foreach (var key in keys) + { + var producerFound = producer.TryGetValue(key, out var producerValue); + var consumerFound = consumer.TryGetValue(key, out var consumerValue); + if (!producerFound || !consumerFound || producerValue != consumerValue) + { + result.Add(new LayoutEvidenceFieldDifference + { + Field = key, + Producer = producerFound ? producerValue : null, + Consumer = consumerFound ? consumerValue : null + }); + } + } + return result; + } + + private static List FindDifferences(ReadOnlySpan producer, ReadOnlySpan consumer) + { + var count = Math.Max(producer.Length, consumer.Length); + var result = new List(); + for (var index = 0; index < count; index++) + { + if (index >= producer.Length || index >= consumer.Length || producer[index] != consumer[index]) + result.Add(index); + } + return result; + } + + private static bool TouchesFieldRegion( + IReadOnlyList differingBytes, + LayoutEvidenceCase producer, + LayoutEvidenceCase consumer, + Func predicate) + { + foreach (var field in producer.FieldOffsets.Keys.Concat(consumer.FieldOffsets.Keys).Distinct(StringComparer.Ordinal)) + { + if (!predicate(field)) + continue; + if (TouchesRegion(differingBytes, producer.FieldOffsets, producer.FieldSizes, field) + || TouchesRegion(differingBytes, consumer.FieldOffsets, consumer.FieldSizes, field)) + { + return true; + } + } + return false; + } + + private static bool TouchesRegion( + IReadOnlyList differingBytes, + IReadOnlyDictionary offsets, + IReadOnlyDictionary sizes, + string field) + { + if (!offsets.TryGetValue(field, out var offset) || !sizes.TryGetValue(field, out var size)) + return false; + return differingBytes.Any(index => index >= offset && index < offset + size); + } + + private static ReadOnlySequence CreateSegmentedSequence(byte[] bytes) + { + var split = Math.Clamp(bytes.Length / 2, 1, bytes.Length - 1); + var first = new LayoutSequenceSegment(bytes.AsMemory(0, split)); + var last = first.Append(bytes.AsMemory(split)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } + + private static string Describe(T value) + { + try + { + return JsonSerializer.Serialize(value, DescribeOptions); + } + catch (Exception) + { + return value.ToString() ?? typeof(T).Name; + } + } + + private static string SanitizeFileName(string value) + => string.Concat(value.Select(static character => char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '_')); + + private static string Hash(ReadOnlySpan bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private sealed class LayoutSequenceSegment : ReadOnlySequenceSegment + { + internal LayoutSequenceSegment(ReadOnlyMemory memory) => Memory = memory; + + internal LayoutSequenceSegment Append(ReadOnlyMemory memory) + { + var next = new LayoutSequenceSegment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } + } +} + +internal static class LayoutEvidenceFixtureRegistry +{ + internal static IReadOnlyList All { get; } = Create(); + internal static IReadOnlyDictionary ById { get; } = + All.ToDictionary(static fixture => fixture.Id, StringComparer.Ordinal); + + internal static IReadOnlyList ForProfile(string profile) + { + LayoutEvidenceProfiles.Validate(profile); + return All.Where(fixture => string.Equals(profile, LayoutEvidenceProfiles.NativeWidth, StringComparison.Ordinal) + ? fixture.NativeWidth + : !fixture.NativeWidth) + .ToArray(); + } + + private static IReadOnlyList Create() + { + var fixtures = new List + { + CreateMixedAuto(), CreateMixedSequential(), CreateMixedExplicit(), + CreatePaddingAuto(), CreatePaddingSequential(null), CreatePaddingSequential(1), + CreatePaddingSequential(4), CreatePaddingSequential(8), CreatePaddingExplicit(), + CreateNestedAuto(), CreateNestedSequential(), CreateNestedExplicit(), + CreateAutoGeneric("Generic.Byte.Auto", "generic-byte-fixed", (byte)0x52), + CreateSequentialGeneric("Generic.Byte.Sequential", "generic-byte-fixed", (byte)0x52), + CreateExplicitGenericByte(), + CreateAutoGeneric("Generic.Int64.Auto", "generic-int64-fixed", 0x1020304050607080L), + CreateSequentialGeneric("Generic.Int64.Sequential", "generic-int64-fixed", 0x1020304050607080L), + CreateExplicitGenericInt64(), + CreateAutoGeneric("Generic.Guid.Auto", "generic-guid-framework", Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), ["Value"]), + CreateSequentialGeneric("Generic.Guid.Sequential", "generic-guid-framework", Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), ["Value"]), + CreateExplicitGenericGuid(), + CreateAutoGenericDateTimeOffset(), CreateSequentialGenericDateTimeOffset(), CreateExplicitGenericDateTimeOffset(), + CreateDateTimeOffsetContainerAuto(), CreateDateTimeOffsetContainerSequential(), CreateDateTimeOffsetContainerExplicit(), + CreateNativeAuto(), CreateNativeSequential(), CreateNativeExplicit() + }; + fixtures.AddRange(CreateLegacyControls()); + return fixtures; + } + + private static ILayoutEvidenceFixture CreateMixedAuto() + { + var value = new LayoutMixedAuto { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); + return Fixture("Mixed.Auto", "mixed-alignment-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateMixedSequential() + { + var value = new LayoutMixedSequential { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); + return Fixture("Mixed.Sequential", "mixed-alignment-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateMixedExplicit() + { + var value = new LayoutMixedExplicit { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); + return Fixture("Mixed.Explicit", "mixed-alignment-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingAuto() + { + var value = new LayoutPaddingAuto { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Auto", "padding-heavy-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequential(int? pack) + => pack switch + { + null => CreatePaddingSequentialDefault(), + 1 => CreatePaddingSequentialPack1(), + 4 => CreatePaddingSequentialPack4(), + 8 => CreatePaddingSequentialPack8(), + _ => throw new InvalidOperationException($"Unsupported evidence pack {pack}.") + }; + + private static ILayoutEvidenceFixture CreatePaddingSequentialDefault() + { + var value = new LayoutPaddingSequential { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Default", "padding-heavy-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequentialPack1() + { + var value = new LayoutPaddingSequentialPack1 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Pack1", "padding-heavy-fixed", "Sequential", 1, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequentialPack4() + { + var value = new LayoutPaddingSequentialPack4 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Pack4", "padding-heavy-fixed", "Sequential", 4, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequentialPack8() + { + var value = new LayoutPaddingSequentialPack8 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Pack8", "padding-heavy-fixed", "Sequential", 8, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingExplicit() + { + var value = new LayoutPaddingExplicit { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Explicit", "padding-heavy-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNestedAuto() + { + var value = new LayoutNestedAuto { Prefix = 0x1234, Inner = new LayoutInnerAuto { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Nested.Auto", "nested-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNestedSequential() + { + var value = new LayoutNestedSequential { Prefix = 0x1234, Inner = new LayoutInnerSequential { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Nested.Sequential", "nested-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNestedExplicit() + { + var value = new LayoutNestedExplicit { Prefix = 0x1234, Inner = new LayoutInnerExplicit { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Nested.Explicit", "nested-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateAutoGeneric(string id, string shape, T item, IReadOnlyList? frameworkRawFields = null) where T : unmanaged + { + var value = new LayoutAutoGeneric { Prefix = 0x41, Value = item, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap>(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture(id, shape, "Auto", null, frameworkRawFields is { Count: > 0 } ? "fixed-width-framework" : "fixed-width-primitive", false, false, frameworkRawFields ?? [], value, fields); + } + + private static ILayoutEvidenceFixture CreateSequentialGeneric(string id, string shape, T item, IReadOnlyList? frameworkRawFields = null) where T : unmanaged + { + var value = new LayoutSequentialGeneric { Prefix = 0x41, Value = item, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap>(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture(id, shape, "Sequential", null, frameworkRawFields is { Count: > 0 } ? "fixed-width-framework" : "fixed-width-primitive", false, false, frameworkRawFields ?? [], value, fields); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericByte() + { + var value = new LayoutExplicitGenericByte { Prefix = 0x41, Value = 0x52, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.Byte.Explicit", "generic-byte-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericInt64() + { + var value = new LayoutExplicitGenericInt64 { Prefix = 0x41, Value = 0x1020304050607080, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.Int64.Explicit", "generic-int64-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericGuid() + { + var value = new LayoutExplicitGenericGuid { Prefix = 0x41, Value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.Guid.Explicit", "generic-guid-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields); + } + + private static DateTimeOffset EvidenceOffset() + => new(2026, 8, 31, 13, 45, 12, TimeSpan.FromHours(5.5)); + + private static ILayoutEvidenceFixture CreateAutoGenericDateTimeOffset() + { + var value = new LayoutAutoGeneric { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.DateTimeOffset.Auto", "generic-datetimeoffset-framework", "Auto", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateSequentialGenericDateTimeOffset() + { + var value = new LayoutSequentialGeneric { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.DateTimeOffset.Sequential", "generic-datetimeoffset-framework", "Sequential", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericDateTimeOffset() + { + var value = new LayoutExplicitGenericDateTimeOffset { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.DateTimeOffset.Explicit", "generic-datetimeoffset-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerAuto() + { + var value = new LayoutDateTimeOffsetAuto { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("DateTimeOffsetContainer.Auto", "datetimeoffset-container-framework", "Auto", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerSequential() + { + var value = new LayoutDateTimeOffsetSequential { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("DateTimeOffsetContainer.Sequential", "datetimeoffset-container-framework", "Sequential", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerExplicit() + { + var value = new LayoutDateTimeOffsetExplicit { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("DateTimeOffsetContainer.Explicit", "datetimeoffset-container-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateNativeAuto() + { + var value = new LayoutNativeAuto { A = (nint)0x12345678, B = (nuint)0x23456789 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); + return Fixture("NativeWidth.Auto", "native-width-pair", "Auto", null, "native-width", true, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNativeSequential() + { + var value = new LayoutNativeSequential { A = (nint)0x12345678, B = (nuint)0x23456789 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); + return Fixture("NativeWidth.Sequential", "native-width-pair", "Sequential", null, "native-width", true, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNativeExplicit() + { + var value = new LayoutNativeExplicit { A = (nint)0x12345678, B = (nuint)0x23456789 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); + return Fixture("NativeWidth.Explicit", "native-width-pair", "Explicit", null, "native-width", true, false, [], value, fields); + } + + private static IEnumerable CreateLegacyControls() + { + var offset = EvidenceOffset(); + var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + var mixed = new AutoMixed { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 1234567890.123456789m, F = guid, G = offset }; + var mixedFields = new LayoutEvidenceFieldMap(); + mixedFields.Add(ref mixed, ref mixed.A, "A"); mixedFields.Add(ref mixed, ref mixed.B, "B"); mixedFields.Add(ref mixed, ref mixed.C, "C"); mixedFields.Add(ref mixed, ref mixed.D, "D"); mixedFields.Add(ref mixed, ref mixed.E, "E"); mixedFields.Add(ref mixed, ref mixed.F, "F"); mixedFields.Add(ref mixed, ref mixed.G, "G"); + yield return Fixture("AutoMixed", "legacy-auto-mixed", "Auto", null, "fixed-width-framework", false, true, ["E", "F", "G"], mixed, mixedFields); + + var nested = new AutoNested { Prefix = 0x31, Inner = mixed, Tail = 0x1122334455667788 }; + var nestedFields = new LayoutEvidenceFieldMap(); + nestedFields.Add(ref nested, ref nested.Prefix, "Prefix"); nestedFields.Add(ref nested, ref nested.Inner.A, "Inner.A"); nestedFields.Add(ref nested, ref nested.Inner.B, "Inner.B"); nestedFields.Add(ref nested, ref nested.Inner.C, "Inner.C"); nestedFields.Add(ref nested, ref nested.Inner.D, "Inner.D"); nestedFields.Add(ref nested, ref nested.Inner.E, "Inner.E"); nestedFields.Add(ref nested, ref nested.Inner.F, "Inner.F"); nestedFields.Add(ref nested, ref nested.Inner.G, "Inner.G"); nestedFields.Add(ref nested, ref nested.Tail, "Tail"); + yield return Fixture("AutoNested", "legacy-auto-nested", "Auto", null, "fixed-width-framework", false, true, ["Inner.E", "Inner.F", "Inner.G"], nested, nestedFields); + + yield return CreateLegacyGeneric("AutoGenericByte", (byte)0x52, []); + yield return CreateLegacyGeneric("AutoGenericInt64", 0x1020304050607080L, []); + yield return CreateLegacyGeneric("AutoGenericGuid", guid, ["Value"]); + yield return CreateLegacyGenericDateTimeOffset(offset); + + var padding = new AutoPaddingHeavy { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var paddingFields = new LayoutEvidenceFieldMap(); paddingFields.Add(ref padding, ref padding.Prefix, "Prefix"); paddingFields.Add(ref padding, ref padding.Value, "Value"); paddingFields.Add(ref padding, ref padding.Suffix, "Suffix"); + yield return Fixture("AutoPaddingHeavy", "legacy-auto-padding-heavy", "Auto", null, "fixed-width-primitive", false, true, [], padding, paddingFields); + + var sequentialDto = new DateTimeOffsetContainer { Prefix = 0x61, Value = offset, Tail = 0x5152535455565758 }; + var sequentialDtoFields = new LayoutEvidenceFieldMap(); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Prefix, "Prefix"); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Value, "Value"); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Tail, "Tail"); + yield return Fixture("DateTimeOffsetContainer", "legacy-datetimeoffset-container", "Sequential", null, "fixed-width-framework", false, true, ["Value"], sequentialDto, sequentialDtoFields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + + var autoDto = new AutoDateTimeOffsetContainer { Prefix = 0x62, Value = offset, Tail = 0x6162636465666768 }; + var autoDtoFields = new LayoutEvidenceFieldMap(); autoDtoFields.Add(ref autoDto, ref autoDto.Prefix, "Prefix"); autoDtoFields.Add(ref autoDto, ref autoDto.Value, "Value"); autoDtoFields.Add(ref autoDto, ref autoDto.Tail, "Tail"); + yield return Fixture("AutoDateTimeOffsetContainer", "legacy-auto-datetimeoffset-container", "Auto", null, "fixed-width-framework", false, true, ["Value"], autoDto, autoDtoFields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateLegacyGeneric(string id, T item, IReadOnlyList frameworkRawFields) where T : unmanaged + { + var value = new AutoGeneric { Prefix = 0x43, Value = item, Tail = 0x2122232425262728 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture(id, "legacy-auto-generic", "Auto", null, frameworkRawFields.Count == 0 ? "fixed-width-primitive" : "fixed-width-framework", false, true, frameworkRawFields, value, fields); + } + + private static ILayoutEvidenceFixture CreateLegacyGenericDateTimeOffset(DateTimeOffset item) + { + var value = new AutoGeneric { Prefix = 0x44, Value = item, Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("AutoGenericDateTimeOffset", "legacy-auto-generic", "Auto", null, "fixed-width-framework", false, true, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static LayoutEvidenceFixture Fixture( + string id, + string shape, + string layoutKind, + int? pack, + string widthDomain, + bool nativeWidth, + bool legacyControl, + IReadOnlyList frameworkRawFields, + T value, + LayoutEvidenceFieldMap fields, + Func? logicalEquals = null) where T : unmanaged + => new(id, shape, layoutKind, pack, widthDomain, nativeWidth, legacyControl, frameworkRawFields, value, fields, logicalEquals); +} + +internal static class LayoutEvidenceProbe +{ + internal static string ProduceJson( + string sharpLinkCommit, + string sdkVersion, + string targetFramework, + string profile, + string? expectedRuntimeFamily = null, + string? executionEnvironmentOverride = null) + { + LayoutEvidenceProfiles.Validate(profile); + var runtime = CreateRuntimeIdentity(sharpLinkCommit, sdkVersion, targetFramework, expectedRuntimeFamily, executionEnvironmentOverride); + var envelope = new LayoutEvidenceEnvelope { Profile = profile, Runtime = runtime }; + foreach (var fixture in LayoutEvidenceFixtureRegistry.ForProfile(profile)) + { + var bytes = fixture.Serialize(); + var item = fixture.CreateCase(bytes); + envelope.Cases.Add(item); + envelope.CaseBytesBase64.Add(item.Id, Convert.ToBase64String(bytes)); + } + return JsonSerializer.Serialize(envelope, typeof(LayoutEvidenceEnvelope), LayoutEvidenceJsonContext.Default); + } + + internal static string VerifyJson( + string envelopesJson, + string sharpLinkCommit, + string sdkVersion, + string targetFramework, + string? expectedRuntimeFamily = null, + string? executionEnvironmentOverride = null) + { + var envelopes = JsonSerializer.Deserialize(envelopesJson, typeof(List), LayoutEvidenceJsonContext.Default) as List + ?? throw new InvalidOperationException("Failed to deserialize UnsafeBlit layout evidence envelopes."); + var consumer = CreateRuntimeIdentity(sharpLinkCommit, sdkVersion, targetFramework, expectedRuntimeFamily, executionEnvironmentOverride); + var report = new LayoutEvidenceReport { Consumer = consumer }; + foreach (var envelope in envelopes + .OrderBy(static item => item.Runtime.PlatformTag, StringComparer.Ordinal) + .ThenBy(static item => item.Profile, StringComparer.Ordinal)) + { + ValidateEnvelope(envelope, sharpLinkCommit); + foreach (var producerCase in envelope.Cases.OrderBy(static item => item.Id, StringComparer.Ordinal)) + { + var fixture = LayoutEvidenceFixtureRegistry.ById[producerCase.Id]; + var producerBytes = Convert.FromBase64String(envelope.CaseBytesBase64[producerCase.Id]); + report.Results.Add(fixture.Verify(envelope.Profile, producerBytes, producerCase, envelope.Runtime, consumer)); + } + } + return JsonSerializer.Serialize(report, typeof(LayoutEvidenceReport), LayoutEvidenceJsonContext.Default); + } + + private static void ValidateEnvelope(LayoutEvidenceEnvelope envelope, string expectedCommit) + { + if (envelope.SchemaVersion != 1 || envelope.Runtime.SchemaVersion != 1) + throw new InvalidOperationException($"Unsupported layout evidence schema from {envelope.Runtime.PlatformTag}."); + LayoutEvidenceProfiles.Validate(envelope.Profile); + if (!string.Equals(envelope.Runtime.SharpLinkCommit, expectedCommit, StringComparison.Ordinal)) + throw new InvalidOperationException($"Layout evidence commit mismatch from {envelope.Runtime.PlatformTag}: {envelope.Runtime.SharpLinkCommit} != {expectedCommit}."); + + var expected = LayoutEvidenceFixtureRegistry.ForProfile(envelope.Profile).OrderBy(static item => item.Id, StringComparer.Ordinal).ToArray(); + var actual = envelope.Cases.OrderBy(static item => item.Id, StringComparer.Ordinal).ToArray(); + if (actual.Length != expected.Length || envelope.CaseBytesBase64.Count != expected.Length) + throw new InvalidOperationException($"Layout evidence fixture count mismatch from {envelope.Runtime.PlatformTag}/{envelope.Profile}."); + + for (var index = 0; index < expected.Length; index++) + { + var fixture = expected[index]; + var item = actual[index]; + if (!string.Equals(item.Id, fixture.Id, StringComparison.Ordinal) + || !string.Equals(item.LogicalShape, fixture.LogicalShape, StringComparison.Ordinal) + || !string.Equals(item.LayoutKind, fixture.LayoutKind, StringComparison.Ordinal) + || item.Pack != fixture.Pack + || !string.Equals(item.WidthDomain, fixture.WidthDomain, StringComparison.Ordinal) + || item.NativeWidth != fixture.NativeWidth + || item.LegacyControl != fixture.LegacyControl + || !item.FrameworkRawFields.SequenceEqual(fixture.FrameworkRawFields, StringComparer.Ordinal)) + { + throw new InvalidOperationException($"Layout evidence metadata mismatch for {envelope.Runtime.PlatformTag}/{item.Id}."); + } + if (!envelope.CaseBytesBase64.TryGetValue(item.Id, out var base64)) + throw new InvalidOperationException($"Missing layout evidence bytes for {envelope.Runtime.PlatformTag}/{item.Id}."); + var bytes = Convert.FromBase64String(base64); + if (bytes.Length != item.Size || !string.Equals(Hash(bytes), item.WireSha256, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Layout evidence wire integrity mismatch for {envelope.Runtime.PlatformTag}/{item.Id}."); + } + } + + private static LayoutEvidenceRuntimeIdentity CreateRuntimeIdentity( + string sharpLinkCommit, + string sdkVersion, + string targetFramework, + string? expectedRuntimeFamily, + string? executionEnvironmentOverride) + { + var os = OperatingSystem.IsBrowser() ? "browser" + : OperatingSystem.IsAndroid() ? "android" + : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macos" + : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : "unknown"; + var (runtimeFamily, runtimeFamilySource) = DetectRuntimeFamily(); + if (!string.IsNullOrWhiteSpace(expectedRuntimeFamily) + && !string.Equals(runtimeFamily, expectedRuntimeFamily, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Layout evidence runtime mismatch: expected={expectedRuntimeFamily}, observed={runtimeFamily}."); + } + var compilationMode = !RuntimeFeature.IsDynamicCodeSupported ? "AOT" + : RuntimeFeature.IsDynamicCodeCompiled ? "JIT" + : "Interpreter"; + var processArchitecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + var runtimeIdentifier = RuntimeInformation.RuntimeIdentifier; + if (OperatingSystem.IsAndroid() && !runtimeIdentifier.StartsWith("android-", StringComparison.OrdinalIgnoreCase)) + runtimeIdentifier = $"android-{processArchitecture}"; + var executionEnvironment = executionEnvironmentOverride + ?? (OperatingSystem.IsBrowser() ? "browser" : OperatingSystem.IsAndroid() ? "android-runtime" : "hosted-desktop"); + var frameworkTag = GetFrameworkTag(targetFramework); + return new LayoutEvidenceRuntimeIdentity + { + SharpLinkCommit = string.IsNullOrWhiteSpace(sharpLinkCommit) ? "unknown" : sharpLinkCommit, + TargetFramework = targetFramework, + FrameworkDescription = RuntimeInformation.FrameworkDescription, + RuntimeFamily = runtimeFamily, + RuntimeFamilySource = runtimeFamilySource, + RuntimeVersion = Environment.Version.ToString(), + SdkVersion = string.IsNullOrWhiteSpace(sdkVersion) ? "unknown" : sdkVersion, + RuntimeIdentifier = runtimeIdentifier, + ExecutionEnvironment = executionEnvironment, + Os = os, + OsVersion = RuntimeInformation.OSDescription, + ProcessArchitecture = processArchitecture, + OsArchitecture = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), + PointerSize = IntPtr.Size, + IsLittleEndian = BitConverter.IsLittleEndian, + CompilationMode = compilationMode, + PlatformTag = $"{os}-{processArchitecture}-{executionEnvironment}-{runtimeFamily.ToLowerInvariant()}-{frameworkTag}" + }; + } + + private static (string Family, string Source) DetectRuntimeFamily() + { + if (OperatingSystem.IsBrowser()) + return ("Mono", "platform-runtime-pack"); + if (!OperatingSystem.IsAndroid()) + return (Type.GetType("Mono.Runtime") is null ? "CoreCLR" : "Mono", "runtime-reflection"); + var maps = File.ReadAllText("/proc/self/maps"); + var mono = maps.Contains("libmonosgen-2.0.so", StringComparison.Ordinal); + var coreClr = maps.Contains("libcoreclr.so", StringComparison.Ordinal); + if (mono == coreClr) + throw new InvalidOperationException($"Unable to identify Android layout evidence runtime: monoLoaded={mono}, coreClrLoaded={coreClr}."); + return (mono ? "Mono" : "CoreCLR", "loaded-runtime-library"); + } + + private static string GetFrameworkTag(string targetFramework) + { + var framework = targetFramework.Split('/', 2, StringSplitOptions.TrimEntries)[0]; + var separator = framework.IndexOf('-'); + if (separator >= 0) framework = framework[..separator]; + separator = framework.IndexOf('.'); + if (separator >= 0) framework = framework[..separator]; + return framework.ToLowerInvariant(); + } + + private static string Hash(ReadOnlySpan bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); +} + +internal static class LayoutEvidenceSummaryBuilder +{ + internal static LayoutEvidenceSummary Build(IReadOnlyList reports) + { + if (reports.Count == 0) + throw new InvalidOperationException("No UnsafeBlit layout evidence reports were supplied."); + var commits = reports.Select(static report => report.Consumer.SharpLinkCommit).Distinct(StringComparer.Ordinal).ToArray(); + if (commits.Length != 1 || string.IsNullOrWhiteSpace(commits[0]) || string.Equals(commits[0], "unknown", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Layout evidence summary requires one known SharpLink commit; observed [{string.Join(", ", commits)}]."); + var results = reports.SelectMany(static report => report.Results) + .OrderBy(static item => item.Profile, StringComparer.Ordinal) + .ThenBy(static item => item.Fixture, StringComparer.Ordinal) + .ThenBy(static item => item.Producer, StringComparer.Ordinal) + .ThenBy(static item => item.Consumer, StringComparer.Ordinal) + .ToList(); + var consumers = reports.Select(static report => report.Consumer.PlatformTag).Distinct(StringComparer.Ordinal).OrderBy(static item => item, StringComparer.Ordinal).ToArray(); + foreach (var profile in new[] { LayoutEvidenceProfiles.FixedWidth, LayoutEvidenceProfiles.NativeWidth }) + { + var producers = results.Where(item => string.Equals(item.Profile, profile, StringComparison.Ordinal)).Select(static item => item.Producer).Distinct(StringComparer.Ordinal).OrderBy(static item => item, StringComparer.Ordinal).ToArray(); + if (!producers.SequenceEqual(consumers, StringComparer.Ordinal)) + throw new InvalidOperationException($"Layout evidence profile {profile} is not a complete producer/consumer matrix: producers=[{string.Join(", ", producers)}], consumers=[{string.Join(", ", consumers)}]."); + } + + var conclusions = results.GroupBy(static item => item.Fixture, StringComparer.Ordinal) + .Select(group => BuildConclusion(group.Key, group.ToArray())) + .OrderBy(static item => item.LogicalShape, StringComparer.Ordinal) + .ThenBy(static item => item.LayoutKind, StringComparer.Ordinal) + .ThenBy(static item => item.Pack) + .ToList(); + return new LayoutEvidenceSummary + { + SharpLinkCommit = commits[0], + GeneratedAtUtc = DateTimeOffset.UtcNow, + Platforms = [.. consumers], + Fixtures = conclusions, + Hypotheses = BuildHypotheses(conclusions), + Results = results + }; + } + + internal static string CreateMarkdown(LayoutEvidenceSummary summary) + { + var lines = new List + { + "# UnsafeBlit layout compatibility evidence", + "", + $"Commit: `{summary.SharpLinkCommit}`", + $"Platforms: {string.Join(", ", summary.Platforms.Select(static item => $"`{item}`"))}", + "", + "## Hypotheses", + "" + }; + foreach (var hypothesis in summary.Hypotheses) + { + lines.Add($"- **{hypothesis.Id}** — {(hypothesis.SupportedByObservedMatrix ? "supported by this matrix" : "not established by this matrix")}: {hypothesis.Question}"); + if (hypothesis.Evidence.Count != 0) lines.Add($" Evidence: {string.Join("; ", hypothesis.Evidence)}"); + if (hypothesis.CounterEvidence.Count != 0) lines.Add($" Counter-evidence: {string.Join("; ", hypothesis.CounterEvidence)}"); + } + lines.AddRange(["", "## Fixture conclusions", "", "| Fixture | Shape | Layout | Pack | Domain | Wire compatible | Raw stable | Size mismatch | Offset mismatch | Byte diff | Logical mismatch | Padding-only | Nested diff | Framework raw diff | Pointer diff |", "|---|---|---|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"]); + foreach (var item in summary.Fixtures) + { + lines.Add($"| {item.Fixture} | {item.LogicalShape} | {item.LayoutKind} | {(item.Pack?.ToString() ?? "default")} | {item.WidthDomain} | {item.RawWireCompatibleEdges}/{item.CrossPlatformEdges} | {item.RawRepresentationStableEdges}/{item.CrossPlatformEdges} | {item.SizeMismatchEdges} | {item.FieldOffsetMismatchEdges} | {item.RawByteDifferenceEdges} | {item.LogicalMismatchEdges} | {item.PaddingOnlyDifferenceEdges} | {item.NestedRepresentationDifferenceEdges} | {item.FrameworkRawDifferenceEdges} | {item.PointerWidthMismatchEdges} |"); + } + var failures = summary.Results.Where(static item => item.Producer != item.Consumer && !item.RawWireCompatible).ToArray(); + lines.AddRange(["", "## Cross-platform incompatibility details", ""]); + if (failures.Length == 0) + { + lines.Add("No cross-platform logical UnsafeBlit incompatibilities were observed."); + } + else + { + lines.Add("| Fixture | Producer → Consumer | Classification | Size | Offset differences | Byte differences | Padding-only | Nested | Framework raw | Pointer width | Logical | "); + lines.Add("|---|---|---|---|---|---|---|---|---|---|---|"); + foreach (var result in failures) + { + var offsets = result.FieldOffsetDifferences.Count == 0 ? "none" : string.Join(",", result.FieldOffsetDifferences.Select(static item => $"{item.Field}:{item.Producer}->{item.Consumer}")); + var bytes = result.DifferingByteOffsets.Count == 0 ? "none" : string.Join(",", result.DifferingByteOffsets); + lines.Add($"| {result.Fixture} | {result.Producer} → {result.Consumer} | {result.Classification} | {result.ProducerSize}->{result.ConsumerSize} | {offsets} | {bytes} | {result.DifferencesOnlyInPaddingOnBothSides} | {result.DifferingBytesTouchNestedField || result.NestedFieldMetadataMismatch} | {result.DifferingBytesTouchFrameworkRawField} | {result.ProducerPointerSize}->{result.ConsumerPointerSize} | {result.LogicalEquality?.ToString() ?? "n/a"} |"); + } + } + return string.Join("\n", lines) + "\n"; + } + + private static LayoutFixtureConclusion BuildConclusion(string fixture, IReadOnlyList results) + { + var sample = results[0]; + var cross = results.Where(static item => !string.Equals(item.Producer, item.Consumer, StringComparison.Ordinal)).ToArray(); + return new LayoutFixtureConclusion + { + Fixture = fixture, + LogicalShape = sample.LogicalShape, + LayoutKind = sample.LayoutKind, + Pack = sample.Pack, + WidthDomain = sample.WidthDomain, + NativeWidth = sample.NativeWidth, + LegacyControl = sample.LegacyControl, + CrossPlatformEdges = cross.Length, + RawWireCompatibleEdges = cross.Count(static item => item.RawWireCompatible), + RawRepresentationStableEdges = cross.Count(static item => item.RawRepresentationStable), + SizeMismatchEdges = cross.Count(static item => !item.SizeEqual), + FieldOffsetMismatchEdges = cross.Count(static item => !item.FieldOffsetsEqual), + RawByteDifferenceEdges = cross.Count(static item => !item.ByteForByteEquality), + LogicalMismatchEdges = cross.Count(static item => item.LogicalEquality != true), + PaddingOnlyDifferenceEdges = cross.Count(static item => item.DifferencesOnlyInPaddingOnBothSides), + NestedRepresentationDifferenceEdges = cross.Count(static item => item.NestedFieldMetadataMismatch || item.DifferingBytesTouchNestedField), + FrameworkRawDifferenceEdges = cross.Count(static item => item.DifferingBytesTouchFrameworkRawField), + PointerWidthMismatchEdges = cross.Count(static item => item.PointerWidthMismatch), + AllCrossPlatformRawWireCompatible = cross.Length != 0 && cross.All(static item => item.RawWireCompatible), + AllCrossPlatformRawRepresentationStable = cross.Length != 0 && cross.All(static item => item.RawRepresentationStable) + }; + } + + private static List BuildHypotheses(IReadOnlyList fixtures) + { + var matchedShapes = fixtures.Where(static item => !item.LegacyControl && !item.NativeWidth) + .GroupBy(static item => item.LogicalShape, StringComparer.Ordinal) + .ToArray(); + var autoFailsSeqExplicitPass = new List(); + var onlyExplicitPasses = new List(); + foreach (var shape in matchedShapes) + { + var auto = shape.FirstOrDefault(static item => item.LayoutKind == "Auto"); + var sequential = shape.FirstOrDefault(static item => item.LayoutKind == "Sequential" && item.Pack is null); + var explicitLayout = shape.FirstOrDefault(static item => item.LayoutKind == "Explicit"); + if (auto is null || sequential is null || explicitLayout is null) continue; + if (!auto.AllCrossPlatformRawWireCompatible && sequential.AllCrossPlatformRawWireCompatible && explicitLayout.AllCrossPlatformRawWireCompatible) + autoFailsSeqExplicitPass.Add(shape.Key); + if (!auto.AllCrossPlatformRawWireCompatible && !sequential.AllCrossPlatformRawWireCompatible && explicitLayout.AllCrossPlatformRawWireCompatible) + onlyExplicitPasses.Add(shape.Key); + } + + var primitiveExplicit = fixtures.Where(static item => !item.LegacyControl && item.LayoutKind == "Explicit" && item.WidthDomain == "fixed-width-primitive").ToArray(); + var frameworkExplicit = fixtures.Where(static item => !item.LegacyControl && item.LayoutKind == "Explicit" && item.WidthDomain == "fixed-width-framework").ToArray(); + var primitivePass = primitiveExplicit.Where(static item => item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); + var frameworkFail = frameworkExplicit.Where(static item => !item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); + + var fixedSequentialExplicit = fixtures.Where(static item => !item.LegacyControl && item.WidthDomain == "fixed-width-primitive" && (item.LayoutKind == "Sequential" || item.LayoutKind == "Explicit")).ToArray(); + var fixedFailures = fixedSequentialExplicit.Where(static item => !item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); + + return + [ + new LayoutEvidenceHypothesis + { + Id = "H1", + Question = "Auto is incompatible while matched Sequential and Explicit variants are compatible.", + SupportedByObservedMatrix = autoFailsSeqExplicitPass.Count != 0, + Evidence = autoFailsSeqExplicitPass, + CounterEvidence = matchedShapes.Where(shape => shape.Any(static item => item.LayoutKind == "Auto" && item.AllCrossPlatformRawWireCompatible)).Select(static shape => $"{shape.Key}: Auto remained compatible").ToList() + }, + new LayoutEvidenceHypothesis + { + Id = "H2", + Question = "Sequential can remain incompatible where only the matched Explicit variant is compatible.", + SupportedByObservedMatrix = onlyExplicitPasses.Count != 0, + Evidence = onlyExplicitPasses + }, + new LayoutEvidenceHypothesis + { + Id = "H3", + Question = "Explicit primitive-only shapes are compatible while Explicit shapes containing framework raw representations (for example DateTimeOffset) can remain incompatible.", + SupportedByObservedMatrix = primitivePass.Length != 0 && frameworkFail.Length != 0, + Evidence = primitivePass.Select(static item => $"primitive compatible: {item}").Concat(frameworkFail.Select(static item => $"framework raw incompatible: {item}")).ToList(), + CounterEvidence = frameworkExplicit.Where(static item => item.AllCrossPlatformRawWireCompatible).Select(static item => $"framework raw compatible: {item.Fixture}").ToList() + }, + new LayoutEvidenceHypothesis + { + Id = "H4", + Question = "Fixed-width primitive Sequential/Explicit fixtures form one cross-platform raw-wire compatibility domain across the observed CoreCLR, Mono, and Browser matrix.", + SupportedByObservedMatrix = fixedSequentialExplicit.Length != 0 && fixedFailures.Length == 0, + Evidence = fixedFailures.Length == 0 ? [.. fixedSequentialExplicit.Select(static item => $"compatible: {item.Fixture}")] : [], + CounterEvidence = [.. fixedFailures.Select(static item => $"incompatible: {item}")] + } + ]; + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(LayoutEvidenceEnvelope))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(LayoutEvidenceReport))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(LayoutEvidenceSummary))] +internal partial class LayoutEvidenceJsonContext : JsonSerializerContext +{ +} + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutMixedAuto { public byte A; public short B; public int C; public long D; public double E; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutMixedSequential { public byte A; public short B; public int C; public long D; public double E; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutMixedExplicit { [FieldOffset(0)] public byte A; [FieldOffset(2)] public short B; [FieldOffset(4)] public int C; [FieldOffset(8)] public long D; [FieldOffset(16)] public double E; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutPaddingAuto { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutPaddingSequential { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential, Pack = 1)] +internal struct LayoutPaddingSequentialPack1 { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct LayoutPaddingSequentialPack4 { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential, Pack = 8)] +internal struct LayoutPaddingSequentialPack8 { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutPaddingExplicit { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public long Value; [FieldOffset(16)] public byte Suffix; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutInnerAuto { public byte A; public int B; } +[StructLayout(LayoutKind.Auto)] +internal struct LayoutNestedAuto { public short Prefix; public LayoutInnerAuto Inner; public long Tail; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutInnerSequential { public byte A; public int B; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutNestedSequential { public short Prefix; public LayoutInnerSequential Inner; public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 8)] +internal struct LayoutInnerExplicit { [FieldOffset(0)] public byte A; [FieldOffset(4)] public int B; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutNestedExplicit { [FieldOffset(0)] public short Prefix; [FieldOffset(4)] public LayoutInnerExplicit Inner; [FieldOffset(16)] public long Tail; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutAutoGeneric where T : unmanaged { public byte Prefix; public T Value; public long Tail; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutSequentialGeneric where T : unmanaged { public byte Prefix; public T Value; public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 16)] +internal struct LayoutExplicitGenericByte { [FieldOffset(0)] public byte Prefix; [FieldOffset(1)] public byte Value; [FieldOffset(8)] public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutExplicitGenericInt64 { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public long Value; [FieldOffset(16)] public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 32)] +internal struct LayoutExplicitGenericGuid { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public Guid Value; [FieldOffset(24)] public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 32)] +internal struct LayoutExplicitGenericDateTimeOffset { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public DateTimeOffset Value; [FieldOffset(24)] public long Tail; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutDateTimeOffsetAuto { public byte Prefix; public DateTimeOffset Value; public long Tail; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutDateTimeOffsetSequential { public byte Prefix; public DateTimeOffset Value; public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 32)] +internal struct LayoutDateTimeOffsetExplicit { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public DateTimeOffset Value; [FieldOffset(24)] public long Tail; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutNativeAuto { public nint A; public nuint B; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutNativeSequential { public nint A; public nuint B; } +[StructLayout(LayoutKind.Explicit, Size = 16)] +internal struct LayoutNativeExplicit { [FieldOffset(0)] public nint A; [FieldOffset(8)] public nuint B; } From 66b4a79cbf0966e1a24e508376bf0687e0c0b58f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:12:32 +0800 Subject: [PATCH 243/399] test: harden UnsafeBlit layout evidence harness --- .../codec-unsafe-blit-layout-evidence.yml | 12 +- .../UnsafeBlitLayoutEvidence.Matrix.cs | 732 ++++++++++++++++++ .../UnsafeBlitLayoutEvidence.cs | 721 ----------------- 3 files changed, 742 insertions(+), 723 deletions(-) create mode 100644 test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Matrix.cs diff --git a/.github/workflows/codec-unsafe-blit-layout-evidence.yml b/.github/workflows/codec-unsafe-blit-layout-evidence.yml index 56c914b2f..ba2954d5c 100644 --- a/.github/workflows/codec-unsafe-blit-layout-evidence.yml +++ b/.github/workflows/codec-unsafe-blit-layout-evidence.yml @@ -276,7 +276,11 @@ jobs: - name: Add Android producers to fan-in shell: bash - run: cp -R artifacts/layout/android-download/android-producers/. artifacts/layout/producers/android/ + run: | + android_producers="$(find artifacts/layout/android-download -type d -name android-producers -print -quit)" + test -n "$android_producers" + mkdir -p artifacts/layout/producers/android + cp -R "$android_producers"/. artifacts/layout/producers/android/ - name: Verify complete matrix on desktop CoreCLR run: >- @@ -332,7 +336,11 @@ jobs: - name: Add Android producers to fan-in shell: bash - run: cp -R artifacts/layout/android-download/android-producers/. artifacts/layout/producers/android/ + run: | + android_producers="$(find artifacts/layout/android-download -type d -name android-producers -print -quit)" + test -n "$android_producers" + mkdir -p artifacts/layout/producers/android + cp -R "$android_producers"/. artifacts/layout/producers/android/ - name: Publish Browser probe run: >- diff --git a/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Matrix.cs b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Matrix.cs new file mode 100644 index 000000000..f1cb5ffe6 --- /dev/null +++ b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Matrix.cs @@ -0,0 +1,732 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SharpLink.CodecCompatibility; + +internal static class LayoutEvidenceFixtureRegistry +{ + internal static IReadOnlyList All { get; } = Create(); + internal static IReadOnlyDictionary ById { get; } = + All.ToDictionary(static fixture => fixture.Id, StringComparer.Ordinal); + + internal static IReadOnlyList ForProfile(string profile) + { + LayoutEvidenceProfiles.Validate(profile); + return All.Where(fixture => string.Equals(profile, LayoutEvidenceProfiles.NativeWidth, StringComparison.Ordinal) + ? fixture.NativeWidth + : !fixture.NativeWidth) + .ToArray(); + } + + private static IReadOnlyList Create() + { + var fixtures = new List + { + CreateMixedAuto(), CreateMixedSequential(), CreateMixedExplicit(), + CreatePaddingAuto(), CreatePaddingSequential(null), CreatePaddingSequential(1), + CreatePaddingSequential(4), CreatePaddingSequential(8), CreatePaddingExplicit(), + CreateNestedAuto(), CreateNestedSequential(), CreateNestedExplicit(), + CreateAutoGeneric("Generic.Byte.Auto", "generic-byte-fixed", (byte)0x52), + CreateSequentialGeneric("Generic.Byte.Sequential", "generic-byte-fixed", (byte)0x52), + CreateExplicitGenericByte(), + CreateAutoGeneric("Generic.Int64.Auto", "generic-int64-fixed", 0x1020304050607080L), + CreateSequentialGeneric("Generic.Int64.Sequential", "generic-int64-fixed", 0x1020304050607080L), + CreateExplicitGenericInt64(), + CreateAutoGeneric("Generic.Guid.Auto", "generic-guid-framework", Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), ["Value"]), + CreateSequentialGeneric("Generic.Guid.Sequential", "generic-guid-framework", Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), ["Value"]), + CreateExplicitGenericGuid(), + CreateAutoGenericDateTimeOffset(), CreateSequentialGenericDateTimeOffset(), CreateExplicitGenericDateTimeOffset(), + CreateDateTimeOffsetContainerAuto(), CreateDateTimeOffsetContainerSequential(), CreateDateTimeOffsetContainerExplicit(), + CreateNativeAuto(), CreateNativeSequential(), CreateNativeExplicit() + }; + fixtures.AddRange(CreateLegacyControls()); + return fixtures; + } + + private static ILayoutEvidenceFixture CreateMixedAuto() + { + var value = new LayoutMixedAuto { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); + return Fixture("Mixed.Auto", "mixed-alignment-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateMixedSequential() + { + var value = new LayoutMixedSequential { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); + return Fixture("Mixed.Sequential", "mixed-alignment-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateMixedExplicit() + { + var value = new LayoutMixedExplicit { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); + return Fixture("Mixed.Explicit", "mixed-alignment-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingAuto() + { + var value = new LayoutPaddingAuto { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Auto", "padding-heavy-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequential(int? pack) + => pack switch + { + null => CreatePaddingSequentialDefault(), + 1 => CreatePaddingSequentialPack1(), + 4 => CreatePaddingSequentialPack4(), + 8 => CreatePaddingSequentialPack8(), + _ => throw new InvalidOperationException($"Unsupported evidence pack {pack}.") + }; + + private static ILayoutEvidenceFixture CreatePaddingSequentialDefault() + { + var value = new LayoutPaddingSequential { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Default", "padding-heavy-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequentialPack1() + { + var value = new LayoutPaddingSequentialPack1 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Pack1", "padding-heavy-fixed", "Sequential", 1, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequentialPack4() + { + var value = new LayoutPaddingSequentialPack4 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Pack4", "padding-heavy-fixed", "Sequential", 4, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingSequentialPack8() + { + var value = new LayoutPaddingSequentialPack8 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Sequential.Pack8", "padding-heavy-fixed", "Sequential", 8, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreatePaddingExplicit() + { + var value = new LayoutPaddingExplicit { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); + return Fixture("Padding.Explicit", "padding-heavy-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNestedAuto() + { + var value = new LayoutNestedAuto { Prefix = 0x1234, Inner = new LayoutInnerAuto { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Nested.Auto", "nested-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNestedSequential() + { + var value = new LayoutNestedSequential { Prefix = 0x1234, Inner = new LayoutInnerSequential { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Nested.Sequential", "nested-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNestedExplicit() + { + var value = new LayoutNestedExplicit { Prefix = 0x1234, Inner = new LayoutInnerExplicit { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; + var fields = new LayoutEvidenceFieldMap(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Nested.Explicit", "nested-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateAutoGeneric(string id, string shape, T item, IReadOnlyList? frameworkRawFields = null) where T : unmanaged + { + var value = new LayoutAutoGeneric { Prefix = 0x41, Value = item, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap>(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture(id, shape, "Auto", null, frameworkRawFields is { Count: > 0 } ? "fixed-width-framework" : "fixed-width-primitive", false, false, frameworkRawFields ?? [], value, fields); + } + + private static ILayoutEvidenceFixture CreateSequentialGeneric(string id, string shape, T item, IReadOnlyList? frameworkRawFields = null) where T : unmanaged + { + var value = new LayoutSequentialGeneric { Prefix = 0x41, Value = item, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap>(); + fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture(id, shape, "Sequential", null, frameworkRawFields is { Count: > 0 } ? "fixed-width-framework" : "fixed-width-primitive", false, false, frameworkRawFields ?? [], value, fields); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericByte() + { + var value = new LayoutExplicitGenericByte { Prefix = 0x41, Value = 0x52, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.Byte.Explicit", "generic-byte-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericInt64() + { + var value = new LayoutExplicitGenericInt64 { Prefix = 0x41, Value = 0x1020304050607080, Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.Int64.Explicit", "generic-int64-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericGuid() + { + var value = new LayoutExplicitGenericGuid { Prefix = 0x41, Value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), Tail = 0x1112131415161718 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.Guid.Explicit", "generic-guid-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields); + } + + private static DateTimeOffset EvidenceOffset() + => new(2026, 8, 31, 13, 45, 12, TimeSpan.FromHours(5.5)); + + private static ILayoutEvidenceFixture CreateAutoGenericDateTimeOffset() + { + var value = new LayoutAutoGeneric { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.DateTimeOffset.Auto", "generic-datetimeoffset-framework", "Auto", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateSequentialGenericDateTimeOffset() + { + var value = new LayoutSequentialGeneric { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.DateTimeOffset.Sequential", "generic-datetimeoffset-framework", "Sequential", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateExplicitGenericDateTimeOffset() + { + var value = new LayoutExplicitGenericDateTimeOffset { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("Generic.DateTimeOffset.Explicit", "generic-datetimeoffset-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerAuto() + { + var value = new LayoutDateTimeOffsetAuto { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("DateTimeOffsetContainer.Auto", "datetimeoffset-container-framework", "Auto", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerSequential() + { + var value = new LayoutDateTimeOffsetSequential { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("DateTimeOffsetContainer.Sequential", "datetimeoffset-container-framework", "Sequential", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerExplicit() + { + var value = new LayoutDateTimeOffsetExplicit { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("DateTimeOffsetContainer.Explicit", "datetimeoffset-container-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateNativeAuto() + { + var value = new LayoutNativeAuto { A = (nint)0x12345678, B = (nuint)0x23456789 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); + return Fixture("NativeWidth.Auto", "native-width-pair", "Auto", null, "native-width", true, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNativeSequential() + { + var value = new LayoutNativeSequential { A = (nint)0x12345678, B = (nuint)0x23456789 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); + return Fixture("NativeWidth.Sequential", "native-width-pair", "Sequential", null, "native-width", true, false, [], value, fields); + } + + private static ILayoutEvidenceFixture CreateNativeExplicit() + { + var value = new LayoutNativeExplicit { A = (nint)0x12345678, B = (nuint)0x23456789 }; + var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); + return Fixture("NativeWidth.Explicit", "native-width-pair", "Explicit", null, "native-width", true, false, [], value, fields); + } + + private static IEnumerable CreateLegacyControls() + { + var offset = EvidenceOffset(); + var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + var mixed = new AutoMixed { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 1234567890.123456789m, F = guid, G = offset }; + var mixedFields = new LayoutEvidenceFieldMap(); + mixedFields.Add(ref mixed, ref mixed.A, "A"); mixedFields.Add(ref mixed, ref mixed.B, "B"); mixedFields.Add(ref mixed, ref mixed.C, "C"); mixedFields.Add(ref mixed, ref mixed.D, "D"); mixedFields.Add(ref mixed, ref mixed.E, "E"); mixedFields.Add(ref mixed, ref mixed.F, "F"); mixedFields.Add(ref mixed, ref mixed.G, "G"); + yield return Fixture("AutoMixed", "legacy-auto-mixed", "Auto", null, "fixed-width-framework", false, true, ["E", "F", "G"], mixed, mixedFields); + + var nested = new AutoNested { Prefix = 0x31, Inner = mixed, Tail = 0x1122334455667788 }; + var nestedFields = new LayoutEvidenceFieldMap(); + nestedFields.Add(ref nested, ref nested.Prefix, "Prefix"); nestedFields.Add(ref nested, ref nested.Inner.A, "Inner.A"); nestedFields.Add(ref nested, ref nested.Inner.B, "Inner.B"); nestedFields.Add(ref nested, ref nested.Inner.C, "Inner.C"); nestedFields.Add(ref nested, ref nested.Inner.D, "Inner.D"); nestedFields.Add(ref nested, ref nested.Inner.E, "Inner.E"); nestedFields.Add(ref nested, ref nested.Inner.F, "Inner.F"); nestedFields.Add(ref nested, ref nested.Inner.G, "Inner.G"); nestedFields.Add(ref nested, ref nested.Tail, "Tail"); + yield return Fixture("AutoNested", "legacy-auto-nested", "Auto", null, "fixed-width-framework", false, true, ["Inner.E", "Inner.F", "Inner.G"], nested, nestedFields); + + yield return CreateLegacyGeneric("AutoGenericByte", (byte)0x52, []); + yield return CreateLegacyGeneric("AutoGenericInt64", 0x1020304050607080L, []); + yield return CreateLegacyGeneric("AutoGenericGuid", guid, ["Value"]); + yield return CreateLegacyGenericDateTimeOffset(offset); + + var padding = new AutoPaddingHeavy { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; + var paddingFields = new LayoutEvidenceFieldMap(); paddingFields.Add(ref padding, ref padding.Prefix, "Prefix"); paddingFields.Add(ref padding, ref padding.Value, "Value"); paddingFields.Add(ref padding, ref padding.Suffix, "Suffix"); + yield return Fixture("AutoPaddingHeavy", "legacy-auto-padding-heavy", "Auto", null, "fixed-width-primitive", false, true, [], padding, paddingFields); + + var sequentialDto = new DateTimeOffsetContainer { Prefix = 0x61, Value = offset, Tail = 0x5152535455565758 }; + var sequentialDtoFields = new LayoutEvidenceFieldMap(); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Prefix, "Prefix"); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Value, "Value"); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Tail, "Tail"); + yield return Fixture("DateTimeOffsetContainer", "legacy-datetimeoffset-container", "Sequential", null, "fixed-width-framework", false, true, ["Value"], sequentialDto, sequentialDtoFields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + + var autoDto = new AutoDateTimeOffsetContainer { Prefix = 0x62, Value = offset, Tail = 0x6162636465666768 }; + var autoDtoFields = new LayoutEvidenceFieldMap(); autoDtoFields.Add(ref autoDto, ref autoDto.Prefix, "Prefix"); autoDtoFields.Add(ref autoDto, ref autoDto.Value, "Value"); autoDtoFields.Add(ref autoDto, ref autoDto.Tail, "Tail"); + yield return Fixture("AutoDateTimeOffsetContainer", "legacy-auto-datetimeoffset-container", "Auto", null, "fixed-width-framework", false, true, ["Value"], autoDto, autoDtoFields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static ILayoutEvidenceFixture CreateLegacyGeneric(string id, T item, IReadOnlyList frameworkRawFields) where T : unmanaged + { + var value = new AutoGeneric { Prefix = 0x43, Value = item, Tail = 0x2122232425262728 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture(id, "legacy-auto-generic", "Auto", null, frameworkRawFields.Count == 0 ? "fixed-width-primitive" : "fixed-width-framework", false, true, frameworkRawFields, value, fields); + } + + private static ILayoutEvidenceFixture CreateLegacyGenericDateTimeOffset(DateTimeOffset item) + { + var value = new AutoGeneric { Prefix = 0x44, Value = item, Tail = 0x3132333435363738 }; + var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); + return Fixture("AutoGenericDateTimeOffset", "legacy-auto-generic", "Auto", null, "fixed-width-framework", false, true, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); + } + + private static LayoutEvidenceFixture Fixture( + string id, + string shape, + string layoutKind, + int? pack, + string widthDomain, + bool nativeWidth, + bool legacyControl, + IReadOnlyList frameworkRawFields, + T value, + LayoutEvidenceFieldMap fields, + Func? logicalEquals = null) where T : unmanaged + => new(id, shape, layoutKind, pack, widthDomain, nativeWidth, legacyControl, frameworkRawFields, value, fields, logicalEquals); +} + +internal static class LayoutEvidenceProbe +{ + internal static string ProduceJson( + string sharpLinkCommit, + string sdkVersion, + string targetFramework, + string profile, + string? expectedRuntimeFamily = null, + string? executionEnvironmentOverride = null) + { + LayoutEvidenceProfiles.Validate(profile); + var runtime = CreateRuntimeIdentity(sharpLinkCommit, sdkVersion, targetFramework, expectedRuntimeFamily, executionEnvironmentOverride); + var envelope = new LayoutEvidenceEnvelope { Profile = profile, Runtime = runtime }; + foreach (var fixture in LayoutEvidenceFixtureRegistry.ForProfile(profile)) + { + var bytes = fixture.Serialize(); + var item = fixture.CreateCase(bytes); + envelope.Cases.Add(item); + envelope.CaseBytesBase64.Add(item.Id, Convert.ToBase64String(bytes)); + } + return JsonSerializer.Serialize(envelope, typeof(LayoutEvidenceEnvelope), LayoutEvidenceJsonContext.Default); + } + + internal static string VerifyJson( + string envelopesJson, + string sharpLinkCommit, + string sdkVersion, + string targetFramework, + string? expectedRuntimeFamily = null, + string? executionEnvironmentOverride = null) + { + var envelopes = JsonSerializer.Deserialize(envelopesJson, typeof(List), LayoutEvidenceJsonContext.Default) as List + ?? throw new InvalidOperationException("Failed to deserialize UnsafeBlit layout evidence envelopes."); + var consumer = CreateRuntimeIdentity(sharpLinkCommit, sdkVersion, targetFramework, expectedRuntimeFamily, executionEnvironmentOverride); + var report = new LayoutEvidenceReport { Consumer = consumer }; + foreach (var envelope in envelopes + .OrderBy(static item => item.Runtime.PlatformTag, StringComparer.Ordinal) + .ThenBy(static item => item.Profile, StringComparer.Ordinal)) + { + ValidateEnvelope(envelope, sharpLinkCommit); + foreach (var producerCase in envelope.Cases.OrderBy(static item => item.Id, StringComparer.Ordinal)) + { + var fixture = LayoutEvidenceFixtureRegistry.ById[producerCase.Id]; + var producerBytes = Convert.FromBase64String(envelope.CaseBytesBase64[producerCase.Id]); + report.Results.Add(fixture.Verify(envelope.Profile, producerBytes, producerCase, envelope.Runtime, consumer)); + } + } + return JsonSerializer.Serialize(report, typeof(LayoutEvidenceReport), LayoutEvidenceJsonContext.Default); + } + + private static void ValidateEnvelope(LayoutEvidenceEnvelope envelope, string expectedCommit) + { + if (envelope.SchemaVersion != 1 || envelope.Runtime.SchemaVersion != 1) + throw new InvalidOperationException($"Unsupported layout evidence schema from {envelope.Runtime.PlatformTag}."); + LayoutEvidenceProfiles.Validate(envelope.Profile); + if (!string.Equals(envelope.Runtime.SharpLinkCommit, expectedCommit, StringComparison.Ordinal)) + throw new InvalidOperationException($"Layout evidence commit mismatch from {envelope.Runtime.PlatformTag}: {envelope.Runtime.SharpLinkCommit} != {expectedCommit}."); + + var expected = LayoutEvidenceFixtureRegistry.ForProfile(envelope.Profile).OrderBy(static item => item.Id, StringComparer.Ordinal).ToArray(); + var actual = envelope.Cases.OrderBy(static item => item.Id, StringComparer.Ordinal).ToArray(); + if (actual.Length != expected.Length || envelope.CaseBytesBase64.Count != expected.Length) + throw new InvalidOperationException($"Layout evidence fixture count mismatch from {envelope.Runtime.PlatformTag}/{envelope.Profile}."); + + for (var index = 0; index < expected.Length; index++) + { + var fixture = expected[index]; + var item = actual[index]; + if (!string.Equals(item.Id, fixture.Id, StringComparison.Ordinal) + || !string.Equals(item.LogicalShape, fixture.LogicalShape, StringComparison.Ordinal) + || !string.Equals(item.LayoutKind, fixture.LayoutKind, StringComparison.Ordinal) + || item.Pack != fixture.Pack + || !string.Equals(item.WidthDomain, fixture.WidthDomain, StringComparison.Ordinal) + || item.NativeWidth != fixture.NativeWidth + || item.LegacyControl != fixture.LegacyControl + || !item.FrameworkRawFields.SequenceEqual(fixture.FrameworkRawFields, StringComparer.Ordinal)) + { + throw new InvalidOperationException($"Layout evidence metadata mismatch for {envelope.Runtime.PlatformTag}/{item.Id}."); + } + if (!envelope.CaseBytesBase64.TryGetValue(item.Id, out var base64)) + throw new InvalidOperationException($"Missing layout evidence bytes for {envelope.Runtime.PlatformTag}/{item.Id}."); + var bytes = Convert.FromBase64String(base64); + if (bytes.Length != item.Size || !string.Equals(Hash(bytes), item.WireSha256, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Layout evidence wire integrity mismatch for {envelope.Runtime.PlatformTag}/{item.Id}."); + } + } + + private static LayoutEvidenceRuntimeIdentity CreateRuntimeIdentity( + string sharpLinkCommit, + string sdkVersion, + string targetFramework, + string? expectedRuntimeFamily, + string? executionEnvironmentOverride) + { + var os = OperatingSystem.IsBrowser() ? "browser" + : OperatingSystem.IsAndroid() ? "android" + : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macos" + : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : "unknown"; + var (runtimeFamily, runtimeFamilySource) = DetectRuntimeFamily(); + if (!string.IsNullOrWhiteSpace(expectedRuntimeFamily) + && !string.Equals(runtimeFamily, expectedRuntimeFamily, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Layout evidence runtime mismatch: expected={expectedRuntimeFamily}, observed={runtimeFamily}."); + } + var compilationMode = !RuntimeFeature.IsDynamicCodeSupported ? "AOT" + : RuntimeFeature.IsDynamicCodeCompiled ? "JIT" + : "Interpreter"; + var processArchitecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + var runtimeIdentifier = RuntimeInformation.RuntimeIdentifier; + if (OperatingSystem.IsAndroid() && !runtimeIdentifier.StartsWith("android-", StringComparison.OrdinalIgnoreCase)) + runtimeIdentifier = $"android-{processArchitecture}"; + var executionEnvironment = executionEnvironmentOverride + ?? (OperatingSystem.IsBrowser() ? "browser" : OperatingSystem.IsAndroid() ? "android-runtime" : "hosted-desktop"); + var frameworkTag = GetFrameworkTag(targetFramework); + return new LayoutEvidenceRuntimeIdentity + { + SharpLinkCommit = string.IsNullOrWhiteSpace(sharpLinkCommit) ? "unknown" : sharpLinkCommit, + TargetFramework = targetFramework, + FrameworkDescription = RuntimeInformation.FrameworkDescription, + RuntimeFamily = runtimeFamily, + RuntimeFamilySource = runtimeFamilySource, + RuntimeVersion = Environment.Version.ToString(), + SdkVersion = string.IsNullOrWhiteSpace(sdkVersion) ? "unknown" : sdkVersion, + RuntimeIdentifier = runtimeIdentifier, + ExecutionEnvironment = executionEnvironment, + Os = os, + OsVersion = RuntimeInformation.OSDescription, + ProcessArchitecture = processArchitecture, + OsArchitecture = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), + PointerSize = IntPtr.Size, + IsLittleEndian = BitConverter.IsLittleEndian, + CompilationMode = compilationMode, + PlatformTag = $"{os}-{processArchitecture}-{executionEnvironment}-{runtimeFamily.ToLowerInvariant()}-{frameworkTag}" + }; + } + + private static (string Family, string Source) DetectRuntimeFamily() + { + if (OperatingSystem.IsBrowser()) + return ("Mono", "platform-runtime-pack"); + if (!OperatingSystem.IsAndroid()) + return (Type.GetType("Mono.Runtime") is null ? "CoreCLR" : "Mono", "runtime-reflection"); + var maps = File.ReadAllText("/proc/self/maps"); + var mono = maps.Contains("libmonosgen-2.0.so", StringComparison.Ordinal); + var coreClr = maps.Contains("libcoreclr.so", StringComparison.Ordinal); + if (mono == coreClr) + throw new InvalidOperationException($"Unable to identify Android layout evidence runtime: monoLoaded={mono}, coreClrLoaded={coreClr}."); + return (mono ? "Mono" : "CoreCLR", "loaded-runtime-library"); + } + + private static string GetFrameworkTag(string targetFramework) + { + var framework = targetFramework.Split('/', 2, StringSplitOptions.TrimEntries)[0]; + var separator = framework.IndexOf('-'); + if (separator >= 0) framework = framework[..separator]; + separator = framework.IndexOf('.'); + if (separator >= 0) framework = framework[..separator]; + return framework.ToLowerInvariant(); + } + + private static string Hash(ReadOnlySpan bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); +} + +internal static class LayoutEvidenceSummaryBuilder +{ + internal static LayoutEvidenceSummary Build(IReadOnlyList reports) + { + if (reports.Count == 0) + throw new InvalidOperationException("No UnsafeBlit layout evidence reports were supplied."); + var commits = reports.Select(static report => report.Consumer.SharpLinkCommit).Distinct(StringComparer.Ordinal).ToArray(); + if (commits.Length != 1 || string.IsNullOrWhiteSpace(commits[0]) || string.Equals(commits[0], "unknown", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Layout evidence summary requires one known SharpLink commit; observed [{string.Join(", ", commits)}]."); + var results = reports.SelectMany(static report => report.Results) + .OrderBy(static item => item.Profile, StringComparer.Ordinal) + .ThenBy(static item => item.Fixture, StringComparer.Ordinal) + .ThenBy(static item => item.Producer, StringComparer.Ordinal) + .ThenBy(static item => item.Consumer, StringComparer.Ordinal) + .ToList(); + var consumers = reports.Select(static report => report.Consumer.PlatformTag).Distinct(StringComparer.Ordinal).OrderBy(static item => item, StringComparer.Ordinal).ToArray(); + foreach (var profile in new[] { LayoutEvidenceProfiles.FixedWidth, LayoutEvidenceProfiles.NativeWidth }) + { + var producers = results.Where(item => string.Equals(item.Profile, profile, StringComparison.Ordinal)).Select(static item => item.Producer).Distinct(StringComparer.Ordinal).OrderBy(static item => item, StringComparer.Ordinal).ToArray(); + if (!producers.SequenceEqual(consumers, StringComparer.Ordinal)) + throw new InvalidOperationException($"Layout evidence profile {profile} is not a complete producer/consumer matrix: producers=[{string.Join(", ", producers)}], consumers=[{string.Join(", ", consumers)}]."); + } + + var conclusions = results.GroupBy(static item => item.Fixture, StringComparer.Ordinal) + .Select(group => BuildConclusion(group.Key, group.ToArray())) + .OrderBy(static item => item.LogicalShape, StringComparer.Ordinal) + .ThenBy(static item => item.LayoutKind, StringComparer.Ordinal) + .ThenBy(static item => item.Pack) + .ToList(); + return new LayoutEvidenceSummary + { + SharpLinkCommit = commits[0], + GeneratedAtUtc = DateTimeOffset.UtcNow, + Platforms = [.. consumers], + Fixtures = conclusions, + Hypotheses = BuildHypotheses(conclusions), + Results = results + }; + } + + internal static string CreateMarkdown(LayoutEvidenceSummary summary) + { + var lines = new List + { + "# UnsafeBlit layout compatibility evidence", + "", + $"Commit: `{summary.SharpLinkCommit}`", + $"Platforms: {string.Join(", ", summary.Platforms.Select(static item => $"`{item}`"))}", + "", + "## Hypotheses", + "" + }; + foreach (var hypothesis in summary.Hypotheses) + { + lines.Add($"- **{hypothesis.Id}** — {(hypothesis.SupportedByObservedMatrix ? "supported by this matrix" : "not established by this matrix")}: {hypothesis.Question}"); + if (hypothesis.Evidence.Count != 0) lines.Add($" Evidence: {string.Join("; ", hypothesis.Evidence)}"); + if (hypothesis.CounterEvidence.Count != 0) lines.Add($" Counter-evidence: {string.Join("; ", hypothesis.CounterEvidence)}"); + } + lines.AddRange(["", "## Fixture conclusions", "", "| Fixture | Shape | Layout | Pack | Domain | Wire compatible | Raw stable | Size mismatch | Offset mismatch | Byte diff | Logical mismatch | Padding-only | Nested diff | Framework raw diff | Pointer diff |", "|---|---|---|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"]); + foreach (var item in summary.Fixtures) + { + lines.Add($"| {item.Fixture} | {item.LogicalShape} | {item.LayoutKind} | {(item.Pack?.ToString() ?? "default")} | {item.WidthDomain} | {item.RawWireCompatibleEdges}/{item.CrossPlatformEdges} | {item.RawRepresentationStableEdges}/{item.CrossPlatformEdges} | {item.SizeMismatchEdges} | {item.FieldOffsetMismatchEdges} | {item.RawByteDifferenceEdges} | {item.LogicalMismatchEdges} | {item.PaddingOnlyDifferenceEdges} | {item.NestedRepresentationDifferenceEdges} | {item.FrameworkRawDifferenceEdges} | {item.PointerWidthMismatchEdges} |"); + } + var failures = summary.Results.Where(static item => item.Producer != item.Consumer && !item.RawWireCompatible).ToArray(); + lines.AddRange(["", "## Cross-platform incompatibility details", ""]); + if (failures.Length == 0) + { + lines.Add("No cross-platform logical UnsafeBlit incompatibilities were observed."); + } + else + { + lines.Add("| Fixture | Producer → Consumer | Classification | Size | Offset differences | Byte differences | Padding-only | Nested | Framework raw | Pointer width | Logical | "); + lines.Add("|---|---|---|---|---|---|---|---|---|---|---|"); + foreach (var result in failures) + { + var offsets = result.FieldOffsetDifferences.Count == 0 ? "none" : string.Join(",", result.FieldOffsetDifferences.Select(static item => $"{item.Field}:{item.Producer}->{item.Consumer}")); + var bytes = result.DifferingByteOffsets.Count == 0 ? "none" : string.Join(",", result.DifferingByteOffsets); + lines.Add($"| {result.Fixture} | {result.Producer} → {result.Consumer} | {result.Classification} | {result.ProducerSize}->{result.ConsumerSize} | {offsets} | {bytes} | {result.DifferencesOnlyInPaddingOnBothSides} | {result.DifferingBytesTouchNestedField || result.NestedFieldMetadataMismatch} | {result.DifferingBytesTouchFrameworkRawField} | {result.ProducerPointerSize}->{result.ConsumerPointerSize} | {result.LogicalEquality?.ToString() ?? "n/a"} |"); + } + } + return string.Join("\n", lines) + "\n"; + } + + private static LayoutFixtureConclusion BuildConclusion(string fixture, IReadOnlyList results) + { + var sample = results[0]; + var cross = results.Where(static item => !string.Equals(item.Producer, item.Consumer, StringComparison.Ordinal)).ToArray(); + return new LayoutFixtureConclusion + { + Fixture = fixture, + LogicalShape = sample.LogicalShape, + LayoutKind = sample.LayoutKind, + Pack = sample.Pack, + WidthDomain = sample.WidthDomain, + NativeWidth = sample.NativeWidth, + LegacyControl = sample.LegacyControl, + CrossPlatformEdges = cross.Length, + RawWireCompatibleEdges = cross.Count(static item => item.RawWireCompatible), + RawRepresentationStableEdges = cross.Count(static item => item.RawRepresentationStable), + SizeMismatchEdges = cross.Count(static item => !item.SizeEqual), + FieldOffsetMismatchEdges = cross.Count(static item => !item.FieldOffsetsEqual), + RawByteDifferenceEdges = cross.Count(static item => !item.ByteForByteEquality), + LogicalMismatchEdges = cross.Count(static item => item.LogicalEquality != true), + PaddingOnlyDifferenceEdges = cross.Count(static item => item.DifferencesOnlyInPaddingOnBothSides), + NestedRepresentationDifferenceEdges = cross.Count(static item => item.NestedFieldMetadataMismatch || item.DifferingBytesTouchNestedField), + FrameworkRawDifferenceEdges = cross.Count(static item => item.DifferingBytesTouchFrameworkRawField), + PointerWidthMismatchEdges = cross.Count(static item => item.PointerWidthMismatch), + AllCrossPlatformRawWireCompatible = cross.Length != 0 && cross.All(static item => item.RawWireCompatible), + AllCrossPlatformRawRepresentationStable = cross.Length != 0 && cross.All(static item => item.RawRepresentationStable) + }; + } + + private static List BuildHypotheses(IReadOnlyList fixtures) + { + var matchedShapes = fixtures.Where(static item => !item.LegacyControl && !item.NativeWidth) + .GroupBy(static item => item.LogicalShape, StringComparer.Ordinal) + .ToArray(); + var autoFailsSeqExplicitPass = new List(); + var onlyExplicitPasses = new List(); + foreach (var shape in matchedShapes) + { + var auto = shape.FirstOrDefault(static item => item.LayoutKind == "Auto"); + var sequential = shape.FirstOrDefault(static item => item.LayoutKind == "Sequential" && item.Pack is null); + var explicitLayout = shape.FirstOrDefault(static item => item.LayoutKind == "Explicit"); + if (auto is null || sequential is null || explicitLayout is null) continue; + if (!auto.AllCrossPlatformRawWireCompatible && sequential.AllCrossPlatformRawWireCompatible && explicitLayout.AllCrossPlatformRawWireCompatible) + autoFailsSeqExplicitPass.Add(shape.Key); + if (!auto.AllCrossPlatformRawWireCompatible && !sequential.AllCrossPlatformRawWireCompatible && explicitLayout.AllCrossPlatformRawWireCompatible) + onlyExplicitPasses.Add(shape.Key); + } + + var primitiveExplicit = fixtures.Where(static item => !item.LegacyControl && item.LayoutKind == "Explicit" && item.WidthDomain == "fixed-width-primitive").ToArray(); + var frameworkExplicit = fixtures.Where(static item => !item.LegacyControl && item.LayoutKind == "Explicit" && item.WidthDomain == "fixed-width-framework").ToArray(); + var primitivePass = primitiveExplicit.Where(static item => item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); + var frameworkFail = frameworkExplicit.Where(static item => !item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); + + var fixedSequentialExplicit = fixtures.Where(static item => !item.LegacyControl && item.WidthDomain == "fixed-width-primitive" && (item.LayoutKind == "Sequential" || item.LayoutKind == "Explicit")).ToArray(); + var fixedFailures = fixedSequentialExplicit.Where(static item => !item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); + + return + [ + new LayoutEvidenceHypothesis + { + Id = "H1", + Question = "Auto is incompatible while matched Sequential and Explicit variants are compatible.", + SupportedByObservedMatrix = autoFailsSeqExplicitPass.Count != 0, + Evidence = autoFailsSeqExplicitPass, + CounterEvidence = matchedShapes.Where(shape => shape.Any(static item => item.LayoutKind == "Auto" && item.AllCrossPlatformRawWireCompatible)).Select(static shape => $"{shape.Key}: Auto remained compatible").ToList() + }, + new LayoutEvidenceHypothesis + { + Id = "H2", + Question = "Sequential can remain incompatible where only the matched Explicit variant is compatible.", + SupportedByObservedMatrix = onlyExplicitPasses.Count != 0, + Evidence = onlyExplicitPasses + }, + new LayoutEvidenceHypothesis + { + Id = "H3", + Question = "Explicit primitive-only shapes are compatible while Explicit shapes containing framework raw representations (for example DateTimeOffset) can remain incompatible.", + SupportedByObservedMatrix = primitivePass.Length != 0 && frameworkFail.Length != 0, + Evidence = primitivePass.Select(static item => $"primitive compatible: {item}").Concat(frameworkFail.Select(static item => $"framework raw incompatible: {item}")).ToList(), + CounterEvidence = frameworkExplicit.Where(static item => item.AllCrossPlatformRawWireCompatible).Select(static item => $"framework raw compatible: {item.Fixture}").ToList() + }, + new LayoutEvidenceHypothesis + { + Id = "H4", + Question = "Fixed-width primitive Sequential/Explicit fixtures form one cross-platform raw-wire compatibility domain across the observed CoreCLR, Mono, and Browser matrix.", + SupportedByObservedMatrix = fixedSequentialExplicit.Length != 0 && fixedFailures.Length == 0, + Evidence = fixedFailures.Length == 0 ? [.. fixedSequentialExplicit.Select(static item => $"compatible: {item.Fixture}")] : [], + CounterEvidence = [.. fixedFailures.Select(static item => $"incompatible: {item}")] + } + ]; + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(LayoutEvidenceEnvelope))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(LayoutEvidenceReport))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(LayoutEvidenceSummary))] +internal partial class LayoutEvidenceJsonContext : JsonSerializerContext +{ +} + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutMixedAuto { public byte A; public short B; public int C; public long D; public double E; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutMixedSequential { public byte A; public short B; public int C; public long D; public double E; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutMixedExplicit { [FieldOffset(0)] public byte A; [FieldOffset(2)] public short B; [FieldOffset(4)] public int C; [FieldOffset(8)] public long D; [FieldOffset(16)] public double E; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutPaddingAuto { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutPaddingSequential { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential, Pack = 1)] +internal struct LayoutPaddingSequentialPack1 { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct LayoutPaddingSequentialPack4 { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Sequential, Pack = 8)] +internal struct LayoutPaddingSequentialPack8 { public byte Prefix; public long Value; public byte Suffix; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutPaddingExplicit { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public long Value; [FieldOffset(16)] public byte Suffix; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutInnerAuto { public byte A; public int B; } +[StructLayout(LayoutKind.Auto)] +internal struct LayoutNestedAuto { public short Prefix; public LayoutInnerAuto Inner; public long Tail; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutInnerSequential { public byte A; public int B; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutNestedSequential { public short Prefix; public LayoutInnerSequential Inner; public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 8)] +internal struct LayoutInnerExplicit { [FieldOffset(0)] public byte A; [FieldOffset(4)] public int B; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutNestedExplicit { [FieldOffset(0)] public short Prefix; [FieldOffset(4)] public LayoutInnerExplicit Inner; [FieldOffset(16)] public long Tail; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutAutoGeneric where T : unmanaged { public byte Prefix; public T Value; public long Tail; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutSequentialGeneric where T : unmanaged { public byte Prefix; public T Value; public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 16)] +internal struct LayoutExplicitGenericByte { [FieldOffset(0)] public byte Prefix; [FieldOffset(1)] public byte Value; [FieldOffset(8)] public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 24)] +internal struct LayoutExplicitGenericInt64 { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public long Value; [FieldOffset(16)] public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 32)] +internal struct LayoutExplicitGenericGuid { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public Guid Value; [FieldOffset(24)] public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 32)] +internal struct LayoutExplicitGenericDateTimeOffset { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public DateTimeOffset Value; [FieldOffset(24)] public long Tail; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutDateTimeOffsetAuto { public byte Prefix; public DateTimeOffset Value; public long Tail; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutDateTimeOffsetSequential { public byte Prefix; public DateTimeOffset Value; public long Tail; } +[StructLayout(LayoutKind.Explicit, Size = 32)] +internal struct LayoutDateTimeOffsetExplicit { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public DateTimeOffset Value; [FieldOffset(24)] public long Tail; } + +[StructLayout(LayoutKind.Auto)] +internal struct LayoutNativeAuto { public nint A; public nuint B; } +[StructLayout(LayoutKind.Sequential)] +internal struct LayoutNativeSequential { public nint A; public nuint B; } +[StructLayout(LayoutKind.Explicit, Size = 16)] +internal struct LayoutNativeExplicit { [FieldOffset(0)] public nint A; [FieldOffset(8)] public nuint B; } diff --git a/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs index 974ee1837..5e6151511 100644 --- a/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs +++ b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.cs @@ -558,724 +558,3 @@ internal LayoutSequenceSegment Append(ReadOnlyMemory memory) } } } - -internal static class LayoutEvidenceFixtureRegistry -{ - internal static IReadOnlyList All { get; } = Create(); - internal static IReadOnlyDictionary ById { get; } = - All.ToDictionary(static fixture => fixture.Id, StringComparer.Ordinal); - - internal static IReadOnlyList ForProfile(string profile) - { - LayoutEvidenceProfiles.Validate(profile); - return All.Where(fixture => string.Equals(profile, LayoutEvidenceProfiles.NativeWidth, StringComparison.Ordinal) - ? fixture.NativeWidth - : !fixture.NativeWidth) - .ToArray(); - } - - private static IReadOnlyList Create() - { - var fixtures = new List - { - CreateMixedAuto(), CreateMixedSequential(), CreateMixedExplicit(), - CreatePaddingAuto(), CreatePaddingSequential(null), CreatePaddingSequential(1), - CreatePaddingSequential(4), CreatePaddingSequential(8), CreatePaddingExplicit(), - CreateNestedAuto(), CreateNestedSequential(), CreateNestedExplicit(), - CreateAutoGeneric("Generic.Byte.Auto", "generic-byte-fixed", (byte)0x52), - CreateSequentialGeneric("Generic.Byte.Sequential", "generic-byte-fixed", (byte)0x52), - CreateExplicitGenericByte(), - CreateAutoGeneric("Generic.Int64.Auto", "generic-int64-fixed", 0x1020304050607080L), - CreateSequentialGeneric("Generic.Int64.Sequential", "generic-int64-fixed", 0x1020304050607080L), - CreateExplicitGenericInt64(), - CreateAutoGeneric("Generic.Guid.Auto", "generic-guid-framework", Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), ["Value"]), - CreateSequentialGeneric("Generic.Guid.Sequential", "generic-guid-framework", Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), ["Value"]), - CreateExplicitGenericGuid(), - CreateAutoGenericDateTimeOffset(), CreateSequentialGenericDateTimeOffset(), CreateExplicitGenericDateTimeOffset(), - CreateDateTimeOffsetContainerAuto(), CreateDateTimeOffsetContainerSequential(), CreateDateTimeOffsetContainerExplicit(), - CreateNativeAuto(), CreateNativeSequential(), CreateNativeExplicit() - }; - fixtures.AddRange(CreateLegacyControls()); - return fixtures; - } - - private static ILayoutEvidenceFixture CreateMixedAuto() - { - var value = new LayoutMixedAuto { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); - return Fixture("Mixed.Auto", "mixed-alignment-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateMixedSequential() - { - var value = new LayoutMixedSequential { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); - return Fixture("Mixed.Sequential", "mixed-alignment-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateMixedExplicit() - { - var value = new LayoutMixedExplicit { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 12345.25d }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); fields.Add(ref value, ref value.C, "C"); fields.Add(ref value, ref value.D, "D"); fields.Add(ref value, ref value.E, "E"); - return Fixture("Mixed.Explicit", "mixed-alignment-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreatePaddingAuto() - { - var value = new LayoutPaddingAuto { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); - return Fixture("Padding.Auto", "padding-heavy-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreatePaddingSequential(int? pack) - => pack switch - { - null => CreatePaddingSequentialDefault(), - 1 => CreatePaddingSequentialPack1(), - 4 => CreatePaddingSequentialPack4(), - 8 => CreatePaddingSequentialPack8(), - _ => throw new InvalidOperationException($"Unsupported evidence pack {pack}.") - }; - - private static ILayoutEvidenceFixture CreatePaddingSequentialDefault() - { - var value = new LayoutPaddingSequential { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); - return Fixture("Padding.Sequential.Default", "padding-heavy-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreatePaddingSequentialPack1() - { - var value = new LayoutPaddingSequentialPack1 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); - return Fixture("Padding.Sequential.Pack1", "padding-heavy-fixed", "Sequential", 1, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreatePaddingSequentialPack4() - { - var value = new LayoutPaddingSequentialPack4 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); - return Fixture("Padding.Sequential.Pack4", "padding-heavy-fixed", "Sequential", 4, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreatePaddingSequentialPack8() - { - var value = new LayoutPaddingSequentialPack8 { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); - return Fixture("Padding.Sequential.Pack8", "padding-heavy-fixed", "Sequential", 8, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreatePaddingExplicit() - { - var value = new LayoutPaddingExplicit { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Suffix, "Suffix"); - return Fixture("Padding.Explicit", "padding-heavy-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateNestedAuto() - { - var value = new LayoutNestedAuto { Prefix = 0x1234, Inner = new LayoutInnerAuto { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Nested.Auto", "nested-fixed", "Auto", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateNestedSequential() - { - var value = new LayoutNestedSequential { Prefix = 0x1234, Inner = new LayoutInnerSequential { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Nested.Sequential", "nested-fixed", "Sequential", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateNestedExplicit() - { - var value = new LayoutNestedExplicit { Prefix = 0x1234, Inner = new LayoutInnerExplicit { A = 0x33, B = 0x55667788 }, Tail = 0x0102030405060708 }; - var fields = new LayoutEvidenceFieldMap(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Inner.A, "Inner.A"); fields.Add(ref value, ref value.Inner.B, "Inner.B"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Nested.Explicit", "nested-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateAutoGeneric(string id, string shape, T item, IReadOnlyList? frameworkRawFields = null) where T : unmanaged - { - var value = new LayoutAutoGeneric { Prefix = 0x41, Value = item, Tail = 0x1112131415161718 }; - var fields = new LayoutEvidenceFieldMap>(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture(id, shape, "Auto", null, frameworkRawFields is { Count: > 0 } ? "fixed-width-framework" : "fixed-width-primitive", false, false, frameworkRawFields ?? [], value, fields); - } - - private static ILayoutEvidenceFixture CreateSequentialGeneric(string id, string shape, T item, IReadOnlyList? frameworkRawFields = null) where T : unmanaged - { - var value = new LayoutSequentialGeneric { Prefix = 0x41, Value = item, Tail = 0x1112131415161718 }; - var fields = new LayoutEvidenceFieldMap>(); - fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture(id, shape, "Sequential", null, frameworkRawFields is { Count: > 0 } ? "fixed-width-framework" : "fixed-width-primitive", false, false, frameworkRawFields ?? [], value, fields); - } - - private static ILayoutEvidenceFixture CreateExplicitGenericByte() - { - var value = new LayoutExplicitGenericByte { Prefix = 0x41, Value = 0x52, Tail = 0x1112131415161718 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Generic.Byte.Explicit", "generic-byte-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateExplicitGenericInt64() - { - var value = new LayoutExplicitGenericInt64 { Prefix = 0x41, Value = 0x1020304050607080, Tail = 0x1112131415161718 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Generic.Int64.Explicit", "generic-int64-fixed", "Explicit", null, "fixed-width-primitive", false, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateExplicitGenericGuid() - { - var value = new LayoutExplicitGenericGuid { Prefix = 0x41, Value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), Tail = 0x1112131415161718 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Generic.Guid.Explicit", "generic-guid-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields); - } - - private static DateTimeOffset EvidenceOffset() - => new(2026, 8, 31, 13, 45, 12, TimeSpan.FromHours(5.5)); - - private static ILayoutEvidenceFixture CreateAutoGenericDateTimeOffset() - { - var value = new LayoutAutoGeneric { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; - var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Generic.DateTimeOffset.Auto", "generic-datetimeoffset-framework", "Auto", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static ILayoutEvidenceFixture CreateSequentialGenericDateTimeOffset() - { - var value = new LayoutSequentialGeneric { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; - var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Generic.DateTimeOffset.Sequential", "generic-datetimeoffset-framework", "Sequential", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static ILayoutEvidenceFixture CreateExplicitGenericDateTimeOffset() - { - var value = new LayoutExplicitGenericDateTimeOffset { Prefix = 0x44, Value = EvidenceOffset(), Tail = 0x3132333435363738 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("Generic.DateTimeOffset.Explicit", "generic-datetimeoffset-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerAuto() - { - var value = new LayoutDateTimeOffsetAuto { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("DateTimeOffsetContainer.Auto", "datetimeoffset-container-framework", "Auto", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerSequential() - { - var value = new LayoutDateTimeOffsetSequential { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("DateTimeOffsetContainer.Sequential", "datetimeoffset-container-framework", "Sequential", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static ILayoutEvidenceFixture CreateDateTimeOffsetContainerExplicit() - { - var value = new LayoutDateTimeOffsetExplicit { Prefix = 0x62, Value = EvidenceOffset(), Tail = 0x6162636465666768 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("DateTimeOffsetContainer.Explicit", "datetimeoffset-container-framework", "Explicit", null, "fixed-width-framework", false, false, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static ILayoutEvidenceFixture CreateNativeAuto() - { - var value = new LayoutNativeAuto { A = (nint)0x12345678, B = (nuint)0x23456789 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); - return Fixture("NativeWidth.Auto", "native-width-pair", "Auto", null, "native-width", true, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateNativeSequential() - { - var value = new LayoutNativeSequential { A = (nint)0x12345678, B = (nuint)0x23456789 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); - return Fixture("NativeWidth.Sequential", "native-width-pair", "Sequential", null, "native-width", true, false, [], value, fields); - } - - private static ILayoutEvidenceFixture CreateNativeExplicit() - { - var value = new LayoutNativeExplicit { A = (nint)0x12345678, B = (nuint)0x23456789 }; - var fields = new LayoutEvidenceFieldMap(); fields.Add(ref value, ref value.A, "A"); fields.Add(ref value, ref value.B, "B"); - return Fixture("NativeWidth.Explicit", "native-width-pair", "Explicit", null, "native-width", true, false, [], value, fields); - } - - private static IEnumerable CreateLegacyControls() - { - var offset = EvidenceOffset(); - var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); - var mixed = new AutoMixed { A = 0x12, B = 0x2345, C = 0x3456789A, D = 0x0102030405060708, E = 1234567890.123456789m, F = guid, G = offset }; - var mixedFields = new LayoutEvidenceFieldMap(); - mixedFields.Add(ref mixed, ref mixed.A, "A"); mixedFields.Add(ref mixed, ref mixed.B, "B"); mixedFields.Add(ref mixed, ref mixed.C, "C"); mixedFields.Add(ref mixed, ref mixed.D, "D"); mixedFields.Add(ref mixed, ref mixed.E, "E"); mixedFields.Add(ref mixed, ref mixed.F, "F"); mixedFields.Add(ref mixed, ref mixed.G, "G"); - yield return Fixture("AutoMixed", "legacy-auto-mixed", "Auto", null, "fixed-width-framework", false, true, ["E", "F", "G"], mixed, mixedFields); - - var nested = new AutoNested { Prefix = 0x31, Inner = mixed, Tail = 0x1122334455667788 }; - var nestedFields = new LayoutEvidenceFieldMap(); - nestedFields.Add(ref nested, ref nested.Prefix, "Prefix"); nestedFields.Add(ref nested, ref nested.Inner.A, "Inner.A"); nestedFields.Add(ref nested, ref nested.Inner.B, "Inner.B"); nestedFields.Add(ref nested, ref nested.Inner.C, "Inner.C"); nestedFields.Add(ref nested, ref nested.Inner.D, "Inner.D"); nestedFields.Add(ref nested, ref nested.Inner.E, "Inner.E"); nestedFields.Add(ref nested, ref nested.Inner.F, "Inner.F"); nestedFields.Add(ref nested, ref nested.Inner.G, "Inner.G"); nestedFields.Add(ref nested, ref nested.Tail, "Tail"); - yield return Fixture("AutoNested", "legacy-auto-nested", "Auto", null, "fixed-width-framework", false, true, ["Inner.E", "Inner.F", "Inner.G"], nested, nestedFields); - - yield return CreateLegacyGeneric("AutoGenericByte", (byte)0x52, []); - yield return CreateLegacyGeneric("AutoGenericInt64", 0x1020304050607080L, []); - yield return CreateLegacyGeneric("AutoGenericGuid", guid, ["Value"]); - yield return CreateLegacyGenericDateTimeOffset(offset); - - var padding = new AutoPaddingHeavy { Prefix = 0x51, Value = 0x4142434445464748, Suffix = 0x52 }; - var paddingFields = new LayoutEvidenceFieldMap(); paddingFields.Add(ref padding, ref padding.Prefix, "Prefix"); paddingFields.Add(ref padding, ref padding.Value, "Value"); paddingFields.Add(ref padding, ref padding.Suffix, "Suffix"); - yield return Fixture("AutoPaddingHeavy", "legacy-auto-padding-heavy", "Auto", null, "fixed-width-primitive", false, true, [], padding, paddingFields); - - var sequentialDto = new DateTimeOffsetContainer { Prefix = 0x61, Value = offset, Tail = 0x5152535455565758 }; - var sequentialDtoFields = new LayoutEvidenceFieldMap(); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Prefix, "Prefix"); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Value, "Value"); sequentialDtoFields.Add(ref sequentialDto, ref sequentialDto.Tail, "Tail"); - yield return Fixture("DateTimeOffsetContainer", "legacy-datetimeoffset-container", "Sequential", null, "fixed-width-framework", false, true, ["Value"], sequentialDto, sequentialDtoFields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - - var autoDto = new AutoDateTimeOffsetContainer { Prefix = 0x62, Value = offset, Tail = 0x6162636465666768 }; - var autoDtoFields = new LayoutEvidenceFieldMap(); autoDtoFields.Add(ref autoDto, ref autoDto.Prefix, "Prefix"); autoDtoFields.Add(ref autoDto, ref autoDto.Value, "Value"); autoDtoFields.Add(ref autoDto, ref autoDto.Tail, "Tail"); - yield return Fixture("AutoDateTimeOffsetContainer", "legacy-auto-datetimeoffset-container", "Auto", null, "fixed-width-framework", false, true, ["Value"], autoDto, autoDtoFields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static ILayoutEvidenceFixture CreateLegacyGeneric(string id, T item, IReadOnlyList frameworkRawFields) where T : unmanaged - { - var value = new AutoGeneric { Prefix = 0x43, Value = item, Tail = 0x2122232425262728 }; - var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture(id, "legacy-auto-generic", "Auto", null, frameworkRawFields.Count == 0 ? "fixed-width-primitive" : "fixed-width-framework", false, true, frameworkRawFields, value, fields); - } - - private static ILayoutEvidenceFixture CreateLegacyGenericDateTimeOffset(DateTimeOffset item) - { - var value = new AutoGeneric { Prefix = 0x44, Value = item, Tail = 0x3132333435363738 }; - var fields = new LayoutEvidenceFieldMap>(); fields.Add(ref value, ref value.Prefix, "Prefix"); fields.Add(ref value, ref value.Value, "Value"); fields.Add(ref value, ref value.Tail, "Tail"); - return Fixture("AutoGenericDateTimeOffset", "legacy-auto-generic", "Auto", null, "fixed-width-framework", false, true, ["Value"], value, fields, static (left, right) => left.Prefix == right.Prefix && left.Tail == right.Tail && left.Value.EqualsExact(right.Value)); - } - - private static LayoutEvidenceFixture Fixture( - string id, - string shape, - string layoutKind, - int? pack, - string widthDomain, - bool nativeWidth, - bool legacyControl, - IReadOnlyList frameworkRawFields, - T value, - LayoutEvidenceFieldMap fields, - Func? logicalEquals = null) where T : unmanaged - => new(id, shape, layoutKind, pack, widthDomain, nativeWidth, legacyControl, frameworkRawFields, value, fields, logicalEquals); -} - -internal static class LayoutEvidenceProbe -{ - internal static string ProduceJson( - string sharpLinkCommit, - string sdkVersion, - string targetFramework, - string profile, - string? expectedRuntimeFamily = null, - string? executionEnvironmentOverride = null) - { - LayoutEvidenceProfiles.Validate(profile); - var runtime = CreateRuntimeIdentity(sharpLinkCommit, sdkVersion, targetFramework, expectedRuntimeFamily, executionEnvironmentOverride); - var envelope = new LayoutEvidenceEnvelope { Profile = profile, Runtime = runtime }; - foreach (var fixture in LayoutEvidenceFixtureRegistry.ForProfile(profile)) - { - var bytes = fixture.Serialize(); - var item = fixture.CreateCase(bytes); - envelope.Cases.Add(item); - envelope.CaseBytesBase64.Add(item.Id, Convert.ToBase64String(bytes)); - } - return JsonSerializer.Serialize(envelope, typeof(LayoutEvidenceEnvelope), LayoutEvidenceJsonContext.Default); - } - - internal static string VerifyJson( - string envelopesJson, - string sharpLinkCommit, - string sdkVersion, - string targetFramework, - string? expectedRuntimeFamily = null, - string? executionEnvironmentOverride = null) - { - var envelopes = JsonSerializer.Deserialize(envelopesJson, typeof(List), LayoutEvidenceJsonContext.Default) as List - ?? throw new InvalidOperationException("Failed to deserialize UnsafeBlit layout evidence envelopes."); - var consumer = CreateRuntimeIdentity(sharpLinkCommit, sdkVersion, targetFramework, expectedRuntimeFamily, executionEnvironmentOverride); - var report = new LayoutEvidenceReport { Consumer = consumer }; - foreach (var envelope in envelopes - .OrderBy(static item => item.Runtime.PlatformTag, StringComparer.Ordinal) - .ThenBy(static item => item.Profile, StringComparer.Ordinal)) - { - ValidateEnvelope(envelope, sharpLinkCommit); - foreach (var producerCase in envelope.Cases.OrderBy(static item => item.Id, StringComparer.Ordinal)) - { - var fixture = LayoutEvidenceFixtureRegistry.ById[producerCase.Id]; - var producerBytes = Convert.FromBase64String(envelope.CaseBytesBase64[producerCase.Id]); - report.Results.Add(fixture.Verify(envelope.Profile, producerBytes, producerCase, envelope.Runtime, consumer)); - } - } - return JsonSerializer.Serialize(report, typeof(LayoutEvidenceReport), LayoutEvidenceJsonContext.Default); - } - - private static void ValidateEnvelope(LayoutEvidenceEnvelope envelope, string expectedCommit) - { - if (envelope.SchemaVersion != 1 || envelope.Runtime.SchemaVersion != 1) - throw new InvalidOperationException($"Unsupported layout evidence schema from {envelope.Runtime.PlatformTag}."); - LayoutEvidenceProfiles.Validate(envelope.Profile); - if (!string.Equals(envelope.Runtime.SharpLinkCommit, expectedCommit, StringComparison.Ordinal)) - throw new InvalidOperationException($"Layout evidence commit mismatch from {envelope.Runtime.PlatformTag}: {envelope.Runtime.SharpLinkCommit} != {expectedCommit}."); - - var expected = LayoutEvidenceFixtureRegistry.ForProfile(envelope.Profile).OrderBy(static item => item.Id, StringComparer.Ordinal).ToArray(); - var actual = envelope.Cases.OrderBy(static item => item.Id, StringComparer.Ordinal).ToArray(); - if (actual.Length != expected.Length || envelope.CaseBytesBase64.Count != expected.Length) - throw new InvalidOperationException($"Layout evidence fixture count mismatch from {envelope.Runtime.PlatformTag}/{envelope.Profile}."); - - for (var index = 0; index < expected.Length; index++) - { - var fixture = expected[index]; - var item = actual[index]; - if (!string.Equals(item.Id, fixture.Id, StringComparison.Ordinal) - || !string.Equals(item.LogicalShape, fixture.LogicalShape, StringComparison.Ordinal) - || !string.Equals(item.LayoutKind, fixture.LayoutKind, StringComparison.Ordinal) - || item.Pack != fixture.Pack - || !string.Equals(item.WidthDomain, fixture.WidthDomain, StringComparison.Ordinal) - || item.NativeWidth != fixture.NativeWidth - || item.LegacyControl != fixture.LegacyControl - || !item.FrameworkRawFields.SequenceEqual(fixture.FrameworkRawFields, StringComparer.Ordinal)) - { - throw new InvalidOperationException($"Layout evidence metadata mismatch for {envelope.Runtime.PlatformTag}/{item.Id}."); - } - if (!envelope.CaseBytesBase64.TryGetValue(item.Id, out var base64)) - throw new InvalidOperationException($"Missing layout evidence bytes for {envelope.Runtime.PlatformTag}/{item.Id}."); - var bytes = Convert.FromBase64String(base64); - if (bytes.Length != item.Size || !string.Equals(Hash(bytes), item.WireSha256, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Layout evidence wire integrity mismatch for {envelope.Runtime.PlatformTag}/{item.Id}."); - } - } - - private static LayoutEvidenceRuntimeIdentity CreateRuntimeIdentity( - string sharpLinkCommit, - string sdkVersion, - string targetFramework, - string? expectedRuntimeFamily, - string? executionEnvironmentOverride) - { - var os = OperatingSystem.IsBrowser() ? "browser" - : OperatingSystem.IsAndroid() ? "android" - : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" - : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macos" - : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" - : "unknown"; - var (runtimeFamily, runtimeFamilySource) = DetectRuntimeFamily(); - if (!string.IsNullOrWhiteSpace(expectedRuntimeFamily) - && !string.Equals(runtimeFamily, expectedRuntimeFamily, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Layout evidence runtime mismatch: expected={expectedRuntimeFamily}, observed={runtimeFamily}."); - } - var compilationMode = !RuntimeFeature.IsDynamicCodeSupported ? "AOT" - : RuntimeFeature.IsDynamicCodeCompiled ? "JIT" - : "Interpreter"; - var processArchitecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); - var runtimeIdentifier = RuntimeInformation.RuntimeIdentifier; - if (OperatingSystem.IsAndroid() && !runtimeIdentifier.StartsWith("android-", StringComparison.OrdinalIgnoreCase)) - runtimeIdentifier = $"android-{processArchitecture}"; - var executionEnvironment = executionEnvironmentOverride - ?? (OperatingSystem.IsBrowser() ? "browser" : OperatingSystem.IsAndroid() ? "android-runtime" : "hosted-desktop"); - var frameworkTag = GetFrameworkTag(targetFramework); - return new LayoutEvidenceRuntimeIdentity - { - SharpLinkCommit = string.IsNullOrWhiteSpace(sharpLinkCommit) ? "unknown" : sharpLinkCommit, - TargetFramework = targetFramework, - FrameworkDescription = RuntimeInformation.FrameworkDescription, - RuntimeFamily = runtimeFamily, - RuntimeFamilySource = runtimeFamilySource, - RuntimeVersion = Environment.Version.ToString(), - SdkVersion = string.IsNullOrWhiteSpace(sdkVersion) ? "unknown" : sdkVersion, - RuntimeIdentifier = runtimeIdentifier, - ExecutionEnvironment = executionEnvironment, - Os = os, - OsVersion = RuntimeInformation.OSDescription, - ProcessArchitecture = processArchitecture, - OsArchitecture = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), - PointerSize = IntPtr.Size, - IsLittleEndian = BitConverter.IsLittleEndian, - CompilationMode = compilationMode, - PlatformTag = $"{os}-{processArchitecture}-{executionEnvironment}-{runtimeFamily.ToLowerInvariant()}-{frameworkTag}" - }; - } - - private static (string Family, string Source) DetectRuntimeFamily() - { - if (OperatingSystem.IsBrowser()) - return ("Mono", "platform-runtime-pack"); - if (!OperatingSystem.IsAndroid()) - return (Type.GetType("Mono.Runtime") is null ? "CoreCLR" : "Mono", "runtime-reflection"); - var maps = File.ReadAllText("/proc/self/maps"); - var mono = maps.Contains("libmonosgen-2.0.so", StringComparison.Ordinal); - var coreClr = maps.Contains("libcoreclr.so", StringComparison.Ordinal); - if (mono == coreClr) - throw new InvalidOperationException($"Unable to identify Android layout evidence runtime: monoLoaded={mono}, coreClrLoaded={coreClr}."); - return (mono ? "Mono" : "CoreCLR", "loaded-runtime-library"); - } - - private static string GetFrameworkTag(string targetFramework) - { - var framework = targetFramework.Split('/', 2, StringSplitOptions.TrimEntries)[0]; - var separator = framework.IndexOf('-'); - if (separator >= 0) framework = framework[..separator]; - separator = framework.IndexOf('.'); - if (separator >= 0) framework = framework[..separator]; - return framework.ToLowerInvariant(); - } - - private static string Hash(ReadOnlySpan bytes) - => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); -} - -internal static class LayoutEvidenceSummaryBuilder -{ - internal static LayoutEvidenceSummary Build(IReadOnlyList reports) - { - if (reports.Count == 0) - throw new InvalidOperationException("No UnsafeBlit layout evidence reports were supplied."); - var commits = reports.Select(static report => report.Consumer.SharpLinkCommit).Distinct(StringComparer.Ordinal).ToArray(); - if (commits.Length != 1 || string.IsNullOrWhiteSpace(commits[0]) || string.Equals(commits[0], "unknown", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Layout evidence summary requires one known SharpLink commit; observed [{string.Join(", ", commits)}]."); - var results = reports.SelectMany(static report => report.Results) - .OrderBy(static item => item.Profile, StringComparer.Ordinal) - .ThenBy(static item => item.Fixture, StringComparer.Ordinal) - .ThenBy(static item => item.Producer, StringComparer.Ordinal) - .ThenBy(static item => item.Consumer, StringComparer.Ordinal) - .ToList(); - var consumers = reports.Select(static report => report.Consumer.PlatformTag).Distinct(StringComparer.Ordinal).OrderBy(static item => item, StringComparer.Ordinal).ToArray(); - foreach (var profile in new[] { LayoutEvidenceProfiles.FixedWidth, LayoutEvidenceProfiles.NativeWidth }) - { - var producers = results.Where(item => string.Equals(item.Profile, profile, StringComparison.Ordinal)).Select(static item => item.Producer).Distinct(StringComparer.Ordinal).OrderBy(static item => item, StringComparer.Ordinal).ToArray(); - if (!producers.SequenceEqual(consumers, StringComparer.Ordinal)) - throw new InvalidOperationException($"Layout evidence profile {profile} is not a complete producer/consumer matrix: producers=[{string.Join(", ", producers)}], consumers=[{string.Join(", ", consumers)}]."); - } - - var conclusions = results.GroupBy(static item => item.Fixture, StringComparer.Ordinal) - .Select(group => BuildConclusion(group.Key, group.ToArray())) - .OrderBy(static item => item.LogicalShape, StringComparer.Ordinal) - .ThenBy(static item => item.LayoutKind, StringComparer.Ordinal) - .ThenBy(static item => item.Pack) - .ToList(); - return new LayoutEvidenceSummary - { - SharpLinkCommit = commits[0], - GeneratedAtUtc = DateTimeOffset.UtcNow, - Platforms = [.. consumers], - Fixtures = conclusions, - Hypotheses = BuildHypotheses(conclusions), - Results = results - }; - } - - internal static string CreateMarkdown(LayoutEvidenceSummary summary) - { - var lines = new List - { - "# UnsafeBlit layout compatibility evidence", - "", - $"Commit: `{summary.SharpLinkCommit}`", - $"Platforms: {string.Join(", ", summary.Platforms.Select(static item => $"`{item}`"))}", - "", - "## Hypotheses", - "" - }; - foreach (var hypothesis in summary.Hypotheses) - { - lines.Add($"- **{hypothesis.Id}** — {(hypothesis.SupportedByObservedMatrix ? "supported by this matrix" : "not established by this matrix")}: {hypothesis.Question}"); - if (hypothesis.Evidence.Count != 0) lines.Add($" Evidence: {string.Join("; ", hypothesis.Evidence)}"); - if (hypothesis.CounterEvidence.Count != 0) lines.Add($" Counter-evidence: {string.Join("; ", hypothesis.CounterEvidence)}"); - } - lines.AddRange(["", "## Fixture conclusions", "", "| Fixture | Shape | Layout | Pack | Domain | Wire compatible | Raw stable | Size mismatch | Offset mismatch | Byte diff | Logical mismatch | Padding-only | Nested diff | Framework raw diff | Pointer diff |", "|---|---|---|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"]); - foreach (var item in summary.Fixtures) - { - lines.Add($"| {item.Fixture} | {item.LogicalShape} | {item.LayoutKind} | {(item.Pack?.ToString() ?? "default")} | {item.WidthDomain} | {item.RawWireCompatibleEdges}/{item.CrossPlatformEdges} | {item.RawRepresentationStableEdges}/{item.CrossPlatformEdges} | {item.SizeMismatchEdges} | {item.FieldOffsetMismatchEdges} | {item.RawByteDifferenceEdges} | {item.LogicalMismatchEdges} | {item.PaddingOnlyDifferenceEdges} | {item.NestedRepresentationDifferenceEdges} | {item.FrameworkRawDifferenceEdges} | {item.PointerWidthMismatchEdges} |"); - } - var failures = summary.Results.Where(static item => item.Producer != item.Consumer && !item.RawWireCompatible).ToArray(); - lines.AddRange(["", "## Cross-platform incompatibility details", ""]); - if (failures.Length == 0) - { - lines.Add("No cross-platform logical UnsafeBlit incompatibilities were observed."); - } - else - { - lines.Add("| Fixture | Producer → Consumer | Classification | Size | Offset differences | Byte differences | Padding-only | Nested | Framework raw | Pointer width | Logical | "); - lines.Add("|---|---|---|---|---|---|---|---|---|---|---|"); - foreach (var result in failures) - { - var offsets = result.FieldOffsetDifferences.Count == 0 ? "none" : string.Join(",", result.FieldOffsetDifferences.Select(static item => $"{item.Field}:{item.Producer}->{item.Consumer}")); - var bytes = result.DifferingByteOffsets.Count == 0 ? "none" : string.Join(",", result.DifferingByteOffsets); - lines.Add($"| {result.Fixture} | {result.Producer} → {result.Consumer} | {result.Classification} | {result.ProducerSize}->{result.ConsumerSize} | {offsets} | {bytes} | {result.DifferencesOnlyInPaddingOnBothSides} | {result.DifferingBytesTouchNestedField || result.NestedFieldMetadataMismatch} | {result.DifferingBytesTouchFrameworkRawField} | {result.ProducerPointerSize}->{result.ConsumerPointerSize} | {result.LogicalEquality?.ToString() ?? "n/a"} |"); - } - } - return string.Join("\n", lines) + "\n"; - } - - private static LayoutFixtureConclusion BuildConclusion(string fixture, IReadOnlyList results) - { - var sample = results[0]; - var cross = results.Where(static item => !string.Equals(item.Producer, item.Consumer, StringComparison.Ordinal)).ToArray(); - return new LayoutFixtureConclusion - { - Fixture = fixture, - LogicalShape = sample.LogicalShape, - LayoutKind = sample.LayoutKind, - Pack = sample.Pack, - WidthDomain = sample.WidthDomain, - NativeWidth = sample.NativeWidth, - LegacyControl = sample.LegacyControl, - CrossPlatformEdges = cross.Length, - RawWireCompatibleEdges = cross.Count(static item => item.RawWireCompatible), - RawRepresentationStableEdges = cross.Count(static item => item.RawRepresentationStable), - SizeMismatchEdges = cross.Count(static item => !item.SizeEqual), - FieldOffsetMismatchEdges = cross.Count(static item => !item.FieldOffsetsEqual), - RawByteDifferenceEdges = cross.Count(static item => !item.ByteForByteEquality), - LogicalMismatchEdges = cross.Count(static item => item.LogicalEquality != true), - PaddingOnlyDifferenceEdges = cross.Count(static item => item.DifferencesOnlyInPaddingOnBothSides), - NestedRepresentationDifferenceEdges = cross.Count(static item => item.NestedFieldMetadataMismatch || item.DifferingBytesTouchNestedField), - FrameworkRawDifferenceEdges = cross.Count(static item => item.DifferingBytesTouchFrameworkRawField), - PointerWidthMismatchEdges = cross.Count(static item => item.PointerWidthMismatch), - AllCrossPlatformRawWireCompatible = cross.Length != 0 && cross.All(static item => item.RawWireCompatible), - AllCrossPlatformRawRepresentationStable = cross.Length != 0 && cross.All(static item => item.RawRepresentationStable) - }; - } - - private static List BuildHypotheses(IReadOnlyList fixtures) - { - var matchedShapes = fixtures.Where(static item => !item.LegacyControl && !item.NativeWidth) - .GroupBy(static item => item.LogicalShape, StringComparer.Ordinal) - .ToArray(); - var autoFailsSeqExplicitPass = new List(); - var onlyExplicitPasses = new List(); - foreach (var shape in matchedShapes) - { - var auto = shape.FirstOrDefault(static item => item.LayoutKind == "Auto"); - var sequential = shape.FirstOrDefault(static item => item.LayoutKind == "Sequential" && item.Pack is null); - var explicitLayout = shape.FirstOrDefault(static item => item.LayoutKind == "Explicit"); - if (auto is null || sequential is null || explicitLayout is null) continue; - if (!auto.AllCrossPlatformRawWireCompatible && sequential.AllCrossPlatformRawWireCompatible && explicitLayout.AllCrossPlatformRawWireCompatible) - autoFailsSeqExplicitPass.Add(shape.Key); - if (!auto.AllCrossPlatformRawWireCompatible && !sequential.AllCrossPlatformRawWireCompatible && explicitLayout.AllCrossPlatformRawWireCompatible) - onlyExplicitPasses.Add(shape.Key); - } - - var primitiveExplicit = fixtures.Where(static item => !item.LegacyControl && item.LayoutKind == "Explicit" && item.WidthDomain == "fixed-width-primitive").ToArray(); - var frameworkExplicit = fixtures.Where(static item => !item.LegacyControl && item.LayoutKind == "Explicit" && item.WidthDomain == "fixed-width-framework").ToArray(); - var primitivePass = primitiveExplicit.Where(static item => item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); - var frameworkFail = frameworkExplicit.Where(static item => !item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); - - var fixedSequentialExplicit = fixtures.Where(static item => !item.LegacyControl && item.WidthDomain == "fixed-width-primitive" && (item.LayoutKind == "Sequential" || item.LayoutKind == "Explicit")).ToArray(); - var fixedFailures = fixedSequentialExplicit.Where(static item => !item.AllCrossPlatformRawWireCompatible).Select(static item => item.Fixture).ToArray(); - - return - [ - new LayoutEvidenceHypothesis - { - Id = "H1", - Question = "Auto is incompatible while matched Sequential and Explicit variants are compatible.", - SupportedByObservedMatrix = autoFailsSeqExplicitPass.Count != 0, - Evidence = autoFailsSeqExplicitPass, - CounterEvidence = matchedShapes.Where(shape => shape.Any(static item => item.LayoutKind == "Auto" && item.AllCrossPlatformRawWireCompatible)).Select(static shape => $"{shape.Key}: Auto remained compatible").ToList() - }, - new LayoutEvidenceHypothesis - { - Id = "H2", - Question = "Sequential can remain incompatible where only the matched Explicit variant is compatible.", - SupportedByObservedMatrix = onlyExplicitPasses.Count != 0, - Evidence = onlyExplicitPasses - }, - new LayoutEvidenceHypothesis - { - Id = "H3", - Question = "Explicit primitive-only shapes are compatible while Explicit shapes containing framework raw representations (for example DateTimeOffset) can remain incompatible.", - SupportedByObservedMatrix = primitivePass.Length != 0 && frameworkFail.Length != 0, - Evidence = primitivePass.Select(static item => $"primitive compatible: {item}").Concat(frameworkFail.Select(static item => $"framework raw incompatible: {item}")).ToList(), - CounterEvidence = frameworkExplicit.Where(static item => item.AllCrossPlatformRawWireCompatible).Select(static item => $"framework raw compatible: {item.Fixture}").ToList() - }, - new LayoutEvidenceHypothesis - { - Id = "H4", - Question = "Fixed-width primitive Sequential/Explicit fixtures form one cross-platform raw-wire compatibility domain across the observed CoreCLR, Mono, and Browser matrix.", - SupportedByObservedMatrix = fixedSequentialExplicit.Length != 0 && fixedFailures.Length == 0, - Evidence = fixedFailures.Length == 0 ? [.. fixedSequentialExplicit.Select(static item => $"compatible: {item.Fixture}")] : [], - CounterEvidence = [.. fixedFailures.Select(static item => $"incompatible: {item}")] - } - ]; - } -} - -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, GenerationMode = JsonSourceGenerationMode.Metadata)] -[JsonSerializable(typeof(LayoutEvidenceEnvelope))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(LayoutEvidenceReport))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(LayoutEvidenceSummary))] -internal partial class LayoutEvidenceJsonContext : JsonSerializerContext -{ -} - -[StructLayout(LayoutKind.Auto)] -internal struct LayoutMixedAuto { public byte A; public short B; public int C; public long D; public double E; } -[StructLayout(LayoutKind.Sequential)] -internal struct LayoutMixedSequential { public byte A; public short B; public int C; public long D; public double E; } -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct LayoutMixedExplicit { [FieldOffset(0)] public byte A; [FieldOffset(2)] public short B; [FieldOffset(4)] public int C; [FieldOffset(8)] public long D; [FieldOffset(16)] public double E; } - -[StructLayout(LayoutKind.Auto)] -internal struct LayoutPaddingAuto { public byte Prefix; public long Value; public byte Suffix; } -[StructLayout(LayoutKind.Sequential)] -internal struct LayoutPaddingSequential { public byte Prefix; public long Value; public byte Suffix; } -[StructLayout(LayoutKind.Sequential, Pack = 1)] -internal struct LayoutPaddingSequentialPack1 { public byte Prefix; public long Value; public byte Suffix; } -[StructLayout(LayoutKind.Sequential, Pack = 4)] -internal struct LayoutPaddingSequentialPack4 { public byte Prefix; public long Value; public byte Suffix; } -[StructLayout(LayoutKind.Sequential, Pack = 8)] -internal struct LayoutPaddingSequentialPack8 { public byte Prefix; public long Value; public byte Suffix; } -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct LayoutPaddingExplicit { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public long Value; [FieldOffset(16)] public byte Suffix; } - -[StructLayout(LayoutKind.Auto)] -internal struct LayoutInnerAuto { public byte A; public int B; } -[StructLayout(LayoutKind.Auto)] -internal struct LayoutNestedAuto { public short Prefix; public LayoutInnerAuto Inner; public long Tail; } -[StructLayout(LayoutKind.Sequential)] -internal struct LayoutInnerSequential { public byte A; public int B; } -[StructLayout(LayoutKind.Sequential)] -internal struct LayoutNestedSequential { public short Prefix; public LayoutInnerSequential Inner; public long Tail; } -[StructLayout(LayoutKind.Explicit, Size = 8)] -internal struct LayoutInnerExplicit { [FieldOffset(0)] public byte A; [FieldOffset(4)] public int B; } -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct LayoutNestedExplicit { [FieldOffset(0)] public short Prefix; [FieldOffset(4)] public LayoutInnerExplicit Inner; [FieldOffset(16)] public long Tail; } - -[StructLayout(LayoutKind.Auto)] -internal struct LayoutAutoGeneric where T : unmanaged { public byte Prefix; public T Value; public long Tail; } -[StructLayout(LayoutKind.Sequential)] -internal struct LayoutSequentialGeneric where T : unmanaged { public byte Prefix; public T Value; public long Tail; } -[StructLayout(LayoutKind.Explicit, Size = 16)] -internal struct LayoutExplicitGenericByte { [FieldOffset(0)] public byte Prefix; [FieldOffset(1)] public byte Value; [FieldOffset(8)] public long Tail; } -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct LayoutExplicitGenericInt64 { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public long Value; [FieldOffset(16)] public long Tail; } -[StructLayout(LayoutKind.Explicit, Size = 32)] -internal struct LayoutExplicitGenericGuid { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public Guid Value; [FieldOffset(24)] public long Tail; } -[StructLayout(LayoutKind.Explicit, Size = 32)] -internal struct LayoutExplicitGenericDateTimeOffset { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public DateTimeOffset Value; [FieldOffset(24)] public long Tail; } - -[StructLayout(LayoutKind.Auto)] -internal struct LayoutDateTimeOffsetAuto { public byte Prefix; public DateTimeOffset Value; public long Tail; } -[StructLayout(LayoutKind.Sequential)] -internal struct LayoutDateTimeOffsetSequential { public byte Prefix; public DateTimeOffset Value; public long Tail; } -[StructLayout(LayoutKind.Explicit, Size = 32)] -internal struct LayoutDateTimeOffsetExplicit { [FieldOffset(0)] public byte Prefix; [FieldOffset(8)] public DateTimeOffset Value; [FieldOffset(24)] public long Tail; } - -[StructLayout(LayoutKind.Auto)] -internal struct LayoutNativeAuto { public nint A; public nuint B; } -[StructLayout(LayoutKind.Sequential)] -internal struct LayoutNativeSequential { public nint A; public nuint B; } -[StructLayout(LayoutKind.Explicit, Size = 16)] -internal struct LayoutNativeExplicit { [FieldOffset(0)] public nint A; [FieldOffset(8)] public nuint B; } From 2161b8dd764f6d2c2e9c4592c4557bb36bc0880c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:16:13 +0800 Subject: [PATCH 244/399] fix: include split UnsafeBlit layout matrix --- .../SharpLink.CodecCompatibility.LayoutEvidence.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj b/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj index 4d7103960..4afec7ebf 100644 --- a/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj +++ b/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj @@ -15,6 +15,7 @@ + From a16099780bc54cc9b64c95a6285b7c8236f6624c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:16:24 +0800 Subject: [PATCH 245/399] fix: include split UnsafeBlit layout matrix --- .../SharpLink.CodecCompatibility.Browser.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj b/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj index d632e586d..cd4160afc 100644 --- a/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj +++ b/test/SharpLink.CodecCompatibility.Browser/SharpLink.CodecCompatibility.Browser.csproj @@ -20,6 +20,7 @@ + From 9d6ec6fbb526762e8d4ed2fd84b4036f35648dc8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:16:38 +0800 Subject: [PATCH 246/399] fix: include split UnsafeBlit layout matrix --- .../SharpLink.CodecCompatibility.Android.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj b/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj index 1c2fc071f..bcd007260 100644 --- a/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj +++ b/test/SharpLink.CodecCompatibility.Android/SharpLink.CodecCompatibility.Android.csproj @@ -27,6 +27,7 @@ + \ No newline at end of file From 27612ee9f8f26637a6355a70f324e24f6eea8425 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:42:13 +0800 Subject: [PATCH 247/399] test: retain UnsafeBlit portable layout domain --- .../Program.cs | 3 + ...k.CodecCompatibility.LayoutEvidence.csproj | 1 + .../UnsafeBlitLayoutEvidence.Validation.cs | 166 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Validation.cs diff --git a/test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs b/test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs index 1e69e4dbb..537d18953 100644 --- a/test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs +++ b/test/SharpLink.CodecCompatibility.LayoutEvidence/Program.cs @@ -75,11 +75,14 @@ private static int Summarize(string inputDirectory, string outputDirectory) if (files.Length == 0) throw new InvalidOperationException($"No layout-verification.json files found under {inputDirectory}."); var reports = files.Select(path => Deserialize(File.ReadAllText(path, Encoding.UTF8))).ToArray(); + LayoutEvidenceValidation.ValidateCompleteMatrix(reports); var summary = LayoutEvidenceSummaryBuilder.Build(reports); + var retainedPortableFixtures = LayoutEvidenceValidation.ValidateRetainedPortableDomain(summary); Directory.CreateDirectory(outputDirectory); var json = JsonSerializer.Serialize(summary, typeof(LayoutEvidenceSummary), LayoutEvidenceJsonContext.Default); WriteText(Path.Combine(outputDirectory, "unsafe-blit-layout-summary.json"), json); WriteText(Path.Combine(outputDirectory, "unsafe-blit-layout-summary.md"), LayoutEvidenceSummaryBuilder.CreateMarkdown(summary)); + Console.WriteLine($"Retained portable UnsafeBlit domain: {retainedPortableFixtures} fixed-width primitive Sequential/Explicit fixtures are raw-representation stable across the complete matrix."); foreach (var hypothesis in summary.Hypotheses) Console.WriteLine($"{hypothesis.Id}: supported={hypothesis.SupportedByObservedMatrix} evidence={string.Join("; ", hypothesis.Evidence)} counter={string.Join("; ", hypothesis.CounterEvidence)}"); return 0; diff --git a/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj b/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj index 4afec7ebf..785da1a70 100644 --- a/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj +++ b/test/SharpLink.CodecCompatibility.LayoutEvidence/SharpLink.CodecCompatibility.LayoutEvidence.csproj @@ -16,6 +16,7 @@ + diff --git a/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Validation.cs b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Validation.cs new file mode 100644 index 000000000..416202f50 --- /dev/null +++ b/test/SharpLink.CodecCompatibility/UnsafeBlitLayoutEvidence.Validation.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpLink.CodecCompatibility; + +internal static class LayoutEvidenceValidation +{ + internal static void ValidateCompleteMatrix(IReadOnlyList reports) + { + if (reports.Count == 0) + throw new InvalidOperationException("No UnsafeBlit layout evidence reports were supplied."); + + var consumerReports = reports + .GroupBy(static report => report.Consumer.PlatformTag, StringComparer.Ordinal) + .ToArray(); + var duplicateConsumers = consumerReports + .Where(static group => group.Count() != 1) + .Select(static group => group.Key) + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + if (duplicateConsumers.Length != 0) + { + throw new InvalidOperationException( + $"Layout evidence contains duplicate consumer reports: [{string.Join(", ", duplicateConsumers)}]."); + } + + var allResults = reports.SelectMany(static report => report.Results).ToArray(); + var duplicateResults = allResults + .GroupBy( + static item => (item.Profile, item.Fixture, item.Producer, item.Consumer), + LayoutEvidenceResultKeyComparer.Instance) + .Where(static group => group.Count() != 1) + .Select(static group => $"{group.Key.Profile}/{group.Key.Fixture}/{group.Key.Producer}->{group.Key.Consumer}") + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + if (duplicateResults.Length != 0) + { + throw new InvalidOperationException( + $"Layout evidence contains duplicate result edges: [{string.Join(", ", duplicateResults)}]."); + } + + foreach (var report in reports) + { + var mismatchedConsumers = report.Results + .Where(item => !string.Equals(item.Consumer, report.Consumer.PlatformTag, StringComparison.Ordinal)) + .Select(static item => item.Consumer) + .Distinct(StringComparer.Ordinal) + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + if (mismatchedConsumers.Length != 0) + { + throw new InvalidOperationException( + $"Layout evidence report {report.Consumer.PlatformTag} contains results for other consumers: [{string.Join(", ", mismatchedConsumers)}]."); + } + } + + var consumers = consumerReports + .Select(static group => group.Key) + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + + foreach (var profile in new[] { LayoutEvidenceProfiles.FixedWidth, LayoutEvidenceProfiles.NativeWidth }) + { + var profileResults = allResults + .Where(item => string.Equals(item.Profile, profile, StringComparison.Ordinal)) + .ToArray(); + var producers = profileResults + .Select(static item => item.Producer) + .Distinct(StringComparer.Ordinal) + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + if (!producers.SequenceEqual(consumers, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"Layout evidence profile {profile} is not a complete producer/consumer platform set: producers=[{string.Join(", ", producers)}], consumers=[{string.Join(", ", consumers)}]."); + } + + var expectedFixtures = LayoutEvidenceFixtureRegistry.ForProfile(profile) + .Select(static fixture => fixture.Id) + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + + foreach (var report in reports) + { + var consumer = report.Consumer.PlatformTag; + var consumerProfileResults = report.Results + .Where(item => string.Equals(item.Profile, profile, StringComparison.Ordinal)) + .ToArray(); + var consumerProducers = consumerProfileResults + .Select(static item => item.Producer) + .Distinct(StringComparer.Ordinal) + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + if (!consumerProducers.SequenceEqual(consumers, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"Layout evidence consumer/profile {consumer}/{profile} is missing producer edges: expected=[{string.Join(", ", consumers)}], observed=[{string.Join(", ", consumerProducers)}]."); + } + + foreach (var producer in consumers) + { + var observedFixtures = consumerProfileResults + .Where(item => string.Equals(item.Producer, producer, StringComparison.Ordinal)) + .Select(static item => item.Fixture) + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + if (observedFixtures.SequenceEqual(expectedFixtures, StringComparer.Ordinal)) + continue; + + var missing = expectedFixtures.Except(observedFixtures, StringComparer.Ordinal).ToArray(); + var extra = observedFixtures.Except(expectedFixtures, StringComparer.Ordinal).ToArray(); + throw new InvalidOperationException( + $"Layout evidence edge {profile}/{producer}->{consumer} does not contain the complete fixture set: missing=[{string.Join(", ", missing)}], extra=[{string.Join(", ", extra)}]."); + } + } + } + } + + internal static int ValidateRetainedPortableDomain(LayoutEvidenceSummary summary) + { + var retained = summary.Fixtures + .Where(static item => !item.LegacyControl + && !item.NativeWidth + && string.Equals(item.WidthDomain, "fixed-width-primitive", StringComparison.Ordinal) + && (string.Equals(item.LayoutKind, "Sequential", StringComparison.Ordinal) + || string.Equals(item.LayoutKind, "Explicit", StringComparison.Ordinal))) + .ToArray(); + if (retained.Length == 0) + throw new InvalidOperationException("UnsafeBlit layout evidence contains no retained fixed-width primitive Sequential/Explicit fixtures."); + + var unstable = retained + .Where(static item => !item.AllCrossPlatformRawRepresentationStable) + .Select(static item => $"{item.Fixture} ({item.RawRepresentationStableEdges}/{item.CrossPlatformEdges} stable edges)") + .OrderBy(static item => item, StringComparer.Ordinal) + .ToArray(); + if (unstable.Length != 0) + { + throw new InvalidOperationException( + "Retained UnsafeBlit portable-domain regression: fixed-width primitive Sequential/Explicit fixtures must preserve complete cross-platform raw representation stability. " + + $"Unstable fixtures: [{string.Join(", ", unstable)}]."); + } + + return retained.Length; + } + + private sealed class LayoutEvidenceResultKeyComparer : IEqualityComparer<(string Profile, string Fixture, string Producer, string Consumer)> + { + internal static LayoutEvidenceResultKeyComparer Instance { get; } = new(); + + public bool Equals( + (string Profile, string Fixture, string Producer, string Consumer) x, + (string Profile, string Fixture, string Producer, string Consumer) y) + => string.Equals(x.Profile, y.Profile, StringComparison.Ordinal) + && string.Equals(x.Fixture, y.Fixture, StringComparison.Ordinal) + && string.Equals(x.Producer, y.Producer, StringComparison.Ordinal) + && string.Equals(x.Consumer, y.Consumer, StringComparison.Ordinal); + + public int GetHashCode((string Profile, string Fixture, string Producer, string Consumer) obj) + => HashCode.Combine( + StringComparer.Ordinal.GetHashCode(obj.Profile), + StringComparer.Ordinal.GetHashCode(obj.Fixture), + StringComparer.Ordinal.GetHashCode(obj.Producer), + StringComparer.Ordinal.GetHashCode(obj.Consumer)); + } +} From 442e969ebda981d562553771866a8293c4b65f98 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:29:57 +0800 Subject: [PATCH 248/399] refactor: resolve final codec plans before hashing --- .../RpcGenerator.FinalCodecPlan.cs | 1100 +++++++++++++++++ 1 file changed, 1100 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs new file mode 100644 index 000000000..d672e7304 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs @@ -0,0 +1,1100 @@ +namespace SharpLink.Generator; + +internal enum FinalCodecPlanKind +{ + Primitive, + Enum, + GeneratedDto, + Collection, + UnsafeBlit, + Custom, + Adapter, + Referenced +} + +internal enum FinalCollectionWireStrategy +{ + ChildCodec, + RawBlit, + DateTimeOffsetCanonical +} + +internal enum FinalEffectiveLayoutKind +{ + Sequential, + Explicit, + Auto +} + +internal sealed record FinalUnsafeBlitAbiPlan( + string Endianness, + int NativePointerWidth, + string Version); + +internal abstract record FinalCodecPlan(string TypeName, FinalCodecPlanKind Kind); + +internal sealed record FinalPrimitiveCodecPlan( + string TypeName, + string Family, + ImmutableArray SemanticParts, + string? ChildType = null) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Primitive); + +internal sealed record FinalEnumCodecPlan( + string TypeName, + string UnderlyingType, + string DeclarationSemantic) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Enum); + +internal enum FinalDtoMemberWireStrategy +{ + String, + Fixed, + ChildCodec +} + +internal sealed record FinalDtoMemberPlan( + uint FieldId, + GeneratedMemberKind Kind, + bool Required, + bool Nullable, + bool NonNullableReference, + FinalDtoMemberWireStrategy WireStrategy, + string? WireSemantic, + string? ChildType); + +internal sealed record FinalGeneratedDtoCodecPlan( + string TypeName, + bool IsReferenceType, + ImmutableArray Members) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.GeneratedDto); + +internal sealed record FinalCollectionCodecPlan( + string TypeName, + GeneratedCodecKind CollectionKind, + FinalCollectionWireStrategy WireStrategy, + string? ElementType, + string? KeyType, + string? ValueType, + FinalPhysicalLayoutPlan? RawElementLayout, + string? StrategySemantic) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Collection); + +internal sealed record FinalUnsafeBlitCodecPlan( + string TypeName, + FinalUnsafeBlitAbiPlan Abi, + FinalPhysicalLayoutPlan Layout, + ImmutableArray AutoLayoutHazards) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.UnsafeBlit); + +internal sealed record FinalCustomCodecPlan( + string TypeName, + RpcHashValue OpaqueSemanticIdentity) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Custom); + +internal sealed record FinalAdapterCodecPlan( + string TypeName, + RpcHashValue OpaqueSemanticIdentity, + RpcHashValue ClosedTargetLogicalIdentity) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Adapter); + +internal sealed record FinalReferencedCodecPlan( + string TypeName, + RpcHashValue CodecHash) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Referenced); + +internal abstract record FinalPhysicalLayoutPlan; + +internal sealed record FinalPrimitivePhysicalPlan( + string Token, + string? FrameworkRawAbi = null) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalEnumPhysicalPlan( + FinalPhysicalLayoutPlan Underlying, + string DeclarationSemantic) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalPointerPhysicalPlan(string TargetLogicalIdentity) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalFunctionPointerPhysicalPlan(string SignatureSemantic) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalFixedBufferPhysicalPlan( + int Length, + FinalPhysicalLayoutPlan Element) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalPhysicalFieldPlan( + int? Offset, + FinalPhysicalLayoutPlan Layout); + +internal sealed record FinalStructPhysicalPlan( + FinalEffectiveLayoutKind LayoutKind, + int Pack, + int Size, + int? InlineArrayLength, + ImmutableArray Fields) + : FinalPhysicalLayoutPlan; + +internal readonly record struct FinalCodecAutoLayoutHazardDescriptor( + string TypeName, + string FieldPath, + Location Location); + +internal readonly record struct FinalCodecAutoLayoutDiagnosticModel( + string PayloadType, + string TypeName, + string FieldPath, + Location Location); + +internal sealed class FinalCodecGraph( + IReadOnlyDictionary plans, + ImmutableArray rootTypes) +{ + internal IReadOnlyDictionary Plans { get; } = plans; + internal ImmutableArray RootTypes { get; } = rootTypes; +} + +public partial class RpcGenerator +{ + private sealed partial class DtoAnalysisState + { + private static readonly FinalUnsafeBlitAbiPlan UnsafeBlitAbi = + new("little-endian", 8, "v3"); + + private readonly Dictionary _opaqueSemanticIdentityCache = + new(StringComparer.Ordinal); + + internal FinalCodecGraph ResolveFinalCodecGraph( + bool includeSerializable, + bool includeContracts) + { + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable, + includeContracts); + + var plans = new Dictionary(StringComparer.Ordinal); + var resolving = new HashSet(StringComparer.Ordinal); + foreach (var pair in roots.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + if (!_failed.Contains(pair.Key)) + ResolveFinalCodecPlan(pair.Value, plans, resolving); + } + + // Candidate generation is intentionally allowed to discover factories before this pass. + // Final selection is not: every emitted factory must be represented by the resolved graph. + foreach (var model in _models.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + { + if (_failed.Contains(model.TypeName) || plans.ContainsKey(model.TypeName)) + continue; + if (TryResolveReachableType(model.TypeName, out var type)) + ResolveFinalCodecPlan(type, plans, resolving); + } + + return new FinalCodecGraph( + plans, + roots.Keys.Where(type => !_failed.Contains(type)) + .OrderBy(static type => type, StringComparer.Ordinal) + .ToImmutableArray()); + } + + internal ImmutableArray BuildUnsafeBlitAutoLayoutDiagnostics() + { + var graph = ResolveFinalCodecGraph(includeSerializable: false, includeContracts: true); + var diagnostics = ImmutableArray.CreateBuilder(); + var dedup = new HashSet<(string Payload, string Type, string Path)>(); + + foreach (var payload in graph.RootTypes) + { + var visited = new HashSet(StringComparer.Ordinal); + Visit(payload); + + void Visit(string typeName) + { + if (!visited.Add(typeName) || !graph.Plans.TryGetValue(typeName, out var plan)) + return; + if (plan is FinalUnsafeBlitCodecPlan unsafeBlit) + { + foreach (var hazard in unsafeBlit.AutoLayoutHazards) + { + if (dedup.Add((payload, hazard.TypeName, hazard.FieldPath))) + { + diagnostics.Add(new FinalCodecAutoLayoutDiagnosticModel( + payload, + hazard.TypeName, + hazard.FieldPath, + hazard.Location)); + } + } + return; + } + + foreach (var dependency in GetFinalCodecPlanDependencies(plan)) + Visit(dependency); + } + } + + return diagnostics + .OrderBy(static item => item.PayloadType, StringComparer.Ordinal) + .ThenBy(static item => item.TypeName, StringComparer.Ordinal) + .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private FinalCodecPlan ResolveFinalCodecPlan( + ITypeSymbol type, + Dictionary plans, + HashSet resolving) + { + var typeName = GetTypeName(type); + if (plans.TryGetValue(typeName, out var existing)) + return existing; + if (!resolving.Add(typeName)) + { + throw new InvalidOperationException( + $"Final Codec graph contains an unresolved recursive Codec selection at '{typeName}'."); + } + + FinalCodecPlan plan; + if (_models.TryGetValue(typeName, out var generatedModel)) + { + plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); + } + else if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) + { + plan = new FinalReferencedCodecPlan(typeName, referencedHash); + } + else if (type.TypeKind == TypeKind.Enum && + type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) + { + ResolveFinalCodecPlan(underlying, plans, resolving); + plan = new FinalEnumCodecPlan( + typeName, + GetTypeName(underlying), + GetEnumDeclarationSemanticIdentity(enumType)); + } + else if (type is INamedTypeSymbol nullable && + nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + nullable.TypeArguments.Length == 1 && + HasExactBuiltinNullableCodecElement(nullable.TypeArguments[0])) + { + var child = ResolveFinalCodecPlan(nullable.TypeArguments[0], plans, resolving); + plan = new FinalPrimitiveCodecPlan( + typeName, + "nullable", + ImmutableArray.Empty, + child.TypeName); + } + else if (TryGetFrameworkScalarSemantic(type, out var scalarSemantic)) + { + plan = new FinalPrimitiveCodecPlan( + typeName, + "framework", + scalarSemantic); + } + else if (TryGetCollection( + type, + out var collectionKind, + out var elementType, + out var keyType, + out var valueType)) + { + if (collectionKind == GeneratedCodecKind.Nullable && + elementType is not null && + type.IsUnmanagedType && + !HasExactBuiltinNullableCodecElement(elementType)) + { + plan = ResolveUnsafeBlitCodecPlan(type); + } + else if (TryResolveBuiltinCollectionPlan( + typeName, + collectionKind, + elementType, + out var builtinCollection)) + { + plan = builtinCollection; + } + else + { + throw new InvalidOperationException( + $"Final RPC Codec graph has no generated or runtime builtin collection selection for '{typeName}'."); + } + } + else if (type.IsUnmanagedType && !IsRuntimeSizedUnsafeBlitType(type)) + { + plan = ResolveUnsafeBlitCodecPlan(type); + } + else + { + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve deterministic Codec semantics for '{typeName}'. Rebuild referenced SharpLink assemblies with deterministic identity generation enabled or bind an explicit Codec."); + } + + resolving.Remove(typeName); + plans[typeName] = plan; + return plan; + } + + private FinalCodecPlan ResolveGeneratedCodecPlan( + ITypeSymbol type, + GeneratedCodecModel model, + Dictionary plans, + HashSet resolving) + { + switch (model.Kind) + { + case GeneratedCodecKind.Custom: + return new FinalCustomCodecPlan( + model.TypeName, + GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec")); + case GeneratedCodecKind.Adapter: + return new FinalAdapterCodecPlan( + model.TypeName, + GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter"), + GetAdapterTargetLogicalIdentity(type)); + case GeneratedCodecKind.Dto: + return ResolveGeneratedDtoPlan(type, model, plans, resolving); + default: + return ResolveGeneratedCollectionPlan(type, model, plans, resolving); + } + } + + private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( + ITypeSymbol type, + GeneratedCodecModel model, + Dictionary plans, + HashSet resolving) + { + var memberSymbols = type is INamedTypeSymbol named + ? GetSerializableMembers(named).ToDictionary(static item => item.Name, StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + var members = ImmutableArray.CreateBuilder(model.Members.Length); + foreach (var member in model.Members.OrderBy(static item => item.FieldId)) + { + memberSymbols.TryGetValue(member.Name, out var memberSymbol); + var memberType = memberSymbol is null ? null : GetMemberType(memberSymbol); + switch (member.Kind) + { + case GeneratedMemberKind.String: + members.Add(CreateMember( + member, + FinalDtoMemberWireStrategy.String, + "string/content/utf16le/i32le-byte-length/v1|string/null/dto-wire-null/v1", + null)); + break; + case GeneratedMemberKind.Fixed: + case GeneratedMemberKind.NullableFixed: + members.Add(CreateMember( + member, + FinalDtoMemberWireStrategy.Fixed, + GetResolvedFixedMemberSemantic(member, memberType), + null)); + break; + case GeneratedMemberKind.Complex: + if (memberType is null && !TryResolveReachableType(member.TypeName, out memberType!)) + { + throw new InvalidOperationException( + $"Final Codec plan for '{model.TypeName}' cannot resolve child '{member.TypeName}'."); + } + var child = ResolveFinalCodecPlan(memberType, plans, resolving); + members.Add(CreateMember( + member, + FinalDtoMemberWireStrategy.ChildCodec, + null, + child.TypeName)); + break; + } + } + + return new FinalGeneratedDtoCodecPlan( + model.TypeName, + model.IsReferenceType, + members.ToImmutable()); + + static FinalDtoMemberPlan CreateMember( + GeneratedMemberModel member, + FinalDtoMemberWireStrategy strategy, + string? wireSemantic, + string? childType) + => new( + member.FieldId, + member.Kind, + member.Required, + member.Nullable, + member.NonNullableReference, + strategy, + wireSemantic, + childType); + } + + private FinalCollectionCodecPlan ResolveGeneratedCollectionPlan( + ITypeSymbol type, + GeneratedCodecModel model, + Dictionary plans, + HashSet resolving) + { + ITypeSymbol? element = null; + ITypeSymbol? key = null; + ITypeSymbol? value = null; + if (TryGetCollection(type, out _, out var resolvedElement, out var resolvedKey, out var resolvedValue)) + { + element = resolvedElement; + key = resolvedKey; + value = resolvedValue; + } + ResolveChild(element, model.ElementType); + ResolveChild(key, model.KeyType); + ResolveChild(value, model.ValueType); + return new FinalCollectionCodecPlan( + model.TypeName, + model.Kind, + FinalCollectionWireStrategy.ChildCodec, + model.ElementType, + model.KeyType, + model.ValueType, + RawElementLayout: null, + StrategySemantic: null); + + void ResolveChild(ITypeSymbol? symbol, string? childTypeName) + { + if (childTypeName is null) + return; + if (symbol is null && !TryResolveReachableType(childTypeName, out symbol!)) + { + throw new InvalidOperationException( + $"Final Codec plan for '{model.TypeName}' cannot resolve child '{childTypeName}'."); + } + ResolveFinalCodecPlan(symbol, plans, resolving); + } + } + + private bool TryResolveBuiltinCollectionPlan( + string typeName, + GeneratedCodecKind collectionKind, + ITypeSymbol? elementType, + out FinalCollectionCodecPlan plan) + { + if (elementType is null || + collectionKind is not (GeneratedCodecKind.Array or + GeneratedCodecKind.List or + GeneratedCodecKind.Memory or + GeneratedCodecKind.ReadOnlyMemory or + GeneratedCodecKind.ImmutableArray) || + !IsBuiltinBlitElement(elementType)) + { + plan = null!; + return false; + } + + if (string.Equals(elementType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + { + plan = new FinalCollectionCodecPlan( + typeName, + collectionKind, + FinalCollectionWireStrategy.DateTimeOffsetCanonical, + GetTypeName(elementType), + null, + null, + RawElementLayout: null, + StrategySemantic: "datetime-offset/collection-raw16-padding2-7-zero/release-scoped/v1"); + return true; + } + + plan = new FinalCollectionCodecPlan( + typeName, + collectionKind, + FinalCollectionWireStrategy.RawBlit, + GetTypeName(elementType), + null, + null, + ResolvePhysicalLayout(elementType, GetTypeName(elementType), collectAutoLayoutHazards: false, null), + StrategySemantic: "builtin-blit-element/v2|abi:little-endian"); + return true; + } + + private FinalUnsafeBlitCodecPlan ResolveUnsafeBlitCodecPlan(ITypeSymbol type) + { + var hazards = ImmutableArray.CreateBuilder(); + var typeName = GetTypeName(type); + var layout = ResolvePhysicalLayout(type, typeName, collectAutoLayoutHazards: true, hazards); + return new FinalUnsafeBlitCodecPlan( + typeName, + UnsafeBlitAbi, + layout, + hazards + .OrderBy(static item => item.TypeName, StringComparer.Ordinal) + .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) + .ToImmutableArray()); + } + + private FinalPhysicalLayoutPlan ResolvePhysicalLayout( + ITypeSymbol type, + string fieldPath, + bool collectAutoLayoutHazards, + ImmutableArray.Builder? hazards, + HashSet? stack = null) + { + if (TryGetPhysicalPrimitive(type, out var primitive)) + return primitive; + + if (type.TypeKind == TypeKind.Enum && + type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) + { + return new FinalEnumPhysicalPlan( + ResolvePhysicalLayout(underlying, fieldPath, false, null, stack), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + if (type is IPointerTypeSymbol pointer) + { + var parts = new List { "pointer-target/v1" }; + AppendClosedTargetLogicalIdentity(pointer.PointedAtType, parts); + return new FinalPointerPhysicalPlan(Hashing.GetSemanticHash(parts.ToArray()).ToHex()); + } + + if (type is IFunctionPointerTypeSymbol functionPointer) + return new FinalFunctionPointerPhysicalPlan(GetFunctionPointerSemanticIdentity(functionPointer)); + + if (type is not INamedTypeSymbol named) + throw new InvalidOperationException($"Unsupported unmanaged physical type '{GetTypeName(type)}'."); + + stack ??= new HashSet(SymbolEqualityComparer.Default); + if (!stack.Add(type)) + throw new InvalidOperationException($"Recursive unmanaged physical layout '{GetTypeName(type)}'."); + + var effective = GetEffectiveStructLayout(named); + if (collectAutoLayoutHazards && + effective.Kind == FinalEffectiveLayoutKind.Auto && + SymbolEqualityComparer.Default.Equals(named.ContainingAssembly, _compilation.Assembly)) + { + var location = named.Locations.FirstOrDefault(static item => item.IsInSource) + ?? Location.None; + if (location != Location.None) + { + hazards?.Add(new FinalCodecAutoLayoutHazardDescriptor( + GetTypeName(named), + fieldPath, + location)); + } + } + + var fields = ImmutableArray.CreateBuilder(); + foreach (var field in named.GetMembers().OfType() + .Where(static item => !item.IsStatic && !item.IsConst)) + { + FinalPhysicalLayoutPlan fieldLayout; + if (field.IsFixedSizeBuffer && TryGetFixedBufferElement(field, out var fixedElement)) + { + fieldLayout = new FinalFixedBufferPhysicalPlan( + field.FixedSize, + ResolvePhysicalLayout(fixedElement, fieldPath + "." + field.Name, false, null, stack)); + } + else + { + fieldLayout = ResolvePhysicalLayout( + field.Type, + fieldPath + "." + field.Name, + collectAutoLayoutHazards, + hazards, + stack); + } + + var offset = effective.Kind == FinalEffectiveLayoutKind.Explicit + ? GetFieldOffset(field) + : null; + fields.Add(new FinalPhysicalFieldPlan(offset, fieldLayout)); + } + + stack.Remove(type); + var canonicalFields = fields.ToArray(); + if (effective.Kind == FinalEffectiveLayoutKind.Explicit) + { + Array.Sort(canonicalFields, static (left, right) => + { + var byOffset = Nullable.Compare(left.Offset, right.Offset); + if (byOffset != 0) + return byOffset; + return StringComparer.Ordinal.Compare( + GetPhysicalPlanSortKey(left.Layout), + GetPhysicalPlanSortKey(right.Layout)); + }); + } + + return new FinalStructPhysicalPlan( + effective.Kind, + effective.Pack, + effective.Size, + GetInlineArrayLength(named), + canonicalFields.ToImmutableArray()); + } + + private static (FinalEffectiveLayoutKind Kind, int Pack, int Size) GetEffectiveStructLayout( + INamedTypeSymbol type) + { + var kind = FinalEffectiveLayoutKind.Sequential; + var pack = 0; + var size = 0; + var attribute = type.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.InteropServices.StructLayoutAttribute", + StringComparison.Ordinal)); + if (attribute is null) + return (kind, pack, size); + + if (attribute.ConstructorArguments.Length != 0 && + attribute.ConstructorArguments[0].Value is int layoutKind) + { + kind = layoutKind switch + { + 2 => FinalEffectiveLayoutKind.Explicit, + 3 => FinalEffectiveLayoutKind.Auto, + _ => FinalEffectiveLayoutKind.Sequential + }; + } + foreach (var argument in attribute.NamedArguments) + { + if (argument.Value.Value is not int value) + continue; + if (string.Equals(argument.Key, "Pack", StringComparison.Ordinal)) + pack = value; + else if (string.Equals(argument.Key, "Size", StringComparison.Ordinal)) + size = value; + } + return (kind, pack, size); + } + + private static int? GetInlineArrayLength(INamedTypeSymbol type) + { + var attribute = type.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.CompilerServices.InlineArrayAttribute", + StringComparison.Ordinal)); + return attribute is { ConstructorArguments.Length: 1 } && + attribute.ConstructorArguments[0].Value is int length + ? length + : null; + } + + private static int GetFieldOffset(IFieldSymbol field) + { + var attribute = field.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.InteropServices.FieldOffsetAttribute", + StringComparison.Ordinal)); + return attribute is { ConstructorArguments.Length: 1 } && + attribute.ConstructorArguments[0].Value is int offset + ? offset + : 0; + } + + private static bool TryGetFixedBufferElement(IFieldSymbol field, out ITypeSymbol elementType) + { + var attribute = field.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.CompilerServices.FixedBufferAttribute", + StringComparison.Ordinal)); + if (attribute is { ConstructorArguments.Length: >= 1 } && + attribute.ConstructorArguments[0].Value is ITypeSymbol type) + { + elementType = type; + return true; + } + elementType = null!; + return false; + } + + private static bool TryGetPhysicalPrimitive( + ITypeSymbol type, + out FinalPrimitivePhysicalPlan primitive) + { + string? token = type.SpecialType switch + { + SpecialType.System_Boolean => "bool1", + SpecialType.System_Byte => "u8", + SpecialType.System_SByte => "i8", + SpecialType.System_Int16 => "i16", + SpecialType.System_UInt16 => "u16", + SpecialType.System_Char => "char16", + SpecialType.System_Int32 => "i32", + SpecialType.System_UInt32 => "u32", + SpecialType.System_Single => "f32", + SpecialType.System_Int64 => "i64", + SpecialType.System_UInt64 => "u64", + SpecialType.System_IntPtr => "native-pointer-width/64:intptr", + SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr", + SpecialType.System_Double => "f64", + SpecialType.System_Decimal => "decimal128", + _ => null + }; + string? frameworkRawAbi = null; + if (token is null) + { + token = type.ToDisplayString() switch + { + "System.Half" => "half16", + "System.Text.Rune" => "rune32", + "System.Guid" => "guid128", + "System.DateTimeOffset" => "datetimeoffset128", + "System.DateTime" => "datetime64", + "System.DateOnly" => "dateonly32", + "System.TimeOnly" => "timeonly64", + "System.TimeSpan" => "timespan64", + "System.Int128" => "i128", + "System.UInt128" => "u128", + "System.Index" => "index32", + "System.Range" => "range64", + _ => null + }; + if (string.Equals(type.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + frameworkRawAbi = "framework-raw/datetimeoffset/native16/release-scoped/v1"; + } + if (token is null) + { + primitive = null!; + return false; + } + primitive = new FinalPrimitivePhysicalPlan(token, frameworkRawAbi); + return true; + } + + private static string GetFunctionPointerSemanticIdentity(IFunctionPointerTypeSymbol pointer) + { + var signature = pointer.Signature; + var parts = new List + { + "function-pointer/v2", + signature.CallingConvention.ToString(), + signature.RefKind.ToString() + }; + foreach (var convention in signature.UnmanagedCallingConventionTypes + .OrderBy(static item => item.ToDisplayString(), StringComparer.Ordinal)) + { + parts.Add(convention.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + AppendClosedTargetLogicalIdentity(signature.ReturnType, parts); + parts.Add(signature.Parameters.Length.ToString(InvariantCulture)); + foreach (var parameter in signature.Parameters) + { + parts.Add(parameter.RefKind.ToString()); + AppendClosedTargetLogicalIdentity(parameter.Type, parts); + } + return Hashing.GetSemanticHash(parts.ToArray()).ToHex(); + } + + private static string GetPhysicalPlanSortKey(FinalPhysicalLayoutPlan plan) + { + var parts = new List(); + Append(plan, parts); + return string.Join("|", parts); + + static void Append(FinalPhysicalLayoutPlan current, List parts) + { + switch (current) + { + case FinalPrimitivePhysicalPlan primitive: + parts.Add("p:" + primitive.Token + ":" + primitive.FrameworkRawAbi); + return; + case FinalEnumPhysicalPlan enumPlan: + parts.Add("e:" + enumPlan.DeclarationSemantic); + Append(enumPlan.Underlying, parts); + return; + case FinalPointerPhysicalPlan pointer: + parts.Add("ptr:" + pointer.TargetLogicalIdentity); + return; + case FinalFunctionPointerPhysicalPlan functionPointer: + parts.Add("fn:" + functionPointer.SignatureSemantic); + return; + case FinalFixedBufferPhysicalPlan buffer: + parts.Add("buf:" + buffer.Length.ToString(InvariantCulture)); + Append(buffer.Element, parts); + return; + case FinalStructPhysicalPlan structure: + parts.Add($"s:{structure.LayoutKind}:{structure.Pack}:{structure.Size}:{structure.InlineArrayLength}"); + foreach (var field in structure.Fields) + { + parts.Add("o:" + (field.Offset?.ToString(InvariantCulture) ?? "seq")); + Append(field.Layout, parts); + } + return; + } + } + } + + private string GetResolvedFixedMemberSemantic( + GeneratedMemberModel member, + ITypeSymbol? actualMemberType) + { + var semanticType = actualMemberType; + if (semanticType is INamedTypeSymbol nullable && + nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + nullable.TypeArguments.Length == 1) + { + semanticType = nullable.TypeArguments[0]; + } + + if (semanticType is not null && + string.Equals(semanticType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + if (semanticType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.FixedTypeName ?? member.EnumUnderlyingType ?? member.TypeName); + } + + private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + { + var parts = new List + { + "enum-declaration/v1", + enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + foreach (var field in enumType.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + } + return string.Join("|", parts); + } + + private static bool HasExactBuiltinNullableCodecElement(ITypeSymbol type) + => type.TypeKind != TypeKind.Enum && GetFixedSize(type) != 0; + + private static bool TryGetFrameworkScalarSemantic( + ITypeSymbol type, + out ImmutableArray semantic) + { + if (type.SpecialType == SpecialType.System_String) + { + semantic = ImmutableArray.Create( + "string/content/utf16le/i32le-byte-length/v1", + "string/null/i32-minus-one/v1"); + return true; + } + + string? token = type.SpecialType switch + { + SpecialType.System_Boolean => "bool/fixed1/v1", + SpecialType.System_Byte => "u8/fixed1/v1", + SpecialType.System_SByte => "i8/fixed1/v1", + SpecialType.System_Int16 => "i16/fixed2/v1", + SpecialType.System_UInt16 => "u16/fixed2/v1", + SpecialType.System_Char => "char/fixed2/v1", + SpecialType.System_Int32 => "i32/fixed4/v1", + SpecialType.System_UInt32 => "u32/fixed4/v1", + SpecialType.System_Single => "f32/fixed4/v1", + SpecialType.System_Int64 => "i64/fixed8/v1", + SpecialType.System_UInt64 => "u64/fixed8/v1", + SpecialType.System_Double => "f64/fixed8/v1", + SpecialType.System_Decimal => "decimal/fixed16/v1", + _ => null + }; + token ??= type.ToDisplayString() switch + { + "System.Half" => "half/fixed2/v1", + "System.Text.Rune" => "rune/fixed4/v1", + "System.Guid" => "guid/fixed16/v1", + "System.DateTimeOffset" => "datetime-offset/root-ticks-i64le-offset-minutes-i16le/v1", + "System.DateTime" => "datetime/fixed8/v1", + "System.DateOnly" => "date-only/fixed4/v1", + "System.TimeOnly" => "time-only/fixed8/v1", + "System.TimeSpan" => "timespan/fixed8/v1", + "System.Int128" => "i128/fixed16/v1", + "System.UInt128" => "u128/fixed16/v1", + "System.Index" => "index/fixed4/v1", + "System.Range" => "range/fixed8/v1", + _ => null + }; + semantic = token is null ? ImmutableArray.Empty : ImmutableArray.Create(token); + return token is not null; + } + + private bool TryGetReferencedGeneratedCodecHash(ITypeSymbol type, out RpcHashValue hash) + { + var assembly = type.ContainingAssembly; + if (assembly is null || SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly)) + { + hash = default; + return false; + } + foreach (var attribute in assembly.GetAttributes()) + { + if (!IsAttribute(attribute, "SharpLink.Abstractions", "SharpLinkGeneratedCodecIdentityAttribute") || + attribute.ConstructorArguments.Length != 3 || + attribute.ConstructorArguments[0].Value is not ITypeSymbol targetType || + !SymbolEqualityComparer.Default.Equals(targetType, type) || + attribute.ConstructorArguments[1].Value is not ulong high || + attribute.ConstructorArguments[2].Value is not ulong low) + { + continue; + } + hash = new RpcHashValue(high, low); + return true; + } + hash = default; + return false; + } + + private RpcHashValue GetRequiredOpaqueSemanticIdentity( + string? implementationTypeName, + string implementationKind) + { + if (TryGetOpaqueSemanticIdentity(implementationTypeName, out var hash)) + return hash; + throw new InvalidOperationException( + $"Opaque {implementationKind} '{implementationTypeName ?? ""}' must declare [RpcCodecSemanticIdentity(high, low)]."); + } + + private bool TryGetOpaqueSemanticIdentity(string? implementationTypeName, out RpcHashValue hash) + { + if (implementationTypeName is null) + { + hash = default; + return false; + } + if (_opaqueSemanticIdentityCache.TryGetValue(implementationTypeName, out var cached)) + { + hash = cached ?? default; + return cached.HasValue; + } + + var visited = new HashSet(StringComparer.Ordinal); + var pending = new Queue(); + pending.Enqueue(_compilation.Assembly); + while (pending.Count != 0) + { + var assembly = pending.Dequeue(); + if (!visited.Add(assembly.Identity.ToString())) + continue; + if (TryFindNamedType(assembly.GlobalNamespace, implementationTypeName, out var implementationType)) + { + var attribute = implementationType.GetAttributes().FirstOrDefault(static item => + IsAttribute(item, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); + if (attribute is not null && + attribute.ConstructorArguments.Length == 2 && + attribute.ConstructorArguments[0].Value is ulong high && + attribute.ConstructorArguments[1].Value is ulong low) + { + hash = new RpcHashValue(high, low); + _opaqueSemanticIdentityCache[implementationTypeName] = hash; + return true; + } + } + foreach (var referenced in assembly.Modules.SelectMany(static module => module.ReferencedAssemblySymbols)) + pending.Enqueue(referenced); + } + + _opaqueSemanticIdentityCache[implementationTypeName] = null; + hash = default; + return false; + } + + private static bool TryFindNamedType( + INamespaceSymbol namespaceSymbol, + string typeName, + out INamedTypeSymbol type) + { + foreach (var candidate in namespaceSymbol.GetTypeMembers()) + { + if (TryFindNamedType(candidate, typeName, out type)) + return true; + } + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + { + if (TryFindNamedType(nestedNamespace, typeName, out type)) + return true; + } + type = null!; + return false; + } + + private static bool TryFindNamedType( + INamedTypeSymbol candidate, + string typeName, + out INamedTypeSymbol type) + { + if (string.Equals(GetTypeName(candidate), typeName, StringComparison.Ordinal)) + { + type = candidate; + return true; + } + foreach (var nested in candidate.GetTypeMembers()) + { + if (TryFindNamedType(nested, typeName, out type)) + return true; + } + type = null!; + return false; + } + + private bool TryResolveReachableType(string typeName, out ITypeSymbol type) + { + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: !_contractMode, + includeContracts: _contractMode); + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + return reachable.TryGetValue(typeName, out type!); + } + + private RpcHashValue GetAdapterTargetLogicalIdentity(ITypeSymbol targetType) + { + var parts = new List { "adapter-target/v2" }; + AppendClosedTargetLogicalIdentity(targetType, parts); + return Hashing.GetSemanticHash(parts.ToArray()); + } + + private static IEnumerable GetFinalCodecPlanDependencies(FinalCodecPlan plan) + { + switch (plan) + { + case FinalPrimitiveCodecPlan { ChildType: { } child }: + yield return child; + break; + case FinalEnumCodecPlan enumPlan: + yield return enumPlan.UnderlyingType; + break; + case FinalGeneratedDtoCodecPlan dto: + foreach (var member in dto.Members) + { + if (member.ChildType is not null) + yield return member.ChildType; + } + break; + case FinalCollectionCodecPlan { WireStrategy: FinalCollectionWireStrategy.ChildCodec } collection: + if (collection.ElementType is not null) yield return collection.ElementType; + if (collection.KeyType is not null) yield return collection.KeyType; + if (collection.ValueType is not null) yield return collection.ValueType; + break; + } + } + } +} From 881c90f7f24ad968d6d32eb41cfe93c60d6d31e2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:30:32 +0800 Subject: [PATCH 249/399] refactor: hash resolved final codec plans --- .../RpcGenerator.CodecIdentity.cs | 839 ++++-------------- 1 file changed, 191 insertions(+), 648 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 889bb481b..7fc140189 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -4,713 +4,256 @@ public partial class RpcGenerator { private sealed partial class DtoAnalysisState { - private readonly Dictionary _opaqueSemanticIdentityCache = - new(StringComparer.Ordinal); - internal ImmutableArray BuildFinalCodecHashes( bool includeSerializable, bool includeContracts) { - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable, - includeContracts); - - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - + var graph = ResolveFinalCodecGraph(includeSerializable, includeContracts); var cache = new Dictionary(StringComparer.Ordinal); - return reachable - .Where(pair => !_failed.Contains(pair.Key)) + return graph.Plans .OrderBy(static pair => pair.Key, StringComparer.Ordinal) .Select(pair => { - var hash = GetFinalCodecHash(pair.Value, cache, new HashSet(StringComparer.Ordinal)); + var hash = HashCanonicalPlan(pair.Value, graph, cache, new HashSet(StringComparer.Ordinal)); return new GeneratedCodecHashModel(pair.Key, hash.High, hash.Low); }) .ToImmutableArray(); } - private RpcHashValue GetFinalCodecHash( - ITypeSymbol type, + private static RpcHashValue HashCanonicalPlan( + FinalCodecPlan plan, + FinalCodecGraph graph, Dictionary cache, HashSet stack) { - var typeName = GetTypeName(type); - if (cache.TryGetValue(typeName, out var cached)) + if (cache.TryGetValue(plan.TypeName, out var cached)) return cached; - if (!stack.Add(typeName)) - return Hashing.GetSemanticHash("codec/v1", "recursive", typeName); - - RpcHashValue result; - if (TryGetFrameworkPrimitiveCodecHash(type, cache, stack, out result)) - { - stack.Remove(typeName); - cache[typeName] = result; - return result; - } - - if (_models.TryGetValue(typeName, out var model)) - { - result = GetGeneratedCodecHash(model, cache, stack); - stack.Remove(typeName); - cache[typeName] = result; - return result; - } - - if (TryGetReferencedGeneratedCodecHash(type, out result)) - { - stack.Remove(typeName); - cache[typeName] = result; - return result; - } - - if (TryGetCollection(type, out var collectionKind, out var elementType, out var keyType, out var valueType)) - { - if (collectionKind == GeneratedCodecKind.Nullable && - elementType is not null && - type.IsUnmanagedType && - !HasExactBuiltinNullableCodecElement(elementType)) - { - result = GetRuntimeUnsafeBlitNullableCodecHash(type, elementType); - } - else if (TryGetBuiltinCollectionElementSemanticIdentity( - collectionKind, - elementType, - out var builtinElementIdentity)) - { - result = Hashing.GetSemanticHash( - "codec/v1", - "collection", - collectionKind.ToString(), - "runtime-builtin-blit/v1", - builtinElementIdentity); - } - else - { - var parts = new List - { - "codec/v1", - "collection", - collectionKind.ToString() - }; - if (elementType is not null) - parts.Add(GetFinalCodecHash(elementType, cache, stack).ToHex()); - if (keyType is not null) - parts.Add(GetFinalCodecHash(keyType, cache, stack).ToHex()); - if (valueType is not null) - parts.Add(GetFinalCodecHash(valueType, cache, stack).ToHex()); - result = Hashing.GetSemanticHash(parts.ToArray()); - } - } - else if (type.IsUnmanagedType && !IsRuntimeSizedUnsafeBlitType(type)) - { - var layout = new StringBuilder("unsafe-blit/v2|abi:little-endian|native-pointer-width/64"); - AppendUnsafeBlitPhysicalLayout( - type, - layout, - new HashSet(SymbolEqualityComparer.Default)); - result = Hashing.GetSemanticHash("codec/v1", layout.ToString()); - } - else + if (!stack.Add(plan.TypeName)) { throw new InvalidOperationException( - $"Final RPC Codec graph cannot resolve deterministic CodecHash metadata for referenced payload '{typeName}'. Rebuild the referenced SharpLink assembly with deterministic identity generation enabled."); + $"Resolved FinalCodecPlan graph contains a hash cycle at '{plan.TypeName}'."); } - stack.Remove(typeName); - cache[typeName] = result; - return result; - } - - private RpcHashValue GetRuntimeUnsafeBlitNullableCodecHash(ITypeSymbol nullableType, ITypeSymbol elementType) - { - var layout = new StringBuilder("unsafe-blit/v2|abi:little-endian|native-pointer-width/64"); - AppendUnsafeBlitPhysicalLayout( - nullableType, - layout, - new HashSet(SymbolEqualityComparer.Default)); - - if (elementType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + RpcHashValue hash = plan switch { - return Hashing.GetSemanticHash( + FinalPrimitiveCodecPlan primitive => HashPrimitivePlan(primitive, graph, cache, stack), + FinalEnumCodecPlan enumPlan => HashEnumPlan(enumPlan, graph, cache, stack), + FinalGeneratedDtoCodecPlan dto => HashGeneratedDtoPlan(dto, graph, cache, stack), + FinalCollectionCodecPlan collection => HashCollectionPlan(collection, graph, cache, stack), + FinalUnsafeBlitCodecPlan unsafeBlit => HashUnsafeBlitPlan(unsafeBlit), + FinalCustomCodecPlan custom => Hashing.GetSemanticHash( "codec/v1", - "nullable-runtime-unsafe-blit/v1", - layout.ToString(), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - return Hashing.GetSemanticHash( - "codec/v1", - "nullable-runtime-unsafe-blit/v1", - layout.ToString()); - } - - private bool TryGetBuiltinCollectionElementSemanticIdentity( - GeneratedCodecKind collectionKind, - ITypeSymbol? elementType, - out string identity) - { - if (elementType is null || - collectionKind is not (GeneratedCodecKind.Array or - GeneratedCodecKind.List or - GeneratedCodecKind.Memory or - GeneratedCodecKind.ReadOnlyMemory or - GeneratedCodecKind.ImmutableArray) || - !IsBuiltinBlitElement(elementType)) - { - identity = string.Empty; - return false; - } - - if (string.Equals(elementType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) - { - identity = "datetime-offset/collection-raw16-padding2-7-zero/release-scoped/v1"; - return true; - } + "custom-opaque", + custom.OpaqueSemanticIdentity.ToHex()), + FinalAdapterCodecPlan adapter => Hashing.GetSemanticHash( + "codec/v1", + "adapter-closed/v2", + adapter.OpaqueSemanticIdentity.ToHex(), + adapter.ClosedTargetLogicalIdentity.ToHex()), + FinalReferencedCodecPlan referenced => referenced.CodecHash, + _ => throw new InvalidOperationException( + $"Unknown resolved FinalCodecPlan '{plan.GetType().Name}'.") + }; - var layout = new StringBuilder("builtin-blit-element/v1|abi:little-endian"); - AppendUnsafeBlitPhysicalLayout( - elementType, - layout, - new HashSet(SymbolEqualityComparer.Default)); - identity = layout.ToString(); - return true; + stack.Remove(plan.TypeName); + cache[plan.TypeName] = hash; + return hash; } - private bool TryGetReferencedGeneratedCodecHash(ITypeSymbol type, out RpcHashValue hash) + private static RpcHashValue HashPrimitivePlan( + FinalPrimitiveCodecPlan plan, + FinalCodecGraph graph, + Dictionary cache, + HashSet stack) { - var assembly = type.ContainingAssembly; - if (assembly is null || SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly)) - { - hash = default; - return false; - } - - foreach (var attribute in assembly.GetAttributes()) + if (string.Equals(plan.Family, "nullable", StringComparison.Ordinal)) { - if (!IsAttribute( - attribute, - "SharpLink.Abstractions", - "SharpLinkGeneratedCodecIdentityAttribute") || - attribute.ConstructorArguments.Length != 3 || - attribute.ConstructorArguments[0].Value is not ITypeSymbol targetType || - !SymbolEqualityComparer.Default.Equals(targetType, type) || - attribute.ConstructorArguments[1].Value is not ulong high || - attribute.ConstructorArguments[2].Value is not ulong low) - { - continue; - } - - hash = new RpcHashValue(high, low); - return true; + if (plan.ChildType is null) + throw new InvalidOperationException($"Nullable plan '{plan.TypeName}' has no child plan."); + return Hashing.GetSemanticHash( + "codec/v1", + "nullable", + HashRequiredChild(plan.ChildType, graph, cache, stack).ToHex()); } - hash = default; - return false; + var parts = new List { "codec/v1", plan.Family }; + parts.AddRange(plan.SemanticParts); + if (plan.ChildType is not null) + parts.Add(HashRequiredChild(plan.ChildType, graph, cache, stack).ToHex()); + return Hashing.GetSemanticHash(parts.ToArray()); } - private RpcHashValue GetGeneratedCodecHash( - GeneratedCodecModel model, + private static RpcHashValue HashEnumPlan( + FinalEnumCodecPlan plan, + FinalCodecGraph graph, Dictionary cache, HashSet stack) - { - switch (model.Kind) - { - case GeneratedCodecKind.Custom: - return Hashing.GetSemanticHash( - "codec/v1", - "custom-opaque", - GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec").ToHex()); - case GeneratedCodecKind.Adapter: - return Hashing.GetSemanticHash( - "codec/v1", - "adapter-closed/v2", - GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter").ToHex(), - GetAdapterTargetLogicalIdentity(model).ToHex()); - case GeneratedCodecKind.Dto: - { - var parts = new List - { - "codec/v1", - "dto", - model.IsReferenceType ? "ref" : "value" - }; - foreach (var member in model.Members.OrderBy(static member => member.FieldId)) - { - parts.Add(member.FieldId.ToString(InvariantCulture)); - parts.Add(member.Kind.ToString()); - parts.Add(member.Required ? "required" : "optional"); - parts.Add(member.Nullable ? "nullable" : "non-nullable"); - parts.Add(member.NonNullableReference ? "non-null-ref" : "other-null-semantics"); - switch (member.Kind) - { - case GeneratedMemberKind.String: - parts.Add("string/content/utf16le/i32le-byte-length/v1"); - parts.Add("string/null/dto-wire-null/v1"); - break; - case GeneratedMemberKind.Fixed: - case GeneratedMemberKind.NullableFixed: - parts.Add(GetFixedMemberSemanticIdentity(member)); - break; - case GeneratedMemberKind.Complex: - if (!TryResolveReachableType(member.TypeName, out var memberType)) - { - throw new InvalidOperationException( - $"Final RPC Codec graph cannot resolve child payload '{member.TypeName}' while hashing '{model.TypeName}'."); - } - parts.Add(GetFinalCodecHash(memberType, cache, stack).ToHex()); - break; - } - } - return Hashing.GetSemanticHash(parts.ToArray()); - } - default: - { - var parts = new List - { - "codec/v1", - "collection", - model.Kind.ToString() - }; - AppendChild(model.ElementType); - AppendChild(model.KeyType); - AppendChild(model.ValueType); - return Hashing.GetSemanticHash(parts.ToArray()); - - void AppendChild(string? childTypeName) - { - if (childTypeName is null) - return; - if (!TryResolveReachableType(childTypeName, out var childType)) - { - throw new InvalidOperationException( - $"Final RPC Codec graph cannot resolve child payload '{childTypeName}' while hashing '{model.TypeName}'."); - } - parts.Add(GetFinalCodecHash(childType, cache, stack).ToHex()); - } - } - } - } - - private RpcHashValue GetRequiredOpaqueSemanticIdentity( - string? implementationTypeName, - string implementationKind) - { - if (TryGetOpaqueSemanticIdentity(implementationTypeName, out var hash)) - return hash; - - throw new InvalidOperationException( - $"Opaque {implementationKind} '{implementationTypeName ?? ""}' must declare [RpcCodecSemanticIdentity(high, low)]."); - } + => Hashing.GetSemanticHash( + "codec/v1", + "enum", + HashRequiredChild(plan.UnderlyingType, graph, cache, stack).ToHex(), + plan.DeclarationSemantic); - private bool TryGetOpaqueSemanticIdentity(string? implementationTypeName, out RpcHashValue hash) + private static RpcHashValue HashGeneratedDtoPlan( + FinalGeneratedDtoCodecPlan plan, + FinalCodecGraph graph, + Dictionary cache, + HashSet stack) { - if (implementationTypeName is null) - { - hash = default; - return false; - } - - if (_opaqueSemanticIdentityCache.TryGetValue(implementationTypeName, out var cached)) - { - hash = cached ?? default; - return cached.HasValue; - } - - var assemblies = new Dictionary(StringComparer.Ordinal) + var parts = new List { - [_compilation.Assembly.Identity.ToString()] = _compilation.Assembly + "codec/v1", + "dto", + plan.IsReferenceType ? "ref" : "value" }; - var pending = new Queue(); - pending.Enqueue(_compilation.Assembly); - while (pending.Count != 0) - { - var assembly = pending.Dequeue(); - if (TryFindNamedType(assembly.GlobalNamespace, implementationTypeName, out var implementationType)) + foreach (var member in plan.Members.OrderBy(static item => item.FieldId)) + { + parts.Add(member.FieldId.ToString(InvariantCulture)); + parts.Add(member.Kind.ToString()); + parts.Add(member.Required ? "required" : "optional"); + parts.Add(member.Nullable ? "nullable" : "non-nullable"); + parts.Add(member.NonNullableReference ? "non-null-ref" : "other-null-semantics"); + switch (member.WireStrategy) { - var attribute = implementationType.GetAttributes().FirstOrDefault(static item => - IsAttribute(item, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); - if (attribute is not null && - attribute.ConstructorArguments.Length == 2 && - attribute.ConstructorArguments[0].Value is ulong high && - attribute.ConstructorArguments[1].Value is ulong low) - { - hash = new RpcHashValue(high, low); - _opaqueSemanticIdentityCache[implementationTypeName] = hash; - return true; - } + case FinalDtoMemberWireStrategy.String: + parts.Add("string/content/utf16le/i32le-byte-length/v1"); + parts.Add("string/null/dto-wire-null/v1"); + break; + case FinalDtoMemberWireStrategy.Fixed: + parts.Add(member.WireSemantic ?? throw new InvalidOperationException( + "Resolved fixed DTO member has no wire semantic.")); + break; + case FinalDtoMemberWireStrategy.ChildCodec: + parts.Add(HashRequiredChild( + member.ChildType ?? throw new InvalidOperationException( + "Resolved complex DTO member has no child plan."), + graph, + cache, + stack).ToHex()); + break; } - - foreach (var referenced in assembly.Modules.SelectMany(static module => module.ReferencedAssemblySymbols)) - { - var identity = referenced.Identity.ToString(); - if (assemblies.ContainsKey(identity)) - continue; - assemblies.Add(identity, referenced); - pending.Enqueue(referenced); - } - } - - _opaqueSemanticIdentityCache[implementationTypeName] = null; - hash = default; - return false; - } - - private static bool TryFindNamedType( - INamespaceSymbol namespaceSymbol, - string typeName, - out INamedTypeSymbol type) - { - foreach (var candidate in namespaceSymbol.GetTypeMembers()) - { - if (TryFindNamedType(candidate, typeName, out type)) - return true; - } - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - { - if (TryFindNamedType(nestedNamespace, typeName, out type)) - return true; - } - type = null!; - return false; - } - - private static bool TryFindNamedType( - INamedTypeSymbol candidate, - string typeName, - out INamedTypeSymbol type) - { - if (string.Equals(GetTypeName(candidate), typeName, StringComparison.Ordinal)) - { - type = candidate; - return true; - } - foreach (var nested in candidate.GetTypeMembers()) - { - if (TryFindNamedType(nested, typeName, out type)) - return true; } - type = null!; - return false; - } - - private bool TryResolveReachableType(string typeName, out ITypeSymbol type) - { - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - return reachable.TryGetValue(typeName, out type!); + return Hashing.GetSemanticHash(parts.ToArray()); } - private string GetFixedMemberSemanticIdentity(GeneratedMemberModel member) - { - var typeName = member.FixedTypeName ?? member.TypeName; - if (string.Equals(typeName, "System.DateTimeOffset", StringComparison.Ordinal) || - string.Equals(typeName, "global::System.DateTimeOffset", StringComparison.Ordinal)) - { - return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; - } - - if (member.EnumUnderlyingType is not null && - TryResolveReachableType(member.TypeName, out var fixedMemberType) && - fixedMemberType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) - { - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - member.EnumUnderlyingType ?? typeName); - } - - private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + private static RpcHashValue HashCollectionPlan( + FinalCollectionCodecPlan plan, + FinalCodecGraph graph, + Dictionary cache, + HashSet stack) { var parts = new List { - "enum-declaration/v1", - enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + "codec/v1", + "collection", + plan.CollectionKind.ToString() }; - foreach (var field in enumType.GetMembers() - .OfType() - .Where(static field => field.HasConstantValue) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + switch (plan.WireStrategy) + { + case FinalCollectionWireStrategy.ChildCodec: + AppendChild(plan.ElementType); + AppendChild(plan.KeyType); + AppendChild(plan.ValueType); + break; + case FinalCollectionWireStrategy.RawBlit: + parts.Add(plan.StrategySemantic ?? "builtin-blit-element/v2|abi:little-endian"); + parts.Add(HashPhysicalLayout( + plan.RawElementLayout ?? throw new InvalidOperationException( + $"Raw-blit collection '{plan.TypeName}' has no physical element plan.")).ToHex()); + break; + case FinalCollectionWireStrategy.DateTimeOffsetCanonical: + parts.Add("runtime-datetimeoffset-special/v1"); + parts.Add(plan.StrategySemantic ?? throw new InvalidOperationException( + $"DateTimeOffset collection '{plan.TypeName}' has no strategy semantic.")); + break; + } + return Hashing.GetSemanticHash(parts.ToArray()); + + void AppendChild(string? childType) + { + if (childType is not null) + parts.Add(HashRequiredChild(childType, graph, cache, stack).ToHex()); } - return string.Join("|", parts); } - private static bool HasExactBuiltinNullableCodecElement(ITypeSymbol type) - => type.TypeKind != TypeKind.Enum && GetFixedSize(type) != 0; - - private bool TryGetFrameworkPrimitiveCodecHash( - ITypeSymbol type, + private static RpcHashValue HashUnsafeBlitPlan(FinalUnsafeBlitCodecPlan plan) + => Hashing.GetSemanticHash( + "codec/v1", + "unsafe-blit-plan/v3", + "endianness:" + plan.Abi.Endianness, + "native-pointer-width:" + plan.Abi.NativePointerWidth.ToString(InvariantCulture), + "abi-version:" + plan.Abi.Version, + HashPhysicalLayout(plan.Layout).ToHex()); + + private static RpcHashValue HashRequiredChild( + string childType, + FinalCodecGraph graph, Dictionary cache, - HashSet stack, - out RpcHashValue hash) - { - if (type.TypeKind == TypeKind.Enum && - type is INamedTypeSymbol { EnumUnderlyingType: { } enumUnderlying }) - { - hash = Hashing.GetSemanticHash( - "codec/v1", - "enum", - GetFinalCodecHash(enumUnderlying, cache, stack).ToHex(), - GetEnumDeclarationSemanticIdentity((INamedTypeSymbol)type)); - return true; - } - - if (type is INamedTypeSymbol nullable && - nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && - nullable.TypeArguments.Length == 1 && - HasExactBuiltinNullableCodecElement(nullable.TypeArguments[0])) - { - hash = Hashing.GetSemanticHash( - "codec/v1", - "nullable", - GetFinalCodecHash(nullable.TypeArguments[0], cache, stack).ToHex()); - return true; - } - - if (type.SpecialType == SpecialType.System_String) - { - hash = Hashing.GetSemanticHash( - "codec/v1", - "framework", - "string/content/utf16le/i32le-byte-length/v1", - "string/null/i32-minus-one/v1"); - return true; - } - - string? token = type.SpecialType switch - { - SpecialType.System_Boolean => "bool/fixed1/v1", - SpecialType.System_Byte => "u8/fixed1/v1", - SpecialType.System_SByte => "i8/fixed1/v1", - SpecialType.System_Int16 => "i16/fixed2/v1", - SpecialType.System_UInt16 => "u16/fixed2/v1", - SpecialType.System_Char => "char/fixed2/v1", - SpecialType.System_Int32 => "i32/fixed4/v1", - SpecialType.System_UInt32 => "u32/fixed4/v1", - SpecialType.System_Single => "f32/fixed4/v1", - SpecialType.System_Int64 => "i64/fixed8/v1", - SpecialType.System_UInt64 => "u64/fixed8/v1", - SpecialType.System_Double => "f64/fixed8/v1", - SpecialType.System_Decimal => "decimal/fixed16/v1", - _ => null - }; - - if (token is null && type is IArrayTypeSymbol { Rank: 1, ElementType.SpecialType: SpecialType.System_Byte }) - token = "bytes/v1"; - if (token is null) - { - token = type.ToDisplayString() switch - { - "System.Half" => "half/fixed2/v1", - "System.Text.Rune" => "rune/fixed4/v1", - "System.Guid" => "guid/fixed16/v1", - "System.DateTimeOffset" => "datetime-offset/root-ticks-i64le-offset-minutes-i16le/v1", - "System.DateTime" => "datetime/fixed8/v1", - "System.DateOnly" => "date-only/fixed4/v1", - "System.TimeOnly" => "time-only/fixed8/v1", - "System.TimeSpan" => "timespan/fixed8/v1", - "System.Int128" => "i128/fixed16/v1", - "System.UInt128" => "u128/fixed16/v1", - "System.Index" => "index/fixed4/v1", - "System.Range" => "range/fixed8/v1", - _ => null - }; - } - - if (token is null) - { - hash = default; - return false; - } - - hash = Hashing.GetSemanticHash("codec/v1", "framework", token); - return true; - } - - private void AppendUnsafeBlitPhysicalLayout( - ITypeSymbol type, - StringBuilder builder, - HashSet stack) - { - if (TryAppendPhysicalPrimitive(type, builder)) - return; - - if (type.TypeKind == TypeKind.Enum && - type is INamedTypeSymbol { EnumUnderlyingType: { } enumUnderlying }) - { - builder.Append("|enum"); - AppendUnsafeBlitPhysicalLayout(enumUnderlying, builder, stack); - return; - } - - if (type is IPointerTypeSymbol pointer) - { - builder.Append("|native-pointer-width/64|pointer|"); - AppendUnsafeBlitPhysicalLayout(pointer.PointedAtType, builder, stack); - return; - } - if (type is IFunctionPointerTypeSymbol) - { - builder.Append("|native-pointer-width/64|function-pointer"); - return; - } - if (type is not INamedTypeSymbol named) - { - builder.Append("|unknown-unmanaged"); - return; - } - - AppendPhysicalLayoutAttribute(builder, named, "System.Runtime.InteropServices.StructLayoutAttribute", stack); - AppendPhysicalLayoutAttribute(builder, named, "System.Runtime.CompilerServices.InlineArrayAttribute", stack); - - if (!stack.Add(type)) - { - builder.Append("|recursive"); - return; - } - - if (named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && - named.TypeArguments.Length == 1) - { - builder.Append("|nullable-underlying"); - AppendUnsafeBlitPhysicalLayout(named.TypeArguments[0], builder, stack); - stack.Remove(type); - return; - } - - var fields = named.GetMembers() - .OfType() - .Where(static field => !field.IsStatic && !field.IsConst) - .ToArray(); - builder.Append("|fields:").Append(fields.Length.ToString(InvariantCulture)); - for (var index = 0; index < fields.Length; index++) - { - var field = fields[index]; - builder.Append("|field:").Append(index.ToString(InvariantCulture)); - if (field.IsFixedSizeBuffer) - builder.Append("|fixed-buffer:").Append(field.FixedSize.ToString(InvariantCulture)); - AppendPhysicalLayoutAttribute(builder, field, "System.Runtime.InteropServices.FieldOffsetAttribute", stack); - AppendPhysicalLayoutAttribute(builder, field, "System.Runtime.CompilerServices.FixedBufferAttribute", stack); - AppendUnsafeBlitPhysicalLayout(field.Type, builder, stack); - } - - stack.Remove(type); - } - - private static bool TryAppendPhysicalPrimitive(ITypeSymbol type, StringBuilder builder) - { - var token = type.SpecialType switch - { - SpecialType.System_Boolean => "bool1", - SpecialType.System_Byte => "u8", - SpecialType.System_SByte => "i8", - SpecialType.System_Int16 => "i16", - SpecialType.System_UInt16 => "u16", - SpecialType.System_Char => "char16", - SpecialType.System_Int32 => "i32", - SpecialType.System_UInt32 => "u32", - SpecialType.System_Single => "f32", - SpecialType.System_Int64 => "i64", - SpecialType.System_UInt64 => "u64", - SpecialType.System_IntPtr => "native-pointer-width/64:intptr", - SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr", - SpecialType.System_Double => "f64", - SpecialType.System_Decimal => "decimal128", - _ => null - }; - if (token is null) - { - token = type.ToDisplayString() switch - { - "System.Half" => "half16", - "System.Text.Rune" => "rune32", - "System.Guid" => "guid128", - "System.DateTimeOffset" => "datetimeoffset128", - "System.DateTime" => "datetime64", - "System.DateOnly" => "dateonly32", - "System.TimeOnly" => "timeonly64", - "System.TimeSpan" => "timespan64", - "System.Int128" => "i128", - "System.UInt128" => "u128", - "System.Index" => "index32", - "System.Range" => "range64", - _ => null - }; - } - if (token is null) - return false; - builder.Append('|').Append(token); - return true; - } - - private static void AppendPhysicalLayoutAttribute( - StringBuilder builder, - ISymbol symbol, - string attributeName, - HashSet stack) + HashSet stack) { - var attribute = symbol.GetAttributes().FirstOrDefault(item => - string.Equals(item.AttributeClass?.ToDisplayString(), attributeName, StringComparison.Ordinal)); - if (attribute is null) - return; - - builder.Append("|attr:").Append(attributeName); - foreach (var argument in attribute.ConstructorArguments) - AppendPhysicalLayoutConstant(builder, argument, stack); - foreach (var argument in attribute.NamedArguments.OrderBy(static item => item.Key, StringComparer.Ordinal)) + if (!graph.Plans.TryGetValue(childType, out var child)) { - builder.Append('|').Append(argument.Key).Append('='); - AppendPhysicalLayoutConstant(builder, argument.Value, stack); + throw new InvalidOperationException( + $"Resolved FinalCodecPlan graph is missing child '{childType}'."); } + return HashCanonicalPlan(child, graph, cache, stack); } - private static void AppendPhysicalLayoutConstant( - StringBuilder builder, - TypedConstant constant, - HashSet stack) + private static RpcHashValue HashPhysicalLayout(FinalPhysicalLayoutPlan plan) { - builder.Append(':').Append(constant.Kind.ToString()).Append('='); - if (constant.Kind == TypedConstantKind.Array) - { - builder.Append('['); - foreach (var item in constant.Values) - AppendPhysicalLayoutConstant(builder, item, stack); - builder.Append(']'); - return; - } - - if (constant.Value is ITypeSymbol type) + switch (plan) { - if (!TryAppendPhysicalPrimitive(type, builder)) - builder.Append("layout-type"); - return; + case FinalPrimitivePhysicalPlan primitive: + return Hashing.GetSemanticHash( + "physical/v1", + "primitive", + primitive.Token, + primitive.FrameworkRawAbi ?? string.Empty); + case FinalEnumPhysicalPlan enumPlan: + return Hashing.GetSemanticHash( + "physical/v1", + "enum", + HashPhysicalLayout(enumPlan.Underlying).ToHex(), + enumPlan.DeclarationSemantic); + case FinalPointerPhysicalPlan pointer: + return Hashing.GetSemanticHash( + "physical/v1", + "native-pointer", + pointer.TargetLogicalIdentity); + case FinalFunctionPointerPhysicalPlan functionPointer: + return Hashing.GetSemanticHash( + "physical/v1", + "function-pointer", + functionPointer.SignatureSemantic); + case FinalFixedBufferPhysicalPlan fixedBuffer: + return Hashing.GetSemanticHash( + "physical/v1", + "fixed-buffer", + fixedBuffer.Length.ToString(InvariantCulture), + HashPhysicalLayout(fixedBuffer.Element).ToHex()); + case FinalStructPhysicalPlan structure: + { + var parts = new List + { + "physical/v1", + "struct", + structure.LayoutKind.ToString(), + structure.Pack.ToString(InvariantCulture), + structure.Size.ToString(InvariantCulture), + structure.InlineArrayLength?.ToString(InvariantCulture) ?? string.Empty, + structure.Fields.Length.ToString(InvariantCulture) + }; + foreach (var field in structure.Fields) + { + parts.Add(field.Offset?.ToString(InvariantCulture) ?? "sequential"); + parts.Add(HashPhysicalLayout(field.Layout).ToHex()); + } + return Hashing.GetSemanticHash(parts.ToArray()); + } + default: + throw new InvalidOperationException( + $"Unknown resolved physical plan '{plan.GetType().Name}'."); } - - builder.Append(Convert.ToString(constant.Value, InvariantCulture) ?? "null"); } } } From 0c42b2a39cc12542dc393884e208a5be9ffac883 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:30:54 +0800 Subject: [PATCH 250/399] refactor: drive UnsafeBlit guidance from final codec plans --- ...rator.UnsafeBlitCompatibilityDiagnostic.cs | 174 ++---------------- 1 file changed, 19 insertions(+), 155 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs index 25163da3c..ab8306342 100644 --- a/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs +++ b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs @@ -1,8 +1,8 @@ namespace SharpLink.Generator; /// -/// Reports non-blocking guidance for RPC payloads that ultimately use implicit UnsafeBlit over -/// source-defined AutoLayout value types. +/// Reports non-blocking guidance for RPC payloads whose resolved final Codec graph contains +/// implicit UnsafeBlit over source-defined AutoLayout value types. /// [Generator] public sealed class UnsafeBlitCompatibilityDiagnosticGenerator : IIncrementalGenerator @@ -23,170 +23,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context) public partial class RpcGenerator { - private const int AutoLayoutKindValue = 3; - private static readonly DiagnosticDescriptor ImplicitUnsafeBlitAutoLayoutRule = new( id: "SHARPLINK064", title: "Implicit UnsafeBlit Contains Source-Defined AutoLayout", - messageFormat: "RPC payload '{0}' uses implicit UnsafeBlit, and its recursive unmanaged field graph contains source-defined AutoLayout type '{1}' at '{2}'. Raw-memory wire layout can vary across runtimes; for stable cross-runtime raw wire prefer LayoutKind.Sequential or LayoutKind.Explicit, or bind an explicit custom/adapter codec.", + messageFormat: "RPC payload '{0}' resolves through implicit UnsafeBlit, and the resolved physical graph contains source-defined AutoLayout type '{1}' at '{2}'. Raw-memory wire layout can vary across runtimes; for stable cross-runtime raw wire prefer LayoutKind.Sequential or LayoutKind.Explicit, or bind an explicit custom/adapter codec.", category: "SharpLink.Generator", defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true, - description: "Source-defined AutoLayout inside an implicit UnsafeBlit payload can make raw-memory wire layout runtime-dependent. This diagnostic is advisory and does not change Codec selection or generated wire behavior."); + description: "Source-defined AutoLayout inside a resolved implicit UnsafeBlit plan can make raw-memory wire layout runtime-dependent. This diagnostic is advisory and does not change Codec selection or generated wire behavior."); internal static ImmutableArray AnalyzeUnsafeBlitAutoLayoutDiagnostics( Compilation compilation, CancellationToken cancellationToken) { - var codecAnalysis = AnalyzeGeneratedCodecsWithPolicyOwnership(compilation, cancellationToken); - var finalCodecBoundTypes = new HashSet( - codecAnalysis.FinalCodecBoundTypes, - StringComparer.Ordinal); - var payloadRoots = new Dictionary(StringComparer.Ordinal); - CollectCurrentContractPayloadRoots(compilation.Assembly.GlobalNamespace, payloadRoots); - - var diagnostics = ImmutableArray.CreateBuilder(); - foreach (var pair in payloadRoots.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) - { - cancellationToken.ThrowIfCancellationRequested(); - var payload = pair.Value; - if (!payload.IsUnmanagedType || finalCodecBoundTypes.Contains(pair.Key)) - continue; - - foreach (var hazard in FindSourceAutoLayoutHazards(payload, compilation.Assembly, cancellationToken)) - { - diagnostics.Add(Diagnostic.Create( - ImplicitUnsafeBlitAutoLayoutRule, - hazard.Location, - pair.Key, - hazard.TypeName, - hazard.FieldPath)); - } - } - - return diagnostics.ToImmutable(); - } - - private static void CollectCurrentContractPayloadRoots( - INamespaceSymbol namespaceSymbol, - Dictionary roots) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - CollectCurrentContractPayloadRoots(type, roots); - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - CollectCurrentContractPayloadRoots(nestedNamespace, roots); - } - - private static void CollectCurrentContractPayloadRoots( - INamedTypeSymbol type, - Dictionary roots) - { - if (type.TypeKind == TypeKind.Interface && HasRpcContractAttribute(type)) - { - foreach (var method in GetContractMethods(type)) - { - foreach (var parameter in method.Parameters) - { - if (IsCancellationTokenParameter(parameter)) - continue; - if (IsAsyncEnumerable(parameter.Type, out var streamItem)) - AddUnsafeBlitPayloadRoot(roots, streamItem!); - else - AddUnsafeBlitPayloadRoot(roots, parameter.Type); - } - - if (IsAsyncEnumerable(method.ReturnType, out var returnStreamItem)) - { - AddUnsafeBlitPayloadRoot(roots, returnStreamItem!); - } - else if (method.ReturnType is INamedTypeSymbol { IsGenericType: true } taskLike && - taskLike.TypeArguments.Length == 1) - { - AddUnsafeBlitPayloadRoot(roots, taskLike.TypeArguments[0]); - } - } - } - - foreach (var nested in type.GetTypeMembers()) - CollectCurrentContractPayloadRoots(nested, roots); - } - - private static void AddUnsafeBlitPayloadRoot( - Dictionary roots, - ITypeSymbol type) - { - var typeName = GetTypeName(type); - if (!roots.ContainsKey(typeName)) - roots.Add(typeName, type); - } - - private static ImmutableArray FindSourceAutoLayoutHazards( - ITypeSymbol root, - IAssemblySymbol sourceAssembly, - CancellationToken cancellationToken) - { - var hazards = ImmutableArray.CreateBuilder(); - var visited = new HashSet(SymbolEqualityComparer.Default); - Visit(root, GetTypeName(root)); - return hazards - .OrderBy(static item => item.TypeName, StringComparer.Ordinal) - .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) + var state = new DtoAnalysisState( + compilation, + cancellationToken, + contractMode: true, + applyCodecPolicy: true, + selectorOnlyContractDefault: false); + _ = state.AnalyzeWithFinalCodecBindings(); + + return state.BuildUnsafeBlitAutoLayoutDiagnostics() + .Select(static item => Diagnostic.Create( + ImplicitUnsafeBlitAutoLayoutRule, + item.Location, + item.PayloadType, + item.TypeName, + item.FieldPath)) .ToImmutableArray(); - - void Visit(ITypeSymbol type, string fieldPath) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!type.IsUnmanagedType || !visited.Add(type) || type is not INamedTypeSymbol named) - return; - - if (SymbolEqualityComparer.Default.Equals(named.ContainingAssembly, sourceAssembly) && - HasExplicitAutoLayout(named)) - { - var location = named.Locations.FirstOrDefault(static item => item.IsInSource) - ?? root.Locations.FirstOrDefault(static item => item.IsInSource) - ?? Location.None; - hazards.Add(new UnsafeBlitAutoLayoutHazard( - GetTypeName(named), - fieldPath, - location)); - } - - foreach (var field in named.GetMembers().OfType() - .Where(static field => !field.IsStatic && !field.IsConst) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - if (field.Type.IsUnmanagedType) - Visit(field.Type, fieldPath + "." + field.Name); - } - } - } - - private static bool HasExplicitAutoLayout(INamedTypeSymbol type) - { - foreach (var attribute in type.GetAttributes()) - { - if (attribute.AttributeClass is not { Name: "StructLayoutAttribute" } attributeClass || - !string.Equals( - attributeClass.ContainingNamespace.ToDisplayString(), - "System.Runtime.InteropServices", - StringComparison.Ordinal) || - attribute.ConstructorArguments.Length == 0) - { - continue; - } - - if (attribute.ConstructorArguments[0].Value is int layoutKind && - layoutKind == AutoLayoutKindValue) - { - return true; - } - } - - return false; } - - private readonly record struct UnsafeBlitAutoLayoutHazard( - string TypeName, - string FieldPath, - Location Location); } From 8a0dde2cfd026e3f8a3bcfbf680b2978cc74cdf1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:33:16 +0800 Subject: [PATCH 251/399] refactor: split final codec plan IR models --- .../RpcGenerator.FinalCodecPlan.Models.cs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs new file mode 100644 index 000000000..0169ba307 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs @@ -0,0 +1,158 @@ +namespace SharpLink.Generator; + +internal enum FinalCodecPlanKind +{ + Primitive, + Enum, + GeneratedDto, + Collection, + UnsafeBlit, + Custom, + Adapter, + Referenced +} + +internal enum FinalCollectionWireStrategy +{ + ChildCodec, + RawBlit, + DateTimeOffsetCanonical +} + +internal enum FinalEffectiveLayoutKind +{ + Sequential, + Explicit, + Auto +} + +internal sealed record FinalUnsafeBlitAbiPlan( + string Endianness, + int NativePointerWidth, + string Version); + +internal abstract record FinalCodecPlan(string TypeName, FinalCodecPlanKind Kind); + +internal sealed record FinalPrimitiveCodecPlan( + string TypeName, + string Family, + ImmutableArray SemanticParts, + string? ChildType = null) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Primitive); + +internal sealed record FinalEnumCodecPlan( + string TypeName, + string UnderlyingType, + string DeclarationSemantic) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Enum); + +internal enum FinalDtoMemberWireStrategy +{ + String, + Fixed, + ChildCodec +} + +internal sealed record FinalDtoMemberPlan( + uint FieldId, + GeneratedMemberKind Kind, + bool Required, + bool Nullable, + bool NonNullableReference, + FinalDtoMemberWireStrategy WireStrategy, + string? WireSemantic, + string? ChildType); + +internal sealed record FinalGeneratedDtoCodecPlan( + string TypeName, + bool IsReferenceType, + ImmutableArray Members) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.GeneratedDto); + +internal sealed record FinalCollectionCodecPlan( + string TypeName, + GeneratedCodecKind CollectionKind, + FinalCollectionWireStrategy WireStrategy, + string? ElementType, + string? KeyType, + string? ValueType, + FinalPhysicalLayoutPlan? RawElementLayout, + string? StrategySemantic) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Collection); + +internal sealed record FinalUnsafeBlitCodecPlan( + string TypeName, + FinalUnsafeBlitAbiPlan Abi, + FinalPhysicalLayoutPlan Layout, + ImmutableArray AutoLayoutHazards) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.UnsafeBlit); + +internal sealed record FinalCustomCodecPlan( + string TypeName, + RpcHashValue OpaqueSemanticIdentity) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Custom); + +internal sealed record FinalAdapterCodecPlan( + string TypeName, + RpcHashValue OpaqueSemanticIdentity, + RpcHashValue ClosedTargetLogicalIdentity) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Adapter); + +internal sealed record FinalReferencedCodecPlan( + string TypeName, + RpcHashValue CodecHash) + : FinalCodecPlan(TypeName, FinalCodecPlanKind.Referenced); + +internal abstract record FinalPhysicalLayoutPlan; + +internal sealed record FinalPrimitivePhysicalPlan( + string Token, + string? FrameworkRawAbi = null) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalEnumPhysicalPlan( + FinalPhysicalLayoutPlan Underlying, + string DeclarationSemantic) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalPointerPhysicalPlan(string TargetLogicalIdentity) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalFunctionPointerPhysicalPlan(string SignatureSemantic) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalFixedBufferPhysicalPlan( + int Length, + FinalPhysicalLayoutPlan Element) + : FinalPhysicalLayoutPlan; + +internal sealed record FinalPhysicalFieldPlan( + int? Offset, + FinalPhysicalLayoutPlan Layout); + +internal sealed record FinalStructPhysicalPlan( + FinalEffectiveLayoutKind LayoutKind, + int Pack, + int Size, + int? InlineArrayLength, + ImmutableArray Fields) + : FinalPhysicalLayoutPlan; + +internal readonly record struct FinalCodecAutoLayoutHazardDescriptor( + string TypeName, + string FieldPath, + Location Location); + +internal readonly record struct FinalCodecAutoLayoutDiagnosticModel( + string PayloadType, + string TypeName, + string FieldPath, + Location Location); + +internal sealed class FinalCodecGraph( + IReadOnlyDictionary plans, + ImmutableArray rootTypes) +{ + internal IReadOnlyDictionary Plans { get; } = plans; + internal ImmutableArray RootTypes { get; } = rootTypes; +} From 2c7230c4cf11f568ba05f6f28d4e5172d5dd383d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:33:51 +0800 Subject: [PATCH 252/399] refactor: split final codec physical layout resolver --- .../RpcGenerator.FinalCodecPlan.Physical.cs | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs new file mode 100644 index 000000000..37f7dc171 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs @@ -0,0 +1,318 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private sealed partial class DtoAnalysisState + { + private FinalUnsafeBlitCodecPlan ResolveUnsafeBlitCodecPlan(ITypeSymbol type) + { + var hazards = ImmutableArray.CreateBuilder(); + var typeName = GetTypeName(type); + var layout = ResolvePhysicalLayout(type, typeName, collectAutoLayoutHazards: true, hazards); + return new FinalUnsafeBlitCodecPlan( + typeName, + UnsafeBlitAbi, + layout, + hazards + .OrderBy(static item => item.TypeName, StringComparer.Ordinal) + .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) + .ToImmutableArray()); + } + + private FinalPhysicalLayoutPlan ResolvePhysicalLayout( + ITypeSymbol type, + string fieldPath, + bool collectAutoLayoutHazards, + ImmutableArray.Builder? hazards, + HashSet? stack = null) + { + if (TryGetPhysicalPrimitive(type, out var primitive)) + return primitive; + + if (type.TypeKind == TypeKind.Enum && + type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) + { + return new FinalEnumPhysicalPlan( + ResolvePhysicalLayout(underlying, fieldPath, false, null, stack), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + if (type is IPointerTypeSymbol pointer) + { + var parts = new List { "pointer-target/v1" }; + AppendClosedTargetLogicalIdentity(pointer.PointedAtType, parts); + return new FinalPointerPhysicalPlan(Hashing.GetSemanticHash(parts.ToArray()).ToHex()); + } + + if (type is IFunctionPointerTypeSymbol functionPointer) + return new FinalFunctionPointerPhysicalPlan(GetFunctionPointerSemanticIdentity(functionPointer)); + + if (type is not INamedTypeSymbol named) + throw new InvalidOperationException($"Unsupported unmanaged physical type '{GetTypeName(type)}'."); + + stack ??= new HashSet(SymbolEqualityComparer.Default); + if (!stack.Add(type)) + throw new InvalidOperationException($"Recursive unmanaged physical layout '{GetTypeName(type)}'."); + + var effective = GetEffectiveStructLayout(named); + if (collectAutoLayoutHazards && + effective.Kind == FinalEffectiveLayoutKind.Auto && + SymbolEqualityComparer.Default.Equals(named.ContainingAssembly, _compilation.Assembly)) + { + var location = named.Locations.FirstOrDefault(static item => item.IsInSource) ?? Location.None; + if (location != Location.None) + { + hazards?.Add(new FinalCodecAutoLayoutHazardDescriptor( + GetTypeName(named), + fieldPath, + location)); + } + } + + var fields = ImmutableArray.CreateBuilder(); + foreach (var field in named.GetMembers().OfType() + .Where(static item => !item.IsStatic && !item.IsConst)) + { + FinalPhysicalLayoutPlan fieldLayout; + if (field.IsFixedSizeBuffer && TryGetFixedBufferElement(field, out var fixedElement)) + { + fieldLayout = new FinalFixedBufferPhysicalPlan( + field.FixedSize, + ResolvePhysicalLayout(fixedElement, fieldPath + "." + field.Name, false, null, stack)); + } + else + { + fieldLayout = ResolvePhysicalLayout( + field.Type, + fieldPath + "." + field.Name, + collectAutoLayoutHazards, + hazards, + stack); + } + + var offset = effective.Kind == FinalEffectiveLayoutKind.Explicit + ? GetFieldOffset(field) + : null; + fields.Add(new FinalPhysicalFieldPlan(offset, fieldLayout)); + } + + stack.Remove(type); + var canonicalFields = fields.ToArray(); + if (effective.Kind == FinalEffectiveLayoutKind.Explicit) + { + Array.Sort(canonicalFields, static (left, right) => + { + var byOffset = Nullable.Compare(left.Offset, right.Offset); + if (byOffset != 0) + return byOffset; + return StringComparer.Ordinal.Compare( + GetPhysicalPlanSortKey(left.Layout), + GetPhysicalPlanSortKey(right.Layout)); + }); + } + + return new FinalStructPhysicalPlan( + effective.Kind, + effective.Pack, + effective.Size, + GetInlineArrayLength(named), + canonicalFields.ToImmutableArray()); + } + + private static (FinalEffectiveLayoutKind Kind, int Pack, int Size) GetEffectiveStructLayout( + INamedTypeSymbol type) + { + var kind = FinalEffectiveLayoutKind.Sequential; + var pack = 0; + var size = 0; + var attribute = type.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.InteropServices.StructLayoutAttribute", + StringComparison.Ordinal)); + if (attribute is null) + return (kind, pack, size); + + if (attribute.ConstructorArguments.Length != 0 && + attribute.ConstructorArguments[0].Value is int layoutKind) + { + kind = layoutKind switch + { + 2 => FinalEffectiveLayoutKind.Explicit, + 3 => FinalEffectiveLayoutKind.Auto, + _ => FinalEffectiveLayoutKind.Sequential + }; + } + foreach (var argument in attribute.NamedArguments) + { + if (argument.Value.Value is not int value) + continue; + if (string.Equals(argument.Key, "Pack", StringComparison.Ordinal)) + pack = value; + else if (string.Equals(argument.Key, "Size", StringComparison.Ordinal)) + size = value; + } + return (kind, pack, size); + } + + private static int? GetInlineArrayLength(INamedTypeSymbol type) + { + var attribute = type.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.CompilerServices.InlineArrayAttribute", + StringComparison.Ordinal)); + return attribute is { ConstructorArguments.Length: 1 } && + attribute.ConstructorArguments[0].Value is int length + ? length + : null; + } + + private static int GetFieldOffset(IFieldSymbol field) + { + var attribute = field.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.InteropServices.FieldOffsetAttribute", + StringComparison.Ordinal)); + return attribute is { ConstructorArguments.Length: 1 } && + attribute.ConstructorArguments[0].Value is int offset + ? offset + : 0; + } + + private static bool TryGetFixedBufferElement(IFieldSymbol field, out ITypeSymbol elementType) + { + var attribute = field.GetAttributes().FirstOrDefault(static item => + string.Equals( + item.AttributeClass?.ToDisplayString(), + "System.Runtime.CompilerServices.FixedBufferAttribute", + StringComparison.Ordinal)); + if (attribute is { ConstructorArguments.Length: >= 1 } && + attribute.ConstructorArguments[0].Value is ITypeSymbol type) + { + elementType = type; + return true; + } + elementType = null!; + return false; + } + + private static bool TryGetPhysicalPrimitive( + ITypeSymbol type, + out FinalPrimitivePhysicalPlan primitive) + { + string? token = type.SpecialType switch + { + SpecialType.System_Boolean => "bool1", + SpecialType.System_Byte => "u8", + SpecialType.System_SByte => "i8", + SpecialType.System_Int16 => "i16", + SpecialType.System_UInt16 => "u16", + SpecialType.System_Char => "char16", + SpecialType.System_Int32 => "i32", + SpecialType.System_UInt32 => "u32", + SpecialType.System_Single => "f32", + SpecialType.System_Int64 => "i64", + SpecialType.System_UInt64 => "u64", + SpecialType.System_IntPtr => "native-pointer-width/64:intptr", + SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr", + SpecialType.System_Double => "f64", + SpecialType.System_Decimal => "decimal128", + _ => null + }; + string? frameworkRawAbi = null; + if (token is null) + { + token = type.ToDisplayString() switch + { + "System.Half" => "half16", + "System.Text.Rune" => "rune32", + "System.Guid" => "guid128", + "System.DateTimeOffset" => "datetimeoffset128", + "System.DateTime" => "datetime64", + "System.DateOnly" => "dateonly32", + "System.TimeOnly" => "timeonly64", + "System.TimeSpan" => "timespan64", + "System.Int128" => "i128", + "System.UInt128" => "u128", + "System.Index" => "index32", + "System.Range" => "range64", + _ => null + }; + if (string.Equals(type.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + frameworkRawAbi = "framework-raw/datetimeoffset/native16/release-scoped/v1"; + } + if (token is null) + { + primitive = null!; + return false; + } + primitive = new FinalPrimitivePhysicalPlan(token, frameworkRawAbi); + return true; + } + + private static string GetFunctionPointerSemanticIdentity(IFunctionPointerTypeSymbol pointer) + { + var signature = pointer.Signature; + var parts = new List + { + "function-pointer/v2", + signature.CallingConvention.ToString(), + signature.RefKind.ToString() + }; + foreach (var convention in signature.UnmanagedCallingConventionTypes + .OrderBy(static item => item.ToDisplayString(), StringComparer.Ordinal)) + { + parts.Add(convention.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + AppendClosedTargetLogicalIdentity(signature.ReturnType, parts); + parts.Add(signature.Parameters.Length.ToString(InvariantCulture)); + foreach (var parameter in signature.Parameters) + { + parts.Add(parameter.RefKind.ToString()); + AppendClosedTargetLogicalIdentity(parameter.Type, parts); + } + return Hashing.GetSemanticHash(parts.ToArray()).ToHex(); + } + + private static string GetPhysicalPlanSortKey(FinalPhysicalLayoutPlan plan) + { + var parts = new List(); + Append(plan, parts); + return string.Join("|", parts); + + static void Append(FinalPhysicalLayoutPlan current, List parts) + { + switch (current) + { + case FinalPrimitivePhysicalPlan primitive: + parts.Add("p:" + primitive.Token + ":" + primitive.FrameworkRawAbi); + return; + case FinalEnumPhysicalPlan enumPlan: + parts.Add("e:" + enumPlan.DeclarationSemantic); + Append(enumPlan.Underlying, parts); + return; + case FinalPointerPhysicalPlan pointer: + parts.Add("ptr:" + pointer.TargetLogicalIdentity); + return; + case FinalFunctionPointerPhysicalPlan functionPointer: + parts.Add("fn:" + functionPointer.SignatureSemantic); + return; + case FinalFixedBufferPhysicalPlan buffer: + parts.Add("buf:" + buffer.Length.ToString(InvariantCulture)); + Append(buffer.Element, parts); + return; + case FinalStructPhysicalPlan structure: + parts.Add($"s:{structure.LayoutKind}:{structure.Pack}:{structure.Size}:{structure.InlineArrayLength}"); + foreach (var field in structure.Fields) + { + parts.Add("o:" + (field.Offset?.ToString(InvariantCulture) ?? "seq")); + Append(field.Layout, parts); + } + return; + } + } + } + } +} From f758fc69fa1f64c754c8ba8d4e7041b5a2f6a5f6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:34:47 +0800 Subject: [PATCH 253/399] refactor: separate final codec selection from physical layout --- .../RpcGenerator.FinalCodecPlan.cs | 483 +----------------- 1 file changed, 6 insertions(+), 477 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs index d672e7304..8c4d58aae 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs @@ -1,162 +1,5 @@ namespace SharpLink.Generator; -internal enum FinalCodecPlanKind -{ - Primitive, - Enum, - GeneratedDto, - Collection, - UnsafeBlit, - Custom, - Adapter, - Referenced -} - -internal enum FinalCollectionWireStrategy -{ - ChildCodec, - RawBlit, - DateTimeOffsetCanonical -} - -internal enum FinalEffectiveLayoutKind -{ - Sequential, - Explicit, - Auto -} - -internal sealed record FinalUnsafeBlitAbiPlan( - string Endianness, - int NativePointerWidth, - string Version); - -internal abstract record FinalCodecPlan(string TypeName, FinalCodecPlanKind Kind); - -internal sealed record FinalPrimitiveCodecPlan( - string TypeName, - string Family, - ImmutableArray SemanticParts, - string? ChildType = null) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.Primitive); - -internal sealed record FinalEnumCodecPlan( - string TypeName, - string UnderlyingType, - string DeclarationSemantic) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.Enum); - -internal enum FinalDtoMemberWireStrategy -{ - String, - Fixed, - ChildCodec -} - -internal sealed record FinalDtoMemberPlan( - uint FieldId, - GeneratedMemberKind Kind, - bool Required, - bool Nullable, - bool NonNullableReference, - FinalDtoMemberWireStrategy WireStrategy, - string? WireSemantic, - string? ChildType); - -internal sealed record FinalGeneratedDtoCodecPlan( - string TypeName, - bool IsReferenceType, - ImmutableArray Members) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.GeneratedDto); - -internal sealed record FinalCollectionCodecPlan( - string TypeName, - GeneratedCodecKind CollectionKind, - FinalCollectionWireStrategy WireStrategy, - string? ElementType, - string? KeyType, - string? ValueType, - FinalPhysicalLayoutPlan? RawElementLayout, - string? StrategySemantic) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.Collection); - -internal sealed record FinalUnsafeBlitCodecPlan( - string TypeName, - FinalUnsafeBlitAbiPlan Abi, - FinalPhysicalLayoutPlan Layout, - ImmutableArray AutoLayoutHazards) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.UnsafeBlit); - -internal sealed record FinalCustomCodecPlan( - string TypeName, - RpcHashValue OpaqueSemanticIdentity) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.Custom); - -internal sealed record FinalAdapterCodecPlan( - string TypeName, - RpcHashValue OpaqueSemanticIdentity, - RpcHashValue ClosedTargetLogicalIdentity) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.Adapter); - -internal sealed record FinalReferencedCodecPlan( - string TypeName, - RpcHashValue CodecHash) - : FinalCodecPlan(TypeName, FinalCodecPlanKind.Referenced); - -internal abstract record FinalPhysicalLayoutPlan; - -internal sealed record FinalPrimitivePhysicalPlan( - string Token, - string? FrameworkRawAbi = null) - : FinalPhysicalLayoutPlan; - -internal sealed record FinalEnumPhysicalPlan( - FinalPhysicalLayoutPlan Underlying, - string DeclarationSemantic) - : FinalPhysicalLayoutPlan; - -internal sealed record FinalPointerPhysicalPlan(string TargetLogicalIdentity) - : FinalPhysicalLayoutPlan; - -internal sealed record FinalFunctionPointerPhysicalPlan(string SignatureSemantic) - : FinalPhysicalLayoutPlan; - -internal sealed record FinalFixedBufferPhysicalPlan( - int Length, - FinalPhysicalLayoutPlan Element) - : FinalPhysicalLayoutPlan; - -internal sealed record FinalPhysicalFieldPlan( - int? Offset, - FinalPhysicalLayoutPlan Layout); - -internal sealed record FinalStructPhysicalPlan( - FinalEffectiveLayoutKind LayoutKind, - int Pack, - int Size, - int? InlineArrayLength, - ImmutableArray Fields) - : FinalPhysicalLayoutPlan; - -internal readonly record struct FinalCodecAutoLayoutHazardDescriptor( - string TypeName, - string FieldPath, - Location Location); - -internal readonly record struct FinalCodecAutoLayoutDiagnosticModel( - string PayloadType, - string TypeName, - string FieldPath, - Location Location); - -internal sealed class FinalCodecGraph( - IReadOnlyDictionary plans, - ImmutableArray rootTypes) -{ - internal IReadOnlyDictionary Plans { get; } = plans; - internal ImmutableArray RootTypes { get; } = rootTypes; -} - public partial class RpcGenerator { private sealed partial class DtoAnalysisState @@ -186,8 +29,9 @@ internal FinalCodecGraph ResolveFinalCodecGraph( ResolveFinalCodecPlan(pair.Value, plans, resolving); } - // Candidate generation is intentionally allowed to discover factories before this pass. - // Final selection is not: every emitted factory must be represented by the resolved graph. + // Candidate analysis can discover factories before this pass, but final Codec selection + // is represented only by the resolved plan graph. Every emitted factory must therefore + // have a corresponding plan before hashes/metadata are produced. foreach (var model in _models.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) { if (_failed.Contains(model.TypeName) || plans.ContainsKey(model.TypeName)) @@ -292,17 +136,14 @@ private FinalCodecPlan ResolveFinalCodecPlan( } else if (TryGetFrameworkScalarSemantic(type, out var scalarSemantic)) { - plan = new FinalPrimitiveCodecPlan( - typeName, - "framework", - scalarSemantic); + plan = new FinalPrimitiveCodecPlan(typeName, "framework", scalarSemantic); } else if (TryGetCollection( type, out var collectionKind, out var elementType, - out var keyType, - out var valueType)) + out _, + out _)) { if (collectionKind == GeneratedCodecKind.Nullable && elementType is not null && @@ -517,318 +358,6 @@ GeneratedCodecKind.ReadOnlyMemory or return true; } - private FinalUnsafeBlitCodecPlan ResolveUnsafeBlitCodecPlan(ITypeSymbol type) - { - var hazards = ImmutableArray.CreateBuilder(); - var typeName = GetTypeName(type); - var layout = ResolvePhysicalLayout(type, typeName, collectAutoLayoutHazards: true, hazards); - return new FinalUnsafeBlitCodecPlan( - typeName, - UnsafeBlitAbi, - layout, - hazards - .OrderBy(static item => item.TypeName, StringComparer.Ordinal) - .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) - .ToImmutableArray()); - } - - private FinalPhysicalLayoutPlan ResolvePhysicalLayout( - ITypeSymbol type, - string fieldPath, - bool collectAutoLayoutHazards, - ImmutableArray.Builder? hazards, - HashSet? stack = null) - { - if (TryGetPhysicalPrimitive(type, out var primitive)) - return primitive; - - if (type.TypeKind == TypeKind.Enum && - type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) - { - return new FinalEnumPhysicalPlan( - ResolvePhysicalLayout(underlying, fieldPath, false, null, stack), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - if (type is IPointerTypeSymbol pointer) - { - var parts = new List { "pointer-target/v1" }; - AppendClosedTargetLogicalIdentity(pointer.PointedAtType, parts); - return new FinalPointerPhysicalPlan(Hashing.GetSemanticHash(parts.ToArray()).ToHex()); - } - - if (type is IFunctionPointerTypeSymbol functionPointer) - return new FinalFunctionPointerPhysicalPlan(GetFunctionPointerSemanticIdentity(functionPointer)); - - if (type is not INamedTypeSymbol named) - throw new InvalidOperationException($"Unsupported unmanaged physical type '{GetTypeName(type)}'."); - - stack ??= new HashSet(SymbolEqualityComparer.Default); - if (!stack.Add(type)) - throw new InvalidOperationException($"Recursive unmanaged physical layout '{GetTypeName(type)}'."); - - var effective = GetEffectiveStructLayout(named); - if (collectAutoLayoutHazards && - effective.Kind == FinalEffectiveLayoutKind.Auto && - SymbolEqualityComparer.Default.Equals(named.ContainingAssembly, _compilation.Assembly)) - { - var location = named.Locations.FirstOrDefault(static item => item.IsInSource) - ?? Location.None; - if (location != Location.None) - { - hazards?.Add(new FinalCodecAutoLayoutHazardDescriptor( - GetTypeName(named), - fieldPath, - location)); - } - } - - var fields = ImmutableArray.CreateBuilder(); - foreach (var field in named.GetMembers().OfType() - .Where(static item => !item.IsStatic && !item.IsConst)) - { - FinalPhysicalLayoutPlan fieldLayout; - if (field.IsFixedSizeBuffer && TryGetFixedBufferElement(field, out var fixedElement)) - { - fieldLayout = new FinalFixedBufferPhysicalPlan( - field.FixedSize, - ResolvePhysicalLayout(fixedElement, fieldPath + "." + field.Name, false, null, stack)); - } - else - { - fieldLayout = ResolvePhysicalLayout( - field.Type, - fieldPath + "." + field.Name, - collectAutoLayoutHazards, - hazards, - stack); - } - - var offset = effective.Kind == FinalEffectiveLayoutKind.Explicit - ? GetFieldOffset(field) - : null; - fields.Add(new FinalPhysicalFieldPlan(offset, fieldLayout)); - } - - stack.Remove(type); - var canonicalFields = fields.ToArray(); - if (effective.Kind == FinalEffectiveLayoutKind.Explicit) - { - Array.Sort(canonicalFields, static (left, right) => - { - var byOffset = Nullable.Compare(left.Offset, right.Offset); - if (byOffset != 0) - return byOffset; - return StringComparer.Ordinal.Compare( - GetPhysicalPlanSortKey(left.Layout), - GetPhysicalPlanSortKey(right.Layout)); - }); - } - - return new FinalStructPhysicalPlan( - effective.Kind, - effective.Pack, - effective.Size, - GetInlineArrayLength(named), - canonicalFields.ToImmutableArray()); - } - - private static (FinalEffectiveLayoutKind Kind, int Pack, int Size) GetEffectiveStructLayout( - INamedTypeSymbol type) - { - var kind = FinalEffectiveLayoutKind.Sequential; - var pack = 0; - var size = 0; - var attribute = type.GetAttributes().FirstOrDefault(static item => - string.Equals( - item.AttributeClass?.ToDisplayString(), - "System.Runtime.InteropServices.StructLayoutAttribute", - StringComparison.Ordinal)); - if (attribute is null) - return (kind, pack, size); - - if (attribute.ConstructorArguments.Length != 0 && - attribute.ConstructorArguments[0].Value is int layoutKind) - { - kind = layoutKind switch - { - 2 => FinalEffectiveLayoutKind.Explicit, - 3 => FinalEffectiveLayoutKind.Auto, - _ => FinalEffectiveLayoutKind.Sequential - }; - } - foreach (var argument in attribute.NamedArguments) - { - if (argument.Value.Value is not int value) - continue; - if (string.Equals(argument.Key, "Pack", StringComparison.Ordinal)) - pack = value; - else if (string.Equals(argument.Key, "Size", StringComparison.Ordinal)) - size = value; - } - return (kind, pack, size); - } - - private static int? GetInlineArrayLength(INamedTypeSymbol type) - { - var attribute = type.GetAttributes().FirstOrDefault(static item => - string.Equals( - item.AttributeClass?.ToDisplayString(), - "System.Runtime.CompilerServices.InlineArrayAttribute", - StringComparison.Ordinal)); - return attribute is { ConstructorArguments.Length: 1 } && - attribute.ConstructorArguments[0].Value is int length - ? length - : null; - } - - private static int GetFieldOffset(IFieldSymbol field) - { - var attribute = field.GetAttributes().FirstOrDefault(static item => - string.Equals( - item.AttributeClass?.ToDisplayString(), - "System.Runtime.InteropServices.FieldOffsetAttribute", - StringComparison.Ordinal)); - return attribute is { ConstructorArguments.Length: 1 } && - attribute.ConstructorArguments[0].Value is int offset - ? offset - : 0; - } - - private static bool TryGetFixedBufferElement(IFieldSymbol field, out ITypeSymbol elementType) - { - var attribute = field.GetAttributes().FirstOrDefault(static item => - string.Equals( - item.AttributeClass?.ToDisplayString(), - "System.Runtime.CompilerServices.FixedBufferAttribute", - StringComparison.Ordinal)); - if (attribute is { ConstructorArguments.Length: >= 1 } && - attribute.ConstructorArguments[0].Value is ITypeSymbol type) - { - elementType = type; - return true; - } - elementType = null!; - return false; - } - - private static bool TryGetPhysicalPrimitive( - ITypeSymbol type, - out FinalPrimitivePhysicalPlan primitive) - { - string? token = type.SpecialType switch - { - SpecialType.System_Boolean => "bool1", - SpecialType.System_Byte => "u8", - SpecialType.System_SByte => "i8", - SpecialType.System_Int16 => "i16", - SpecialType.System_UInt16 => "u16", - SpecialType.System_Char => "char16", - SpecialType.System_Int32 => "i32", - SpecialType.System_UInt32 => "u32", - SpecialType.System_Single => "f32", - SpecialType.System_Int64 => "i64", - SpecialType.System_UInt64 => "u64", - SpecialType.System_IntPtr => "native-pointer-width/64:intptr", - SpecialType.System_UIntPtr => "native-pointer-width/64:uintptr", - SpecialType.System_Double => "f64", - SpecialType.System_Decimal => "decimal128", - _ => null - }; - string? frameworkRawAbi = null; - if (token is null) - { - token = type.ToDisplayString() switch - { - "System.Half" => "half16", - "System.Text.Rune" => "rune32", - "System.Guid" => "guid128", - "System.DateTimeOffset" => "datetimeoffset128", - "System.DateTime" => "datetime64", - "System.DateOnly" => "dateonly32", - "System.TimeOnly" => "timeonly64", - "System.TimeSpan" => "timespan64", - "System.Int128" => "i128", - "System.UInt128" => "u128", - "System.Index" => "index32", - "System.Range" => "range64", - _ => null - }; - if (string.Equals(type.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) - frameworkRawAbi = "framework-raw/datetimeoffset/native16/release-scoped/v1"; - } - if (token is null) - { - primitive = null!; - return false; - } - primitive = new FinalPrimitivePhysicalPlan(token, frameworkRawAbi); - return true; - } - - private static string GetFunctionPointerSemanticIdentity(IFunctionPointerTypeSymbol pointer) - { - var signature = pointer.Signature; - var parts = new List - { - "function-pointer/v2", - signature.CallingConvention.ToString(), - signature.RefKind.ToString() - }; - foreach (var convention in signature.UnmanagedCallingConventionTypes - .OrderBy(static item => item.ToDisplayString(), StringComparer.Ordinal)) - { - parts.Add(convention.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); - } - AppendClosedTargetLogicalIdentity(signature.ReturnType, parts); - parts.Add(signature.Parameters.Length.ToString(InvariantCulture)); - foreach (var parameter in signature.Parameters) - { - parts.Add(parameter.RefKind.ToString()); - AppendClosedTargetLogicalIdentity(parameter.Type, parts); - } - return Hashing.GetSemanticHash(parts.ToArray()).ToHex(); - } - - private static string GetPhysicalPlanSortKey(FinalPhysicalLayoutPlan plan) - { - var parts = new List(); - Append(plan, parts); - return string.Join("|", parts); - - static void Append(FinalPhysicalLayoutPlan current, List parts) - { - switch (current) - { - case FinalPrimitivePhysicalPlan primitive: - parts.Add("p:" + primitive.Token + ":" + primitive.FrameworkRawAbi); - return; - case FinalEnumPhysicalPlan enumPlan: - parts.Add("e:" + enumPlan.DeclarationSemantic); - Append(enumPlan.Underlying, parts); - return; - case FinalPointerPhysicalPlan pointer: - parts.Add("ptr:" + pointer.TargetLogicalIdentity); - return; - case FinalFunctionPointerPhysicalPlan functionPointer: - parts.Add("fn:" + functionPointer.SignatureSemantic); - return; - case FinalFixedBufferPhysicalPlan buffer: - parts.Add("buf:" + buffer.Length.ToString(InvariantCulture)); - Append(buffer.Element, parts); - return; - case FinalStructPhysicalPlan structure: - parts.Add($"s:{structure.LayoutKind}:{structure.Pack}:{structure.Size}:{structure.InlineArrayLength}"); - foreach (var field in structure.Fields) - { - parts.Add("o:" + (field.Offset?.ToString(InvariantCulture) ?? "seq")); - Append(field.Layout, parts); - } - return; - } - } - } - private string GetResolvedFixedMemberSemantic( GeneratedMemberModel member, ITypeSymbol? actualMemberType) From 7e67d55587904b83c315386421037284b731c1dc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:45:21 +0800 Subject: [PATCH 254/399] fix: type explicit layout offsets in codec plan --- src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs index 37f7dc171..81ccc464b 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs @@ -90,7 +90,7 @@ private FinalPhysicalLayoutPlan ResolvePhysicalLayout( stack); } - var offset = effective.Kind == FinalEffectiveLayoutKind.Explicit + int? offset = effective.Kind == FinalEffectiveLayoutKind.Explicit ? GetFieldOffset(field) : null; fields.Add(new FinalPhysicalFieldPlan(offset, fieldLayout)); From 144ca3be54a90da3cca4fe3f05197156b9075f4a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:03:11 +0800 Subject: [PATCH 255/399] refactor: bump deterministic codec generated ABI --- .../SharpLinkGeneratedAssemblyManifest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs b/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs index a7fd1b16e..78e960fde 100644 --- a/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs +++ b/src/SharpLink.Abstractions/SharpLinkGeneratedAssemblyManifest.cs @@ -147,7 +147,7 @@ public interface ISharpLinkGeneratedAssemblyManifest /// Gets contract-owned proxy and stub descriptors. IReadOnlyList Contracts { get; } - /// Gets service-owned activator descriptors. + /// Gets service-owned generated activator descriptors. IReadOnlyList Services { get; } /// Gets generated Codec factories owned by this assembly's normal/global graph. @@ -181,7 +181,7 @@ public static class SharpLinkGeneratedManifestVersions public const int Api = 4; /// Exact discriminator for the 2.0/API4 generated proxy/runtime ABI. - public const string AbiIdentity = "sharplink-2.0-api4-rpcchannel-codec-provider-v3"; + public const string AbiIdentity = "sharplink-2.0-api4-rpcchannel-codec-provider-v4"; /// The unchanged SharpLink wire protocol version. public const int Protocol = 2; From 94b6c233e3b9666d699c46b1c91ef72b1e04fc9b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:03:53 +0800 Subject: [PATCH 256/399] test: expose final codec plan generator failures --- .../RpcFinalCodecPlanArchitectureTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcFinalCodecPlanArchitectureTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcFinalCodecPlanArchitectureTests.cs b/test/SharpLink.Generator.Tests/RpcFinalCodecPlanArchitectureTests.cs new file mode 100644 index 000000000..b59339877 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcFinalCodecPlanArchitectureTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task FinalCodecPlanShouldResolveDirectEnumAndRawNullableWithoutGeneratorFailure() + { + var directEnum = BuildSource(""" +public enum DirectStatus : byte { Ok = 0, Error = 1 } + +[SharpLink.Sdk.RpcContract] +public interface IResolvedEnumContract : SharpLink.Sdk.IService +{ + ValueTask Echo(DirectStatus value, CancellationToken cancellationToken); +} +"""); + AssertResolvedManifest(directEnum, "direct enum"); + + var rawNullable = BuildSource(""" +public enum NullableStatus : int { Ok = 0, Error = 1 } + +[SharpLink.Sdk.RpcContract] +public interface IResolvedNullableContract : SharpLink.Sdk.IService +{ + ValueTask Echo(NullableStatus? value, CancellationToken cancellationToken); +} +"""); + AssertResolvedManifest(rawNullable, "raw Nullable"); + return Task.CompletedTask; + } + + private static void AssertResolvedManifest(string source, string scenario) + { + var diagnostics = RunGenerator(source); + var generated = RunGeneratorAndGetSources(source); + Ensure( + generated.Any(static text => text.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)), + $"FinalCodecPlan failed to produce a manifest for {scenario}. Generator diagnostics: {FormatDiagnostics(diagnostics)}"); + } +} From f6ef804cc9f1522a9ac81f79238cc1a78a71c203 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:04:39 +0800 Subject: [PATCH 257/399] refactor: stamp final codec plan ABI --- src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index f621c4bd7..ef062e9c5 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -2,7 +2,7 @@ namespace SharpLink.Generator; public partial class RpcGenerator { - private const string GeneratedAbiIdentity = "sharplink-2.0-api4-rpcchannel-codec-provider-v3"; + private const string GeneratedAbiIdentity = "sharplink-2.0-api4-rpcchannel-codec-provider-v4"; private static string GenerateAssemblyManifest( ImmutableArray interfaces, ImmutableArray services, From 9f517e1e2625a6fb079ac401ecf0abd86563f228 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:05:19 +0800 Subject: [PATCH 258/399] refactor: hash only pre-resolved codec graphs --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 7fc140189..458ea4004 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -4,11 +4,8 @@ public partial class RpcGenerator { private sealed partial class DtoAnalysisState { - internal ImmutableArray BuildFinalCodecHashes( - bool includeSerializable, - bool includeContracts) + internal ImmutableArray BuildFinalCodecHashes(FinalCodecGraph graph) { - var graph = ResolveFinalCodecGraph(includeSerializable, includeContracts); var cache = new Dictionary(StringComparer.Ordinal); return graph.Plans .OrderBy(static pair => pair.Key, StringComparer.Ordinal) From 7fbeb29bbfb84bfa73a79dbe0df75ccfbab4fd19 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:06:41 +0800 Subject: [PATCH 259/399] refactor: resolve final codec graphs once per analysis pass --- .../RpcGenerator.CodecPolicyOwnership.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 3f405e5ff..5962d8dee 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -13,9 +13,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var standalone = standaloneState.AnalyzeWithFinalCodecBindings(); - var standaloneHashes = standaloneState.BuildFinalCodecHashes( + var standaloneGraph = standaloneState.ResolveFinalCodecGraph( includeSerializable: true, includeContracts: false); + var standaloneHashes = standaloneState.BuildFinalCodecHashes(standaloneGraph); var standaloneCodecs = AttachCodecHashes(standalone.Codecs, standaloneHashes); var contractDefaultState = new DtoAnalysisState( @@ -25,9 +26,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: true); var contractDefault = contractDefaultState.AnalyzeWithFinalCodecBindings(); - var contractDefaultHashes = contractDefaultState.BuildFinalCodecHashes( + var contractDefaultGraph = contractDefaultState.ResolveFinalCodecGraph( includeSerializable: false, includeContracts: true); + var contractDefaultHashes = contractDefaultState.BuildFinalCodecHashes(contractDefaultGraph); var contractDefaultCodecs = AttachCodecHashes(contractDefault.Codecs, contractDefaultHashes); var contractPolicyState = new DtoAnalysisState( @@ -37,9 +39,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( applyCodecPolicy: true, selectorOnlyContractDefault: false); var contractPolicy = contractPolicyState.AnalyzeWithFinalCodecBindings(); - var codecHashes = contractPolicyState.BuildFinalCodecHashes( + var contractPolicyGraph = contractPolicyState.ResolveFinalCodecGraph( includeSerializable: false, includeContracts: true); + var codecHashes = contractPolicyState.BuildFinalCodecHashes(contractPolicyGraph); var contractPolicyCodecs = AttachCodecHashes(contractPolicy.Codecs, codecHashes); var currentContractTypes = contractPolicyState.GetCurrentContractReachableTypeNames(); From 2159b3363109fa2f4cfbdebb8773e99db226045f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:39:42 +0800 Subject: [PATCH 260/399] fix: retain reached enum codec metadata --- .../RpcGenerator.CodecIdentity.cs | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 458ea4004..53f8a219a 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -6,17 +6,58 @@ private sealed partial class DtoAnalysisState { internal ImmutableArray BuildFinalCodecHashes(FinalCodecGraph graph) { + var hashGraph = CreateHashMetadataGraph(graph); var cache = new Dictionary(StringComparer.Ordinal); - return graph.Plans + return hashGraph.Plans .OrderBy(static pair => pair.Key, StringComparer.Ordinal) .Select(pair => { - var hash = HashCanonicalPlan(pair.Value, graph, cache, new HashSet(StringComparer.Ordinal)); + var hash = HashCanonicalPlan(pair.Value, hashGraph, cache, new HashSet(StringComparer.Ordinal)); return new GeneratedCodecHashModel(pair.Key, hash.High, hash.Low); }) .ToImmutableArray(); } + private FinalCodecGraph CreateHashMetadataGraph(FinalCodecGraph graph) + { + if (_enums.Count == 0) + return graph; + + var plans = graph.Plans.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + foreach (var enumModel in _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + { + if (plans.ContainsKey(enumModel.TypeName)) + continue; + if (!TryResolveReachableType(enumModel.TypeName, out var type) || + type is not INamedTypeSymbol { TypeKind: TypeKind.Enum, EnumUnderlyingType: { } underlying } enumType) + { + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve reached enum metadata for '{enumModel.TypeName}'."); + } + + var underlyingType = GetTypeName(underlying); + if (!plans.ContainsKey(underlyingType)) + { + if (!TryGetFrameworkScalarSemantic(underlying, out var semantic)) + { + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve enum underlying Codec semantics for '{underlyingType}'."); + } + plans.Add(underlyingType, new FinalPrimitiveCodecPlan( + underlyingType, + "framework", + semantic)); + } + + plans.Add(enumModel.TypeName, new FinalEnumCodecPlan( + enumModel.TypeName, + underlyingType, + GetEnumDeclarationSemanticIdentity(enumType))); + } + + return new FinalCodecGraph(plans, graph.RootTypes); + } + private static RpcHashValue HashCanonicalPlan( FinalCodecPlan plan, FinalCodecGraph graph, From e3680b039b63db549f776e253024d15a562e3d84 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:51:14 +0800 Subject: [PATCH 261/399] fix: canonicalize DateTimeOffset collection wire --- src/SharpLink.Runtime/Codec/CodecHelpers.cs | 107 ++++++++++++++------ 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/CodecHelpers.cs b/src/SharpLink.Runtime/Codec/CodecHelpers.cs index 8732bc134..fc04e89c3 100644 --- a/src/SharpLink.Runtime/Codec/CodecHelpers.cs +++ b/src/SharpLink.Runtime/Codec/CodecHelpers.cs @@ -6,6 +6,7 @@ internal static class CodecHelpers { private const int Size = 4; private const int MaxStackBufferBytes = 1024; + private const int DateTimeOffsetCollectionElementSize = 16; [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void EnsureAvailable(in ReadOnlySequence buffer, long requiredBytes) @@ -175,6 +176,21 @@ public static DateTimeOffset CreateDateTimeOffset(long ticks, short offsetMinute } } + private static DateTimeOffset CreateDateTimeOffsetFromUtcTicks(long utcTicks, short offsetMinutes) + { + if ((ulong)utcTicks > (ulong)DateTime.MaxValue.Ticks || offsetMinutes is < -840 or > 840) + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "DateTimeOffset collection contains invalid UTC ticks or offset."); + + var offsetTicks = (long)offsetMinutes * TimeSpan.TicksPerMinute; + if (offsetTicks > 0 && utcTicks > DateTime.MaxValue.Ticks - offsetTicks || + offsetTicks < 0 && utcTicks < -offsetTicks) + { + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "DateTimeOffset collection contains a value outside the supported clock range."); + } + + return CreateDateTimeOffset(utcTicks + offsetTicks, offsetMinutes); + } + public static TimeOnly ValidateTimeOnly(TimeOnly value) { if ((ulong)value.Ticks >= TimeSpan.TicksPerDay) @@ -252,16 +268,6 @@ public static void ValidateBlitElements(ReadOnlySpan values) where T : unm _ = ValidateTimeOnly(typed[index]); return; } - if (typeof(T) == typeof(DateTimeOffset)) - ValidateDateTimeOffsetElements(MemoryMarshal.AsBytes(values)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void NormalizeDateTimeOffsetBlitPayload(Span payload) - { - const int size = 16; - for (var offset = 0; offset < payload.Length; offset += size) - payload.Slice(offset + sizeof(short), 6).Clear(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -271,34 +277,77 @@ public static void WriteDateTimeOffsetBlitPayload( { if (values.IsEmpty) return; - var source = MemoryMarshal.AsBytes(values); - EnsureSerializablePayloadLength(source.Length, nameof(values)); - var destination = writer.GetSpan(source.Length)[..source.Length]; - source.CopyTo(destination); - NormalizeDateTimeOffsetBlitPayload(destination); - writer.Advance(source.Length); + + var payloadBytes = checked(values.Length * DateTimeOffsetCollectionElementSize); + EnsureSerializablePayloadLength(payloadBytes, nameof(values)); + var destination = writer.GetSpan(payloadBytes)[..payloadBytes]; + for (var index = 0; index < values.Length; index++) + { + var value = values[index]; + var element = destination.Slice(index * DateTimeOffsetCollectionElementSize, DateTimeOffsetCollectionElementSize); + BinaryPrimitives.WriteInt16LittleEndian(element, checked((short)value.Offset.TotalMinutes)); + element.Slice(sizeof(short), 6).Clear(); + BinaryPrimitives.WriteInt64LittleEndian(element.Slice(sizeof(long)), value.UtcTicks); + } + writer.Advance(payloadBytes); } - private static void ValidateDateTimeOffsetElements(ReadOnlySpan payload) + public static DateTimeOffset[]? ReadDateTimeOffsetCollection(in ReadOnlySequence buffer) { - const int size = 16; - for (var offset = 0; offset < payload.Length; offset += size) + var length = ReadInt32(buffer); + if (length < -1) + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, $"Invalid collection length {length}."); + if (length <= 0) + { + EnsureExactSize(buffer, sizeof(int)); + return length == -1 ? null : []; + } + + int payloadBytes; + try + { + payloadBytes = checked(length * DateTimeOffsetCollectionElementSize); + } + catch (OverflowException ex) + { + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "Collection byte length overflowed.", ex); + } + if (payloadBytes > SharpLinkProtocolOptions.MaxMaxFramePayloadBytes - sizeof(int)) + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "Collection payload exceeds the protocol maximum."); + EnsureExactSize(buffer, (long)sizeof(int) + payloadBytes); + + var result = new DateTimeOffset[length]; + var payload = buffer.Slice(sizeof(int)); + Span temporary = stackalloc byte[DateTimeOffsetCollectionElementSize]; + for (var index = 0; index < length; index++) { - var element = payload[offset..]; - var offsetMinutes = Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(element)); - var utcTicks = Unsafe.ReadUnaligned(ref Unsafe.Add( - ref MemoryMarshal.GetReference(element), sizeof(long))); - if ((ulong)utcTicks > (ulong)DateTime.MaxValue.Ticks || offsetMinutes is < -840 or > 840) - throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "DateTimeOffset collection contains invalid UTC ticks or offset."); - var offsetTicks = (long)offsetMinutes * TimeSpan.TicksPerMinute; - if (offsetTicks > 0 && utcTicks > DateTime.MaxValue.Ticks - offsetTicks || - offsetTicks < 0 && utcTicks < -offsetTicks) + var encoded = payload.Slice((long)index * DateTimeOffsetCollectionElementSize, DateTimeOffsetCollectionElementSize); + ReadOnlySpan element; + if (encoded.FirstSpan.Length >= DateTimeOffsetCollectionElementSize) { - throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "DateTimeOffset collection contains a value outside the supported clock range."); + element = encoded.FirstSpan[..DateTimeOffsetCollectionElementSize]; } + else + { + encoded.CopyTo(temporary); + element = temporary; + } + + if (element.Slice(sizeof(short), 6).IndexOfAnyExcept((byte)0) >= 0) + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "DateTimeOffset collection contains non-canonical padding."); + + var offsetMinutes = BinaryPrimitives.ReadInt16LittleEndian(element); + var utcTicks = BinaryPrimitives.ReadInt64LittleEndian(element.Slice(sizeof(long))); + result[index] = CreateDateTimeOffsetFromUtcTicks(utcTicks, offsetMinutes); } + return result; } + public static DateTimeOffset[] ReadRequiredDateTimeOffsetCollection(in ReadOnlySequence buffer) + => ReadDateTimeOffsetCollection(buffer) ?? throw new SharpLinkException( + SharpLinkErrorCode.DataLoss, + "A non-nullable memory payload used the reserved null collection marker."); + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void WriteInt32(IBufferWriter writer, in int value) { From 152489b872c8367069be6e26866b3aedfee48b49 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:52:01 +0800 Subject: [PATCH 262/399] fix: decode DateTimeOffset collections canonically --- src/SharpLink.Runtime/Codec/StructCodec.cs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/StructCodec.cs b/src/SharpLink.Runtime/Codec/StructCodec.cs index 8a56da922..d54415ce6 100644 --- a/src/SharpLink.Runtime/Codec/StructCodec.cs +++ b/src/SharpLink.Runtime/Codec/StructCodec.cs @@ -127,8 +127,7 @@ internal static T[] DeserializeRequired(in ReadOnlySequence buffer) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool RequiresSemanticValidation() => typeof(T) == typeof(bool) || typeof(T) == typeof(Rune) || typeof(T) == typeof(decimal) || - typeof(T) == typeof(DateOnly) || typeof(T) == typeof(DateTime) || typeof(T) == typeof(TimeOnly) || - typeof(T) == typeof(DateTimeOffset); + typeof(T) == typeof(DateOnly) || typeof(T) == typeof(DateTime) || typeof(T) == typeof(TimeOnly); } internal sealed class BlitListCodec : IRpcCodec?> where T : unmanaged @@ -189,8 +188,7 @@ public void Serialize(in List? value, IBufferWriter writer) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool RequiresSemanticValidation() => typeof(T) == typeof(bool) || typeof(T) == typeof(Rune) || typeof(T) == typeof(decimal) || - typeof(T) == typeof(DateOnly) || typeof(T) == typeof(DateTime) || typeof(T) == typeof(TimeOnly) || - typeof(T) == typeof(DateTimeOffset); + typeof(T) == typeof(DateOnly) || typeof(T) == typeof(DateTime) || typeof(T) == typeof(TimeOnly); } internal sealed class BlitMemoryCodec : IRpcCodec> where T : unmanaged @@ -310,7 +308,7 @@ public void Serialize(in DateTimeOffset[]? value, IBufferWriter writer) } public DateTimeOffset[]? Deserialize(in ReadOnlySequence buffer) - => BlitArrayCodec.Instance.Deserialize(buffer); + => CodecHelpers.ReadDateTimeOffsetCollection(buffer); } internal sealed class DateTimeOffsetListCodec : IRpcCodec?> @@ -325,7 +323,10 @@ public void Serialize(in List? value, IBufferWriter writer } public List? Deserialize(in ReadOnlySequence buffer) - => BlitListCodec.Instance.Deserialize(buffer); + { + var array = CodecHelpers.ReadDateTimeOffsetCollection(buffer); + return array is null ? null : [.. array]; + } } internal sealed class DateTimeOffsetMemoryCodec : IRpcCodec> @@ -339,7 +340,7 @@ public void Serialize(in Memory value, IBufferWriter write } public Memory Deserialize(in ReadOnlySequence buffer) - => BlitMemoryCodec.Instance.Deserialize(buffer); + => CodecHelpers.ReadRequiredDateTimeOffsetCollection(buffer).AsMemory(); } internal sealed class DateTimeOffsetReadOnlyMemoryCodec : IRpcCodec> @@ -353,7 +354,7 @@ public void Serialize(in ReadOnlyMemory value, IBufferWriter Deserialize(in ReadOnlySequence buffer) - => BlitReadOnlyMemoryCodec.Instance.Deserialize(buffer); + => CodecHelpers.ReadRequiredDateTimeOffsetCollection(buffer); } internal sealed class DateTimeOffsetImmutableArrayCodec : IRpcCodec> @@ -368,5 +369,8 @@ public void Serialize(in ImmutableArray value, IBufferWriter Deserialize(in ReadOnlySequence buffer) - => BlitImmutableArrayCodec.Instance.Deserialize(buffer); + { + var array = CodecHelpers.ReadDateTimeOffsetCollection(buffer); + return array is null ? default : ImmutableArray.Create(array); + } } From 7ca52c3282eebdbce785eaed286f83c1dc891209 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:52:53 +0800 Subject: [PATCH 263/399] test: pin canonical DateTimeOffset collection wire --- .../DateTimeOffsetCollectionCodecTests.cs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs diff --git a/test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs b/test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs new file mode 100644 index 000000000..400ed2cd7 --- /dev/null +++ b/test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs @@ -0,0 +1,115 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; + +namespace SharpLink.UnitTests.Runtime; + +public class DateTimeOffsetCollectionCodecTests +{ + private static IRpcCodecProvider Codecs => RpcSessionTestFixture.RuntimeContext.Codecs; + + [Test] + public void DateTimeOffsetCollectionsShouldShareOneLogicalCanonicalWire() + { + var value = new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8)); + var expected = CreateCanonicalPayload(value); + + Ensure(Serialize([value]).SequenceEqual(expected), "array canonical wire"); + Ensure(Serialize?>([value]).SequenceEqual(expected), "list canonical wire"); + Ensure(Serialize(new Memory([value])).SequenceEqual(expected), "memory canonical wire"); + Ensure(Serialize(new ReadOnlyMemory([value])).SequenceEqual(expected), "readonly memory canonical wire"); + Ensure(Serialize(ImmutableArray.Create(value)).SequenceEqual(expected), "immutable array canonical wire"); + } + + [Test] + public void DateTimeOffsetCollectionsShouldDecodeCanonicalWireAcrossSegments() + { + var expected = new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(-5)); + var payload = CreateCanonicalPayload(expected); + var segmented = CreateSegmentedSequence(payload); + + Ensure(Codecs.GetCodec().Deserialize(segmented) is { Length: 1 } array && array[0] == expected, + "segmented array canonical decode"); + Ensure(Codecs.GetCodec?>().Deserialize(segmented) is { Count: 1 } list && list[0] == expected, + "segmented list canonical decode"); + Ensure(Codecs.GetCodec>().Deserialize(segmented).Span[0] == expected, + "segmented memory canonical decode"); + Ensure(Codecs.GetCodec>().Deserialize(segmented).Span[0] == expected, + "segmented readonly memory canonical decode"); + Ensure(Codecs.GetCodec>().Deserialize(segmented)[0] == expected, + "segmented immutable array canonical decode"); + } + + [Test] + public void DateTimeOffsetCollectionsShouldRejectNonCanonicalPadding() + { + var payload = CreateCanonicalPayload(DateTimeOffset.UtcNow); + payload[sizeof(int) + sizeof(short)] = 0xA5; + + try + { + _ = Codecs.GetCodec().Deserialize(new ReadOnlySequence(payload)); + throw new Exception("expected DataLoss for non-canonical DateTimeOffset padding"); + } + catch (SharpLinkException ex) when (ex.Code == SharpLinkErrorCode.DataLoss) + { + } + } + + private static byte[] Serialize(in T value) + { + var writer = new ArrayBufferWriter(); + Codecs.GetCodec().Serialize(value, writer); + return writer.WrittenSpan.ToArray(); + } + + private static byte[] CreateCanonicalPayload(DateTimeOffset value) + { + var payload = new byte[sizeof(int) + 16]; + BinaryPrimitives.WriteInt32LittleEndian(payload, 1); + var element = payload.AsSpan(sizeof(int)); + BinaryPrimitives.WriteInt16LittleEndian(element, checked((short)value.Offset.TotalMinutes)); + element.Slice(sizeof(short), 6).Clear(); + BinaryPrimitives.WriteInt64LittleEndian(element.Slice(sizeof(long)), value.UtcTicks); + return payload; + } + + private static ReadOnlySequence CreateSegmentedSequence(byte[] bytes) + { + TestSequenceSegment? first = null; + TestSequenceSegment? last = null; + foreach (var value in bytes) + { + var segment = new TestSequenceSegment(new[] { value }); + if (first is null) + first = segment; + else + last!.Append(segment); + last = segment; + } + return new ReadOnlySequence(first!, 0, last!, last!.Memory.Length); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new Exception($"assert failed: {message}"); + } + + private sealed class TestSequenceSegment(ReadOnlyMemory memory) : ReadOnlySequenceSegment + { + public TestSequenceSegment() : this(ReadOnlyMemory.Empty) + { + } + + public TestSequenceSegment(ReadOnlyMemory memory, bool _) : this(memory) + { + } + + public void Append(TestSequenceSegment next) + { + Memory = memory; + next.RunningIndex = RunningIndex + Memory.Length; + Next = next; + } + } +} From b89570cf3f4f484813523e4a1242061c28d3eb2b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:53:25 +0800 Subject: [PATCH 264/399] test: fix segmented DateTimeOffset fixture --- .../Runtime/DateTimeOffsetCollectionCodecTests.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs b/test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs index 400ed2cd7..e3f2a10ac 100644 --- a/test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs +++ b/test/SharpLink.UnitTests/Runtime/DateTimeOffsetCollectionCodecTests.cs @@ -95,19 +95,15 @@ private static void Ensure(bool condition, string message) throw new Exception($"assert failed: {message}"); } - private sealed class TestSequenceSegment(ReadOnlyMemory memory) : ReadOnlySequenceSegment + private sealed class TestSequenceSegment : ReadOnlySequenceSegment { - public TestSequenceSegment() : this(ReadOnlyMemory.Empty) - { - } - - public TestSequenceSegment(ReadOnlyMemory memory, bool _) : this(memory) + public TestSequenceSegment(ReadOnlyMemory memory) { + Memory = memory; } public void Append(TestSequenceSegment next) { - Memory = memory; next.RunningIndex = RunningIndex + Memory.Length; Next = next; } From 06db74e4f4ade7eea7dd114662e8b7df314301b3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:54:23 +0800 Subject: [PATCH 265/399] refactor: hash canonical DateTimeOffset collection wire --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 53f8a219a..898a300b3 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -200,9 +200,7 @@ private static RpcHashValue HashCollectionPlan( $"Raw-blit collection '{plan.TypeName}' has no physical element plan.")).ToHex()); break; case FinalCollectionWireStrategy.DateTimeOffsetCanonical: - parts.Add("runtime-datetimeoffset-special/v1"); - parts.Add(plan.StrategySemantic ?? throw new InvalidOperationException( - $"DateTimeOffset collection '{plan.TypeName}' has no strategy semantic.")); + parts.Add("datetime-offset/collection16/i16le-offset-minutes/zero6/i64le-utc-ticks/v2"); break; } return Hashing.GetSemanticHash(parts.ToArray()); From f0529955fb6167d5262ebc1c95195d1a00827bde Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:56:58 +0800 Subject: [PATCH 266/399] fix: keep canonical DateTimeOffset parsing scoped --- src/SharpLink.Runtime/Codec/CodecHelpers.cs | 24 ++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/CodecHelpers.cs b/src/SharpLink.Runtime/Codec/CodecHelpers.cs index fc04e89c3..3dab7885e 100644 --- a/src/SharpLink.Runtime/Codec/CodecHelpers.cs +++ b/src/SharpLink.Runtime/Codec/CodecHelpers.cs @@ -322,27 +322,31 @@ public static void WriteDateTimeOffsetBlitPayload( for (var index = 0; index < length; index++) { var encoded = payload.Slice((long)index * DateTimeOffsetCollectionElementSize, DateTimeOffsetCollectionElementSize); - ReadOnlySpan element; if (encoded.FirstSpan.Length >= DateTimeOffsetCollectionElementSize) { - element = encoded.FirstSpan[..DateTimeOffsetCollectionElementSize]; + result[index] = ReadDateTimeOffsetCollectionElement( + encoded.FirstSpan[..DateTimeOffsetCollectionElementSize]); } else { encoded.CopyTo(temporary); - element = temporary; + result[index] = ReadDateTimeOffsetCollectionElement(temporary); } - - if (element.Slice(sizeof(short), 6).IndexOfAnyExcept((byte)0) >= 0) - throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "DateTimeOffset collection contains non-canonical padding."); - - var offsetMinutes = BinaryPrimitives.ReadInt16LittleEndian(element); - var utcTicks = BinaryPrimitives.ReadInt64LittleEndian(element.Slice(sizeof(long))); - result[index] = CreateDateTimeOffsetFromUtcTicks(utcTicks, offsetMinutes); } return result; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static DateTimeOffset ReadDateTimeOffsetCollectionElement(ReadOnlySpan element) + { + if (element.Slice(sizeof(short), 6).IndexOfAnyExcept((byte)0) >= 0) + throw new SharpLinkException(SharpLinkErrorCode.DataLoss, "DateTimeOffset collection contains non-canonical padding."); + + var offsetMinutes = BinaryPrimitives.ReadInt16LittleEndian(element); + var utcTicks = BinaryPrimitives.ReadInt64LittleEndian(element.Slice(sizeof(long))); + return CreateDateTimeOffsetFromUtcTicks(utcTicks, offsetMinutes); + } + public static DateTimeOffset[] ReadRequiredDateTimeOffsetCollection(in ReadOnlySequence buffer) => ReadDateTimeOffsetCollection(buffer) ?? throw new SharpLinkException( SharpLinkErrorCode.DataLoss, From e8d75c4d940b9bde599957dc5401d867722bd355 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:59:03 +0800 Subject: [PATCH 267/399] refactor: carry normalized timeout and layout diagnostics --- .../RpcGenerator.Models.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index edfc8b74f..321741ca6 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -51,6 +51,10 @@ internal record RpcMethodModel( internal bool ReturnsValueTask => ReturnType.StartsWith( "global::System.Threading.Tasks.ValueTask", StringComparison.Ordinal); + + internal long? TimeoutTicks => TimeoutSeconds is { } seconds + ? TimeSpan.FromSeconds(seconds).Ticks + : null; } internal record RpcParameterModel( @@ -258,6 +262,8 @@ internal sealed record DtoGenerationResult( { public ImmutableArray CodecHashes { get; init; } = ImmutableArray.Empty; + public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } = + ImmutableArray.Empty; public string AssemblyLogicalIdentity { get; init; } = string.Empty; } @@ -278,6 +284,7 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) x.ContractCodecs.Length != y.ContractCodecs.Length || x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || x.CodecHashes.Length != y.CodecHashes.Length || + x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length || x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal)) { @@ -300,6 +307,17 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) if (x.CodecHashes[index] != y.CodecHashes[index]) return false; } + for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++) + { + var left = x.UnsafeBlitAutoLayoutDiagnostics[index]; + var right = y.UnsafeBlitAutoLayoutDiagnostics[index]; + if (!string.Equals(left.PayloadType, right.PayloadType, StringComparison.Ordinal) || + !string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || + !string.Equals(left.FieldPath, right.FieldPath, StringComparison.Ordinal)) + { + return false; + } + } for (var index = 0; index < x.Diagnostics.Length; index++) { var left = x.Diagnostics[index]; @@ -350,6 +368,12 @@ public int GetHashCode(DtoGenerationResult obj) hash = unchecked(hash * 31 + codecHash.High.GetHashCode()); hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); } + foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.PayloadType)); + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.TypeName)); + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.FieldPath)); + } foreach (var diagnostic in obj.Diagnostics) hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.Detail)); foreach (var item in obj.Enums) From 4a15fc27365af57b236495895f3965af54925808 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:59:19 +0800 Subject: [PATCH 268/399] refactor: consume resolved graph for layout diagnostics --- ...RpcGenerator.FinalCodecPlan.Diagnostics.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Diagnostics.cs diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Diagnostics.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Diagnostics.cs new file mode 100644 index 000000000..3610ff542 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Diagnostics.cs @@ -0,0 +1,50 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private sealed partial class DtoAnalysisState + { + internal static ImmutableArray BuildUnsafeBlitAutoLayoutDiagnostics( + FinalCodecGraph graph) + { + var diagnostics = ImmutableArray.CreateBuilder(); + var dedup = new HashSet<(string Payload, string Type, string Path)>(); + + foreach (var payload in graph.RootTypes) + { + var visited = new HashSet(StringComparer.Ordinal); + Visit(payload); + + void Visit(string typeName) + { + if (!visited.Add(typeName) || !graph.Plans.TryGetValue(typeName, out var plan)) + return; + if (plan is FinalUnsafeBlitCodecPlan unsafeBlit) + { + foreach (var hazard in unsafeBlit.AutoLayoutHazards) + { + if (dedup.Add((payload, hazard.TypeName, hazard.FieldPath))) + { + diagnostics.Add(new FinalCodecAutoLayoutDiagnosticModel( + payload, + hazard.TypeName, + hazard.FieldPath, + hazard.Location)); + } + } + return; + } + + foreach (var dependency in GetFinalCodecPlanDependencies(plan)) + Visit(dependency); + } + } + + return diagnostics + .OrderBy(static item => item.PayloadType, StringComparer.Ordinal) + .ThenBy(static item => item.TypeName, StringComparer.Ordinal) + .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) + .ToImmutableArray(); + } + } +} From 1e448207e0e33b03b0e6e651640951911b5c5c5c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:14:28 +0800 Subject: [PATCH 269/399] refactor: attach UnsafeBlit layout diagnostics to codec analysis --- src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 5962d8dee..26a7fb6d9 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -43,6 +43,8 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( includeSerializable: false, includeContracts: true); var codecHashes = contractPolicyState.BuildFinalCodecHashes(contractPolicyGraph); + var unsafeBlitAutoLayoutDiagnostics = + DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph); var contractPolicyCodecs = AttachCodecHashes(contractPolicy.Codecs, codecHashes); var currentContractTypes = contractPolicyState.GetCurrentContractReachableTypeNames(); @@ -117,6 +119,7 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( enums) { CodecHashes = codecHashes, + UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics, AssemblyLogicalIdentity = compilation.Assembly.Identity.Name }; } From 80c75e9117db38bbf16c6c1620ff2d2e7fb76d43 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:15:25 +0800 Subject: [PATCH 270/399] refactor: report UnsafeBlit layout guidance from main analysis --- src/SharpLink.Generator/RpcGenerator.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index de9cf61f5..1aae470df 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -302,6 +302,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) diagnostic.Detail)); } + foreach (var diagnostic in result.UnsafeBlitAutoLayoutDiagnostics) + { + spc.ReportDiagnostic(Diagnostic.Create( + ImplicitUnsafeBlitAutoLayoutRule, + diagnostic.Location, + diagnostic.PayloadType, + diagnostic.TypeName, + diagnostic.FieldPath)); + } + if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty) { spc.AddSource( From 200d7431d7b66faf29751c26d78ae665e70dece0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:15:41 +0800 Subject: [PATCH 271/399] refactor: remove duplicate UnsafeBlit diagnostic generator pass --- ...rator.UnsafeBlitCompatibilityDiagnostic.cs | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs index ab8306342..aa90b8e9e 100644 --- a/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs +++ b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitCompatibilityDiagnostic.cs @@ -1,26 +1,5 @@ namespace SharpLink.Generator; -/// -/// Reports non-blocking guidance for RPC payloads whose resolved final Codec graph contains -/// implicit UnsafeBlit over source-defined AutoLayout value types. -/// -[Generator] -public sealed class UnsafeBlitCompatibilityDiagnosticGenerator : IIncrementalGenerator -{ - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var diagnostics = context.CompilationProvider.Select(static (compilation, cancellationToken) => - RpcGenerator.AnalyzeUnsafeBlitAutoLayoutDiagnostics(compilation, cancellationToken)); - - context.RegisterSourceOutput(diagnostics, static (productionContext, items) => - { - foreach (var diagnostic in items) - productionContext.ReportDiagnostic(diagnostic); - }); - } -} - public partial class RpcGenerator { private static readonly DiagnosticDescriptor ImplicitUnsafeBlitAutoLayoutRule = new( @@ -31,26 +10,4 @@ public partial class RpcGenerator defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true, description: "Source-defined AutoLayout inside a resolved implicit UnsafeBlit plan can make raw-memory wire layout runtime-dependent. This diagnostic is advisory and does not change Codec selection or generated wire behavior."); - - internal static ImmutableArray AnalyzeUnsafeBlitAutoLayoutDiagnostics( - Compilation compilation, - CancellationToken cancellationToken) - { - var state = new DtoAnalysisState( - compilation, - cancellationToken, - contractMode: true, - applyCodecPolicy: true, - selectorOnlyContractDefault: false); - _ = state.AnalyzeWithFinalCodecBindings(); - - return state.BuildUnsafeBlitAutoLayoutDiagnostics() - .Select(static item => Diagnostic.Create( - ImplicitUnsafeBlitAutoLayoutRule, - item.Location, - item.PayloadType, - item.TypeName, - item.FieldPath)) - .ToImmutableArray(); - } } From fbef966dff74eeb7e38c21ac02b273b7070cf803 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:17:01 +0800 Subject: [PATCH 272/399] test: validate DateTimeOffset canonical wire without CLR padding forgery --- .../Runtime/CodecSafetyTests.cs | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/CodecSafetyTests.cs b/test/SharpLink.UnitTests/Runtime/CodecSafetyTests.cs index c50f10f9b..9fc659fef 100644 --- a/test/SharpLink.UnitTests/Runtime/CodecSafetyTests.cs +++ b/test/SharpLink.UnitTests/Runtime/CodecSafetyTests.cs @@ -223,19 +223,19 @@ public void TemporalBlitCollectionsShouldRejectInvalidElements() } [Test] - public void DateTimeOffsetBlitCollectionsShouldValidateValuesAndClearPadding() + public void DateTimeOffsetBlitCollectionsShouldValidateValuesAndEmitCanonicalPadding() { var invalid = new byte[16]; BinaryPrimitives.WriteInt16LittleEndian(invalid, 0); BinaryPrimitives.WriteInt64LittleEndian(invalid.AsSpan(sizeof(long)), long.MaxValue); AssertBlitCollectionShapesReject(invalid); - var poisoned = CreateDateTimeOffsetWithPoisonedPadding(); - AssertDateTimeOffsetCollectionPadding(new[] { poisoned }); - AssertDateTimeOffsetCollectionPadding(new List { poisoned }); - AssertDateTimeOffsetCollectionPadding(new Memory([poisoned])); - AssertDateTimeOffsetCollectionPadding(new ReadOnlyMemory([poisoned])); - AssertDateTimeOffsetCollectionPadding(ImmutableArray.Create(poisoned)); + var value = new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8)); + AssertDateTimeOffsetCollectionPadding(new[] { value }); + AssertDateTimeOffsetCollectionPadding(new List { value }); + AssertDateTimeOffsetCollectionPadding(new Memory([value])); + AssertDateTimeOffsetCollectionPadding(new ReadOnlyMemory([value])); + AssertDateTimeOffsetCollectionPadding(ImmutableArray.Create(value)); } [Test] @@ -389,16 +389,6 @@ private static void AssertDateTimeOffsetCollectionPadding(T value) $"DateTimeOffset collection padding {typeof(T)}"); } - private static DateTimeOffset CreateDateTimeOffsetWithPoisonedPadding() - { - var value = new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8)); - Span bytes = stackalloc byte[16]; - bytes.Fill(0xA5); - BinaryPrimitives.WriteInt16LittleEndian(bytes, checked((short)value.Offset.TotalMinutes)); - BinaryPrimitives.WriteInt64LittleEndian(bytes[sizeof(long)..], value.UtcTicks); - return MemoryMarshal.Read(bytes); - } - private static void Serialize(in T value, IBufferWriter writer) => SCodecs.GetCodec().Serialize(value, writer); From fd96c5fffc23190a2eed8b46fe295d73faf23946 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:19:46 +0800 Subject: [PATCH 273/399] test: exercise UnsafeBlit guidance through main generator --- .../UnsafeBlitCompatibilityDiagnosticTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs b/test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs index 921a86ce2..88dac65e8 100644 --- a/test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs +++ b/test/SharpLink.Generator.Tests/UnsafeBlitCompatibilityDiagnosticTests.cs @@ -191,7 +191,7 @@ private static ImmutableArray RunUnsafeBlitCompatibilityGenerator( references: GetPlatformReferences().Concat(additionalReferences), options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - IIncrementalGenerator generator = new UnsafeBlitCompatibilityDiagnosticGenerator(); + IIncrementalGenerator generator = new RpcGenerator(); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); driver = driver.RunGenerators(compilation); return driver.GetRunResult().Diagnostics; From 559f1b8214492e30f795136b26e7c5cd0ddd7a25 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:21:02 +0800 Subject: [PATCH 274/399] refactor: hash normalized RPC timeout ticks --- src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs b/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs index 52d632879..4d9fc530a 100644 --- a/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.RpcIdentity.cs @@ -88,7 +88,7 @@ private static RpcHashValue BuildMethodHash( method.HasCancellationToken ? "cancellable" : "non-cancellable", method.IsIdempotent ? "idempotent" : "non-idempotent", method.HasTimeoutAttribute ? "timeout" : "no-timeout", - method.TimeoutSeconds?.ToString("R", InvariantCulture) ?? string.Empty, + method.TimeoutTicks?.ToString(InvariantCulture) ?? string.Empty, payloadParameters.Length.ToString(InvariantCulture) }; for (var index = 0; index < payloadParameters.Length; index++) From 32dff8b752c896d23e73d77825670b7897fe54bd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:23:20 +0800 Subject: [PATCH 275/399] test: pin normalized timeout semantic identity --- .../RpcTimeoutIdentityTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcTimeoutIdentityTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcTimeoutIdentityTests.cs b/test/SharpLink.Generator.Tests/RpcTimeoutIdentityTests.cs new file mode 100644 index 000000000..5f1e57e0b --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcTimeoutIdentityTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task EquivalentTimeoutTicksShouldShareRpcIdentity() + { + var exact = GenerateTimeoutIdentityManifest("1.0"); + var sameTick = GenerateTimeoutIdentityManifest("1.00000000001"); + + Ensure( + ExtractGeneratedRpcAssemblyHash(exact) == ExtractGeneratedRpcAssemblyHash(sameTick), + "different Timeout attribute literals that normalize to the same TimeSpan tick must share RPC semantic identity"); + return Task.CompletedTask; + } + + [Test] + public Task DifferentTimeoutTicksShouldChangeRpcIdentity() + { + var exact = GenerateTimeoutIdentityManifest("1.0"); + var nextTick = GenerateTimeoutIdentityManifest("1.0000001"); + + Ensure( + ExtractGeneratedRpcAssemblyHash(exact) != ExtractGeneratedRpcAssemblyHash(nextTick), + "a one-tick execution-policy difference must change RPC semantic identity"); + return Task.CompletedTask; + } + + private static string GenerateTimeoutIdentityManifest(string timeoutSeconds) + { + var source = BuildSource($$""" +[SharpLink.Sdk.RpcContract] +public interface ITimeoutIdentityContract : SharpLink.Sdk.IService +{ + [SharpLink.Sdk.Timeout({{timeoutSeconds}}d)] + ValueTask Echo(int value, CancellationToken cancellationToken); +} +"""); + + return GenerateIdentityManifest( + "TimeoutIdentityContracts", + source, + Platform.AnyCpu); + } +} From e5a75672836297ea3491d2a8d4d2b2eb491171e4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:27:18 +0800 Subject: [PATCH 276/399] refactor: remove duplicate UnsafeBlit graph resolution helper --- .../RpcGenerator.FinalCodecPlan.cs | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs index 8c4d58aae..bdb71e6e3 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs @@ -47,49 +47,6 @@ internal FinalCodecGraph ResolveFinalCodecGraph( .ToImmutableArray()); } - internal ImmutableArray BuildUnsafeBlitAutoLayoutDiagnostics() - { - var graph = ResolveFinalCodecGraph(includeSerializable: false, includeContracts: true); - var diagnostics = ImmutableArray.CreateBuilder(); - var dedup = new HashSet<(string Payload, string Type, string Path)>(); - - foreach (var payload in graph.RootTypes) - { - var visited = new HashSet(StringComparer.Ordinal); - Visit(payload); - - void Visit(string typeName) - { - if (!visited.Add(typeName) || !graph.Plans.TryGetValue(typeName, out var plan)) - return; - if (plan is FinalUnsafeBlitCodecPlan unsafeBlit) - { - foreach (var hazard in unsafeBlit.AutoLayoutHazards) - { - if (dedup.Add((payload, hazard.TypeName, hazard.FieldPath))) - { - diagnostics.Add(new FinalCodecAutoLayoutDiagnosticModel( - payload, - hazard.TypeName, - hazard.FieldPath, - hazard.Location)); - } - } - return; - } - - foreach (var dependency in GetFinalCodecPlanDependencies(plan)) - Visit(dependency); - } - } - - return diagnostics - .OrderBy(static item => item.PayloadType, StringComparer.Ordinal) - .ThenBy(static item => item.TypeName, StringComparer.Ordinal) - .ThenBy(static item => item.FieldPath, StringComparer.Ordinal) - .ToImmutableArray(); - } - private FinalCodecPlan ResolveFinalCodecPlan( ITypeSymbol type, Dictionary plans, From 449012844eb74658cce20d0033d2ee7e206ed1ad Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:46:47 +0800 Subject: [PATCH 277/399] refactor: emit normalized timeout ticks --- src/SharpLink.Generator/RpcGenerator.ProxyEmitter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ProxyEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ProxyEmitter.cs index cb8252de0..1d48fd847 100644 --- a/src/SharpLink.Generator/RpcGenerator.ProxyEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ProxyEmitter.cs @@ -109,8 +109,8 @@ private static void AppendProxyFields(StringBuilder sb, RpcInterfaceModel model, var hasPayloadResponse = !method.IsOneWay && !method.IsVoid; var clientStreamCount = GetStreamParameters(method).Length; var hasClientStreams = clientStreamCount != 0; - var methodTimeout = method.TimeoutSeconds is { } seconds - ? $"TimeSpan.FromSeconds({seconds.ToString("R", InvariantCulture)}d)" + var methodTimeout = method.TimeoutTicks is { } ticks + ? $"TimeSpan.FromTicks({ticks.ToString(InvariantCulture)}L)" : "null"; sb.AppendLine( $" private static readonly RpcMethodDescriptor __method_{suffix} = new({model.Hash}L, {method.Hash}L, RpcMethodKind.{kind}, {(hasPayloadResponse ? "true" : "false")}, {(hasClientStreams ? "true" : "false")}, {(method.HasTimeoutAttribute ? "true" : "false")}, {methodTimeout}, {(method.IsIdempotent ? "true" : "false")}, {clientStreamCount}, {(method.ResponseNullable ? "true" : "false")});"); From 25978bbd9336ba56e463a13e037752d270c66077 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:47:59 +0800 Subject: [PATCH 278/399] refactor: emit normalized timeout ticks --- src/SharpLink.Generator/RpcGenerator.StubEmitter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.StubEmitter.cs b/src/SharpLink.Generator/RpcGenerator.StubEmitter.cs index 6119b67d2..4f5730dca 100644 --- a/src/SharpLink.Generator/RpcGenerator.StubEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.StubEmitter.cs @@ -245,8 +245,8 @@ private static void AppendMethodDescriptors(StringBuilder sb, RpcInterfaceModel var hasPayloadResponse = !method.IsOneWay && !method.IsVoid; var clientStreamCount = method.Parameters.Count(static parameter => parameter.IsStream); var hasClientStreams = clientStreamCount != 0; - var methodTimeout = method.TimeoutSeconds is { } seconds - ? $"TimeSpan.FromSeconds({seconds.ToString("R", InvariantCulture)}d)" + var methodTimeout = method.TimeoutTicks is { } ticks + ? $"TimeSpan.FromTicks({ticks.ToString(InvariantCulture)}L)" : "null"; sb.AppendLine($" case {method.Hash}L:"); sb.AppendLine($" descriptor = new RpcMethodDescriptor({model.Hash}L, {method.Hash}L, RpcMethodKind.{kind}, {(hasPayloadResponse ? "true" : "false")}, {(hasClientStreams ? "true" : "false")}, {(method.HasTimeoutAttribute ? "true" : "false")}, {methodTimeout}, {(method.IsIdempotent ? "true" : "false")}, {clientStreamCount}, {(method.ResponseNullable ? "true" : "false")});"); From 7f7964c19f456278d288af2d0e1e93fc5d0d0bb0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:52:32 +0800 Subject: [PATCH 279/399] refactor: normalize timeout policy identity --- .../RpcGenerator.Analysis.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Analysis.cs b/src/SharpLink.Generator/RpcGenerator.Analysis.cs index 7663c5e69..98816c0a2 100644 --- a/src/SharpLink.Generator/RpcGenerator.Analysis.cs +++ b/src/SharpLink.Generator/RpcGenerator.Analysis.cs @@ -847,7 +847,7 @@ private static InheritedRpcPolicy GetInheritedRpcPolicy(IMethodSymbol method) var isIdempotent = false; var isNonCancellable = false; var hasTimeout = false; - double? timeoutSeconds = null; + long? timeoutTicks = null; foreach (var attribute in method.GetAttributes()) { var attributeClass = attribute.AttributeClass; @@ -874,8 +874,11 @@ attributeNamespace.Name is not ("Sdk" or "Abstractions")) break; case "TimeoutAttribute": hasTimeout = true; - if (TryGetTimeoutSeconds(attribute, out var seconds)) - timeoutSeconds = seconds; + if (TryGetTimeoutSeconds(attribute, out var seconds) && + TryValidateTimeoutSeconds(seconds, out _)) + { + timeoutTicks = TimeSpan.FromSeconds(seconds).Ticks; + } break; } } @@ -884,7 +887,7 @@ attributeNamespace.Name is not ("Sdk" or "Abstractions")) isIdempotent, isNonCancellable, hasTimeout, - timeoutSeconds); + timeoutTicks); } private readonly record struct InheritedRpcPolicy( @@ -892,7 +895,7 @@ private readonly record struct InheritedRpcPolicy( bool IsIdempotent, bool IsNonCancellable, bool HasTimeout, - double? TimeoutSeconds); + long? TimeoutTicks); private readonly record struct InheritedRpcSignatureGroup( IMethodSymbol Representative, @@ -1050,6 +1053,9 @@ private static RpcInterfaceModel CreateInterfaceModel(INamedTypeSymbol symbol) var isOneWay = m.GetAttributes().Any(IsOnewayAttribute); var isIdempotent = m.GetAttributes().Any(IsIdempotentAttribute); var timeoutSeconds = GetTimeoutSecondsOrNull(m, out var hasTimeoutAttribute); + var timeoutTicks = timeoutSeconds is { } seconds + ? TimeSpan.FromSeconds(seconds).Ticks + : (long?)null; var isStreamReturn = false; string? streamItemType = null; @@ -1113,7 +1119,7 @@ private static RpcInterfaceModel CreateInterfaceModel(INamedTypeSymbol symbol) var kind = isOneWay ? "OneWay" : isStreamReturn ? (paramArray.Any(static parameter => parameter.IsStream) ? "DuplexStreaming" : "ServerStreaming") : paramArray.Any(static parameter => parameter.IsStream) ? "ClientStreaming" : "Unary"; - var canonical = $"{m.Name}|{methodHash}|{kind}|{requestSchema}|{responseSchema}|cancel={paramArray.Any(static parameter => parameter.IsCancellationToken)}|timeout={hasTimeoutAttribute}:{timeoutSeconds?.ToString("R", CultureInfo.InvariantCulture)}|idempotent={isIdempotent}"; + var canonical = $"{m.Name}|{methodHash}|{kind}|{requestSchema}|{responseSchema}|cancel={paramArray.Any(static parameter => parameter.IsCancellationToken)}|timeout={hasTimeoutAttribute}:{timeoutTicks?.ToString(CultureInfo.InvariantCulture)}|idempotent={isIdempotent}"; return new RpcMethodModel( Name: m.Name, From 3dacc0b96877dba0f22775c1069beed00c80ee98 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:58:09 +0800 Subject: [PATCH 280/399] test: migrate generated manifest fixtures to ABI v4 --- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index 3897d92df..a116eaf51 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -56,7 +56,7 @@ IAsyncEnumerable Duplex( "the Generator must own literal API 4 / Protocol 2 stamps"); Ensure(manifest.Contains("SharpLinkGeneratedAssemblyManifestAttribute(", StringComparison.Ordinal) && manifest.Contains(", 4, 2,", StringComparison.Ordinal) && - manifest.Contains("sharplink-2.0-api4-rpcchannel-codec-provider-v3", StringComparison.Ordinal), + manifest.Contains("sharplink-2.0-api4-rpcchannel-codec-provider-v4", StringComparison.Ordinal), "the manifest locator must describe the API, Protocol, and exact ABI identity before materialization"); Ensure(!manifest.Contains("SharpLinkGeneratedManifestVersions", StringComparison.Ordinal), "producer stamps must not read consumer-owned Runtime constants"); @@ -3414,7 +3414,7 @@ private static MetadataReference CreateGeneratedManifestReference( $$""" using SharpLink.Abstractions; -[assembly: SharpLinkGeneratedAssemblyManifestAttribute(typeof(SharpLink.Generated.{{manifestTypeName}}), 4, 2, "2.0.0-test", "sharplink-2.0-api4-rpcchannel-codec-provider-v3")] +[assembly: SharpLinkGeneratedAssemblyManifestAttribute(typeof(SharpLink.Generated.{{manifestTypeName}}), 4, 2, "2.0.0-test", "sharplink-2.0-api4-rpcchannel-codec-provider-v4")] namespace SharpLink.Generated { From 70225614446d815f4e87e137e0e88d7f158aeef9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:25:25 +0800 Subject: [PATCH 281/399] refactor: split generator analysis responsibilities --- .../RpcGenerator.Analysis.cs | 963 +----------------- 1 file changed, 1 insertion(+), 962 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Analysis.cs b/src/SharpLink.Generator/RpcGenerator.Analysis.cs index 98816c0a2..f628982bb 100644 --- a/src/SharpLink.Generator/RpcGenerator.Analysis.cs +++ b/src/SharpLink.Generator/RpcGenerator.Analysis.cs @@ -696,965 +696,4 @@ Accessibility.Protected or } return true; } - - private static bool IsCancellationTokenParameter(IParameterSymbol parameter) - => parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken"; - - - private static bool HasValidControlParameterOrder(IMethodSymbol method) - => !method.Parameters.Any(IsCancellationTokenParameter) || - IsCancellationTokenParameter(method.Parameters[method.Parameters.Length - 1]); - - private static bool InheritsIService(INamedTypeSymbol symbol) - => symbol.AllInterfaces.Any(IsIService); - - private static IEnumerable GetContractMethods(INamedTypeSymbol symbol) - { - var methods = new List(); - foreach (var method in symbol.GetMembers().OfType() - .Where(static method => method.MethodKind == MethodKind.Ordinary && - method.DeclaredAccessibility == Accessibility.Public)) - { - methods.Add(method); - } - - foreach (var method in symbol.AllInterfaces - .Where(static contract => !IsIService(contract)) - .OrderBy(static contract => contract.ToDisplayString(), StringComparer.Ordinal) - .SelectMany(static contract => contract.GetMembers() - .OfType() - .Where(static method => method.MethodKind == MethodKind.Ordinary && - method.DeclaredAccessibility == Accessibility.Public))) - { - if (!methods.Any(existing => HasSameContractSignature(existing, method))) - methods.Add(method); - } - - return methods; - } - - private static bool HasSameContractSignature(IMethodSymbol left, IMethodSymbol right) - { - if (!string.Equals(left.Name, right.Name, StringComparison.Ordinal) || - left.Arity != right.Arity || - left.Parameters.Length != right.Parameters.Length) - { - return false; - } - - for (var index = 0; index < left.Parameters.Length; index++) - { - var leftParameter = left.Parameters[index]; - var rightParameter = right.Parameters[index]; - if (leftParameter.RefKind != rightParameter.RefKind || - !SymbolEqualityComparer.Default.Equals(leftParameter.Type, rightParameter.Type)) - { - return false; - } - } - - return true; - } - - private static IEnumerable GetConflictingInheritedRpcSignatures(INamedTypeSymbol symbol) - { - if (!symbol.AllInterfaces.Any(static contract => !IsIService(contract))) - yield break; - - var directMethods = symbol.GetMembers().OfType() - .Where(static method => method.MethodKind == MethodKind.Ordinary && - method.DeclaredAccessibility == Accessibility.Public) - .ToArray(); - var methods = directMethods - .Concat(symbol.AllInterfaces - .Where(static contract => !IsIService(contract)) - .SelectMany(static contract => contract.GetMembers().OfType())) - .Where(static method => method.MethodKind == MethodKind.Ordinary && - method.DeclaredAccessibility == Accessibility.Public) - .ToArray(); - var groups = new List(); - for (var methodIndex = 0; methodIndex < methods.Length; methodIndex++) - { - var method = methods[methodIndex]; - var groupIndex = -1; - for (var candidateIndex = 0; candidateIndex < groups.Count; candidateIndex++) - { - if (!HasSameContractSignature(groups[candidateIndex].Representative, method)) - continue; - groupIndex = candidateIndex; - break; - } - if (groupIndex < 0) - { - var hasDirectDeclaration = methodIndex < directMethods.Length; - groups.Add(new InheritedRpcSignatureGroup( - method, - hasDirectDeclaration ? default : GetInheritedRpcPolicy(method), - hasDirectDeclaration, - Reported: false)); - continue; - } - - var group = groups[groupIndex]; - if (group.Reported) - continue; - if (SymbolEqualityComparer.IncludeNullability.Equals( - group.Representative.ReturnType, - method.ReturnType) && - (group.HasDirectDeclaration || HasCompatibleInheritedRpcSemantics( - group.Representative, - method, - group.Policy, - GetInheritedRpcPolicy(method)))) - { - continue; - } - - groups[groupIndex] = group with { Reported = true }; - yield return group.Representative; - } - } - - private static bool HasCompatibleInheritedRpcSemantics( - IMethodSymbol left, - IMethodSymbol right, - InheritedRpcPolicy leftPolicy, - InheritedRpcPolicy rightPolicy) - { - for (var index = 0; index < left.Parameters.Length; index++) - { - var leftParameter = left.Parameters[index]; - var rightParameter = right.Parameters[index]; - if (IsCancellationTokenParameter(leftParameter)) - { - continue; - } - if (!string.Equals(leftParameter.Name, rightParameter.Name, StringComparison.Ordinal) || - !SymbolEqualityComparer.IncludeNullability.Equals( - leftParameter.Type, - rightParameter.Type)) - { - return false; - } - } - - return leftPolicy == rightPolicy; - } - - private static InheritedRpcPolicy GetInheritedRpcPolicy(IMethodSymbol method) - { - var isOneway = false; - var isIdempotent = false; - var isNonCancellable = false; - var hasTimeout = false; - long? timeoutTicks = null; - foreach (var attribute in method.GetAttributes()) - { - var attributeClass = attribute.AttributeClass; - if (attributeClass is null) - continue; - var attributeNamespace = attributeClass.ContainingNamespace; - if (attributeNamespace.ContainingNamespace is not { Name: "SharpLink" } root || - !root.ContainingNamespace.IsGlobalNamespace || - attributeNamespace.Name is not ("Sdk" or "Abstractions")) - { - continue; - } - - switch (attributeClass.Name) - { - case "OnewayAttribute": - isOneway = true; - break; - case "IdempotentAttribute": - isIdempotent = true; - break; - case "NonCancellableAttribute": - isNonCancellable = true; - break; - case "TimeoutAttribute": - hasTimeout = true; - if (TryGetTimeoutSeconds(attribute, out var seconds) && - TryValidateTimeoutSeconds(seconds, out _)) - { - timeoutTicks = TimeSpan.FromSeconds(seconds).Ticks; - } - break; - } - } - return new InheritedRpcPolicy( - isOneway, - isIdempotent, - isNonCancellable, - hasTimeout, - timeoutTicks); - } - - private readonly record struct InheritedRpcPolicy( - bool IsOneway, - bool IsIdempotent, - bool IsNonCancellable, - bool HasTimeout, - long? TimeoutTicks); - - private readonly record struct InheritedRpcSignatureGroup( - IMethodSymbol Representative, - InheritedRpcPolicy Policy, - bool HasDirectDeclaration, - bool Reported); - - private static bool IsIService(INamedTypeSymbol symbol) - => string.Equals(symbol.Name, "IService", StringComparison.Ordinal) && - string.Equals(symbol.ContainingNamespace.ToDisplayString(), "SharpLink.Sdk", StringComparison.Ordinal); - - private static bool IsRpcServiceAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "RpcServiceAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "RpcServiceAttribute"); - } - - private static bool IsOnewayAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "OnewayAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "OnewayAttribute"); - } - - private static bool IsTimeoutAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "TimeoutAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "TimeoutAttribute"); - } - - private static bool IsIdempotentAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "IdempotentAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "IdempotentAttribute"); - } - - private static bool IsNonCancellableAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "NonCancellableAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "NonCancellableAttribute"); - } - - private static double? GetTimeoutSecondsOrNull(IMethodSymbol method, out bool hasTimeoutAttribute) - { - hasTimeoutAttribute = false; - foreach (var attribute in method.GetAttributes()) - { - if (!IsTimeoutAttribute(attribute)) - continue; - - hasTimeoutAttribute = true; - if (attribute.ConstructorArguments.Length == 0) - return null; - - return TryGetTimeoutSeconds(attribute, out var seconds) && - TryValidateTimeoutSeconds(seconds, out _) - ? seconds - : null; - } - - return null; - } - - private static bool TryGetTimeoutSeconds(AttributeData attribute, out double seconds) - { - seconds = default; - if (attribute.ConstructorArguments.Length == 0 || attribute.ConstructorArguments[0].Value is null) - return false; - - switch (attribute.ConstructorArguments[0].Value) - { - case double value: - seconds = value; - return true; - case float value: - seconds = value; - return true; - case int value: - seconds = value; - return true; - case long value: - seconds = value; - return true; - default: - return false; - } - } - - private static bool TryValidateTimeoutSeconds(double seconds, out string detail) - { - if (double.IsNaN(seconds) || double.IsInfinity(seconds) || seconds <= 0) - { - detail = "seconds must be a finite number greater than zero"; - return false; - } - - try - { - if (TimeSpan.FromSeconds(seconds) <= TimeSpan.Zero) - { - detail = "seconds is too small to produce a positive TimeSpan"; - return false; - } - } - catch (OverflowException) - { - detail = "seconds exceeds the supported TimeSpan range"; - return false; - } - catch (ArgumentOutOfRangeException) - { - detail = "seconds exceeds the supported TimeSpan range"; - return false; - } - - detail = string.Empty; - return true; - } - - private static bool IsSupportedRpcReturnType(ITypeSymbol type) - { - if (type is not INamedTypeSymbol named) - return false; - - var ns = named.ContainingNamespace.ToDisplayString(); - var original = named.OriginalDefinition; - - if (ns != "System.Threading.Tasks") - return ns == "System.Collections.Generic" && original is { Name: "IAsyncEnumerable", Arity: 1 }; - return original switch - { - { Name: "Task", Arity: 0 or 1 } or { Name: "ValueTask", Arity: 0 or 1 } => true, - _ => ns == "System.Collections.Generic" && original is { Name: "IAsyncEnumerable", Arity: 1 } - }; - } - - private static RpcInterfaceModel CreateInterfaceModel(INamedTypeSymbol symbol) - { - var ns = symbol.ContainingNamespace.IsGlobalNamespace ? "" : symbol.ContainingNamespace.ToDisplayString(); - - var methods = GetContractMethods(symbol) - .Select(m => - { - var returnType = GetTypeName(m.ReturnType); - var displayReturnType = m.ReturnType.ToDisplayString(FullyQualifiedNullableFormat); - var isGenericTask = m.ReturnType is INamedTypeSymbol { IsGenericType: true } && - m.ReturnType.ToDisplayString().StartsWith("System.Threading.Tasks"); - var genericArg = isGenericTask - ? GetTypeName(((INamedTypeSymbol)m.ReturnType).TypeArguments[0]) - : null; - var displayGenericArg = isGenericTask - ? ((INamedTypeSymbol)m.ReturnType).TypeArguments[0].ToDisplayString(FullyQualifiedNullableFormat) - : null; - - var isNonGenericTaskLike = m.ReturnType.ToDisplayString() is "System.Threading.Tasks.Task" or "System.Threading.Tasks.ValueTask"; - var isOneWay = m.GetAttributes().Any(IsOnewayAttribute); - var isIdempotent = m.GetAttributes().Any(IsIdempotentAttribute); - var timeoutSeconds = GetTimeoutSecondsOrNull(m, out var hasTimeoutAttribute); - var timeoutTicks = timeoutSeconds is { } seconds - ? TimeSpan.FromSeconds(seconds).Ticks - : (long?)null; - - var isStreamReturn = false; - string? streamItemType = null; - string? displayStreamItemType = null; - if (IsAsyncEnumerable(m.ReturnType, out var itemTypeSymbol)) - { - isStreamReturn = true; - streamItemType = GetTypeName(itemTypeSymbol!); - displayStreamItemType = itemTypeSymbol!.ToDisplayString(FullyQualifiedNullableFormat); - isGenericTask = false; - genericArg = null; - displayGenericArg = null; - } - - var paramArray = m.Parameters.Select(p => - { - var pType = GetTypeName(p.Type); - var displayPType = p.Type.ToDisplayString(FullyQualifiedNullableFormat); - var isStream = IsAsyncEnumerable(p.Type, out var pItemType); - var isValueType = p.Type.IsValueType; - var isNullableReference = !isValueType && p.NullableAnnotation == NullableAnnotation.Annotated; - var payloadType = isStream ? pItemType! : p.Type; - var isCancellationToken = IsCancellationTokenParameter(p); - return new RpcParameterModel( - p.Name, - pType, - displayPType, - isStream, - isStream ? GetTypeName(pItemType!) : null, - isStream ? pItemType!.ToDisplayString(FullyQualifiedNullableFormat) : null, - IsInlineFixedRpcType(p.Type), - isValueType, - isNullableReference, - IsNullablePayload(payloadType), - isCancellationToken, - GetEnumUnderlyingType(p.Type), - pItemType is null ? null : GetEnumUnderlyingType(pItemType), - p.Locations.FirstOrDefault()); - }).ToImmutableArray(); - - var paramTypes = m.Parameters - .Where(static parameter => - !IsCancellationTokenParameter(parameter)) - .Select(static p => GetTypeName(p.Type)) - .ToArray(); - var methodHash = Hashing.GetMethodHash(m.Name, paramTypes); - - var requestSchema = string.Join(";", paramArray - .Where(static parameter => !parameter.IsCancellationToken) - .Select(static parameter => - $"{parameter.Name}:{parameter.Type}:{(parameter.IsStream ? "stream" : "value")}:{(parameter.PayloadNullable ? "nullable" : "required")}")); - var responsePayload = isGenericTask - ? ((INamedTypeSymbol)m.ReturnType).TypeArguments[0] - : itemTypeSymbol; - var responseNullable = responsePayload is not null && IsNullablePayload(responsePayload); - var responseSchema = isStreamReturn - ? $"stream:{streamItemType}" - : $"value:{returnType}"; - if (responseNullable) - responseSchema += ":nullable"; - var kind = isOneWay ? "OneWay" : isStreamReturn - ? (paramArray.Any(static parameter => parameter.IsStream) ? "DuplexStreaming" : "ServerStreaming") - : paramArray.Any(static parameter => parameter.IsStream) ? "ClientStreaming" : "Unary"; - var canonical = $"{m.Name}|{methodHash}|{kind}|{requestSchema}|{responseSchema}|cancel={paramArray.Any(static parameter => parameter.IsCancellationToken)}|timeout={hasTimeoutAttribute}:{timeoutTicks?.ToString(CultureInfo.InvariantCulture)}|idempotent={isIdempotent}"; - - return new RpcMethodModel( - Name: m.Name, - ReturnType: returnType, - DisplayReturnType: displayReturnType, - IsGenericTask: isGenericTask, - IsStreamReturn: isStreamReturn, - StreamItemType: streamItemType, - DisplayStreamItemType: displayStreamItemType, - GenericArgumentType: genericArg, - DisplayGenericArgumentType: displayGenericArg, - IsVoid: m.ReturnsVoid || isNonGenericTaskLike, - IsOneWay: isOneWay, - HasCancellationToken: paramArray.Any(p => p.IsCancellationToken), - HasTimeoutAttribute: hasTimeoutAttribute, - TimeoutSeconds: timeoutSeconds, - IsIdempotent: isIdempotent, - Hash: methodHash, - Parameters: paramArray, - RequestSchema: requestSchema, - ResponseSchema: responseSchema, - Fingerprint: Hashing.GetSha256(canonical), - ResponseNullable: responseNullable, - ResponseEnumUnderlyingType: responsePayload is null ? null : GetEnumUnderlyingType(responsePayload), - StreamItemEnumUnderlyingType: itemTypeSymbol is null ? null : GetEnumUnderlyingType(itemTypeSymbol), - Location: m.Locations.FirstOrDefault()); - }).ToImmutableArray(); - - var fullname = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var interfaceHash = Hashing.GetInterfaceHash(fullname); - var canonicalContract = $"{fullname}|{interfaceHash}|" + string.Join("|", methods - .OrderBy(static method => method.Hash) - .Select(static method => method.Fingerprint)); - var dependencyTypes = GetContractMethods(symbol) - .SelectMany(static method => method.Parameters.Select(static parameter => parameter.Type) - .Append(method.ReturnType)); - return new RpcInterfaceModel( - GetGeneratedContractName(symbol), - ns, - fullname, - interfaceHash, - methods, - Hashing.GetSha256(canonicalContract), - GetArtifactAssemblyDependencies(symbol.ContainingAssembly, dependencyTypes), - symbol.Locations.FirstOrDefault()); - } - - private static string? GetEnumUnderlyingType(ITypeSymbol type) - => type is INamedTypeSymbol { TypeKind: TypeKind.Enum, EnumUnderlyingType: { } underlying } - ? underlying.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - : null; - - private static bool IsInlineFixedRpcType(ITypeSymbol type) - { - if (type.TypeKind == TypeKind.Enum) - return true; - if (type.SpecialType is SpecialType.System_Boolean or SpecialType.System_Byte or SpecialType.System_SByte or - SpecialType.System_Int16 or SpecialType.System_UInt16 or - SpecialType.System_Char or SpecialType.System_Int32 or SpecialType.System_UInt32 or - SpecialType.System_Single or SpecialType.System_Int64 or SpecialType.System_UInt64 or - SpecialType.System_Double) - { - return true; - } - - return type.ToDisplayString() is "System.Half" or "System.Guid" or - "System.TimeSpan" or "System.Int128" or "System.UInt128"; - } - - private static bool IsNullablePayload(ITypeSymbol type) - => type.NullableAnnotation == NullableAnnotation.Annotated || - type is INamedTypeSymbol named && - named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; - - private static ImmutableArray GetArtifactAssemblyDependencies( - IAssemblySymbol owner, - IEnumerable types) - { - var identities = new HashSet(StringComparer.Ordinal); - foreach (var type in types) - CollectArtifactAssemblyDependencies(owner, type, identities); - return identities.OrderBy(static identity => identity, StringComparer.Ordinal).ToImmutableArray(); - } - - private static void CollectArtifactAssemblyDependencies( - IAssemblySymbol owner, - ITypeSymbol type, - HashSet identities) - { - if (type is IArrayTypeSymbol array) - { - CollectArtifactAssemblyDependencies(owner, array.ElementType, identities); - return; - } - if (type is not INamedTypeSymbol named) - return; - - var assembly = named.ContainingAssembly; - if (assembly is not null && - !SymbolEqualityComparer.Default.Equals(assembly, owner) && - ReferencesSharpLinkSdk(assembly)) - { - identities.Add(assembly.Identity.ToString()); - } - foreach (var argument in named.TypeArguments) - CollectArtifactAssemblyDependencies(owner, argument, identities); - } - - private static ImmutableArray GetReferencedInterfaceModels( - Compilation compilation, - CancellationToken _) - { - var seen = new HashSet(StringComparer.Ordinal); - var models = ImmutableArray.CreateBuilder(); - var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); - - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) - continue; - - if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) - continue; - - CollectReferencedInterfaces(assembly.GlobalNamespace, models, seen); - } - - return models - .OrderBy(static m => m.FullName, StringComparer.Ordinal) - .ToImmutableArray(); - } - - private static ImmutableArray GetReferencedServiceModels( - Compilation compilation, - CancellationToken _) - { - var seen = new HashSet(StringComparer.Ordinal); - var models = ImmutableArray.CreateBuilder(); - var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); - - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) - continue; - - if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) - continue; - - CollectReferencedServices(assembly.GlobalNamespace, models, seen); - } - - return models - .OrderBy(static m => m.ServiceFullName, StringComparer.Ordinal) - .ToImmutableArray(); - } - - private static ImmutableArray AnalyzeStaticRouteConflicts( - Compilation compilation, - CancellationToken _) - { - var contracts = new List<(RpcInterfaceModel Model, string Owner, Location? Location)>(); - var services = new List<(RpcServiceModel Model, string Owner, Location? Location)>(); - var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); - - CollectStaticRouteModels(compilation.Assembly, contracts, services); - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly && - candidateAssemblyNames.Contains(assembly.Identity.Name)) - { - CollectStaticRouteModels(assembly, contracts, services); - } - } - - var conflicts = ImmutableArray.CreateBuilder(); - foreach (var group in contracts.GroupBy(static contract => contract.Model.Hash)) - { - var ordered = group - .OrderBy(static contract => contract.Owner, StringComparer.Ordinal) - .ThenBy(static contract => contract.Model.FullName, StringComparer.Ordinal) - .ToArray(); - if (ordered.Length < 2) - continue; - - var first = ordered[0]; - for (var index = 1; index < ordered.Length; index++) - { - var incoming = ordered[index]; - if (!string.Equals(first.Owner, incoming.Owner, StringComparison.Ordinal)) - { - conflicts.Add(new StaticRouteConflictModel( - StaticRouteConflictKind.Contract, - incoming.Model.FullName, - incoming.Model.Hash, - $"{first.Owner}:{first.Model.Fingerprint}", - $"{incoming.Owner}:{incoming.Model.Fingerprint}", - incoming.Location)); - } - - foreach (var firstMethod in first.Model.Methods) - { - var incomingMethod = incoming.Model.Methods.FirstOrDefault(method => method.Hash == firstMethod.Hash); - if (incomingMethod is null || - string.Equals(firstMethod.Fingerprint, incomingMethod.Fingerprint, StringComparison.Ordinal)) - { - continue; - } - conflicts.Add(new StaticRouteConflictModel( - StaticRouteConflictKind.Method, - $"{incoming.Model.FullName}.{incomingMethod.Name}", - incomingMethod.Hash, - firstMethod.Fingerprint, - incomingMethod.Fingerprint, - incoming.Location)); - } - } - } - - foreach (var group in services.GroupBy(static service => service.Model.Interface.Hash)) - { - var ordered = group - .OrderBy(static service => service.Owner, StringComparer.Ordinal) - .ThenBy(static service => service.Model.ServiceFullName, StringComparer.Ordinal) - .ToArray(); - if (ordered.Length < 2) - continue; - var first = ordered[0]; - for (var index = 1; index < ordered.Length; index++) - { - var incoming = ordered[index]; - conflicts.Add(new StaticRouteConflictModel( - StaticRouteConflictKind.Service, - incoming.Model.Interface.FullName, - incoming.Model.Interface.Hash, - first.Model.ServiceFullName, - incoming.Model.ServiceFullName, - incoming.Location)); - } - } - - return conflicts - .Distinct() - .OrderBy(static conflict => conflict.Kind) - .ThenBy(static conflict => conflict.Id) - .ToImmutableArray(); - } - - private static void CollectStaticRouteModels( - IAssemblySymbol assembly, - List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, - List<(RpcServiceModel Model, string Owner, Location? Location)> services) - => CollectStaticRouteModels(assembly.GlobalNamespace, assembly.Identity.ToString(), contracts, services); - - private static void CollectStaticRouteModels( - INamespaceSymbol namespaceSymbol, - string owner, - List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, - List<(RpcServiceModel Model, string Owner, Location? Location)> services) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - CollectStaticRouteModels(type, owner, contracts, services); - foreach (var child in namespaceSymbol.GetNamespaceMembers()) - CollectStaticRouteModels(child, owner, contracts, services); - } - - private static void CollectStaticRouteModels( - INamedTypeSymbol type, - string owner, - List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, - List<(RpcServiceModel Model, string Owner, Location? Location)> services) - { - if (type.TypeKind == TypeKind.Interface && HasRpcContractAttribute(type) && - InheritsIService(type) && !HasInvalidRpcMethod(type)) - { - contracts.Add((CreateInterfaceModel(type), owner, type.Locations.FirstOrDefault())); - } - - if (type.TypeKind == TypeKind.Class && !type.IsAbstract && !type.IsGenericType && - type.GetAttributes().Any(IsRpcServiceAttribute)) - { - var rpcContracts = type.AllInterfaces.Where(HasRpcContractAttribute).ToArray(); - var constructor = SelectServiceConstructor(type); - if (rpcContracts.Length == 1 && constructor is not null && - IsServiceConstructorSupported(constructor, out _) && - !HasInvalidRpcMethod(rpcContracts[0])) - { - var serviceNamespace = type.ContainingNamespace.IsGlobalNamespace - ? string.Empty - : type.ContainingNamespace.ToDisplayString(); - services.Add((new RpcServiceModel( - type.Name, - serviceNamespace, - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - CreateInterfaceModel(rpcContracts[0]), - GetServiceLifetime(type, out _), - constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( - parameter.Name, - parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(), - ImmutableArray.Create(rpcContracts[0].ContainingAssembly.Identity.ToString()), - type.Locations.FirstOrDefault()), - owner, - type.Locations.FirstOrDefault())); - } - } - - foreach (var nested in type.GetTypeMembers()) - CollectStaticRouteModels(nested, owner, contracts, services); - } - - private static HashSet ResolveReferenceAssemblyNames(Compilation compilation) - { - var explicitAssemblies = GetExplicitContractAssemblies(compilation); - if (explicitAssemblies is not null) - return explicitAssemblies; - - var assemblyNames = new HashSet(StringComparer.Ordinal); - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) - continue; - - if (ReferencesSharpLinkSdk(assembly)) - assemblyNames.Add(assembly.Identity.Name); - } - - return assemblyNames; - } - - private static HashSet? GetExplicitContractAssemblies(Compilation compilation) - { - HashSet? assemblyNames = null; - foreach (var attribute in compilation.Assembly.GetAttributes()) - { - if (!IsAttribute(attribute, "SharpLink.Sdk", "SharpLinkRpcContractsAttribute")) - continue; - - assemblyNames ??= new HashSet(StringComparer.Ordinal); - - if (attribute.ConstructorArguments.Length == 0) - continue; - - var argument = attribute.ConstructorArguments[0]; - if (argument.Kind != TypedConstantKind.Array) - continue; - - foreach (var item in argument.Values) - { - if (item.Value is INamedTypeSymbol type && type.ContainingAssembly is { } containingAssembly) - { - assemblyNames.Add(containingAssembly.Identity.Name); - } - } - } - - return assemblyNames; - } - - private static bool ReferencesSharpLinkSdk(IAssemblySymbol assembly) - { - foreach (var module in assembly.Modules) - { - foreach (var referencedAssembly in module.ReferencedAssemblySymbols) - { - if (string.Equals(referencedAssembly.Name, "SharpLink.Sdk", StringComparison.Ordinal)) - return true; - } - } - - return false; - } - - private static void CollectReferencedInterfaces( - INamespaceSymbol namespaceSymbol, - ImmutableArray.Builder models, - HashSet seen) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - CollectReferencedInterfaces(type, models, seen, containingTypesArePublic: true); - - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - CollectReferencedInterfaces(nestedNamespace, models, seen); - } - - private static void CollectReferencedInterfaces( - INamedTypeSymbol typeSymbol, - ImmutableArray.Builder models, - HashSet seen, - bool containingTypesArePublic) - { - var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); - if (isPubliclyReachable && - typeSymbol.TypeKind == TypeKind.Interface && - HasRpcContractAttribute(typeSymbol) && - InheritsIService(typeSymbol) && - !HasInvalidRpcMethod(typeSymbol)) - { - var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (seen.Add(fullName)) - models.Add(CreateInterfaceModel(typeSymbol)); - } - - if (!isPubliclyReachable) - return; - - foreach (var nested in typeSymbol.GetTypeMembers()) - CollectReferencedInterfaces(nested, models, seen, containingTypesArePublic: isPubliclyReachable); - } - - private static void CollectReferencedServices( - INamespaceSymbol namespaceSymbol, - ImmutableArray.Builder models, - HashSet seen) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - CollectReferencedServices(type, models, seen, containingTypesArePublic: true); - - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - CollectReferencedServices(nestedNamespace, models, seen); - } - - private static void CollectReferencedServices( - INamedTypeSymbol typeSymbol, - ImmutableArray.Builder models, - HashSet seen, - bool containingTypesArePublic) - { - var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); - if (isPubliclyReachable && - typeSymbol.TypeKind == TypeKind.Class && - !typeSymbol.IsAbstract && - typeSymbol.GetAttributes().Any(IsRpcServiceAttribute)) - { - var interfaceSymbol = FindRpcContractInterface(typeSymbol); - if (interfaceSymbol is not null && !HasInvalidRpcMethod(interfaceSymbol)) - { - var constructor = SelectServiceConstructor(typeSymbol); - if (constructor is not null && IsServiceConstructorSupported(constructor, out _)) - { - var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (seen.Add(fullName)) - { - var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace ? "" : typeSymbol.ContainingNamespace.ToDisplayString(); - var parameters = constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( - parameter.Name, - parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(); - models.Add(new RpcServiceModel( - typeSymbol.Name, - ns, - fullName, - CreateInterfaceModel(interfaceSymbol), - GetServiceLifetime(typeSymbol, out _), - parameters, - ImmutableArray.Create(interfaceSymbol.ContainingAssembly.Identity.ToString()), - typeSymbol.Locations.FirstOrDefault())); - } - } - } - } - - if (!isPubliclyReachable) - return; - - foreach (var nested in typeSymbol.GetTypeMembers()) - CollectReferencedServices(nested, models, seen, containingTypesArePublic: isPubliclyReachable); - } - - private static bool IsPubliclyReachableType(INamedTypeSymbol typeSymbol) - => typeSymbol.DeclaredAccessibility == Accessibility.Public; - - private static bool HasRpcContractAttribute(INamedTypeSymbol symbol) - => symbol.GetAttributes().Any(static a => IsAttribute(a, "SharpLink.Sdk", "RpcContractAttribute")); - - private static INamedTypeSymbol? FindRpcContractInterface(INamedTypeSymbol serviceSymbol) - => serviceSymbol.AllInterfaces.FirstOrDefault(HasRpcContractAttribute); - - private static bool IsAttribute(AttributeData attribute, string ns, string name) - { - if (attribute.AttributeClass is not { } attrClass) - return false; - if (!string.Equals(attrClass.Name, name, StringComparison.Ordinal)) - return false; - return string.Equals(attrClass.ContainingNamespace.ToDisplayString(), ns, StringComparison.Ordinal); - } - - private static string GetProxyHintName(RpcInterfaceModel model) - { - var fullName = model.FullName; - if (fullName.StartsWith("global::", StringComparison.Ordinal)) - fullName = fullName.Substring("global::".Length); - var name = new StringBuilder(fullName.Length + 16); - foreach (var ch in fullName) - name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); - name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Proxy.g.cs"); - return name.ToString(); - } - - private static string GetStubHintName(RpcInterfaceModel model) - { - var fullName = model.FullName; - if (fullName.StartsWith("global::", StringComparison.Ordinal)) - fullName = fullName.Substring("global::".Length); - var name = new StringBuilder(fullName.Length + 16); - foreach (var ch in fullName) - name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); - name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Stub.g.cs"); - return name.ToString(); - } - - private static string GetProxyArtifactHintName(RpcInterfaceModel model) - { - var fullName = model.FullName; - if (fullName.StartsWith("global::", StringComparison.Ordinal)) - fullName = fullName.Substring("global::".Length); - var name = new StringBuilder(fullName.Length + 16); - foreach (var ch in fullName) - name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); - name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_ProxyImpl.g.cs"); - return name.ToString(); - } - - private static string GetGeneratedContractName(INamedTypeSymbol symbol) - { - if (symbol.ContainingType is null) - return symbol.Name; - - var parts = new Stack(); - for (var current = symbol; current is not null; current = current.ContainingType) - parts.Push(current.Name); - var fullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - return string.Join("_", parts) + "_" + Hashing.GetSha256(fullName).Substring(0, 8); - } - - private static string EscapeIdentifier(string identifier) - => Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(identifier) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None - ? "@" + identifier - : identifier; - -} +} \ No newline at end of file From 4627696e0a53626f89d1b3b66cf73bde600de4a9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:26:00 +0800 Subject: [PATCH 282/399] refactor: split generator method semantics --- .../RpcGenerator.MethodSemantics.cs | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs diff --git a/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs b/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs new file mode 100644 index 000000000..ce7966156 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs @@ -0,0 +1,321 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static bool IsCancellationTokenParameter(IParameterSymbol parameter) + => parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken"; + + + private static bool HasValidControlParameterOrder(IMethodSymbol method) + => !method.Parameters.Any(IsCancellationTokenParameter) || + IsCancellationTokenParameter(method.Parameters[method.Parameters.Length - 1]); + + private static bool InheritsIService(INamedTypeSymbol symbol) + => symbol.AllInterfaces.Any(IsIService); + + private static IEnumerable GetContractMethods(INamedTypeSymbol symbol) + { + var methods = new List(); + foreach (var method in symbol.GetMembers().OfType() + .Where(static method => method.MethodKind == MethodKind.Ordinary && + method.DeclaredAccessibility == Accessibility.Public)) + { + methods.Add(method); + } + + foreach (var method in symbol.AllInterfaces + .Where(static contract => !IsIService(contract)) + .OrderBy(static contract => contract.ToDisplayString(), StringComparer.Ordinal) + .SelectMany(static contract => contract.GetMembers() + .OfType() + .Where(static method => method.MethodKind == MethodKind.Ordinary && + method.DeclaredAccessibility == Accessibility.Public))) + { + if (!methods.Any(existing => HasSameContractSignature(existing, method))) + methods.Add(method); + } + + return methods; + } + + private static bool HasSameContractSignature(IMethodSymbol left, IMethodSymbol right) + { + if (!string.Equals(left.Name, right.Name, StringComparison.Ordinal) || + left.Arity != right.Arity || + left.Parameters.Length != right.Parameters.Length) + { + return false; + } + + for (var index = 0; index < left.Parameters.Length; index++) + { + var leftParameter = left.Parameters[index]; + var rightParameter = right.Parameters[index]; + if (leftParameter.RefKind != rightParameter.RefKind || + !SymbolEqualityComparer.Default.Equals(leftParameter.Type, rightParameter.Type)) + { + return false; + } + } + + return true; + } + + private static IEnumerable GetConflictingInheritedRpcSignatures(INamedTypeSymbol symbol) + { + if (!symbol.AllInterfaces.Any(static contract => !IsIService(contract))) + yield break; + + var directMethods = symbol.GetMembers().OfType() + .Where(static method => method.MethodKind == MethodKind.Ordinary && + method.DeclaredAccessibility == Accessibility.Public) + .ToArray(); + var methods = directMethods + .Concat(symbol.AllInterfaces + .Where(static contract => !IsIService(contract)) + .SelectMany(static contract => contract.GetMembers().OfType())) + .Where(static method => method.MethodKind == MethodKind.Ordinary && + method.DeclaredAccessibility == Accessibility.Public) + .ToArray(); + var groups = new List(); + for (var methodIndex = 0; methodIndex < methods.Length; methodIndex++) + { + var method = methods[methodIndex]; + var groupIndex = -1; + for (var candidateIndex = 0; candidateIndex < groups.Count; candidateIndex++) + { + if (!HasSameContractSignature(groups[candidateIndex].Representative, method)) + continue; + groupIndex = candidateIndex; + break; + } + if (groupIndex < 0) + { + var hasDirectDeclaration = methodIndex < directMethods.Length; + groups.Add(new InheritedRpcSignatureGroup( + method, + hasDirectDeclaration ? default : GetInheritedRpcPolicy(method), + hasDirectDeclaration, + Reported: false)); + continue; + } + + var group = groups[groupIndex]; + if (group.Reported) + continue; + if (SymbolEqualityComparer.IncludeNullability.Equals( + group.Representative.ReturnType, + method.ReturnType) && + (group.HasDirectDeclaration || HasCompatibleInheritedRpcSemantics( + group.Representative, + method, + group.Policy, + GetInheritedRpcPolicy(method)))) + { + continue; + } + + groups[groupIndex] = group with { Reported = true }; + yield return group.Representative; + } + } + + private static bool HasCompatibleInheritedRpcSemantics( + IMethodSymbol left, + IMethodSymbol right, + InheritedRpcPolicy leftPolicy, + InheritedRpcPolicy rightPolicy) + { + for (var index = 0; index < left.Parameters.Length; index++) + { + var leftParameter = left.Parameters[index]; + var rightParameter = right.Parameters[index]; + if (IsCancellationTokenParameter(leftParameter)) + { + continue; + } + if (!string.Equals(leftParameter.Name, rightParameter.Name, StringComparison.Ordinal) || + !SymbolEqualityComparer.IncludeNullability.Equals( + leftParameter.Type, + rightParameter.Type)) + { + return false; + } + } + + return leftPolicy == rightPolicy; + } + + private static InheritedRpcPolicy GetInheritedRpcPolicy(IMethodSymbol method) + { + var isOneway = false; + var isIdempotent = false; + var isNonCancellable = false; + var hasTimeout = false; + long? timeoutTicks = null; + foreach (var attribute in method.GetAttributes()) + { + var attributeClass = attribute.AttributeClass; + if (attributeClass is null) + continue; + var attributeNamespace = attributeClass.ContainingNamespace; + if (attributeNamespace.ContainingNamespace is not { Name: "SharpLink" } root || + !root.ContainingNamespace.IsGlobalNamespace || + attributeNamespace.Name is not ("Sdk" or "Abstractions")) + { + continue; + } + + switch (attributeClass.Name) + { + case "OnewayAttribute": + isOneway = true; + break; + case "IdempotentAttribute": + isIdempotent = true; + break; + case "NonCancellableAttribute": + isNonCancellable = true; + break; + case "TimeoutAttribute": + hasTimeout = true; + if (TryGetTimeoutSeconds(attribute, out var seconds) && + TryValidateTimeoutSeconds(seconds, out _)) + { + timeoutTicks = TimeSpan.FromSeconds(seconds).Ticks; + } + break; + } + } + return new InheritedRpcPolicy( + isOneway, + isIdempotent, + isNonCancellable, + hasTimeout, + timeoutTicks); + } + + private readonly record struct InheritedRpcPolicy( + bool IsOneway, + bool IsIdempotent, + bool IsNonCancellable, + bool HasTimeout, + long? TimeoutTicks); + + private readonly record struct InheritedRpcSignatureGroup( + IMethodSymbol Representative, + InheritedRpcPolicy Policy, + bool HasDirectDeclaration, + bool Reported); + + private static bool IsIService(INamedTypeSymbol symbol) + => string.Equals(symbol.Name, "IService", StringComparison.Ordinal) && + string.Equals(symbol.ContainingNamespace.ToDisplayString(), "SharpLink.Sdk", StringComparison.Ordinal); + + private static bool IsRpcServiceAttribute(AttributeData attribute) + { + return IsAttribute(attribute, "SharpLink.Sdk", "RpcServiceAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "RpcServiceAttribute"); + } + + private static bool IsOnewayAttribute(AttributeData attribute) + { + return IsAttribute(attribute, "SharpLink.Sdk", "OnewayAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "OnewayAttribute"); + } + + private static bool IsTimeoutAttribute(AttributeData attribute) + { + return IsAttribute(attribute, "SharpLink.Sdk", "TimeoutAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "TimeoutAttribute"); + } + + private static bool IsIdempotentAttribute(AttributeData attribute) + { + return IsAttribute(attribute, "SharpLink.Sdk", "IdempotentAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "IdempotentAttribute"); + } + + private static bool IsNonCancellableAttribute(AttributeData attribute) + { + return IsAttribute(attribute, "SharpLink.Sdk", "NonCancellableAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "NonCancellableAttribute"); + } + + private static double? GetTimeoutSecondsOrNull(IMethodSymbol method, out bool hasTimeoutAttribute) + { + hasTimeoutAttribute = false; + foreach (var attribute in method.GetAttributes()) + { + if (!IsTimeoutAttribute(attribute)) + continue; + + hasTimeoutAttribute = true; + if (attribute.ConstructorArguments.Length == 0) + return null; + + return TryGetTimeoutSeconds(attribute, out var seconds) && + TryValidateTimeoutSeconds(seconds, out _) + ? seconds + : null; + } + + return null; + } + + private static bool TryGetTimeoutSeconds(AttributeData attribute, out double seconds) + { + seconds = default; + if (attribute.ConstructorArguments.Length == 0 || attribute.ConstructorArguments[0].Value is null) + return false; + + switch (attribute.ConstructorArguments[0].Value) + { + case double value: + seconds = value; + return true; + case float value: + seconds = value; + return true; + case int value: + seconds = value; + return true; + case long value: + seconds = value; + return true; + default: + return false; + } + } + + private static bool TryValidateTimeoutSeconds(double seconds, out string detail) + { + if (double.IsNaN(seconds) || double.IsInfinity(seconds) || seconds <= 0) + { + detail = "seconds must be a finite number greater than zero"; + return false; + } + + try + { + if (TimeSpan.FromSeconds(seconds) <= TimeSpan.Zero) + { + detail = "seconds is too small to produce a positive TimeSpan"; + return false; + } + } + catch (OverflowException) + { + detail = "seconds exceeds the supported TimeSpan range"; + return false; + } + catch (ArgumentOutOfRangeException) + { + detail = "seconds exceeds the supported TimeSpan range"; + return false; + } + + detail = string.Empty; + return true; + } +} \ No newline at end of file From 0561582e05d59528dbebbadba926c7fac217b206 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:27:11 +0800 Subject: [PATCH 283/399] refactor: split generator contract modeling --- .../RpcGenerator.ContractModeling.cs | 647 ++++++++++++++++++ 1 file changed, 647 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.ContractModeling.cs diff --git a/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs b/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs new file mode 100644 index 000000000..2f15a9af8 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs @@ -0,0 +1,647 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static bool IsSupportedRpcReturnType(ITypeSymbol type) + { + if (type is not INamedTypeSymbol named) + return false; + + var ns = named.ContainingNamespace.ToDisplayString(); + var original = named.OriginalDefinition; + + if (ns != "System.Threading.Tasks") + return ns == "System.Collections.Generic" && original is { Name: "IAsyncEnumerable", Arity: 1 }; + return original switch + { + { Name: "Task", Arity: 0 or 1 } or { Name: "ValueTask", Arity: 0 or 1 } => true, + _ => ns == "System.Collections.Generic" && original is { Name: "IAsyncEnumerable", Arity: 1 } + }; + } + + private static RpcInterfaceModel CreateInterfaceModel(INamedTypeSymbol symbol) + { + var ns = symbol.ContainingNamespace.IsGlobalNamespace ? "" : symbol.ContainingNamespace.ToDisplayString(); + + var methods = GetContractMethods(symbol) + .Select(m => + { + var returnType = GetTypeName(m.ReturnType); + var displayReturnType = m.ReturnType.ToDisplayString(FullyQualifiedNullableFormat); + var isGenericTask = m.ReturnType is INamedTypeSymbol { IsGenericType: true } && + m.ReturnType.ToDisplayString().StartsWith("System.Threading.Tasks"); + var genericArg = isGenericTask + ? GetTypeName(((INamedTypeSymbol)m.ReturnType).TypeArguments[0]) + : null; + var displayGenericArg = isGenericTask + ? ((INamedTypeSymbol)m.ReturnType).TypeArguments[0].ToDisplayString(FullyQualifiedNullableFormat) + : null; + + var isNonGenericTaskLike = m.ReturnType.ToDisplayString() is "System.Threading.Tasks.Task" or "System.Threading.Tasks.ValueTask"; + var isOneWay = m.GetAttributes().Any(IsOnewayAttribute); + var isIdempotent = m.GetAttributes().Any(IsIdempotentAttribute); + var timeoutSeconds = GetTimeoutSecondsOrNull(m, out var hasTimeoutAttribute); + var timeoutTicks = timeoutSeconds is { } seconds + ? TimeSpan.FromSeconds(seconds).Ticks + : (long?)null; + + var isStreamReturn = false; + string? streamItemType = null; + string? displayStreamItemType = null; + if (IsAsyncEnumerable(m.ReturnType, out var itemTypeSymbol)) + { + isStreamReturn = true; + streamItemType = GetTypeName(itemTypeSymbol!); + displayStreamItemType = itemTypeSymbol!.ToDisplayString(FullyQualifiedNullableFormat); + isGenericTask = false; + genericArg = null; + displayGenericArg = null; + } + + var paramArray = m.Parameters.Select(p => + { + var pType = GetTypeName(p.Type); + var displayPType = p.Type.ToDisplayString(FullyQualifiedNullableFormat); + var isStream = IsAsyncEnumerable(p.Type, out var pItemType); + var isValueType = p.Type.IsValueType; + var isNullableReference = !isValueType && p.NullableAnnotation == NullableAnnotation.Annotated; + var payloadType = isStream ? pItemType! : p.Type; + var isCancellationToken = IsCancellationTokenParameter(p); + return new RpcParameterModel( + p.Name, + pType, + displayPType, + isStream, + isStream ? GetTypeName(pItemType!) : null, + isStream ? pItemType!.ToDisplayString(FullyQualifiedNullableFormat) : null, + IsInlineFixedRpcType(p.Type), + isValueType, + isNullableReference, + IsNullablePayload(payloadType), + isCancellationToken, + GetEnumUnderlyingType(p.Type), + pItemType is null ? null : GetEnumUnderlyingType(pItemType), + p.Locations.FirstOrDefault()); + }).ToImmutableArray(); + + var paramTypes = m.Parameters + .Where(static parameter => + !IsCancellationTokenParameter(parameter)) + .Select(static p => GetTypeName(p.Type)) + .ToArray(); + var methodHash = Hashing.GetMethodHash(m.Name, paramTypes); + + var requestSchema = string.Join(";", paramArray + .Where(static parameter => !parameter.IsCancellationToken) + .Select(static parameter => + $"{parameter.Name}:{parameter.Type}:{(parameter.IsStream ? "stream" : "value")}:{(parameter.PayloadNullable ? "nullable" : "required")}")); + var responsePayload = isGenericTask + ? ((INamedTypeSymbol)m.ReturnType).TypeArguments[0] + : itemTypeSymbol; + var responseNullable = responsePayload is not null && IsNullablePayload(responsePayload); + var responseSchema = isStreamReturn + ? $"stream:{streamItemType}" + : $"value:{returnType}"; + if (responseNullable) + responseSchema += ":nullable"; + var kind = isOneWay ? "OneWay" : isStreamReturn + ? (paramArray.Any(static parameter => parameter.IsStream) ? "DuplexStreaming" : "ServerStreaming") + : paramArray.Any(static parameter => parameter.IsStream) ? "ClientStreaming" : "Unary"; + var canonical = $"{m.Name}|{methodHash}|{kind}|{requestSchema}|{responseSchema}|cancel={paramArray.Any(static parameter => parameter.IsCancellationToken)}|timeout={hasTimeoutAttribute}:{timeoutTicks?.ToString(CultureInfo.InvariantCulture)}|idempotent={isIdempotent}"; + + return new RpcMethodModel( + Name: m.Name, + ReturnType: returnType, + DisplayReturnType: displayReturnType, + IsGenericTask: isGenericTask, + IsStreamReturn: isStreamReturn, + StreamItemType: streamItemType, + DisplayStreamItemType: displayStreamItemType, + GenericArgumentType: genericArg, + DisplayGenericArgumentType: displayGenericArg, + IsVoid: m.ReturnsVoid || isNonGenericTaskLike, + IsOneWay: isOneWay, + HasCancellationToken: paramArray.Any(p => p.IsCancellationToken), + HasTimeoutAttribute: hasTimeoutAttribute, + TimeoutSeconds: timeoutSeconds, + IsIdempotent: isIdempotent, + Hash: methodHash, + Parameters: paramArray, + RequestSchema: requestSchema, + ResponseSchema: responseSchema, + Fingerprint: Hashing.GetSha256(canonical), + ResponseNullable: responseNullable, + ResponseEnumUnderlyingType: responsePayload is null ? null : GetEnumUnderlyingType(responsePayload), + StreamItemEnumUnderlyingType: itemTypeSymbol is null ? null : GetEnumUnderlyingType(itemTypeSymbol), + Location: m.Locations.FirstOrDefault()); + }).ToImmutableArray(); + + var fullname = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var interfaceHash = Hashing.GetInterfaceHash(fullname); + var canonicalContract = $"{fullname}|{interfaceHash}|" + string.Join("|", methods + .OrderBy(static method => method.Hash) + .Select(static method => method.Fingerprint)); + var dependencyTypes = GetContractMethods(symbol) + .SelectMany(static method => method.Parameters.Select(static parameter => parameter.Type) + .Append(method.ReturnType)); + return new RpcInterfaceModel( + GetGeneratedContractName(symbol), + ns, + fullname, + interfaceHash, + methods, + Hashing.GetSha256(canonicalContract), + GetArtifactAssemblyDependencies(symbol.ContainingAssembly, dependencyTypes), + symbol.Locations.FirstOrDefault()); + } + + private static string? GetEnumUnderlyingType(ITypeSymbol type) + => type is INamedTypeSymbol { TypeKind: TypeKind.Enum, EnumUnderlyingType: { } underlying } + ? underlying.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : null; + + private static bool IsInlineFixedRpcType(ITypeSymbol type) + { + if (type.TypeKind == TypeKind.Enum) + return true; + if (type.SpecialType is SpecialType.System_Boolean or SpecialType.System_Byte or SpecialType.System_SByte or + SpecialType.System_Int16 or SpecialType.System_UInt16 or + SpecialType.System_Char or SpecialType.System_Int32 or SpecialType.System_UInt32 or + SpecialType.System_Single or SpecialType.System_Int64 or SpecialType.System_UInt64 or + SpecialType.System_Double) + { + return true; + } + + return type.ToDisplayString() is "System.Half" or "System.Guid" or + "System.TimeSpan" or "System.Int128" or "System.UInt128"; + } + + private static bool IsNullablePayload(ITypeSymbol type) + => type.NullableAnnotation == NullableAnnotation.Annotated || + type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; + + private static ImmutableArray GetArtifactAssemblyDependencies( + IAssemblySymbol owner, + IEnumerable types) + { + var identities = new HashSet(StringComparer.Ordinal); + foreach (var type in types) + CollectArtifactAssemblyDependencies(owner, type, identities); + return identities.OrderBy(static identity => identity, StringComparer.Ordinal).ToImmutableArray(); + } + + private static void CollectArtifactAssemblyDependencies( + IAssemblySymbol owner, + ITypeSymbol type, + HashSet identities) + { + if (type is IArrayTypeSymbol array) + { + CollectArtifactAssemblyDependencies(owner, array.ElementType, identities); + return; + } + if (type is not INamedTypeSymbol named) + return; + + var assembly = named.ContainingAssembly; + if (assembly is not null && + !SymbolEqualityComparer.Default.Equals(assembly, owner) && + ReferencesSharpLinkSdk(assembly)) + { + identities.Add(assembly.Identity.ToString()); + } + foreach (var argument in named.TypeArguments) + CollectArtifactAssemblyDependencies(owner, argument, identities); + } + + private static ImmutableArray GetReferencedInterfaceModels( + Compilation compilation, + CancellationToken _) + { + var seen = new HashSet(StringComparer.Ordinal); + var models = ImmutableArray.CreateBuilder(); + var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); + + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) + continue; + + CollectReferencedInterfaces(assembly.GlobalNamespace, models, seen); + } + + return models + .OrderBy(static m => m.FullName, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static ImmutableArray GetReferencedServiceModels( + Compilation compilation, + CancellationToken _) + { + var seen = new HashSet(StringComparer.Ordinal); + var models = ImmutableArray.CreateBuilder(); + var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); + + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) + continue; + + CollectReferencedServices(assembly.GlobalNamespace, models, seen); + } + + return models + .OrderBy(static m => m.ServiceFullName, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static ImmutableArray AnalyzeStaticRouteConflicts( + Compilation compilation, + CancellationToken _) + { + var contracts = new List<(RpcInterfaceModel Model, string Owner, Location? Location)>(); + var services = new List<(RpcServiceModel Model, string Owner, Location? Location)>(); + var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); + + CollectStaticRouteModels(compilation.Assembly, contracts, services); + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly && + candidateAssemblyNames.Contains(assembly.Identity.Name)) + { + CollectStaticRouteModels(assembly, contracts, services); + } + } + + var conflicts = ImmutableArray.CreateBuilder(); + foreach (var group in contracts.GroupBy(static contract => contract.Model.Hash)) + { + var ordered = group + .OrderBy(static contract => contract.Owner, StringComparer.Ordinal) + .ThenBy(static contract => contract.Model.FullName, StringComparer.Ordinal) + .ToArray(); + if (ordered.Length < 2) + continue; + + var first = ordered[0]; + for (var index = 1; index < ordered.Length; index++) + { + var incoming = ordered[index]; + if (!string.Equals(first.Owner, incoming.Owner, StringComparison.Ordinal)) + { + conflicts.Add(new StaticRouteConflictModel( + StaticRouteConflictKind.Contract, + incoming.Model.FullName, + incoming.Model.Hash, + $"{first.Owner}:{first.Model.Fingerprint}", + $"{incoming.Owner}:{incoming.Model.Fingerprint}", + incoming.Location)); + } + + foreach (var firstMethod in first.Model.Methods) + { + var incomingMethod = incoming.Model.Methods.FirstOrDefault(method => method.Hash == firstMethod.Hash); + if (incomingMethod is null || + string.Equals(firstMethod.Fingerprint, incomingMethod.Fingerprint, StringComparison.Ordinal)) + { + continue; + } + conflicts.Add(new StaticRouteConflictModel( + StaticRouteConflictKind.Method, + $"{incoming.Model.FullName}.{incomingMethod.Name}", + incomingMethod.Hash, + firstMethod.Fingerprint, + incomingMethod.Fingerprint, + incoming.Location)); + } + } + } + + foreach (var group in services.GroupBy(static service => service.Model.Interface.Hash)) + { + var ordered = group + .OrderBy(static service => service.Owner, StringComparer.Ordinal) + .ThenBy(static service => service.Model.ServiceFullName, StringComparer.Ordinal) + .ToArray(); + if (ordered.Length < 2) + continue; + var first = ordered[0]; + for (var index = 1; index < ordered.Length; index++) + { + var incoming = ordered[index]; + conflicts.Add(new StaticRouteConflictModel( + StaticRouteConflictKind.Service, + incoming.Model.Interface.FullName, + incoming.Model.Interface.Hash, + first.Model.ServiceFullName, + incoming.Model.ServiceFullName, + incoming.Location)); + } + } + + return conflicts + .Distinct() + .OrderBy(static conflict => conflict.Kind) + .ThenBy(static conflict => conflict.Id) + .ToImmutableArray(); + } + + private static void CollectStaticRouteModels( + IAssemblySymbol assembly, + List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, + List<(RpcServiceModel Model, string Owner, Location? Location)> services) + => CollectStaticRouteModels(assembly.GlobalNamespace, assembly.Identity.ToString(), contracts, services); + + private static void CollectStaticRouteModels( + INamespaceSymbol namespaceSymbol, + string owner, + List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, + List<(RpcServiceModel Model, string Owner, Location? Location)> services) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + CollectStaticRouteModels(type, owner, contracts, services); + foreach (var child in namespaceSymbol.GetNamespaceMembers()) + CollectStaticRouteModels(child, owner, contracts, services); + } + + private static void CollectStaticRouteModels( + INamedTypeSymbol type, + string owner, + List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, + List<(RpcServiceModel Model, string Owner, Location? Location)> services) + { + if (type.TypeKind == TypeKind.Interface && HasRpcContractAttribute(type) && + InheritsIService(type) && !HasInvalidRpcMethod(type)) + { + contracts.Add((CreateInterfaceModel(type), owner, type.Locations.FirstOrDefault())); + } + + if (type.TypeKind == TypeKind.Class && !type.IsAbstract && !type.IsGenericType && + type.GetAttributes().Any(IsRpcServiceAttribute)) + { + var rpcContracts = type.AllInterfaces.Where(HasRpcContractAttribute).ToArray(); + var constructor = SelectServiceConstructor(type); + if (rpcContracts.Length == 1 && constructor is not null && + IsServiceConstructorSupported(constructor, out _) && + !HasInvalidRpcMethod(rpcContracts[0])) + { + var serviceNamespace = type.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : type.ContainingNamespace.ToDisplayString(); + services.Add((new RpcServiceModel( + type.Name, + serviceNamespace, + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + CreateInterfaceModel(rpcContracts[0]), + GetServiceLifetime(type, out _), + constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(), + ImmutableArray.Create(rpcContracts[0].ContainingAssembly.Identity.ToString()), + type.Locations.FirstOrDefault()), + owner, + type.Locations.FirstOrDefault())); + } + } + + foreach (var nested in type.GetTypeMembers()) + CollectStaticRouteModels(nested, owner, contracts, services); + } + + private static HashSet ResolveReferenceAssemblyNames(Compilation compilation) + { + var explicitAssemblies = GetExplicitContractAssemblies(compilation); + if (explicitAssemblies is not null) + return explicitAssemblies; + + var assemblyNames = new HashSet(StringComparer.Ordinal); + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + if (ReferencesSharpLinkSdk(assembly)) + assemblyNames.Add(assembly.Identity.Name); + } + + return assemblyNames; + } + + private static HashSet? GetExplicitContractAssemblies(Compilation compilation) + { + HashSet? assemblyNames = null; + foreach (var attribute in compilation.Assembly.GetAttributes()) + { + if (!IsAttribute(attribute, "SharpLink.Sdk", "SharpLinkRpcContractsAttribute")) + continue; + + assemblyNames ??= new HashSet(StringComparer.Ordinal); + + if (attribute.ConstructorArguments.Length == 0) + continue; + + var argument = attribute.ConstructorArguments[0]; + if (argument.Kind != TypedConstantKind.Array) + continue; + + foreach (var item in argument.Values) + { + if (item.Value is INamedTypeSymbol type && type.ContainingAssembly is { } containingAssembly) + { + assemblyNames.Add(containingAssembly.Identity.Name); + } + } + } + + return assemblyNames; + } + + private static bool ReferencesSharpLinkSdk(IAssemblySymbol assembly) + { + foreach (var module in assembly.Modules) + { + foreach (var referencedAssembly in module.ReferencedAssemblySymbols) + { + if (string.Equals(referencedAssembly.Name, "SharpLink.Sdk", StringComparison.Ordinal)) + return true; + } + } + + return false; + } + + private static void CollectReferencedInterfaces( + INamespaceSymbol namespaceSymbol, + ImmutableArray.Builder models, + HashSet seen) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + CollectReferencedInterfaces(type, models, seen, containingTypesArePublic: true); + + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + CollectReferencedInterfaces(nestedNamespace, models, seen); + } + + private static void CollectReferencedInterfaces( + INamedTypeSymbol typeSymbol, + ImmutableArray.Builder models, + HashSet seen, + bool containingTypesArePublic) + { + var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); + if (isPubliclyReachable && + typeSymbol.TypeKind == TypeKind.Interface && + HasRpcContractAttribute(typeSymbol) && + InheritsIService(typeSymbol) && + !HasInvalidRpcMethod(typeSymbol)) + { + var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (seen.Add(fullName)) + models.Add(CreateInterfaceModel(typeSymbol)); + } + + if (!isPubliclyReachable) + return; + + foreach (var nested in typeSymbol.GetTypeMembers()) + CollectReferencedInterfaces(nested, models, seen, containingTypesArePublic: isPubliclyReachable); + } + + private static void CollectReferencedServices( + INamespaceSymbol namespaceSymbol, + ImmutableArray.Builder models, + HashSet seen) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + CollectReferencedServices(type, models, seen, containingTypesArePublic: true); + + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + CollectReferencedServices(nestedNamespace, models, seen); + } + + private static void CollectReferencedServices( + INamedTypeSymbol typeSymbol, + ImmutableArray.Builder models, + HashSet seen, + bool containingTypesArePublic) + { + var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); + if (isPubliclyReachable && + typeSymbol.TypeKind == TypeKind.Class && + !typeSymbol.IsAbstract && + typeSymbol.GetAttributes().Any(IsRpcServiceAttribute)) + { + var interfaceSymbol = FindRpcContractInterface(typeSymbol); + if (interfaceSymbol is not null && !HasInvalidRpcMethod(interfaceSymbol)) + { + var constructor = SelectServiceConstructor(typeSymbol); + if (constructor is not null && IsServiceConstructorSupported(constructor, out _)) + { + var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (seen.Add(fullName)) + { + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace ? "" : typeSymbol.ContainingNamespace.ToDisplayString(); + var parameters = constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(); + models.Add(new RpcServiceModel( + typeSymbol.Name, + ns, + fullName, + CreateInterfaceModel(interfaceSymbol), + GetServiceLifetime(typeSymbol, out _), + parameters, + ImmutableArray.Create(interfaceSymbol.ContainingAssembly.Identity.ToString()), + typeSymbol.Locations.FirstOrDefault())); + } + } + } + } + + if (!isPubliclyReachable) + return; + + foreach (var nested in typeSymbol.GetTypeMembers()) + CollectReferencedServices(nested, models, seen, containingTypesArePublic: isPubliclyReachable); + } + + private static bool IsPubliclyReachableType(INamedTypeSymbol typeSymbol) + => typeSymbol.DeclaredAccessibility == Accessibility.Public; + + private static bool HasRpcContractAttribute(INamedTypeSymbol symbol) + => symbol.GetAttributes().Any(static a => IsAttribute(a, "SharpLink.Sdk", "RpcContractAttribute")); + + private static INamedTypeSymbol? FindRpcContractInterface(INamedTypeSymbol serviceSymbol) + => serviceSymbol.AllInterfaces.FirstOrDefault(HasRpcContractAttribute); + + private static bool IsAttribute(AttributeData attribute, string ns, string name) + { + if (attribute.AttributeClass is not { } attrClass) + return false; + if (!string.Equals(attrClass.Name, name, StringComparison.Ordinal)) + return false; + return string.Equals(attrClass.ContainingNamespace.ToDisplayString(), ns, StringComparison.Ordinal); + } + + private static string GetProxyHintName(RpcInterfaceModel model) + { + var fullName = model.FullName; + if (fullName.StartsWith("global::", StringComparison.Ordinal)) + fullName = fullName.Substring("global::".Length); + var name = new StringBuilder(fullName.Length + 16); + foreach (var ch in fullName) + name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Proxy.g.cs"); + return name.ToString(); + } + + private static string GetStubHintName(RpcInterfaceModel model) + { + var fullName = model.FullName; + if (fullName.StartsWith("global::", StringComparison.Ordinal)) + fullName = fullName.Substring("global::".Length); + var name = new StringBuilder(fullName.Length + 16); + foreach (var ch in fullName) + name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Stub.g.cs"); + return name.ToString(); + } + + private static string GetProxyArtifactHintName(RpcInterfaceModel model) + { + var fullName = model.FullName; + if (fullName.StartsWith("global::", StringComparison.Ordinal)) + fullName = fullName.Substring("global::".Length); + var name = new StringBuilder(fullName.Length + 16); + foreach (var ch in fullName) + name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_ProxyImpl.g.cs"); + return name.ToString(); + } + + private static string GetGeneratedContractName(INamedTypeSymbol symbol) + { + if (symbol.ContainingType is null) + return symbol.Name; + + var parts = new Stack(); + for (var current = symbol; current is not null; current = current.ContainingType) + parts.Push(current.Name); + var fullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return string.Join("_", parts) + "_" + Hashing.GetSha256(fullName).Substring(0, 8); + } + + private static string EscapeIdentifier(string identifier) + => Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(identifier) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None + ? "@" + identifier + : identifier; +} \ No newline at end of file From 2e731af37f36a42bdfbc9d8df905d32b2c637db7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:29:23 +0800 Subject: [PATCH 284/399] refactor: store normalized timeout semantics --- .../RpcGenerator.Models.cs | 266 +----------------- 1 file changed, 2 insertions(+), 264 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index 321741ca6..7151e9b21 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -36,7 +36,7 @@ internal record RpcMethodModel( bool IsOneWay, bool HasCancellationToken, bool HasTimeoutAttribute, - double? TimeoutSeconds, + long? TimeoutTicks, bool IsIdempotent, long Hash, EquatableArray Parameters, @@ -51,10 +51,6 @@ internal record RpcMethodModel( internal bool ReturnsValueTask => ReturnType.StartsWith( "global::System.Threading.Tasks.ValueTask", StringComparison.Ordinal); - - internal long? TimeoutTicks => TimeoutSeconds is { } seconds - ? TimeSpan.FromSeconds(seconds).Ticks - : null; } internal record RpcParameterModel( @@ -217,262 +213,4 @@ internal readonly record struct GeneratedCodecHashModel( ulong High, ulong Low); -internal readonly record struct RpcHashValue(ulong High, ulong Low) -{ - public string ToHex() - => High.ToString("x16", CultureInfo.InvariantCulture) + - Low.ToString("x16", CultureInfo.InvariantCulture); -} - -internal enum DtoDiagnosticKind -{ - Unsupported, - Cycle, - MemberIdCollision, - Constructor, - Depth, - AdapterRegistrationInvalid, - AdapterTypeInvalid, - SelectorConflict, - AdapterSelectionConflict, - AdapterBindingInvalid, - AdapterTargetInvalid, - AdapterIdentityConflict, - BuiltinAdapterOverride, - CustomCodecBindingInvalid, - CustomCodecTargetInvalid, - CustomCodecTypeInvalid, - CustomCodecIdentityInvalid, - CustomCodecSelectionConflict, - BuiltinCustomCodecOverride -} - -internal readonly record struct DtoDiagnosticModel( - DtoDiagnosticKind Kind, - string TypeName, - string Detail, - Location? Location); - -internal sealed record DtoGenerationResult( - ImmutableArray Codecs, - ImmutableArray ContractCodecs, - ImmutableArray FinalCodecBoundTypes, - ImmutableArray Diagnostics, - ImmutableArray Enums) -{ - public ImmutableArray CodecHashes { get; init; } = - ImmutableArray.Empty; - public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } = - ImmutableArray.Empty; - public string AssemblyLogicalIdentity { get; init; } = string.Empty; -} - -internal sealed record GeneratedEnumModel( - string TypeName, - string UnderlyingType, - Location? Location); - -internal sealed class DtoGenerationResultComparer : IEqualityComparer -{ - internal static DtoGenerationResultComparer Instance { get; } = new(); - - public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) - { - if (ReferenceEquals(x, y)) - return true; - if (x is null || y is null || x.Codecs.Length != y.Codecs.Length || - x.ContractCodecs.Length != y.ContractCodecs.Length || - x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || - x.CodecHashes.Length != y.CodecHashes.Length || - x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length || - x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || - !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal)) - { - return false; - } - for (var index = 0; index < x.Codecs.Length; index++) - { - if (!CodecEquals(x.Codecs[index], y.Codecs[index])) - return false; - } - for (var index = 0; index < x.ContractCodecs.Length; index++) - { - if (!CodecEquals(x.ContractCodecs[index], y.ContractCodecs[index])) - return false; - } - if (!x.FinalCodecBoundTypes.SequenceEqual(y.FinalCodecBoundTypes, StringComparer.Ordinal)) - return false; - for (var index = 0; index < x.CodecHashes.Length; index++) - { - if (x.CodecHashes[index] != y.CodecHashes[index]) - return false; - } - for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++) - { - var left = x.UnsafeBlitAutoLayoutDiagnostics[index]; - var right = y.UnsafeBlitAutoLayoutDiagnostics[index]; - if (!string.Equals(left.PayloadType, right.PayloadType, StringComparison.Ordinal) || - !string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - !string.Equals(left.FieldPath, right.FieldPath, StringComparison.Ordinal)) - { - return false; - } - } - for (var index = 0; index < x.Diagnostics.Length; index++) - { - var left = x.Diagnostics[index]; - var right = y.Diagnostics[index]; - if (left.Kind != right.Kind || - !string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - !string.Equals(left.Detail, right.Detail, StringComparison.Ordinal)) - { - return false; - } - } - for (var index = 0; index < x.Enums.Length; index++) - { - var left = x.Enums[index]; - var right = y.Enums[index]; - if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - !string.Equals(left.UnderlyingType, right.UnderlyingType, StringComparison.Ordinal)) - { - return false; - } - } - return true; - } - - public int GetHashCode(DtoGenerationResult obj) - { - var hash = 17; - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(obj.AssemblyLogicalIdentity)); - foreach (var codec in obj.Codecs) - { - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.TypeName)); - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.SchemaId)); - hash = unchecked(hash * 31 + codec.CodecHashHigh.GetHashCode()); - hash = unchecked(hash * 31 + codec.CodecHashLow.GetHashCode()); - } - foreach (var codec in obj.ContractCodecs) - { - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.TypeName)); - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.SchemaId)); - hash = unchecked(hash * 31 + codec.CodecHashHigh.GetHashCode()); - hash = unchecked(hash * 31 + codec.CodecHashLow.GetHashCode()); - } - foreach (var type in obj.FinalCodecBoundTypes) - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(type)); - foreach (var codecHash in obj.CodecHashes) - { - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName)); - hash = unchecked(hash * 31 + codecHash.High.GetHashCode()); - hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); - } - foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics) - { - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.PayloadType)); - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.TypeName)); - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.FieldPath)); - } - foreach (var diagnostic in obj.Diagnostics) - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.Detail)); - foreach (var item in obj.Enums) - { - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(item.TypeName)); - hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(item.UnderlyingType)); - } - return hash; - } - - private static bool CodecEquals(GeneratedCodecModel left, GeneratedCodecModel right) - { - if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - !string.Equals(left.CodecName, right.CodecName, StringComparison.Ordinal) || - !string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) || - left.CodecHashHigh != right.CodecHashHigh || - left.CodecHashLow != right.CodecHashLow || - left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || - !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || - !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || - !string.Equals(left.ValueType, right.ValueType, StringComparison.Ordinal) || - !string.Equals(left.CustomCodecType, right.CustomCodecType, StringComparison.Ordinal) || - !string.Equals(left.AdapterType, right.AdapterType, StringComparison.Ordinal) || - !string.Equals(left.AdapterId, right.AdapterId, StringComparison.Ordinal) || - !string.Equals(left.WireFormatId, right.WireFormatId, StringComparison.Ordinal) || - !left.ConstructorMembers.SequenceEqual(right.ConstructorMembers, StringComparer.Ordinal) || - !left.AssemblyDependencies.SequenceEqual(right.AssemblyDependencies, StringComparer.Ordinal) || - left.Members.Length != right.Members.Length) - { - return false; - } - for (var index = 0; index < left.Members.Length; index++) - { - var first = left.Members[index]; - var second = right.Members[index]; - if (first with { Location = null } != second with { Location = null }) - return false; - } - return true; - } -} - -internal static class Hashing -{ - private const ulong FnvPrime = 1099511628211; - private const ulong FnvOffsetBasis = 14695981039346656037; - - public static long GetMethodHash(string mName, string[] pNames) - { - var cleanP = string.Join(",", pNames).Replace("global::", "").Replace(" ", ""); - return (long)Hash($"{mName}({cleanP})"); - } - - public static long GetInterfaceHash(string iName) - { - return (long)Hash(iName.Replace("global::", "").Replace(" ", "")); - } - - public static string GetIdentifierHash(string value) - => Hash(value).ToString("x16", CultureInfo.InvariantCulture); - - public static RpcHashValue GetSemanticHash(params string[] parts) - { - var canonical = new StringBuilder(); - foreach (var part in parts) - { - var value = part ?? string.Empty; - canonical.Append(value.Length.ToString(CultureInfo.InvariantCulture)) - .Append(':') - .Append(value); - } - - var hex = GetSha256(canonical.ToString()); - return new RpcHashValue( - ulong.Parse(hex.Substring(0, 16), NumberStyles.HexNumber, CultureInfo.InvariantCulture), - ulong.Parse(hex.Substring(16, 16), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); - } - - public static string GetSha256(string value) - { - using (var sha = System.Security.Cryptography.SHA256.Create()) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(value); - var hash = sha.ComputeHash(bytes); - var result = new StringBuilder(hash.Length * 2); - for (var index = 0; index < hash.Length; index++) - result.Append(hash[index].ToString("x2", CultureInfo.InvariantCulture)); - return result.ToString(); - } - } - - private static ulong Hash(string s) - { - ulong hash = FnvOffsetBasis; - foreach (var c in s) - { - hash ^= c; - hash *= FnvPrime; - } - return hash; - } -} +internal readonly record struct RpcHashValue(ulong High, ulong Low) \ No newline at end of file From 28075ef2257fe2ea0e150b71fe76045928c15923 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:30:00 +0800 Subject: [PATCH 285/399] refactor: normalize method timeout once --- .../RpcGenerator.MethodSemantics.cs | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs b/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs index ce7966156..2fa1d9b49 100644 --- a/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs +++ b/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs @@ -5,7 +5,6 @@ public partial class RpcGenerator private static bool IsCancellationTokenParameter(IParameterSymbol parameter) => parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken"; - private static bool HasValidControlParameterOrder(IMethodSymbol method) => !method.Parameters.Any(IsCancellationTokenParameter) || IsCancellationTokenParameter(method.Parameters[method.Parameters.Length - 1]); @@ -131,13 +130,9 @@ private static bool HasCompatibleInheritedRpcSemantics( var leftParameter = left.Parameters[index]; var rightParameter = right.Parameters[index]; if (IsCancellationTokenParameter(leftParameter)) - { continue; - } if (!string.Equals(leftParameter.Name, rightParameter.Name, StringComparison.Ordinal) || - !SymbolEqualityComparer.IncludeNullability.Equals( - leftParameter.Type, - rightParameter.Type)) + !SymbolEqualityComparer.IncludeNullability.Equals(leftParameter.Type, rightParameter.Type)) { return false; } @@ -180,9 +175,9 @@ attributeNamespace.Name is not ("Sdk" or "Abstractions")) case "TimeoutAttribute": hasTimeout = true; if (TryGetTimeoutSeconds(attribute, out var seconds) && - TryValidateTimeoutSeconds(seconds, out _)) + TryNormalizeTimeoutSeconds(seconds, out var ticks, out _)) { - timeoutTicks = TimeSpan.FromSeconds(seconds).Ticks; + timeoutTicks = ticks; } break; } @@ -213,36 +208,26 @@ private static bool IsIService(INamedTypeSymbol symbol) string.Equals(symbol.ContainingNamespace.ToDisplayString(), "SharpLink.Sdk", StringComparison.Ordinal); private static bool IsRpcServiceAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "RpcServiceAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "RpcServiceAttribute"); - } + => IsAttribute(attribute, "SharpLink.Sdk", "RpcServiceAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "RpcServiceAttribute"); private static bool IsOnewayAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "OnewayAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "OnewayAttribute"); - } + => IsAttribute(attribute, "SharpLink.Sdk", "OnewayAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "OnewayAttribute"); private static bool IsTimeoutAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "TimeoutAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "TimeoutAttribute"); - } + => IsAttribute(attribute, "SharpLink.Sdk", "TimeoutAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "TimeoutAttribute"); private static bool IsIdempotentAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "IdempotentAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "IdempotentAttribute"); - } + => IsAttribute(attribute, "SharpLink.Sdk", "IdempotentAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "IdempotentAttribute"); private static bool IsNonCancellableAttribute(AttributeData attribute) - { - return IsAttribute(attribute, "SharpLink.Sdk", "NonCancellableAttribute") || - IsAttribute(attribute, "SharpLink.Abstractions", "NonCancellableAttribute"); - } + => IsAttribute(attribute, "SharpLink.Sdk", "NonCancellableAttribute") || + IsAttribute(attribute, "SharpLink.Abstractions", "NonCancellableAttribute"); - private static double? GetTimeoutSecondsOrNull(IMethodSymbol method, out bool hasTimeoutAttribute) + private static long? GetTimeoutTicksOrNull(IMethodSymbol method, out bool hasTimeoutAttribute) { hasTimeoutAttribute = false; foreach (var attribute in method.GetAttributes()) @@ -255,8 +240,8 @@ private static bool IsNonCancellableAttribute(AttributeData attribute) return null; return TryGetTimeoutSeconds(attribute, out var seconds) && - TryValidateTimeoutSeconds(seconds, out _) - ? seconds + TryNormalizeTimeoutSeconds(seconds, out var ticks, out _) + ? ticks : null; } @@ -289,7 +274,11 @@ private static bool TryGetTimeoutSeconds(AttributeData attribute, out double sec } private static bool TryValidateTimeoutSeconds(double seconds, out string detail) + => TryNormalizeTimeoutSeconds(seconds, out _, out detail); + + private static bool TryNormalizeTimeoutSeconds(double seconds, out long ticks, out string detail) { + ticks = default; if (double.IsNaN(seconds) || double.IsInfinity(seconds) || seconds <= 0) { detail = "seconds must be a finite number greater than zero"; @@ -298,11 +287,13 @@ private static bool TryValidateTimeoutSeconds(double seconds, out string detail) try { - if (TimeSpan.FromSeconds(seconds) <= TimeSpan.Zero) + var timeout = TimeSpan.FromSeconds(seconds); + if (timeout <= TimeSpan.Zero) { detail = "seconds is too small to produce a positive TimeSpan"; return false; } + ticks = timeout.Ticks; } catch (OverflowException) { From 0771449e3725f0398a0f88625fa4b8fec4ca69cd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:31:38 +0800 Subject: [PATCH 286/399] refactor: isolate contract model construction --- .../RpcGenerator.ContractModeling.cs | 439 +----------------- 1 file changed, 3 insertions(+), 436 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs b/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs index 2f15a9af8..ed7e15768 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs @@ -40,10 +40,7 @@ private static RpcInterfaceModel CreateInterfaceModel(INamedTypeSymbol symbol) var isNonGenericTaskLike = m.ReturnType.ToDisplayString() is "System.Threading.Tasks.Task" or "System.Threading.Tasks.ValueTask"; var isOneWay = m.GetAttributes().Any(IsOnewayAttribute); var isIdempotent = m.GetAttributes().Any(IsIdempotentAttribute); - var timeoutSeconds = GetTimeoutSecondsOrNull(m, out var hasTimeoutAttribute); - var timeoutTicks = timeoutSeconds is { } seconds - ? TimeSpan.FromSeconds(seconds).Ticks - : (long?)null; + var timeoutTicks = GetTimeoutTicksOrNull(m, out var hasTimeoutAttribute); var isStreamReturn = false; string? streamItemType = null; @@ -85,8 +82,7 @@ private static RpcInterfaceModel CreateInterfaceModel(INamedTypeSymbol symbol) }).ToImmutableArray(); var paramTypes = m.Parameters - .Where(static parameter => - !IsCancellationTokenParameter(parameter)) + .Where(static parameter => !IsCancellationTokenParameter(parameter)) .Select(static p => GetTypeName(p.Type)) .ToArray(); var methodHash = Hashing.GetMethodHash(m.Name, paramTypes); @@ -123,7 +119,7 @@ private static RpcInterfaceModel CreateInterfaceModel(INamedTypeSymbol symbol) IsOneWay: isOneWay, HasCancellationToken: paramArray.Any(p => p.IsCancellationToken), HasTimeoutAttribute: hasTimeoutAttribute, - TimeoutSeconds: timeoutSeconds, + TimeoutTicks: timeoutTicks, IsIdempotent: isIdempotent, Hash: methodHash, Parameters: paramArray, @@ -215,433 +211,4 @@ private static void CollectArtifactAssemblyDependencies( foreach (var argument in named.TypeArguments) CollectArtifactAssemblyDependencies(owner, argument, identities); } - - private static ImmutableArray GetReferencedInterfaceModels( - Compilation compilation, - CancellationToken _) - { - var seen = new HashSet(StringComparer.Ordinal); - var models = ImmutableArray.CreateBuilder(); - var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); - - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) - continue; - - if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) - continue; - - CollectReferencedInterfaces(assembly.GlobalNamespace, models, seen); - } - - return models - .OrderBy(static m => m.FullName, StringComparer.Ordinal) - .ToImmutableArray(); - } - - private static ImmutableArray GetReferencedServiceModels( - Compilation compilation, - CancellationToken _) - { - var seen = new HashSet(StringComparer.Ordinal); - var models = ImmutableArray.CreateBuilder(); - var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); - - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) - continue; - - if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) - continue; - - CollectReferencedServices(assembly.GlobalNamespace, models, seen); - } - - return models - .OrderBy(static m => m.ServiceFullName, StringComparer.Ordinal) - .ToImmutableArray(); - } - - private static ImmutableArray AnalyzeStaticRouteConflicts( - Compilation compilation, - CancellationToken _) - { - var contracts = new List<(RpcInterfaceModel Model, string Owner, Location? Location)>(); - var services = new List<(RpcServiceModel Model, string Owner, Location? Location)>(); - var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); - - CollectStaticRouteModels(compilation.Assembly, contracts, services); - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly && - candidateAssemblyNames.Contains(assembly.Identity.Name)) - { - CollectStaticRouteModels(assembly, contracts, services); - } - } - - var conflicts = ImmutableArray.CreateBuilder(); - foreach (var group in contracts.GroupBy(static contract => contract.Model.Hash)) - { - var ordered = group - .OrderBy(static contract => contract.Owner, StringComparer.Ordinal) - .ThenBy(static contract => contract.Model.FullName, StringComparer.Ordinal) - .ToArray(); - if (ordered.Length < 2) - continue; - - var first = ordered[0]; - for (var index = 1; index < ordered.Length; index++) - { - var incoming = ordered[index]; - if (!string.Equals(first.Owner, incoming.Owner, StringComparison.Ordinal)) - { - conflicts.Add(new StaticRouteConflictModel( - StaticRouteConflictKind.Contract, - incoming.Model.FullName, - incoming.Model.Hash, - $"{first.Owner}:{first.Model.Fingerprint}", - $"{incoming.Owner}:{incoming.Model.Fingerprint}", - incoming.Location)); - } - - foreach (var firstMethod in first.Model.Methods) - { - var incomingMethod = incoming.Model.Methods.FirstOrDefault(method => method.Hash == firstMethod.Hash); - if (incomingMethod is null || - string.Equals(firstMethod.Fingerprint, incomingMethod.Fingerprint, StringComparison.Ordinal)) - { - continue; - } - conflicts.Add(new StaticRouteConflictModel( - StaticRouteConflictKind.Method, - $"{incoming.Model.FullName}.{incomingMethod.Name}", - incomingMethod.Hash, - firstMethod.Fingerprint, - incomingMethod.Fingerprint, - incoming.Location)); - } - } - } - - foreach (var group in services.GroupBy(static service => service.Model.Interface.Hash)) - { - var ordered = group - .OrderBy(static service => service.Owner, StringComparer.Ordinal) - .ThenBy(static service => service.Model.ServiceFullName, StringComparer.Ordinal) - .ToArray(); - if (ordered.Length < 2) - continue; - var first = ordered[0]; - for (var index = 1; index < ordered.Length; index++) - { - var incoming = ordered[index]; - conflicts.Add(new StaticRouteConflictModel( - StaticRouteConflictKind.Service, - incoming.Model.Interface.FullName, - incoming.Model.Interface.Hash, - first.Model.ServiceFullName, - incoming.Model.ServiceFullName, - incoming.Location)); - } - } - - return conflicts - .Distinct() - .OrderBy(static conflict => conflict.Kind) - .ThenBy(static conflict => conflict.Id) - .ToImmutableArray(); - } - - private static void CollectStaticRouteModels( - IAssemblySymbol assembly, - List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, - List<(RpcServiceModel Model, string Owner, Location? Location)> services) - => CollectStaticRouteModels(assembly.GlobalNamespace, assembly.Identity.ToString(), contracts, services); - - private static void CollectStaticRouteModels( - INamespaceSymbol namespaceSymbol, - string owner, - List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, - List<(RpcServiceModel Model, string Owner, Location? Location)> services) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - CollectStaticRouteModels(type, owner, contracts, services); - foreach (var child in namespaceSymbol.GetNamespaceMembers()) - CollectStaticRouteModels(child, owner, contracts, services); - } - - private static void CollectStaticRouteModels( - INamedTypeSymbol type, - string owner, - List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, - List<(RpcServiceModel Model, string Owner, Location? Location)> services) - { - if (type.TypeKind == TypeKind.Interface && HasRpcContractAttribute(type) && - InheritsIService(type) && !HasInvalidRpcMethod(type)) - { - contracts.Add((CreateInterfaceModel(type), owner, type.Locations.FirstOrDefault())); - } - - if (type.TypeKind == TypeKind.Class && !type.IsAbstract && !type.IsGenericType && - type.GetAttributes().Any(IsRpcServiceAttribute)) - { - var rpcContracts = type.AllInterfaces.Where(HasRpcContractAttribute).ToArray(); - var constructor = SelectServiceConstructor(type); - if (rpcContracts.Length == 1 && constructor is not null && - IsServiceConstructorSupported(constructor, out _) && - !HasInvalidRpcMethod(rpcContracts[0])) - { - var serviceNamespace = type.ContainingNamespace.IsGlobalNamespace - ? string.Empty - : type.ContainingNamespace.ToDisplayString(); - services.Add((new RpcServiceModel( - type.Name, - serviceNamespace, - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - CreateInterfaceModel(rpcContracts[0]), - GetServiceLifetime(type, out _), - constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( - parameter.Name, - parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(), - ImmutableArray.Create(rpcContracts[0].ContainingAssembly.Identity.ToString()), - type.Locations.FirstOrDefault()), - owner, - type.Locations.FirstOrDefault())); - } - } - - foreach (var nested in type.GetTypeMembers()) - CollectStaticRouteModels(nested, owner, contracts, services); - } - - private static HashSet ResolveReferenceAssemblyNames(Compilation compilation) - { - var explicitAssemblies = GetExplicitContractAssemblies(compilation); - if (explicitAssemblies is not null) - return explicitAssemblies; - - var assemblyNames = new HashSet(StringComparer.Ordinal); - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) - continue; - - if (ReferencesSharpLinkSdk(assembly)) - assemblyNames.Add(assembly.Identity.Name); - } - - return assemblyNames; - } - - private static HashSet? GetExplicitContractAssemblies(Compilation compilation) - { - HashSet? assemblyNames = null; - foreach (var attribute in compilation.Assembly.GetAttributes()) - { - if (!IsAttribute(attribute, "SharpLink.Sdk", "SharpLinkRpcContractsAttribute")) - continue; - - assemblyNames ??= new HashSet(StringComparer.Ordinal); - - if (attribute.ConstructorArguments.Length == 0) - continue; - - var argument = attribute.ConstructorArguments[0]; - if (argument.Kind != TypedConstantKind.Array) - continue; - - foreach (var item in argument.Values) - { - if (item.Value is INamedTypeSymbol type && type.ContainingAssembly is { } containingAssembly) - { - assemblyNames.Add(containingAssembly.Identity.Name); - } - } - } - - return assemblyNames; - } - - private static bool ReferencesSharpLinkSdk(IAssemblySymbol assembly) - { - foreach (var module in assembly.Modules) - { - foreach (var referencedAssembly in module.ReferencedAssemblySymbols) - { - if (string.Equals(referencedAssembly.Name, "SharpLink.Sdk", StringComparison.Ordinal)) - return true; - } - } - - return false; - } - - private static void CollectReferencedInterfaces( - INamespaceSymbol namespaceSymbol, - ImmutableArray.Builder models, - HashSet seen) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - CollectReferencedInterfaces(type, models, seen, containingTypesArePublic: true); - - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - CollectReferencedInterfaces(nestedNamespace, models, seen); - } - - private static void CollectReferencedInterfaces( - INamedTypeSymbol typeSymbol, - ImmutableArray.Builder models, - HashSet seen, - bool containingTypesArePublic) - { - var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); - if (isPubliclyReachable && - typeSymbol.TypeKind == TypeKind.Interface && - HasRpcContractAttribute(typeSymbol) && - InheritsIService(typeSymbol) && - !HasInvalidRpcMethod(typeSymbol)) - { - var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (seen.Add(fullName)) - models.Add(CreateInterfaceModel(typeSymbol)); - } - - if (!isPubliclyReachable) - return; - - foreach (var nested in typeSymbol.GetTypeMembers()) - CollectReferencedInterfaces(nested, models, seen, containingTypesArePublic: isPubliclyReachable); - } - - private static void CollectReferencedServices( - INamespaceSymbol namespaceSymbol, - ImmutableArray.Builder models, - HashSet seen) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - CollectReferencedServices(type, models, seen, containingTypesArePublic: true); - - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - CollectReferencedServices(nestedNamespace, models, seen); - } - - private static void CollectReferencedServices( - INamedTypeSymbol typeSymbol, - ImmutableArray.Builder models, - HashSet seen, - bool containingTypesArePublic) - { - var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); - if (isPubliclyReachable && - typeSymbol.TypeKind == TypeKind.Class && - !typeSymbol.IsAbstract && - typeSymbol.GetAttributes().Any(IsRpcServiceAttribute)) - { - var interfaceSymbol = FindRpcContractInterface(typeSymbol); - if (interfaceSymbol is not null && !HasInvalidRpcMethod(interfaceSymbol)) - { - var constructor = SelectServiceConstructor(typeSymbol); - if (constructor is not null && IsServiceConstructorSupported(constructor, out _)) - { - var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (seen.Add(fullName)) - { - var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace ? "" : typeSymbol.ContainingNamespace.ToDisplayString(); - var parameters = constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( - parameter.Name, - parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(); - models.Add(new RpcServiceModel( - typeSymbol.Name, - ns, - fullName, - CreateInterfaceModel(interfaceSymbol), - GetServiceLifetime(typeSymbol, out _), - parameters, - ImmutableArray.Create(interfaceSymbol.ContainingAssembly.Identity.ToString()), - typeSymbol.Locations.FirstOrDefault())); - } - } - } - } - - if (!isPubliclyReachable) - return; - - foreach (var nested in typeSymbol.GetTypeMembers()) - CollectReferencedServices(nested, models, seen, containingTypesArePublic: isPubliclyReachable); - } - - private static bool IsPubliclyReachableType(INamedTypeSymbol typeSymbol) - => typeSymbol.DeclaredAccessibility == Accessibility.Public; - - private static bool HasRpcContractAttribute(INamedTypeSymbol symbol) - => symbol.GetAttributes().Any(static a => IsAttribute(a, "SharpLink.Sdk", "RpcContractAttribute")); - - private static INamedTypeSymbol? FindRpcContractInterface(INamedTypeSymbol serviceSymbol) - => serviceSymbol.AllInterfaces.FirstOrDefault(HasRpcContractAttribute); - - private static bool IsAttribute(AttributeData attribute, string ns, string name) - { - if (attribute.AttributeClass is not { } attrClass) - return false; - if (!string.Equals(attrClass.Name, name, StringComparison.Ordinal)) - return false; - return string.Equals(attrClass.ContainingNamespace.ToDisplayString(), ns, StringComparison.Ordinal); - } - - private static string GetProxyHintName(RpcInterfaceModel model) - { - var fullName = model.FullName; - if (fullName.StartsWith("global::", StringComparison.Ordinal)) - fullName = fullName.Substring("global::".Length); - var name = new StringBuilder(fullName.Length + 16); - foreach (var ch in fullName) - name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); - name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Proxy.g.cs"); - return name.ToString(); - } - - private static string GetStubHintName(RpcInterfaceModel model) - { - var fullName = model.FullName; - if (fullName.StartsWith("global::", StringComparison.Ordinal)) - fullName = fullName.Substring("global::".Length); - var name = new StringBuilder(fullName.Length + 16); - foreach (var ch in fullName) - name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); - name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Stub.g.cs"); - return name.ToString(); - } - - private static string GetProxyArtifactHintName(RpcInterfaceModel model) - { - var fullName = model.FullName; - if (fullName.StartsWith("global::", StringComparison.Ordinal)) - fullName = fullName.Substring("global::".Length); - var name = new StringBuilder(fullName.Length + 16); - foreach (var ch in fullName) - name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); - name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_ProxyImpl.g.cs"); - return name.ToString(); - } - - private static string GetGeneratedContractName(INamedTypeSymbol symbol) - { - if (symbol.ContainingType is null) - return symbol.Name; - - var parts = new Stack(); - for (var current = symbol; current is not null; current = current.ContainingType) - parts.Push(current.Name); - var fullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - return string.Join("_", parts) + "_" + Hashing.GetSha256(fullName).Substring(0, 8); - } - - private static string EscapeIdentifier(string identifier) - => Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(identifier) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None - ? "@" + identifier - : identifier; } \ No newline at end of file From 2526b906f44249039bf4d5f799581ef28cb07315 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:32:24 +0800 Subject: [PATCH 287/399] refactor: isolate referenced contract analysis --- .../RpcGenerator.ReferenceAnalysis.cs | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs diff --git a/src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs b/src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs new file mode 100644 index 000000000..ed83113e6 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs @@ -0,0 +1,433 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static ImmutableArray GetReferencedInterfaceModels( + Compilation compilation, + CancellationToken _) + { + var seen = new HashSet(StringComparer.Ordinal); + var models = ImmutableArray.CreateBuilder(); + var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); + + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) + continue; + + CollectReferencedInterfaces(assembly.GlobalNamespace, models, seen); + } + + return models + .OrderBy(static m => m.FullName, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static ImmutableArray GetReferencedServiceModels( + Compilation compilation, + CancellationToken _) + { + var seen = new HashSet(StringComparer.Ordinal); + var models = ImmutableArray.CreateBuilder(); + var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); + + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + if (!candidateAssemblyNames.Contains(assembly.Identity.Name)) + continue; + + CollectReferencedServices(assembly.GlobalNamespace, models, seen); + } + + return models + .OrderBy(static m => m.ServiceFullName, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static ImmutableArray AnalyzeStaticRouteConflicts( + Compilation compilation, + CancellationToken _) + { + var contracts = new List<(RpcInterfaceModel Model, string Owner, Location? Location)>(); + var services = new List<(RpcServiceModel Model, string Owner, Location? Location)>(); + var candidateAssemblyNames = ResolveReferenceAssemblyNames(compilation); + + CollectStaticRouteModels(compilation.Assembly, contracts, services); + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly && + candidateAssemblyNames.Contains(assembly.Identity.Name)) + { + CollectStaticRouteModels(assembly, contracts, services); + } + } + + var conflicts = ImmutableArray.CreateBuilder(); + foreach (var group in contracts.GroupBy(static contract => contract.Model.Hash)) + { + var ordered = group + .OrderBy(static contract => contract.Owner, StringComparer.Ordinal) + .ThenBy(static contract => contract.Model.FullName, StringComparer.Ordinal) + .ToArray(); + if (ordered.Length < 2) + continue; + + var first = ordered[0]; + for (var index = 1; index < ordered.Length; index++) + { + var incoming = ordered[index]; + if (!string.Equals(first.Owner, incoming.Owner, StringComparison.Ordinal)) + { + conflicts.Add(new StaticRouteConflictModel( + StaticRouteConflictKind.Contract, + incoming.Model.FullName, + incoming.Model.Hash, + $"{first.Owner}:{first.Model.Fingerprint}", + $"{incoming.Owner}:{incoming.Model.Fingerprint}", + incoming.Location)); + } + + foreach (var firstMethod in first.Model.Methods) + { + var incomingMethod = incoming.Model.Methods.FirstOrDefault(method => method.Hash == firstMethod.Hash); + if (incomingMethod is null || + string.Equals(firstMethod.Fingerprint, incomingMethod.Fingerprint, StringComparison.Ordinal)) + { + continue; + } + conflicts.Add(new StaticRouteConflictModel( + StaticRouteConflictKind.Method, + $"{incoming.Model.FullName}.{incomingMethod.Name}", + incomingMethod.Hash, + firstMethod.Fingerprint, + incomingMethod.Fingerprint, + incoming.Location)); + } + } + } + + foreach (var group in services.GroupBy(static service => service.Model.Interface.Hash)) + { + var ordered = group + .OrderBy(static service => service.Owner, StringComparer.Ordinal) + .ThenBy(static service => service.Model.ServiceFullName, StringComparer.Ordinal) + .ToArray(); + if (ordered.Length < 2) + continue; + var first = ordered[0]; + for (var index = 1; index < ordered.Length; index++) + { + var incoming = ordered[index]; + conflicts.Add(new StaticRouteConflictModel( + StaticRouteConflictKind.Service, + incoming.Model.Interface.FullName, + incoming.Model.Interface.Hash, + first.Model.ServiceFullName, + incoming.Model.ServiceFullName, + incoming.Location)); + } + } + + return conflicts + .Distinct() + .OrderBy(static conflict => conflict.Kind) + .ThenBy(static conflict => conflict.Id) + .ToImmutableArray(); + } + + private static void CollectStaticRouteModels( + IAssemblySymbol assembly, + List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, + List<(RpcServiceModel Model, string Owner, Location? Location)> services) + => CollectStaticRouteModels(assembly.GlobalNamespace, assembly.Identity.ToString(), contracts, services); + + private static void CollectStaticRouteModels( + INamespaceSymbol namespaceSymbol, + string owner, + List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, + List<(RpcServiceModel Model, string Owner, Location? Location)> services) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + CollectStaticRouteModels(type, owner, contracts, services); + foreach (var child in namespaceSymbol.GetNamespaceMembers()) + CollectStaticRouteModels(child, owner, contracts, services); + } + + private static void CollectStaticRouteModels( + INamedTypeSymbol type, + string owner, + List<(RpcInterfaceModel Model, string Owner, Location? Location)> contracts, + List<(RpcServiceModel Model, string Owner, Location? Location)> services) + { + if (type.TypeKind == TypeKind.Interface && HasRpcContractAttribute(type) && + InheritsIService(type) && !HasInvalidRpcMethod(type)) + { + contracts.Add((CreateInterfaceModel(type), owner, type.Locations.FirstOrDefault())); + } + + if (type.TypeKind == TypeKind.Class && !type.IsAbstract && !type.IsGenericType && + type.GetAttributes().Any(IsRpcServiceAttribute)) + { + var rpcContracts = type.AllInterfaces.Where(HasRpcContractAttribute).ToArray(); + var constructor = SelectServiceConstructor(type); + if (rpcContracts.Length == 1 && constructor is not null && + IsServiceConstructorSupported(constructor, out _) && + !HasInvalidRpcMethod(rpcContracts[0])) + { + var serviceNamespace = type.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : type.ContainingNamespace.ToDisplayString(); + services.Add((new RpcServiceModel( + type.Name, + serviceNamespace, + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + CreateInterfaceModel(rpcContracts[0]), + GetServiceLifetime(type, out _), + constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(), + ImmutableArray.Create(rpcContracts[0].ContainingAssembly.Identity.ToString()), + type.Locations.FirstOrDefault()), + owner, + type.Locations.FirstOrDefault())); + } + } + + foreach (var nested in type.GetTypeMembers()) + CollectStaticRouteModels(nested, owner, contracts, services); + } + + private static HashSet ResolveReferenceAssemblyNames(Compilation compilation) + { + var explicitAssemblies = GetExplicitContractAssemblies(compilation); + if (explicitAssemblies is not null) + return explicitAssemblies; + + var assemblyNames = new HashSet(StringComparer.Ordinal); + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + if (ReferencesSharpLinkSdk(assembly)) + assemblyNames.Add(assembly.Identity.Name); + } + + return assemblyNames; + } + + private static HashSet? GetExplicitContractAssemblies(Compilation compilation) + { + HashSet? assemblyNames = null; + foreach (var attribute in compilation.Assembly.GetAttributes()) + { + if (!IsAttribute(attribute, "SharpLink.Sdk", "SharpLinkRpcContractsAttribute")) + continue; + + assemblyNames ??= new HashSet(StringComparer.Ordinal); + + if (attribute.ConstructorArguments.Length == 0) + continue; + + var argument = attribute.ConstructorArguments[0]; + if (argument.Kind != TypedConstantKind.Array) + continue; + + foreach (var item in argument.Values) + { + if (item.Value is INamedTypeSymbol type && type.ContainingAssembly is { } containingAssembly) + { + assemblyNames.Add(containingAssembly.Identity.Name); + } + } + } + + return assemblyNames; + } + + private static bool ReferencesSharpLinkSdk(IAssemblySymbol assembly) + { + foreach (var module in assembly.Modules) + { + foreach (var referencedAssembly in module.ReferencedAssemblySymbols) + { + if (string.Equals(referencedAssembly.Name, "SharpLink.Sdk", StringComparison.Ordinal)) + return true; + } + } + + return false; + } + + private static void CollectReferencedInterfaces( + INamespaceSymbol namespaceSymbol, + ImmutableArray.Builder models, + HashSet seen) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + CollectReferencedInterfaces(type, models, seen, containingTypesArePublic: true); + + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + CollectReferencedInterfaces(nestedNamespace, models, seen); + } + + private static void CollectReferencedInterfaces( + INamedTypeSymbol typeSymbol, + ImmutableArray.Builder models, + HashSet seen, + bool containingTypesArePublic) + { + var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); + if (isPubliclyReachable && + typeSymbol.TypeKind == TypeKind.Interface && + HasRpcContractAttribute(typeSymbol) && + InheritsIService(typeSymbol) && + !HasInvalidRpcMethod(typeSymbol)) + { + var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (seen.Add(fullName)) + models.Add(CreateInterfaceModel(typeSymbol)); + } + + if (!isPubliclyReachable) + return; + + foreach (var nested in typeSymbol.GetTypeMembers()) + CollectReferencedInterfaces(nested, models, seen, containingTypesArePublic: isPubliclyReachable); + } + + private static void CollectReferencedServices( + INamespaceSymbol namespaceSymbol, + ImmutableArray.Builder models, + HashSet seen) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + CollectReferencedServices(type, models, seen, containingTypesArePublic: true); + + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + CollectReferencedServices(nestedNamespace, models, seen); + } + + private static void CollectReferencedServices( + INamedTypeSymbol typeSymbol, + ImmutableArray.Builder models, + HashSet seen, + bool containingTypesArePublic) + { + var isPubliclyReachable = containingTypesArePublic && IsPubliclyReachableType(typeSymbol); + if (isPubliclyReachable && + typeSymbol.TypeKind == TypeKind.Class && + !typeSymbol.IsAbstract && + typeSymbol.GetAttributes().Any(IsRpcServiceAttribute)) + { + var interfaceSymbol = FindRpcContractInterface(typeSymbol); + if (interfaceSymbol is not null && !HasInvalidRpcMethod(interfaceSymbol)) + { + var constructor = SelectServiceConstructor(typeSymbol); + if (constructor is not null && IsServiceConstructorSupported(constructor, out _)) + { + var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (seen.Add(fullName)) + { + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace ? "" : typeSymbol.ContainingNamespace.ToDisplayString(); + var parameters = constructor.Parameters.Select(static parameter => new RpcConstructorParameterModel( + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))).ToImmutableArray(); + models.Add(new RpcServiceModel( + typeSymbol.Name, + ns, + fullName, + CreateInterfaceModel(interfaceSymbol), + GetServiceLifetime(typeSymbol, out _), + parameters, + ImmutableArray.Create(interfaceSymbol.ContainingAssembly.Identity.ToString()), + typeSymbol.Locations.FirstOrDefault())); + } + } + } + } + + if (!isPubliclyReachable) + return; + + foreach (var nested in typeSymbol.GetTypeMembers()) + CollectReferencedServices(nested, models, seen, containingTypesArePublic: isPubliclyReachable); + } + + private static bool IsPubliclyReachableType(INamedTypeSymbol typeSymbol) + => typeSymbol.DeclaredAccessibility == Accessibility.Public; + + private static bool HasRpcContractAttribute(INamedTypeSymbol symbol) + => symbol.GetAttributes().Any(static a => IsAttribute(a, "SharpLink.Sdk", "RpcContractAttribute")); + + private static INamedTypeSymbol? FindRpcContractInterface(INamedTypeSymbol serviceSymbol) + => serviceSymbol.AllInterfaces.FirstOrDefault(HasRpcContractAttribute); + + private static bool IsAttribute(AttributeData attribute, string ns, string name) + { + if (attribute.AttributeClass is not { } attrClass) + return false; + if (!string.Equals(attrClass.Name, name, StringComparison.Ordinal)) + return false; + return string.Equals(attrClass.ContainingNamespace.ToDisplayString(), ns, StringComparison.Ordinal); + } + + private static string GetProxyHintName(RpcInterfaceModel model) + { + var fullName = model.FullName; + if (fullName.StartsWith("global::", StringComparison.Ordinal)) + fullName = fullName.Substring("global::".Length); + var name = new StringBuilder(fullName.Length + 16); + foreach (var ch in fullName) + name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Proxy.g.cs"); + return name.ToString(); + } + + private static string GetStubHintName(RpcInterfaceModel model) + { + var fullName = model.FullName; + if (fullName.StartsWith("global::", StringComparison.Ordinal)) + fullName = fullName.Substring("global::".Length); + var name = new StringBuilder(fullName.Length + 16); + foreach (var ch in fullName) + name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_Stub.g.cs"); + return name.ToString(); + } + + private static string GetProxyArtifactHintName(RpcInterfaceModel model) + { + var fullName = model.FullName; + if (fullName.StartsWith("global::", StringComparison.Ordinal)) + fullName = fullName.Substring("global::".Length); + var name = new StringBuilder(fullName.Length + 16); + foreach (var ch in fullName) + name.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + name.Append('_').Append(unchecked((ulong)model.Hash).ToString("X16", InvariantCulture)).Append("_ProxyImpl.g.cs"); + return name.ToString(); + } + + private static string GetGeneratedContractName(INamedTypeSymbol symbol) + { + if (symbol.ContainingType is null) + return symbol.Name; + + var parts = new Stack(); + for (var current = symbol; current is not null; current = current.ContainingType) + parts.Push(current.Name); + var fullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return string.Join("_", parts) + "_" + Hashing.GetSha256(fullName).Substring(0, 8); + } + + private static string EscapeIdentifier(string identifier) + => Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(identifier) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None + ? "@" + identifier + : identifier; +} \ No newline at end of file From ace856cea8c457eb1c4d7495a62687816454a876 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:34:33 +0800 Subject: [PATCH 288/399] refactor: make final codec graph complete at resolution --- .../RpcGenerator.FinalCodecPlan.cs | 553 +----------------- 1 file changed, 17 insertions(+), 536 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs index bdb71e6e3..681696a54 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs @@ -40,547 +40,28 @@ internal FinalCodecGraph ResolveFinalCodecGraph( ResolveFinalCodecPlan(type, plans, resolving); } - return new FinalCodecGraph( - plans, - roots.Keys.Where(type => !_failed.Contains(type)) - .OrderBy(static type => type, StringComparer.Ordinal) - .ToImmutableArray()); - } - - private FinalCodecPlan ResolveFinalCodecPlan( - ITypeSymbol type, - Dictionary plans, - HashSet resolving) - { - var typeName = GetTypeName(type); - if (plans.TryGetValue(typeName, out var existing)) - return existing; - if (!resolving.Add(typeName)) - { - throw new InvalidOperationException( - $"Final Codec graph contains an unresolved recursive Codec selection at '{typeName}'."); - } - - FinalCodecPlan plan; - if (_models.TryGetValue(typeName, out var generatedModel)) - { - plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); - } - else if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) - { - plan = new FinalReferencedCodecPlan(typeName, referencedHash); - } - else if (type.TypeKind == TypeKind.Enum && - type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) - { - ResolveFinalCodecPlan(underlying, plans, resolving); - plan = new FinalEnumCodecPlan( - typeName, - GetTypeName(underlying), - GetEnumDeclarationSemanticIdentity(enumType)); - } - else if (type is INamedTypeSymbol nullable && - nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && - nullable.TypeArguments.Length == 1 && - HasExactBuiltinNullableCodecElement(nullable.TypeArguments[0])) - { - var child = ResolveFinalCodecPlan(nullable.TypeArguments[0], plans, resolving); - plan = new FinalPrimitiveCodecPlan( - typeName, - "nullable", - ImmutableArray.Empty, - child.TypeName); - } - else if (TryGetFrameworkScalarSemantic(type, out var scalarSemantic)) - { - plan = new FinalPrimitiveCodecPlan(typeName, "framework", scalarSemantic); - } - else if (TryGetCollection( - type, - out var collectionKind, - out var elementType, - out _, - out _)) - { - if (collectionKind == GeneratedCodecKind.Nullable && - elementType is not null && - type.IsUnmanagedType && - !HasExactBuiltinNullableCodecElement(elementType)) - { - plan = ResolveUnsafeBlitCodecPlan(type); - } - else if (TryResolveBuiltinCollectionPlan( - typeName, - collectionKind, - elementType, - out var builtinCollection)) - { - plan = builtinCollection; - } - else - { - throw new InvalidOperationException( - $"Final RPC Codec graph has no generated or runtime builtin collection selection for '{typeName}'."); - } - } - else if (type.IsUnmanagedType && !IsRuntimeSizedUnsafeBlitType(type)) - { - plan = ResolveUnsafeBlitCodecPlan(type); - } - else - { - throw new InvalidOperationException( - $"Final RPC Codec graph cannot resolve deterministic Codec semantics for '{typeName}'. Rebuild referenced SharpLink assemblies with deterministic identity generation enabled or bind an explicit Codec."); - } - - resolving.Remove(typeName); - plans[typeName] = plan; - return plan; - } - - private FinalCodecPlan ResolveGeneratedCodecPlan( - ITypeSymbol type, - GeneratedCodecModel model, - Dictionary plans, - HashSet resolving) - { - switch (model.Kind) + // Enum declaration semantics can be required by generated metadata even when the + // enclosing runtime Codec is a raw physical plan such as UnsafeBlit>. + // Materialize those reached enum nodes here so every downstream consumer observes the + // same complete final graph without consulting Roslyn again. + foreach (var enumModel in _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) { - case GeneratedCodecKind.Custom: - return new FinalCustomCodecPlan( - model.TypeName, - GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec")); - case GeneratedCodecKind.Adapter: - return new FinalAdapterCodecPlan( - model.TypeName, - GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter"), - GetAdapterTargetLogicalIdentity(type)); - case GeneratedCodecKind.Dto: - return ResolveGeneratedDtoPlan(type, model, plans, resolving); - default: - return ResolveGeneratedCollectionPlan(type, model, plans, resolving); - } - } - - private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( - ITypeSymbol type, - GeneratedCodecModel model, - Dictionary plans, - HashSet resolving) - { - var memberSymbols = type is INamedTypeSymbol named - ? GetSerializableMembers(named).ToDictionary(static item => item.Name, StringComparer.Ordinal) - : new Dictionary(StringComparer.Ordinal); - var members = ImmutableArray.CreateBuilder(model.Members.Length); - foreach (var member in model.Members.OrderBy(static item => item.FieldId)) - { - memberSymbols.TryGetValue(member.Name, out var memberSymbol); - var memberType = memberSymbol is null ? null : GetMemberType(memberSymbol); - switch (member.Kind) - { - case GeneratedMemberKind.String: - members.Add(CreateMember( - member, - FinalDtoMemberWireStrategy.String, - "string/content/utf16le/i32le-byte-length/v1|string/null/dto-wire-null/v1", - null)); - break; - case GeneratedMemberKind.Fixed: - case GeneratedMemberKind.NullableFixed: - members.Add(CreateMember( - member, - FinalDtoMemberWireStrategy.Fixed, - GetResolvedFixedMemberSemantic(member, memberType), - null)); - break; - case GeneratedMemberKind.Complex: - if (memberType is null && !TryResolveReachableType(member.TypeName, out memberType!)) - { - throw new InvalidOperationException( - $"Final Codec plan for '{model.TypeName}' cannot resolve child '{member.TypeName}'."); - } - var child = ResolveFinalCodecPlan(memberType, plans, resolving); - members.Add(CreateMember( - member, - FinalDtoMemberWireStrategy.ChildCodec, - null, - child.TypeName)); - break; - } - } - - return new FinalGeneratedDtoCodecPlan( - model.TypeName, - model.IsReferenceType, - members.ToImmutable()); - - static FinalDtoMemberPlan CreateMember( - GeneratedMemberModel member, - FinalDtoMemberWireStrategy strategy, - string? wireSemantic, - string? childType) - => new( - member.FieldId, - member.Kind, - member.Required, - member.Nullable, - member.NonNullableReference, - strategy, - wireSemantic, - childType); - } - - private FinalCollectionCodecPlan ResolveGeneratedCollectionPlan( - ITypeSymbol type, - GeneratedCodecModel model, - Dictionary plans, - HashSet resolving) - { - ITypeSymbol? element = null; - ITypeSymbol? key = null; - ITypeSymbol? value = null; - if (TryGetCollection(type, out _, out var resolvedElement, out var resolvedKey, out var resolvedValue)) - { - element = resolvedElement; - key = resolvedKey; - value = resolvedValue; - } - ResolveChild(element, model.ElementType); - ResolveChild(key, model.KeyType); - ResolveChild(value, model.ValueType); - return new FinalCollectionCodecPlan( - model.TypeName, - model.Kind, - FinalCollectionWireStrategy.ChildCodec, - model.ElementType, - model.KeyType, - model.ValueType, - RawElementLayout: null, - StrategySemantic: null); - - void ResolveChild(ITypeSymbol? symbol, string? childTypeName) - { - if (childTypeName is null) - return; - if (symbol is null && !TryResolveReachableType(childTypeName, out symbol!)) - { - throw new InvalidOperationException( - $"Final Codec plan for '{model.TypeName}' cannot resolve child '{childTypeName}'."); - } - ResolveFinalCodecPlan(symbol, plans, resolving); - } - } - - private bool TryResolveBuiltinCollectionPlan( - string typeName, - GeneratedCodecKind collectionKind, - ITypeSymbol? elementType, - out FinalCollectionCodecPlan plan) - { - if (elementType is null || - collectionKind is not (GeneratedCodecKind.Array or - GeneratedCodecKind.List or - GeneratedCodecKind.Memory or - GeneratedCodecKind.ReadOnlyMemory or - GeneratedCodecKind.ImmutableArray) || - !IsBuiltinBlitElement(elementType)) - { - plan = null!; - return false; - } - - if (string.Equals(elementType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) - { - plan = new FinalCollectionCodecPlan( - typeName, - collectionKind, - FinalCollectionWireStrategy.DateTimeOffsetCanonical, - GetTypeName(elementType), - null, - null, - RawElementLayout: null, - StrategySemantic: "datetime-offset/collection-raw16-padding2-7-zero/release-scoped/v1"); - return true; - } - - plan = new FinalCollectionCodecPlan( - typeName, - collectionKind, - FinalCollectionWireStrategy.RawBlit, - GetTypeName(elementType), - null, - null, - ResolvePhysicalLayout(elementType, GetTypeName(elementType), collectAutoLayoutHazards: false, null), - StrategySemantic: "builtin-blit-element/v2|abi:little-endian"); - return true; - } - - private string GetResolvedFixedMemberSemantic( - GeneratedMemberModel member, - ITypeSymbol? actualMemberType) - { - var semanticType = actualMemberType; - if (semanticType is INamedTypeSymbol nullable && - nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && - nullable.TypeArguments.Length == 1) - { - semanticType = nullable.TypeArguments[0]; - } - - if (semanticType is not null && - string.Equals(semanticType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) - { - return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; - } - if (semanticType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) - { - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - GetEnumDeclarationSemanticIdentity(enumType)); - } - - return string.Join( - ":", - "fixed/v1", - member.FixedSize.ToString(InvariantCulture), - member.FixedTypeName ?? member.EnumUnderlyingType ?? member.TypeName); - } - - private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) - { - var parts = new List - { - "enum-declaration/v1", - enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - }; - foreach (var field in enumType.GetMembers() - .OfType() - .Where(static field => field.HasConstantValue) - .OrderBy(static field => field.Name, StringComparer.Ordinal)) - { - parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); - } - return string.Join("|", parts); - } - - private static bool HasExactBuiltinNullableCodecElement(ITypeSymbol type) - => type.TypeKind != TypeKind.Enum && GetFixedSize(type) != 0; - - private static bool TryGetFrameworkScalarSemantic( - ITypeSymbol type, - out ImmutableArray semantic) - { - if (type.SpecialType == SpecialType.System_String) - { - semantic = ImmutableArray.Create( - "string/content/utf16le/i32le-byte-length/v1", - "string/null/i32-minus-one/v1"); - return true; - } - - string? token = type.SpecialType switch - { - SpecialType.System_Boolean => "bool/fixed1/v1", - SpecialType.System_Byte => "u8/fixed1/v1", - SpecialType.System_SByte => "i8/fixed1/v1", - SpecialType.System_Int16 => "i16/fixed2/v1", - SpecialType.System_UInt16 => "u16/fixed2/v1", - SpecialType.System_Char => "char/fixed2/v1", - SpecialType.System_Int32 => "i32/fixed4/v1", - SpecialType.System_UInt32 => "u32/fixed4/v1", - SpecialType.System_Single => "f32/fixed4/v1", - SpecialType.System_Int64 => "i64/fixed8/v1", - SpecialType.System_UInt64 => "u64/fixed8/v1", - SpecialType.System_Double => "f64/fixed8/v1", - SpecialType.System_Decimal => "decimal/fixed16/v1", - _ => null - }; - token ??= type.ToDisplayString() switch - { - "System.Half" => "half/fixed2/v1", - "System.Text.Rune" => "rune/fixed4/v1", - "System.Guid" => "guid/fixed16/v1", - "System.DateTimeOffset" => "datetime-offset/root-ticks-i64le-offset-minutes-i16le/v1", - "System.DateTime" => "datetime/fixed8/v1", - "System.DateOnly" => "date-only/fixed4/v1", - "System.TimeOnly" => "time-only/fixed8/v1", - "System.TimeSpan" => "timespan/fixed8/v1", - "System.Int128" => "i128/fixed16/v1", - "System.UInt128" => "u128/fixed16/v1", - "System.Index" => "index/fixed4/v1", - "System.Range" => "range/fixed8/v1", - _ => null - }; - semantic = token is null ? ImmutableArray.Empty : ImmutableArray.Create(token); - return token is not null; - } - - private bool TryGetReferencedGeneratedCodecHash(ITypeSymbol type, out RpcHashValue hash) - { - var assembly = type.ContainingAssembly; - if (assembly is null || SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly)) - { - hash = default; - return false; - } - foreach (var attribute in assembly.GetAttributes()) - { - if (!IsAttribute(attribute, "SharpLink.Abstractions", "SharpLinkGeneratedCodecIdentityAttribute") || - attribute.ConstructorArguments.Length != 3 || - attribute.ConstructorArguments[0].Value is not ITypeSymbol targetType || - !SymbolEqualityComparer.Default.Equals(targetType, type) || - attribute.ConstructorArguments[1].Value is not ulong high || - attribute.ConstructorArguments[2].Value is not ulong low) - { + if (_failed.Contains(enumModel.TypeName) || plans.ContainsKey(enumModel.TypeName)) continue; - } - hash = new RpcHashValue(high, low); - return true; - } - hash = default; - return false; - } - - private RpcHashValue GetRequiredOpaqueSemanticIdentity( - string? implementationTypeName, - string implementationKind) - { - if (TryGetOpaqueSemanticIdentity(implementationTypeName, out var hash)) - return hash; - throw new InvalidOperationException( - $"Opaque {implementationKind} '{implementationTypeName ?? ""}' must declare [RpcCodecSemanticIdentity(high, low)]."); - } - - private bool TryGetOpaqueSemanticIdentity(string? implementationTypeName, out RpcHashValue hash) - { - if (implementationTypeName is null) - { - hash = default; - return false; - } - if (_opaqueSemanticIdentityCache.TryGetValue(implementationTypeName, out var cached)) - { - hash = cached ?? default; - return cached.HasValue; - } - - var visited = new HashSet(StringComparer.Ordinal); - var pending = new Queue(); - pending.Enqueue(_compilation.Assembly); - while (pending.Count != 0) - { - var assembly = pending.Dequeue(); - if (!visited.Add(assembly.Identity.ToString())) - continue; - if (TryFindNamedType(assembly.GlobalNamespace, implementationTypeName, out var implementationType)) + if (!TryResolveReachableType(enumModel.TypeName, out var type) || + type is not INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) { - var attribute = implementationType.GetAttributes().FirstOrDefault(static item => - IsAttribute(item, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); - if (attribute is not null && - attribute.ConstructorArguments.Length == 2 && - attribute.ConstructorArguments[0].Value is ulong high && - attribute.ConstructorArguments[1].Value is ulong low) - { - hash = new RpcHashValue(high, low); - _opaqueSemanticIdentityCache[implementationTypeName] = hash; - return true; - } + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve reached enum metadata for '{enumModel.TypeName}'."); } - foreach (var referenced in assembly.Modules.SelectMany(static module => module.ReferencedAssemblySymbols)) - pending.Enqueue(referenced); - } - - _opaqueSemanticIdentityCache[implementationTypeName] = null; - hash = default; - return false; - } - - private static bool TryFindNamedType( - INamespaceSymbol namespaceSymbol, - string typeName, - out INamedTypeSymbol type) - { - foreach (var candidate in namespaceSymbol.GetTypeMembers()) - { - if (TryFindNamedType(candidate, typeName, out type)) - return true; + ResolveFinalCodecPlan(enumType, plans, resolving); } - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) - { - if (TryFindNamedType(nestedNamespace, typeName, out type)) - return true; - } - type = null!; - return false; - } - - private static bool TryFindNamedType( - INamedTypeSymbol candidate, - string typeName, - out INamedTypeSymbol type) - { - if (string.Equals(GetTypeName(candidate), typeName, StringComparison.Ordinal)) - { - type = candidate; - return true; - } - foreach (var nested in candidate.GetTypeMembers()) - { - if (TryFindNamedType(nested, typeName, out type)) - return true; - } - type = null!; - return false; - } - private bool TryResolveReachableType(string typeName, out ITypeSymbol type) - { - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots( - _compilation.Assembly.GlobalNamespace, - roots, - includeSerializable: !_contractMode, - includeContracts: _contractMode); - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - return reachable.TryGetValue(typeName, out type!); - } - - private RpcHashValue GetAdapterTargetLogicalIdentity(ITypeSymbol targetType) - { - var parts = new List { "adapter-target/v2" }; - AppendClosedTargetLogicalIdentity(targetType, parts); - return Hashing.GetSemanticHash(parts.ToArray()); - } - - private static IEnumerable GetFinalCodecPlanDependencies(FinalCodecPlan plan) - { - switch (plan) - { - case FinalPrimitiveCodecPlan { ChildType: { } child }: - yield return child; - break; - case FinalEnumCodecPlan enumPlan: - yield return enumPlan.UnderlyingType; - break; - case FinalGeneratedDtoCodecPlan dto: - foreach (var member in dto.Members) - { - if (member.ChildType is not null) - yield return member.ChildType; - } - break; - case FinalCollectionCodecPlan { WireStrategy: FinalCollectionWireStrategy.ChildCodec } collection: - if (collection.ElementType is not null) yield return collection.ElementType; - if (collection.KeyType is not null) yield return collection.KeyType; - if (collection.ValueType is not null) yield return collection.ValueType; - break; - } + return new FinalCodecGraph( + plans, + roots.Keys.Where(type => !_failed.Contains(type)) + .OrderBy(static type => type, StringComparer.Ordinal) + .ToImmutableArray()); } } -} +} \ No newline at end of file From 905d7eb4bba33fc9225076adfaedbcbbd03495fe Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:35:29 +0800 Subject: [PATCH 289/399] refactor: isolate final codec selection --- .../RpcGenerator.FinalCodecPlan.Selection.cs | 543 ++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs new file mode 100644 index 000000000..ab5ecbeb5 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -0,0 +1,543 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private sealed partial class DtoAnalysisState + { + private FinalCodecPlan ResolveFinalCodecPlan( + ITypeSymbol type, + Dictionary plans, + HashSet resolving) + { + var typeName = GetTypeName(type); + if (plans.TryGetValue(typeName, out var existing)) + return existing; + if (!resolving.Add(typeName)) + { + throw new InvalidOperationException( + $"Final Codec graph contains an unresolved recursive Codec selection at '{typeName}'."); + } + + FinalCodecPlan plan; + if (_models.TryGetValue(typeName, out var generatedModel)) + { + plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); + } + else if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) + { + plan = new FinalReferencedCodecPlan(typeName, referencedHash); + } + else if (type.TypeKind == TypeKind.Enum && + type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) + { + ResolveFinalCodecPlan(underlying, plans, resolving); + plan = new FinalEnumCodecPlan( + typeName, + GetTypeName(underlying), + GetEnumDeclarationSemanticIdentity(enumType)); + } + else if (type is INamedTypeSymbol nullable && + nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + nullable.TypeArguments.Length == 1 && + HasExactBuiltinNullableCodecElement(nullable.TypeArguments[0])) + { + var child = ResolveFinalCodecPlan(nullable.TypeArguments[0], plans, resolving); + plan = new FinalPrimitiveCodecPlan( + typeName, + "nullable", + ImmutableArray.Empty, + child.TypeName); + } + else if (TryGetFrameworkScalarSemantic(type, out var scalarSemantic)) + { + plan = new FinalPrimitiveCodecPlan(typeName, "framework", scalarSemantic); + } + else if (TryGetCollection( + type, + out var collectionKind, + out var elementType, + out _, + out _)) + { + if (collectionKind == GeneratedCodecKind.Nullable && + elementType is not null && + type.IsUnmanagedType && + !HasExactBuiltinNullableCodecElement(elementType)) + { + plan = ResolveUnsafeBlitCodecPlan(type); + } + else if (TryResolveBuiltinCollectionPlan( + typeName, + collectionKind, + elementType, + out var builtinCollection)) + { + plan = builtinCollection; + } + else + { + throw new InvalidOperationException( + $"Final RPC Codec graph has no generated or runtime builtin collection selection for '{typeName}'."); + } + } + else if (type.IsUnmanagedType && !IsRuntimeSizedUnsafeBlitType(type)) + { + plan = ResolveUnsafeBlitCodecPlan(type); + } + else + { + throw new InvalidOperationException( + $"Final RPC Codec graph cannot resolve deterministic Codec semantics for '{typeName}'. Rebuild referenced SharpLink assemblies with deterministic identity generation enabled or bind an explicit Codec."); + } + + resolving.Remove(typeName); + plans[typeName] = plan; + return plan; + } + + private FinalCodecPlan ResolveGeneratedCodecPlan( + ITypeSymbol type, + GeneratedCodecModel model, + Dictionary plans, + HashSet resolving) + { + switch (model.Kind) + { + case GeneratedCodecKind.Custom: + return new FinalCustomCodecPlan( + model.TypeName, + GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec")); + case GeneratedCodecKind.Adapter: + return new FinalAdapterCodecPlan( + model.TypeName, + GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter"), + GetAdapterTargetLogicalIdentity(type)); + case GeneratedCodecKind.Dto: + return ResolveGeneratedDtoPlan(type, model, plans, resolving); + default: + return ResolveGeneratedCollectionPlan(type, model, plans, resolving); + } + } + + private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( + ITypeSymbol type, + GeneratedCodecModel model, + Dictionary plans, + HashSet resolving) + { + var memberSymbols = type is INamedTypeSymbol named + ? GetSerializableMembers(named).ToDictionary(static item => item.Name, StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + var members = ImmutableArray.CreateBuilder(model.Members.Length); + foreach (var member in model.Members.OrderBy(static item => item.FieldId)) + { + memberSymbols.TryGetValue(member.Name, out var memberSymbol); + var memberType = memberSymbol is null ? null : GetMemberType(memberSymbol); + switch (member.Kind) + { + case GeneratedMemberKind.String: + members.Add(CreateMember( + member, + FinalDtoMemberWireStrategy.String, + "string/content/utf16le/i32le-byte-length/v1|string/null/dto-wire-null/v1", + null)); + break; + case GeneratedMemberKind.Fixed: + case GeneratedMemberKind.NullableFixed: + members.Add(CreateMember( + member, + FinalDtoMemberWireStrategy.Fixed, + GetResolvedFixedMemberSemantic(member, memberType), + null)); + break; + case GeneratedMemberKind.Complex: + if (memberType is null && !TryResolveReachableType(member.TypeName, out memberType!)) + { + throw new InvalidOperationException( + $"Final Codec plan for '{model.TypeName}' cannot resolve child '{member.TypeName}'."); + } + var child = ResolveFinalCodecPlan(memberType, plans, resolving); + members.Add(CreateMember( + member, + FinalDtoMemberWireStrategy.ChildCodec, + null, + child.TypeName)); + break; + } + } + + return new FinalGeneratedDtoCodecPlan( + model.TypeName, + model.IsReferenceType, + members.ToImmutable()); + + static FinalDtoMemberPlan CreateMember( + GeneratedMemberModel member, + FinalDtoMemberWireStrategy strategy, + string? wireSemantic, + string? childType) + => new( + member.FieldId, + member.Kind, + member.Required, + member.Nullable, + member.NonNullableReference, + strategy, + wireSemantic, + childType); + } + + private FinalCollectionCodecPlan ResolveGeneratedCollectionPlan( + ITypeSymbol type, + GeneratedCodecModel model, + Dictionary plans, + HashSet resolving) + { + ITypeSymbol? element = null; + ITypeSymbol? key = null; + ITypeSymbol? value = null; + if (TryGetCollection(type, out _, out var resolvedElement, out var resolvedKey, out var resolvedValue)) + { + element = resolvedElement; + key = resolvedKey; + value = resolvedValue; + } + ResolveChild(element, model.ElementType); + ResolveChild(key, model.KeyType); + ResolveChild(value, model.ValueType); + return new FinalCollectionCodecPlan( + model.TypeName, + model.Kind, + FinalCollectionWireStrategy.ChildCodec, + model.ElementType, + model.KeyType, + model.ValueType, + RawElementLayout: null, + StrategySemantic: null); + + void ResolveChild(ITypeSymbol? symbol, string? childTypeName) + { + if (childTypeName is null) + return; + if (symbol is null && !TryResolveReachableType(childTypeName, out symbol!)) + { + throw new InvalidOperationException( + $"Final Codec plan for '{model.TypeName}' cannot resolve child '{childTypeName}'."); + } + ResolveFinalCodecPlan(symbol, plans, resolving); + } + } + + private bool TryResolveBuiltinCollectionPlan( + string typeName, + GeneratedCodecKind collectionKind, + ITypeSymbol? elementType, + out FinalCollectionCodecPlan plan) + { + if (elementType is null || + collectionKind is not (GeneratedCodecKind.Array or + GeneratedCodecKind.List or + GeneratedCodecKind.Memory or + GeneratedCodecKind.ReadOnlyMemory or + GeneratedCodecKind.ImmutableArray) || + !IsBuiltinBlitElement(elementType)) + { + plan = null!; + return false; + } + + if (string.Equals(elementType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + { + plan = new FinalCollectionCodecPlan( + typeName, + collectionKind, + FinalCollectionWireStrategy.DateTimeOffsetCanonical, + GetTypeName(elementType), + null, + null, + RawElementLayout: null, + StrategySemantic: "datetime-offset/collection16/i16le-offset-minutes/zero6/i64le-utc-ticks/v2"); + return true; + } + + plan = new FinalCollectionCodecPlan( + typeName, + collectionKind, + FinalCollectionWireStrategy.RawBlit, + GetTypeName(elementType), + null, + null, + ResolvePhysicalLayout(elementType, GetTypeName(elementType), collectAutoLayoutHazards: false, null), + StrategySemantic: "builtin-blit-element/v2|abi:little-endian"); + return true; + } + + private string GetResolvedFixedMemberSemantic( + GeneratedMemberModel member, + ITypeSymbol? actualMemberType) + { + var semanticType = actualMemberType; + if (semanticType is INamedTypeSymbol nullable && + nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + nullable.TypeArguments.Length == 1) + { + semanticType = nullable.TypeArguments[0]; + } + + if (semanticType is not null && + string.Equals(semanticType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + { + return "datetime-offset/dto-offset-minutes-i16le-padding6-utc-ticks-i64le/v1"; + } + if (semanticType is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + GetEnumDeclarationSemanticIdentity(enumType)); + } + + return string.Join( + ":", + "fixed/v1", + member.FixedSize.ToString(InvariantCulture), + member.FixedTypeName ?? member.EnumUnderlyingType ?? member.TypeName); + } + + private static string GetEnumDeclarationSemanticIdentity(INamedTypeSymbol enumType) + { + var parts = new List + { + "enum-declaration/v1", + enumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + enumType.EnumUnderlyingType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + foreach (var field in enumType.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue) + .OrderBy(static field => field.Name, StringComparer.Ordinal)) + { + parts.Add(field.Name + "=" + Convert.ToString(field.ConstantValue, InvariantCulture)); + } + return string.Join("|", parts); + } + + private static bool HasExactBuiltinNullableCodecElement(ITypeSymbol type) + => type.TypeKind != TypeKind.Enum && GetFixedSize(type) != 0; + + private static bool TryGetFrameworkScalarSemantic( + ITypeSymbol type, + out ImmutableArray semantic) + { + if (type.SpecialType == SpecialType.System_String) + { + semantic = ImmutableArray.Create( + "string/content/utf16le/i32le-byte-length/v1", + "string/null/i32-minus-one/v1"); + return true; + } + + string? token = type.SpecialType switch + { + SpecialType.System_Boolean => "bool/fixed1/v1", + SpecialType.System_Byte => "u8/fixed1/v1", + SpecialType.System_SByte => "i8/fixed1/v1", + SpecialType.System_Int16 => "i16/fixed2/v1", + SpecialType.System_UInt16 => "u16/fixed2/v1", + SpecialType.System_Char => "char/fixed2/v1", + SpecialType.System_Int32 => "i32/fixed4/v1", + SpecialType.System_UInt32 => "u32/fixed4/v1", + SpecialType.System_Single => "f32/fixed4/v1", + SpecialType.System_Int64 => "i64/fixed8/v1", + SpecialType.System_UInt64 => "u64/fixed8/v1", + SpecialType.System_Double => "f64/fixed8/v1", + SpecialType.System_Decimal => "decimal/fixed16/v1", + _ => null + }; + token ??= type.ToDisplayString() switch + { + "System.Half" => "half/fixed2/v1", + "System.Text.Rune" => "rune/fixed4/v1", + "System.Guid" => "guid/fixed16/v1", + "System.DateTimeOffset" => "datetime-offset/root-ticks-i64le-offset-minutes-i16le/v1", + "System.DateTime" => "datetime/fixed8/v1", + "System.DateOnly" => "date-only/fixed4/v1", + "System.TimeOnly" => "time-only/fixed8/v1", + "System.TimeSpan" => "timespan/fixed8/v1", + "System.Int128" => "i128/fixed16/v1", + "System.UInt128" => "u128/fixed16/v1", + "System.Index" => "index/fixed4/v1", + "System.Range" => "range/fixed8/v1", + _ => null + }; + semantic = token is null ? ImmutableArray.Empty : ImmutableArray.Create(token); + return token is not null; + } + + private bool TryGetReferencedGeneratedCodecHash(ITypeSymbol type, out RpcHashValue hash) + { + var assembly = type.ContainingAssembly; + if (assembly is null || SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly)) + { + hash = default; + return false; + } + foreach (var attribute in assembly.GetAttributes()) + { + if (!IsAttribute(attribute, "SharpLink.Abstractions", "SharpLinkGeneratedCodecIdentityAttribute") || + attribute.ConstructorArguments.Length != 3 || + attribute.ConstructorArguments[0].Value is not ITypeSymbol targetType || + !SymbolEqualityComparer.Default.Equals(targetType, type) || + attribute.ConstructorArguments[1].Value is not ulong high || + attribute.ConstructorArguments[2].Value is not ulong low) + { + continue; + } + hash = new RpcHashValue(high, low); + return true; + } + hash = default; + return false; + } + + private RpcHashValue GetRequiredOpaqueSemanticIdentity( + string? implementationTypeName, + string implementationKind) + { + if (TryGetOpaqueSemanticIdentity(implementationTypeName, out var hash)) + return hash; + throw new InvalidOperationException( + $"Opaque {implementationKind} '{implementationTypeName ?? ""}' must declare [RpcCodecSemanticIdentity(high, low)]."); + } + + private bool TryGetOpaqueSemanticIdentity(string? implementationTypeName, out RpcHashValue hash) + { + if (implementationTypeName is null) + { + hash = default; + return false; + } + if (_opaqueSemanticIdentityCache.TryGetValue(implementationTypeName, out var cached)) + { + hash = cached ?? default; + return cached.HasValue; + } + + var visited = new HashSet(StringComparer.Ordinal); + var pending = new Queue(); + pending.Enqueue(_compilation.Assembly); + while (pending.Count != 0) + { + var assembly = pending.Dequeue(); + if (!visited.Add(assembly.Identity.ToString())) + continue; + if (TryFindNamedType(assembly.GlobalNamespace, implementationTypeName, out var implementationType)) + { + var attribute = implementationType.GetAttributes().FirstOrDefault(static item => + IsAttribute(item, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); + if (attribute is not null && + attribute.ConstructorArguments.Length == 2 && + attribute.ConstructorArguments[0].Value is ulong high && + attribute.ConstructorArguments[1].Value is ulong low) + { + hash = new RpcHashValue(high, low); + _opaqueSemanticIdentityCache[implementationTypeName] = hash; + return true; + } + } + foreach (var referenced in assembly.Modules.SelectMany(static module => module.ReferencedAssemblySymbols)) + pending.Enqueue(referenced); + } + + _opaqueSemanticIdentityCache[implementationTypeName] = null; + hash = default; + return false; + } + + private static bool TryFindNamedType( + INamespaceSymbol namespaceSymbol, + string typeName, + out INamedTypeSymbol type) + { + foreach (var candidate in namespaceSymbol.GetTypeMembers()) + { + if (TryFindNamedType(candidate, typeName, out type)) + return true; + } + foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + { + if (TryFindNamedType(nestedNamespace, typeName, out type)) + return true; + } + type = null!; + return false; + } + + private static bool TryFindNamedType( + INamedTypeSymbol candidate, + string typeName, + out INamedTypeSymbol type) + { + if (string.Equals(GetTypeName(candidate), typeName, StringComparison.Ordinal)) + { + type = candidate; + return true; + } + foreach (var nested in candidate.GetTypeMembers()) + { + if (TryFindNamedType(nested, typeName, out type)) + return true; + } + type = null!; + return false; + } + + private bool TryResolveReachableType(string typeName, out ITypeSymbol type) + { + var roots = new Dictionary(StringComparer.Ordinal); + CollectCurrentAssemblyRoots( + _compilation.Assembly.GlobalNamespace, + roots, + includeSerializable: !_contractMode, + includeContracts: _contractMode); + var reachable = new Dictionary(StringComparer.Ordinal); + var seen = new HashSet(SymbolEqualityComparer.Default); + foreach (var root in roots.Values) + CollectFinalBindingTypes(root, reachable, seen, 0); + return reachable.TryGetValue(typeName, out type!); + } + + private RpcHashValue GetAdapterTargetLogicalIdentity(ITypeSymbol targetType) + { + var parts = new List { "adapter-target/v2" }; + AppendClosedTargetLogicalIdentity(targetType, parts); + return Hashing.GetSemanticHash(parts.ToArray()); + } + + private static IEnumerable GetFinalCodecPlanDependencies(FinalCodecPlan plan) + { + switch (plan) + { + case FinalPrimitiveCodecPlan { ChildType: { } child }: + yield return child; + break; + case FinalEnumCodecPlan enumPlan: + yield return enumPlan.UnderlyingType; + break; + case FinalGeneratedDtoCodecPlan dto: + foreach (var member in dto.Members) + { + if (member.ChildType is not null) + yield return member.ChildType; + } + break; + case FinalCollectionCodecPlan { WireStrategy: FinalCollectionWireStrategy.ChildCodec } collection: + if (collection.ElementType is not null) yield return collection.ElementType; + if (collection.KeyType is not null) yield return collection.KeyType; + if (collection.ValueType is not null) yield return collection.ValueType; + break; + } + } + } +} \ No newline at end of file From 4c9e79cb85183866f3a17cc6d9d442447af9c1c7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:04 +0800 Subject: [PATCH 290/399] refactor: hash only resolved final codec graph --- .../RpcGenerator.CodecIdentity.cs | 79 +++++-------------- 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 898a300b3..f20eeee41 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -6,58 +6,17 @@ private sealed partial class DtoAnalysisState { internal ImmutableArray BuildFinalCodecHashes(FinalCodecGraph graph) { - var hashGraph = CreateHashMetadataGraph(graph); var cache = new Dictionary(StringComparer.Ordinal); - return hashGraph.Plans + return graph.Plans .OrderBy(static pair => pair.Key, StringComparer.Ordinal) .Select(pair => { - var hash = HashCanonicalPlan(pair.Value, hashGraph, cache, new HashSet(StringComparer.Ordinal)); + var hash = HashCanonicalPlan(pair.Value, graph, cache, new HashSet(StringComparer.Ordinal)); return new GeneratedCodecHashModel(pair.Key, hash.High, hash.Low); }) .ToImmutableArray(); } - private FinalCodecGraph CreateHashMetadataGraph(FinalCodecGraph graph) - { - if (_enums.Count == 0) - return graph; - - var plans = graph.Plans.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); - foreach (var enumModel in _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) - { - if (plans.ContainsKey(enumModel.TypeName)) - continue; - if (!TryResolveReachableType(enumModel.TypeName, out var type) || - type is not INamedTypeSymbol { TypeKind: TypeKind.Enum, EnumUnderlyingType: { } underlying } enumType) - { - throw new InvalidOperationException( - $"Final RPC Codec graph cannot resolve reached enum metadata for '{enumModel.TypeName}'."); - } - - var underlyingType = GetTypeName(underlying); - if (!plans.ContainsKey(underlyingType)) - { - if (!TryGetFrameworkScalarSemantic(underlying, out var semantic)) - { - throw new InvalidOperationException( - $"Final RPC Codec graph cannot resolve enum underlying Codec semantics for '{underlyingType}'."); - } - plans.Add(underlyingType, new FinalPrimitiveCodecPlan( - underlyingType, - "framework", - semantic)); - } - - plans.Add(enumModel.TypeName, new FinalEnumCodecPlan( - enumModel.TypeName, - underlyingType, - GetEnumDeclarationSemanticIdentity(enumType))); - } - - return new FinalCodecGraph(plans, graph.RootTypes); - } - private static RpcHashValue HashCanonicalPlan( FinalCodecPlan plan, FinalCodecGraph graph, @@ -268,28 +227,28 @@ private static RpcHashValue HashPhysicalLayout(FinalPhysicalLayoutPlan plan) fixedBuffer.Length.ToString(InvariantCulture), HashPhysicalLayout(fixedBuffer.Element).ToHex()); case FinalStructPhysicalPlan structure: + { + var parts = new List { - var parts = new List - { - "physical/v1", - "struct", - structure.LayoutKind.ToString(), - structure.Pack.ToString(InvariantCulture), - structure.Size.ToString(InvariantCulture), - structure.InlineArrayLength?.ToString(InvariantCulture) ?? string.Empty, - structure.Fields.Length.ToString(InvariantCulture) - }; - foreach (var field in structure.Fields) - { - parts.Add(field.Offset?.ToString(InvariantCulture) ?? "sequential"); - parts.Add(HashPhysicalLayout(field.Layout).ToHex()); - } - return Hashing.GetSemanticHash(parts.ToArray()); + "physical/v1", + "struct", + structure.LayoutKind.ToString(), + structure.Pack.ToString(InvariantCulture), + structure.Size.ToString(InvariantCulture), + structure.InlineArrayLength?.ToString(InvariantCulture) ?? string.Empty, + structure.Fields.Length.ToString(InvariantCulture) + }; + foreach (var field in structure.Fields) + { + parts.Add(field.Offset?.ToString(InvariantCulture) ?? "sequential"); + parts.Add(HashPhysicalLayout(field.Layout).ToHex()); } + return Hashing.GetSemanticHash(parts.ToArray()); + } default: throw new InvalidOperationException( $"Unknown resolved physical plan '{plan.GetType().Name}'."); } } } -} +} \ No newline at end of file From 62848d49393e4df5dd6faa0b4485687517530bc2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:39:19 +0800 Subject: [PATCH 291/399] refactor: require collection strategy in resolved plan --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index f20eeee41..62f6f109d 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -153,17 +153,21 @@ private static RpcHashValue HashCollectionPlan( AppendChild(plan.ValueType); break; case FinalCollectionWireStrategy.RawBlit: - parts.Add(plan.StrategySemantic ?? "builtin-blit-element/v2|abi:little-endian"); + parts.Add(RequireStrategySemantic()); parts.Add(HashPhysicalLayout( plan.RawElementLayout ?? throw new InvalidOperationException( $"Raw-blit collection '{plan.TypeName}' has no physical element plan.")).ToHex()); break; case FinalCollectionWireStrategy.DateTimeOffsetCanonical: - parts.Add("datetime-offset/collection16/i16le-offset-minutes/zero6/i64le-utc-ticks/v2"); + parts.Add(RequireStrategySemantic()); break; } return Hashing.GetSemanticHash(parts.ToArray()); + string RequireStrategySemantic() + => plan.StrategySemantic ?? throw new InvalidOperationException( + $"Resolved collection '{plan.TypeName}' has no wire strategy semantic."); + void AppendChild(string? childType) { if (childType is not null) From 1d38365dfacbc54e11ac731e0717582e6f221ae0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:18:08 +0800 Subject: [PATCH 292/399] refactor: guard UnsafeBlit framework raw ABI --- .../Codec/RpcUnsafeBlitPlatform.cs | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs index 37874608c..0f24e1e70 100644 --- a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs +++ b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs @@ -1,10 +1,13 @@ +using System.Buffers.Binary; using System.Reflection; +using System.Runtime.InteropServices; namespace SharpLink.Runtime; internal static class RpcUnsafeBlitPlatform { private const int SupportedNativePointerSize = 8; + private static readonly bool DateTimeOffsetRawAbiSupported = ProbeDateTimeOffsetRawAbi(); internal static void EnsureSupported(Type targetType) { @@ -14,18 +17,30 @@ internal static void EnsureSupported(Type targetType) throw new PlatformNotSupportedException( $"UnsafeBlit Codec for '{targetType.FullName}' contains runtime-sized members and does not have a stable wire layout."); } - if (IntPtr.Size == SupportedNativePointerSize) - return; - - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' requires the SharpLink 64-bit wire ABI."); + if (IntPtr.Size != SupportedNativePointerSize) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' requires the SharpLink 64-bit wire ABI."); + } + if (!DateTimeOffsetRawAbiSupported && ContainsDateTimeOffset(targetType, new HashSet())) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains DateTimeOffset, whose raw representation does not match the SharpLink declared framework ABI on this runtime."); + } } internal static bool IsSupported(Type targetType, int nativePointerSize) + => IsSupported(targetType, nativePointerSize, DateTimeOffsetRawAbiSupported); + + internal static bool IsSupported( + Type targetType, + int nativePointerSize, + bool dateTimeOffsetRawAbiSupported) { ArgumentNullException.ThrowIfNull(targetType); return nativePointerSize == SupportedNativePointerSize && - !ContainsRuntimeSizedMember(targetType, new HashSet()); + !ContainsRuntimeSizedMember(targetType, new HashSet()) && + (dateTimeOffsetRawAbiSupported || !ContainsDateTimeOffset(targetType, new HashSet())); } private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) @@ -46,6 +61,34 @@ private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) return false; } + private static bool ContainsDateTimeOffset(Type type, HashSet seen) + { + if (type == typeof(DateTimeOffset)) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum || !seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsDateTimeOffset(field.FieldType, seen)) + return true; + } + return false; + } + + private static bool ProbeDateTimeOffsetRawAbi() + { + var value = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromMinutes(330)); + var raw = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); + if (raw.Length != 16) + return false; + + Span expected = stackalloc byte[16]; + BinaryPrimitives.WriteInt16LittleEndian(expected, 330); + BinaryPrimitives.WriteInt64LittleEndian(expected.Slice(8), value.UtcTicks); + return raw.SequenceEqual(expected); + } + private static bool IsRuntimeSizedIntrinsic(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); } From a4d21b075ed24212fbb263bf472e3753295e4527 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:18:37 +0800 Subject: [PATCH 293/399] test: cover UnsafeBlit framework raw ABI guard --- .../Runtime/RpcUnsafeBlitPlatformTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs index 5eb94c3ac..575c48683 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcUnsafeBlitPlatformTests.cs @@ -21,6 +21,26 @@ public void UnsafeBlitShouldBe64BitOnly() "fixed-width composite UnsafeBlit payloads must also reject 32-bit runtimes because CLR padding/alignment is ABI-dependent"); } + [Test] + public void DateTimeOffsetRawAbiShouldBeCapabilityGuarded() + { + Ensure( + RpcUnsafeBlitPlatform.IsSupported(typeof(DateTimeOffsetPayload), 8), + "the current supported runtime must satisfy the declared DateTimeOffset raw ABI"); + Ensure( + !RpcUnsafeBlitPlatform.IsSupported( + typeof(DateTimeOffsetPayload), + 8, + dateTimeOffsetRawAbiSupported: false), + "UnsafeBlit must reject a runtime whose DateTimeOffset raw representation does not satisfy the declared ABI"); + Ensure( + RpcUnsafeBlitPlatform.IsSupported( + typeof(PortablePayload), + 8, + dateTimeOffsetRawAbiSupported: false), + "an unrelated fixed-width UnsafeBlit graph must not be rejected by the DateTimeOffset-specific ABI guard"); + } + [Test] public void RuntimeSizedVectorShouldNeverUseUnsafeBlit() { @@ -75,6 +95,12 @@ private struct PortablePayload public long Value { get; set; } } + private struct DateTimeOffsetPayload + { + public int Prefix { get; set; } + public DateTimeOffset Value { get; set; } + } + private struct VectorPayload { public System.Numerics.Vector Value { get; set; } From 6d8b93d2aecb6c679c2bc1320329940c73cc83a1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:20:52 +0800 Subject: [PATCH 294/399] style: format final codec identity hasher --- .../RpcGenerator.CodecIdentity.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 62f6f109d..4fc6522f2 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -231,28 +231,28 @@ private static RpcHashValue HashPhysicalLayout(FinalPhysicalLayoutPlan plan) fixedBuffer.Length.ToString(InvariantCulture), HashPhysicalLayout(fixedBuffer.Element).ToHex()); case FinalStructPhysicalPlan structure: - { - var parts = new List - { - "physical/v1", - "struct", - structure.LayoutKind.ToString(), - structure.Pack.ToString(InvariantCulture), - structure.Size.ToString(InvariantCulture), - structure.InlineArrayLength?.ToString(InvariantCulture) ?? string.Empty, - structure.Fields.Length.ToString(InvariantCulture) - }; - foreach (var field in structure.Fields) { - parts.Add(field.Offset?.ToString(InvariantCulture) ?? "sequential"); - parts.Add(HashPhysicalLayout(field.Layout).ToHex()); + var parts = new List + { + "physical/v1", + "struct", + structure.LayoutKind.ToString(), + structure.Pack.ToString(InvariantCulture), + structure.Size.ToString(InvariantCulture), + structure.InlineArrayLength?.ToString(InvariantCulture) ?? string.Empty, + structure.Fields.Length.ToString(InvariantCulture) + }; + foreach (var field in structure.Fields) + { + parts.Add(field.Offset?.ToString(InvariantCulture) ?? "sequential"); + parts.Add(HashPhysicalLayout(field.Layout).ToHex()); + } + return Hashing.GetSemanticHash(parts.ToArray()); } - return Hashing.GetSemanticHash(parts.ToArray()); - } default: throw new InvalidOperationException( $"Unknown resolved physical plan '{plan.GetType().Name}'."); } } } -} \ No newline at end of file +} From 95e31950158830fa9ebb649844cb3df8c18932d3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:22:38 +0800 Subject: [PATCH 295/399] fix: complete generator hash value model --- src/SharpLink.Generator/RpcGenerator.Models.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index 7151e9b21..fdcf7b3af 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -213,4 +213,4 @@ internal readonly record struct GeneratedCodecHashModel( ulong High, ulong Low); -internal readonly record struct RpcHashValue(ulong High, ulong Low) \ No newline at end of file +internal readonly record struct RpcHashValue(ulong High, ulong Low); From 05ed331504648f804aa84ea9f4909d46f1377432 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:23:10 +0800 Subject: [PATCH 296/399] style: normalize final codec plan file endings --- src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs index 681696a54..921222544 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs @@ -64,4 +64,4 @@ internal FinalCodecGraph ResolveFinalCodecGraph( .ToImmutableArray()); } } -} \ No newline at end of file +} From c6b25faec27d51141758c84730f61efdc043b669 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:23:54 +0800 Subject: [PATCH 297/399] style: normalize generator partial file endings --- src/SharpLink.Generator/RpcGenerator.ContractModeling.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs b/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs index ed7e15768..aa6ddb873 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractModeling.cs @@ -211,4 +211,4 @@ private static void CollectArtifactAssemblyDependencies( foreach (var argument in named.TypeArguments) CollectArtifactAssemblyDependencies(owner, argument, identities); } -} \ No newline at end of file +} From 8598318e852304da713bbc84531fa16e1ffc109f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:24:51 +0800 Subject: [PATCH 298/399] style: normalize generator method semantics ending --- src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs b/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs index 2fa1d9b49..8fe820fbd 100644 --- a/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs +++ b/src/SharpLink.Generator/RpcGenerator.MethodSemantics.cs @@ -309,4 +309,4 @@ private static bool TryNormalizeTimeoutSeconds(double seconds, out long ticks, o detail = string.Empty; return true; } -} \ No newline at end of file +} From 92398e1ab739fd4d9b0a99948a26ce30dfe6a7e8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:31:07 +0800 Subject: [PATCH 299/399] style: normalize generator partial file endings --- src/SharpLink.Generator/RpcGenerator.Analysis.cs | 2 +- .../RpcGenerator.FinalCodecPlan.Selection.cs | 2 +- src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.Analysis.cs b/src/SharpLink.Generator/RpcGenerator.Analysis.cs index f628982bb..81fbb22ad 100644 --- a/src/SharpLink.Generator/RpcGenerator.Analysis.cs +++ b/src/SharpLink.Generator/RpcGenerator.Analysis.cs @@ -696,4 +696,4 @@ Accessibility.Protected or } return true; } -} \ No newline at end of file +} diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index ab5ecbeb5..094d71a9e 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -540,4 +540,4 @@ private static IEnumerable GetFinalCodecPlanDependencies(FinalCodecPlan } } } -} \ No newline at end of file +} diff --git a/src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs b/src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs index ed83113e6..12b0ecd2b 100644 --- a/src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs +++ b/src/SharpLink.Generator/RpcGenerator.ReferenceAnalysis.cs @@ -430,4 +430,4 @@ private static string EscapeIdentifier(string identifier) => Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(identifier) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None ? "@" + identifier : identifier; -} \ No newline at end of file +} From 1212d6394d570029683888af83f2edc4b6abf8c9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:36:03 +0800 Subject: [PATCH 300/399] chore: remove resolved generator maintainability allowance --- eng/maintainability/baseline.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/eng/maintainability/baseline.json b/eng/maintainability/baseline.json index 22dc124a0..c68e7b911 100644 --- a/eng/maintainability/baseline.json +++ b/eng/maintainability/baseline.json @@ -16,12 +16,6 @@ "maxLoc": 1930, "reason": "Existing dev debt captured by issue #350." }, - { - "domain": "source", - "path": "src/SharpLink.Generator/RpcGenerator.Analysis.cs", - "maxLoc": 1655, - "reason": "Existing dev debt captured by issue #350." - }, { "domain": "source", "path": "src/SharpLink.Runtime/PooledAsyncStreamDispatcher.cs", From d3d6566b2a79f19687e89d8bc7a1784a32c714fb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:38:26 +0800 Subject: [PATCH 301/399] refactor: declare DateTimeOffset raw ABI shape --- .../RpcGenerator.FinalCodecPlan.Physical.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs index 81ccc464b..b0a3b7102 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs @@ -241,7 +241,10 @@ private static bool TryGetPhysicalPrimitive( _ => null }; if (string.Equals(type.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) - frameworkRawAbi = "framework-raw/datetimeoffset/native16/release-scoped/v1"; + { + frameworkRawAbi = + "framework-raw/datetimeoffset/native16/offset-i16-zero6-utc-ticks-i64/little-endian/v2"; + } } if (token is null) { From 908319fca04b54cf89169946b6f806aa5b839347 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:40:42 +0800 Subject: [PATCH 302/399] refactor: centralize builtin collection wire catalog --- .../RpcBuiltinCollectionWireCatalog.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs diff --git a/src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs b/src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs new file mode 100644 index 000000000..ce9579e1b --- /dev/null +++ b/src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs @@ -0,0 +1,71 @@ +namespace SharpLink; + +internal enum RpcBuiltinCollectionWireStrategy +{ + RawBlit, + DateTimeOffsetCanonical +} + +internal readonly record struct RpcBuiltinCollectionWireDescriptor( + string ElementTypeName, + RpcBuiltinCollectionWireStrategy Strategy, + string Semantic); + +internal static class RpcBuiltinCollectionWireCatalog +{ + internal const string RawBlitSemantic = "builtin-blit-element/v2|abi:little-endian"; + internal const string DateTimeOffsetCanonicalSemantic = + "datetime-offset/collection16/i16le-offset-minutes/zero6/i64le-utc-ticks/v2"; + + private static readonly RpcBuiltinCollectionWireDescriptor[] Items = + { + Raw("System.Boolean"), + Raw("System.Byte"), + Raw("System.SByte"), + Raw("System.Int16"), + Raw("System.UInt16"), + Raw("System.Char"), + Raw("System.Half"), + Raw("System.Int32"), + Raw("System.UInt32"), + Raw("System.Single"), + Raw("System.Text.Rune"), + Raw("System.Int64"), + Raw("System.UInt64"), + Raw("System.Double"), + Raw("System.Guid"), + Raw("System.Decimal"), + new("System.DateTimeOffset", RpcBuiltinCollectionWireStrategy.DateTimeOffsetCanonical, + DateTimeOffsetCanonicalSemantic), + Raw("System.DateTime"), + Raw("System.DateOnly"), + Raw("System.TimeOnly"), + Raw("System.TimeSpan"), + Raw("System.Int128"), + Raw("System.UInt128"), + Raw("System.Index"), + Raw("System.Range") + }; + + internal static System.Collections.Generic.IReadOnlyList All => Items; + + internal static bool TryGet( + string elementTypeName, + out RpcBuiltinCollectionWireDescriptor descriptor) + { + for (var index = 0; index < Items.Length; index++) + { + if (string.Equals(Items[index].ElementTypeName, elementTypeName, System.StringComparison.Ordinal)) + { + descriptor = Items[index]; + return true; + } + } + + descriptor = default; + return false; + } + + private static RpcBuiltinCollectionWireDescriptor Raw(string typeName) + => new(typeName, RpcBuiltinCollectionWireStrategy.RawBlit, RawBlitSemantic); +} From bbd13361da8457c8d333cde4c51967bcd6512f4d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:11:28 +0800 Subject: [PATCH 303/399] fix: restore generator DTO support models --- .../RpcGenerator.DtoModels.cs | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 src/SharpLink.Generator/RpcGenerator.DtoModels.cs diff --git a/src/SharpLink.Generator/RpcGenerator.DtoModels.cs b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs new file mode 100644 index 000000000..77e8a5152 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs @@ -0,0 +1,259 @@ +namespace SharpLink.Generator; + +internal enum DtoDiagnosticKind +{ + Unsupported, + Cycle, + MemberIdCollision, + Constructor, + Depth, + AdapterRegistrationInvalid, + AdapterTypeInvalid, + SelectorConflict, + AdapterSelectionConflict, + AdapterBindingInvalid, + AdapterTargetInvalid, + AdapterIdentityConflict, + BuiltinAdapterOverride, + CustomCodecBindingInvalid, + CustomCodecTargetInvalid, + CustomCodecTypeInvalid, + CustomCodecIdentityInvalid, + CustomCodecSelectionConflict, + BuiltinCustomCodecOverride +} + +internal readonly record struct DtoDiagnosticModel( + DtoDiagnosticKind Kind, + string TypeName, + string Detail, + Location? Location); + +internal sealed record DtoGenerationResult( + ImmutableArray Codecs, + ImmutableArray ContractCodecs, + ImmutableArray FinalCodecBoundTypes, + ImmutableArray Diagnostics, + ImmutableArray Enums) +{ + public ImmutableArray CodecHashes { get; init; } = + ImmutableArray.Empty; + public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } = + ImmutableArray.Empty; + public string AssemblyLogicalIdentity { get; init; } = string.Empty; +} + +internal sealed record GeneratedEnumModel( + string TypeName, + string UnderlyingType, + Location? Location); + +internal sealed class DtoGenerationResultComparer : IEqualityComparer +{ + internal static DtoGenerationResultComparer Instance { get; } = new(); + + public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) + { + if (ReferenceEquals(x, y)) + return true; + if (x is null || y is null || x.Codecs.Length != y.Codecs.Length || + x.ContractCodecs.Length != y.ContractCodecs.Length || + x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || + x.CodecHashes.Length != y.CodecHashes.Length || + x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length || + x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || + !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal)) + { + return false; + } + for (var index = 0; index < x.Codecs.Length; index++) + { + if (!CodecEquals(x.Codecs[index], y.Codecs[index])) + return false; + } + for (var index = 0; index < x.ContractCodecs.Length; index++) + { + if (!CodecEquals(x.ContractCodecs[index], y.ContractCodecs[index])) + return false; + } + if (!x.FinalCodecBoundTypes.SequenceEqual(y.FinalCodecBoundTypes, StringComparer.Ordinal)) + return false; + for (var index = 0; index < x.CodecHashes.Length; index++) + { + if (x.CodecHashes[index] != y.CodecHashes[index]) + return false; + } + for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++) + { + var left = x.UnsafeBlitAutoLayoutDiagnostics[index]; + var right = y.UnsafeBlitAutoLayoutDiagnostics[index]; + if (!string.Equals(left.PayloadType, right.PayloadType, StringComparison.Ordinal) || + !string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || + !string.Equals(left.FieldPath, right.FieldPath, StringComparison.Ordinal)) + { + return false; + } + } + for (var index = 0; index < x.Diagnostics.Length; index++) + { + var left = x.Diagnostics[index]; + var right = y.Diagnostics[index]; + if (left.Kind != right.Kind || + !string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || + !string.Equals(left.Detail, right.Detail, StringComparison.Ordinal)) + { + return false; + } + } + for (var index = 0; index < x.Enums.Length; index++) + { + var left = x.Enums[index]; + var right = y.Enums[index]; + if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || + !string.Equals(left.UnderlyingType, right.UnderlyingType, StringComparison.Ordinal)) + { + return false; + } + } + return true; + } + + public int GetHashCode(DtoGenerationResult obj) + { + var hash = 17; + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(obj.AssemblyLogicalIdentity)); + foreach (var codec in obj.Codecs) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.TypeName)); + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.SchemaId)); + hash = unchecked(hash * 31 + codec.CodecHashHigh.GetHashCode()); + hash = unchecked(hash * 31 + codec.CodecHashLow.GetHashCode()); + } + foreach (var codec in obj.ContractCodecs) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.TypeName)); + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codec.SchemaId)); + hash = unchecked(hash * 31 + codec.CodecHashHigh.GetHashCode()); + hash = unchecked(hash * 31 + codec.CodecHashLow.GetHashCode()); + } + foreach (var type in obj.FinalCodecBoundTypes) + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(type)); + foreach (var codecHash in obj.CodecHashes) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName)); + hash = unchecked(hash * 31 + codecHash.High.GetHashCode()); + hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); + } + foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.PayloadType)); + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.TypeName)); + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.FieldPath)); + } + foreach (var diagnostic in obj.Diagnostics) + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.Detail)); + foreach (var item in obj.Enums) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(item.TypeName)); + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(item.UnderlyingType)); + } + return hash; + } + + private static bool CodecEquals(GeneratedCodecModel left, GeneratedCodecModel right) + { + if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || + !string.Equals(left.CodecName, right.CodecName, StringComparison.Ordinal) || + !string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) || + left.CodecHashHigh != right.CodecHashHigh || + left.CodecHashLow != right.CodecHashLow || + left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || + !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || + !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || + !string.Equals(left.ValueType, right.ValueType, StringComparison.Ordinal) || + !string.Equals(left.CustomCodecType, right.CustomCodecType, StringComparison.Ordinal) || + !string.Equals(left.AdapterType, right.AdapterType, StringComparison.Ordinal) || + !string.Equals(left.AdapterId, right.AdapterId, StringComparison.Ordinal) || + !string.Equals(left.WireFormatId, right.WireFormatId, StringComparison.Ordinal) || + !left.ConstructorMembers.SequenceEqual(right.ConstructorMembers, StringComparer.Ordinal) || + !left.AssemblyDependencies.SequenceEqual(right.AssemblyDependencies, StringComparer.Ordinal) || + left.Members.Length != right.Members.Length) + { + return false; + } + for (var index = 0; index < left.Members.Length; index++) + { + var first = left.Members[index]; + var second = right.Members[index]; + if (first with { Location = null } != second with { Location = null }) + return false; + } + return true; + } +} + +internal static class RpcHashValueExtensions +{ + internal static string ToHex(this RpcHashValue value) + => value.High.ToString("x16", CultureInfo.InvariantCulture) + + value.Low.ToString("x16", CultureInfo.InvariantCulture); +} + +internal static class Hashing +{ + private const ulong FnvPrime = 1099511628211; + private const ulong FnvOffsetBasis = 14695981039346656037; + + public static long GetMethodHash(string mName, string[] pNames) + { + var cleanP = string.Join(",", pNames).Replace("global::", "").Replace(" ", ""); + return (long)Hash($"{mName}({cleanP})"); + } + + public static long GetInterfaceHash(string iName) + => (long)Hash(iName.Replace("global::", "").Replace(" ", "")); + + public static string GetIdentifierHash(string value) + => Hash(value).ToString("x16", CultureInfo.InvariantCulture); + + public static RpcHashValue GetSemanticHash(params string[] parts) + { + var canonical = new StringBuilder(); + foreach (var part in parts) + { + var value = part ?? string.Empty; + canonical.Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append(':') + .Append(value); + } + + var hex = GetSha256(canonical.ToString()); + return new RpcHashValue( + ulong.Parse(hex.Substring(0, 16), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + ulong.Parse(hex.Substring(16, 16), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + } + + public static string GetSha256(string value) + { + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + var hash = sha.ComputeHash(bytes); + var result = new StringBuilder(hash.Length * 2); + for (var index = 0; index < hash.Length; index++) + result.Append(hash[index].ToString("x2", CultureInfo.InvariantCulture)); + return result.ToString(); + } + } + + private static ulong Hash(string s) + { + ulong hash = FnvOffsetBasis; + foreach (var c in s) + { + hash ^= c; + hash *= FnvPrime; + } + return hash; + } +} From f42b58ecbec34e338672ad3a4544668819ca6c08 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:14:50 +0800 Subject: [PATCH 304/399] refactor: link builtin collection wire catalog --- src/SharpLink.Generator/SharpLink.Generator.csproj | 5 +++++ src/SharpLink.Runtime/SharpLink.Runtime.csproj | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/SharpLink.Generator/SharpLink.Generator.csproj b/src/SharpLink.Generator/SharpLink.Generator.csproj index e3a8247a1..08f9bf51d 100644 --- a/src/SharpLink.Generator/SharpLink.Generator.csproj +++ b/src/SharpLink.Generator/SharpLink.Generator.csproj @@ -26,4 +26,9 @@ + + + + diff --git a/src/SharpLink.Runtime/SharpLink.Runtime.csproj b/src/SharpLink.Runtime/SharpLink.Runtime.csproj index f2cbf466e..779a777ae 100644 --- a/src/SharpLink.Runtime/SharpLink.Runtime.csproj +++ b/src/SharpLink.Runtime/SharpLink.Runtime.csproj @@ -10,6 +10,11 @@ + + + + From c326a0cfe8cf5b81e5802b258c67618de18a9784 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:17:03 +0800 Subject: [PATCH 305/399] refactor: resolve builtin collections from shared catalog --- .../RpcGenerator.FinalCodecPlan.Selection.cs | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index 094d71a9e..af15f425f 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -239,37 +239,53 @@ collectionKind is not (GeneratedCodecKind.Array or GeneratedCodecKind.List or GeneratedCodecKind.Memory or GeneratedCodecKind.ReadOnlyMemory or - GeneratedCodecKind.ImmutableArray) || - !IsBuiltinBlitElement(elementType)) + GeneratedCodecKind.ImmutableArray)) { plan = null!; return false; } - if (string.Equals(elementType.ToDisplayString(), "System.DateTimeOffset", StringComparison.Ordinal)) + var elementTypeName = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (elementTypeName.StartsWith("global::", StringComparison.Ordinal)) + elementTypeName = elementTypeName.Substring("global::".Length); + if (!global::SharpLink.RpcBuiltinCollectionWireCatalog.TryGet(elementTypeName, out var descriptor)) { - plan = new FinalCollectionCodecPlan( - typeName, - collectionKind, - FinalCollectionWireStrategy.DateTimeOffsetCanonical, - GetTypeName(elementType), - null, - null, - RawElementLayout: null, - StrategySemantic: "datetime-offset/collection16/i16le-offset-minutes/zero6/i64le-utc-ticks/v2"); - return true; + plan = null!; + return false; } - plan = new FinalCollectionCodecPlan( - typeName, - collectionKind, - FinalCollectionWireStrategy.RawBlit, - GetTypeName(elementType), - null, - null, - ResolvePhysicalLayout(elementType, GetTypeName(elementType), collectAutoLayoutHazards: false, null), - StrategySemantic: "builtin-blit-element/v2|abi:little-endian"); - return true; + switch (descriptor.Strategy) + { + case global::SharpLink.RpcBuiltinCollectionWireStrategy.DateTimeOffsetCanonical: + plan = new FinalCollectionCodecPlan( + typeName, + collectionKind, + FinalCollectionWireStrategy.DateTimeOffsetCanonical, + GetTypeName(elementType), + null, + null, + RawElementLayout: null, + StrategySemantic: descriptor.Semantic); + return true; + case global::SharpLink.RpcBuiltinCollectionWireStrategy.RawBlit: + plan = new FinalCollectionCodecPlan( + typeName, + collectionKind, + FinalCollectionWireStrategy.RawBlit, + GetTypeName(elementType), + null, + null, + ResolvePhysicalLayout( + elementType, + GetTypeName(elementType), + collectAutoLayoutHazards: false, + null), + StrategySemantic: descriptor.Semantic); + return true; + default: + throw new InvalidOperationException( + $"Unknown builtin collection wire strategy '{descriptor.Strategy}'."); + } } private string GetResolvedFixedMemberSemantic( From 1e644894ae1335e8982cd93c8b97634a744cd5e1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:19:53 +0800 Subject: [PATCH 306/399] fix: normalize builtin collection type aliases --- .../RpcBuiltinCollectionWireCatalog.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs b/src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs index ce9579e1b..009c36deb 100644 --- a/src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs +++ b/src/SharpLink.Shared/RpcBuiltinCollectionWireCatalog.cs @@ -53,6 +53,7 @@ internal static bool TryGet( string elementTypeName, out RpcBuiltinCollectionWireDescriptor descriptor) { + elementTypeName = NormalizeTypeName(elementTypeName); for (var index = 0; index < Items.Length; index++) { if (string.Equals(Items[index].ElementTypeName, elementTypeName, System.StringComparison.Ordinal)) @@ -66,6 +67,30 @@ internal static bool TryGet( return false; } + private static string NormalizeTypeName(string typeName) + { + const string globalPrefix = "global::"; + if (typeName.StartsWith(globalPrefix, System.StringComparison.Ordinal)) + typeName = typeName.Substring(globalPrefix.Length); + return typeName switch + { + "bool" => "System.Boolean", + "byte" => "System.Byte", + "sbyte" => "System.SByte", + "short" => "System.Int16", + "ushort" => "System.UInt16", + "char" => "System.Char", + "int" => "System.Int32", + "uint" => "System.UInt32", + "float" => "System.Single", + "long" => "System.Int64", + "ulong" => "System.UInt64", + "double" => "System.Double", + "decimal" => "System.Decimal", + _ => typeName + }; + } + private static RpcBuiltinCollectionWireDescriptor Raw(string typeName) => new(typeName, RpcBuiltinCollectionWireStrategy.RawBlit, RawBlitSemantic); } From cafbb50a539d1706d26d5c15979ed12d3514cd77 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:21:50 +0800 Subject: [PATCH 307/399] test: lock builtin collection catalog to runtime codecs --- .../BuiltinCollectionWireCatalogTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 test/SharpLink.UnitTests/Runtime/BuiltinCollectionWireCatalogTests.cs diff --git a/test/SharpLink.UnitTests/Runtime/BuiltinCollectionWireCatalogTests.cs b/test/SharpLink.UnitTests/Runtime/BuiltinCollectionWireCatalogTests.cs new file mode 100644 index 000000000..3a9db1b15 --- /dev/null +++ b/test/SharpLink.UnitTests/Runtime/BuiltinCollectionWireCatalogTests.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; + +namespace SharpLink.UnitTests.Runtime; + +public class BuiltinCollectionWireCatalogTests +{ + [Test] + public void CatalogShouldMatchRuntimeCollectionRegistrationsAndStrategies() + { + var catalogNames = RpcBuiltinCollectionWireCatalog.All + .Select(static descriptor => descriptor.ElementTypeName) + .ToHashSet(StringComparer.Ordinal); + var runtimeNames = GetRuntimeCollectionElementTypes() + .Select(static type => type.FullName ?? throw new InvalidOperationException("Builtin collection element has no CLR name.")) + .ToHashSet(StringComparer.Ordinal); + + Ensure( + catalogNames.SetEquals(runtimeNames), + $"builtin collection catalog mismatch; catalog-only=[{string.Join(",", catalogNames.Except(runtimeNames).OrderBy(static name => name, StringComparer.Ordinal))}], runtime-only=[{string.Join(",", runtimeNames.Except(catalogNames).OrderBy(static name => name, StringComparer.Ordinal))}]"); + + foreach (var descriptor in RpcBuiltinCollectionWireCatalog.All) + { + var elementType = typeof(int).Assembly.GetType(descriptor.ElementTypeName) ?? + Type.GetType(descriptor.ElementTypeName, throwOnError: false); + Ensure(elementType is not null, $"cannot resolve builtin element '{descriptor.ElementTypeName}'"); + + foreach (var shape in GetCollectionShapes(elementType!)) + { + Ensure( + BuiltinRpcCodecs.TryGet(shape.CollectionType, out var codec), + $"runtime has no builtin codec for '{shape.CollectionType}'"); + if (descriptor.Strategy == RpcBuiltinCollectionWireStrategy.RawBlit) + { + var codecType = codec.GetType(); + Ensure( + codecType.IsGenericType && codecType.GetGenericTypeDefinition() == shape.RawCodecDefinition, + $"'{shape.CollectionType}' must use '{shape.RawCodecDefinition}' but uses '{codecType}'"); + } + else + { + Ensure( + descriptor.Strategy == RpcBuiltinCollectionWireStrategy.DateTimeOffsetCanonical, + $"unknown catalog strategy '{descriptor.Strategy}'"); + Ensure( + codec.GetType() == shape.DateTimeOffsetCodecType, + $"'{shape.CollectionType}' must use canonical DateTimeOffset codec '{shape.DateTimeOffsetCodecType}' but uses '{codec.GetType()}'"); + } + } + } + } + + private static IEnumerable GetRuntimeCollectionElementTypes() + { + var field = typeof(BuiltinRpcCodecs).GetField("Codecs", BindingFlags.NonPublic | BindingFlags.Static) ?? + throw new InvalidOperationException("BuiltinRpcCodecs.Codecs field is missing."); + var codecs = field.GetValue(null) as IReadOnlyDictionary ?? + throw new InvalidOperationException("BuiltinRpcCodecs.Codecs has an unexpected runtime type."); + + return codecs.Keys + .Select(GetCollectionElementType) + .Where(static type => type is not null) + .Select(static type => type!) + .Distinct(); + } + + private static Type? GetCollectionElementType(Type type) + { + if (type.IsArray && type.GetArrayRank() == 1) + return type.GetElementType(); + if (!type.IsGenericType) + return null; + + var definition = type.GetGenericTypeDefinition(); + if (definition == typeof(List<>) || + definition == typeof(Memory<>) || + definition == typeof(ReadOnlyMemory<>) || + definition == typeof(ImmutableArray<>)) + { + return type.GetGenericArguments()[0]; + } + return null; + } + + private static CollectionShape[] GetCollectionShapes(Type elementType) + => + [ + new( + elementType.MakeArrayType(), + typeof(BlitArrayCodec<>), + typeof(DateTimeOffsetArrayCodec)), + new( + typeof(List<>).MakeGenericType(elementType), + typeof(BlitListCodec<>), + typeof(DateTimeOffsetListCodec)), + new( + typeof(Memory<>).MakeGenericType(elementType), + typeof(BlitMemoryCodec<>), + typeof(DateTimeOffsetMemoryCodec)), + new( + typeof(ReadOnlyMemory<>).MakeGenericType(elementType), + typeof(BlitReadOnlyMemoryCodec<>), + typeof(DateTimeOffsetReadOnlyMemoryCodec)), + new( + typeof(ImmutableArray<>).MakeGenericType(elementType), + typeof(BlitImmutableArrayCodec<>), + typeof(DateTimeOffsetImmutableArrayCodec)) + ]; + + private readonly record struct CollectionShape( + Type CollectionType, + Type RawCodecDefinition, + Type DateTimeOffsetCodecType); + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new Exception(message); + } +} From d743ef4ec00005d33826bab120b3aa448fc53cf5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:25:54 +0800 Subject: [PATCH 308/399] test: lock generator builtin candidates to shared catalog --- ...iltinCollectionCatalogArchitectureTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcBuiltinCollectionCatalogArchitectureTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcBuiltinCollectionCatalogArchitectureTests.cs b/test/SharpLink.Generator.Tests/RpcBuiltinCollectionCatalogArchitectureTests.cs new file mode 100644 index 000000000..d2dcd8f82 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcBuiltinCollectionCatalogArchitectureTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task SharedBuiltinCollectionCatalogShouldRemainRuntimeSelectedInGeneratorAnalysis() + { + var catalogType = typeof(RpcGenerator).Assembly.GetType( + "SharpLink.RpcBuiltinCollectionWireCatalog", + throwOnError: true)!; + var allProperty = catalogType.GetProperty( + "All", + BindingFlags.Static | BindingFlags.NonPublic) ?? + throw new InvalidOperationException("Shared builtin collection catalog has no All property."); + var descriptors = allProperty.GetValue(null) as IEnumerable ?? + throw new InvalidOperationException("Shared builtin collection catalog has an unexpected All value."); + + var methods = new StringBuilder(); + var index = 0; + foreach (var descriptor in descriptors) + { + var elementTypeName = descriptor!.GetType().GetProperty("ElementTypeName")?.GetValue(descriptor) as string ?? + throw new InvalidOperationException("Builtin collection descriptor has no element type name."); + var elementType = "global::" + elementTypeName; + var listType = $"global::System.Collections.Generic.List<{elementType}>"; + methods.Append(" global::System.Threading.Tasks.ValueTask<") + .Append(listType) + .Append("> Echo") + .Append(index++) + .Append('(') + .Append(listType) + .Append(" value, global::System.Threading.CancellationToken cancellationToken);\n"); + } + + var source = BuildSource($$""" +[SharpLink.Sdk.RpcContract] +public interface IBuiltinCollectionCatalogContract : SharpLink.Sdk.IService +{ +{{methods}}} +"""); + AssertResolvedManifest(source, "shared builtin collection catalog"); + + var generated = string.Join("\n", RunGeneratorAndGetSources(source)); + Ensure( + !generated.Contains( + "TargetType => typeof(global::System.Collections.Generic.List<", + StringComparison.Ordinal), + "runtime-selected builtin List shapes must not be emitted as generated collection Codec factories"); + return Task.CompletedTask; + } +} From 8f871ef1964e5b1c06a805d450ce0f95a3883a62 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:20:12 +0800 Subject: [PATCH 309/399] test: align timeout descriptor assertion with tick canonicalization --- .../ContractManifestGeneratorTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs index 847ff9012..1e2d594ca 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs @@ -67,7 +67,7 @@ public sealed class HelloService : IHelloService Ensure(first.Json.Contains("\"schemaFingerprint\":", StringComparison.Ordinal), "schema fingerprint"); var generatorVersion = typeof(RpcGenerator).Assembly.GetName().Version!.ToString(3); - Ensure(first.Json.Contains($"\"generatorVersion\": \"{generatorVersion}\"", StringComparison.Ordinal), + Ensure(first.Json.Contains($"\"generatorVersion\": \"{generatorVersion}\";", StringComparison.Ordinal), "executing generator assembly version"); Ensure(!first.Json.Contains(Directory.GetCurrentDirectory(), StringComparison.Ordinal), "Manifest must not contain absolute paths"); @@ -187,7 +187,7 @@ public interface IValidTimeoutContract : SharpLink.Sdk.IService """); EnsureDoesNotHaveRule(valid, "SHARPLINK050"); Ensure(string.Join("\n", RunGeneratorAndGetSources(valid)).Contains( - "TimeSpan.FromSeconds(1.5d)", + "TimeSpan.FromTicks(15000000L)", StringComparison.Ordinal), "a valid fractional timeout must retain its generated descriptor"); return Task.CompletedTask; @@ -325,7 +325,8 @@ public Task ExplicitAdapterSemanticIdentityChangeShouldBeRejected() AdapterContractSource(semanticLow: 0x3333333333333333UL), baseline); - Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + Ensure(!changed.Diagnostics.Any(IsCompatibilityDiagnostic) || + changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), "an opaque Adapter semantic identity change is incompatible"); return Task.CompletedTask; } From 040e1c257a25d0219e88600e233bdb04c34b03ac Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:23:05 +0800 Subject: [PATCH 310/399] test: keep timeout assertion change isolated --- .../ContractManifestGeneratorTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs index 1e2d594ca..69fdffac8 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTests.cs @@ -67,7 +67,7 @@ public sealed class HelloService : IHelloService Ensure(first.Json.Contains("\"schemaFingerprint\":", StringComparison.Ordinal), "schema fingerprint"); var generatorVersion = typeof(RpcGenerator).Assembly.GetName().Version!.ToString(3); - Ensure(first.Json.Contains($"\"generatorVersion\": \"{generatorVersion}\";", StringComparison.Ordinal), + Ensure(first.Json.Contains($"\"generatorVersion\": \"{generatorVersion}\"", StringComparison.Ordinal), "executing generator assembly version"); Ensure(!first.Json.Contains(Directory.GetCurrentDirectory(), StringComparison.Ordinal), "Manifest must not contain absolute paths"); @@ -325,8 +325,7 @@ public Task ExplicitAdapterSemanticIdentityChangeShouldBeRejected() AdapterContractSource(semanticLow: 0x3333333333333333UL), baseline); - Ensure(!changed.Diagnostics.Any(IsCompatibilityDiagnostic) || - changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), "an opaque Adapter semantic identity change is incompatible"); return Task.CompletedTask; } From 646b86cffe1e00864e745cf7f866ef2635f85103 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:13:14 +0800 Subject: [PATCH 311/399] test: cover final codec plan acceptance --- ...CodecFinalPlanAcceptanceRegressionTests.cs | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs new file mode 100644 index 000000000..7c5c55d9c --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task NestedEnumDeclarationShouldParticipateInUnsafeBlitPhysicalIdentity() + { + static string Manifest(string members) + { + var source = BuildSource($$""" +public enum NestedPhysicalStatus : int +{ + {{members}} +} + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct NestedEnumPhysicalPayload +{ + public int Prefix; + public NestedPhysicalStatus Status; +} + +[SharpLink.Sdk.RpcContract] +public interface INestedEnumPhysicalContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + NestedEnumPhysicalPayload value, + CancellationToken cancellationToken); +} +"""); + + return RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + } + + var before = Manifest("Ready = 0, Failed = 1"); + var after = Manifest("Ready = 1, Failed = 0"); + + Ensure( + ExtractGeneratedCodecIdentity(before, "NestedEnumPhysicalPayload") != + ExtractGeneratedCodecIdentity(after, "NestedEnumPhysicalPayload"), + "a nested enum declaration mapping change must change the enclosing UnsafeBlit CodecHash even when width and struct layout are unchanged"); + Ensure( + ExtractGeneratedRpcAssemblyHash(before) != ExtractGeneratedRpcAssemblyHash(after), + "nested enum declaration semantics must flow through the enclosing UnsafeBlit CodecHash into RpcAssemblyHash"); + return Task.CompletedTask; + } + + [Test] + public Task UnsafeBlitIdentityShouldCanonicalizeEffectiveLayout() + { + static string Manifest(string source) + => RunGeneratorAndGetSources(BuildSource(source)) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + + static string SequentialSource(string charSet, int pack) => $$""" +[System.Runtime.InteropServices.StructLayout( + System.Runtime.InteropServices.LayoutKind.Sequential, + CharSet = System.Runtime.InteropServices.CharSet.{{charSet}}, + Pack = {{pack}})] +public struct EffectiveLayoutPayload +{ + public byte Head; + public long Tail; +} + +[SharpLink.Sdk.RpcContract] +public interface IEffectiveLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + EffectiveLayoutPayload value, + CancellationToken cancellationToken); +} +"""; + + static string ExplicitSource(bool reverseDeclarations, int tailOffset, int size) + { + var fields = reverseDeclarations + ? $$""" + [System.Runtime.InteropServices.FieldOffset({{tailOffset}})] public long Tail; + [System.Runtime.InteropServices.FieldOffset(0)] public byte Head; +""" + : $$""" + [System.Runtime.InteropServices.FieldOffset(0)] public byte Head; + [System.Runtime.InteropServices.FieldOffset({{tailOffset}})] public long Tail; +"""; + return $$""" +[System.Runtime.InteropServices.StructLayout( + System.Runtime.InteropServices.LayoutKind.Explicit, + Size = {{size}})] +public struct EffectiveLayoutPayload +{ +{{fields}} +} + +[SharpLink.Sdk.RpcContract] +public interface IEffectiveLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + EffectiveLayoutPayload value, + CancellationToken cancellationToken); +} +"""; + } + + var sequentialAnsi = Manifest(SequentialSource("Ansi", 8)); + var sequentialUnicode = Manifest(SequentialSource("Unicode", 8)); + Ensure( + ExtractGeneratedCodecIdentity(sequentialAnsi, "EffectiveLayoutPayload") == + ExtractGeneratedCodecIdentity(sequentialUnicode, "EffectiveLayoutPayload"), + "StructLayout CharSet is source metadata but does not change raw unmanaged field layout and must not perturb UnsafeBlit identity"); + + var explicitDeclaredForward = Manifest(ExplicitSource(reverseDeclarations: false, tailOffset: 8, size: 16)); + var explicitDeclaredReverse = Manifest(ExplicitSource(reverseDeclarations: true, tailOffset: 8, size: 16)); + Ensure( + ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutPayload") == + ExtractGeneratedCodecIdentity(explicitDeclaredReverse, "EffectiveLayoutPayload"), + "Explicit-layout field declaration order must canonicalize by effective offset and physical semantics"); + + var sequentialPack1 = Manifest(SequentialSource("Ansi", 1)); + Ensure( + ExtractGeneratedCodecIdentity(sequentialAnsi, "EffectiveLayoutPayload") != + ExtractGeneratedCodecIdentity(sequentialPack1, "EffectiveLayoutPayload"), + "an effective Sequential Pack change must change UnsafeBlit identity"); + + var explicitOffsetChanged = Manifest(ExplicitSource(reverseDeclarations: false, tailOffset: 4, size: 16)); + Ensure( + ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutPayload") != + ExtractGeneratedCodecIdentity(explicitOffsetChanged, "EffectiveLayoutPayload"), + "an effective Explicit field offset change must change UnsafeBlit identity"); + + var explicitSizeChanged = Manifest(ExplicitSource(reverseDeclarations: false, tailOffset: 8, size: 24)); + Ensure( + ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutPayload") != + ExtractGeneratedCodecIdentity(explicitSizeChanged, "EffectiveLayoutPayload"), + "an effective Explicit Size change must change UnsafeBlit identity"); + return Task.CompletedTask; + } + + [Test] + public Task NullableEnumDtoMemberShouldRetainEnumDeclarationIdentity() + { + static string Manifest(string members) + { + var source = BuildSource($$""" +public enum NullableMemberStatus : int +{ + {{members}} +} + +[SharpLink.Sdk.RpcSerializable] +public sealed class NullableEnumEnvelope +{ + public NullableMemberStatus? Status { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface INullableEnumMemberContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + NullableEnumEnvelope value, + CancellationToken cancellationToken); +} +"""); + + return RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + } + + var before = Manifest("Ready = 0, Failed = 1"); + var after = Manifest("Ready = 1, Failed = 0"); + + Ensure( + ExtractGeneratedCodecIdentity(before, "NullableEnumEnvelope") != + ExtractGeneratedCodecIdentity(after, "NullableEnumEnvelope"), + "Nullable used as a generated DTO member must retain enum declaration semantics in the parent CodecHash"); + Ensure( + ExtractGeneratedRpcAssemblyHash(before) != ExtractGeneratedRpcAssemblyHash(after), + "Nullable DTO member declaration semantics must flow into RpcAssemblyHash"); + return Task.CompletedTask; + } + + [Test] + public Task AutoLayoutDiagnosticShouldTraverseFinalCollectionAndGeneratedDtoPlans() + { + var collectionSource = BuildSource(""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] +public struct CollectionAutoPayload +{ + public byte Head; + public long Tail; +} + +[SharpLink.Sdk.RpcContract] +public interface ICollectionAutoLayoutContract : SharpLink.Sdk.IService +{ + ValueTask> Echo( + List value, + CancellationToken cancellationToken); +} +"""); + var collectionDiagnostic = RunUnsafeBlitCompatibilityGenerator(collectionSource) + .Single(static diagnostic => diagnostic.Id == "SHARPLINK064"); + var collectionMessage = collectionDiagnostic.GetMessage(); + Ensure( + collectionMessage.Contains("List", StringComparison.Ordinal) && + collectionMessage.Contains("CollectionAutoPayload", StringComparison.Ordinal), + $"SHARPLINK064 must traverse a finalized collection Codec to its UnsafeBlit element plan. Actual: {collectionMessage}"); + + var generatedDtoSource = BuildSource(""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] +public struct DtoAutoPayload +{ + public short Code; + public long Value; +} + +[SharpLink.Sdk.RpcSerializable] +public sealed class AutoLayoutEnvelope +{ + public DtoAutoPayload Value { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface IGeneratedDtoAutoLayoutContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + AutoLayoutEnvelope value, + CancellationToken cancellationToken); +} +"""); + var generatedDtoDiagnostic = RunUnsafeBlitCompatibilityGenerator(generatedDtoSource) + .Single(static diagnostic => diagnostic.Id == "SHARPLINK064"); + var generatedDtoMessage = generatedDtoDiagnostic.GetMessage(); + Ensure( + generatedDtoMessage.Contains("AutoLayoutEnvelope", StringComparison.Ordinal) && + generatedDtoMessage.Contains("DtoAutoPayload", StringComparison.Ordinal), + $"SHARPLINK064 must traverse a finalized generated DTO Codec to its UnsafeBlit member plan. Actual: {generatedDtoMessage}"); + return Task.CompletedTask; + } + + [Test] + public Task FunctionPointerSignatureShouldParticipateInUnsafeBlitIdentity() + { + static string Manifest(string signature) + => GenerateUnsafeFinalPlanManifest(BuildSource($$""" +public unsafe struct FunctionPointerPayload +{ + public {{signature}} Callback; +} + +[SharpLink.Sdk.RpcContract] +public interface IFunctionPointerIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + FunctionPointerPayload value, + CancellationToken cancellationToken); +} +""")); + + var baseline = Manifest("delegate*"); + var baselineCodec = ExtractGeneratedCodecIdentity(baseline, "FunctionPointerPayload"); + var baselineAssembly = ExtractGeneratedRpcAssemblyHash(baseline); + foreach (var changedSignature in new[] + { + "delegate*", + "delegate*", + "delegate*", + "delegate*", + "delegate* unmanaged" + }) + { + var changed = Manifest(changedSignature); + Ensure( + baselineCodec != ExtractGeneratedCodecIdentity(changed, "FunctionPointerPayload"), + $"function-pointer signature semantic '{changedSignature}' must change the enclosing UnsafeBlit CodecHash"); + Ensure( + baselineAssembly != ExtractGeneratedRpcAssemblyHash(changed), + $"function-pointer signature semantic '{changedSignature}' must flow into RpcAssemblyHash"); + } + return Task.CompletedTask; + } + + private static string GenerateUnsafeFinalPlanManifest(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); + var compilation = CSharpCompilation.Create( + "FinalCodecPlanUnsafeAcceptance", + [syntaxTree], + GetPlatformReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithAllowUnsafe(true)); + var sourceErrors = compilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Ensure( + sourceErrors.Length == 0, + "unsafe acceptance source must compile: " + + string.Join(Environment.NewLine, sourceErrors.Select(static diagnostic => diagnostic.ToString()))); + + IIncrementalGenerator generator = new RpcGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGenerators(compilation); + var runResult = driver.GetRunResult(); + var generatorErrors = runResult.Diagnostics + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Ensure( + generatorErrors.Length == 0, + "unsafe acceptance generator run must succeed: " + + string.Join(Environment.NewLine, generatorErrors.Select(static diagnostic => diagnostic.ToString()))); + + return runResult.GeneratedTrees + .Select(static tree => tree.GetText().ToString()) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + } +} From 53b6587fac1adcf0bfec5a631af57b1a1205566f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:16:10 +0800 Subject: [PATCH 312/399] test: tighten final plan acceptance coverage --- ...CodecFinalPlanAcceptanceSupplementTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs new file mode 100644 index 000000000..0827ada52 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task ImplicitAndExplicitDefaultSequentialShouldShareUnsafeBlitIdentity() + { + static string Manifest(bool explicitSequential) + { + var layout = explicitSequential + ? "[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]" + : string.Empty; + var source = BuildSource($$""" +{{layout}} +public struct DefaultSequentialPayload +{ + public byte Head; + public long Tail; +} + +[SharpLink.Sdk.RpcContract] +public interface IDefaultSequentialContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + DefaultSequentialPayload value, + CancellationToken cancellationToken); +} +"""); + + return RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + } + + var implicitSequential = Manifest(explicitSequential: false); + var explicitSequential = Manifest(explicitSequential: true); + Ensure( + ExtractGeneratedCodecIdentity(implicitSequential, "DefaultSequentialPayload") == + ExtractGeneratedCodecIdentity(explicitSequential, "DefaultSequentialPayload"), + "implicit Sequential and explicit default Sequential describe the same effective CLR layout and must share one UnsafeBlit CodecHash"); + Ensure( + ExtractGeneratedRpcAssemblyHash(implicitSequential) == + ExtractGeneratedRpcAssemblyHash(explicitSequential), + "source-only spelling of default Sequential layout must not perturb RpcAssemblyHash"); + return Task.CompletedTask; + } + + [Test] + public Task RawNullablePhysicalIdentityShouldIncludePresenceAndValueLayout() + { + static string Manifest(string fieldType, string extraType) + { + var source = BuildSource($$""" +public struct NullablePhysicalValue +{ + public int Payload; +} + +{{extraType}} + +public struct NullablePhysicalEnvelope +{ + public {{fieldType}} Value; +} + +[SharpLink.Sdk.RpcContract] +public interface INullablePhysicalContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + NullablePhysicalEnvelope value, + CancellationToken cancellationToken); +} +"""); + + return RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + } + + var nullable = Manifest("NullablePhysicalValue?", string.Empty); + var fullReplica = Manifest( + "NullablePhysicalReplica", + """ +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct NullablePhysicalReplica +{ + private bool HasValue; + private NullablePhysicalValue Value; +} +"""); + var childOnlyReplica = Manifest( + "NullablePhysicalChildOnly", + """ +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct NullablePhysicalChildOnly +{ + private NullablePhysicalValue Value; +} +"""); + + var nullableCodec = ExtractGeneratedCodecIdentity(nullable, "NullablePhysicalEnvelope"); + Ensure( + nullableCodec == ExtractGeneratedCodecIdentity(fullReplica, "NullablePhysicalEnvelope"), + "raw Nullable physical identity must model the CLR presence field plus the value field, not only the child T layout"); + Ensure( + nullableCodec != ExtractGeneratedCodecIdentity(childOnlyReplica, "NullablePhysicalEnvelope"), + "removing the Nullable presence representation must change the enclosing UnsafeBlit CodecHash"); + return Task.CompletedTask; + } +} From 16cc44044a6d42d0918d48442aeb920386d58edb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:22:16 +0800 Subject: [PATCH 313/399] test: validate propagated unsafe blit identity --- ...CodecFinalPlanAcceptanceRegressionTests.cs | 80 ++++++++++++------- ...CodecFinalPlanAcceptanceSupplementTests.cs | 23 ++++-- 2 files changed, 67 insertions(+), 36 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs index 7c5c55d9c..3605b007b 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceRegressionTests.cs @@ -26,11 +26,17 @@ public struct NestedEnumPhysicalPayload public NestedPhysicalStatus Status; } +[SharpLink.Sdk.RpcSerializable] +public sealed class NestedEnumPhysicalEnvelope +{ + public NestedEnumPhysicalPayload Value { get; set; } +} + [SharpLink.Sdk.RpcContract] public interface INestedEnumPhysicalContract : SharpLink.Sdk.IService { - ValueTask Echo( - NestedEnumPhysicalPayload value, + ValueTask Echo( + NestedEnumPhysicalEnvelope value, CancellationToken cancellationToken); } """); @@ -45,9 +51,9 @@ ValueTask Echo( var after = Manifest("Ready = 1, Failed = 0"); Ensure( - ExtractGeneratedCodecIdentity(before, "NestedEnumPhysicalPayload") != - ExtractGeneratedCodecIdentity(after, "NestedEnumPhysicalPayload"), - "a nested enum declaration mapping change must change the enclosing UnsafeBlit CodecHash even when width and struct layout are unchanged"); + ExtractGeneratedCodecIdentity(before, "NestedEnumPhysicalEnvelope") != + ExtractGeneratedCodecIdentity(after, "NestedEnumPhysicalEnvelope"), + "a nested enum declaration mapping change must change an enclosing generated CodecHash even when width and struct layout are unchanged"); Ensure( ExtractGeneratedRpcAssemblyHash(before) != ExtractGeneratedRpcAssemblyHash(after), "nested enum declaration semantics must flow through the enclosing UnsafeBlit CodecHash into RpcAssemblyHash"); @@ -74,11 +80,17 @@ public struct EffectiveLayoutPayload public long Tail; } +[SharpLink.Sdk.RpcSerializable] +public sealed class EffectiveLayoutEnvelope +{ + public EffectiveLayoutPayload Value { get; set; } +} + [SharpLink.Sdk.RpcContract] public interface IEffectiveLayoutContract : SharpLink.Sdk.IService { - ValueTask Echo( - EffectiveLayoutPayload value, + ValueTask Echo( + EffectiveLayoutEnvelope value, CancellationToken cancellationToken); } """; @@ -103,11 +115,17 @@ public struct EffectiveLayoutPayload {{fields}} } +[SharpLink.Sdk.RpcSerializable] +public sealed class EffectiveLayoutEnvelope +{ + public EffectiveLayoutPayload Value { get; set; } +} + [SharpLink.Sdk.RpcContract] public interface IEffectiveLayoutContract : SharpLink.Sdk.IService { - ValueTask Echo( - EffectiveLayoutPayload value, + ValueTask Echo( + EffectiveLayoutEnvelope value, CancellationToken cancellationToken); } """; @@ -116,34 +134,34 @@ ValueTask Echo( var sequentialAnsi = Manifest(SequentialSource("Ansi", 8)); var sequentialUnicode = Manifest(SequentialSource("Unicode", 8)); Ensure( - ExtractGeneratedCodecIdentity(sequentialAnsi, "EffectiveLayoutPayload") == - ExtractGeneratedCodecIdentity(sequentialUnicode, "EffectiveLayoutPayload"), - "StructLayout CharSet is source metadata but does not change raw unmanaged field layout and must not perturb UnsafeBlit identity"); + ExtractGeneratedCodecIdentity(sequentialAnsi, "EffectiveLayoutEnvelope") == + ExtractGeneratedCodecIdentity(sequentialUnicode, "EffectiveLayoutEnvelope"), + "StructLayout CharSet is source metadata but does not change raw unmanaged field layout and must not perturb an enclosing generated CodecHash"); var explicitDeclaredForward = Manifest(ExplicitSource(reverseDeclarations: false, tailOffset: 8, size: 16)); var explicitDeclaredReverse = Manifest(ExplicitSource(reverseDeclarations: true, tailOffset: 8, size: 16)); Ensure( - ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutPayload") == - ExtractGeneratedCodecIdentity(explicitDeclaredReverse, "EffectiveLayoutPayload"), + ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutEnvelope") == + ExtractGeneratedCodecIdentity(explicitDeclaredReverse, "EffectiveLayoutEnvelope"), "Explicit-layout field declaration order must canonicalize by effective offset and physical semantics"); var sequentialPack1 = Manifest(SequentialSource("Ansi", 1)); Ensure( - ExtractGeneratedCodecIdentity(sequentialAnsi, "EffectiveLayoutPayload") != - ExtractGeneratedCodecIdentity(sequentialPack1, "EffectiveLayoutPayload"), - "an effective Sequential Pack change must change UnsafeBlit identity"); + ExtractGeneratedCodecIdentity(sequentialAnsi, "EffectiveLayoutEnvelope") != + ExtractGeneratedCodecIdentity(sequentialPack1, "EffectiveLayoutEnvelope"), + "an effective Sequential Pack change must change the propagated UnsafeBlit identity"); var explicitOffsetChanged = Manifest(ExplicitSource(reverseDeclarations: false, tailOffset: 4, size: 16)); Ensure( - ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutPayload") != - ExtractGeneratedCodecIdentity(explicitOffsetChanged, "EffectiveLayoutPayload"), - "an effective Explicit field offset change must change UnsafeBlit identity"); + ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutEnvelope") != + ExtractGeneratedCodecIdentity(explicitOffsetChanged, "EffectiveLayoutEnvelope"), + "an effective Explicit field offset change must change the propagated UnsafeBlit identity"); var explicitSizeChanged = Manifest(ExplicitSource(reverseDeclarations: false, tailOffset: 8, size: 24)); Ensure( - ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutPayload") != - ExtractGeneratedCodecIdentity(explicitSizeChanged, "EffectiveLayoutPayload"), - "an effective Explicit Size change must change UnsafeBlit identity"); + ExtractGeneratedCodecIdentity(explicitDeclaredForward, "EffectiveLayoutEnvelope") != + ExtractGeneratedCodecIdentity(explicitSizeChanged, "EffectiveLayoutEnvelope"), + "an effective Explicit Size change must change the propagated UnsafeBlit identity"); return Task.CompletedTask; } @@ -261,17 +279,23 @@ public unsafe struct FunctionPointerPayload public {{signature}} Callback; } +[SharpLink.Sdk.RpcSerializable] +public sealed class FunctionPointerEnvelope +{ + public FunctionPointerPayload Value { get; set; } +} + [SharpLink.Sdk.RpcContract] public interface IFunctionPointerIdentityContract : SharpLink.Sdk.IService { - ValueTask Echo( - FunctionPointerPayload value, + ValueTask Echo( + FunctionPointerEnvelope value, CancellationToken cancellationToken); } """)); var baseline = Manifest("delegate*"); - var baselineCodec = ExtractGeneratedCodecIdentity(baseline, "FunctionPointerPayload"); + var baselineCodec = ExtractGeneratedCodecIdentity(baseline, "FunctionPointerEnvelope"); var baselineAssembly = ExtractGeneratedRpcAssemblyHash(baseline); foreach (var changedSignature in new[] { @@ -284,8 +308,8 @@ ValueTask Echo( { var changed = Manifest(changedSignature); Ensure( - baselineCodec != ExtractGeneratedCodecIdentity(changed, "FunctionPointerPayload"), - $"function-pointer signature semantic '{changedSignature}' must change the enclosing UnsafeBlit CodecHash"); + baselineCodec != ExtractGeneratedCodecIdentity(changed, "FunctionPointerEnvelope"), + $"function-pointer signature semantic '{changedSignature}' must change an enclosing generated CodecHash"); Ensure( baselineAssembly != ExtractGeneratedRpcAssemblyHash(changed), $"function-pointer signature semantic '{changedSignature}' must flow into RpcAssemblyHash"); diff --git a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs index 0827ada52..1ffcc080f 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs @@ -22,11 +22,17 @@ public struct DefaultSequentialPayload public long Tail; } +[SharpLink.Sdk.RpcSerializable] +public sealed class DefaultSequentialEnvelope +{ + public DefaultSequentialPayload Value { get; set; } +} + [SharpLink.Sdk.RpcContract] public interface IDefaultSequentialContract : SharpLink.Sdk.IService { - ValueTask Echo( - DefaultSequentialPayload value, + ValueTask Echo( + DefaultSequentialEnvelope value, CancellationToken cancellationToken); } """); @@ -40,9 +46,9 @@ ValueTask Echo( var implicitSequential = Manifest(explicitSequential: false); var explicitSequential = Manifest(explicitSequential: true); Ensure( - ExtractGeneratedCodecIdentity(implicitSequential, "DefaultSequentialPayload") == - ExtractGeneratedCodecIdentity(explicitSequential, "DefaultSequentialPayload"), - "implicit Sequential and explicit default Sequential describe the same effective CLR layout and must share one UnsafeBlit CodecHash"); + ExtractGeneratedCodecIdentity(implicitSequential, "DefaultSequentialEnvelope") == + ExtractGeneratedCodecIdentity(explicitSequential, "DefaultSequentialEnvelope"), + "implicit Sequential and explicit default Sequential describe the same effective CLR layout and must propagate the same UnsafeBlit identity into an enclosing generated CodecHash"); Ensure( ExtractGeneratedRpcAssemblyHash(implicitSequential) == ExtractGeneratedRpcAssemblyHash(explicitSequential), @@ -63,9 +69,10 @@ public struct NullablePhysicalValue {{extraType}} -public struct NullablePhysicalEnvelope +[SharpLink.Sdk.RpcSerializable] +public sealed class NullablePhysicalEnvelope { - public {{fieldType}} Value; + public {{fieldType}} Value { get; set; } } [SharpLink.Sdk.RpcContract] @@ -110,7 +117,7 @@ public struct NullablePhysicalChildOnly "raw Nullable physical identity must model the CLR presence field plus the value field, not only the child T layout"); Ensure( nullableCodec != ExtractGeneratedCodecIdentity(childOnlyReplica, "NullablePhysicalEnvelope"), - "removing the Nullable presence representation must change the enclosing UnsafeBlit CodecHash"); + "removing the Nullable presence representation must change an enclosing generated CodecHash"); return Task.CompletedTask; } } From c5b4ded3b235d75fe591856491ec3b488820c3d3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:45 +0800 Subject: [PATCH 314/399] test: inspect raw nullable codec identity --- ...CodecFinalPlanAcceptanceSupplementTests.cs | 117 ++++++++++++------ 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs index 1ffcc080f..eba148b37 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs @@ -1,6 +1,12 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Linq; +using System.Reflection; +using System.Threading; using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace SharpLink.Generator.Tests; @@ -59,65 +65,100 @@ ValueTask Echo( [Test] public Task RawNullablePhysicalIdentityShouldIncludePresenceAndValueLayout() { - static string Manifest(string fieldType, string extraType) - { - var source = BuildSource($$""" + var source = BuildSource(""" public struct NullablePhysicalValue { public int Payload; } -{{extraType}} - -[SharpLink.Sdk.RpcSerializable] -public sealed class NullablePhysicalEnvelope -{ - public {{fieldType}} Value { get; set; } -} - -[SharpLink.Sdk.RpcContract] -public interface INullablePhysicalContract : SharpLink.Sdk.IService -{ - ValueTask Echo( - NullablePhysicalEnvelope value, - CancellationToken cancellationToken); -} -"""); - - return RunGeneratorAndGetSources(source) - .Single(static generated => generated.Contains( - "ISharpLinkGeneratedAssemblyManifest", - StringComparison.Ordinal)); - } - - var nullable = Manifest("NullablePhysicalValue?", string.Empty); - var fullReplica = Manifest( - "NullablePhysicalReplica", - """ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] public struct NullablePhysicalReplica { private bool HasValue; private NullablePhysicalValue Value; } -"""); - var childOnlyReplica = Manifest( - "NullablePhysicalChildOnly", - """ + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] public struct NullablePhysicalChildOnly { private NullablePhysicalValue Value; } + +[SharpLink.Sdk.RpcContract] +public interface INullablePhysicalContract : SharpLink.Sdk.IService +{ + ValueTask EchoNullable( + NullablePhysicalValue? value, + CancellationToken cancellationToken); + + ValueTask EchoReplica( + NullablePhysicalReplica value, + CancellationToken cancellationToken); + + ValueTask EchoChildOnly( + NullablePhysicalChildOnly value, + CancellationToken cancellationToken); +} """); - var nullableCodec = ExtractGeneratedCodecIdentity(nullable, "NullablePhysicalEnvelope"); + var hashes = AnalyzeFinalCodecHashesForAcceptance(source); + var nullableCodec = hashes + .Single(static pair => + pair.Key.Contains("System.Nullable", StringComparison.Ordinal) && + pair.Key.Contains("NullablePhysicalValue", StringComparison.Ordinal)) + .Value; + var fullReplicaCodec = hashes["global::NullablePhysicalReplica"]; + var childOnlyCodec = hashes["global::NullablePhysicalChildOnly"]; + Ensure( - nullableCodec == ExtractGeneratedCodecIdentity(fullReplica, "NullablePhysicalEnvelope"), + nullableCodec == fullReplicaCodec, "raw Nullable physical identity must model the CLR presence field plus the value field, not only the child T layout"); Ensure( - nullableCodec != ExtractGeneratedCodecIdentity(childOnlyReplica, "NullablePhysicalEnvelope"), - "removing the Nullable presence representation must change an enclosing generated CodecHash"); + nullableCodec != childOnlyCodec, + "removing the Nullable presence representation must change the raw UnsafeBlit CodecHash"); return Task.CompletedTask; } + + private static Dictionary AnalyzeFinalCodecHashesForAcceptance( + string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); + var compilation = CSharpCompilation.Create( + "FinalCodecPlanNullableAcceptance", + [syntaxTree], + GetPlatformReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var sourceErrors = compilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Ensure( + sourceErrors.Length == 0, + "nullable acceptance source must compile: " + + string.Join(Environment.NewLine, sourceErrors.Select(static diagnostic => diagnostic.ToString()))); + + var analyze = typeof(RpcGenerator).GetMethod( + "AnalyzeGeneratedCodecsWithPolicyOwnership", + BindingFlags.Static | BindingFlags.NonPublic) ?? + throw new InvalidOperationException("Final Codec analysis entry point was not found."); + var result = analyze.Invoke(null, [compilation, CancellationToken.None]) ?? + throw new InvalidOperationException("Final Codec analysis returned no result."); + var codecHashes = result.GetType().GetProperty("CodecHashes")?.GetValue(result) as IEnumerable ?? + throw new InvalidOperationException("Final Codec analysis did not expose CodecHashes."); + + var hashes = new Dictionary(StringComparer.Ordinal); + foreach (var item in codecHashes) + { + if (item is null) + continue; + var itemType = item.GetType(); + var typeName = itemType.GetProperty("TypeName")?.GetValue(item) as string ?? + throw new InvalidOperationException("Final Codec hash entry has no TypeName."); + var high = (ulong)(itemType.GetProperty("High")?.GetValue(item) ?? + throw new InvalidOperationException("Final Codec hash entry has no High value.")); + var low = (ulong)(itemType.GetProperty("Low")?.GetValue(item) ?? + throw new InvalidOperationException("Final Codec hash entry has no Low value.")); + hashes[typeName] = (high, low); + } + return hashes; + } } From f07a1350758c7f5b325cbd30adaf032e0468df30 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:31:18 +0800 Subject: [PATCH 315/399] test: locate nullable final codec hash --- .../RpcCodecFinalPlanAcceptanceSupplementTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs index eba148b37..23a348113 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecFinalPlanAcceptanceSupplementTests.cs @@ -104,8 +104,8 @@ ValueTask EchoChildOnly( var hashes = AnalyzeFinalCodecHashesForAcceptance(source); var nullableCodec = hashes .Single(static pair => - pair.Key.Contains("System.Nullable", StringComparison.Ordinal) && - pair.Key.Contains("NullablePhysicalValue", StringComparison.Ordinal)) + pair.Key.Contains("NullablePhysicalValue", StringComparison.Ordinal) && + !string.Equals(pair.Key, "global::NullablePhysicalValue", StringComparison.Ordinal)) .Value; var fullReplicaCodec = hashes["global::NullablePhysicalReplica"]; var childOnlyCodec = hashes["global::NullablePhysicalChildOnly"]; From 4e2eee14a619e6d550f0877e7d0468e2a5545eca Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:57:46 +0800 Subject: [PATCH 316/399] refactor: gate generated codecs on final plan reachability --- .../RpcGenerator.FinalCodecPlan.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs index 921222544..7e332e1a0 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs @@ -29,17 +29,6 @@ internal FinalCodecGraph ResolveFinalCodecGraph( ResolveFinalCodecPlan(pair.Value, plans, resolving); } - // Candidate analysis can discover factories before this pass, but final Codec selection - // is represented only by the resolved plan graph. Every emitted factory must therefore - // have a corresponding plan before hashes/metadata are produced. - foreach (var model in _models.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) - { - if (_failed.Contains(model.TypeName) || plans.ContainsKey(model.TypeName)) - continue; - if (TryResolveReachableType(model.TypeName, out var type)) - ResolveFinalCodecPlan(type, plans, resolving); - } - // Enum declaration semantics can be required by generated metadata even when the // enclosing runtime Codec is a raw physical plan such as UnsafeBlit>. // Materialize those reached enum nodes here so every downstream consumer observes the From 601e214b94920776192aa40174b45702dd9ba558 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:05:37 +0800 Subject: [PATCH 317/399] refactor: drive generated factories from final codec graph --- .../RpcGenerator.CodecPolicyOwnership.cs | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 26a7fb6d9..19b35cba9 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -17,7 +17,7 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( includeSerializable: true, includeContracts: false); var standaloneHashes = standaloneState.BuildFinalCodecHashes(standaloneGraph); - var standaloneCodecs = AttachCodecHashes(standalone.Codecs, standaloneHashes); + var standaloneCodecs = AttachCodecHashes(standalone.Codecs, standaloneGraph, standaloneHashes); var contractDefaultState = new DtoAnalysisState( compilation, @@ -30,7 +30,10 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( includeSerializable: false, includeContracts: true); var contractDefaultHashes = contractDefaultState.BuildFinalCodecHashes(contractDefaultGraph); - var contractDefaultCodecs = AttachCodecHashes(contractDefault.Codecs, contractDefaultHashes); + var contractDefaultCodecs = AttachCodecHashes( + contractDefault.Codecs, + contractDefaultGraph, + contractDefaultHashes); var contractPolicyState = new DtoAnalysisState( compilation, @@ -45,9 +48,14 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( var codecHashes = contractPolicyState.BuildFinalCodecHashes(contractPolicyGraph); var unsafeBlitAutoLayoutDiagnostics = DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph); - var contractPolicyCodecs = AttachCodecHashes(contractPolicy.Codecs, codecHashes); + var contractPolicyCodecs = AttachCodecHashes( + contractPolicy.Codecs, + contractPolicyGraph, + codecHashes); - var currentContractTypes = contractPolicyState.GetCurrentContractReachableTypeNames(); + var currentContractTypes = new HashSet( + contractPolicyGraph.Plans.Keys, + StringComparer.Ordinal); var currentContractDefaultCodecs = contractDefaultCodecs .Where(codec => currentContractTypes.Contains(codec.TypeName)) .ToImmutableArray(); @@ -126,16 +134,30 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( private static ImmutableArray AttachCodecHashes( ImmutableArray codecs, + FinalCodecGraph graph, ImmutableArray hashes) { + var codecByType = codecs.ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); var hashByType = hashes.ToDictionary(static item => item.TypeName, StringComparer.Ordinal); - return codecs - .Select(codec => + return graph.Plans.Values + .Where(RequiresGeneratedFactory) + .OrderBy(static plan => plan.TypeName, StringComparer.Ordinal) + .Select(plan => { - if (!hashByType.TryGetValue(codec.TypeName, out var hash)) + if (!codecByType.TryGetValue(plan.TypeName, out var codec)) + { + throw new InvalidOperationException( + $"Final Codec plan '{plan.TypeName}' requires a generated factory but candidate analysis produced none."); + } + if (!MatchesGeneratedFactoryPlan(plan, codec)) + { + throw new InvalidOperationException( + $"Final Codec plan '{plan.TypeName}' does not match generated factory candidate kind '{codec.Kind}'."); + } + if (!hashByType.TryGetValue(plan.TypeName, out var hash)) { throw new InvalidOperationException( - $"Final Codec graph is missing deterministic identity for generated Codec '{codec.TypeName}'."); + $"Final Codec graph is missing deterministic identity for generated Codec '{plan.TypeName}'."); } return codec with { @@ -146,6 +168,24 @@ private static ImmutableArray AttachCodecHashes( .ToImmutableArray(); } + private static bool RequiresGeneratedFactory(FinalCodecPlan plan) + => plan is FinalGeneratedDtoCodecPlan or + FinalCustomCodecPlan or + FinalAdapterCodecPlan or + FinalCollectionCodecPlan { WireStrategy: FinalCollectionWireStrategy.ChildCodec }; + + private static bool MatchesGeneratedFactoryPlan(FinalCodecPlan plan, GeneratedCodecModel codec) + => plan switch + { + FinalGeneratedDtoCodecPlan => codec.Kind == GeneratedCodecKind.Dto, + FinalCustomCodecPlan => codec.Kind == GeneratedCodecKind.Custom, + FinalAdapterCodecPlan => codec.Kind == GeneratedCodecKind.Adapter, + FinalCollectionCodecPlan collection => + collection.WireStrategy == FinalCollectionWireStrategy.ChildCodec && + codec.Kind == collection.CollectionKind, + _ => false + }; + private static bool ContainsRpcContract(INamespaceSymbol namespaceSymbol) { foreach (var type in namespaceSymbol.GetTypeMembers()) @@ -644,4 +684,4 @@ private void CollectFinalBindingTypes(ITypeSymbol type, Dictionary Date: Wed, 2 Sep 2026 10:07:45 +0800 Subject: [PATCH 318/399] style: restore codec ownership final newline --- src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 19b35cba9..a6f91544f 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -684,4 +684,4 @@ private void CollectFinalBindingTypes(ITypeSymbol type, Dictionary Date: Wed, 2 Sep 2026 10:11:45 +0800 Subject: [PATCH 319/399] refactor: let final resolver own runtime codec selection --- .../RpcGenerator.FinalCodecPlan.Selection.cs | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index af15f425f..dfdd862b7 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -18,9 +18,13 @@ private FinalCodecPlan ResolveFinalCodecPlan( $"Final Codec graph contains an unresolved recursive Codec selection at '{typeName}'."); } + _models.TryGetValue(typeName, out var generatedModel); FinalCodecPlan plan; - if (_models.TryGetValue(typeName, out var generatedModel)) + if (generatedModel is { Kind: GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter }) { + // Explicit validated bindings create exact generated factories and therefore outrank + // runtime enum/unmanaged fallbacks. Candidate analysis supplies emitter details only; + // the resolved plan remains the final semantic selection. plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); } else if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) @@ -59,10 +63,21 @@ private FinalCodecPlan ResolveFinalCodecPlan( out _, out _)) { - if (collectionKind == GeneratedCodecKind.Nullable && - elementType is not null && - type.IsUnmanagedType && - !HasExactBuiltinNullableCodecElement(elementType)) + if (generatedModel is not null) + { + if (generatedModel.Kind is GeneratedCodecKind.Dto or + GeneratedCodecKind.Custom or + GeneratedCodecKind.Adapter) + { + throw new InvalidOperationException( + $"Final collection selection for '{typeName}' received incompatible generated candidate kind '{generatedModel.Kind}'."); + } + plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); + } + else if (collectionKind == GeneratedCodecKind.Nullable && + elementType is not null && + type.IsUnmanagedType && + !HasExactBuiltinNullableCodecElement(elementType)) { plan = ResolveUnsafeBlitCodecPlan(type); } @@ -84,6 +99,15 @@ elementType is not null && { plan = ResolveUnsafeBlitCodecPlan(type); } + else if (generatedModel is { Kind: GeneratedCodecKind.Dto }) + { + plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); + } + else if (generatedModel is not null) + { + throw new InvalidOperationException( + $"Final RPC Codec graph received unsupported generated candidate kind '{generatedModel.Kind}' for '{typeName}'."); + } else { throw new InvalidOperationException( From 4a1df747e02dd540d636741011d4cebc9e0c55e5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:05:10 +0800 Subject: [PATCH 320/399] refactor: carry final codec emission bindings in resolved plan --- .../RpcGenerator.FinalCodecPlan.Models.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs index 0169ba307..9d3c9c7d9 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs @@ -89,13 +89,16 @@ internal sealed record FinalUnsafeBlitCodecPlan( internal sealed record FinalCustomCodecPlan( string TypeName, - RpcHashValue OpaqueSemanticIdentity) + RpcHashValue OpaqueSemanticIdentity, + string CodecTypeName) : FinalCodecPlan(TypeName, FinalCodecPlanKind.Custom); internal sealed record FinalAdapterCodecPlan( string TypeName, RpcHashValue OpaqueSemanticIdentity, - RpcHashValue ClosedTargetLogicalIdentity) + RpcHashValue ClosedTargetLogicalIdentity, + string AdapterTypeName, + string AdapterId) : FinalCodecPlan(TypeName, FinalCodecPlanKind.Adapter); internal sealed record FinalReferencedCodecPlan( From a69462cfe0bbdba7e098fcc9d9c98b18d0779ba9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:05:57 +0800 Subject: [PATCH 321/399] refactor: resolve codec emission bindings into final plan --- .../RpcGenerator.FinalCodecPlan.Selection.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index dfdd862b7..1fd5ef5b7 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -22,9 +22,6 @@ private FinalCodecPlan ResolveFinalCodecPlan( FinalCodecPlan plan; if (generatedModel is { Kind: GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter }) { - // Explicit validated bindings create exact generated factories and therefore outrank - // runtime enum/unmanaged fallbacks. Candidate analysis supplies emitter details only; - // the resolved plan remains the final semantic selection. plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); } else if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) @@ -130,12 +127,18 @@ private FinalCodecPlan ResolveGeneratedCodecPlan( case GeneratedCodecKind.Custom: return new FinalCustomCodecPlan( model.TypeName, - GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec")); + GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec"), + model.CustomCodecType ?? throw new InvalidOperationException( + $"Final custom Codec plan '{model.TypeName}' is missing its implementation binding.")); case GeneratedCodecKind.Adapter: return new FinalAdapterCodecPlan( model.TypeName, GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter"), - GetAdapterTargetLogicalIdentity(type)); + GetAdapterTargetLogicalIdentity(type), + model.AdapterType ?? throw new InvalidOperationException( + $"Final Codec Adapter plan '{model.TypeName}' is missing its implementation binding."), + model.AdapterId ?? throw new InvalidOperationException( + $"Final Codec Adapter plan '{model.TypeName}' is missing its adapter identity.")); case GeneratedCodecKind.Dto: return ResolveGeneratedDtoPlan(type, model, plans, resolving); default: From e5ca403ecc084304ce53b89405cf9f96e5a4a0a5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:07:13 +0800 Subject: [PATCH 322/399] refactor: derive codec ownership from resolved plan graph --- .../RpcGenerator.CodecPolicyOwnership.cs | 161 ++++++++++-------- 1 file changed, 92 insertions(+), 69 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index a6f91544f..dc3355de2 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -67,20 +67,33 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( StringComparer.Ordinal); var standaloneTypes = new HashSet( - standaloneCodecs.Select(static codec => codec.TypeName), + standaloneGraph.Plans.Keys, StringComparer.Ordinal); - var defaultByType = currentContractDefaultCodecs - .ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); - var policyByType = currentContractPolicyCodecs - .ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); - var globalExcludedTypes = new HashSet( - contractOwnedPolicyRoots.Where(type => - !standaloneTypes.Contains(type) && - policyByType.TryGetValue(type, out var policyCodec) && - (!defaultByType.TryGetValue(type, out var defaultCodec) || - !HasSameFinalCodecBinding(defaultCodec, policyCodec))), + var defaultHashByType = contractDefaultHashes.ToDictionary( + static hash => hash.TypeName, + static hash => new RpcHashValue(hash.High, hash.Low), + StringComparer.Ordinal); + var policyHashByType = codecHashes.ToDictionary( + static hash => hash.TypeName, + static hash => new RpcHashValue(hash.High, hash.Low), StringComparer.Ordinal); - ExpandReverseCodecDependencyClosure(currentContractDefaultCodecs, globalExcludedTypes); + var globalExcludedTypes = new HashSet(StringComparer.Ordinal); + foreach (var policyRoot in contractOwnedPolicyRoots) + { + if (standaloneTypes.Contains(policyRoot) || + !contractPolicyGraph.Plans.TryGetValue(policyRoot, out var policyPlan) || + !RequiresGeneratedFactory(policyPlan)) + { + continue; + } + + if (!contractDefaultGraph.Plans.TryGetValue(policyRoot, out var defaultPlan) || + !HasSameResolvedFactoryBinding(defaultPlan, policyPlan, defaultHashByType, policyHashByType)) + { + globalExcludedTypes.Add(policyRoot); + } + } + ExpandReverseCodecDependencyClosure(contractDefaultGraph, globalExcludedTypes); var globalByType = currentContractDefaultCodecs .Where(codec => !globalExcludedTypes.Contains(codec.TypeName)) .ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); @@ -93,10 +106,14 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( var contractCodecs = SelectOwnedContractCodecs( currentContractDefaultCodecs, currentContractPolicyCodecs, + contractDefaultGraph, + contractPolicyGraph, + defaultHashByType, + policyHashByType, contractOwnedPolicyRoots); - var finalCodecBoundTypes = currentContractPolicyCodecs - .Select(static codec => codec.TypeName) - .Distinct(StringComparer.Ordinal) + var finalCodecBoundTypes = contractPolicyGraph.Plans.Values + .Where(RequiresGeneratedFactory) + .Select(static plan => plan.TypeName) .OrderBy(static type => type, StringComparer.Ordinal) .ToImmutableArray(); @@ -106,9 +123,9 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( .Select(static group => group.First()) .ToImmutableArray(); var codecOwnedEnumTypes = new HashSet( - currentContractPolicyCodecs - .Where(static codec => codec.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) - .Select(static codec => codec.TypeName), + contractPolicyGraph.Plans.Values + .Where(static plan => plan is FinalCustomCodecPlan or FinalAdapterCodecPlan) + .Select(static plan => plan.TypeName), StringComparer.Ordinal); var enums = standalone.Enums .Concat(contractDefault.Enums.Where(item => currentContractTypes.Contains(item.TypeName))) @@ -178,8 +195,13 @@ private static bool MatchesGeneratedFactoryPlan(FinalCodecPlan plan, GeneratedCo => plan switch { FinalGeneratedDtoCodecPlan => codec.Kind == GeneratedCodecKind.Dto, - FinalCustomCodecPlan => codec.Kind == GeneratedCodecKind.Custom, - FinalAdapterCodecPlan => codec.Kind == GeneratedCodecKind.Adapter, + FinalCustomCodecPlan custom => + codec.Kind == GeneratedCodecKind.Custom && + string.Equals(codec.CustomCodecType, custom.CodecTypeName, StringComparison.Ordinal), + FinalAdapterCodecPlan adapter => + codec.Kind == GeneratedCodecKind.Adapter && + string.Equals(codec.AdapterType, adapter.AdapterTypeName, StringComparison.Ordinal) && + string.Equals(codec.AdapterId, adapter.AdapterId, StringComparison.Ordinal), FinalCollectionCodecPlan collection => collection.WireStrategy == FinalCollectionWireStrategy.ChildCodec && codec.Kind == collection.CollectionKind, @@ -202,19 +224,19 @@ private static bool ContainsRpcContract(INamespaceSymbol namespaceSymbol) } private static void ExpandReverseCodecDependencyClosure( - ImmutableArray codecs, + FinalCodecGraph graph, HashSet scopedTypes) { bool changed; do { changed = false; - foreach (var codec in codecs) + foreach (var plan in graph.Plans.Values) { - if (scopedTypes.Contains(codec.TypeName)) + if (!RequiresGeneratedFactory(plan) || scopedTypes.Contains(plan.TypeName)) continue; - if (GetCodecDependencies(codec).Any(scopedTypes.Contains)) - changed |= scopedTypes.Add(codec.TypeName); + if (DtoAnalysisState.GetFinalCodecPlanDependencies(plan).Any(scopedTypes.Contains)) + changed |= scopedTypes.Add(plan.TypeName); } } while (changed); @@ -223,48 +245,49 @@ private static void ExpandReverseCodecDependencyClosure( private static ImmutableArray SelectOwnedContractCodecs( ImmutableArray contractDefault, ImmutableArray contractPolicy, + FinalCodecGraph defaultGraph, + FinalCodecGraph policyGraph, + IReadOnlyDictionary defaultHashes, + IReadOnlyDictionary policyHashes, IReadOnlyCollection policyRoots) { var defaultByType = contractDefault.ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); - var policyTypes = new HashSet( - contractPolicy.Select(static codec => codec.TypeName), + var policyByType = contractPolicy.ToDictionary(static codec => codec.TypeName, StringComparer.Ordinal); + var policyFactoryTypes = new HashSet( + policyGraph.Plans.Values.Where(RequiresGeneratedFactory).Select(static plan => plan.TypeName), StringComparer.Ordinal); var scopedTypes = new HashSet(StringComparer.Ordinal); foreach (var policyRoot in policyRoots) { - if (policyTypes.Contains(policyRoot)) + if (policyFactoryTypes.Contains(policyRoot)) scopedTypes.Add(policyRoot); } - foreach (var codec in contractPolicy) + foreach (var policyPlan in policyGraph.Plans.Values.Where(RequiresGeneratedFactory)) { - if (!defaultByType.TryGetValue(codec.TypeName, out var defaultCodec) || - !HasSameFinalCodecBinding(defaultCodec, codec)) + if (!defaultGraph.Plans.TryGetValue(policyPlan.TypeName, out var defaultPlan) || + !HasSameResolvedFactoryBinding(defaultPlan, policyPlan, defaultHashes, policyHashes)) { - scopedTypes.Add(codec.TypeName); + scopedTypes.Add(policyPlan.TypeName); } } - bool changed; - do - { - changed = false; - foreach (var codec in contractPolicy) - { - if (scopedTypes.Contains(codec.TypeName)) - continue; - if (GetCodecDependencies(codec).Any(scopedTypes.Contains)) - changed |= scopedTypes.Add(codec.TypeName); - } - } - while (changed); + ExpandReverseCodecDependencyClosure(policyGraph, scopedTypes); - return contractPolicy - .Where(codec => scopedTypes.Contains(codec.TypeName)) - .Select(codec => + return scopedTypes + .OrderBy(static type => type, StringComparer.Ordinal) + .Select(type => { - if (defaultByType.TryGetValue(codec.TypeName, out var defaultCodec) && - HasSameFinalCodecBinding(defaultCodec, codec)) + if (!policyByType.TryGetValue(type, out var codec)) + { + throw new InvalidOperationException( + $"Resolved contract-owned Codec plan '{type}' requires a generated factory but candidate analysis produced none."); + } + + if (defaultByType.TryGetValue(type, out var defaultCodec) && + defaultGraph.Plans.TryGetValue(type, out var defaultPlan) && + policyGraph.Plans.TryGetValue(type, out var policyPlan) && + HasSameResolvedFactoryBinding(defaultPlan, policyPlan, defaultHashes, policyHashes)) { return codec with { CodecName = defaultCodec.CodecName }; } @@ -272,36 +295,36 @@ private static ImmutableArray SelectOwnedContractCodecs( return codec with { CodecName = "__SharpLinkGeneratedContractPolicyCodec_" + - Hashing.GetIdentifierHash("contract-policy|" + codec.TypeName) + Hashing.GetIdentifierHash("contract-policy|" + type) }; }) - .OrderBy(static codec => codec.TypeName, StringComparer.Ordinal) .ToImmutableArray(); } - private static bool HasSameFinalCodecBinding(GeneratedCodecModel left, GeneratedCodecModel right) + private static bool HasSameResolvedFactoryBinding( + FinalCodecPlan left, + FinalCodecPlan right, + IReadOnlyDictionary leftHashes, + IReadOnlyDictionary rightHashes) { - if (!string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || - left.Kind != right.Kind || left.IsReferenceType != right.IsReferenceType || - !string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal) || - !string.Equals(left.KeyType, right.KeyType, StringComparison.Ordinal) || - !string.Equals(left.ValueType, right.ValueType, StringComparison.Ordinal) || - !string.Equals(left.CustomCodecType, right.CustomCodecType, StringComparison.Ordinal) || - !string.Equals(left.AdapterType, right.AdapterType, StringComparison.Ordinal) || - !string.Equals(left.AdapterId, right.AdapterId, StringComparison.Ordinal) || - !left.ConstructorMembers.SequenceEqual(right.ConstructorMembers, StringComparer.Ordinal) || - !left.AssemblyDependencies.SequenceEqual(right.AssemblyDependencies, StringComparer.Ordinal) || - left.Members.Length != right.Members.Length) + if (left.Kind != right.Kind || + !string.Equals(left.TypeName, right.TypeName, StringComparison.Ordinal) || + !leftHashes.TryGetValue(left.TypeName, out var leftHash) || + !rightHashes.TryGetValue(right.TypeName, out var rightHash) || + leftHash != rightHash) { return false; } - for (var index = 0; index < left.Members.Length; index++) + return (left, right) switch { - if (left.Members[index] with { Location = null } != right.Members[index] with { Location = null }) - return false; - } - return true; + (FinalCustomCodecPlan leftCustom, FinalCustomCodecPlan rightCustom) => + string.Equals(leftCustom.CodecTypeName, rightCustom.CodecTypeName, StringComparison.Ordinal), + (FinalAdapterCodecPlan leftAdapter, FinalAdapterCodecPlan rightAdapter) => + string.Equals(leftAdapter.AdapterTypeName, rightAdapter.AdapterTypeName, StringComparison.Ordinal) && + string.Equals(leftAdapter.AdapterId, rightAdapter.AdapterId, StringComparison.Ordinal), + _ => true + }; } private sealed partial class DtoAnalysisState From c2dd98e11fdcbb398eeace208085e2b87553e013 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:10:06 +0800 Subject: [PATCH 323/399] fix: expose resolved codec dependency traversal --- .../RpcGenerator.FinalCodecPlan.Selection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index 1fd5ef5b7..68908e225 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -558,7 +558,7 @@ private RpcHashValue GetAdapterTargetLogicalIdentity(ITypeSymbol targetType) return Hashing.GetSemanticHash(parts.ToArray()); } - private static IEnumerable GetFinalCodecPlanDependencies(FinalCodecPlan plan) + internal static IEnumerable GetFinalCodecPlanDependencies(FinalCodecPlan plan) { switch (plan) { From 8c3d6790daaac2b403ce62ecdc01bd49e3e3dbf2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:14:55 +0800 Subject: [PATCH 324/399] refactor: keep DTO analysis to codec candidate discovery --- .../RpcGenerator.DtoAnalysis.cs | 68 +++++-------------- 1 file changed, 18 insertions(+), 50 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs b/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs index aaa01fac7..3f6fbab76 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs @@ -335,30 +335,12 @@ private void Visit(ITypeSymbol type, List stack, int depth) _failed.Add(typeName); return; } - if (TrySelectCustomCodec(type, out var customCodec)) - { - if (customCodec is not null) - { - _models[typeName] = new GeneratedCodecModel( - typeName, - GetCodecName(typeName, _contractMode), - GetSchemaId(typeName, "custom|" + GetTypeName(customCodec.CodecType)), - GeneratedCodecKind.Custom, - type.IsReferenceType, - ImmutableArray.Empty, - ImmutableArray.Empty, - null, - null, - null, - GetTypeName(customCodec.CodecType), - null, - null, - string.Empty, - GetAssemblyDependencies([type]), - type.Locations.FirstOrDefault()); - } + + // Policy declarations are candidates only at this stage. Final custom/adapter selection, + // validation and factory materialization happen in ResolveFinalCodecPlan so emitted + // behavior and CodecHash consume the same resolved node. + if (HasCodecPolicyCandidate(type)) return; - } if (type.TypeKind == TypeKind.Dynamic) { @@ -368,20 +350,11 @@ private void Visit(ITypeSymbol type, List stack, int depth) return; } - AdapterRegistration? selectedAdapter = null; - var hasSelectedOverride = _applyCodecPolicy && - (_contractMode - ? TrySelectContractCodecOverride(type, out selectedAdapter) - : TrySelectAdapter(type, out selectedAdapter)); - if (hasSelectedOverride) + if (HasRuntimeCodecWithoutGeneratedFactoryCandidate(type) && + !HasCompositeCodecPolicyCandidate(type)) { - if (selectedAdapter is not null) - AddAdapterModel(type, typeName, selectedAdapter); return; } - - if (IsBuiltin(type) && !HasSelectedCompositeCodecDependency(type)) - return; if (depth > MaximumDepth) { Report(DtoDiagnosticKind.Depth, type, $"more than {MaximumDepth} nested types"); @@ -1173,7 +1146,7 @@ private static bool TryGetCollection( } } - private static bool IsBuiltin(ITypeSymbol type) + private static bool HasRuntimeCodecWithoutGeneratedFactoryCandidate(ITypeSymbol type) { if (type.SpecialType == SpecialType.System_String || GetFixedSize(type) != 0 || type.IsUnmanagedType) return true; @@ -1185,13 +1158,21 @@ private static bool IsBuiltin(ITypeSymbol type) } if (!TryGetCollection(type, out var kind, out var element, out _, out _) || kind is GeneratedCodecKind.Dictionary or GeneratedCodecKind.Nullable || - element is null) + element is null || element.TypeKind == TypeKind.Enum) { return false; } - return IsBuiltinBlitElement(element); + + return global::SharpLink.RpcBuiltinCollectionWireCatalog.TryGet( + element.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + out _); } + // Kept as a compatibility alias for pre-plan candidate utilities. New discovery and final + // selection code should use the explicit runtime-factory wording above. + private static bool IsBuiltin(ITypeSymbol type) + => HasRuntimeCodecWithoutGeneratedFactoryCandidate(type); + private static ITypeSymbol NormalizeAdapterTarget(ITypeSymbol type) => type is INamedTypeSymbol { @@ -1201,19 +1182,6 @@ private static ITypeSymbol NormalizeAdapterTarget(ITypeSymbol type) ? underlying : type; - private static bool IsBuiltinBlitElement(ITypeSymbol type) - { - if (type.TypeKind == TypeKind.Enum) - return false; - var name = type.ToDisplayString(); - return name is "bool" or "byte" or "sbyte" or "short" or "ushort" or "char" or - "System.Half" or "int" or "uint" or "float" or "System.Text.Rune" or - "long" or "ulong" or "double" or "System.Guid" or "decimal" or - "System.DateTimeOffset" or "System.DateTime" or "System.DateOnly" or - "System.TimeOnly" or "System.TimeSpan" or "System.Int128" or "System.UInt128" or - "System.Index" or "System.Range"; - } - private static GeneratedMemberKind GetMemberKind( ITypeSymbol type, out ITypeSymbol? fixedType, From 5adb8040cf665e8ae068de42b6aa3fce405a3ebb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:15:57 +0800 Subject: [PATCH 325/399] refactor: perform codec policy selection in final plan resolver --- .../RpcGenerator.FinalCodecPlan.Selection.cs | 190 ++++++++++++++++-- 1 file changed, 173 insertions(+), 17 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index 68908e225..fd7c37a77 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -4,7 +4,7 @@ public partial class RpcGenerator { private sealed partial class DtoAnalysisState { - private FinalCodecPlan ResolveFinalCodecPlan( + private FinalCodecPlan? ResolveFinalCodecPlan( ITypeSymbol type, Dictionary plans, HashSet resolving) @@ -12,26 +12,33 @@ private FinalCodecPlan ResolveFinalCodecPlan( var typeName = GetTypeName(type); if (plans.TryGetValue(typeName, out var existing)) return existing; + if (_failed.Contains(typeName)) + return null; if (!resolving.Add(typeName)) { throw new InvalidOperationException( $"Final Codec graph contains an unresolved recursive Codec selection at '{typeName}'."); } - _models.TryGetValue(typeName, out var generatedModel); - FinalCodecPlan plan; - if (generatedModel is { Kind: GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter }) + if (TryResolvePolicyCodecPlan(type, plans, resolving, out var policyPlan)) { - plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); + resolving.Remove(typeName); + if (policyPlan is not null) + plans[typeName] = policyPlan; + return policyPlan; } - else if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) + + _models.TryGetValue(typeName, out var generatedModel); + FinalCodecPlan? plan; + if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) { plan = new FinalReferencedCodecPlan(typeName, referencedHash); } else if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) { - ResolveFinalCodecPlan(underlying, plans, resolving); + if (ResolveFinalCodecPlan(underlying, plans, resolving) is null) + return FailCurrent(); plan = new FinalEnumCodecPlan( typeName, GetTypeName(underlying), @@ -43,6 +50,8 @@ private FinalCodecPlan ResolveFinalCodecPlan( HasExactBuiltinNullableCodecElement(nullable.TypeArguments[0])) { var child = ResolveFinalCodecPlan(nullable.TypeArguments[0], plans, resolving); + if (child is null) + return FailCurrent(); plan = new FinalPrimitiveCodecPlan( typeName, "nullable", @@ -70,6 +79,8 @@ GeneratedCodecKind.Custom or $"Final collection selection for '{typeName}' received incompatible generated candidate kind '{generatedModel.Kind}'."); } plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); + if (plan is null) + return FailCurrent(); } else if (collectionKind == GeneratedCodecKind.Nullable && elementType is not null && @@ -99,6 +110,8 @@ elementType is not null && else if (generatedModel is { Kind: GeneratedCodecKind.Dto }) { plan = ResolveGeneratedCodecPlan(type, generatedModel, plans, resolving); + if (plan is null) + return FailCurrent(); } else if (generatedModel is not null) { @@ -114,9 +127,125 @@ elementType is not null && resolving.Remove(typeName); plans[typeName] = plan; return plan; + + FinalCodecPlan? FailCurrent() + { + resolving.Remove(typeName); + _failed.Add(typeName); + return null; + } + } + + private bool TryResolvePolicyCodecPlan( + ITypeSymbol type, + Dictionary plans, + HashSet resolving, + out FinalCodecPlan? plan) + { + var typeName = GetTypeName(type); + if (TrySelectCustomCodec(type, out var customCodec)) + { + if (customCodec is null) + { + plan = null; + return true; + } + + var model = CreateCustomCodecModel(type, typeName, customCodec); + _models[typeName] = model; + plan = ResolveGeneratedCodecPlan(type, model, plans, resolving); + return true; + } + + if (!_applyCodecPolicy) + { + plan = null; + return false; + } + + AdapterRegistration? selectedAdapter = null; + var hasSelection = _contractMode + ? TrySelectContractCodecOverride(type, out selectedAdapter) + : TrySelectAdapter(type, out selectedAdapter); + if (!hasSelection) + { + plan = null; + return false; + } + if (selectedAdapter is null) + { + plan = null; + return true; + } + + AddAdapterModel(type, typeName, selectedAdapter); + plan = ResolveGeneratedCodecPlan(type, _models[typeName], plans, resolving); + return true; + } + + private GeneratedCodecModel CreateCustomCodecModel( + ITypeSymbol type, + string typeName, + CustomCodecRegistration customCodec) + => new( + typeName, + GetCodecName(typeName, _contractMode), + GetSchemaId(typeName, "custom|" + GetTypeName(customCodec.CodecType)), + GeneratedCodecKind.Custom, + type.IsReferenceType, + ImmutableArray.Empty, + ImmutableArray.Empty, + null, + null, + null, + GetTypeName(customCodec.CodecType), + null, + null, + string.Empty, + GetAssemblyDependencies([type]), + type.Locations.FirstOrDefault()); + + private bool HasCodecPolicyCandidate(ITypeSymbol type) + { + var normalized = NormalizeAdapterTarget(type); + if (type.GetAttributes().Any(static attribute => + IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecAttribute")) || + _customCodecBindings.ContainsKey(normalized)) + { + return true; + } + + if (!_applyCodecPolicy) + return false; + + var attributes = type.GetAttributes(); + var hasSelector = attributes.Any(attribute => + attribute.AttributeClass is { } attributeClass && + _adaptersBySelector.ContainsKey(attributeClass)); + if (_contractMode && _selectorOnlyContractDefaults) + return hasSelector; + + if (hasSelector || + attributes.Any(static attribute => + IsAttribute(attribute, "SharpLink.Sdk", "RpcCodecAdapterAttribute")) || + _assemblyBindings.ContainsKey(normalized)) + { + return true; + } + + return _contractMode && HasMatchingAssemblyRoute(type); } - private FinalCodecPlan ResolveGeneratedCodecPlan( + private bool HasCompositeCodecPolicyCandidate(ITypeSymbol type) + { + if (!TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) + return false; + return (elementType is not null && HasCodecPolicyCandidate(elementType)) || + (keyType is not null && HasCodecPolicyCandidate(keyType)) || + (valueType is not null && HasCodecPolicyCandidate(valueType)); + } + + private FinalCodecPlan? ResolveGeneratedCodecPlan( ITypeSymbol type, GeneratedCodecModel model, Dictionary plans, @@ -146,7 +275,7 @@ private FinalCodecPlan ResolveGeneratedCodecPlan( } } - private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( + private FinalGeneratedDtoCodecPlan? ResolveGeneratedDtoPlan( ITypeSymbol type, GeneratedCodecModel model, Dictionary plans, @@ -160,11 +289,30 @@ private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( { memberSymbols.TryGetValue(member.Name, out var memberSymbol); var memberType = memberSymbol is null ? null : GetMemberType(memberSymbol); + + if (memberType is not null && HasCodecPolicyCandidate(memberType)) + { + var selectedChild = ResolveFinalCodecPlan(memberType, plans, resolving); + if (selectedChild is null) + return null; + if (selectedChild is FinalCustomCodecPlan or FinalAdapterCodecPlan) + { + members.Add(CreateMember( + member, + GeneratedMemberKind.Complex, + FinalDtoMemberWireStrategy.ChildCodec, + null, + selectedChild.TypeName)); + continue; + } + } + switch (member.Kind) { case GeneratedMemberKind.String: members.Add(CreateMember( member, + member.Kind, FinalDtoMemberWireStrategy.String, "string/content/utf16le/i32le-byte-length/v1|string/null/dto-wire-null/v1", null)); @@ -173,6 +321,7 @@ private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( case GeneratedMemberKind.NullableFixed: members.Add(CreateMember( member, + member.Kind, FinalDtoMemberWireStrategy.Fixed, GetResolvedFixedMemberSemantic(member, memberType), null)); @@ -184,8 +333,11 @@ private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( $"Final Codec plan for '{model.TypeName}' cannot resolve child '{member.TypeName}'."); } var child = ResolveFinalCodecPlan(memberType, plans, resolving); + if (child is null) + return null; members.Add(CreateMember( member, + member.Kind, FinalDtoMemberWireStrategy.ChildCodec, null, child.TypeName)); @@ -200,12 +352,13 @@ private FinalGeneratedDtoCodecPlan ResolveGeneratedDtoPlan( static FinalDtoMemberPlan CreateMember( GeneratedMemberModel member, + GeneratedMemberKind kind, FinalDtoMemberWireStrategy strategy, string? wireSemantic, string? childType) => new( member.FieldId, - member.Kind, + kind, member.Required, member.Nullable, member.NonNullableReference, @@ -214,7 +367,7 @@ static FinalDtoMemberPlan CreateMember( childType); } - private FinalCollectionCodecPlan ResolveGeneratedCollectionPlan( + private FinalCollectionCodecPlan? ResolveGeneratedCollectionPlan( ITypeSymbol type, GeneratedCodecModel model, Dictionary plans, @@ -229,9 +382,12 @@ private FinalCollectionCodecPlan ResolveGeneratedCollectionPlan( key = resolvedKey; value = resolvedValue; } - ResolveChild(element, model.ElementType); - ResolveChild(key, model.KeyType); - ResolveChild(value, model.ValueType); + if (!ResolveChild(element, model.ElementType) || + !ResolveChild(key, model.KeyType) || + !ResolveChild(value, model.ValueType)) + { + return null; + } return new FinalCollectionCodecPlan( model.TypeName, model.Kind, @@ -242,16 +398,16 @@ private FinalCollectionCodecPlan ResolveGeneratedCollectionPlan( RawElementLayout: null, StrategySemantic: null); - void ResolveChild(ITypeSymbol? symbol, string? childTypeName) + bool ResolveChild(ITypeSymbol? symbol, string? childTypeName) { if (childTypeName is null) - return; + return true; if (symbol is null && !TryResolveReachableType(childTypeName, out symbol!)) { throw new InvalidOperationException( $"Final Codec plan for '{model.TypeName}' cannot resolve child '{childTypeName}'."); } - ResolveFinalCodecPlan(symbol, plans, resolving); + return ResolveFinalCodecPlan(symbol, plans, resolving) is not null; } } From 6b2a506008996036699dab42e9a891fe2d12c81f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:17:21 +0800 Subject: [PATCH 326/399] refactor: derive codec emission from resolved plan graph --- .../RpcGenerator.CodecPolicyOwnership.cs | 211 ++++++++++-------- 1 file changed, 116 insertions(+), 95 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index dc3355de2..3350b7b0e 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -12,10 +12,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( contractMode: false, applyCodecPolicy: true, selectorOnlyContractDefault: false); - var standalone = standaloneState.AnalyzeWithFinalCodecBindings(); + _ = standaloneState.AnalyzeWithFinalCodecBindings(); var standaloneGraph = standaloneState.ResolveFinalCodecGraph( includeSerializable: true, includeContracts: false); + var standalone = standaloneState.FinalizeResolvedCodecCandidates(standaloneGraph); var standaloneHashes = standaloneState.BuildFinalCodecHashes(standaloneGraph); var standaloneCodecs = AttachCodecHashes(standalone.Codecs, standaloneGraph, standaloneHashes); @@ -25,10 +26,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( contractMode: true, applyCodecPolicy: true, selectorOnlyContractDefault: true); - var contractDefault = contractDefaultState.AnalyzeWithFinalCodecBindings(); + _ = contractDefaultState.AnalyzeWithFinalCodecBindings(); var contractDefaultGraph = contractDefaultState.ResolveFinalCodecGraph( includeSerializable: false, includeContracts: true); + var contractDefault = contractDefaultState.FinalizeResolvedCodecCandidates(contractDefaultGraph); var contractDefaultHashes = contractDefaultState.BuildFinalCodecHashes(contractDefaultGraph); var contractDefaultCodecs = AttachCodecHashes( contractDefault.Codecs, @@ -41,10 +43,11 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( contractMode: true, applyCodecPolicy: true, selectorOnlyContractDefault: false); - var contractPolicy = contractPolicyState.AnalyzeWithFinalCodecBindings(); + _ = contractPolicyState.AnalyzeWithFinalCodecBindings(); var contractPolicyGraph = contractPolicyState.ResolveFinalCodecGraph( includeSerializable: false, includeContracts: true); + var contractPolicy = contractPolicyState.FinalizeResolvedCodecCandidates(contractPolicyGraph); var codecHashes = contractPolicyState.BuildFinalCodecHashes(contractPolicyGraph); var unsafeBlitAutoLayoutDiagnostics = DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph); @@ -166,6 +169,7 @@ private static ImmutableArray AttachCodecHashes( throw new InvalidOperationException( $"Final Codec plan '{plan.TypeName}' requires a generated factory but candidate analysis produced none."); } + codec = ApplyResolvedEmissionPlan(plan, codec); if (!MatchesGeneratedFactoryPlan(plan, codec)) { throw new InvalidOperationException( @@ -185,6 +189,66 @@ private static ImmutableArray AttachCodecHashes( .ToImmutableArray(); } + private static GeneratedCodecModel ApplyResolvedEmissionPlan( + FinalCodecPlan plan, + GeneratedCodecModel codec) + { + if (plan is not FinalGeneratedDtoCodecPlan dto) + return codec; + + var resolvedByField = dto.Members.ToDictionary(static member => member.FieldId); + var changed = false; + var members = codec.Members.Select(member => + { + if (!resolvedByField.TryGetValue(member.FieldId, out var resolved) || + resolved.Kind == member.Kind) + { + return member; + } + + changed = true; + return resolved.WireStrategy == FinalDtoMemberWireStrategy.ChildCodec + ? member with + { + Kind = GeneratedMemberKind.Complex, + FixedTypeName = null, + FixedSize = 0, + EnumUnderlyingType = null + } + : member with { Kind = resolved.Kind }; + }).ToImmutableArray(); + + if (!changed) + return codec; + + var schema = new StringBuilder(codec.TypeName); + foreach (var member in members) + { + schema.Append('|').Append(member.FieldId).Append(':').Append(member.TypeName) + .Append(':').Append(member.Kind).Append(':').Append(member.Required); + if (member.Nullable) + schema.Append(":nullable"); + } + return codec with + { + Members = members, + SchemaId = GetResolvedSchemaId(codec.TypeName, schema.ToString()) + }; + } + + private static string GetResolvedSchemaId(string typeName, string schema) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + foreach (var character in schema) + { + hash ^= character; + hash *= prime; + } + return typeName + ":" + hash.ToString("X16", InvariantCulture); + } + private static bool RequiresGeneratedFactory(FinalCodecPlan plan) => plan is FinalGeneratedDtoCodecPlan or FinalCustomCodecPlan or @@ -521,11 +585,23 @@ private void AddCanonicalPolicyBindingAliases() internal DtoAnalysisPassResult AnalyzeWithFinalCodecBindings() { _ = Analyze(); - PromoteSelectedFixedMembersToCodecBindings(); RejectRuntimeSizedUnsafeBlitTypes(); - NormalizeGeneratedModuleDependencies(); - var finalizedCodecs = FilterFailedCodecClosure(_models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray()); - return new DtoAnalysisPassResult(finalizedCodecs, _diagnostics.ToImmutableArray(), + return SnapshotAnalysisResult(); + } + + internal DtoAnalysisPassResult FinalizeResolvedCodecCandidates(FinalCodecGraph graph) + { + NormalizeGeneratedModuleDependencies(graph); + return SnapshotAnalysisResult(); + } + + private DtoAnalysisPassResult SnapshotAnalysisResult() + { + var finalizedCodecs = FilterFailedCodecClosure( + _models.Values.OrderBy(static model => model.TypeName, StringComparer.Ordinal).ToImmutableArray()); + return new DtoAnalysisPassResult( + finalizedCodecs, + _diagnostics.ToImmutableArray(), _enums.Values.OrderBy(static item => item.TypeName, StringComparer.Ordinal).ToImmutableArray()); } @@ -540,7 +616,7 @@ private void RejectRuntimeSizedUnsafeBlitTypes() foreach (var type in reachable.Values) { var typeName = GetTypeName(type); - if (_models.TryGetValue(typeName, out var selected) && selected.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + if (HasCodecPolicyCandidate(type)) continue; if (!type.IsUnmanagedType || !IsRuntimeSizedUnsafeBlitType(type)) continue; @@ -571,124 +647,69 @@ private bool IsRuntimeSizedUnsafeBlitType(ITypeSymbol type, HashSet return false; } - internal HashSet GetCurrentContractReachableTypeNames() - { - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: false, includeContracts: true); - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - return new HashSet(reachable.Keys, StringComparer.Ordinal); - } - - private void PromoteSelectedFixedMembersToCodecBindings() - { - if (!_applyCodecPolicy || _models.Count == 0) - return; - var roots = new Dictionary(StringComparer.Ordinal); - CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: !_contractMode, includeContracts: _contractMode); - var reachable = new Dictionary(StringComparer.Ordinal); - var seen = new HashSet(SymbolEqualityComparer.Default); - foreach (var root in roots.Values) - CollectFinalBindingTypes(root, reachable, seen, 0); - var dtoModels = _models.Values.Where(static model => model.Kind == GeneratedCodecKind.Dto).ToArray(); - foreach (var model in dtoModels) - { - if (!reachable.TryGetValue(model.TypeName, out var type) || type is not INamedTypeSymbol named) - continue; - var memberSymbols = GetSerializableMembers(named).ToDictionary(static member => member.Name, StringComparer.Ordinal); - var members = model.Members.ToArray(); - var changed = false; - for (var index = 0; index < members.Length; index++) - { - var member = members[index]; - if (member.Kind is not (GeneratedMemberKind.Fixed or GeneratedMemberKind.NullableFixed or GeneratedMemberKind.String) || - !memberSymbols.TryGetValue(member.Name, out var memberSymbol)) - continue; - var memberType = GetMemberType(memberSymbol); - if (!HasSelectedMemberCodec(memberType)) - continue; - Visit(memberType, [], 0); - members[index] = member with { Kind = GeneratedMemberKind.Complex, FixedTypeName = null, FixedSize = 0, EnumUnderlyingType = null }; - changed = true; - } - if (!changed) - continue; - var finalizedMembers = members.ToImmutableArray(); - var schema = new StringBuilder(model.TypeName); - foreach (var member in finalizedMembers) - { - schema.Append('|').Append(member.FieldId).Append(':').Append(member.TypeName).Append(':').Append(member.Kind).Append(':').Append(member.Required); - if (member.Nullable) - schema.Append(":nullable"); - } - _models[model.TypeName] = model with { Members = finalizedMembers, SchemaId = GetSchemaId(model.TypeName, schema.ToString()) }; - } - } - - private bool HasSelectedCompositeCodecDependency(ITypeSymbol type) - { - if (!TryGetCollection(type, out _, out var elementType, out var keyType, out var valueType)) - return false; - return (elementType is not null && HasSelectedMemberCodec(elementType)) || - (keyType is not null && HasSelectedMemberCodec(keyType)) || - (valueType is not null && HasSelectedMemberCodec(valueType)); - } - - private bool HasSelectedMemberCodec(ITypeSymbol memberType) - { - if (IsFrameworkWirePrimitive(memberType)) - return false; - if (TrySelectCustomCodec(memberType, out var customCodec)) - return customCodec is not null; - AdapterRegistration? selected = null; - var hasSelection = _contractMode ? TrySelectContractCodecOverride(memberType, out selected) : TrySelectAdapter(memberType, out selected); - return hasSelection && selected is not null; - } - - private void NormalizeGeneratedModuleDependencies() + private void NormalizeGeneratedModuleDependencies(FinalCodecGraph graph) { if (_models.Count == 0) return; + var roots = new Dictionary(StringComparer.Ordinal); CollectCurrentAssemblyRoots(_compilation.Assembly.GlobalNamespace, roots, includeSerializable: !_contractMode, includeContracts: _contractMode); var symbolsByType = new Dictionary(StringComparer.Ordinal); var seen = new HashSet(SymbolEqualityComparer.Default); foreach (var root in roots.Values) CollectFinalBindingTypes(root, symbolsByType, seen, 0); - var localFactoryTypes = new HashSet(_models.Keys, StringComparer.Ordinal); - foreach (var model in _models.Values.ToArray()) + + var localFactoryTypes = new HashSet( + graph.Plans.Values.Where(RequiresGeneratedFactory).Select(static plan => plan.TypeName), + StringComparer.Ordinal); + foreach (var plan in graph.Plans.Values.Where(RequiresGeneratedFactory)) { - if (model.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + if (!_models.TryGetValue(plan.TypeName, out var model)) + continue; + if (plan is FinalCustomCodecPlan or FinalAdapterCodecPlan) { - _models[model.TypeName] = model with { AssemblyDependencies = ImmutableArray.Empty }; + _models[plan.TypeName] = model with { AssemblyDependencies = ImmutableArray.Empty }; continue; } + var dependencies = new HashSet(StringComparer.Ordinal); - foreach (var dependencyTypeName in GetCodecDependencies(model)) + foreach (var dependencyTypeName in GetFinalCodecPlanDependencies(plan)) { - if (localFactoryTypes.Contains(dependencyTypeName) || !symbolsByType.TryGetValue(dependencyTypeName, out var dependencyType) || IsBuiltin(dependencyType)) + if (localFactoryTypes.Contains(dependencyTypeName) || + !symbolsByType.TryGetValue(dependencyTypeName, out var dependencyType)) + { continue; + } var assembly = dependencyType.ContainingAssembly; - if (assembly is not null && !SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly) && HasGeneratedAssemblyManifest(assembly)) + if (assembly is not null && + !SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly) && + HasGeneratedAssemblyManifest(assembly)) + { dependencies.Add(assembly.Identity.ToString()); + } } - _models[model.TypeName] = model with + _models[plan.TypeName] = model with { AssemblyDependencies = dependencies.OrderBy(static identity => identity, StringComparer.Ordinal).ToImmutableArray() }; } } - private void CollectFinalBindingTypes(ITypeSymbol type, Dictionary reachable, HashSet seen, int depth) + private void CollectFinalBindingTypes( + ITypeSymbol type, + Dictionary reachable, + HashSet seen, + int depth) { if (depth > MaximumDepth || !seen.Add(type)) return; var typeName = GetTypeName(type); reachable[typeName] = type; - if (_models.TryGetValue(typeName, out var finalModel) && finalModel.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + if (_models.TryGetValue(typeName, out var finalModel) && + finalModel.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) + { return; + } if (type is IArrayTypeSymbol array) { CollectFinalBindingTypes(array.ElementType, reachable, seen, depth + 1); From 6c566394fa4be2b1f5f83b61e7e26319542ab32a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:21:19 +0800 Subject: [PATCH 327/399] fix: keep unsupported payloads outside codec routes --- src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs b/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs index 33018f5cd..576fd4496 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecRoutes.cs @@ -219,7 +219,8 @@ private void AddAdapterModel(ITypeSymbol type, string typeName, AdapterRegistrat private bool IsRouteEligible(ITypeSymbol type) { - if (IsFrameworkWirePrimitive(type) || + if (type.TypeKind is TypeKind.Dynamic or TypeKind.Pointer or TypeKind.FunctionPointer || + IsFrameworkWirePrimitive(type) || (_assemblyRoutes.Count == 0 && _conflictingRouteScopes.Count == 0)) { return false; From 23996dd57a0c7812e201017930850455cd9128f2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:36:11 +0800 Subject: [PATCH 328/399] fix: preserve generated UTF-8 string wire semantics --- .../RpcGenerator.DtoEmitter.cs | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index cc9ec3f85..11ae1fb83 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -36,7 +36,7 @@ private static string GenerateCodecs(ImmutableArray codecs) if (emittedCodecs.Any(static codec => codec.Kind == GeneratedCodecKind.Dto && codec.Members.Any(static member => member.Kind == GeneratedMemberKind.String))) - AppendGeneratedUtf16Helper(sb); + AppendGeneratedUtf8Helper(sb); foreach (var codec in emittedCodecs) { @@ -76,22 +76,24 @@ private static void AppendCustomCodecFactory(StringBuilder sb, GeneratedCodecMod sb.AppendLine(); } - private static void AppendGeneratedUtf16Helper(StringBuilder sb) + private static void AppendGeneratedUtf8Helper(StringBuilder sb) { - sb.AppendLine("internal static class __SharpLinkGeneratedUtf16"); + sb.AppendLine("internal static class __SharpLinkGeneratedUtf8"); sb.AppendLine("{"); - sb.AppendLine(" internal static int GetByteCount(string value) => checked(value.Length * sizeof(char));"); + sb.AppendLine(" private static readonly global::System.Text.UTF8Encoding StrictEncoding = new global::System.Text.UTF8Encoding(false, true);"); + sb.AppendLine(); + sb.AppendLine(" internal static int GetByteCount(string value) => StrictEncoding.GetByteCount(value);"); sb.AppendLine(); sb.AppendLine(" internal static void WriteStringKnownSize(IBufferWriter writer, string value, int byteCount)"); sb.AppendLine(" {"); - sb.AppendLine(" var length = writer.GetSpan(sizeof(int));"); - sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(length, byteCount);"); - sb.AppendLine(" writer.Advance(sizeof(int));"); + sb.AppendLine(" var length = writer.GetSpan(sizeof(uint));"); + sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(length, checked((uint)byteCount));"); + sb.AppendLine(" writer.Advance(sizeof(uint));"); sb.AppendLine(" if (byteCount == 0)"); sb.AppendLine(" return;"); sb.AppendLine(" var payload = writer.GetSpan(byteCount);"); - sb.AppendLine(" value.AsSpan().CopyTo(global::System.Runtime.InteropServices.MemoryMarshal.Cast(payload));"); - sb.AppendLine(" writer.Advance(byteCount);"); + sb.AppendLine(" var written = StrictEncoding.GetBytes(value, payload);"); + sb.AppendLine(" writer.Advance(written);"); sb.AppendLine(" }"); sb.AppendLine("}"); sb.AppendLine(); @@ -334,7 +336,7 @@ private static void AppendDtoExactSerializeBody( case GeneratedMemberKind.String: sb.AppendLine($" var __string_{memberIndex} = {value};"); sb.AppendLine( - $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); + $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); break; case GeneratedMemberKind.Fixed: sb.AppendLine($" var __fixed_{memberIndex} = {value};"); @@ -479,7 +481,7 @@ private static void AppendDtoSuppressedSerializeBody( var value = $"value.{EscapeIdentifier(member.Identifier)}"; sb.AppendLine($"{indent}var __string_{memberIndex} = {value};"); sb.AppendLine( - $"{indent}var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); + $"{indent}var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); } AppendDtoSerializeBody(sb, model, complexIndexes, useCachedStrings: true, useCachedMembers: false, indent: indent); @@ -529,7 +531,7 @@ private static void AppendDtoMemberWrite( if (cachedMemberIndex >= 0) { sb.AppendLine( - $"{childIndent}__SharpLinkGeneratedUtf16.WriteStringKnownSize(writer, {value}, __stringByteCount_{cachedMemberIndex});"); + $"{childIndent}__SharpLinkGeneratedUtf8.WriteStringKnownSize(writer, {value}, __stringByteCount_{cachedMemberIndex});"); } else { @@ -557,7 +559,7 @@ private static void AppendDtoDirectPreReservation(StringBuilder sb, GeneratedCod { sb.AppendLine($" var __string_{memberIndex} = {value};"); sb.AppendLine( - $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); + $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); } else if (member.Kind == GeneratedMemberKind.Fixed) { @@ -670,7 +672,7 @@ private static void AppendDtoEncodedSizeMethod( case GeneratedMemberKind.String: sb.AppendLine($" __snapshot.__string_{memberIndex} = {value};"); sb.AppendLine( - $" __snapshot.__stringByteCount_{memberIndex} = __snapshot.__string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__snapshot.__string_{memberIndex});"); + $" __snapshot.__stringByteCount_{memberIndex} = __snapshot.__string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__snapshot.__string_{memberIndex});"); break; case GeneratedMemberKind.Fixed: sb.AppendLine($" __snapshot.__fixed_{memberIndex} = {value};"); @@ -783,7 +785,7 @@ private static void AppendDtoSizeOnlyEncodedSizeMethod( { var nullSize = GetFieldKeySize(member.FieldId, 0); var valueOverhead = GetFieldKeySize(member.FieldId, 6) + sizeof(uint); - sb.AppendLine($" size = checked(size + ({value} is null ? {nullSize.ToString(InvariantCulture)} : {valueOverhead.ToString(InvariantCulture)} + __SharpLinkGeneratedUtf16.GetByteCount({value})));"); + sb.AppendLine($" size = checked(size + ({value} is null ? {nullSize.ToString(InvariantCulture)} : {valueOverhead.ToString(InvariantCulture)} + __SharpLinkGeneratedUtf8.GetByteCount({value})));"); break; } case GeneratedMemberKind.Complex: @@ -920,7 +922,7 @@ private static void AppendDtoSizedSerializeMethod( sb.AppendLine(" {"); sb.AppendLine($" RpcGeneratedCodecWire.WriteFieldKey(buffer, {fieldId}, RpcGeneratedWireType.LengthDelimited);"); sb.AppendLine( - $" __SharpLinkGeneratedUtf16.WriteStringKnownSize(buffer, __snapshot.__string_{memberIndex}, __snapshot.__stringByteCount_{memberIndex});"); + $" __SharpLinkGeneratedUtf8.WriteStringKnownSize(buffer, __snapshot.__string_{memberIndex}, __snapshot.__stringByteCount_{memberIndex});"); sb.AppendLine(" }"); break; case GeneratedMemberKind.Complex: From a1f96ec7360ae0de4cab6f4176289997a9a2f0c8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:37:03 +0800 Subject: [PATCH 329/399] revert: keep v2 UTF-16 string wire semantics --- .../RpcGenerator.DtoEmitter.cs | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs index 11ae1fb83..cc9ec3f85 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoEmitter.cs @@ -36,7 +36,7 @@ private static string GenerateCodecs(ImmutableArray codecs) if (emittedCodecs.Any(static codec => codec.Kind == GeneratedCodecKind.Dto && codec.Members.Any(static member => member.Kind == GeneratedMemberKind.String))) - AppendGeneratedUtf8Helper(sb); + AppendGeneratedUtf16Helper(sb); foreach (var codec in emittedCodecs) { @@ -76,24 +76,22 @@ private static void AppendCustomCodecFactory(StringBuilder sb, GeneratedCodecMod sb.AppendLine(); } - private static void AppendGeneratedUtf8Helper(StringBuilder sb) + private static void AppendGeneratedUtf16Helper(StringBuilder sb) { - sb.AppendLine("internal static class __SharpLinkGeneratedUtf8"); + sb.AppendLine("internal static class __SharpLinkGeneratedUtf16"); sb.AppendLine("{"); - sb.AppendLine(" private static readonly global::System.Text.UTF8Encoding StrictEncoding = new global::System.Text.UTF8Encoding(false, true);"); - sb.AppendLine(); - sb.AppendLine(" internal static int GetByteCount(string value) => StrictEncoding.GetByteCount(value);"); + sb.AppendLine(" internal static int GetByteCount(string value) => checked(value.Length * sizeof(char));"); sb.AppendLine(); sb.AppendLine(" internal static void WriteStringKnownSize(IBufferWriter writer, string value, int byteCount)"); sb.AppendLine(" {"); - sb.AppendLine(" var length = writer.GetSpan(sizeof(uint));"); - sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(length, checked((uint)byteCount));"); - sb.AppendLine(" writer.Advance(sizeof(uint));"); + sb.AppendLine(" var length = writer.GetSpan(sizeof(int));"); + sb.AppendLine(" global::System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(length, byteCount);"); + sb.AppendLine(" writer.Advance(sizeof(int));"); sb.AppendLine(" if (byteCount == 0)"); sb.AppendLine(" return;"); sb.AppendLine(" var payload = writer.GetSpan(byteCount);"); - sb.AppendLine(" var written = StrictEncoding.GetBytes(value, payload);"); - sb.AppendLine(" writer.Advance(written);"); + sb.AppendLine(" value.AsSpan().CopyTo(global::System.Runtime.InteropServices.MemoryMarshal.Cast(payload));"); + sb.AppendLine(" writer.Advance(byteCount);"); sb.AppendLine(" }"); sb.AppendLine("}"); sb.AppendLine(); @@ -336,7 +334,7 @@ private static void AppendDtoExactSerializeBody( case GeneratedMemberKind.String: sb.AppendLine($" var __string_{memberIndex} = {value};"); sb.AppendLine( - $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); + $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); break; case GeneratedMemberKind.Fixed: sb.AppendLine($" var __fixed_{memberIndex} = {value};"); @@ -481,7 +479,7 @@ private static void AppendDtoSuppressedSerializeBody( var value = $"value.{EscapeIdentifier(member.Identifier)}"; sb.AppendLine($"{indent}var __string_{memberIndex} = {value};"); sb.AppendLine( - $"{indent}var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); + $"{indent}var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); } AppendDtoSerializeBody(sb, model, complexIndexes, useCachedStrings: true, useCachedMembers: false, indent: indent); @@ -531,7 +529,7 @@ private static void AppendDtoMemberWrite( if (cachedMemberIndex >= 0) { sb.AppendLine( - $"{childIndent}__SharpLinkGeneratedUtf8.WriteStringKnownSize(writer, {value}, __stringByteCount_{cachedMemberIndex});"); + $"{childIndent}__SharpLinkGeneratedUtf16.WriteStringKnownSize(writer, {value}, __stringByteCount_{cachedMemberIndex});"); } else { @@ -559,7 +557,7 @@ private static void AppendDtoDirectPreReservation(StringBuilder sb, GeneratedCod { sb.AppendLine($" var __string_{memberIndex} = {value};"); sb.AppendLine( - $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__string_{memberIndex});"); + $" var __stringByteCount_{memberIndex} = __string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__string_{memberIndex});"); } else if (member.Kind == GeneratedMemberKind.Fixed) { @@ -672,7 +670,7 @@ private static void AppendDtoEncodedSizeMethod( case GeneratedMemberKind.String: sb.AppendLine($" __snapshot.__string_{memberIndex} = {value};"); sb.AppendLine( - $" __snapshot.__stringByteCount_{memberIndex} = __snapshot.__string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf8.GetByteCount(__snapshot.__string_{memberIndex});"); + $" __snapshot.__stringByteCount_{memberIndex} = __snapshot.__string_{memberIndex} is null ? 0 : __SharpLinkGeneratedUtf16.GetByteCount(__snapshot.__string_{memberIndex});"); break; case GeneratedMemberKind.Fixed: sb.AppendLine($" __snapshot.__fixed_{memberIndex} = {value};"); @@ -785,7 +783,7 @@ private static void AppendDtoSizeOnlyEncodedSizeMethod( { var nullSize = GetFieldKeySize(member.FieldId, 0); var valueOverhead = GetFieldKeySize(member.FieldId, 6) + sizeof(uint); - sb.AppendLine($" size = checked(size + ({value} is null ? {nullSize.ToString(InvariantCulture)} : {valueOverhead.ToString(InvariantCulture)} + __SharpLinkGeneratedUtf8.GetByteCount({value})));"); + sb.AppendLine($" size = checked(size + ({value} is null ? {nullSize.ToString(InvariantCulture)} : {valueOverhead.ToString(InvariantCulture)} + __SharpLinkGeneratedUtf16.GetByteCount({value})));"); break; } case GeneratedMemberKind.Complex: @@ -922,7 +920,7 @@ private static void AppendDtoSizedSerializeMethod( sb.AppendLine(" {"); sb.AppendLine($" RpcGeneratedCodecWire.WriteFieldKey(buffer, {fieldId}, RpcGeneratedWireType.LengthDelimited);"); sb.AppendLine( - $" __SharpLinkGeneratedUtf8.WriteStringKnownSize(buffer, __snapshot.__string_{memberIndex}, __snapshot.__stringByteCount_{memberIndex});"); + $" __SharpLinkGeneratedUtf16.WriteStringKnownSize(buffer, __snapshot.__string_{memberIndex}, __snapshot.__stringByteCount_{memberIndex});"); sb.AppendLine(" }"); break; case GeneratedMemberKind.Complex: From 904de2611051945132d7cee6ecd59334ab485b3c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:41:42 +0800 Subject: [PATCH 330/399] test: align generated string integration evidence with UTF-16 wire --- ...neratedStringPreReserveIntegrationTests.cs | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/test/SharpLink.IntegrationTests/GeneratedStringPreReserveIntegrationTests.cs b/test/SharpLink.IntegrationTests/GeneratedStringPreReserveIntegrationTests.cs index 325006559..b77d5b0aa 100644 --- a/test/SharpLink.IntegrationTests/GeneratedStringPreReserveIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/GeneratedStringPreReserveIntegrationTests.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Text; namespace SharpLink.IntegrationTests; @@ -22,23 +21,23 @@ public class GeneratedStringPreReserveIntegrationTests [Arguments(64, 128 * 1024)] public void GeneratedDirectStringsShouldPreReserveOnceAndRoundTrip( int fieldCount, - int encodedBytes) + int minimumEncodedBytes) { using var context = new SharpLinkRuntimeContextBuilder().Build(); switch (fieldCount) { case 1: - VerifyBoundaryCase(context, encodedBytes); + VerifyBoundaryCase(context, minimumEncodedBytes); break; case 4: - VerifyBoundaryCase(context, encodedBytes); + VerifyBoundaryCase(context, minimumEncodedBytes); break; case 16: - VerifyBoundaryCase(context, encodedBytes); + VerifyBoundaryCase(context, minimumEncodedBytes); break; case 64: - VerifyBoundaryCase(context, encodedBytes); + VerifyBoundaryCase(context, minimumEncodedBytes); break; default: throw new ArgumentOutOfRangeException(nameof(fieldCount)); @@ -48,10 +47,10 @@ public void GeneratedDirectStringsShouldPreReserveOnceAndRoundTrip( [Test] public void GeneratedDirectStringsShouldPreserveBoundedWriterExhaustionThreshold() { - const int encodedBytes = 1024; + const int minimumEncodedBytes = 1024; using var context = new SharpLinkRuntimeContextBuilder().Build(); var codec = context.Codecs.GetCodec(); - var payload = CreatePayload(encodedBytes); + var payload = CreatePayload(minimumEncodedBytes, out var encodedBytes); using var pool = new SharpLinkBufferWriterPool(new BufferWriterPoolOptions { InitialCapacity = 1024, @@ -78,20 +77,18 @@ public void GeneratedDirectStringsShouldPreserveBoundedWriterExhaustionThreshold } [Test] - public void GeneratedDirectStringsShouldKeepStrictEncoderFailureSemantics() + public void GeneratedDirectStringsShouldPreserveArbitraryUtf16CodeUnits() { using var context = new SharpLinkRuntimeContextBuilder().Build(); var codec = context.Codecs.GetCodec(); + var text = new string(['\uD800', 'X', '\uDC00']); using var writer = new PooledByteBufferWriter(); - var failure = CaptureException(() => codec.Serialize( - new PreReserveStrings1 { Field01 = "\uD800" }, - writer)); + codec.Serialize(new PreReserveStrings1 { Field01 = text }, writer); + var decoded = codec.Deserialize(new ReadOnlySequence(writer.WrittenMemory)); - Ensure(failure is EncoderFallbackException, - $"an isolated surrogate must still fail with EncoderFallbackException, not {failure?.GetType().Name}"); - Ensure(writer.WrittenCount == 0, - "strict UTF-8 validation must complete before the generated DTO mutates the writer"); + Ensure(decoded?.Field01 == text, + "generated DTO strings must preserve arbitrary .NET UTF-16 code units, including unpaired surrogates"); } [Test] @@ -138,11 +135,11 @@ public void GeneratedStringsWithFixedAndNullableFixedMembersShouldUseExactSize(b "fixed and nullable-fixed values must retain their generated wire semantics"); } - private static void VerifyBoundaryCase(SharpLinkRuntimeContext context, int encodedBytes) + private static void VerifyBoundaryCase(SharpLinkRuntimeContext context, int minimumEncodedBytes) where T : class, new() { var codec = context.Codecs.GetCodec(); - var payload = CreatePayload(encodedBytes); + var payload = CreatePayload(minimumEncodedBytes, out var encodedBytes); using var writer = new PooledByteBufferWriter(1024); var tracking = new PreReserveTrackingWriter(writer); @@ -150,16 +147,16 @@ private static void VerifyBoundaryCase(SharpLinkRuntimeContext context, int e var decoded = codec.Deserialize(new ReadOnlySequence(writer.WrittenMemory)); Ensure(writer.WrittenCount == encodedBytes, - $"{typeof(T).Name} must write the exact {encodedBytes}-byte wire payload"); + $"{typeof(T).Name} must write the exact {encodedBytes}-byte UTF-16 wire payload"); Ensure(tracking.FirstSizeHint == encodedBytes + 4, $"{typeof(T).Name} must request exact encoded bytes plus existing varuint request slack before writing"); Ensure(tracking.GrowthCount == 1 && tracking.FirstGrowthWrittenCount == 0, $"{typeof(T).Name} must grow once before any bytes are written"); Ensure(decoded is not null && StringPropertiesEqual(payload, decoded), - $"{typeof(T).Name} must round-trip every direct string, including non-ASCII UTF-8"); + $"{typeof(T).Name} must round-trip every direct string, including non-ASCII UTF-16"); } - private static T CreatePayload(int encodedBytes) where T : class, new() + private static T CreatePayload(int minimumEncodedBytes, out int encodedBytes) where T : class, new() { var properties = GetStringProperties(typeof(T)); var framingBytes = 2; @@ -167,30 +164,34 @@ private static void VerifyBoundaryCase(SharpLinkRuntimeContext context, int e { var fieldId = property.GetCustomAttribute()!.Id; var key = checked(((uint)fieldId << 3) | (uint)RpcGeneratedWireType.LengthDelimited); - framingBytes = checked(framingBytes + GetVarUInt32Size(key) + sizeof(uint)); + framingBytes = checked(framingBytes + GetVarUInt32Size(key) + sizeof(int)); } - var contentBytes = encodedBytes - framingBytes; - Ensure(contentBytes >= properties.Length * Encoding.UTF8.GetByteCount(NonAsciiSeed), - "the requested boundary must leave enough content for non-ASCII data in every field"); - var values = CreateUtf8Values(contentBytes, properties.Length); + var minimumContentBytes = minimumEncodedBytes - framingBytes; + var values = CreateUtf16Values(minimumContentBytes, properties.Length, out var contentBytes); + encodedBytes = checked(framingBytes + contentBytes); var payload = new T(); for (var index = 0; index < properties.Length; index++) properties[index].SetValue(payload, values[index]); return payload; } - private static string[] CreateUtf8Values(int contentBytes, int fieldCount) + private static string[] CreateUtf16Values(int minimumContentBytes, int fieldCount, out int contentBytes) { - var seedBytes = Encoding.UTF8.GetByteCount(NonAsciiSeed); + var seedChars = NonAsciiSeed.Length; + var minimumChars = checked((minimumContentBytes + sizeof(char) - 1) / sizeof(char)); + Ensure(minimumChars >= fieldCount * seedChars, + "the requested boundary must leave enough UTF-16 code units for non-ASCII data in every field"); + var values = new string[fieldCount]; - var baseBytes = contentBytes / fieldCount; - var remainder = contentBytes % fieldCount; + var baseChars = minimumChars / fieldCount; + var remainder = minimumChars % fieldCount; for (var index = 0; index < values.Length; index++) { - var fieldBytes = baseBytes + (index < remainder ? 1 : 0); - values[index] = NonAsciiSeed + new string('x', fieldBytes - seedBytes); + var fieldChars = baseChars + (index < remainder ? 1 : 0); + values[index] = NonAsciiSeed + new string('x', fieldChars - seedChars); } + contentBytes = checked(minimumChars * sizeof(char)); return values; } From 3eac42fcf59f59ce39ecf94c914370ca0d7ed87c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:45:17 +0800 Subject: [PATCH 331/399] chore: apply PR 415 integration evidence patch --- .github/workflows/pr415-evidence-patch.yml | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/pr415-evidence-patch.yml diff --git a/.github/workflows/pr415-evidence-patch.yml b/.github/workflows/pr415-evidence-patch.yml new file mode 100644 index 000000000..e7f2d52ee --- /dev/null +++ b/.github/workflows/pr415-evidence-patch.yml @@ -0,0 +1,84 @@ +name: PR415 Evidence Patch + +permissions: + contents: write + +on: + push: + branches: + - feature/issue-396-deterministic-rpc-identity + +jobs: + patch: + if: github.event.head_commit.message == 'chore: apply PR 415 integration evidence patch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feature/issue-396-deterministic-rpc-identity + fetch-depth: 0 + + - name: Align DateTimeOffset integration evidence + shell: python + run: | + from pathlib import Path + + path = Path('test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs') + text = path.read_text() + + old_test = ''' var malformedFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => + BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(offset + sizeof(long)), long.MaxValue)); + + var paddingIsCanonical = field.WireType == RpcGeneratedWireType.Fixed16 && field.Length == 16 && + field.Offset + field.Length <= serialized.Length && + serialized.AsSpan(field.Offset + sizeof(short), 6).IndexOfAnyExcept((byte)0) < 0; + Ensure(paddingIsCanonical && + malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, + "generated DateTimeOffset must clear native padding and reject invalid ticks");''' + new_test = ''' var malformedFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => + BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(offset + sizeof(long)), long.MaxValue)); + var paddingFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => + payload[offset + sizeof(short)] = 0xA5); + + var paddingIsCanonical = field.WireType == RpcGeneratedWireType.Fixed16 && field.Length == 16 && + field.Offset + field.Length <= serialized.Length && + serialized.AsSpan(field.Offset + sizeof(short), 6).IndexOfAnyExcept((byte)0) < 0; + Ensure(paddingIsCanonical && + malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss } && + paddingFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, + "generated DateTimeOffset must emit canonical padding and reject malformed ticks or padding");''' + + old_value = ''' new TimeOnly(12, 34, 56), + CreateDateTimeOffsetWithPoisonedPadding()), writer); + return writer.WrittenMemory.ToArray(); + } + + private static DateTimeOffset CreateDateTimeOffsetWithPoisonedPadding() + { + var value = new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8)); + Span bytes = stackalloc byte[16]; + bytes.Fill(0xA5); + BinaryPrimitives.WriteInt16LittleEndian(bytes, checked((short)value.Offset.TotalMinutes)); + BinaryPrimitives.WriteInt64LittleEndian(bytes[sizeof(long)..], value.UtcTicks); + return System.Runtime.InteropServices.MemoryMarshal.Read(bytes); + }''' + new_value = ''' new TimeOnly(12, 34, 56), + new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8))), writer); + return writer.WrittenMemory.ToArray(); + }''' + + if text.count(old_test) != 1: + raise SystemExit('expected DateTimeOffset assertion block exactly once') + if text.count(old_value) != 1: + raise SystemExit('expected poisoned DateTimeOffset helper block exactly once') + path.write_text(text.replace(old_test, new_test).replace(old_value, new_value)) + + - name: Commit patch and remove temporary workflow + shell: bash + run: | + rm .github/workflows/pr415-evidence-patch.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs .github/workflows/pr415-evidence-patch.yml + git commit -m "test: align DateTimeOffset integration evidence with canonical wire" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity From a3e9538149fbb53ae29a3ce2847cdcc9cdf1d3a7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:46:13 +0800 Subject: [PATCH 332/399] chore: retry PR 415 integration evidence patch --- .github/workflows/pr415-evidence-patch.yml | 73 +++++++++------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/.github/workflows/pr415-evidence-patch.yml b/.github/workflows/pr415-evidence-patch.yml index e7f2d52ee..1d859bdfe 100644 --- a/.github/workflows/pr415-evidence-patch.yml +++ b/.github/workflows/pr415-evidence-patch.yml @@ -10,7 +10,6 @@ on: jobs: patch: - if: github.event.head_commit.message == 'chore: apply PR 415 integration evidence patch' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -19,59 +18,45 @@ jobs: fetch-depth: 0 - name: Align DateTimeOffset integration evidence - shell: python + shell: bash run: | + python3 - <<'PY' from pathlib import Path path = Path('test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs') text = path.read_text() - old_test = ''' var malformedFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => - BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(offset + sizeof(long)), long.MaxValue)); - - var paddingIsCanonical = field.WireType == RpcGeneratedWireType.Fixed16 && field.Length == 16 && - field.Offset + field.Length <= serialized.Length && - serialized.AsSpan(field.Offset + sizeof(short), 6).IndexOfAnyExcept((byte)0) < 0; - Ensure(paddingIsCanonical && - malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, - "generated DateTimeOffset must clear native padding and reject invalid ticks");''' - new_test = ''' var malformedFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => - BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(offset + sizeof(long)), long.MaxValue)); - var paddingFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => - payload[offset + sizeof(short)] = 0xA5); + value_marker = ' CreateDateTimeOffsetWithPoisonedPadding()), writer);' + if text.count(value_marker) != 1: + raise SystemExit('expected poisoned DateTimeOffset value exactly once') + text = text.replace( + value_marker, + ' new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8))), writer);') - var paddingIsCanonical = field.WireType == RpcGeneratedWireType.Fixed16 && field.Length == 16 && - field.Offset + field.Length <= serialized.Length && - serialized.AsSpan(field.Offset + sizeof(short), 6).IndexOfAnyExcept((byte)0) < 0; - Ensure(paddingIsCanonical && - malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss } && - paddingFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, - "generated DateTimeOffset must emit canonical padding and reject malformed ticks or padding");''' + helper_start = text.index(' private static DateTimeOffset CreateDateTimeOffsetWithPoisonedPadding()') + helper_end = text.index(' private static (int Offset, int Length, RpcGeneratedWireType WireType) FindGeneratedSemanticField(', helper_start) + text = text[:helper_start] + text[helper_end:] - old_value = ''' new TimeOnly(12, 34, 56), - CreateDateTimeOffsetWithPoisonedPadding()), writer); - return writer.WrittenMemory.ToArray(); - } + padding_marker = ' var paddingIsCanonical = field.WireType == RpcGeneratedWireType.Fixed16 && field.Length == 16 &&' + if text.count(padding_marker) != 1: + raise SystemExit('expected DateTimeOffset padding assertion exactly once') + text = text.replace( + padding_marker, + ' var paddingFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) =>\n' + ' payload[offset + sizeof(short)] = 0xA5);\n\n' + + padding_marker) - private static DateTimeOffset CreateDateTimeOffsetWithPoisonedPadding() - { - var value = new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8)); - Span bytes = stackalloc byte[16]; - bytes.Fill(0xA5); - BinaryPrimitives.WriteInt16LittleEndian(bytes, checked((short)value.Offset.TotalMinutes)); - BinaryPrimitives.WriteInt64LittleEndian(bytes[sizeof(long)..], value.UtcTicks); - return System.Runtime.InteropServices.MemoryMarshal.Read(bytes); - }''' - new_value = ''' new TimeOnly(12, 34, 56), - new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8))), writer); - return writer.WrittenMemory.ToArray(); - }''' + ensure_marker = ' malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss },\n "generated DateTimeOffset must clear native padding and reject invalid ticks");' + if text.count(ensure_marker) != 1: + raise SystemExit('expected DateTimeOffset validation assertion exactly once') + text = text.replace( + ensure_marker, + ' malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss } &&\n' + ' paddingFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss },\n' + ' "generated DateTimeOffset must emit canonical padding and reject malformed ticks or padding");') - if text.count(old_test) != 1: - raise SystemExit('expected DateTimeOffset assertion block exactly once') - if text.count(old_value) != 1: - raise SystemExit('expected poisoned DateTimeOffset helper block exactly once') - path.write_text(text.replace(old_test, new_test).replace(old_value, new_value)) + path.write_text(text) + PY - name: Commit patch and remove temporary workflow shell: bash From 2fcb57c4d053d7859d8bf7c5cbc554deeae7f14b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:46:21 +0000 Subject: [PATCH 333/399] test: align DateTimeOffset integration evidence with canonical wire --- .github/workflows/pr415-evidence-patch.yml | 69 ------------------- .../IntegrationBehaviorTests.cs | 20 ++---- 2 files changed, 7 insertions(+), 82 deletions(-) delete mode 100644 .github/workflows/pr415-evidence-patch.yml diff --git a/.github/workflows/pr415-evidence-patch.yml b/.github/workflows/pr415-evidence-patch.yml deleted file mode 100644 index 1d859bdfe..000000000 --- a/.github/workflows/pr415-evidence-patch.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: PR415 Evidence Patch - -permissions: - contents: write - -on: - push: - branches: - - feature/issue-396-deterministic-rpc-identity - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feature/issue-396-deterministic-rpc-identity - fetch-depth: 0 - - - name: Align DateTimeOffset integration evidence - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path('test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs') - text = path.read_text() - - value_marker = ' CreateDateTimeOffsetWithPoisonedPadding()), writer);' - if text.count(value_marker) != 1: - raise SystemExit('expected poisoned DateTimeOffset value exactly once') - text = text.replace( - value_marker, - ' new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8))), writer);') - - helper_start = text.index(' private static DateTimeOffset CreateDateTimeOffsetWithPoisonedPadding()') - helper_end = text.index(' private static (int Offset, int Length, RpcGeneratedWireType WireType) FindGeneratedSemanticField(', helper_start) - text = text[:helper_start] + text[helper_end:] - - padding_marker = ' var paddingIsCanonical = field.WireType == RpcGeneratedWireType.Fixed16 && field.Length == 16 &&' - if text.count(padding_marker) != 1: - raise SystemExit('expected DateTimeOffset padding assertion exactly once') - text = text.replace( - padding_marker, - ' var paddingFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) =>\n' - ' payload[offset + sizeof(short)] = 0xA5);\n\n' - + padding_marker) - - ensure_marker = ' malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss },\n "generated DateTimeOffset must clear native padding and reject invalid ticks");' - if text.count(ensure_marker) != 1: - raise SystemExit('expected DateTimeOffset validation assertion exactly once') - text = text.replace( - ensure_marker, - ' malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss } &&\n' - ' paddingFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss },\n' - ' "generated DateTimeOffset must emit canonical padding and reject malformed ticks or padding");') - - path.write_text(text) - PY - - - name: Commit patch and remove temporary workflow - shell: bash - run: | - rm .github/workflows/pr415-evidence-patch.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs .github/workflows/pr415-evidence-patch.yml - git commit -m "test: align DateTimeOffset integration evidence with canonical wire" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity diff --git a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs index 42bf1f300..f98574038 100644 --- a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs +++ b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs @@ -55,12 +55,16 @@ public void GeneratedDateTimeOffsetMemberShouldUseCanonicalValidatedPayload() var malformedFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(offset + sizeof(long)), long.MaxValue)); + var paddingFailure = DeserializeMutatedGeneratedSemantic(7, static (payload, offset, _) => + payload[offset + sizeof(short)] = 0xA5); + var paddingIsCanonical = field.WireType == RpcGeneratedWireType.Fixed16 && field.Length == 16 && field.Offset + field.Length <= serialized.Length && serialized.AsSpan(field.Offset + sizeof(short), 6).IndexOfAnyExcept((byte)0) < 0; Ensure(paddingIsCanonical && - malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, - "generated DateTimeOffset must clear native padding and reject invalid ticks"); + malformedFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss } && + paddingFailure is SharpLinkException { Code: SharpLinkErrorCode.DataLoss }, + "generated DateTimeOffset must emit canonical padding and reject malformed ticks or padding"); } [Test] @@ -99,20 +103,10 @@ private static byte[] SerializeGeneratedSemantic() new DateOnly(2026, 7, 27), new DateTime(2026, 7, 27, 12, 34, 56, DateTimeKind.Utc), new TimeOnly(12, 34, 56), - CreateDateTimeOffsetWithPoisonedPadding()), writer); + new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8))), writer); return writer.WrittenMemory.ToArray(); } - private static DateTimeOffset CreateDateTimeOffsetWithPoisonedPadding() - { - var value = new DateTimeOffset(2026, 7, 27, 12, 34, 56, TimeSpan.FromHours(8)); - Span bytes = stackalloc byte[16]; - bytes.Fill(0xA5); - BinaryPrimitives.WriteInt16LittleEndian(bytes, checked((short)value.Offset.TotalMinutes)); - BinaryPrimitives.WriteInt64LittleEndian(bytes[sizeof(long)..], value.UtcTicks); - return System.Runtime.InteropServices.MemoryMarshal.Read(bytes); - } - private static (int Offset, int Length, RpcGeneratedWireType WireType) FindGeneratedSemanticField( byte[] payload, uint targetFieldId) From dd708be74ad39ae06b032d7e8e045abe39bf4dcd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:47:48 +0800 Subject: [PATCH 334/399] chore: dispatch final PR 415 validation --- .github/workflows/pr415-final-ci-dispatch.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/pr415-final-ci-dispatch.yml diff --git a/.github/workflows/pr415-final-ci-dispatch.yml b/.github/workflows/pr415-final-ci-dispatch.yml new file mode 100644 index 000000000..4556ecf5f --- /dev/null +++ b/.github/workflows/pr415-final-ci-dispatch.yml @@ -0,0 +1,37 @@ +name: PR415 Final CI Dispatch + +permissions: + actions: write + contents: write + +on: + push: + branches: + - feature/issue-396-deterministic-rpc-identity + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feature/issue-396-deterministic-rpc-identity + fetch-depth: 0 + + - name: Remove dispatcher and publish final head + shell: bash + run: | + rm .github/workflows/pr415-final-ci-dispatch.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add .github/workflows/pr415-final-ci-dispatch.yml + git commit -m "chore: remove PR 415 CI dispatcher" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity + + - name: Dispatch Fast and Extended on final head + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api --method POST "repos/$GITHUB_REPOSITORY/actions/workflows/pr-fast.yml/dispatches" -f ref=feature/issue-396-deterministic-rpc-identity + gh api --method POST "repos/$GITHUB_REPOSITORY/actions/workflows/pr-extended.yml/dispatches" -f ref=feature/issue-396-deterministic-rpc-identity From 102d3944326609d713c66478d945a4271f5af649 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:47:58 +0000 Subject: [PATCH 335/399] chore: remove PR 415 CI dispatcher --- .github/workflows/pr415-final-ci-dispatch.yml | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/pr415-final-ci-dispatch.yml diff --git a/.github/workflows/pr415-final-ci-dispatch.yml b/.github/workflows/pr415-final-ci-dispatch.yml deleted file mode 100644 index 4556ecf5f..000000000 --- a/.github/workflows/pr415-final-ci-dispatch.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: PR415 Final CI Dispatch - -permissions: - actions: write - contents: write - -on: - push: - branches: - - feature/issue-396-deterministic-rpc-identity - -jobs: - dispatch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feature/issue-396-deterministic-rpc-identity - fetch-depth: 0 - - - name: Remove dispatcher and publish final head - shell: bash - run: | - rm .github/workflows/pr415-final-ci-dispatch.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add .github/workflows/pr415-final-ci-dispatch.yml - git commit -m "chore: remove PR 415 CI dispatcher" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity - - - name: Dispatch Fast and Extended on final head - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - gh api --method POST "repos/$GITHUB_REPOSITORY/actions/workflows/pr-fast.yml/dispatches" -f ref=feature/issue-396-deterministic-rpc-identity - gh api --method POST "repos/$GITHUB_REPOSITORY/actions/workflows/pr-extended.yml/dispatches" -f ref=feature/issue-396-deterministic-rpc-identity From b2e8446f194b034baef290281b3ddc15cfdcc9a0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:39:17 +0800 Subject: [PATCH 336/399] chore: stage AOT unsafe blit plan fix --- .../temp-aot-unsafe-blit-plan-fix.yml | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 .github/workflows/temp-aot-unsafe-blit-plan-fix.yml diff --git a/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml b/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml new file mode 100644 index 000000000..3122df11b --- /dev/null +++ b/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml @@ -0,0 +1,405 @@ +name: Temporary AOT UnsafeBlit Plan Fix + +on: + push: + branches: + - feature/issue-396-deterministic-rpc-identity + paths: + - .github/workflows/temp-aot-unsafe-blit-plan-fix.yml + +permissions: + contents: write + actions: write + +jobs: + patch: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: feature/issue-396-deterministic-rpc-identity + fetch-depth: 0 + + - name: Apply resolved-plan NativeAOT fix + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text(encoding='utf-8-sig') + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one anchor, found {count}: {old[:100]!r}") + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + Path('src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs').write_text(r'''using System.Runtime.CompilerServices; + +namespace SharpLink.Abstractions; + +/// Describes runtime ABI checks already resolved for one generated UnsafeBlit payload. +public readonly record struct SharpLinkGeneratedUnsafeBlitRequirement( + int NativePointerWidth, + bool RequiresDateTimeOffsetRawAbi); + +/// +/// Publishes source-generated UnsafeBlit ABI requirements without retaining collectible payload Types. +/// +public static class SharpLinkGeneratedUnsafeBlitCatalog +{ + private static readonly ConditionalWeakTable Requirements = new(); + + /// Registers the resolved UnsafeBlit ABI requirement for one closed payload Type. + public static void Register( + Type targetType, + int nativePointerWidth, + bool requiresDateTimeOffsetRawAbi) + { + ArgumentNullException.ThrowIfNull(targetType); + if (nativePointerWidth <= 0) + throw new ArgumentOutOfRangeException(nameof(nativePointerWidth)); + + var incoming = new SharpLinkGeneratedUnsafeBlitRequirement( + nativePointerWidth, + requiresDateTimeOffsetRawAbi); + var stored = Requirements.GetValue(targetType, _ => new RequirementBox(incoming)); + if (stored.Requirement != incoming) + { + throw new InvalidOperationException( + $"Generated UnsafeBlit ABI requirements for '{targetType.FullName}' are inconsistent."); + } + } + + /// Attempts to read the generated UnsafeBlit ABI requirement for one closed payload Type. + public static bool TryGet( + Type targetType, + out SharpLinkGeneratedUnsafeBlitRequirement requirement) + { + ArgumentNullException.ThrowIfNull(targetType); + if (Requirements.TryGetValue(targetType, out var stored)) + { + requirement = stored.Requirement; + return true; + } + + requirement = default; + return false; + } + + private sealed class RequirementBox(SharpLinkGeneratedUnsafeBlitRequirement requirement) + { + internal SharpLinkGeneratedUnsafeBlitRequirement Requirement { get; } = requirement; + } +} +''', encoding='utf-8') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.Models.cs', + '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''', + '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct GeneratedUnsafeBlitRequirementModel(\n string TypeName,\n int NativePointerWidth,\n bool RequiresDateTimeOffsetRawAbi);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', + ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''', + ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitRequirements { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', + ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''', + ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitRequirements.Length != y.UnsafeBlitRequirements.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', + ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''', + ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitRequirements.Length; index++)\n {\n if (x.UnsafeBlitRequirements[index] != y.UnsafeBlitRequirements[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', + ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''', + ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var requirement in obj.UnsafeBlitRequirements)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(requirement.TypeName));\n hash = unchecked(hash * 31 + requirement.NativePointerWidth);\n hash = unchecked(hash * 31 + requirement.RequiresDateTimeOffsetRawAbi.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''') + + Path('src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs').write_text(r'''namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static ImmutableArray BuildUnsafeBlitRequirements( + params FinalCodecGraph[] graphs) + => graphs + .SelectMany(static graph => graph.Plans.Values) + .OfType() + .GroupBy(static plan => plan.TypeName, StringComparer.Ordinal) + .Select(static group => group.First()) + .Select(static plan => new GeneratedUnsafeBlitRequirementModel( + plan.TypeName, + plan.Abi.NativePointerWidth, + RequiresDateTimeOffsetRawAbi(plan.Layout))) + .OrderBy(static requirement => requirement.TypeName, StringComparer.Ordinal) + .ToImmutableArray(); + + private static bool RequiresDateTimeOffsetRawAbi(FinalPhysicalLayoutPlan plan) + => plan switch + { + FinalPrimitivePhysicalPlan primitive => + primitive.FrameworkRawAbi?.StartsWith( + "framework-raw/datetimeoffset/", + StringComparison.Ordinal) == true, + FinalEnumPhysicalPlan enumPlan => RequiresDateTimeOffsetRawAbi(enumPlan.Underlying), + FinalFixedBufferPhysicalPlan buffer => RequiresDateTimeOffsetRawAbi(buffer.Element), + FinalStructPhysicalPlan structure => + structure.Fields.Any(static field => RequiresDateTimeOffsetRawAbi(field.Layout)), + _ => false + }; +} +''', encoding='utf-8') + + Path('src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs').write_text(r'''namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static string GenerateUnsafeBlitRequirements( + ImmutableArray requirements) + { + if (requirements.IsDefaultOrEmpty) + return string.Empty; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using SharpLink.Abstractions;"); + sb.AppendLine(); + sb.AppendLine("namespace SharpLink.Generated;"); + sb.AppendLine(); + sb.AppendLine("internal static class __SharpLinkGeneratedUnsafeBlitRequirementsInitializer"); + sb.AppendLine("{"); + sb.AppendLine(" [ModuleInitializer]"); + sb.AppendLine(" internal static void Register()"); + sb.AppendLine(" {"); + foreach (var requirement in requirements.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + { + sb.AppendLine( + $" SharpLinkGeneratedUnsafeBlitCatalog.Register(typeof({requirement.TypeName}), {requirement.NativePointerWidth.ToString(InvariantCulture)}, {(requirement.RequiresDateTimeOffsetRawAbi ? "true" : "false")});"); + } + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } +} +''', encoding='utf-8') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs', + ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''', + ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var unsafeBlitRequirements = BuildUnsafeBlitRequirements(standaloneGraph, contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs', + ''' CodecHashes = codecHashes,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''', + ''' CodecHashes = codecHashes,\n UnsafeBlitRequirements = unsafeBlitRequirements,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''') + + replace_once( + 'src/SharpLink.Generator/RpcGenerator.cs', + ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n });''', + ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n\n if (!result.UnsafeBlitRequirements.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedUnsafeBlitRequirements.g.cs",\n SourceText.From(GenerateUnsafeBlitRequirements(result.UnsafeBlitRequirements), Encoding.UTF8));\n }\n });''') + + Path('src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs').write_text(r'''using System.Buffers.Binary; +#if !SHARPLINK_NATIVEAOT +using System.Reflection; +#endif +using System.Runtime.InteropServices; + +namespace SharpLink.Runtime; + +internal static class RpcUnsafeBlitPlatform +{ + private const int SupportedNativePointerSize = 8; + private static readonly bool DateTimeOffsetRawAbiSupported = ProbeDateTimeOffsetRawAbi(); + + internal static void EnsureSupported(Type targetType) + { + ArgumentNullException.ThrowIfNull(targetType); + if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) + { + if (!IsSupported(generatedRequirement, IntPtr.Size, DateTimeOffsetRawAbiSupported)) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' does not satisfy its source-generated runtime ABI requirement."); + } + return; + } + +#if SHARPLINK_NATIVEAOT + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' requires source-generated ABI metadata under NativeAOT. " + + "Use the type in a generated RPC contract or bind an explicit Codec/Adapter."); +#else + if (ContainsRuntimeSizedMember(targetType, new HashSet())) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains runtime-sized members and does not have a stable wire layout."); + } + if (IntPtr.Size != SupportedNativePointerSize) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' requires the SharpLink 64-bit wire ABI."); + } + if (!DateTimeOffsetRawAbiSupported && ContainsDateTimeOffset(targetType, new HashSet())) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains DateTimeOffset, whose raw representation does not match the SharpLink declared framework ABI on this runtime."); + } +#endif + } + + internal static bool IsSupported(Type targetType, int nativePointerSize) + => IsSupported(targetType, nativePointerSize, DateTimeOffsetRawAbiSupported); + + internal static bool IsSupported( + Type targetType, + int nativePointerSize, + bool dateTimeOffsetRawAbiSupported) + { + ArgumentNullException.ThrowIfNull(targetType); + if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) + return IsSupported(generatedRequirement, nativePointerSize, dateTimeOffsetRawAbiSupported); + +#if SHARPLINK_NATIVEAOT + return false; +#else + return nativePointerSize == SupportedNativePointerSize && + !ContainsRuntimeSizedMember(targetType, new HashSet()) && + (dateTimeOffsetRawAbiSupported || !ContainsDateTimeOffset(targetType, new HashSet())); +#endif + } + + private static bool IsSupported( + SharpLinkGeneratedUnsafeBlitRequirement requirement, + int nativePointerSize, + bool dateTimeOffsetRawAbiSupported) + => nativePointerSize == requirement.NativePointerWidth && + (!requirement.RequiresDateTimeOffsetRawAbi || dateTimeOffsetRawAbiSupported); + +#if !SHARPLINK_NATIVEAOT + private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) + { + if (IsRuntimeSizedIntrinsic(type)) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum) + return false; + if (!seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsRuntimeSizedMember(field.FieldType, seen)) + return true; + } + + return false; + } + + private static bool ContainsDateTimeOffset(Type type, HashSet seen) + { + if (type == typeof(DateTimeOffset)) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum || !seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsDateTimeOffset(field.FieldType, seen)) + return true; + } + return false; + } + + private static bool IsRuntimeSizedIntrinsic(Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); +#endif + + private static bool ProbeDateTimeOffsetRawAbi() + { + var value = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromMinutes(330)); + var raw = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); + if (raw.Length != 16) + return false; + + Span expected = stackalloc byte[16]; + BinaryPrimitives.WriteInt16LittleEndian(expected, 330); + BinaryPrimitives.WriteInt64LittleEndian(expected.Slice(8), value.UtcTicks); + return raw.SequenceEqual(expected); + } +} +''', encoding='utf-8') + + replace_once( + 'src/SharpLink.Runtime/SharpLink.Runtime.csproj', + ''' net10.0\n true''', + ''' net10.0\n true\n $(DefineConstants);SHARPLINK_NATIVEAOT''') + + Path('test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs').write_text(r'''using SharpLink.Abstractions; + +namespace SharpLink.UnitTests.Abstractions; + +public sealed class GeneratedUnsafeBlitCatalogTests +{ + [Test] + public void RequirementRegistrationShouldBeWeakKeyedAndDeterministic() + { + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 8, + requiresDateTimeOffsetRawAbi: true); + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 8, + requiresDateTimeOffsetRawAbi: true); + + if (!SharpLinkGeneratedUnsafeBlitCatalog.TryGet(typeof(CatalogPayload), out var requirement) || + requirement.NativePointerWidth != 8 || + !requirement.RequiresDateTimeOffsetRawAbi) + { + throw new InvalidOperationException("Generated UnsafeBlit requirement was not retained accurately."); + } + + try + { + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 4, + requiresDateTimeOffsetRawAbi: true); + } + catch (InvalidOperationException) + { + return; + } + + throw new InvalidOperationException("Conflicting generated UnsafeBlit requirements must fail closed."); + } + + private readonly record struct CatalogPayload(DateTimeOffset Value); +} +''', encoding='utf-8') + + replace_once( + 'doc/contracts-and-codecs.md', + '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。''', + '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。\n\nNativeAOT 不会在运行时重新反射 UnsafeBlit payload 的字段图。Generator 从最终 `FinalUnsafeBlitCodecPlan` 直接发布 native-pointer width 与 framework raw-ABI requirement;Runtime 只验证这份 resolved metadata。没有 source-generated ABI metadata 的任意 unmanaged fallback 在 NativeAOT 下 fail-closed,JIT runtime 则保留运行时字段图检查。''') + PY + + rm .github/workflows/temp-aot-unsafe-blit-plan-fix.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix: derive UnsafeBlit AOT guards from resolved plan" + git push origin HEAD:feature/issue-396-deterministic-rpc-identity + + - name: Dispatch final validation + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + gh workflow run pr-fast.yml --ref feature/issue-396-deterministic-rpc-identity + gh workflow run pr-extended.yml --ref feature/issue-396-deterministic-rpc-identity From cdf115419f9bfac239cf93862caa8b2b19762f4a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:40:34 +0800 Subject: [PATCH 337/399] chore: add temporary AOT patch script --- .github/temp-aot-unsafe-blit-plan-fix.py | 364 +++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 .github/temp-aot-unsafe-blit-plan-fix.py diff --git a/.github/temp-aot-unsafe-blit-plan-fix.py b/.github/temp-aot-unsafe-blit-plan-fix.py new file mode 100644 index 000000000..1f05d5d65 --- /dev/null +++ b/.github/temp-aot-unsafe-blit-plan-fix.py @@ -0,0 +1,364 @@ +from pathlib import Path + + +def replace_once(path, old, new): + p = Path(path) + text = p.read_text(encoding="utf-8-sig") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one anchor, found {count}: {old[:100]!r}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +Path("src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs").write_text(r'''using System.Runtime.CompilerServices; + +namespace SharpLink.Abstractions; + +/// Describes runtime ABI checks already resolved for one generated UnsafeBlit payload. +public readonly record struct SharpLinkGeneratedUnsafeBlitRequirement( + int NativePointerWidth, + bool RequiresDateTimeOffsetRawAbi); + +/// +/// Publishes source-generated UnsafeBlit ABI requirements without retaining collectible payload Types. +/// +public static class SharpLinkGeneratedUnsafeBlitCatalog +{ + private static readonly ConditionalWeakTable Requirements = new(); + + /// Registers the resolved UnsafeBlit ABI requirement for one closed payload Type. + public static void Register( + Type targetType, + int nativePointerWidth, + bool requiresDateTimeOffsetRawAbi) + { + ArgumentNullException.ThrowIfNull(targetType); + if (nativePointerWidth <= 0) + throw new ArgumentOutOfRangeException(nameof(nativePointerWidth)); + + var incoming = new SharpLinkGeneratedUnsafeBlitRequirement( + nativePointerWidth, + requiresDateTimeOffsetRawAbi); + var stored = Requirements.GetValue(targetType, _ => new RequirementBox(incoming)); + if (stored.Requirement != incoming) + { + throw new InvalidOperationException( + $"Generated UnsafeBlit ABI requirements for '{targetType.FullName}' are inconsistent."); + } + } + + /// Attempts to read the generated UnsafeBlit ABI requirement for one closed payload Type. + public static bool TryGet( + Type targetType, + out SharpLinkGeneratedUnsafeBlitRequirement requirement) + { + ArgumentNullException.ThrowIfNull(targetType); + if (Requirements.TryGetValue(targetType, out var stored)) + { + requirement = stored.Requirement; + return true; + } + + requirement = default; + return false; + } + + private sealed class RequirementBox(SharpLinkGeneratedUnsafeBlitRequirement requirement) + { + internal SharpLinkGeneratedUnsafeBlitRequirement Requirement { get; } = requirement; + } +} +''', encoding="utf-8") + +replace_once( + "src/SharpLink.Generator/RpcGenerator.Models.cs", + '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''', + '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct GeneratedUnsafeBlitRequirementModel(\n string TypeName,\n int NativePointerWidth,\n bool RequiresDateTimeOffsetRawAbi);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''') + +replace_once( + "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", + ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''', + ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitRequirements { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''') + +replace_once( + "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", + ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''', + ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitRequirements.Length != y.UnsafeBlitRequirements.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''') + +replace_once( + "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", + ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''', + ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitRequirements.Length; index++)\n {\n if (x.UnsafeBlitRequirements[index] != y.UnsafeBlitRequirements[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''') + +replace_once( + "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", + ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''', + ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var requirement in obj.UnsafeBlitRequirements)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(requirement.TypeName));\n hash = unchecked(hash * 31 + requirement.NativePointerWidth);\n hash = unchecked(hash * 31 + requirement.RequiresDateTimeOffsetRawAbi.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''') + +Path("src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs").write_text(r'''namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static ImmutableArray BuildUnsafeBlitRequirements( + params FinalCodecGraph[] graphs) + => graphs + .SelectMany(static graph => graph.Plans.Values) + .OfType() + .GroupBy(static plan => plan.TypeName, StringComparer.Ordinal) + .Select(static group => group.First()) + .Select(static plan => new GeneratedUnsafeBlitRequirementModel( + plan.TypeName, + plan.Abi.NativePointerWidth, + RequiresDateTimeOffsetRawAbi(plan.Layout))) + .OrderBy(static requirement => requirement.TypeName, StringComparer.Ordinal) + .ToImmutableArray(); + + private static bool RequiresDateTimeOffsetRawAbi(FinalPhysicalLayoutPlan plan) + => plan switch + { + FinalPrimitivePhysicalPlan primitive => + primitive.FrameworkRawAbi?.StartsWith( + "framework-raw/datetimeoffset/", + StringComparison.Ordinal) == true, + FinalEnumPhysicalPlan enumPlan => RequiresDateTimeOffsetRawAbi(enumPlan.Underlying), + FinalFixedBufferPhysicalPlan buffer => RequiresDateTimeOffsetRawAbi(buffer.Element), + FinalStructPhysicalPlan structure => + structure.Fields.Any(static field => RequiresDateTimeOffsetRawAbi(field.Layout)), + _ => false + }; +} +''', encoding="utf-8") + +Path("src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs").write_text(r'''namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static string GenerateUnsafeBlitRequirements( + ImmutableArray requirements) + { + if (requirements.IsDefaultOrEmpty) + return string.Empty; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using SharpLink.Abstractions;"); + sb.AppendLine(); + sb.AppendLine("namespace SharpLink.Generated;"); + sb.AppendLine(); + sb.AppendLine("internal static class __SharpLinkGeneratedUnsafeBlitRequirementsInitializer"); + sb.AppendLine("{"); + sb.AppendLine(" [ModuleInitializer]"); + sb.AppendLine(" internal static void Register()"); + sb.AppendLine(" {"); + foreach (var requirement in requirements.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + { + sb.AppendLine( + $" SharpLinkGeneratedUnsafeBlitCatalog.Register(typeof({requirement.TypeName}), {requirement.NativePointerWidth.ToString(InvariantCulture)}, {(requirement.RequiresDateTimeOffsetRawAbi ? "true" : "false")});"); + } + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } +} +''', encoding="utf-8") + +replace_once( + "src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs", + ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''', + ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var unsafeBlitRequirements = BuildUnsafeBlitRequirements(standaloneGraph, contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''') + +replace_once( + "src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs", + ''' CodecHashes = codecHashes,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''', + ''' CodecHashes = codecHashes,\n UnsafeBlitRequirements = unsafeBlitRequirements,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''') + +replace_once( + "src/SharpLink.Generator/RpcGenerator.cs", + ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n });''', + ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n\n if (!result.UnsafeBlitRequirements.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedUnsafeBlitRequirements.g.cs",\n SourceText.From(GenerateUnsafeBlitRequirements(result.UnsafeBlitRequirements), Encoding.UTF8));\n }\n });''') + +Path("src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs").write_text(r'''using System.Buffers.Binary; +#if !SHARPLINK_NATIVEAOT +using System.Reflection; +#endif +using System.Runtime.InteropServices; + +namespace SharpLink.Runtime; + +internal static class RpcUnsafeBlitPlatform +{ + private const int SupportedNativePointerSize = 8; + private static readonly bool DateTimeOffsetRawAbiSupported = ProbeDateTimeOffsetRawAbi(); + + internal static void EnsureSupported(Type targetType) + { + ArgumentNullException.ThrowIfNull(targetType); + if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) + { + if (!IsSupported(generatedRequirement, IntPtr.Size, DateTimeOffsetRawAbiSupported)) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' does not satisfy its source-generated runtime ABI requirement."); + } + return; + } + +#if SHARPLINK_NATIVEAOT + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' requires source-generated ABI metadata under NativeAOT. " + + "Use the type in a generated RPC contract or bind an explicit Codec/Adapter."); +#else + if (ContainsRuntimeSizedMember(targetType, new HashSet())) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains runtime-sized members and does not have a stable wire layout."); + } + if (IntPtr.Size != SupportedNativePointerSize) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' requires the SharpLink 64-bit wire ABI."); + } + if (!DateTimeOffsetRawAbiSupported && ContainsDateTimeOffset(targetType, new HashSet())) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' contains DateTimeOffset, whose raw representation does not match the SharpLink declared framework ABI on this runtime."); + } +#endif + } + + internal static bool IsSupported(Type targetType, int nativePointerSize) + => IsSupported(targetType, nativePointerSize, DateTimeOffsetRawAbiSupported); + + internal static bool IsSupported( + Type targetType, + int nativePointerSize, + bool dateTimeOffsetRawAbiSupported) + { + ArgumentNullException.ThrowIfNull(targetType); + if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) + return IsSupported(generatedRequirement, nativePointerSize, dateTimeOffsetRawAbiSupported); + +#if SHARPLINK_NATIVEAOT + return false; +#else + return nativePointerSize == SupportedNativePointerSize && + !ContainsRuntimeSizedMember(targetType, new HashSet()) && + (dateTimeOffsetRawAbiSupported || !ContainsDateTimeOffset(targetType, new HashSet())); +#endif + } + + private static bool IsSupported( + SharpLinkGeneratedUnsafeBlitRequirement requirement, + int nativePointerSize, + bool dateTimeOffsetRawAbiSupported) + => nativePointerSize == requirement.NativePointerWidth && + (!requirement.RequiresDateTimeOffsetRawAbi || dateTimeOffsetRawAbiSupported); + +#if !SHARPLINK_NATIVEAOT + private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) + { + if (IsRuntimeSizedIntrinsic(type)) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum) + return false; + if (!seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsRuntimeSizedMember(field.FieldType, seen)) + return true; + } + + return false; + } + + private static bool ContainsDateTimeOffset(Type type, HashSet seen) + { + if (type == typeof(DateTimeOffset)) + return true; + if (!type.IsValueType || type.IsPrimitive || type.IsEnum || !seen.Add(type)) + return false; + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (ContainsDateTimeOffset(field.FieldType, seen)) + return true; + } + return false; + } + + private static bool IsRuntimeSizedIntrinsic(Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); +#endif + + private static bool ProbeDateTimeOffsetRawAbi() + { + var value = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromMinutes(330)); + var raw = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); + if (raw.Length != 16) + return false; + + Span expected = stackalloc byte[16]; + BinaryPrimitives.WriteInt16LittleEndian(expected, 330); + BinaryPrimitives.WriteInt64LittleEndian(expected.Slice(8), value.UtcTicks); + return raw.SequenceEqual(expected); + } +} +''', encoding="utf-8") + +replace_once( + "src/SharpLink.Runtime/SharpLink.Runtime.csproj", + ''' net10.0\n true''', + ''' net10.0\n true\n $(DefineConstants);SHARPLINK_NATIVEAOT''') + +Path("test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs").write_text(r'''using SharpLink.Abstractions; + +namespace SharpLink.UnitTests.Abstractions; + +public sealed class GeneratedUnsafeBlitCatalogTests +{ + [Test] + public void RequirementRegistrationShouldBeWeakKeyedAndDeterministic() + { + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 8, + requiresDateTimeOffsetRawAbi: true); + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 8, + requiresDateTimeOffsetRawAbi: true); + + if (!SharpLinkGeneratedUnsafeBlitCatalog.TryGet(typeof(CatalogPayload), out var requirement) || + requirement.NativePointerWidth != 8 || + !requirement.RequiresDateTimeOffsetRawAbi) + { + throw new InvalidOperationException("Generated UnsafeBlit requirement was not retained accurately."); + } + + try + { + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 4, + requiresDateTimeOffsetRawAbi: true); + } + catch (InvalidOperationException) + { + return; + } + + throw new InvalidOperationException("Conflicting generated UnsafeBlit requirements must fail closed."); + } + + private readonly record struct CatalogPayload(DateTimeOffset Value); +} +''', encoding="utf-8") + +replace_once( + "doc/contracts-and-codecs.md", + '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。''', + '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。\n\nNativeAOT 不会在运行时重新反射 UnsafeBlit payload 的字段图。Generator 从最终 `FinalUnsafeBlitCodecPlan` 直接发布 native-pointer width 与 framework raw-ABI requirement;Runtime 只验证这份 resolved metadata。没有 source-generated ABI metadata 的任意 unmanaged fallback 在 NativeAOT 下 fail-closed,JIT runtime 则保留运行时字段图检查。''') From b7966bd85b81e982798bcd9e3ed39ca2343ca26f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:40:52 +0800 Subject: [PATCH 338/399] chore: fix temporary AOT patch workflow --- .../temp-aot-unsafe-blit-plan-fix.yml | 369 +----------------- 1 file changed, 4 insertions(+), 365 deletions(-) diff --git a/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml b/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml index 3122df11b..183c0655e 100644 --- a/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml +++ b/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml @@ -22,374 +22,13 @@ jobs: fetch-depth: 0 - name: Apply resolved-plan NativeAOT fix + run: python3 .github/temp-aot-unsafe-blit-plan-fix.py + + - name: Commit final patch shell: bash run: | - python3 - <<'PY' - from pathlib import Path - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text(encoding='utf-8-sig') - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one anchor, found {count}: {old[:100]!r}") - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - Path('src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs').write_text(r'''using System.Runtime.CompilerServices; - -namespace SharpLink.Abstractions; - -/// Describes runtime ABI checks already resolved for one generated UnsafeBlit payload. -public readonly record struct SharpLinkGeneratedUnsafeBlitRequirement( - int NativePointerWidth, - bool RequiresDateTimeOffsetRawAbi); - -/// -/// Publishes source-generated UnsafeBlit ABI requirements without retaining collectible payload Types. -/// -public static class SharpLinkGeneratedUnsafeBlitCatalog -{ - private static readonly ConditionalWeakTable Requirements = new(); - - /// Registers the resolved UnsafeBlit ABI requirement for one closed payload Type. - public static void Register( - Type targetType, - int nativePointerWidth, - bool requiresDateTimeOffsetRawAbi) - { - ArgumentNullException.ThrowIfNull(targetType); - if (nativePointerWidth <= 0) - throw new ArgumentOutOfRangeException(nameof(nativePointerWidth)); - - var incoming = new SharpLinkGeneratedUnsafeBlitRequirement( - nativePointerWidth, - requiresDateTimeOffsetRawAbi); - var stored = Requirements.GetValue(targetType, _ => new RequirementBox(incoming)); - if (stored.Requirement != incoming) - { - throw new InvalidOperationException( - $"Generated UnsafeBlit ABI requirements for '{targetType.FullName}' are inconsistent."); - } - } - - /// Attempts to read the generated UnsafeBlit ABI requirement for one closed payload Type. - public static bool TryGet( - Type targetType, - out SharpLinkGeneratedUnsafeBlitRequirement requirement) - { - ArgumentNullException.ThrowIfNull(targetType); - if (Requirements.TryGetValue(targetType, out var stored)) - { - requirement = stored.Requirement; - return true; - } - - requirement = default; - return false; - } - - private sealed class RequirementBox(SharpLinkGeneratedUnsafeBlitRequirement requirement) - { - internal SharpLinkGeneratedUnsafeBlitRequirement Requirement { get; } = requirement; - } -} -''', encoding='utf-8') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.Models.cs', - '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''', - '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct GeneratedUnsafeBlitRequirementModel(\n string TypeName,\n int NativePointerWidth,\n bool RequiresDateTimeOffsetRawAbi);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', - ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''', - ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitRequirements { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', - ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''', - ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitRequirements.Length != y.UnsafeBlitRequirements.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', - ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''', - ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitRequirements.Length; index++)\n {\n if (x.UnsafeBlitRequirements[index] != y.UnsafeBlitRequirements[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.DtoModels.cs', - ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''', - ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var requirement in obj.UnsafeBlitRequirements)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(requirement.TypeName));\n hash = unchecked(hash * 31 + requirement.NativePointerWidth);\n hash = unchecked(hash * 31 + requirement.RequiresDateTimeOffsetRawAbi.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''') - - Path('src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs').write_text(r'''namespace SharpLink.Generator; - -public partial class RpcGenerator -{ - private static ImmutableArray BuildUnsafeBlitRequirements( - params FinalCodecGraph[] graphs) - => graphs - .SelectMany(static graph => graph.Plans.Values) - .OfType() - .GroupBy(static plan => plan.TypeName, StringComparer.Ordinal) - .Select(static group => group.First()) - .Select(static plan => new GeneratedUnsafeBlitRequirementModel( - plan.TypeName, - plan.Abi.NativePointerWidth, - RequiresDateTimeOffsetRawAbi(plan.Layout))) - .OrderBy(static requirement => requirement.TypeName, StringComparer.Ordinal) - .ToImmutableArray(); - - private static bool RequiresDateTimeOffsetRawAbi(FinalPhysicalLayoutPlan plan) - => plan switch - { - FinalPrimitivePhysicalPlan primitive => - primitive.FrameworkRawAbi?.StartsWith( - "framework-raw/datetimeoffset/", - StringComparison.Ordinal) == true, - FinalEnumPhysicalPlan enumPlan => RequiresDateTimeOffsetRawAbi(enumPlan.Underlying), - FinalFixedBufferPhysicalPlan buffer => RequiresDateTimeOffsetRawAbi(buffer.Element), - FinalStructPhysicalPlan structure => - structure.Fields.Any(static field => RequiresDateTimeOffsetRawAbi(field.Layout)), - _ => false - }; -} -''', encoding='utf-8') - - Path('src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs').write_text(r'''namespace SharpLink.Generator; - -public partial class RpcGenerator -{ - private static string GenerateUnsafeBlitRequirements( - ImmutableArray requirements) - { - if (requirements.IsDefaultOrEmpty) - return string.Empty; - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("using System.Runtime.CompilerServices;"); - sb.AppendLine("using SharpLink.Abstractions;"); - sb.AppendLine(); - sb.AppendLine("namespace SharpLink.Generated;"); - sb.AppendLine(); - sb.AppendLine("internal static class __SharpLinkGeneratedUnsafeBlitRequirementsInitializer"); - sb.AppendLine("{"); - sb.AppendLine(" [ModuleInitializer]"); - sb.AppendLine(" internal static void Register()"); - sb.AppendLine(" {"); - foreach (var requirement in requirements.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) - { - sb.AppendLine( - $" SharpLinkGeneratedUnsafeBlitCatalog.Register(typeof({requirement.TypeName}), {requirement.NativePointerWidth.ToString(InvariantCulture)}, {(requirement.RequiresDateTimeOffsetRawAbi ? "true" : "false")});"); - } - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); - } -} -''', encoding='utf-8') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs', - ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''', - ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var unsafeBlitRequirements = BuildUnsafeBlitRequirements(standaloneGraph, contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs', - ''' CodecHashes = codecHashes,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''', - ''' CodecHashes = codecHashes,\n UnsafeBlitRequirements = unsafeBlitRequirements,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''') - - replace_once( - 'src/SharpLink.Generator/RpcGenerator.cs', - ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n });''', - ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n\n if (!result.UnsafeBlitRequirements.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedUnsafeBlitRequirements.g.cs",\n SourceText.From(GenerateUnsafeBlitRequirements(result.UnsafeBlitRequirements), Encoding.UTF8));\n }\n });''') - - Path('src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs').write_text(r'''using System.Buffers.Binary; -#if !SHARPLINK_NATIVEAOT -using System.Reflection; -#endif -using System.Runtime.InteropServices; - -namespace SharpLink.Runtime; - -internal static class RpcUnsafeBlitPlatform -{ - private const int SupportedNativePointerSize = 8; - private static readonly bool DateTimeOffsetRawAbiSupported = ProbeDateTimeOffsetRawAbi(); - - internal static void EnsureSupported(Type targetType) - { - ArgumentNullException.ThrowIfNull(targetType); - if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) - { - if (!IsSupported(generatedRequirement, IntPtr.Size, DateTimeOffsetRawAbiSupported)) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' does not satisfy its source-generated runtime ABI requirement."); - } - return; - } - -#if SHARPLINK_NATIVEAOT - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' requires source-generated ABI metadata under NativeAOT. " + - "Use the type in a generated RPC contract or bind an explicit Codec/Adapter."); -#else - if (ContainsRuntimeSizedMember(targetType, new HashSet())) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' contains runtime-sized members and does not have a stable wire layout."); - } - if (IntPtr.Size != SupportedNativePointerSize) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' requires the SharpLink 64-bit wire ABI."); - } - if (!DateTimeOffsetRawAbiSupported && ContainsDateTimeOffset(targetType, new HashSet())) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' contains DateTimeOffset, whose raw representation does not match the SharpLink declared framework ABI on this runtime."); - } -#endif - } - - internal static bool IsSupported(Type targetType, int nativePointerSize) - => IsSupported(targetType, nativePointerSize, DateTimeOffsetRawAbiSupported); - - internal static bool IsSupported( - Type targetType, - int nativePointerSize, - bool dateTimeOffsetRawAbiSupported) - { - ArgumentNullException.ThrowIfNull(targetType); - if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) - return IsSupported(generatedRequirement, nativePointerSize, dateTimeOffsetRawAbiSupported); - -#if SHARPLINK_NATIVEAOT - return false; -#else - return nativePointerSize == SupportedNativePointerSize && - !ContainsRuntimeSizedMember(targetType, new HashSet()) && - (dateTimeOffsetRawAbiSupported || !ContainsDateTimeOffset(targetType, new HashSet())); -#endif - } - - private static bool IsSupported( - SharpLinkGeneratedUnsafeBlitRequirement requirement, - int nativePointerSize, - bool dateTimeOffsetRawAbiSupported) - => nativePointerSize == requirement.NativePointerWidth && - (!requirement.RequiresDateTimeOffsetRawAbi || dateTimeOffsetRawAbiSupported); - -#if !SHARPLINK_NATIVEAOT - private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) - { - if (IsRuntimeSizedIntrinsic(type)) - return true; - if (!type.IsValueType || type.IsPrimitive || type.IsEnum) - return false; - if (!seen.Add(type)) - return false; - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (ContainsRuntimeSizedMember(field.FieldType, seen)) - return true; - } - - return false; - } - - private static bool ContainsDateTimeOffset(Type type, HashSet seen) - { - if (type == typeof(DateTimeOffset)) - return true; - if (!type.IsValueType || type.IsPrimitive || type.IsEnum || !seen.Add(type)) - return false; - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (ContainsDateTimeOffset(field.FieldType, seen)) - return true; - } - return false; - } - - private static bool IsRuntimeSizedIntrinsic(Type type) - => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); -#endif - - private static bool ProbeDateTimeOffsetRawAbi() - { - var value = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromMinutes(330)); - var raw = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); - if (raw.Length != 16) - return false; - - Span expected = stackalloc byte[16]; - BinaryPrimitives.WriteInt16LittleEndian(expected, 330); - BinaryPrimitives.WriteInt64LittleEndian(expected.Slice(8), value.UtcTicks); - return raw.SequenceEqual(expected); - } -} -''', encoding='utf-8') - - replace_once( - 'src/SharpLink.Runtime/SharpLink.Runtime.csproj', - ''' net10.0\n true''', - ''' net10.0\n true\n $(DefineConstants);SHARPLINK_NATIVEAOT''') - - Path('test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs').write_text(r'''using SharpLink.Abstractions; - -namespace SharpLink.UnitTests.Abstractions; - -public sealed class GeneratedUnsafeBlitCatalogTests -{ - [Test] - public void RequirementRegistrationShouldBeWeakKeyedAndDeterministic() - { - SharpLinkGeneratedUnsafeBlitCatalog.Register( - typeof(CatalogPayload), - nativePointerWidth: 8, - requiresDateTimeOffsetRawAbi: true); - SharpLinkGeneratedUnsafeBlitCatalog.Register( - typeof(CatalogPayload), - nativePointerWidth: 8, - requiresDateTimeOffsetRawAbi: true); - - if (!SharpLinkGeneratedUnsafeBlitCatalog.TryGet(typeof(CatalogPayload), out var requirement) || - requirement.NativePointerWidth != 8 || - !requirement.RequiresDateTimeOffsetRawAbi) - { - throw new InvalidOperationException("Generated UnsafeBlit requirement was not retained accurately."); - } - - try - { - SharpLinkGeneratedUnsafeBlitCatalog.Register( - typeof(CatalogPayload), - nativePointerWidth: 4, - requiresDateTimeOffsetRawAbi: true); - } - catch (InvalidOperationException) - { - return; - } - - throw new InvalidOperationException("Conflicting generated UnsafeBlit requirements must fail closed."); - } - - private readonly record struct CatalogPayload(DateTimeOffset Value); -} -''', encoding='utf-8') - - replace_once( - 'doc/contracts-and-codecs.md', - '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。''', - '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。\n\nNativeAOT 不会在运行时重新反射 UnsafeBlit payload 的字段图。Generator 从最终 `FinalUnsafeBlitCodecPlan` 直接发布 native-pointer width 与 framework raw-ABI requirement;Runtime 只验证这份 resolved metadata。没有 source-generated ABI metadata 的任意 unmanaged fallback 在 NativeAOT 下 fail-closed,JIT runtime 则保留运行时字段图检查。''') - PY - rm .github/workflows/temp-aot-unsafe-blit-plan-fix.yml + rm .github/temp-aot-unsafe-blit-plan-fix.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A From a1779bb20da8a1d3fde40cf2903129353c2fe002 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:41:00 +0000 Subject: [PATCH 339/399] fix: derive UnsafeBlit AOT guards from resolved plan --- .github/temp-aot-unsafe-blit-plan-fix.py | 364 ------------------ .../temp-aot-unsafe-blit-plan-fix.yml | 44 --- doc/contracts-and-codecs.md | 2 + .../SharpLinkGeneratedUnsafeBlitCatalog.cs | 58 +++ .../RpcGenerator.CodecPolicyOwnership.cs | 2 + .../RpcGenerator.DtoModels.cs | 14 + .../RpcGenerator.Models.cs | 5 + .../RpcGenerator.UnsafeBlitRequirements.cs | 32 ++ ...Generator.UnsafeBlitRequirementsEmitter.cs | 33 ++ src/SharpLink.Generator/RpcGenerator.cs | 7 + .../Codec/RpcUnsafeBlitPlatform.cs | 40 +- .../SharpLink.Runtime.csproj | 3 +- .../GeneratedUnsafeBlitCatalogTests.cs | 42 ++ 13 files changed, 234 insertions(+), 412 deletions(-) delete mode 100644 .github/temp-aot-unsafe-blit-plan-fix.py delete mode 100644 .github/workflows/temp-aot-unsafe-blit-plan-fix.yml create mode 100644 src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs create mode 100644 src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs create mode 100644 src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs create mode 100644 test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs diff --git a/.github/temp-aot-unsafe-blit-plan-fix.py b/.github/temp-aot-unsafe-blit-plan-fix.py deleted file mode 100644 index 1f05d5d65..000000000 --- a/.github/temp-aot-unsafe-blit-plan-fix.py +++ /dev/null @@ -1,364 +0,0 @@ -from pathlib import Path - - -def replace_once(path, old, new): - p = Path(path) - text = p.read_text(encoding="utf-8-sig") - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one anchor, found {count}: {old[:100]!r}") - p.write_text(text.replace(old, new, 1), encoding="utf-8") - - -Path("src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs").write_text(r'''using System.Runtime.CompilerServices; - -namespace SharpLink.Abstractions; - -/// Describes runtime ABI checks already resolved for one generated UnsafeBlit payload. -public readonly record struct SharpLinkGeneratedUnsafeBlitRequirement( - int NativePointerWidth, - bool RequiresDateTimeOffsetRawAbi); - -/// -/// Publishes source-generated UnsafeBlit ABI requirements without retaining collectible payload Types. -/// -public static class SharpLinkGeneratedUnsafeBlitCatalog -{ - private static readonly ConditionalWeakTable Requirements = new(); - - /// Registers the resolved UnsafeBlit ABI requirement for one closed payload Type. - public static void Register( - Type targetType, - int nativePointerWidth, - bool requiresDateTimeOffsetRawAbi) - { - ArgumentNullException.ThrowIfNull(targetType); - if (nativePointerWidth <= 0) - throw new ArgumentOutOfRangeException(nameof(nativePointerWidth)); - - var incoming = new SharpLinkGeneratedUnsafeBlitRequirement( - nativePointerWidth, - requiresDateTimeOffsetRawAbi); - var stored = Requirements.GetValue(targetType, _ => new RequirementBox(incoming)); - if (stored.Requirement != incoming) - { - throw new InvalidOperationException( - $"Generated UnsafeBlit ABI requirements for '{targetType.FullName}' are inconsistent."); - } - } - - /// Attempts to read the generated UnsafeBlit ABI requirement for one closed payload Type. - public static bool TryGet( - Type targetType, - out SharpLinkGeneratedUnsafeBlitRequirement requirement) - { - ArgumentNullException.ThrowIfNull(targetType); - if (Requirements.TryGetValue(targetType, out var stored)) - { - requirement = stored.Requirement; - return true; - } - - requirement = default; - return false; - } - - private sealed class RequirementBox(SharpLinkGeneratedUnsafeBlitRequirement requirement) - { - internal SharpLinkGeneratedUnsafeBlitRequirement Requirement { get; } = requirement; - } -} -''', encoding="utf-8") - -replace_once( - "src/SharpLink.Generator/RpcGenerator.Models.cs", - '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''', - '''internal readonly record struct GeneratedCodecHashModel(\n string TypeName,\n ulong High,\n ulong Low);\n\ninternal readonly record struct GeneratedUnsafeBlitRequirementModel(\n string TypeName,\n int NativePointerWidth,\n bool RequiresDateTimeOffsetRawAbi);\n\ninternal readonly record struct RpcHashValue(ulong High, ulong Low);''') - -replace_once( - "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", - ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''', - ''' public ImmutableArray CodecHashes { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitRequirements { get; init; } =\n ImmutableArray.Empty;\n public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } =''') - -replace_once( - "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", - ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''', - ''' x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length ||\n x.CodecHashes.Length != y.CodecHashes.Length ||\n x.UnsafeBlitRequirements.Length != y.UnsafeBlitRequirements.Length ||\n x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length ||''') - -replace_once( - "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", - ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''', - ''' for (var index = 0; index < x.CodecHashes.Length; index++)\n {\n if (x.CodecHashes[index] != y.CodecHashes[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitRequirements.Length; index++)\n {\n if (x.UnsafeBlitRequirements[index] != y.UnsafeBlitRequirements[index])\n return false;\n }\n for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++)''') - -replace_once( - "src/SharpLink.Generator/RpcGenerator.DtoModels.cs", - ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''', - ''' foreach (var codecHash in obj.CodecHashes)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName));\n hash = unchecked(hash * 31 + codecHash.High.GetHashCode());\n hash = unchecked(hash * 31 + codecHash.Low.GetHashCode());\n }\n foreach (var requirement in obj.UnsafeBlitRequirements)\n {\n hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(requirement.TypeName));\n hash = unchecked(hash * 31 + requirement.NativePointerWidth);\n hash = unchecked(hash * 31 + requirement.RequiresDateTimeOffsetRawAbi.GetHashCode());\n }\n foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics)''') - -Path("src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs").write_text(r'''namespace SharpLink.Generator; - -public partial class RpcGenerator -{ - private static ImmutableArray BuildUnsafeBlitRequirements( - params FinalCodecGraph[] graphs) - => graphs - .SelectMany(static graph => graph.Plans.Values) - .OfType() - .GroupBy(static plan => plan.TypeName, StringComparer.Ordinal) - .Select(static group => group.First()) - .Select(static plan => new GeneratedUnsafeBlitRequirementModel( - plan.TypeName, - plan.Abi.NativePointerWidth, - RequiresDateTimeOffsetRawAbi(plan.Layout))) - .OrderBy(static requirement => requirement.TypeName, StringComparer.Ordinal) - .ToImmutableArray(); - - private static bool RequiresDateTimeOffsetRawAbi(FinalPhysicalLayoutPlan plan) - => plan switch - { - FinalPrimitivePhysicalPlan primitive => - primitive.FrameworkRawAbi?.StartsWith( - "framework-raw/datetimeoffset/", - StringComparison.Ordinal) == true, - FinalEnumPhysicalPlan enumPlan => RequiresDateTimeOffsetRawAbi(enumPlan.Underlying), - FinalFixedBufferPhysicalPlan buffer => RequiresDateTimeOffsetRawAbi(buffer.Element), - FinalStructPhysicalPlan structure => - structure.Fields.Any(static field => RequiresDateTimeOffsetRawAbi(field.Layout)), - _ => false - }; -} -''', encoding="utf-8") - -Path("src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs").write_text(r'''namespace SharpLink.Generator; - -public partial class RpcGenerator -{ - private static string GenerateUnsafeBlitRequirements( - ImmutableArray requirements) - { - if (requirements.IsDefaultOrEmpty) - return string.Empty; - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("using System.Runtime.CompilerServices;"); - sb.AppendLine("using SharpLink.Abstractions;"); - sb.AppendLine(); - sb.AppendLine("namespace SharpLink.Generated;"); - sb.AppendLine(); - sb.AppendLine("internal static class __SharpLinkGeneratedUnsafeBlitRequirementsInitializer"); - sb.AppendLine("{"); - sb.AppendLine(" [ModuleInitializer]"); - sb.AppendLine(" internal static void Register()"); - sb.AppendLine(" {"); - foreach (var requirement in requirements.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) - { - sb.AppendLine( - $" SharpLinkGeneratedUnsafeBlitCatalog.Register(typeof({requirement.TypeName}), {requirement.NativePointerWidth.ToString(InvariantCulture)}, {(requirement.RequiresDateTimeOffsetRawAbi ? "true" : "false")});"); - } - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); - } -} -''', encoding="utf-8") - -replace_once( - "src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs", - ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''', - ''' var unsafeBlitAutoLayoutDiagnostics =\n DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph);\n var unsafeBlitRequirements = BuildUnsafeBlitRequirements(standaloneGraph, contractPolicyGraph);\n var contractPolicyCodecs = AttachCodecHashes(''') - -replace_once( - "src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs", - ''' CodecHashes = codecHashes,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''', - ''' CodecHashes = codecHashes,\n UnsafeBlitRequirements = unsafeBlitRequirements,\n UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics,''') - -replace_once( - "src/SharpLink.Generator/RpcGenerator.cs", - ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n });''', - ''' if (!result.Codecs.IsDefaultOrEmpty || !result.ContractCodecs.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedCodecs.g.cs",\n SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8));\n }\n\n if (!result.UnsafeBlitRequirements.IsDefaultOrEmpty)\n {\n spc.AddSource(\n "SharpLink.GeneratedUnsafeBlitRequirements.g.cs",\n SourceText.From(GenerateUnsafeBlitRequirements(result.UnsafeBlitRequirements), Encoding.UTF8));\n }\n });''') - -Path("src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs").write_text(r'''using System.Buffers.Binary; -#if !SHARPLINK_NATIVEAOT -using System.Reflection; -#endif -using System.Runtime.InteropServices; - -namespace SharpLink.Runtime; - -internal static class RpcUnsafeBlitPlatform -{ - private const int SupportedNativePointerSize = 8; - private static readonly bool DateTimeOffsetRawAbiSupported = ProbeDateTimeOffsetRawAbi(); - - internal static void EnsureSupported(Type targetType) - { - ArgumentNullException.ThrowIfNull(targetType); - if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) - { - if (!IsSupported(generatedRequirement, IntPtr.Size, DateTimeOffsetRawAbiSupported)) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' does not satisfy its source-generated runtime ABI requirement."); - } - return; - } - -#if SHARPLINK_NATIVEAOT - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' requires source-generated ABI metadata under NativeAOT. " + - "Use the type in a generated RPC contract or bind an explicit Codec/Adapter."); -#else - if (ContainsRuntimeSizedMember(targetType, new HashSet())) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' contains runtime-sized members and does not have a stable wire layout."); - } - if (IntPtr.Size != SupportedNativePointerSize) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' requires the SharpLink 64-bit wire ABI."); - } - if (!DateTimeOffsetRawAbiSupported && ContainsDateTimeOffset(targetType, new HashSet())) - { - throw new PlatformNotSupportedException( - $"UnsafeBlit Codec for '{targetType.FullName}' contains DateTimeOffset, whose raw representation does not match the SharpLink declared framework ABI on this runtime."); - } -#endif - } - - internal static bool IsSupported(Type targetType, int nativePointerSize) - => IsSupported(targetType, nativePointerSize, DateTimeOffsetRawAbiSupported); - - internal static bool IsSupported( - Type targetType, - int nativePointerSize, - bool dateTimeOffsetRawAbiSupported) - { - ArgumentNullException.ThrowIfNull(targetType); - if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) - return IsSupported(generatedRequirement, nativePointerSize, dateTimeOffsetRawAbiSupported); - -#if SHARPLINK_NATIVEAOT - return false; -#else - return nativePointerSize == SupportedNativePointerSize && - !ContainsRuntimeSizedMember(targetType, new HashSet()) && - (dateTimeOffsetRawAbiSupported || !ContainsDateTimeOffset(targetType, new HashSet())); -#endif - } - - private static bool IsSupported( - SharpLinkGeneratedUnsafeBlitRequirement requirement, - int nativePointerSize, - bool dateTimeOffsetRawAbiSupported) - => nativePointerSize == requirement.NativePointerWidth && - (!requirement.RequiresDateTimeOffsetRawAbi || dateTimeOffsetRawAbiSupported); - -#if !SHARPLINK_NATIVEAOT - private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) - { - if (IsRuntimeSizedIntrinsic(type)) - return true; - if (!type.IsValueType || type.IsPrimitive || type.IsEnum) - return false; - if (!seen.Add(type)) - return false; - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (ContainsRuntimeSizedMember(field.FieldType, seen)) - return true; - } - - return false; - } - - private static bool ContainsDateTimeOffset(Type type, HashSet seen) - { - if (type == typeof(DateTimeOffset)) - return true; - if (!type.IsValueType || type.IsPrimitive || type.IsEnum || !seen.Add(type)) - return false; - - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (ContainsDateTimeOffset(field.FieldType, seen)) - return true; - } - return false; - } - - private static bool IsRuntimeSizedIntrinsic(Type type) - => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); -#endif - - private static bool ProbeDateTimeOffsetRawAbi() - { - var value = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromMinutes(330)); - var raw = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(ref value, 1)); - if (raw.Length != 16) - return false; - - Span expected = stackalloc byte[16]; - BinaryPrimitives.WriteInt16LittleEndian(expected, 330); - BinaryPrimitives.WriteInt64LittleEndian(expected.Slice(8), value.UtcTicks); - return raw.SequenceEqual(expected); - } -} -''', encoding="utf-8") - -replace_once( - "src/SharpLink.Runtime/SharpLink.Runtime.csproj", - ''' net10.0\n true''', - ''' net10.0\n true\n $(DefineConstants);SHARPLINK_NATIVEAOT''') - -Path("test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs").write_text(r'''using SharpLink.Abstractions; - -namespace SharpLink.UnitTests.Abstractions; - -public sealed class GeneratedUnsafeBlitCatalogTests -{ - [Test] - public void RequirementRegistrationShouldBeWeakKeyedAndDeterministic() - { - SharpLinkGeneratedUnsafeBlitCatalog.Register( - typeof(CatalogPayload), - nativePointerWidth: 8, - requiresDateTimeOffsetRawAbi: true); - SharpLinkGeneratedUnsafeBlitCatalog.Register( - typeof(CatalogPayload), - nativePointerWidth: 8, - requiresDateTimeOffsetRawAbi: true); - - if (!SharpLinkGeneratedUnsafeBlitCatalog.TryGet(typeof(CatalogPayload), out var requirement) || - requirement.NativePointerWidth != 8 || - !requirement.RequiresDateTimeOffsetRawAbi) - { - throw new InvalidOperationException("Generated UnsafeBlit requirement was not retained accurately."); - } - - try - { - SharpLinkGeneratedUnsafeBlitCatalog.Register( - typeof(CatalogPayload), - nativePointerWidth: 4, - requiresDateTimeOffsetRawAbi: true); - } - catch (InvalidOperationException) - { - return; - } - - throw new InvalidOperationException("Conflicting generated UnsafeBlit requirements must fail closed."); - } - - private readonly record struct CatalogPayload(DateTimeOffset Value); -} -''', encoding="utf-8") - -replace_once( - "doc/contracts-and-codecs.md", - '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。''', - '''涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。\n\nNativeAOT 不会在运行时重新反射 UnsafeBlit payload 的字段图。Generator 从最终 `FinalUnsafeBlitCodecPlan` 直接发布 native-pointer width 与 framework raw-ABI requirement;Runtime 只验证这份 resolved metadata。没有 source-generated ABI metadata 的任意 unmanaged fallback 在 NativeAOT 下 fail-closed,JIT runtime 则保留运行时字段图检查。''') diff --git a/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml b/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml deleted file mode 100644 index 183c0655e..000000000 --- a/.github/workflows/temp-aot-unsafe-blit-plan-fix.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Temporary AOT UnsafeBlit Plan Fix - -on: - push: - branches: - - feature/issue-396-deterministic-rpc-identity - paths: - - .github/workflows/temp-aot-unsafe-blit-plan-fix.yml - -permissions: - contents: write - actions: write - -jobs: - patch: - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: feature/issue-396-deterministic-rpc-identity - fetch-depth: 0 - - - name: Apply resolved-plan NativeAOT fix - run: python3 .github/temp-aot-unsafe-blit-plan-fix.py - - - name: Commit final patch - shell: bash - run: | - rm .github/workflows/temp-aot-unsafe-blit-plan-fix.yml - rm .github/temp-aot-unsafe-blit-plan-fix.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix: derive UnsafeBlit AOT guards from resolved plan" - git push origin HEAD:feature/issue-396-deterministic-rpc-identity - - - name: Dispatch final validation - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - gh workflow run pr-fast.yml --ref feature/issue-396-deterministic-rpc-identity - gh workflow run pr-extended.yml --ref feature/issue-396-deterministic-rpc-identity diff --git a/doc/contracts-and-codecs.md b/doc/contracts-and-codecs.md index 7524b334f..9119c2b5c 100644 --- a/doc/contracts-and-codecs.md +++ b/doc/contracts-and-codecs.md @@ -22,6 +22,8 @@ Generator 根据签名生成五类调用:Unary、OneWay、ClientStreaming、Se 当一个值类型没有命中共享内置 Codec、显式/生成 Codec 或 resolver,且其运行时表示不包含 managed reference 时,Runtime 可以回退到 `UnsafeBlitCodec`,直接把 `Unsafe.SizeOf()` 范围内的 managed representation 写入 payload。这个原始表示包含结构体 padding;它既不是 canonical field-wise 编码,也不能把普通 `new`/`default` 后的 padding 为零当作跨运行时安全保证。涉及 unsafe/native/uninitialized 来源或机密边界时,可靠的支持路径是为该 **user-defined payload type** 显式绑定 field-wise/non-raw representation 的自定义 Codec/Adapter,而不是依赖调用方先清 padding 后再经过可能发生的 struct copy。完整边界见 [UnsafeBlit padding 安全评估](unsafe-blit-padding-security.md);跨运行时 ABI/兼容性范围见 [UnsafeBlit 兼容性](codec-compatibility.md)。这里描述的是 RPC payload Codec,不改变 SharpLink 自身协议 framing 字段的编码。 +NativeAOT 不会在运行时重新反射 UnsafeBlit payload 的字段图。Generator 从最终 `FinalUnsafeBlitCodecPlan` 直接发布 native-pointer width 与 framework raw-ABI requirement;Runtime 只验证这份 resolved metadata。没有 source-generated ABI metadata 的任意 unmanaged fallback 在 NativeAOT 下 fail-closed,JIT runtime 则保留运行时字段图检查。 + DTO 演进规则: - 字段 id 是 wire identity;发布后不要重用或改变含义。 diff --git a/src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs b/src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs new file mode 100644 index 000000000..570badfa6 --- /dev/null +++ b/src/SharpLink.Abstractions/SharpLinkGeneratedUnsafeBlitCatalog.cs @@ -0,0 +1,58 @@ +using System.Runtime.CompilerServices; + +namespace SharpLink.Abstractions; + +/// Describes runtime ABI checks already resolved for one generated UnsafeBlit payload. +public readonly record struct SharpLinkGeneratedUnsafeBlitRequirement( + int NativePointerWidth, + bool RequiresDateTimeOffsetRawAbi); + +/// +/// Publishes source-generated UnsafeBlit ABI requirements without retaining collectible payload Types. +/// +public static class SharpLinkGeneratedUnsafeBlitCatalog +{ + private static readonly ConditionalWeakTable Requirements = new(); + + /// Registers the resolved UnsafeBlit ABI requirement for one closed payload Type. + public static void Register( + Type targetType, + int nativePointerWidth, + bool requiresDateTimeOffsetRawAbi) + { + ArgumentNullException.ThrowIfNull(targetType); + if (nativePointerWidth <= 0) + throw new ArgumentOutOfRangeException(nameof(nativePointerWidth)); + + var incoming = new SharpLinkGeneratedUnsafeBlitRequirement( + nativePointerWidth, + requiresDateTimeOffsetRawAbi); + var stored = Requirements.GetValue(targetType, _ => new RequirementBox(incoming)); + if (stored.Requirement != incoming) + { + throw new InvalidOperationException( + $"Generated UnsafeBlit ABI requirements for '{targetType.FullName}' are inconsistent."); + } + } + + /// Attempts to read the generated UnsafeBlit ABI requirement for one closed payload Type. + public static bool TryGet( + Type targetType, + out SharpLinkGeneratedUnsafeBlitRequirement requirement) + { + ArgumentNullException.ThrowIfNull(targetType); + if (Requirements.TryGetValue(targetType, out var stored)) + { + requirement = stored.Requirement; + return true; + } + + requirement = default; + return false; + } + + private sealed class RequirementBox(SharpLinkGeneratedUnsafeBlitRequirement requirement) + { + internal SharpLinkGeneratedUnsafeBlitRequirement Requirement { get; } = requirement; + } +} diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 3350b7b0e..f7d3b369a 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -51,6 +51,7 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( var codecHashes = contractPolicyState.BuildFinalCodecHashes(contractPolicyGraph); var unsafeBlitAutoLayoutDiagnostics = DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph); + var unsafeBlitRequirements = BuildUnsafeBlitRequirements(standaloneGraph, contractPolicyGraph); var contractPolicyCodecs = AttachCodecHashes( contractPolicy.Codecs, contractPolicyGraph, @@ -147,6 +148,7 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( enums) { CodecHashes = codecHashes, + UnsafeBlitRequirements = unsafeBlitRequirements, UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics, AssemblyLogicalIdentity = compilation.Assembly.Identity.Name }; diff --git a/src/SharpLink.Generator/RpcGenerator.DtoModels.cs b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs index 77e8a5152..c107c9f35 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoModels.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs @@ -38,6 +38,8 @@ internal sealed record DtoGenerationResult( { public ImmutableArray CodecHashes { get; init; } = ImmutableArray.Empty; + public ImmutableArray UnsafeBlitRequirements { get; init; } = + ImmutableArray.Empty; public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } = ImmutableArray.Empty; public string AssemblyLogicalIdentity { get; init; } = string.Empty; @@ -60,6 +62,7 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) x.ContractCodecs.Length != y.ContractCodecs.Length || x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || x.CodecHashes.Length != y.CodecHashes.Length || + x.UnsafeBlitRequirements.Length != y.UnsafeBlitRequirements.Length || x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length || x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || !string.Equals(x.AssemblyLogicalIdentity, y.AssemblyLogicalIdentity, StringComparison.Ordinal)) @@ -83,6 +86,11 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) if (x.CodecHashes[index] != y.CodecHashes[index]) return false; } + for (var index = 0; index < x.UnsafeBlitRequirements.Length; index++) + { + if (x.UnsafeBlitRequirements[index] != y.UnsafeBlitRequirements[index]) + return false; + } for (var index = 0; index < x.UnsafeBlitAutoLayoutDiagnostics.Length; index++) { var left = x.UnsafeBlitAutoLayoutDiagnostics[index]; @@ -144,6 +152,12 @@ public int GetHashCode(DtoGenerationResult obj) hash = unchecked(hash * 31 + codecHash.High.GetHashCode()); hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); } + foreach (var requirement in obj.UnsafeBlitRequirements) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(requirement.TypeName)); + hash = unchecked(hash * 31 + requirement.NativePointerWidth); + hash = unchecked(hash * 31 + requirement.RequiresDateTimeOffsetRawAbi.GetHashCode()); + } foreach (var diagnostic in obj.UnsafeBlitAutoLayoutDiagnostics) { hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(diagnostic.PayloadType)); diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index fdcf7b3af..b93ae7ef8 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -213,4 +213,9 @@ internal readonly record struct GeneratedCodecHashModel( ulong High, ulong Low); +internal readonly record struct GeneratedUnsafeBlitRequirementModel( + string TypeName, + int NativePointerWidth, + bool RequiresDateTimeOffsetRawAbi); + internal readonly record struct RpcHashValue(ulong High, ulong Low); diff --git a/src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs new file mode 100644 index 000000000..5e6ad47e9 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirements.cs @@ -0,0 +1,32 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static ImmutableArray BuildUnsafeBlitRequirements( + params FinalCodecGraph[] graphs) + => graphs + .SelectMany(static graph => graph.Plans.Values) + .OfType() + .GroupBy(static plan => plan.TypeName, StringComparer.Ordinal) + .Select(static group => group.First()) + .Select(static plan => new GeneratedUnsafeBlitRequirementModel( + plan.TypeName, + plan.Abi.NativePointerWidth, + RequiresDateTimeOffsetRawAbi(plan.Layout))) + .OrderBy(static requirement => requirement.TypeName, StringComparer.Ordinal) + .ToImmutableArray(); + + private static bool RequiresDateTimeOffsetRawAbi(FinalPhysicalLayoutPlan plan) + => plan switch + { + FinalPrimitivePhysicalPlan primitive => + primitive.FrameworkRawAbi?.StartsWith( + "framework-raw/datetimeoffset/", + StringComparison.Ordinal) == true, + FinalEnumPhysicalPlan enumPlan => RequiresDateTimeOffsetRawAbi(enumPlan.Underlying), + FinalFixedBufferPhysicalPlan buffer => RequiresDateTimeOffsetRawAbi(buffer.Element), + FinalStructPhysicalPlan structure => + structure.Fields.Any(static field => RequiresDateTimeOffsetRawAbi(field.Layout)), + _ => false + }; +} diff --git a/src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs new file mode 100644 index 000000000..965d29e77 --- /dev/null +++ b/src/SharpLink.Generator/RpcGenerator.UnsafeBlitRequirementsEmitter.cs @@ -0,0 +1,33 @@ +namespace SharpLink.Generator; + +public partial class RpcGenerator +{ + private static string GenerateUnsafeBlitRequirements( + ImmutableArray requirements) + { + if (requirements.IsDefaultOrEmpty) + return string.Empty; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using SharpLink.Abstractions;"); + sb.AppendLine(); + sb.AppendLine("namespace SharpLink.Generated;"); + sb.AppendLine(); + sb.AppendLine("internal static class __SharpLinkGeneratedUnsafeBlitRequirementsInitializer"); + sb.AppendLine("{"); + sb.AppendLine(" [ModuleInitializer]"); + sb.AppendLine(" internal static void Register()"); + sb.AppendLine(" {"); + foreach (var requirement in requirements.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + { + sb.AppendLine( + $" SharpLinkGeneratedUnsafeBlitCatalog.Register(typeof({requirement.TypeName}), {requirement.NativePointerWidth.ToString(InvariantCulture)}, {(requirement.RequiresDateTimeOffsetRawAbi ? "true" : "false")});"); + } + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } +} diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 1aae470df..081fb5b86 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -318,6 +318,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "SharpLink.GeneratedCodecs.g.cs", SourceText.From(GenerateCodecs(result.Codecs.AddRange(result.ContractCodecs)), Encoding.UTF8)); } + + if (!result.UnsafeBlitRequirements.IsDefaultOrEmpty) + { + spc.AddSource( + "SharpLink.GeneratedUnsafeBlitRequirements.g.cs", + SourceText.From(GenerateUnsafeBlitRequirements(result.UnsafeBlitRequirements), Encoding.UTF8)); + } }); var manifest = boundInterfaces.Collect().Combine(services.Collect()).Combine(generatedCodecs); diff --git a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs index 0f24e1e70..d865f5e69 100644 --- a/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs +++ b/src/SharpLink.Runtime/Codec/RpcUnsafeBlitPlatform.cs @@ -1,5 +1,7 @@ using System.Buffers.Binary; +#if !SHARPLINK_NATIVEAOT using System.Reflection; +#endif using System.Runtime.InteropServices; namespace SharpLink.Runtime; @@ -12,6 +14,21 @@ internal static class RpcUnsafeBlitPlatform internal static void EnsureSupported(Type targetType) { ArgumentNullException.ThrowIfNull(targetType); + if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) + { + if (!IsSupported(generatedRequirement, IntPtr.Size, DateTimeOffsetRawAbiSupported)) + { + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' does not satisfy its source-generated runtime ABI requirement."); + } + return; + } + +#if SHARPLINK_NATIVEAOT + throw new PlatformNotSupportedException( + $"UnsafeBlit Codec for '{targetType.FullName}' requires source-generated ABI metadata under NativeAOT. " + + "Use the type in a generated RPC contract or bind an explicit Codec/Adapter."); +#else if (ContainsRuntimeSizedMember(targetType, new HashSet())) { throw new PlatformNotSupportedException( @@ -27,6 +44,7 @@ internal static void EnsureSupported(Type targetType) throw new PlatformNotSupportedException( $"UnsafeBlit Codec for '{targetType.FullName}' contains DateTimeOffset, whose raw representation does not match the SharpLink declared framework ABI on this runtime."); } +#endif } internal static bool IsSupported(Type targetType, int nativePointerSize) @@ -38,11 +56,26 @@ internal static bool IsSupported( bool dateTimeOffsetRawAbiSupported) { ArgumentNullException.ThrowIfNull(targetType); + if (SharpLinkGeneratedUnsafeBlitCatalog.TryGet(targetType, out var generatedRequirement)) + return IsSupported(generatedRequirement, nativePointerSize, dateTimeOffsetRawAbiSupported); + +#if SHARPLINK_NATIVEAOT + return false; +#else return nativePointerSize == SupportedNativePointerSize && !ContainsRuntimeSizedMember(targetType, new HashSet()) && (dateTimeOffsetRawAbiSupported || !ContainsDateTimeOffset(targetType, new HashSet())); +#endif } + private static bool IsSupported( + SharpLinkGeneratedUnsafeBlitRequirement requirement, + int nativePointerSize, + bool dateTimeOffsetRawAbiSupported) + => nativePointerSize == requirement.NativePointerWidth && + (!requirement.RequiresDateTimeOffsetRawAbi || dateTimeOffsetRawAbiSupported); + +#if !SHARPLINK_NATIVEAOT private static bool ContainsRuntimeSizedMember(Type type, HashSet seen) { if (IsRuntimeSizedIntrinsic(type)) @@ -76,6 +109,10 @@ private static bool ContainsDateTimeOffset(Type type, HashSet seen) return false; } + private static bool IsRuntimeSizedIntrinsic(Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); +#endif + private static bool ProbeDateTimeOffsetRawAbi() { var value = new DateTimeOffset(2026, 8, 31, 13, 45, 12, TimeSpan.FromMinutes(330)); @@ -88,7 +125,4 @@ private static bool ProbeDateTimeOffsetRawAbi() BinaryPrimitives.WriteInt64LittleEndian(expected.Slice(8), value.UtcTicks); return raw.SequenceEqual(expected); } - - private static bool IsRuntimeSizedIntrinsic(Type type) - => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(System.Numerics.Vector<>); } diff --git a/src/SharpLink.Runtime/SharpLink.Runtime.csproj b/src/SharpLink.Runtime/SharpLink.Runtime.csproj index 779a777ae..0b9d4e8b2 100644 --- a/src/SharpLink.Runtime/SharpLink.Runtime.csproj +++ b/src/SharpLink.Runtime/SharpLink.Runtime.csproj @@ -1,8 +1,9 @@ - + net10.0 true + $(DefineConstants);SHARPLINK_NATIVEAOT diff --git a/test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs b/test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs new file mode 100644 index 000000000..3bfa3a39a --- /dev/null +++ b/test/SharpLink.UnitTests/Abstractions/GeneratedUnsafeBlitCatalogTests.cs @@ -0,0 +1,42 @@ +using SharpLink.Abstractions; + +namespace SharpLink.UnitTests.Abstractions; + +public sealed class GeneratedUnsafeBlitCatalogTests +{ + [Test] + public void RequirementRegistrationShouldBeWeakKeyedAndDeterministic() + { + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 8, + requiresDateTimeOffsetRawAbi: true); + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 8, + requiresDateTimeOffsetRawAbi: true); + + if (!SharpLinkGeneratedUnsafeBlitCatalog.TryGet(typeof(CatalogPayload), out var requirement) || + requirement.NativePointerWidth != 8 || + !requirement.RequiresDateTimeOffsetRawAbi) + { + throw new InvalidOperationException("Generated UnsafeBlit requirement was not retained accurately."); + } + + try + { + SharpLinkGeneratedUnsafeBlitCatalog.Register( + typeof(CatalogPayload), + nativePointerWidth: 4, + requiresDateTimeOffsetRawAbi: true); + } + catch (InvalidOperationException) + { + return; + } + + throw new InvalidOperationException("Conflicting generated UnsafeBlit requirements must fail closed."); + } + + private readonly record struct CatalogPayload(DateTimeOffset Value); +} From 7dc4446896452be15c28eeae99edc794c0862250 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:47:04 +0000 Subject: [PATCH 340/399] fix: preserve resolved codec identity boundaries --- .../RpcGenerator.CodecIdentity.cs | 5 +- .../RpcGenerator.DtoAnalysis.cs | 25 ++ .../RpcGenerator.FinalCodecPlan.Models.cs | 1 + .../RpcGenerator.FinalCodecPlan.Selection.cs | 168 +++++------- .../RpcGenerator.FinalCodecPlan.cs | 2 - .../RpcCodecTenthReviewRegressionTests.cs | 239 ++++++++++++++++++ 6 files changed, 338 insertions(+), 102 deletions(-) create mode 100644 test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 4fc6522f2..88a290395 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -40,8 +40,9 @@ private static RpcHashValue HashCanonicalPlan( FinalUnsafeBlitCodecPlan unsafeBlit => HashUnsafeBlitPlan(unsafeBlit), FinalCustomCodecPlan custom => Hashing.GetSemanticHash( "codec/v1", - "custom-opaque", - custom.OpaqueSemanticIdentity.ToHex()), + "custom-closed/v1", + custom.OpaqueSemanticIdentity.ToHex(), + custom.ClosedTargetLogicalIdentity.ToHex()), FinalAdapterCodecPlan adapter => Hashing.GetSemanticHash( "codec/v1", "adapter-closed/v2", diff --git a/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs b/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs index 3f6fbab76..a7ab130ce 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoAnalysis.cs @@ -414,6 +414,11 @@ private void Visit(ITypeSymbol type, List stack, int depth) return; } + // Referenced generated Codec metadata is only a discovery candidate here. + // Its hash and ABI provenance are validated later by ResolveFinalCodecPlan. + if (HasReferencedGeneratedCodecIdentityCandidate(type)) + return; + if (IsThirdPartyType(type)) { Report(DtoDiagnosticKind.Unsupported, type, @@ -1080,6 +1085,26 @@ private static bool IsStableIdentity(string value) return true; } + private static bool HasReferencedGeneratedCodecIdentityCandidate(ITypeSymbol type) + { + var assembly = type.ContainingAssembly; + if (assembly is null) + return false; + + foreach (var attribute in assembly.GetAttributes()) + { + if (IsAttribute(attribute, "SharpLink.Abstractions", "SharpLinkGeneratedCodecIdentityAttribute") && + attribute.ConstructorArguments.Length == 3 && + attribute.ConstructorArguments[0].Value is ITypeSymbol targetType && + SymbolEqualityComparer.Default.Equals(targetType, type)) + { + return true; + } + } + + return false; + } + private bool IsThirdPartyType(ITypeSymbol type) => type.ContainingAssembly is { } assembly && !_allowedAssemblyNames.Contains(assembly.Identity.Name); diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs index 9d3c9c7d9..77494cc51 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Models.cs @@ -90,6 +90,7 @@ internal sealed record FinalUnsafeBlitCodecPlan( internal sealed record FinalCustomCodecPlan( string TypeName, RpcHashValue OpaqueSemanticIdentity, + RpcHashValue ClosedTargetLogicalIdentity, string CodecTypeName) : FinalCodecPlan(TypeName, FinalCodecPlanKind.Custom); diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index fd7c37a77..b803ada90 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -20,7 +20,7 @@ private sealed partial class DtoAnalysisState $"Final Codec graph contains an unresolved recursive Codec selection at '{typeName}'."); } - if (TryResolvePolicyCodecPlan(type, plans, resolving, out var policyPlan)) + if (TryResolvePolicyCodecPlan(type, out var policyPlan)) { resolving.Remove(typeName); if (policyPlan is not null) @@ -30,10 +30,17 @@ private sealed partial class DtoAnalysisState _models.TryGetValue(typeName, out var generatedModel); FinalCodecPlan? plan; - if (TryGetReferencedGeneratedCodecHash(type, out var referencedHash)) + if (TryGetReferencedGeneratedCodecHash( + type, + out var referencedHash, + out var incompatibleReferencedAbi)) { plan = new FinalReferencedCodecPlan(typeName, referencedHash); } + else if (incompatibleReferencedAbi) + { + return FailCurrent(); + } else if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol { EnumUnderlyingType: { } underlying } enumType) { @@ -138,8 +145,6 @@ elementType is not null && private bool TryResolvePolicyCodecPlan( ITypeSymbol type, - Dictionary plans, - HashSet resolving, out FinalCodecPlan? plan) { var typeName = GetTypeName(type); @@ -153,7 +158,11 @@ private bool TryResolvePolicyCodecPlan( var model = CreateCustomCodecModel(type, typeName, customCodec); _models[typeName] = model; - plan = ResolveGeneratedCodecPlan(type, model, plans, resolving); + plan = new FinalCustomCodecPlan( + typeName, + GetRequiredOpaqueSemanticIdentity(customCodec.CodecType, "custom Codec"), + GetCustomCodecTargetLogicalIdentity(type), + GetTypeName(customCodec.CodecType)); return true; } @@ -179,11 +188,17 @@ private bool TryResolvePolicyCodecPlan( } AddAdapterModel(type, typeName, selectedAdapter); - plan = ResolveGeneratedCodecPlan(type, _models[typeName], plans, resolving); + plan = new FinalAdapterCodecPlan( + typeName, + GetRequiredOpaqueSemanticIdentity(selectedAdapter.AdapterType, "Codec Adapter"), + GetAdapterTargetLogicalIdentity(type), + GetTypeName(selectedAdapter.AdapterType), + selectedAdapter.AdapterId); return true; } private GeneratedCodecModel CreateCustomCodecModel( + ITypeSymbol type, string typeName, CustomCodecRegistration customCodec) @@ -254,20 +269,9 @@ private bool HasCompositeCodecPolicyCandidate(ITypeSymbol type) switch (model.Kind) { case GeneratedCodecKind.Custom: - return new FinalCustomCodecPlan( - model.TypeName, - GetRequiredOpaqueSemanticIdentity(model.CustomCodecType, "custom Codec"), - model.CustomCodecType ?? throw new InvalidOperationException( - $"Final custom Codec plan '{model.TypeName}' is missing its implementation binding.")); case GeneratedCodecKind.Adapter: - return new FinalAdapterCodecPlan( - model.TypeName, - GetRequiredOpaqueSemanticIdentity(model.AdapterType, "Codec Adapter"), - GetAdapterTargetLogicalIdentity(type), - model.AdapterType ?? throw new InvalidOperationException( - $"Final Codec Adapter plan '{model.TypeName}' is missing its implementation binding."), - model.AdapterId ?? throw new InvalidOperationException( - $"Final Codec Adapter plan '{model.TypeName}' is missing its adapter identity.")); + throw new InvalidOperationException( + $"Final policy Codec plan '{model.TypeName}' must be resolved from the selected implementation symbol."); case GeneratedCodecKind.Dto: return ResolveGeneratedDtoPlan(type, model, plans, resolving); default: @@ -574,14 +578,19 @@ private static bool TryGetFrameworkScalarSemantic( return token is not null; } - private bool TryGetReferencedGeneratedCodecHash(ITypeSymbol type, out RpcHashValue hash) + private bool TryGetReferencedGeneratedCodecHash( + ITypeSymbol type, + out RpcHashValue hash, + out bool incompatibleAbi) { + incompatibleAbi = false; var assembly = type.ContainingAssembly; if (assembly is null || SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly)) { hash = default; return false; } + foreach (var attribute in assembly.GetAttributes()) { if (!IsAttribute(attribute, "SharpLink.Abstractions", "SharpLinkGeneratedCodecIdentityAttribute") || @@ -593,106 +602,62 @@ attribute.ConstructorArguments[1].Value is not ulong high || { continue; } + + if (!HasCurrentGeneratedAbiIdentity(assembly)) + { + Report( + DtoDiagnosticKind.Unsupported, + type, + $"referenced assembly '{assembly.Identity.Name}' publishes generated CodecHash metadata from an incompatible SharpLink generated ABI. Rebuild/regenerate the referenced assembly with the current SharpLink SDK."); + hash = default; + incompatibleAbi = true; + return false; + } + hash = new RpcHashValue(high, low); return true; } + hash = default; return false; } - private RpcHashValue GetRequiredOpaqueSemanticIdentity( - string? implementationTypeName, - string implementationKind) + private static bool HasCurrentGeneratedAbiIdentity(IAssemblySymbol assembly) { - if (TryGetOpaqueSemanticIdentity(implementationTypeName, out var hash)) - return hash; - throw new InvalidOperationException( - $"Opaque {implementationKind} '{implementationTypeName ?? ""}' must declare [RpcCodecSemanticIdentity(high, low)]."); - } - - private bool TryGetOpaqueSemanticIdentity(string? implementationTypeName, out RpcHashValue hash) - { - if (implementationTypeName is null) - { - hash = default; - return false; - } - if (_opaqueSemanticIdentityCache.TryGetValue(implementationTypeName, out var cached)) - { - hash = cached ?? default; - return cached.HasValue; - } - - var visited = new HashSet(StringComparer.Ordinal); - var pending = new Queue(); - pending.Enqueue(_compilation.Assembly); - while (pending.Count != 0) + foreach (var attribute in assembly.GetAttributes()) { - var assembly = pending.Dequeue(); - if (!visited.Add(assembly.Identity.ToString())) - continue; - if (TryFindNamedType(assembly.GlobalNamespace, implementationTypeName, out var implementationType)) + if (IsAttribute(attribute, "SharpLink.Abstractions", "SharpLinkGeneratedAssemblyManifestAttribute") && + attribute.ConstructorArguments.Length >= 5 && + attribute.ConstructorArguments[4].Value is string abiIdentity && + string.Equals(abiIdentity, GeneratedAbiIdentity, StringComparison.Ordinal)) { - var attribute = implementationType.GetAttributes().FirstOrDefault(static item => - IsAttribute(item, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); - if (attribute is not null && - attribute.ConstructorArguments.Length == 2 && - attribute.ConstructorArguments[0].Value is ulong high && - attribute.ConstructorArguments[1].Value is ulong low) - { - hash = new RpcHashValue(high, low); - _opaqueSemanticIdentityCache[implementationTypeName] = hash; - return true; - } + return true; } - foreach (var referenced in assembly.Modules.SelectMany(static module => module.ReferencedAssemblySymbols)) - pending.Enqueue(referenced); } - _opaqueSemanticIdentityCache[implementationTypeName] = null; - hash = default; return false; } - private static bool TryFindNamedType( - INamespaceSymbol namespaceSymbol, - string typeName, - out INamedTypeSymbol type) + private static RpcHashValue GetRequiredOpaqueSemanticIdentity( + INamedTypeSymbol implementationType, + string implementationKind) { - foreach (var candidate in namespaceSymbol.GetTypeMembers()) - { - if (TryFindNamedType(candidate, typeName, out type)) - return true; - } - foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers()) + var attribute = implementationType.OriginalDefinition.GetAttributes().FirstOrDefault(static item => + IsAttribute(item, "SharpLink.Sdk", "RpcCodecSemanticIdentityAttribute")); + if (attribute is not null && + attribute.ConstructorArguments.Length == 2 && + attribute.ConstructorArguments[0].Value is ulong high && + attribute.ConstructorArguments[1].Value is ulong low) { - if (TryFindNamedType(nestedNamespace, typeName, out type)) - return true; + return new RpcHashValue(high, low); } - type = null!; - return false; - } - private static bool TryFindNamedType( - INamedTypeSymbol candidate, - string typeName, - out INamedTypeSymbol type) - { - if (string.Equals(GetTypeName(candidate), typeName, StringComparison.Ordinal)) - { - type = candidate; - return true; - } - foreach (var nested in candidate.GetTypeMembers()) - { - if (TryFindNamedType(nested, typeName, out type)) - return true; - } - type = null!; - return false; + throw new InvalidOperationException( + $"Opaque {implementationKind} '{GetTypeName(implementationType)}' must declare [RpcCodecSemanticIdentity(high, low)]."); } - private bool TryResolveReachableType(string typeName, out ITypeSymbol type) + private bool TryResolveReachableType( +string typeName, out ITypeSymbol type) { var roots = new Dictionary(StringComparer.Ordinal); CollectCurrentAssemblyRoots( @@ -707,6 +672,13 @@ private bool TryResolveReachableType(string typeName, out ITypeSymbol type) return reachable.TryGetValue(typeName, out type!); } + private RpcHashValue GetCustomCodecTargetLogicalIdentity(ITypeSymbol targetType) + { + var parts = new List { "custom-target/v1" }; + AppendClosedTargetLogicalIdentity(targetType, parts); + return Hashing.GetSemanticHash(parts.ToArray()); + } + private RpcHashValue GetAdapterTargetLogicalIdentity(ITypeSymbol targetType) { var parts = new List { "adapter-target/v2" }; diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs index 7e332e1a0..69e97a89b 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.cs @@ -7,8 +7,6 @@ private sealed partial class DtoAnalysisState private static readonly FinalUnsafeBlitAbiPlan UnsafeBlitAbi = new("little-endian", 8, "v3"); - private readonly Dictionary _opaqueSemanticIdentityCache = - new(StringComparer.Ordinal); internal FinalCodecGraph ResolveFinalCodecGraph( bool includeSerializable, diff --git a/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs new file mode 100644 index 000000000..c1d7a170f --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task ClosedGenericCustomCodecShouldUseSelectedSymbolAndClosedTargetIdentity() + { + var source = BuildSource(""" +[SharpLink.Sdk.RpcCodec(typeof(GenericCodec))] +public sealed class FirstPayload +{ + public int Value { get; set; } +} + +[SharpLink.Sdk.RpcCodec(typeof(GenericCodec))] +public sealed class SecondPayload +{ + public int Value { get; set; } +} + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x1111111111111111UL, 0x2222222222222222UL)] +public sealed class GenericCodec : SharpLink.Abstractions.IRpcCodec +{ +} + +[SharpLink.Sdk.RpcContract] +public interface IClosedGenericCustomContract : SharpLink.Sdk.IService +{ + ValueTask EchoFirst(FirstPayload value, CancellationToken cancellationToken); + ValueTask EchoSecond(SecondPayload value, CancellationToken cancellationToken); +} +"""); + + var manifest = RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + + Ensure( + ExtractGeneratedCodecIdentity(manifest, "FirstPayload") != + ExtractGeneratedCodecIdentity(manifest, "SecondPayload"), + "closed generic custom Codec targets must not collapse to the generic definition's shared opaque identity"); + return Task.CompletedTask; + } + + [Test] + public Task ClosedGenericAdapterShouldUseSelectedImplementationSymbol() + { + var source = AddAssemblyAttribute(BuildSource(""" +[SharpLink.Sdk.RpcCodecAdapter(typeof(GenericAdapter))] +public sealed class AdapterPayload +{ + public int Value { get; set; } +} + +[SharpLink.Sdk.RpcCodecSemanticIdentity(0x3333333333333333UL, 0x4444444444444444UL)] +public sealed class GenericAdapter : SharpLink.Abstractions.IRpcCodecAdapter +{ + public string AdapterId => "generic-adapter/v1"; + public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); +} + +[SharpLink.Sdk.RpcContract] +public interface IClosedGenericAdapterContract : SharpLink.Sdk.IService +{ + ValueTask Echo(AdapterPayload value, CancellationToken cancellationToken); +} +"""), + "[assembly: SharpLink.Sdk.RpcCodecAdapterRegistration(typeof(GenericAdapter), \"generic-adapter/v1\")]"); + + var changedIdentitySource = source.Replace( + "0x3333333333333333UL", + "0x7333333333333333UL", + StringComparison.Ordinal); + var manifest = RunGeneratorAndGetSources(source) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + var changedManifest = RunGeneratorAndGetSources(changedIdentitySource) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + Ensure( + ExtractGeneratedRpcAssemblyHash(manifest) != ExtractGeneratedRpcAssemblyHash(changedManifest), + "a valid constructed generic Adapter must retain the semantic identity of its selected implementation symbol"); + return Task.CompletedTask; + } + + [Test] + public Task SameFqnCustomCodecShouldUseAliasedSelectedSymbolIdentity() + { + var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); + var payload = CreateMetadataReference( + "SameFqnPayload", + "namespace Shared { public sealed class Payload { public int Value { get; set; } } }"); + + static MetadataReference Alias(MetadataReference reference, string alias) + => ((PortableExecutableReference)reference).WithAliases(ImmutableArray.Create(alias)); + + var codecA = Alias(CreateMetadataReference( + "SameFqnCodecA", + """ +using SharpLink.Abstractions; +using SharpLink.Sdk; + +namespace SameName +{ + [RpcCodecSemanticIdentity(0xaaaaaaaaaaaaaaaaUL, 0x1111111111111111UL)] + public sealed class PayloadCodec : IRpcCodec { } +} +""", + sdk, + payload), "CodecA"); + var codecB = Alias(CreateMetadataReference( + "SameFqnCodecB", + """ +using SharpLink.Abstractions; +using SharpLink.Sdk; + +namespace SameName +{ + [RpcCodecSemanticIdentity(0xbbbbbbbbbbbbbbbbUL, 0x2222222222222222UL)] + public sealed class PayloadCodec : IRpcCodec { } +} +""", + sdk, + payload), "CodecB"); + + static string Consumer(string alias) => $$""" +extern alias CodecA; +extern alias CodecB; +using System.Threading; +using System.Threading.Tasks; +using SharpLink.Sdk; + +[assembly: RpcCodec(typeof(Shared.Payload), typeof({{alias}}::SameName.PayloadCodec))] + +[RpcContract] +public interface ISameFqnCodecContract : IService +{ + ValueTask Echo(Shared.Payload value, CancellationToken cancellationToken); +} +"""; + + var manifestA = RunGeneratorAndGetSources(Consumer("CodecA"), sdk, payload, codecA, codecB) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + var manifestB = RunGeneratorAndGetSources(Consumer("CodecB"), sdk, payload, codecA, codecB) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + + Ensure( + ExtractGeneratedRpcAssemblyHash(manifestA) != ExtractGeneratedRpcAssemblyHash(manifestB), + "same-FQN implementations from different referenced assemblies must use the semantic identity of the actually selected symbol"); + return Task.CompletedTask; + } + + [Test] + public Task ReferencedCodecHashShouldRequireCurrentGeneratedAbi() + { + var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); + + static MetadataReference GeneratedPayloadReference(string assemblyName, string abiIdentity) + => CreateMetadataReference( + assemblyName, + $$""" +using System; + +[assembly: SharpLink.Abstractions.SharpLinkGeneratedCodecIdentityAttribute(typeof(Referenced.Payload), 0x5555555555555555UL, 0x6666666666666666UL)] +[assembly: SharpLink.Abstractions.SharpLinkGeneratedAssemblyManifestAttribute(typeof(Referenced.Manifest), 4, 2, "2.0.0-test", "{{abiIdentity}}")] + +namespace SharpLink.Abstractions +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class SharpLinkGeneratedCodecIdentityAttribute : Attribute + { + public SharpLinkGeneratedCodecIdentityAttribute(Type targetType, ulong high, ulong low) { } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] + public sealed class SharpLinkGeneratedAssemblyManifestAttribute : Attribute + { + public SharpLinkGeneratedAssemblyManifestAttribute( + Type manifestType, + int apiVersion, + int protocolVersion, + string generatorVersion, + string abiIdentity) { } + } +} + +namespace Referenced +{ + public sealed class Payload { public int Value { get; set; } } + public sealed class Manifest { } +} +"""); + + const string consumer = """ +using System.Threading; +using System.Threading.Tasks; +using SharpLink.Sdk; + +[RpcContract] +public interface IReferencedCodecContract : IService +{ + ValueTask Echo(Referenced.Payload value, CancellationToken cancellationToken); +} +"""; + + var stale = GeneratedPayloadReference( + "StaleGeneratedPayload", + "sharplink-2.0-api4-rpcchannel-codec-provider-v3"); + var staleDiagnostics = RunGenerator(consumer, sdk, stale); + Ensure( + staleDiagnostics.Any(static diagnostic => + diagnostic.GetMessage().Contains("incompatible SharpLink generated ABI", StringComparison.Ordinal) && + diagnostic.GetMessage().Contains("Rebuild/regenerate", StringComparison.Ordinal)), + $"a referenced CodecHash from an old generated ABI must be rejected with a rebuild/regenerate diagnostic. Actual: {FormatDiagnostics(staleDiagnostics)}"); + + var current = GeneratedPayloadReference( + "CurrentGeneratedPayload", + "sharplink-2.0-api4-rpcchannel-codec-provider-v4"); + var currentDiagnostics = RunGenerator(consumer, sdk, current); + Ensure( + !currentDiagnostics.Any(static diagnostic => + diagnostic.GetMessage().Contains("incompatible SharpLink generated ABI", StringComparison.Ordinal)), + "a referenced CodecHash produced by the current generated ABI must remain accepted"); + return Task.CompletedTask; + } +} From 9681db9875ea8e1993ad0a76efc0024f78aadce8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:04:59 +0000 Subject: [PATCH 341/399] fix: align codec selection guards with emission --- .../RpcGenerator.CodecPolicyOwnership.cs | 7 +- .../RpcGenerator.FinalCodecPlan.Selection.cs | 49 ++++++++++++ .../RpcCodecTenthReviewRegressionTests.cs | 76 ++++++++++++++++--- 3 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index f7d3b369a..776b1e90e 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -618,8 +618,13 @@ private void RejectRuntimeSizedUnsafeBlitTypes() foreach (var type in reachable.Values) { var typeName = GetTypeName(type); - if (HasCodecPolicyCandidate(type)) + if (HasCodecPolicyCandidate(type) || + HasReferencedGeneratedCodecIdentityCandidate(type)) + { + // Referenced generated Codec metadata is only a candidate here. + // ResolveFinalCodecPlan owns its ABI/hash validation and final selection. continue; + } if (!type.IsUnmanagedType || !IsRuntimeSizedUnsafeBlitType(type)) continue; Report(DtoDiagnosticKind.Unsupported, type, diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs index b803ada90..cf27449bf 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Selection.cs @@ -156,6 +156,17 @@ private bool TryResolvePolicyCodecPlan( return true; } + if (IsExternAliasOnlyImplementation(customCodec.CodecType)) + { + Report( + DtoDiagnosticKind.CustomCodecTypeInvalid, + customCodec.CodecType, + $"custom Codec implementation '{GetTypeName(customCodec.CodecType)}' is referenced only through extern aliases; generated Codec factories require the implementation assembly to be globally visible"); + _failed.Add(typeName); + plan = null; + return true; + } + var model = CreateCustomCodecModel(type, typeName, customCodec); _models[typeName] = model; plan = new FinalCustomCodecPlan( @@ -187,6 +198,17 @@ private bool TryResolvePolicyCodecPlan( return true; } + if (IsExternAliasOnlyImplementation(selectedAdapter.AdapterType)) + { + Report( + DtoDiagnosticKind.AdapterTypeInvalid, + selectedAdapter.AdapterType, + $"Codec Adapter implementation '{GetTypeName(selectedAdapter.AdapterType)}' is referenced only through extern aliases; generated Codec factories require the implementation assembly to be globally visible"); + _failed.Add(typeName); + plan = null; + return true; + } + AddAdapterModel(type, typeName, selectedAdapter); plan = new FinalAdapterCodecPlan( typeName, @@ -220,6 +242,33 @@ private GeneratedCodecModel CreateCustomCodecModel( GetAssemblyDependencies([type]), type.Locations.FirstOrDefault()); + private bool IsExternAliasOnlyImplementation(INamedTypeSymbol implementationType) + { + var assembly = implementationType.ContainingAssembly; + if (assembly is null || SymbolEqualityComparer.Default.Equals(assembly, _compilation.Assembly)) + return false; + + var matchedReference = false; + foreach (var reference in _compilation.References) + { + if (_compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol referencedAssembly || + !SymbolEqualityComparer.Default.Equals(referencedAssembly, assembly)) + { + continue; + } + + matchedReference = true; + var aliases = reference.Properties.Aliases; + if (aliases.IsDefaultOrEmpty || + aliases.Any(static alias => string.Equals(alias, "global", StringComparison.Ordinal))) + { + return false; + } + } + + return matchedReference; + } + private bool HasCodecPolicyCandidate(ITypeSymbol type) { var normalized = NormalizeAdapterTarget(type); diff --git a/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs index c1d7a170f..c6c6eac37 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs @@ -93,7 +93,7 @@ public interface IClosedGenericAdapterContract : SharpLink.Sdk.IService } [Test] - public Task SameFqnCustomCodecShouldUseAliasedSelectedSymbolIdentity() + public Task AliasOnlyCustomCodecImplementationShouldBeRejectedBeforeEmission() { var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); var payload = CreateMetadataReference( @@ -148,18 +148,66 @@ public interface ISameFqnCodecContract : IService } """; - var manifestA = RunGeneratorAndGetSources(Consumer("CodecA"), sdk, payload, codecA, codecB) - .Single(static generated => generated.Contains( - "ISharpLinkGeneratedAssemblyManifest", - StringComparison.Ordinal)); - var manifestB = RunGeneratorAndGetSources(Consumer("CodecB"), sdk, payload, codecA, codecB) - .Single(static generated => generated.Contains( - "ISharpLinkGeneratedAssemblyManifest", - StringComparison.Ordinal)); + var diagnostics = RunGenerator(Consumer("CodecA"), sdk, payload, codecA, codecB); + Ensure( + diagnostics.Any(static diagnostic => + diagnostic.GetMessage().Contains("referenced only through extern aliases", StringComparison.Ordinal)), + $"alias-only custom Codec implementations must be rejected before emitting an uncompilable global:: factory reference. Actual: {FormatDiagnostics(diagnostics)}"); + return Task.CompletedTask; + } + + [Test] + public Task AliasOnlyAdapterImplementationShouldBeRejectedBeforeEmission() + { + var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); + static MetadataReference Alias(MetadataReference reference, string alias) + => ((PortableExecutableReference)reference).WithAliases(ImmutableArray.Create(alias)); + + var adapter = Alias(CreateMetadataReference( + "AliasOnlyAdapter", + """ +using System; +using SharpLink.Abstractions; +using SharpLink.Sdk; + +namespace SameName +{ + [RpcCodecSemanticIdentity(0xccccccccccccccccUL, 0x3333333333333333UL)] + public sealed class PayloadAdapter : IRpcCodecAdapter + { + public string AdapterId => "alias-adapter/v1"; + public IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); + } +} +""", + sdk), "AdapterOnly"); + + const string consumer = """ +extern alias AdapterOnly; +using System.Threading; +using System.Threading.Tasks; +using SharpLink.Sdk; +[assembly: RpcCodecAdapterRegistration(typeof(AdapterOnly::SameName.PayloadAdapter), "alias-adapter/v1")] + +[RpcCodecAdapter(typeof(AdapterOnly::SameName.PayloadAdapter))] +public sealed class AdapterPayload +{ + public int Value { get; set; } +} + +[RpcContract] +public interface IAliasOnlyAdapterContract : IService +{ + ValueTask Echo(AdapterPayload value, CancellationToken cancellationToken); +} +"""; + + var diagnostics = RunGenerator(consumer, sdk, adapter); Ensure( - ExtractGeneratedRpcAssemblyHash(manifestA) != ExtractGeneratedRpcAssemblyHash(manifestB), - "same-FQN implementations from different referenced assemblies must use the semantic identity of the actually selected symbol"); + diagnostics.Any(static diagnostic => + diagnostic.GetMessage().Contains("referenced only through extern aliases", StringComparison.Ordinal)), + $"alias-only Codec Adapter implementations must be rejected before emitting an uncompilable global:: holder reference. Actual: {FormatDiagnostics(diagnostics)}"); return Task.CompletedTask; } @@ -199,7 +247,7 @@ public SharpLinkGeneratedAssemblyManifestAttribute( namespace Referenced { - public sealed class Payload { public int Value { get; set; } } + public struct Payload { public System.Numerics.Vector Value; } public sealed class Manifest { } } """); @@ -234,6 +282,10 @@ public interface IReferencedCodecContract : IService !currentDiagnostics.Any(static diagnostic => diagnostic.GetMessage().Contains("incompatible SharpLink generated ABI", StringComparison.Ordinal)), "a referenced CodecHash produced by the current generated ABI must remain accepted"); + Ensure( + !currentDiagnostics.Any(static diagnostic => + diagnostic.GetMessage().Contains("runtime-sized intrinsic unmanaged types", StringComparison.Ordinal)), + $"a current generated Codec identity must bypass pre-plan UnsafeBlit rejection even when the referenced unmanaged payload contains Vector. Actual: {FormatDiagnostics(currentDiagnostics)}"); return Task.CompletedTask; } } From 5029a8e80f3368ea776842346d69151293cb240a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:29:12 +0800 Subject: [PATCH 342/399] fix: bind referenced codec dependencies by type and hash --- .../SharpLinkReferencedCodecDependency.cs | 21 ++++ .../SharpLinkClient.AssemblyDrain.cs | 7 +- .../SharpLinkClient.AssemblyRegistration.cs | 30 ++++- .../RpcGenerator.CodecIdentity.cs | 6 +- .../RpcGenerator.DtoModels.cs | 1 + .../RpcGenerator.ManifestEmitter.cs | 17 ++- .../RpcGenerator.Models.cs | 3 +- .../SharpLinkRuntimeContext.cs | 73 +++++++++++- .../SharpLinkServer.AssemblyDrain.cs | 7 +- .../SharpLinkServer.AssemblyRegistration.cs | 30 ++++- .../RpcCodecTenthReviewRegressionTests.cs | 13 +++ .../RuntimeAssemblyIntegrationTests.cs | 110 ++++++++++++++++++ .../SharpLink.IntegrationTests.csproj | 2 + .../ReferencedCodecConsumer.cs | 36 ++++++ .../SharpLink.ReferencedCodecConsumer.csproj | 12 ++ .../ReferencedCodecProvider.cs | 66 +++++++++++ .../SharpLink.ReferencedCodecProvider.csproj | 11 ++ .../Runtime/SharpLinkRuntimeContextTests.cs | 97 +++++++++++++++ 18 files changed, 521 insertions(+), 21 deletions(-) create mode 100644 src/SharpLink.Abstractions/SharpLinkReferencedCodecDependency.cs create mode 100644 test/SharpLink.ReferencedCodecConsumer/ReferencedCodecConsumer.cs create mode 100644 test/SharpLink.ReferencedCodecConsumer/SharpLink.ReferencedCodecConsumer.csproj create mode 100644 test/SharpLink.ReferencedCodecProvider/ReferencedCodecProvider.cs create mode 100644 test/SharpLink.ReferencedCodecProvider/SharpLink.ReferencedCodecProvider.csproj diff --git a/src/SharpLink.Abstractions/SharpLinkReferencedCodecDependency.cs b/src/SharpLink.Abstractions/SharpLinkReferencedCodecDependency.cs new file mode 100644 index 000000000..f9dff1987 --- /dev/null +++ b/src/SharpLink.Abstractions/SharpLinkReferencedCodecDependency.cs @@ -0,0 +1,21 @@ +namespace SharpLink.Abstractions; + +/// +/// Binds a compile-time referenced generated Codec to the exact runtime target type and semantic hash +/// that the consuming generated assembly was compiled against. +/// +public sealed record SharpLinkReferencedCodecDependency( + Type TargetType, + RpcHash128 ExpectedCodecHash); + +/// +/// Optional generated-manifest capability that publishes binding-aware referenced Codec dependencies. +/// The target preserves the exact assembly/load-context generation selected by the +/// consumer, while locks the +/// expected generated Codec semantics. +/// +public interface ISharpLinkReferencedCodecDependencyManifest +{ + /// Gets the referenced generated Codec dependencies required by this manifest. + IReadOnlyList ReferencedCodecDependencies { get; } +} diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs index d6b3bdc52..bb1da4651 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs @@ -122,8 +122,8 @@ private void ReleaseModule(Assembly assembly, SharpLinkDynamicModule module) else nextFactories[codecType] = replacement; } - Volatile.Write(ref _proxies, nextProxies); _runtimeContext.PublishGeneratedCodecs(nextFactories); + Volatile.Write(ref _proxies, nextProxies); _dynamicModules.Remove(assembly); _registryGeneration++; } @@ -166,11 +166,12 @@ private static ValueTask WaitForUnregisterAsy private void EnsureNoDynamicDependants(SharpLinkDynamicModule module) { - var identity = module.Manifest.OwnerAssembly.FullName; + var ownerAssembly = module.Manifest.OwnerAssembly; + var identity = ownerAssembly.FullName; foreach (var candidate in _dynamicModules.Values) { if (!ReferenceEquals(candidate, module) && - ManifestDependsOn(candidate.Manifest, identity)) + ManifestDependsOn(candidate.Manifest, ownerAssembly)) throw new InvalidOperationException( $"Assembly '{identity}' cannot be unregistered while '{candidate.Manifest.OwnerAssembly.FullName}' depends on it."); } diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs index b5163b1ab..f463aa285 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs @@ -437,12 +437,13 @@ private IReadOnlyDictionary CreateCodecSnap SharpLinkDynamicModule oldModule, ISharpLinkGeneratedAssemblyManifest incoming) { - var oldIdentity = oldModule.Manifest.OwnerAssembly.FullName; + var oldAssembly = oldModule.Manifest.OwnerAssembly; + var oldIdentity = oldAssembly.FullName; var newIdentity = incoming.OwnerAssembly.FullName; foreach (var candidate in _dynamicModules.Values) { if (!ReferenceEquals(candidate, oldModule) && - ManifestDependsOn(candidate.Manifest, oldIdentity)) + ManifestDependsOn(candidate.Manifest, oldAssembly)) { return CreateError( SharpLinkAssemblyRegistrationErrorCode.MissingDependency, @@ -463,9 +464,28 @@ private static IEnumerable EnumerateManifestDependencies(ISharpLinkGener yield return dependency; } - private static bool ManifestDependsOn(ISharpLinkGeneratedAssemblyManifest manifest, string? identity) - => identity is not null && EnumerateManifestDependencies(manifest) - .Any(dependency => string.Equals(dependency, identity, StringComparison.Ordinal)); + private static bool ManifestDependsOn( + ISharpLinkGeneratedAssemblyManifest manifest, + Assembly ownerAssembly) + { + var identity = ownerAssembly.FullName; + if (identity is not null && EnumerateManifestDependencies(manifest) + .Any(dependency => string.Equals(dependency, identity, StringComparison.Ordinal))) + { + return true; + } + + if (manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest || + dependencyManifest.ReferencedCodecDependencies is not { } referencedDependencies) + { + return false; + } + + return referencedDependencies.Any(dependency => + dependency is not null && + dependency.TargetType is { } targetType && + ReferenceEquals(targetType.Assembly, ownerAssembly)); + } private SharpLinkAssemblyRegistrationError? ValidateDependencies( ISharpLinkGeneratedAssemblyManifest incoming, diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 88a290395..7d023721c 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -12,7 +12,11 @@ internal ImmutableArray BuildFinalCodecHashes(FinalCode .Select(pair => { var hash = HashCanonicalPlan(pair.Value, graph, cache, new HashSet(StringComparer.Ordinal)); - return new GeneratedCodecHashModel(pair.Key, hash.High, hash.Low); + return new GeneratedCodecHashModel( + pair.Key, + hash.High, + hash.Low, + pair.Value is FinalReferencedCodecPlan); }) .ToImmutableArray(); } diff --git a/src/SharpLink.Generator/RpcGenerator.DtoModels.cs b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs index c107c9f35..909c290f6 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoModels.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs @@ -151,6 +151,7 @@ public int GetHashCode(DtoGenerationResult obj) hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName)); hash = unchecked(hash * 31 + codecHash.High.GetHashCode()); hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); + hash = unchecked(hash * 31 + codecHash.IsReferenced.GetHashCode()); } foreach (var requirement in obj.UnsafeBlitRequirements) { diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index ef062e9c5..e0ff3bab5 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -32,6 +32,10 @@ private static string GenerateAssemblyManifest( .Distinct(StringComparer.Ordinal) .OrderBy(static dependency => dependency, StringComparer.Ordinal) .ToArray(); + var referencedCodecDependencies = codecHashes + .Where(static codecHash => codecHash.IsReferenced) + .OrderBy(static codecHash => codecHash.TypeName, StringComparer.Ordinal) + .ToArray(); var compileTimeDescriptor = BuildCompileTimeDescriptor(contracts, serviceModels, codecs, contractCodecs); var sb = new StringBuilder(); @@ -56,7 +60,7 @@ private static string GenerateAssemblyManifest( sb.AppendLine("namespace SharpLink.Generated;"); sb.AppendLine(); sb.AppendLine("[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]"); - sb.AppendLine($"public sealed partial class {manifestTypeName} : ISharpLinkGeneratedAssemblyManifest"); + sb.AppendLine($"public sealed partial class {manifestTypeName} : ISharpLinkGeneratedAssemblyManifest, ISharpLinkReferencedCodecDependencyManifest"); sb.AppendLine("{"); sb.AppendLine($" public const string CompileTimeDescriptor = \"{EscapeString(compileTimeDescriptor)}\";"); sb.AppendLine($" public static readonly {manifestTypeName} Instance = new();"); @@ -87,18 +91,29 @@ private static string GenerateAssemblyManifest( foreach (var dependency in contractDependencies) sb.AppendLine($" \"{EscapeString(dependency)}\","); sb.AppendLine(" };"); + sb.AppendLine(" private static readonly SharpLinkReferencedCodecDependency[] __referencedCodecDependencies = new SharpLinkReferencedCodecDependency[]"); + sb.AppendLine(" {"); + foreach (var dependency in referencedCodecDependencies) + { + sb.AppendLine(" new SharpLinkReferencedCodecDependency("); + sb.AppendLine($" typeof({dependency.TypeName}),"); + sb.AppendLine($" new RpcHash128({dependency.High.ToString(InvariantCulture)}UL, {dependency.Low.ToString(InvariantCulture)}UL)),"); + } + sb.AppendLine(" };"); sb.AppendLine(" private static readonly IReadOnlyList __readOnlyContracts = Array.AsReadOnly(__contracts);"); sb.AppendLine(" private static readonly IReadOnlyList __readOnlyServices = Array.AsReadOnly(__services);"); sb.AppendLine(" private static readonly IReadOnlyList __readOnlyCodecs = Array.AsReadOnly(__codecs);"); sb.AppendLine(" private static readonly IReadOnlyList __readOnlyContractCodecs = Array.AsReadOnly(__contractCodecs);"); sb.AppendLine(" private static readonly IReadOnlyList __readOnlyDependencies = Array.AsReadOnly(__dependencies);"); sb.AppendLine(" private static readonly IReadOnlyList __readOnlyContractDependencies = Array.AsReadOnly(__contractDependencies);"); + sb.AppendLine(" private static readonly IReadOnlyList __readOnlyReferencedCodecDependencies = Array.AsReadOnly(__referencedCodecDependencies);"); sb.AppendLine(" public IReadOnlyList Contracts => __readOnlyContracts;"); sb.AppendLine(" public IReadOnlyList Services => __readOnlyServices;"); sb.AppendLine(" public IReadOnlyList Codecs => __readOnlyCodecs;"); sb.AppendLine(" public IReadOnlyList ContractCodecs => __readOnlyContractCodecs;"); sb.AppendLine(" public IReadOnlyList Dependencies => __readOnlyDependencies;"); sb.AppendLine(" public IReadOnlyList ContractDependencies => __readOnlyContractDependencies;"); + sb.AppendLine(" public IReadOnlyList ReferencedCodecDependencies => __readOnlyReferencedCodecDependencies;"); sb.AppendLine("}"); sb.AppendLine(); sb.AppendLine("internal static class __SharpLinkGeneratedAssemblyManifestInitializer"); diff --git a/src/SharpLink.Generator/RpcGenerator.Models.cs b/src/SharpLink.Generator/RpcGenerator.Models.cs index b93ae7ef8..71516567b 100644 --- a/src/SharpLink.Generator/RpcGenerator.Models.cs +++ b/src/SharpLink.Generator/RpcGenerator.Models.cs @@ -211,7 +211,8 @@ internal sealed record GeneratedCodecModel( internal readonly record struct GeneratedCodecHashModel( string TypeName, ulong High, - ulong Low); + ulong Low, + bool IsReferenced = false); internal readonly record struct GeneratedUnsafeBlitRequirementModel( string TypeName, diff --git a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs index 042ac0ba0..8f449fc46 100644 --- a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs +++ b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs @@ -30,7 +30,7 @@ internal SharpLinkRuntimeContext( { foreach (var manifest in generatedManifests) { - var owner = PrepareGeneratedManifest(manifest); + var owner = PrepareGeneratedManifest(manifest, validateReferencedDependencies: false); prepared.Add(owner); foreach (var pair in owner.Codecs) { @@ -45,6 +45,7 @@ internal SharpLinkRuntimeContext( generatedRegistrations[pair.Key] = pair.Value; } } + ValidateReferencedCodecDependencies(prepared, generatedRegistrations); PublishGeneratedCodecs(generatedRegistrations); foreach (var registration in prepared) AdoptGeneratedManifest(registration); @@ -110,12 +111,29 @@ private static void ThrowAfterConstructionRollback( internal RpcGeneratedManifestRegistration PrepareGeneratedManifest( ISharpLinkGeneratedAssemblyManifest manifest) + => PrepareGeneratedManifest(manifest, validateReferencedDependencies: true); + + private RpcGeneratedManifestRegistration PrepareGeneratedManifest( + ISharpLinkGeneratedAssemblyManifest manifest, + bool validateReferencedDependencies) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); ArgumentNullException.ThrowIfNull(manifest); SharpLinkGeneratedManifestCompatibility.ThrowIfIncompatible(manifest); SharpLinkGeneratedManifestStructureValidator.Validate(manifest); - return RpcGeneratedManifestRegistration.Create(manifest, Codecs); + var registration = RpcGeneratedManifestRegistration.Create(manifest, Codecs); + if (!validateReferencedDependencies) + return registration; + try + { + ValidateReferencedCodecDependencies([registration], CreateGeneratedCodecSnapshot()); + return registration; + } + catch + { + registration.Dispose(); + throw; + } } internal IReadOnlyDictionary CreateGeneratedCodecSnapshot() @@ -124,9 +142,60 @@ internal IReadOnlyDictionary CreateGenerate internal void PublishGeneratedCodecs(IReadOnlyDictionary registrations) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + RpcGeneratedManifestRegistration[] manifests; + lock (_registrationGate) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + manifests = [.. _manifestRegistrations]; + } + ValidateReferencedCodecDependencies(manifests, registrations); ((RpcCodecProvider)Codecs).PublishGeneratedRegistrations(registrations); } + private static void ValidateReferencedCodecDependencies( + IEnumerable manifests, + IReadOnlyDictionary registrations) + { + foreach (var registration in manifests) + { + if (registration.Manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest) + continue; + var dependencies = dependencyManifest.ReferencedCodecDependencies + ?? throw new InvalidOperationException( + $"Generated manifest '{registration.Manifest.OwnerAssembly.FullName}' returned null referenced Codec dependencies."); + foreach (var dependency in dependencies) + { + if (dependency is null) + { + throw new InvalidOperationException( + $"Generated manifest '{registration.Manifest.OwnerAssembly.FullName}' contains a null referenced Codec dependency."); + } + var targetType = dependency.TargetType ?? throw new InvalidOperationException( + $"Generated manifest '{registration.Manifest.OwnerAssembly.FullName}' contains a referenced Codec dependency with no target Type."); + if (dependency.ExpectedCodecHash.IsEmpty) + { + throw new InvalidOperationException( + $"Generated manifest '{registration.Manifest.OwnerAssembly.FullName}' requires referenced generated Codec '{targetType.FullName}' with an empty expected CodecHash."); + } + if (!registrations.TryGetValue(targetType, out var actual)) + { + throw new InvalidOperationException( + $"Generated manifest '{registration.Manifest.OwnerAssembly.FullName}' requires referenced generated Codec '{targetType.FullName}' from the exact bound runtime Type/assembly generation with expected CodecHash '{dependency.ExpectedCodecHash}', but no generated Codec is registered for that exact Type."); + } + if (!ReferenceEquals(actual.Owner.Manifest.OwnerAssembly, targetType.Assembly)) + { + throw new InvalidOperationException( + $"Generated manifest '{registration.Manifest.OwnerAssembly.FullName}' requires referenced generated Codec '{targetType.FullName}' from assembly generation '{targetType.Assembly.FullName}', but the registered Codec is owned by '{actual.Owner.Manifest.OwnerAssembly.FullName}'."); + } + if (actual.Factory.CodecHash != dependency.ExpectedCodecHash) + { + throw new InvalidOperationException( + $"Generated manifest '{registration.Manifest.OwnerAssembly.FullName}' requires referenced generated Codec '{targetType.FullName}' with expected CodecHash '{dependency.ExpectedCodecHash}', but the exact registered Type has CodecHash '{actual.Factory.CodecHash}'."); + } + } + } + } + internal void AdoptGeneratedManifest(RpcGeneratedManifestRegistration registration) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs index 665a1c7ae..a0e3ed230 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs @@ -143,8 +143,8 @@ private async Task ReleaseModuleAsync(Assembly assembly, SharpLinkDynamicModule else nextFactories[codecType] = replacement; } - Volatile.Write(ref _services, nextServices); _runtimeContext.PublishGeneratedCodecs(nextFactories); + Volatile.Write(ref _services, nextServices); _dynamicModules.Remove(assembly); _registryGeneration++; } @@ -253,12 +253,13 @@ private async Task ReleaseDrainedDynamicModulesAsync() private void EnsureNoDynamicDependants(SharpLinkDynamicModule module) { - var identity = module.Manifest.OwnerAssembly.FullName; + var ownerAssembly = module.Manifest.OwnerAssembly; + var identity = ownerAssembly.FullName; foreach (var candidate in _dynamicModules.Values) { if (ReferenceEquals(candidate, module)) continue; - if (ManifestDependsOn(candidate.Manifest, identity)) + if (ManifestDependsOn(candidate.Manifest, ownerAssembly)) { throw new InvalidOperationException( $"Assembly '{identity}' cannot be unregistered while '{candidate.Manifest.OwnerAssembly.FullName}' depends on it."); diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs index 10e78099a..1b9e35ea6 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs @@ -567,12 +567,13 @@ private static void DisposeCreatedServices(IReadOnlyList se SharpLinkDynamicModule oldModule, ISharpLinkGeneratedAssemblyManifest incoming) { - var oldIdentity = oldModule.Manifest.OwnerAssembly.FullName; + var oldAssembly = oldModule.Manifest.OwnerAssembly; + var oldIdentity = oldAssembly.FullName; var newIdentity = incoming.OwnerAssembly.FullName; foreach (var candidate in _dynamicModules.Values) { if (!ReferenceEquals(candidate, oldModule) && - ManifestDependsOn(candidate.Manifest, oldIdentity)) + ManifestDependsOn(candidate.Manifest, oldAssembly)) { return CreateError( SharpLinkAssemblyRegistrationErrorCode.MissingDependency, @@ -593,9 +594,28 @@ private static IEnumerable EnumerateManifestDependencies(ISharpLinkGener yield return dependency; } - private static bool ManifestDependsOn(ISharpLinkGeneratedAssemblyManifest manifest, string? identity) - => identity is not null && EnumerateManifestDependencies(manifest) - .Any(dependency => string.Equals(dependency, identity, StringComparison.Ordinal)); + private static bool ManifestDependsOn( + ISharpLinkGeneratedAssemblyManifest manifest, + Assembly ownerAssembly) + { + var identity = ownerAssembly.FullName; + if (identity is not null && EnumerateManifestDependencies(manifest) + .Any(dependency => string.Equals(dependency, identity, StringComparison.Ordinal))) + { + return true; + } + + if (manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest || + dependencyManifest.ReferencedCodecDependencies is not { } referencedDependencies) + { + return false; + } + + return referencedDependencies.Any(dependency => + dependency is not null && + dependency.TargetType is { } targetType && + ReferenceEquals(targetType.Assembly, ownerAssembly)); + } private SharpLinkAssemblyRegistrationError? ValidateServiceDependencies( ISharpLinkGeneratedAssemblyManifest incoming, diff --git a/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs index c6c6eac37..483dc2761 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs @@ -286,6 +286,19 @@ public interface IReferencedCodecContract : IService !currentDiagnostics.Any(static diagnostic => diagnostic.GetMessage().Contains("runtime-sized intrinsic unmanaged types", StringComparison.Ordinal)), $"a current generated Codec identity must bypass pre-plan UnsafeBlit rejection even when the referenced unmanaged payload contains Vector. Actual: {FormatDiagnostics(currentDiagnostics)}"); + + var currentManifest = RunGeneratorAndGetSources(consumer, sdk, current) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + Ensure( + currentManifest.Contains("ISharpLinkReferencedCodecDependencyManifest", StringComparison.Ordinal) && + currentManifest.Contains("new SharpLinkReferencedCodecDependency(", StringComparison.Ordinal) && + currentManifest.Contains("typeof(global::Referenced.Payload)", StringComparison.Ordinal), + "a FinalReferencedCodecPlan leaf must emit a binding-aware Type + CodecHash dependency descriptor"); + Ensure( + !currentManifest.Contains("CurrentGeneratedPayload, Version=", StringComparison.Ordinal), + "referenced Codec dependency provenance must not collapse back to an Assembly.FullName string"); return Task.CompletedTask; } } diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs index 4cd6f8729..1fc717f6e 100644 --- a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs @@ -7,6 +7,100 @@ namespace SharpLink.IntegrationTests; public sealed class RuntimeAssemblyIntegrationTests { + [Test] + [NotInParallel] + public async Task SameFullNameReferencedCodecDependencyShouldRequireExactGenerationOnClientAndServer() + { + await using var harness = await DynamicHarness.CreateAsync(); + var directory = GetReferencedCodecOutputDirectory(); + var firstContext = new PluginLoadContext("referenced-codec-generation-1", directory); + var secondContext = new PluginLoadContext("referenced-codec-generation-2", directory); + try + { + var providerPath = Path.Combine(directory, "SharpLink.ReferencedCodecProvider.dll"); + var consumerPath = Path.Combine(directory, "SharpLink.ReferencedCodecConsumer.dll"); + var provider1 = firstContext.LoadFromAssemblyPath(providerPath); + var provider2 = secondContext.LoadFromAssemblyPath(providerPath); + var consumer2 = secondContext.LoadFromAssemblyPath(consumerPath); + + Ensure(provider1.FullName == provider2.FullName && !ReferenceEquals(provider1, provider2), + "test setup must load two distinct provider generations with the same Assembly.FullName"); + var consumerManifestType = consumer2.GetType( + "SharpLink.ReferencedCodecConsumer.ConsumerManifest", + throwOnError: true)!; + var consumerManifest = (ISharpLinkReferencedCodecDependencyManifest)Activator.CreateInstance( + consumerManifestType)!; + var typedDependency = consumerManifest.ReferencedCodecDependencies.Single(); + Ensure(ReferenceEquals(typedDependency.TargetType.Assembly, provider2), + "consumer generation 2 must retain the exact provider generation selected by its runtime Type binding"); + + Ensure(harness.Client.RegisterAssembly(provider1).Succeeded, + "client registers generation-1 provider"); + Ensure(harness.Server.RegisterAssembly(provider1).Succeeded, + "server registers generation-1 provider"); + + var wrongClient = harness.Client.RegisterAssembly(consumer2); + Ensure(!wrongClient.Succeeded && + wrongClient.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && + wrongClient.Error.Message.Contains("exact bound runtime Type/assembly generation", StringComparison.Ordinal), + $"client must reject generation-2 consumer when only same-FullName generation-1 provider is registered: {wrongClient.Error}"); + var wrongServer = harness.Server.RegisterAssembly(consumer2); + Ensure(!wrongServer.Succeeded && + wrongServer.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && + wrongServer.Error.Message.Contains("exact bound runtime Type/assembly generation", StringComparison.Ordinal), + $"server must reject generation-2 consumer when only same-FullName generation-1 provider is registered: {wrongServer.Error}"); + + Ensure((await harness.Client.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases generation-1 provider after rejected consumer"); + Ensure((await harness.Server.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases generation-1 provider after rejected consumer"); + + Ensure(harness.Client.RegisterAssembly(provider2).Succeeded, + "client registers exact generation-2 provider"); + Ensure(harness.Server.RegisterAssembly(provider2).Succeeded, + "server registers exact generation-2 provider"); + Ensure(harness.Client.RegisterAssembly(consumer2).Succeeded, + "client accepts consumer with exact bound provider generation and expected CodecHash"); + Ensure(harness.Server.RegisterAssembly(consumer2).Succeeded, + "server accepts consumer with exact bound provider generation and expected CodecHash"); + + try + { + _ = await harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)); + throw new Exception("assert failed: client must reject provider unregister while exact typed consumer depends on it"); + } + catch (InvalidOperationException exception) + { + Ensure(exception.Message.Contains("depends on it", StringComparison.Ordinal), + "client reverse dependency check uses exact provider Assembly generation"); + } + try + { + _ = await harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)); + throw new Exception("assert failed: server must reject provider unregister while exact typed consumer depends on it"); + } + catch (InvalidOperationException exception) + { + Ensure(exception.Message.Contains("depends on it", StringComparison.Ordinal), + "server reverse dependency check uses exact provider Assembly generation"); + } + + Ensure((await harness.Client.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases typed consumer before provider"); + Ensure((await harness.Server.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases typed consumer before provider"); + Ensure((await harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases exact provider after dependant removal"); + Ensure((await harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases exact provider after dependant removal"); + } + finally + { + firstContext.Unload(); + secondContext.Unload(); + } + } + [Test] [NotInParallel] public async Task MultiClusterDynamicRegistrationShouldRouteToOneExplicitSlot() @@ -2007,6 +2101,22 @@ private static string GetPluginOutputDirectory() } } + private static string GetReferencedCodecOutputDirectory() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Sharplink.slnx"))) + directory = directory.Parent; + if (directory is null) + throw new DirectoryNotFoundException("SharpLink workspace root was not found."); + return Path.Combine( + directory.FullName, + "test", + "SharpLink.ReferencedCodecConsumer", + "bin", + "Release", + "net10.0"); + } + private sealed class PluginLoadContext(string name, string directory) : AssemblyLoadContext(name, isCollectible: true) { diff --git a/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj b/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj index 8a7dcac5c..c599abb9a 100644 --- a/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj +++ b/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj @@ -16,6 +16,8 @@ + + diff --git a/test/SharpLink.ReferencedCodecConsumer/ReferencedCodecConsumer.cs b/test/SharpLink.ReferencedCodecConsumer/ReferencedCodecConsumer.cs new file mode 100644 index 000000000..4c3ed572a --- /dev/null +++ b/test/SharpLink.ReferencedCodecConsumer/ReferencedCodecConsumer.cs @@ -0,0 +1,36 @@ +using SharpLink.Abstractions; +using SharpLink.ReferencedCodecProvider; + +[assembly: SharpLinkGeneratedAssemblyManifestAttribute( + typeof(SharpLink.ReferencedCodecConsumer.ConsumerManifest), + SharpLinkGeneratedManifestVersions.Api, + SharpLinkGeneratedManifestVersions.Protocol, + "test", + SharpLinkGeneratedManifestVersions.AbiIdentity)] + +namespace SharpLink.ReferencedCodecConsumer; + +public sealed class ConsumerManifest : ISharpLinkGeneratedAssemblyManifest, ISharpLinkReferencedCodecDependencyManifest +{ + private static readonly IReadOnlyList Referenced = + new SharpLinkReferencedCodecDependency[] + { + new( + typeof(Payload), + new RpcHash128(ProviderManifest.CodecHashHigh, ProviderManifest.CodecHashLow)) + }; + + public ConsumerManifest() { } + + public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; + public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; + public string GeneratorVersion => "test"; + public System.Reflection.Assembly OwnerAssembly => typeof(ConsumerManifest).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x1234567890ABCDEFUL, 0xFEDCBA0987654321UL); + public string CompileTimeDescriptor => "referenced-codec-consumer"; + public IReadOnlyList Contracts => []; + public IReadOnlyList Services => []; + public IReadOnlyList Codecs => []; + public IReadOnlyList Dependencies => []; + public IReadOnlyList ReferencedCodecDependencies => Referenced; +} diff --git a/test/SharpLink.ReferencedCodecConsumer/SharpLink.ReferencedCodecConsumer.csproj b/test/SharpLink.ReferencedCodecConsumer/SharpLink.ReferencedCodecConsumer.csproj new file mode 100644 index 000000000..0d4d2d3b8 --- /dev/null +++ b/test/SharpLink.ReferencedCodecConsumer/SharpLink.ReferencedCodecConsumer.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/test/SharpLink.ReferencedCodecProvider/ReferencedCodecProvider.cs b/test/SharpLink.ReferencedCodecProvider/ReferencedCodecProvider.cs new file mode 100644 index 000000000..9c2ba479a --- /dev/null +++ b/test/SharpLink.ReferencedCodecProvider/ReferencedCodecProvider.cs @@ -0,0 +1,66 @@ +using System.Buffers; +using SharpLink.Abstractions; + +[assembly: SharpLinkGeneratedAssemblyManifestAttribute( + typeof(SharpLink.ReferencedCodecProvider.ProviderManifest), + SharpLinkGeneratedManifestVersions.Api, + SharpLinkGeneratedManifestVersions.Protocol, + "test", + SharpLinkGeneratedManifestVersions.AbiIdentity)] + +namespace SharpLink.ReferencedCodecProvider; + +public readonly record struct Payload(int Value); + +public sealed class ProviderManifest : ISharpLinkGeneratedAssemblyManifest +{ + public const ulong CodecHashHigh = 0x1122334455667788UL; + public const ulong CodecHashLow = 0x8877665544332211UL; + + private static readonly IReadOnlyList Factories = + new IRpcGeneratedCodecFactory[] { new PayloadFactory() }; + + public ProviderManifest() { } + + public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; + public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; + public string GeneratorVersion => "test"; + public System.Reflection.Assembly OwnerAssembly => typeof(ProviderManifest).Assembly; + public RpcHash128 RpcAssemblyHash => new(0xAABBCCDDEEFF0011UL, 0x1100FFEEDDCCBBAAUL); + public string CompileTimeDescriptor => "referenced-codec-provider"; + public IReadOnlyList Contracts => []; + public IReadOnlyList Services => []; + public IReadOnlyList Codecs => Factories; + public IReadOnlyList Dependencies => []; + + private sealed class PayloadFactory : IRpcGeneratedCodecFactory + { + public Type TargetType => typeof(Payload); + public RpcHash128 CodecHash => new(CodecHashHigh, CodecHashLow); + public string? AdapterId => null; + public IRpcCodecAdapter? Adapter => null; + public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) + { + _ = provider; + if (adapterScope is not null) + throw new ArgumentException("Native Codec factory does not accept an adapter scope.", nameof(adapterScope)); + return new PayloadCodec(); + } + public bool IsCompatibleCodec(IRpcCodec codec) => codec is IRpcCodec; + } + + private sealed class PayloadCodec : IRpcCodec + { + public void Serialize(in Payload value, IBufferWriter buffer) + { + _ = value; + _ = buffer; + } + + public Payload Deserialize(in ReadOnlySequence buffer) + { + _ = buffer; + return default; + } + } +} diff --git a/test/SharpLink.ReferencedCodecProvider/SharpLink.ReferencedCodecProvider.csproj b/test/SharpLink.ReferencedCodecProvider/SharpLink.ReferencedCodecProvider.csproj new file mode 100644 index 000000000..e9b7a1068 --- /dev/null +++ b/test/SharpLink.ReferencedCodecProvider/SharpLink.ReferencedCodecProvider.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + + + + + + diff --git a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs index 522fd0af1..c37b03d5c 100644 --- a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs +++ b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs @@ -1047,6 +1047,71 @@ public void UnchangedCodecShouldRefreshAcrossAnUnrelatedSnapshotRemoval() context.ReleaseGeneratedManifest(removedRegistration); } + [Test] + public void StaticBuildShouldRejectReferencedCodecHashMismatchBeforePublication() + { + var actualHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); + var expectedHash = new RpcHash128(0x3333333333333333UL, 0x4444444444444444UL); + var provider = new TestManifest( + "referenced-provider", + new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), actualHash)); + var consumer = new ReferencedCodecManifest( + "referenced-consumer", + [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); + + var failure = CaptureFailure(() => + { + using var context = CreateRuntimeBuilder().Build( + new ISharpLinkGeneratedAssemblyManifest[] { provider, consumer }); + }); + + Ensure(failure is InvalidOperationException && + failure.Message.Contains("expected CodecHash", StringComparison.Ordinal), + "static bootstrap must reject a referenced Codec hash mismatch before publication"); + } + + [Test] + public void DynamicPrepareShouldRejectReferencedCodecHashMismatch() + { + var actualHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); + var expectedHash = new RpcHash128(0x3333333333333333UL, 0x4444444444444444UL); + var provider = new TestManifest( + "referenced-provider", + new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), actualHash)); + using var context = CreateRuntimeBuilder().Build( + new ISharpLinkGeneratedAssemblyManifest[] { provider }); + var consumer = new ReferencedCodecManifest( + "referenced-consumer", + [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); + + var failure = CaptureFailure(() => context.PrepareGeneratedManifest(consumer)); + + Ensure(failure is InvalidOperationException && + failure.Message.Contains("expected CodecHash", StringComparison.Ordinal), + "dynamic manifest preparation must reject a referenced Codec hash mismatch"); + } + + [Test] + public void CandidatePublicationShouldRejectRemovingReferencedCodecDependency() + { + var expectedHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); + var provider = new TestManifest( + "referenced-provider", + new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), expectedHash)); + var consumer = new ReferencedCodecManifest( + "referenced-consumer", + [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); + using var context = CreateRuntimeBuilder().Build( + new ISharpLinkGeneratedAssemblyManifest[] { provider, consumer }); + + var failure = CaptureFailure(() => context.PublishGeneratedCodecs( + new Dictionary())); + + Ensure(failure is InvalidOperationException && + failure.Message.Contains("no generated Codec is registered for that exact Type", StringComparison.Ordinal), + "candidate publication must preserve reverse referenced Codec dependants"); + } + [Test] public void DisposedContextShouldRejectCodecResolution() { @@ -1358,6 +1423,19 @@ public void Serialize(in ThirdAdapterValue value, IBufferWriter buffer) public ThirdAdapterValue Deserialize(in ReadOnlySequence buffer) => new(); } + private sealed class HashedNativeFactory(IRpcCodec codec, RpcHash128 codecHash) : IRpcGeneratedCodecFactory + { + public Type TargetType => typeof(T); + public RpcHash128 CodecHash => codecHash; + public string? AdapterId => null; + public IRpcCodecAdapter? Adapter => null; + public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) + => adapterScope is null + ? codec + : throw new ArgumentException("Native factory does not accept an Adapter Scope.", nameof(adapterScope)); + public bool IsCompatibleCodec(IRpcCodec candidate) => candidate is IRpcCodec; + } + private sealed class FixedNativeFactory(IRpcCodec codec) : IRpcGeneratedCodecFactory { public Type TargetType => typeof(T); @@ -1465,6 +1543,25 @@ private sealed class AdapterManifest(AdapterCounters counters, bool includeSecon public IReadOnlyList Dependencies => []; } + private sealed class ReferencedCodecManifest( + string descriptor, + SharpLinkReferencedCodecDependency[] referencedCodecDependencies) + : ISharpLinkGeneratedAssemblyManifest, ISharpLinkReferencedCodecDependencyManifest + { + public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; + public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; + public string GeneratorVersion => "test"; + public Assembly OwnerAssembly => typeof(ReferencedCodecManifest).Assembly; + public RpcHash128 RpcAssemblyHash => TestAssemblyHash; + public string CompileTimeDescriptor => descriptor; + public IReadOnlyList Contracts => []; + public IReadOnlyList Services => []; + public IReadOnlyList Codecs => []; + public IReadOnlyList Dependencies => []; + public IReadOnlyList ReferencedCodecDependencies { get; } = + referencedCodecDependencies; + } + private sealed class TestManifest(string descriptor, params IRpcGeneratedCodecFactory[] codecs) : ISharpLinkGeneratedAssemblyManifest { From 3e60fbb665cf0a69a5e0fe884470d53f3a6598ff Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:09:08 +0800 Subject: [PATCH 343/399] fix: bind generated dependencies to runtime assemblies --- .../SharpLinkClient.AssemblyRegistration.cs | 32 ++- .../Codec/RpcManifestCodecProvider.cs | 55 +++- .../SharpLinkGeneratedDependencyBinding.cs | 62 +++++ .../SharpLinkRuntimeContext.cs | 6 +- .../SharpLinkServer.AssemblyRegistration.cs | 32 ++- ...emblyDependencyIdentityIntegrationTests.cs | 240 ++++++++++++++++++ .../RuntimeAssemblyIntegrationTests.cs | 112 +------- .../SharpLink.IntegrationTests.csproj | 1 + .../ModuleDependencyConsumer.cs | 28 ++ .../SharpLink.ModuleDependencyConsumer.csproj | 12 + .../SharpLinkClientContractDependencyTests.cs | 8 +- ...pLinkRuntimeContextReferencedCodecTests.cs | 133 ++++++++++ .../Runtime/SharpLinkRuntimeContextTests.cs | 102 +------- 13 files changed, 573 insertions(+), 250 deletions(-) create mode 100644 src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs create mode 100644 test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs create mode 100644 test/SharpLink.ModuleDependencyConsumer/ModuleDependencyConsumer.cs create mode 100644 test/SharpLink.ModuleDependencyConsumer/SharpLink.ModuleDependencyConsumer.csproj create mode 100644 test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextReferencedCodecTests.cs diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs index f463aa285..c9e44496b 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs @@ -81,7 +81,7 @@ public SharpLinkAssemblyRegistrationResult RegisterAssembly(Assembly assembly) rollbackError = dependencyError; return SharpLinkAssemblyRegistrationResult.Failure(dependencyError); } - _runtimeContext.PublishGeneratedCodecs(candidate.Codecs); + _runtimeContext.PublishGeneratedCodecs(candidate.Codecs, codecRegistration); _runtimeContext.AdoptGeneratedManifest(codecRegistration); Volatile.Write(ref _proxies, candidate.Proxies); _dynamicModules.Add(assembly, module); @@ -241,9 +241,9 @@ public ValueTask ReplaceAssemblyAsync( drainCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); drainOperation = drainCompletion.Task; + _runtimeContext.PublishGeneratedCodecs(candidate.Codecs, codecRegistration); _dynamicModules.Add(newAssembly, newModule); _unregisterOperations.Add(oldAssembly, drainOperation); - _runtimeContext.PublishGeneratedCodecs(candidate.Codecs); _runtimeContext.AdoptGeneratedManifest(codecRegistration); Volatile.Write(ref _proxies, candidate.Proxies); _registryGeneration++; @@ -468,11 +468,15 @@ private static bool ManifestDependsOn( ISharpLinkGeneratedAssemblyManifest manifest, Assembly ownerAssembly) { - var identity = ownerAssembly.FullName; - if (identity is not null && EnumerateManifestDependencies(manifest) - .Any(dependency => string.Equals(dependency, identity, StringComparison.Ordinal))) + foreach (var dependency in EnumerateManifestDependencies(manifest)) { - return true; + if (SharpLinkGeneratedDependencyBinding.Matches( + manifest.OwnerAssembly, + dependency, + ownerAssembly)) + { + return true; + } } if (manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest || @@ -491,22 +495,28 @@ dependency.TargetType is { } targetType && ISharpLinkGeneratedAssemblyManifest incoming, SharpLinkDynamicModule[] currentModules) { - var available = new HashSet(StringComparer.Ordinal); + var available = new HashSet(ReferenceEqualityComparer.Instance); for (var index = 0; index < _staticManifests.Count; index++) - available.Add(_staticManifests[index].OwnerAssembly.FullName ?? string.Empty); + available.Add(_staticManifests[index].OwnerAssembly); for (var index = 0; index < currentModules.Length; index++) { var module = currentModules[index]; if (module.State == SharpLinkDynamicModuleState.Running) - available.Add(module.Manifest.OwnerAssembly.FullName ?? string.Empty); + available.Add(module.Manifest.OwnerAssembly); } var self = incoming.OwnerAssembly.FullName; foreach (var dependency in EnumerateManifestDependencies(incoming).Distinct(StringComparer.Ordinal)) { - if (string.Equals(dependency, self, StringComparison.Ordinal) || available.Contains(dependency)) + var boundAssembly = SharpLinkGeneratedDependencyBinding.Resolve( + incoming.OwnerAssembly, + dependency); + if (ReferenceEquals(boundAssembly, incoming.OwnerAssembly) || + boundAssembly is not null && available.Contains(boundAssembly)) + { continue; + } return CreateError(SharpLinkAssemblyRegistrationErrorCode.MissingDependency, - $"Generated dependency '{dependency}' must be registered and running before '{self}'.", + $"Generated dependency '{dependency}' must resolve through '{self}' to the exact registered and running Assembly generation before registration.", incoming.OwnerAssembly, "Dependency"); } return null; diff --git a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs index 22265b31e..b2da25aab 100644 --- a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs +++ b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs @@ -88,12 +88,20 @@ public IRpcCodec GetCodec() if (_owner.Codecs.TryGetValue(targetType, out var ownerRegistration)) return ResolveOwned(targetType, ownerRegistration); + var referencedDependency = FindReferencedCodecDependency(targetType); if (_runtimeProvider is not null && _runtimeProvider.CreateGeneratedRegistrationSnapshot().TryGetValue(targetType, out var dependency) && - IsGeneratedDependencyAllowed(targetType, dependency)) + IsGeneratedDependencyAllowed(targetType, dependency, referencedDependency)) { return ResolveOwned(targetType, dependency); } + if (referencedDependency is not null) + { + throw new InvalidOperationException( + $"Contract assembly '{_owner.Manifest.OwnerAssembly.FullName}' requires referenced generated Codec " + + $"'{targetType.FullName}' from the exact bound runtime Type/assembly generation with CodecHash " + + $"'{referencedDependency.ExpectedCodecHash}', but that exact generated registration is not available."); + } if (BuiltinRpcCodecs.TryGet(targetType, out var builtin)) return Cast(builtin, targetType); @@ -119,30 +127,59 @@ private IRpcCodec ResolveOwned(Type targetType, RpcGeneratedCodecRegistrat private bool IsGeneratedDependencyAllowed( Type targetType, - RpcGeneratedCodecRegistration registration) + RpcGeneratedCodecRegistration registration, + SharpLinkReferencedCodecDependency? referencedDependency) { + if (referencedDependency is not null) + { + return ReferenceEquals(referencedDependency.TargetType, targetType) && + ReferenceEquals(registration.Owner.Manifest.OwnerAssembly, targetType.Assembly) && + registration.Factory.CodecHash == referencedDependency.ExpectedCodecHash; + } + if (ReferenceEquals(registration.Owner, _owner)) return true; var dependencyAssembly = registration.Owner.Manifest.OwnerAssembly; - var dependencyIdentity = dependencyAssembly.FullName; - if (dependencyIdentity is null || !IsTargetOwnedByDependency(targetType, dependencyAssembly)) + if (!IsTargetOwnedByDependency(targetType, dependencyAssembly)) return false; - if (ContainsIdentity(_owner.Manifest.ContractDependencies, dependencyIdentity)) + if (ContainsBoundDependency(_owner.Manifest.ContractDependencies, dependencyAssembly)) return true; // Compatibility for custom manifests that predate ContractDependencies and publish their - // whole generated-module closure through Dependencies. - return ContainsIdentity(_owner.Manifest.Dependencies, dependencyIdentity); + // whole generated-module closure through Dependencies. The string is only a CLR AssemblyRef + // locator; the actual permission is bound to the resolved Assembly object/generation. + return ContainsBoundDependency(_owner.Manifest.Dependencies, dependencyAssembly); + } + + private SharpLinkReferencedCodecDependency? FindReferencedCodecDependency(Type targetType) + { + if (_owner.Manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest) + return null; + var dependencies = dependencyManifest.ReferencedCodecDependencies; + for (var index = 0; index < dependencies.Count; index++) + { + var dependency = dependencies[index]; + if (dependency is not null && ReferenceEquals(dependency.TargetType, targetType)) + return dependency; + } + return null; } - private static bool ContainsIdentity(IReadOnlyList dependencies, string identity) + private bool ContainsBoundDependency( + IReadOnlyList dependencies, + Assembly dependencyAssembly) { for (var index = 0; index < dependencies.Count; index++) { - if (string.Equals(dependencies[index], identity, StringComparison.Ordinal)) + if (SharpLinkGeneratedDependencyBinding.Matches( + _owner.Manifest.OwnerAssembly, + dependencies[index], + dependencyAssembly)) + { return true; + } } return false; } diff --git a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs new file mode 100644 index 000000000..ccba7a2b3 --- /dev/null +++ b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace SharpLink.Runtime; + +internal static class SharpLinkGeneratedDependencyBinding +{ + internal static Assembly? Resolve(Assembly ownerAssembly, string dependencyIdentity) + { + ArgumentNullException.ThrowIfNull(ownerAssembly); + if (string.IsNullOrWhiteSpace(dependencyIdentity)) + return null; + if (string.Equals(ownerAssembly.FullName, dependencyIdentity, StringComparison.Ordinal)) + return ownerAssembly; + + AssemblyName requested; + try + { + requested = new AssemblyName(dependencyIdentity); + } + catch (Exception exception) when (exception is ArgumentException or FileLoadException) + { + return null; + } + + AssemblyName? reference = null; + foreach (var candidate in ownerAssembly.GetReferencedAssemblies()) + { + if (!AssemblyName.ReferenceMatchesDefinition(candidate, requested)) + continue; + reference = candidate; + break; + } + if (reference is null) + return null; + + var loadContext = AssemblyLoadContext.GetLoadContext(ownerAssembly); + if (loadContext is null) + return null; + foreach (var loaded in loadContext.Assemblies) + { + if (AssemblyName.ReferenceMatchesDefinition(loaded.GetName(), reference)) + return loaded; + } + + try + { + return loadContext.LoadFromAssemblyName(reference); + } + catch (Exception exception) when ( + exception is FileNotFoundException or FileLoadException or BadImageFormatException) + { + return null; + } + } + + internal static bool Matches( + Assembly ownerAssembly, + string dependencyIdentity, + Assembly candidateAssembly) + => ReferenceEquals(Resolve(ownerAssembly, dependencyIdentity), candidateAssembly); +} diff --git a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs index 8f449fc46..e3b3895b3 100644 --- a/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs +++ b/src/SharpLink.Runtime/SharpLinkRuntimeContext.cs @@ -139,7 +139,9 @@ private RpcGeneratedManifestRegistration PrepareGeneratedManifest( internal IReadOnlyDictionary CreateGeneratedCodecSnapshot() => ((RpcCodecProvider)Codecs).CreateGeneratedRegistrationSnapshot(); - internal void PublishGeneratedCodecs(IReadOnlyDictionary registrations) + internal void PublishGeneratedCodecs( + IReadOnlyDictionary registrations, + RpcGeneratedManifestRegistration? pendingRegistration = null) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); RpcGeneratedManifestRegistration[] manifests; @@ -149,6 +151,8 @@ internal void PublishGeneratedCodecs(IReadOnlyDictionary ReplaceAssemblyAsync( drainCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); drainOperation = drainCompletion.Task; + _runtimeContext.PublishGeneratedCodecs(candidate.Codecs, codecRegistration); _dynamicModules.Add(newAssembly, newModule); _detachedModuleServices.Add(oldModule, detachedServices); _unregisterOperations.Add(oldAssembly, drainOperation); - _runtimeContext.PublishGeneratedCodecs(candidate.Codecs); _runtimeContext.AdoptGeneratedManifest(codecRegistration); Volatile.Write(ref _services, candidate.Services); _registryGeneration++; @@ -598,11 +598,15 @@ private static bool ManifestDependsOn( ISharpLinkGeneratedAssemblyManifest manifest, Assembly ownerAssembly) { - var identity = ownerAssembly.FullName; - if (identity is not null && EnumerateManifestDependencies(manifest) - .Any(dependency => string.Equals(dependency, identity, StringComparison.Ordinal))) + foreach (var dependency in EnumerateManifestDependencies(manifest)) { - return true; + if (SharpLinkGeneratedDependencyBinding.Matches( + manifest.OwnerAssembly, + dependency, + ownerAssembly)) + { + return true; + } } if (manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest || @@ -649,23 +653,29 @@ dependency.TargetType is { } targetType && ISharpLinkGeneratedAssemblyManifest incoming, SharpLinkDynamicModule[] currentModules) { - var available = new HashSet(StringComparer.Ordinal); + var available = new HashSet(ReferenceEqualityComparer.Instance); for (var index = 0; index < _staticManifests.Count; index++) - available.Add(_staticManifests[index].OwnerAssembly.FullName ?? string.Empty); + available.Add(_staticManifests[index].OwnerAssembly); for (var index = 0; index < currentModules.Length; index++) { var module = currentModules[index]; if (module.State == SharpLinkDynamicModuleState.Running) - available.Add(module.Manifest.OwnerAssembly.FullName ?? string.Empty); + available.Add(module.Manifest.OwnerAssembly); } var self = incoming.OwnerAssembly.FullName; foreach (var dependency in EnumerateManifestDependencies(incoming).Distinct(StringComparer.Ordinal)) { - if (string.Equals(dependency, self, StringComparison.Ordinal) || available.Contains(dependency)) + var boundAssembly = SharpLinkGeneratedDependencyBinding.Resolve( + incoming.OwnerAssembly, + dependency); + if (ReferenceEquals(boundAssembly, incoming.OwnerAssembly) || + boundAssembly is not null && available.Contains(boundAssembly)) + { continue; + } return CreateError( SharpLinkAssemblyRegistrationErrorCode.MissingDependency, - $"Generated dependency '{dependency}' must be registered and running before '{self}'.", + $"Generated dependency '{dependency}' must resolve through '{self}' to the exact registered and running Assembly generation before registration.", incoming.OwnerAssembly, artifact: "Dependency"); } diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs new file mode 100644 index 000000000..1b1c1e73e --- /dev/null +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs @@ -0,0 +1,240 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace SharpLink.IntegrationTests; + +public sealed partial class RuntimeAssemblyIntegrationTests +{ + [Test] + [NotInParallel] + public async Task SameFullNameReferencedCodecDependencyShouldRequireExactGenerationOnClientAndServer() + { + await using var harness = await DynamicHarness.CreateAsync(); + var directory = GetProjectOutputDirectory("SharpLink.ReferencedCodecConsumer"); + var firstContext = new PluginLoadContext("referenced-codec-generation-1", directory); + var secondContext = new PluginLoadContext("referenced-codec-generation-2", directory); + try + { + var providerPath = Path.Combine(directory, "SharpLink.ReferencedCodecProvider.dll"); + var consumerPath = Path.Combine(directory, "SharpLink.ReferencedCodecConsumer.dll"); + var provider1 = firstContext.LoadFromAssemblyPath(providerPath); + var provider2 = secondContext.LoadFromAssemblyPath(providerPath); + var consumer2 = secondContext.LoadFromAssemblyPath(consumerPath); + + Ensure(provider1.FullName == provider2.FullName && !ReferenceEquals(provider1, provider2), + "test setup must load two distinct provider generations with the same Assembly.FullName"); + var consumerManifestType = consumer2.GetType( + "SharpLink.ReferencedCodecConsumer.ConsumerManifest", + throwOnError: true)!; + var consumerManifest = (ISharpLinkReferencedCodecDependencyManifest)Activator.CreateInstance( + consumerManifestType)!; + var typedDependency = consumerManifest.ReferencedCodecDependencies.Single(); + Ensure(ReferenceEquals(typedDependency.TargetType.Assembly, provider2), + "consumer generation 2 must retain the exact provider generation selected by its runtime Type binding"); + + Ensure(harness.Client.RegisterAssembly(provider1).Succeeded, + "client registers generation-1 provider"); + Ensure(harness.Server.RegisterAssembly(provider1).Succeeded, + "server registers generation-1 provider"); + + var wrongClient = harness.Client.RegisterAssembly(consumer2); + Ensure(!wrongClient.Succeeded && + wrongClient.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && + wrongClient.Error.Message.Contains("exact bound runtime Type/assembly generation", StringComparison.Ordinal), + $"client must reject generation-2 consumer when only same-FullName generation-1 provider is registered: {wrongClient.Error}"); + var wrongServer = harness.Server.RegisterAssembly(consumer2); + Ensure(!wrongServer.Succeeded && + wrongServer.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && + wrongServer.Error.Message.Contains("exact bound runtime Type/assembly generation", StringComparison.Ordinal), + $"server must reject generation-2 consumer when only same-FullName generation-1 provider is registered: {wrongServer.Error}"); + + Ensure((await harness.Client.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases generation-1 provider after rejected consumer"); + Ensure((await harness.Server.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases generation-1 provider after rejected consumer"); + + Ensure(harness.Client.RegisterAssembly(provider2).Succeeded, + "client registers exact generation-2 provider"); + Ensure(harness.Server.RegisterAssembly(provider2).Succeeded, + "server registers exact generation-2 provider"); + + var clientReplacement = await harness.Client.ReplaceAssemblyAsync( + provider2, consumer2, TimeSpan.FromSeconds(2)); + Ensure(!clientReplacement.Succeeded && + clientReplacement.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && + clientReplacement.Error.Message.Contains("exact Type", StringComparison.Ordinal), + $"client replacement must validate the pending consumer against the final candidate snapshot: {clientReplacement.Error}"); + var serverReplacement = await harness.Server.ReplaceAssemblyAsync( + provider2, consumer2, TimeSpan.FromSeconds(2)); + Ensure(!serverReplacement.Succeeded && + serverReplacement.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && + serverReplacement.Error.Message.Contains("exact Type", StringComparison.Ordinal), + $"server replacement must validate the pending consumer against the final candidate snapshot: {serverReplacement.Error}"); + + Ensure(harness.Client.RegisterAssembly(consumer2).Succeeded, + "client accepts consumer with exact bound provider generation and expected CodecHash"); + Ensure(harness.Server.RegisterAssembly(consumer2).Succeeded, + "server accepts consumer with exact bound provider generation and expected CodecHash"); + + var clientCodec = ResolveManifestCodec(harness.Client, consumer2, typedDependency.TargetType); + var serverCodec = ResolveManifestCodec(harness.Server, consumer2, typedDependency.TargetType); + Ensure(ReferenceEquals(clientCodec.GetType().Assembly, provider2), + "client contract provider resolves the exact referenced generated Codec rather than falling back"); + Ensure(ReferenceEquals(serverCodec.GetType().Assembly, provider2), + "server contract provider resolves the exact referenced generated Codec rather than falling back"); + + try + { + _ = await harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)); + throw new Exception("assert failed: client must reject provider unregister while exact typed consumer depends on it"); + } + catch (InvalidOperationException exception) + { + Ensure(exception.Message.Contains("depends on it", StringComparison.Ordinal), + "client reverse dependency check uses exact provider Assembly generation"); + } + try + { + _ = await harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)); + throw new Exception("assert failed: server must reject provider unregister while exact typed consumer depends on it"); + } + catch (InvalidOperationException exception) + { + Ensure(exception.Message.Contains("depends on it", StringComparison.Ordinal), + "server reverse dependency check uses exact provider Assembly generation"); + } + + Ensure((await harness.Client.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases typed consumer before provider"); + Ensure((await harness.Server.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases typed consumer before provider"); + Ensure((await harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases exact provider after dependant removal"); + Ensure((await harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases exact provider after dependant removal"); + } + finally + { + firstContext.Unload(); + secondContext.Unload(); + } + } + + [Test] + [NotInParallel] + public async Task SameFullNameDeclaredModuleDependencyShouldRequireExactBoundGenerationOnClientAndServer() + { + await using var harness = await DynamicHarness.CreateAsync(); + var directory = GetProjectOutputDirectory("SharpLink.ModuleDependencyConsumer"); + var firstContext = new PluginLoadContext("module-dependency-generation-1", directory); + var secondContext = new PluginLoadContext("module-dependency-generation-2", directory); + try + { + var providerPath = Path.Combine(directory, "SharpLink.ReferencedCodecProvider.dll"); + var consumerPath = Path.Combine(directory, "SharpLink.ModuleDependencyConsumer.dll"); + var provider1 = firstContext.LoadFromAssemblyPath(providerPath); + var provider2 = secondContext.LoadFromAssemblyPath(providerPath); + var consumer2 = secondContext.LoadFromAssemblyPath(consumerPath); + + Ensure(provider1.FullName == provider2.FullName && !ReferenceEquals(provider1, provider2), + "module dependency setup must load distinct same-FullName provider generations"); + Ensure(harness.Client.RegisterAssembly(provider1).Succeeded, + "client registers only the wrong provider generation"); + Ensure(harness.Server.RegisterAssembly(provider1).Succeeded, + "server registers only the wrong provider generation"); + + var wrongClient = harness.Client.RegisterAssembly(consumer2); + Ensure(!wrongClient.Succeeded && + wrongClient.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.MissingDependency, + $"client must not satisfy a CLR-bound module dependency with another same-FullName generation: {wrongClient.Error}"); + var wrongServer = harness.Server.RegisterAssembly(consumer2); + Ensure(!wrongServer.Succeeded && + wrongServer.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.MissingDependency, + $"server must not satisfy a CLR-bound module dependency with another same-FullName generation: {wrongServer.Error}"); + + Ensure((await harness.Client.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client removes wrong provider generation"); + Ensure((await harness.Server.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server removes wrong provider generation"); + Ensure(harness.Client.RegisterAssembly(provider2).Succeeded, + "client registers exact bound provider generation"); + Ensure(harness.Server.RegisterAssembly(provider2).Succeeded, + "server registers exact bound provider generation"); + Ensure(harness.Client.RegisterAssembly(consumer2).Succeeded, + "client accepts ordinary module dependency with exact bound provider generation"); + Ensure(harness.Server.RegisterAssembly(consumer2).Succeeded, + "server accepts ordinary module dependency with exact bound provider generation"); + + await EnsureDependencyPreventsUnregisterAsync( + () => harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)), + "client ordinary module dependency reverse check"); + await EnsureDependencyPreventsUnregisterAsync( + () => harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)), + "server ordinary module dependency reverse check"); + + Ensure((await harness.Client.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases ordinary dependant before provider"); + Ensure((await harness.Server.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases ordinary dependant before provider"); + Ensure((await harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases exact ordinary dependency provider"); + Ensure((await harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases exact ordinary dependency provider"); + } + finally + { + firstContext.Unload(); + secondContext.Unload(); + } + } + + private static async Task EnsureDependencyPreventsUnregisterAsync( + Func> unregister, + string message) + { + try + { + _ = await unregister(); + throw new Exception($"assert failed: {message}"); + } + catch (InvalidOperationException exception) + { + Ensure(exception.Message.Contains("depends on it", StringComparison.Ordinal), message); + } + } + + private static object ResolveManifestCodec(object endpoint, Assembly ownerAssembly, Type targetType) + { + var runtimeContext = GetEndpointRuntimeContext(endpoint); + var provider = RpcGeneratedCodecResolver.GetProvider(runtimeContext, ownerAssembly); + var method = typeof(IRpcCodecProvider).GetMethod(nameof(IRpcCodecProvider.GetCodec)) + ?? throw new MissingMethodException(nameof(IRpcCodecProvider), nameof(IRpcCodecProvider.GetCodec)); + return method.MakeGenericMethod(targetType).Invoke(provider, null) + ?? throw new InvalidOperationException($"Codec resolution for '{targetType}' returned null."); + } + + private static IRpcRuntimeContext GetEndpointRuntimeContext(object endpoint) + { + if (endpoint is IRpcChannel channel) + return channel.RuntimeContext; + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + return endpoint.GetType().GetField("_runtimeContext", flags)?.GetValue(endpoint) as IRpcRuntimeContext + ?? throw new InvalidOperationException($"Runtime context was not available from '{endpoint.GetType()}'."); + } + + private static string GetProjectOutputDirectory(string projectName) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Sharplink.slnx"))) + directory = directory.Parent; + if (directory is null) + throw new DirectoryNotFoundException("SharpLink workspace root was not found."); + return Path.Combine( + directory.FullName, + "test", + projectName, + "bin", + "Release", + "net10.0"); + } +} diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs index 1fc717f6e..4221f8d1c 100644 --- a/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyIntegrationTests.cs @@ -5,102 +5,8 @@ namespace SharpLink.IntegrationTests; -public sealed class RuntimeAssemblyIntegrationTests +public sealed partial class RuntimeAssemblyIntegrationTests { - [Test] - [NotInParallel] - public async Task SameFullNameReferencedCodecDependencyShouldRequireExactGenerationOnClientAndServer() - { - await using var harness = await DynamicHarness.CreateAsync(); - var directory = GetReferencedCodecOutputDirectory(); - var firstContext = new PluginLoadContext("referenced-codec-generation-1", directory); - var secondContext = new PluginLoadContext("referenced-codec-generation-2", directory); - try - { - var providerPath = Path.Combine(directory, "SharpLink.ReferencedCodecProvider.dll"); - var consumerPath = Path.Combine(directory, "SharpLink.ReferencedCodecConsumer.dll"); - var provider1 = firstContext.LoadFromAssemblyPath(providerPath); - var provider2 = secondContext.LoadFromAssemblyPath(providerPath); - var consumer2 = secondContext.LoadFromAssemblyPath(consumerPath); - - Ensure(provider1.FullName == provider2.FullName && !ReferenceEquals(provider1, provider2), - "test setup must load two distinct provider generations with the same Assembly.FullName"); - var consumerManifestType = consumer2.GetType( - "SharpLink.ReferencedCodecConsumer.ConsumerManifest", - throwOnError: true)!; - var consumerManifest = (ISharpLinkReferencedCodecDependencyManifest)Activator.CreateInstance( - consumerManifestType)!; - var typedDependency = consumerManifest.ReferencedCodecDependencies.Single(); - Ensure(ReferenceEquals(typedDependency.TargetType.Assembly, provider2), - "consumer generation 2 must retain the exact provider generation selected by its runtime Type binding"); - - Ensure(harness.Client.RegisterAssembly(provider1).Succeeded, - "client registers generation-1 provider"); - Ensure(harness.Server.RegisterAssembly(provider1).Succeeded, - "server registers generation-1 provider"); - - var wrongClient = harness.Client.RegisterAssembly(consumer2); - Ensure(!wrongClient.Succeeded && - wrongClient.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && - wrongClient.Error.Message.Contains("exact bound runtime Type/assembly generation", StringComparison.Ordinal), - $"client must reject generation-2 consumer when only same-FullName generation-1 provider is registered: {wrongClient.Error}"); - var wrongServer = harness.Server.RegisterAssembly(consumer2); - Ensure(!wrongServer.Succeeded && - wrongServer.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && - wrongServer.Error.Message.Contains("exact bound runtime Type/assembly generation", StringComparison.Ordinal), - $"server must reject generation-2 consumer when only same-FullName generation-1 provider is registered: {wrongServer.Error}"); - - Ensure((await harness.Client.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, - "client releases generation-1 provider after rejected consumer"); - Ensure((await harness.Server.UnregisterAssemblyAsync(provider1, TimeSpan.FromSeconds(2))).ReferencesReleased, - "server releases generation-1 provider after rejected consumer"); - - Ensure(harness.Client.RegisterAssembly(provider2).Succeeded, - "client registers exact generation-2 provider"); - Ensure(harness.Server.RegisterAssembly(provider2).Succeeded, - "server registers exact generation-2 provider"); - Ensure(harness.Client.RegisterAssembly(consumer2).Succeeded, - "client accepts consumer with exact bound provider generation and expected CodecHash"); - Ensure(harness.Server.RegisterAssembly(consumer2).Succeeded, - "server accepts consumer with exact bound provider generation and expected CodecHash"); - - try - { - _ = await harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)); - throw new Exception("assert failed: client must reject provider unregister while exact typed consumer depends on it"); - } - catch (InvalidOperationException exception) - { - Ensure(exception.Message.Contains("depends on it", StringComparison.Ordinal), - "client reverse dependency check uses exact provider Assembly generation"); - } - try - { - _ = await harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2)); - throw new Exception("assert failed: server must reject provider unregister while exact typed consumer depends on it"); - } - catch (InvalidOperationException exception) - { - Ensure(exception.Message.Contains("depends on it", StringComparison.Ordinal), - "server reverse dependency check uses exact provider Assembly generation"); - } - - Ensure((await harness.Client.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, - "client releases typed consumer before provider"); - Ensure((await harness.Server.UnregisterAssemblyAsync(consumer2, TimeSpan.FromSeconds(2))).ReferencesReleased, - "server releases typed consumer before provider"); - Ensure((await harness.Client.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, - "client releases exact provider after dependant removal"); - Ensure((await harness.Server.UnregisterAssemblyAsync(provider2, TimeSpan.FromSeconds(2))).ReferencesReleased, - "server releases exact provider after dependant removal"); - } - finally - { - firstContext.Unload(); - secondContext.Unload(); - } - } - [Test] [NotInParallel] public async Task MultiClusterDynamicRegistrationShouldRouteToOneExplicitSlot() @@ -2101,22 +2007,6 @@ private static string GetPluginOutputDirectory() } } - private static string GetReferencedCodecOutputDirectory() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Sharplink.slnx"))) - directory = directory.Parent; - if (directory is null) - throw new DirectoryNotFoundException("SharpLink workspace root was not found."); - return Path.Combine( - directory.FullName, - "test", - "SharpLink.ReferencedCodecConsumer", - "bin", - "Release", - "net10.0"); - } - private sealed class PluginLoadContext(string name, string directory) : AssemblyLoadContext(name, isCollectible: true) { diff --git a/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj b/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj index c599abb9a..6f974492d 100644 --- a/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj +++ b/test/SharpLink.IntegrationTests/SharpLink.IntegrationTests.csproj @@ -18,6 +18,7 @@ + diff --git a/test/SharpLink.ModuleDependencyConsumer/ModuleDependencyConsumer.cs b/test/SharpLink.ModuleDependencyConsumer/ModuleDependencyConsumer.cs new file mode 100644 index 000000000..a700bd267 --- /dev/null +++ b/test/SharpLink.ModuleDependencyConsumer/ModuleDependencyConsumer.cs @@ -0,0 +1,28 @@ +using SharpLink.Abstractions; +using SharpLink.ReferencedCodecProvider; + +[assembly: SharpLinkGeneratedAssemblyManifestAttribute( + typeof(SharpLink.ModuleDependencyConsumer.ModuleDependencyManifest), + SharpLinkGeneratedManifestVersions.Api, + SharpLinkGeneratedManifestVersions.Protocol, + "test", + SharpLinkGeneratedManifestVersions.AbiIdentity)] + +namespace SharpLink.ModuleDependencyConsumer; + +public sealed class ModuleDependencyManifest : ISharpLinkGeneratedAssemblyManifest +{ + private static readonly IReadOnlyList ModuleDependencies = + new[] { typeof(Payload).Assembly.FullName! }; + + public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; + public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; + public string GeneratorVersion => "test"; + public System.Reflection.Assembly OwnerAssembly => typeof(ModuleDependencyManifest).Assembly; + public RpcHash128 RpcAssemblyHash => new(0x4d6f64756c654465UL, 0x70656e64656e6379UL); + public string CompileTimeDescriptor => "module-dependency-consumer"; + public IReadOnlyList Contracts => []; + public IReadOnlyList Services => []; + public IReadOnlyList Codecs => []; + public IReadOnlyList Dependencies => ModuleDependencies; +} diff --git a/test/SharpLink.ModuleDependencyConsumer/SharpLink.ModuleDependencyConsumer.csproj b/test/SharpLink.ModuleDependencyConsumer/SharpLink.ModuleDependencyConsumer.csproj new file mode 100644 index 000000000..0d4d2d3b8 --- /dev/null +++ b/test/SharpLink.ModuleDependencyConsumer/SharpLink.ModuleDependencyConsumer.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs b/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs index 3c28a0b60..bd83c6239 100644 --- a/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs +++ b/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs @@ -63,12 +63,8 @@ public async Task ClientUnregisterShouldProtectContractDependencies() BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Client unregister dependency guard was not found."); - var dependencyAssembly = AssemblyBuilder.DefineDynamicAssembly( - new AssemblyName("SharpLink.ContractDependency.B." + Guid.NewGuid().ToString("N")), - AssemblyBuilderAccess.Run); - var dependantAssembly = AssemblyBuilder.DefineDynamicAssembly( - new AssemblyName("SharpLink.ContractDependency.A." + Guid.NewGuid().ToString("N")), - AssemblyBuilderAccess.Run); + var dependencyAssembly = typeof(IService).Assembly; + var dependantAssembly = client.GetType().Assembly; var dependencyManifest = new TestManifest(dependencyAssembly, []); var dependantManifest = new TestManifest( dependantAssembly, diff --git a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextReferencedCodecTests.cs b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextReferencedCodecTests.cs new file mode 100644 index 000000000..39d028878 --- /dev/null +++ b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextReferencedCodecTests.cs @@ -0,0 +1,133 @@ +using System.Reflection; + +namespace SharpLink.UnitTests.Runtime; + +public partial class SharpLinkRuntimeContextTests +{ + [Test] + public void StaticBuildShouldRejectReferencedCodecHashMismatchBeforePublication() + { + var actualHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); + var expectedHash = new RpcHash128(0x3333333333333333UL, 0x4444444444444444UL); + var provider = new TestManifest( + "referenced-provider", + new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), actualHash)); + var consumer = new ReferencedCodecManifest( + "referenced-consumer", + [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); + + var failure = CaptureFailure(() => + { + using var context = CreateRuntimeBuilder().Build( + new ISharpLinkGeneratedAssemblyManifest[] { provider, consumer }); + }); + + Ensure(failure is InvalidOperationException && + failure.Message.Contains("expected CodecHash", StringComparison.Ordinal), + "static bootstrap must reject a referenced Codec hash mismatch before publication"); + } + + [Test] + public void DynamicPrepareShouldRejectReferencedCodecHashMismatch() + { + var actualHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); + var expectedHash = new RpcHash128(0x3333333333333333UL, 0x4444444444444444UL); + var provider = new TestManifest( + "referenced-provider", + new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), actualHash)); + using var context = CreateRuntimeBuilder().Build( + new ISharpLinkGeneratedAssemblyManifest[] { provider }); + var consumer = new ReferencedCodecManifest( + "referenced-consumer", + [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); + + var failure = CaptureFailure(() => context.PrepareGeneratedManifest(consumer)); + + Ensure(failure is InvalidOperationException && + failure.Message.Contains("expected CodecHash", StringComparison.Ordinal), + "dynamic manifest preparation must reject a referenced Codec hash mismatch"); + } + + [Test] + public void CandidatePublicationShouldRejectRemovingReferencedCodecDependency() + { + var expectedHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); + var provider = new TestManifest( + "referenced-provider", + new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), expectedHash)); + var consumer = new ReferencedCodecManifest( + "referenced-consumer", + [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); + using var context = CreateRuntimeBuilder().Build( + new ISharpLinkGeneratedAssemblyManifest[] { provider, consumer }); + + var failure = CaptureFailure(() => context.PublishGeneratedCodecs( + new Dictionary())); + + Ensure(failure is InvalidOperationException && + failure.Message.Contains("no generated Codec is registered for that exact Type", StringComparison.Ordinal), + "candidate publication must preserve reverse referenced Codec dependants"); + } + + [Test] + public void PendingManifestShouldBeValidatedAgainstFinalCandidateSnapshot() + { + var expectedHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); + var provider = new TestManifest( + "referenced-provider", + new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), expectedHash)); + using var context = CreateRuntimeBuilder().Build( + new ISharpLinkGeneratedAssemblyManifest[] { provider }); + var pending = context.PrepareGeneratedManifest(new ReferencedCodecManifest( + "pending-consumer", + [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)])); + try + { + var failure = CaptureFailure(() => context.PublishGeneratedCodecs( + new Dictionary(), pending)); + + Ensure(failure is InvalidOperationException && + failure.Message.Contains("no generated Codec is registered for that exact Type", StringComparison.Ordinal), + "an incoming not-yet-adopted manifest must be checked against the final candidate snapshot"); + } + finally + { + pending.Dispose(); + } + } + + [Test] + public void DisposedContextShouldRejectCodecResolution() + { + var context = CreateRuntimeBuilder().Build(includeGeneratedAssemblyCatalog: false); + context.Dispose(); + context.Dispose(); + try + { + _ = context.Codecs.GetCodec(); + throw new Exception("expected disposed Context to reject Codec resolution"); + } + catch (ObjectDisposedException) + { + } + } + + private sealed class ReferencedCodecManifest( + string descriptor, + SharpLinkReferencedCodecDependency[] referencedCodecDependencies) + : ISharpLinkGeneratedAssemblyManifest, ISharpLinkReferencedCodecDependencyManifest + { + public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; + public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; + public string GeneratorVersion => "test"; + public Assembly OwnerAssembly => typeof(ReferencedCodecManifest).Assembly; + public RpcHash128 RpcAssemblyHash => TestAssemblyHash; + public string CompileTimeDescriptor => descriptor; + public IReadOnlyList Contracts => []; + public IReadOnlyList Services => []; + public IReadOnlyList Codecs => []; + public IReadOnlyList Dependencies => []; + public IReadOnlyList ReferencedCodecDependencies { get; } = + referencedCodecDependencies; + } +} diff --git a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs index c37b03d5c..39bc07687 100644 --- a/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs +++ b/test/SharpLink.UnitTests/Runtime/SharpLinkRuntimeContextTests.cs @@ -6,7 +6,7 @@ namespace SharpLink.UnitTests.Runtime; -public class SharpLinkRuntimeContextTests +public partial class SharpLinkRuntimeContextTests { private static readonly TimeSpan RaceCoordinationTimeout = TimeSpan.FromSeconds(10); private static readonly RpcHash128 TestAssemblyHash = new(0x72756e74696d652dUL, 0x746573742d763031UL); @@ -1047,87 +1047,6 @@ public void UnchangedCodecShouldRefreshAcrossAnUnrelatedSnapshotRemoval() context.ReleaseGeneratedManifest(removedRegistration); } - [Test] - public void StaticBuildShouldRejectReferencedCodecHashMismatchBeforePublication() - { - var actualHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); - var expectedHash = new RpcHash128(0x3333333333333333UL, 0x4444444444444444UL); - var provider = new TestManifest( - "referenced-provider", - new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), actualHash)); - var consumer = new ReferencedCodecManifest( - "referenced-consumer", - [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); - - var failure = CaptureFailure(() => - { - using var context = CreateRuntimeBuilder().Build( - new ISharpLinkGeneratedAssemblyManifest[] { provider, consumer }); - }); - - Ensure(failure is InvalidOperationException && - failure.Message.Contains("expected CodecHash", StringComparison.Ordinal), - "static bootstrap must reject a referenced Codec hash mismatch before publication"); - } - - [Test] - public void DynamicPrepareShouldRejectReferencedCodecHashMismatch() - { - var actualHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); - var expectedHash = new RpcHash128(0x3333333333333333UL, 0x4444444444444444UL); - var provider = new TestManifest( - "referenced-provider", - new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), actualHash)); - using var context = CreateRuntimeBuilder().Build( - new ISharpLinkGeneratedAssemblyManifest[] { provider }); - var consumer = new ReferencedCodecManifest( - "referenced-consumer", - [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); - - var failure = CaptureFailure(() => context.PrepareGeneratedManifest(consumer)); - - Ensure(failure is InvalidOperationException && - failure.Message.Contains("expected CodecHash", StringComparison.Ordinal), - "dynamic manifest preparation must reject a referenced Codec hash mismatch"); - } - - [Test] - public void CandidatePublicationShouldRejectRemovingReferencedCodecDependency() - { - var expectedHash = new RpcHash128(0x1111111111111111UL, 0x2222222222222222UL); - var provider = new TestManifest( - "referenced-provider", - new HashedNativeFactory(new TaggedThirdAdapterValueCodec(1), expectedHash)); - var consumer = new ReferencedCodecManifest( - "referenced-consumer", - [new SharpLinkReferencedCodecDependency(typeof(ThirdAdapterValue), expectedHash)]); - using var context = CreateRuntimeBuilder().Build( - new ISharpLinkGeneratedAssemblyManifest[] { provider, consumer }); - - var failure = CaptureFailure(() => context.PublishGeneratedCodecs( - new Dictionary())); - - Ensure(failure is InvalidOperationException && - failure.Message.Contains("no generated Codec is registered for that exact Type", StringComparison.Ordinal), - "candidate publication must preserve reverse referenced Codec dependants"); - } - - [Test] - public void DisposedContextShouldRejectCodecResolution() - { - var context = CreateRuntimeBuilder().Build(includeGeneratedAssemblyCatalog: false); - context.Dispose(); - context.Dispose(); - try - { - _ = context.Codecs.GetCodec(); - throw new Exception("expected disposed Context to reject Codec resolution"); - } - catch (ObjectDisposedException) - { - } - } - [Test] public void AdapterFreeCustomWireCodecShouldBeAccepted() { @@ -1543,25 +1462,6 @@ private sealed class AdapterManifest(AdapterCounters counters, bool includeSecon public IReadOnlyList Dependencies => []; } - private sealed class ReferencedCodecManifest( - string descriptor, - SharpLinkReferencedCodecDependency[] referencedCodecDependencies) - : ISharpLinkGeneratedAssemblyManifest, ISharpLinkReferencedCodecDependencyManifest - { - public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; - public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; - public string GeneratorVersion => "test"; - public Assembly OwnerAssembly => typeof(ReferencedCodecManifest).Assembly; - public RpcHash128 RpcAssemblyHash => TestAssemblyHash; - public string CompileTimeDescriptor => descriptor; - public IReadOnlyList Contracts => []; - public IReadOnlyList Services => []; - public IReadOnlyList Codecs => []; - public IReadOnlyList Dependencies => []; - public IReadOnlyList ReferencedCodecDependencies { get; } = - referencedCodecDependencies; - } - private sealed class TestManifest(string descriptor, params IRpcGeneratedCodecFactory[] codecs) : ISharpLinkGeneratedAssemblyManifest { From 937c6f8497d2aff9b266ddf4649d9808b8a2a634 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:55:07 +0800 Subject: [PATCH 344/399] fix: preserve referenced codec baseline and shutdown identity --- .../SharpLinkClient.AssemblyRegistration.cs | 24 +---- src/SharpLink.Client/SharpLinkClient.cs | 14 +-- ...enerator.ContractManifest.Compatibility.cs | 8 +- ...nerator.ContractManifest.Infrastructure.cs | 13 +-- .../RpcGenerator.ContractManifest.cs | 32 ++++++- .../SharpLinkGeneratedDependencyBinding.cs | 83 ++++++++++++++++ .../SharpLinkServer.AssemblyDrain.cs | 6 +- .../SharpLinkServer.AssemblyRegistration.cs | 24 +---- .../ContractManifestGeneratorTestHelpers.cs | 5 +- .../RpcCodecTenthReviewRegressionTests.cs | 95 +++++++++++++++++++ ...emblyDependencyIdentityIntegrationTests.cs | 46 +++++++++ 11 files changed, 275 insertions(+), 75 deletions(-) diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs index c9e44496b..438677c5c 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs @@ -467,29 +467,7 @@ private static IEnumerable EnumerateManifestDependencies(ISharpLinkGener private static bool ManifestDependsOn( ISharpLinkGeneratedAssemblyManifest manifest, Assembly ownerAssembly) - { - foreach (var dependency in EnumerateManifestDependencies(manifest)) - { - if (SharpLinkGeneratedDependencyBinding.Matches( - manifest.OwnerAssembly, - dependency, - ownerAssembly)) - { - return true; - } - } - - if (manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest || - dependencyManifest.ReferencedCodecDependencies is not { } referencedDependencies) - { - return false; - } - - return referencedDependencies.Any(dependency => - dependency is not null && - dependency.TargetType is { } targetType && - ReferenceEquals(targetType.Assembly, ownerAssembly)); - } + => SharpLinkGeneratedDependencyBinding.ManifestDependsOn(manifest, ownerAssembly); private SharpLinkAssemblyRegistrationError? ValidateDependencies( ISharpLinkGeneratedAssemblyManifest incoming, diff --git a/src/SharpLink.Client/SharpLinkClient.cs b/src/SharpLink.Client/SharpLinkClient.cs index ddd3f6b8d..cb608fd8f 100644 --- a/src/SharpLink.Client/SharpLinkClient.cs +++ b/src/SharpLink.Client/SharpLinkClient.cs @@ -262,18 +262,8 @@ private Assembly[] GetDynamicAssembliesForShutdown() if (modules.Length == 1) return [modules[0].Assembly]; - var identities = new string[modules.Length]; - var dependencies = new string[modules.Length][]; - for (var index = 0; index < modules.Length; index++) - { - var manifest = modules[index].Manifest; - identities[index] = manifest.OwnerAssembly.FullName ?? - manifest.OwnerAssembly.GetName().Name ?? - string.Empty; - dependencies[index] = EnumerateManifestDependencies(manifest).ToArray(); - } - - var order = GetShutdownDependencyOrder(identities, dependencies); + var manifests = modules.Select(static module => module.Manifest).ToArray(); + var order = SharpLinkGeneratedDependencyBinding.GetDependantsFirstOrder(manifests); var assemblies = new Assembly[order.Length]; for (var index = 0; index < order.Length; index++) assemblies[index] = modules[order[index]].Assembly; diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs index 4d44afaf4..a53349e63 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -274,12 +274,14 @@ private static IEnumerable CompareContractManif if (!currentCodecs.TryGetValue(oldCodec.Type, out var newCodec)) continue; - var opaque = + var identityBound = string.Equals(oldCodec.Kind, "Custom", StringComparison.Ordinal) || string.Equals(oldCodec.Kind, "Adapter", StringComparison.Ordinal) || + string.Equals(oldCodec.Kind, "Referenced", StringComparison.Ordinal) || string.Equals(newCodec.Kind, "Custom", StringComparison.Ordinal) || - string.Equals(newCodec.Kind, "Adapter", StringComparison.Ordinal); - if (!opaque || string.Equals(oldCodec.CodecHash, newCodec.CodecHash, StringComparison.Ordinal)) + string.Equals(newCodec.Kind, "Adapter", StringComparison.Ordinal) || + string.Equals(newCodec.Kind, "Referenced", StringComparison.Ordinal); + if (!identityBound || string.Equals(oldCodec.CodecHash, newCodec.CodecHash, StringComparison.Ordinal)) continue; if (directlyDescribedCodecTypes.Contains(oldCodec.Type)) continue; diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs index 6f08215d2..7a09d0299 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs @@ -18,17 +18,18 @@ manifest.Unions is null || return false; } - var opaqueCodecTypes = new HashSet( + var identityBoundCodecTypes = new HashSet( manifest.Codecs .Where(static codec => codec is not null && (string.Equals(codec.Kind, "Custom", StringComparison.Ordinal) || - string.Equals(codec.Kind, "Adapter", StringComparison.Ordinal)) && + string.Equals(codec.Kind, "Adapter", StringComparison.Ordinal) || + string.Equals(codec.Kind, "Referenced", StringComparison.Ordinal)) && IsValidCodecHash(codec.CodecHash)) .Select(static codec => codec.Type), StringComparer.Ordinal); bool HasValueIdentity(string type, string? codecHash) - => !opaqueCodecTypes.Contains(type) || IsValidCodecHash(codecHash); + => !identityBoundCodecTypes.Contains(type) || IsValidCodecHash(codecHash); return manifest.Contracts.All(contract => contract is not null && @@ -79,10 +80,10 @@ private static bool IsValidCodecHash(string? value) private static string GetCodecHash(GeneratedCodecModel codec) => new RpcHashValue(codec.CodecHashHigh, codec.CodecHashLow).ToHex(); - private static string? GetOpaqueCodecHash( + private static string? GetContractCodecHash( string typeName, - IReadOnlyDictionary opaqueCodecHashes) - => opaqueCodecHashes.TryGetValue(RemoveGlobalPrefix(typeName), out var codecHash) + IReadOnlyDictionary contractCodecHashes) + => contractCodecHashes.TryGetValue(RemoveGlobalPrefix(typeName), out var codecHash) ? codecHash : null; diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index dc922b82d..6fe1772c2 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -219,12 +219,19 @@ private static ContractManifestDocument CreateContractManifest( static group => group.Key, static group => new RpcHashValue(group.First().High, group.First().Low).ToHex(), StringComparer.Ordinal); - var opaqueCodecHashes = codecsByType + var contractCodecHashes = codecsByType .Where(static pair => pair.Value.Kind is GeneratedCodecKind.Custom or GeneratedCodecKind.Adapter) .ToDictionary( static pair => pair.Key, static pair => GetCodecHash(pair.Value), StringComparer.Ordinal); + foreach (var codecHash in codecHashes + .Where(static item => item.IsReferenced) + .OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + { + contractCodecHashes[RemoveGlobalPrefix(codecHash.TypeName)] = + new RpcHashValue(codecHash.High, codecHash.Low).ToHex(); + } foreach (var contract in interfaces .Where(static item => item is not null) .Select(static item => item!) @@ -263,7 +270,7 @@ private static ContractManifestDocument CreateContractManifest( WireType = GetContractWireType(typeName, parameter.IsStream ? parameter.StreamItemEnumUnderlyingType : parameter.EnumUnderlyingType), - CodecHash = GetOpaqueCodecHash(typeName, opaqueCodecHashes), + CodecHash = GetContractCodecHash(typeName, contractCodecHashes), Nullable = parameter.PayloadNullable, Stream = parameter.IsStream, SourceLocation = parameter.Location @@ -283,7 +290,7 @@ private static ContractManifestDocument CreateContractManifest( method.IsStreamReturn ? method.StreamItemEnumUnderlyingType : method.ResponseEnumUnderlyingType), - CodecHash = GetOpaqueCodecHash(responseType, opaqueCodecHashes), + CodecHash = GetContractCodecHash(responseType, contractCodecHashes), Nullable = method.ResponseNullable, Stream = method.IsStreamReturn, SourceLocation = method.Location @@ -311,7 +318,7 @@ private static ContractManifestDocument CreateContractManifest( Id = member.FieldId, Type = RemoveGlobalPrefix(member.TypeName), WireType = GetMemberWireType(member), - CodecHash = GetOpaqueCodecHash(member.TypeName, opaqueCodecHashes), + CodecHash = GetContractCodecHash(member.TypeName, contractCodecHashes), Nullable = member.Nullable, Required = member.Required, ExplicitId = member.HasExplicitId, @@ -331,6 +338,23 @@ private static ContractManifestDocument CreateContractManifest( SourceLocation = codec.Location }); } + var emittedCodecTypes = new HashSet( + document.Codecs.Select(static item => item.Type), + StringComparer.Ordinal); + foreach (var codecHash in codecHashes + .Where(static item => item.IsReferenced) + .OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + { + var typeName = RemoveGlobalPrefix(codecHash.TypeName); + if (!emittedCodecTypes.Add(typeName)) + continue; + document.Codecs.Add(new ContractManifestCodec + { + Type = typeName, + Kind = "Referenced", + CodecHash = new RpcHashValue(codecHash.High, codecHash.Low).ToHex() + }); + } var enums = new Dictionary(StringComparer.Ordinal); void AddEnum(string? name, string? underlying, Location? location) diff --git a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs index ccba7a2b3..59d3a5a77 100644 --- a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs +++ b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs @@ -59,4 +59,87 @@ internal static bool Matches( string dependencyIdentity, Assembly candidateAssembly) => ReferenceEquals(Resolve(ownerAssembly, dependencyIdentity), candidateAssembly); + + internal static bool ManifestDependsOn( + ISharpLinkGeneratedAssemblyManifest manifest, + Assembly ownerAssembly) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(ownerAssembly); + foreach (var dependency in manifest.Dependencies) + { + if (Matches(manifest.OwnerAssembly, dependency, ownerAssembly)) + return true; + } + foreach (var dependency in manifest.ContractDependencies) + { + if (Matches(manifest.OwnerAssembly, dependency, ownerAssembly)) + return true; + } + if (manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest || + dependencyManifest.ReferencedCodecDependencies is not { } referencedDependencies) + { + return false; + } + for (var index = 0; index < referencedDependencies.Count; index++) + { + var dependency = referencedDependencies[index]; + if (dependency is not null && + dependency.TargetType is { } targetType && + ReferenceEquals(targetType.Assembly, ownerAssembly)) + { + return true; + } + } + return false; + } + + internal static int[] GetDependantsFirstOrder( + IReadOnlyList manifests) + { + ArgumentNullException.ThrowIfNull(manifests); + var remaining = new bool[manifests.Count]; + Array.Fill(remaining, true); + var order = new int[manifests.Count]; + for (var outputIndex = 0; outputIndex < order.Length; outputIndex++) + { + var selected = -1; + for (var candidate = 0; candidate < manifests.Count; candidate++) + { + if (!remaining[candidate]) + continue; + var candidateAssembly = manifests[candidate].OwnerAssembly; + var hasRemainingDependant = false; + for (var dependant = 0; dependant < manifests.Count; dependant++) + { + if (dependant == candidate || !remaining[dependant]) + continue; + if (ManifestDependsOn(manifests[dependant], candidateAssembly)) + { + hasRemainingDependant = true; + break; + } + } + if (!hasRemainingDependant) + { + selected = candidate; + break; + } + } + if (selected < 0) + { + for (var candidate = manifests.Count - 1; candidate >= 0; candidate--) + { + if (remaining[candidate]) + { + selected = candidate; + break; + } + } + } + order[outputIndex] = selected; + remaining[selected] = false; + } + return order; + } } diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs index a0e3ed230..e1877dea9 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs @@ -229,10 +229,12 @@ private async Task ReleaseDrainedDynamicModulesAsync() lock (_registryGate) modules = [.. _dynamicModules]; + var manifests = modules.Select(static pair => pair.Value.Manifest).ToArray(); + var order = SharpLinkGeneratedDependencyBinding.GetDependantsFirstOrder(manifests); List? failures = null; - for (var index = 0; index < modules.Length; index++) + for (var index = 0; index < order.Length; index++) { - var pair = modules[index]; + var pair = modules[order[index]]; try { pair.Value.TryBeginDraining(); diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs index b9ff47f74..389f02ef2 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs @@ -597,29 +597,7 @@ private static IEnumerable EnumerateManifestDependencies(ISharpLinkGener private static bool ManifestDependsOn( ISharpLinkGeneratedAssemblyManifest manifest, Assembly ownerAssembly) - { - foreach (var dependency in EnumerateManifestDependencies(manifest)) - { - if (SharpLinkGeneratedDependencyBinding.Matches( - manifest.OwnerAssembly, - dependency, - ownerAssembly)) - { - return true; - } - } - - if (manifest is not ISharpLinkReferencedCodecDependencyManifest dependencyManifest || - dependencyManifest.ReferencedCodecDependencies is not { } referencedDependencies) - { - return false; - } - - return referencedDependencies.Any(dependency => - dependency is not null && - dependency.TargetType is { } targetType && - ReferenceEquals(targetType.Assembly, ownerAssembly)); - } + => SharpLinkGeneratedDependencyBinding.ManifestDependsOn(manifest, ownerAssembly); private SharpLinkAssemblyRegistrationError? ValidateServiceDependencies( ISharpLinkGeneratedAssemblyManifest incoming, diff --git a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs index a7be3038c..8de7a9552 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs @@ -238,14 +238,15 @@ private static bool IsCompatibilityDiagnostic(Diagnostic diagnostic) private static ContractGeneratorResult RunContractGenerator( string source, string? baseline = null, - string? outputPath = null) + string? outputPath = null, + params MetadataReference[] additionalReferences) { const string baselinePath = "/contracts/previous.sharplink.json"; var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default); var compilation = CSharpCompilation.Create( "ContractManifestTestAssembly", [syntaxTree], - GetPlatformReferences(), + GetPlatformReferences().Concat(additionalReferences), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var properties = new Dictionary(StringComparer.Ordinal); var additionalTexts = ImmutableArray.Empty; diff --git a/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs index 483dc2761..3645f384a 100644 --- a/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/RpcCodecTenthReviewRegressionTests.cs @@ -211,6 +211,101 @@ public interface IAliasOnlyAdapterContract : IService return Task.CompletedTask; } + [Test] + public Task ReferencedCodecHashChangeShouldFailDirectAndNestedContractBaselines() + { + static MetadataReference GeneratedPayloadReference(ulong low) + => CreateMetadataReference( + "ReferencedBaselinePayload", + $$""" +using System; + +[assembly: SharpLink.Abstractions.SharpLinkGeneratedCodecIdentityAttribute(typeof(Referenced.Payload), 0x5555555555555555UL, {{low}}UL)] +[assembly: SharpLink.Abstractions.SharpLinkGeneratedAssemblyManifestAttribute(typeof(Referenced.Manifest), 4, 2, "2.0.0-test", "sharplink-2.0-api4-rpcchannel-codec-provider-v4")] + +namespace SharpLink.Abstractions +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class SharpLinkGeneratedCodecIdentityAttribute : Attribute + { + public SharpLinkGeneratedCodecIdentityAttribute(Type targetType, ulong high, ulong low) { } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] + public sealed class SharpLinkGeneratedAssemblyManifestAttribute : Attribute + { + public SharpLinkGeneratedAssemblyManifestAttribute( + Type manifestType, + int apiVersion, + int protocolVersion, + string generatorVersion, + string abiIdentity) { } + } +} + +namespace Referenced +{ + public sealed class Payload { public int Value { get; set; } } + public sealed class Manifest { } +} +"""); + + const string directConsumer = """ +using System.Threading; +using System.Threading.Tasks; +using SharpLink.Sdk; + +[RpcContract] +public interface IReferencedBaselineContract : IService +{ + ValueTask Echo(Referenced.Payload value, CancellationToken cancellationToken); +} +"""; + const string nestedConsumer = """ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SharpLink.Sdk; + +[RpcContract] +public interface IReferencedNestedBaselineContract : IService +{ + ValueTask> Echo(List value, CancellationToken cancellationToken); +} +"""; + + var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); + var h1 = GeneratedPayloadReference(0x1111111111111111UL); + var h2 = GeneratedPayloadReference(0x2222222222222222UL); + var directBaseline = RunContractGenerator(directConsumer, additionalReferences: [sdk, h1]).Json; + var directDocument = System.Text.Json.Nodes.JsonNode.Parse(directBaseline)!.AsObject(); + var directRequest = directDocument["contracts"]!.AsArray()[0]!["methods"]!.AsArray()[0]!["request"]!.AsArray()[0]!.AsObject(); + Ensure(IsValidCodecHashText(directRequest["codecHash"]?.GetValue()), + "a direct referenced final Codec leaf must persist its exact hash on the request value"); + var directReferencedCodec = directDocument["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(static item => item["type"]!.GetValue() == "Referenced.Payload"); + Ensure(directReferencedCodec["kind"]!.GetValue() == "Referenced" && + IsValidCodecHashText(directReferencedCodec["codecHash"]?.GetValue()), + "a direct referenced final Codec leaf must also persist in the reachable Codec identity inventory"); + var directChanged = RunContractGenerator(directConsumer, directBaseline, additionalReferences: [sdk, h2]); + Ensure(directChanged.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + $"a direct referenced final CodecHash H1 -> H2 change must fail the contract baseline. Actual: {FormatDiagnostics(directChanged.Diagnostics)}"); + + var nestedBaseline = RunContractGenerator(nestedConsumer, additionalReferences: [sdk, h1]).Json; + var nestedDocument = System.Text.Json.Nodes.JsonNode.Parse(nestedBaseline)!.AsObject(); + var referencedCodec = nestedDocument["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(static item => item["type"]!.GetValue() == "Referenced.Payload"); + Ensure(referencedCodec["kind"]!.GetValue() == "Referenced" && + IsValidCodecHashText(referencedCodec["codecHash"]?.GetValue()), + "nested referenced final Codec leaves must be persisted in the reachable Codec identity inventory"); + var nestedChanged = RunContractGenerator(nestedConsumer, nestedBaseline, additionalReferences: [sdk, h2]); + Ensure(nestedChanged.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + $"a nested referenced final CodecHash H1 -> H2 change must fail the contract baseline. Actual: {FormatDiagnostics(nestedChanged.Diagnostics)}"); + return Task.CompletedTask; + } + [Test] public Task ReferencedCodecHashShouldRequireCurrentGeneratedAbi() { diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs index 1b1c1e73e..93990363a 100644 --- a/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs @@ -120,6 +120,39 @@ public async Task SameFullNameReferencedCodecDependencyShouldRequireExactGenerat } } + [Test] + [NotInParallel] + public async Task ReferencedCodecDependenciesShouldShutdownDependantsBeforeProvidersOnClientAndServer() + { + await using var harness = await DynamicHarness.CreateAsync(); + var directory = GetProjectOutputDirectory("SharpLink.ReferencedCodecConsumer"); + var loadContext = new PluginLoadContext("referenced-codec-shutdown", directory); + try + { + var provider = loadContext.LoadFromAssemblyPath( + Path.Combine(directory, "SharpLink.ReferencedCodecProvider.dll")); + var consumer = loadContext.LoadFromAssemblyPath( + Path.Combine(directory, "SharpLink.ReferencedCodecConsumer.dll")); + + Ensure(harness.Client.RegisterAssembly(provider).Succeeded, "client registers shutdown provider first"); + Ensure(harness.Server.RegisterAssembly(provider).Succeeded, "server registers shutdown provider first"); + Ensure(harness.Client.RegisterAssembly(consumer).Succeeded, "client registers typed dependant second"); + Ensure(harness.Server.RegisterAssembly(consumer).Succeeded, "server registers typed dependant second"); + + await harness.Client.StopAsync(); + await harness.Server.StopAsync(TimeSpan.Zero); + + Ensure(GetDynamicModuleCount(harness.Client) == 0, + "client StopAsync must release both typed dependant and provider without leaving the provider registered"); + Ensure(GetDynamicModuleCount(harness.Server) == 0, + "server StopAsync must release both typed dependant and provider without leaving the provider registered"); + } + finally + { + loadContext.Unload(); + } + } + [Test] [NotInParallel] public async Task SameFullNameDeclaredModuleDependencyShouldRequireExactBoundGenerationOnClientAndServer() @@ -188,6 +221,19 @@ await EnsureDependencyPreventsUnregisterAsync( } } + private static int GetDynamicModuleCount(object endpoint) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var field = endpoint.GetType().GetField("_dynamicModules", flags) + ?? throw new InvalidOperationException($"Dynamic module registry was not available from '{endpoint.GetType()}'."); + var registry = field.GetValue(endpoint) + ?? throw new InvalidOperationException("Dynamic module registry was null."); + var countProperty = registry.GetType().GetProperty("Count") + ?? throw new InvalidOperationException("Dynamic module registry count was unavailable."); + return (int)(countProperty.GetValue(registry) + ?? throw new InvalidOperationException("Dynamic module registry count was null.")); + } + private static async Task EnsureDependencyPreventsUnregisterAsync( Func> unregister, string message) From 65be5e1adc30b41684fd0a536d5c66ade57d28bb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:26:45 +0800 Subject: [PATCH 345/399] fix: retain final codec identities in contract baseline --- src/SharpLink.Generator/RpcGenerator.ContractManifest.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index 6fe1772c2..4a8f6051e 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -226,7 +226,8 @@ private static ContractManifestDocument CreateContractManifest( static pair => GetCodecHash(pair.Value), StringComparer.Ordinal); foreach (var codecHash in codecHashes - .Where(static item => item.IsReferenced) + .Where(item => item.IsReferenced || + !codecsByType.ContainsKey(RemoveGlobalPrefix(item.TypeName))) .OrderBy(static item => item.TypeName, StringComparer.Ordinal)) { contractCodecHashes[RemoveGlobalPrefix(codecHash.TypeName)] = @@ -341,9 +342,7 @@ private static ContractManifestDocument CreateContractManifest( var emittedCodecTypes = new HashSet( document.Codecs.Select(static item => item.Type), StringComparer.Ordinal); - foreach (var codecHash in codecHashes - .Where(static item => item.IsReferenced) - .OrderBy(static item => item.TypeName, StringComparer.Ordinal)) + foreach (var codecHash in codecHashes.OrderBy(static item => item.TypeName, StringComparer.Ordinal)) { var typeName = RemoveGlobalPrefix(codecHash.TypeName); if (!emittedCodecTypes.Add(typeName)) @@ -351,7 +350,7 @@ private static ContractManifestDocument CreateContractManifest( document.Codecs.Add(new ContractManifestCodec { Type = typeName, - Kind = "Referenced", + Kind = codecHash.IsReferenced ? "Referenced" : "Final", CodecHash = new RpcHashValue(codecHash.High, codecHash.Low).ToHex() }); } From abfd361507ef5a042e32b35d13a89a21526a23ec Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:27:42 +0800 Subject: [PATCH 346/399] fix: compare unresolved final codec identities in baselines --- .../RpcGenerator.ContractManifest.Compatibility.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs index a53349e63..5ad86e1ff 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -278,9 +278,11 @@ private static IEnumerable CompareContractManif string.Equals(oldCodec.Kind, "Custom", StringComparison.Ordinal) || string.Equals(oldCodec.Kind, "Adapter", StringComparison.Ordinal) || string.Equals(oldCodec.Kind, "Referenced", StringComparison.Ordinal) || + string.Equals(oldCodec.Kind, "Final", StringComparison.Ordinal) || string.Equals(newCodec.Kind, "Custom", StringComparison.Ordinal) || string.Equals(newCodec.Kind, "Adapter", StringComparison.Ordinal) || - string.Equals(newCodec.Kind, "Referenced", StringComparison.Ordinal); + string.Equals(newCodec.Kind, "Referenced", StringComparison.Ordinal) || + string.Equals(newCodec.Kind, "Final", StringComparison.Ordinal); if (!identityBound || string.Equals(oldCodec.CodecHash, newCodec.CodecHash, StringComparison.Ordinal)) continue; if (directlyDescribedCodecTypes.Contains(oldCodec.Type)) From 7cbbd102afb6b235da05e20bef6f84a4a6652fa9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:28:01 +0800 Subject: [PATCH 347/399] test: cover unsafe blit baseline identity changes --- ...ContractManifestUnsafeBlitBaselineTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/ContractManifestUnsafeBlitBaselineTests.cs diff --git a/test/SharpLink.Generator.Tests/ContractManifestUnsafeBlitBaselineTests.cs b/test/SharpLink.Generator.Tests/ContractManifestUnsafeBlitBaselineTests.cs new file mode 100644 index 000000000..3f8ade1dd --- /dev/null +++ b/test/SharpLink.Generator.Tests/ContractManifestUnsafeBlitBaselineTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task DirectUnsafeBlitLayoutChangeShouldFailContractBaseline() + { + static string ContractSource(string fieldType) => BuildSource($$""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct RawPayload +{ + public {{fieldType}} Value; +} + +[SharpLink.Sdk.RpcContract] +public interface IRawService : SharpLink.Sdk.IService +{ + ValueTask Echo(RawPayload value, CancellationToken cancellationToken); +} +"""); + + var baselineResult = RunContractGenerator(ContractSource("int")); + var root = System.Text.Json.Nodes.JsonNode.Parse(baselineResult.Json)!.AsObject(); + var method = root["contracts"]!.AsArray().Single()!["methods"]!.AsArray().Single()!.AsObject(); + var request = method["request"]!.AsArray().Single()!.AsObject(); + Ensure(IsValidCodecHashText(request["codecHash"]?.GetValue()), + "direct UnsafeBlit payload must retain its final CodecHash in the baseline value"); + var rawCodec = root["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(static item => item["type"]!.GetValue() == "RawPayload"); + Ensure(rawCodec["kind"]!.GetValue() == "Final", + "non-emitted final codec leaves must be retained in the complete identity inventory"); + Ensure(IsValidCodecHashText(rawCodec["codecHash"]?.GetValue()), + "UnsafeBlit inventory entry must retain its final CodecHash"); + + var changed = RunContractGenerator(ContractSource("long"), baselineResult.Json); + + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing the physical UnsafeBlit layout must fail baseline comparison"); + return Task.CompletedTask; + } + + [Test] + public Task NestedUnsafeBlitLayoutChangeShouldFailContractBaseline() + { + static string ContractSource(string fieldType) => BuildSource($$""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct RawPayload +{ + public {{fieldType}} Value; +} + +[SharpLink.Sdk.RpcContract] +public interface IRawService : SharpLink.Sdk.IService +{ + ValueTask> Echo(List value, CancellationToken cancellationToken); +} +"""); + + var baselineResult = RunContractGenerator(ContractSource("int")); + var root = System.Text.Json.Nodes.JsonNode.Parse(baselineResult.Json)!.AsObject(); + var rawCodec = root["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(static item => item["type"]!.GetValue() == "RawPayload"); + Ensure(rawCodec["kind"]!.GetValue() == "Final", + "nested UnsafeBlit leaf must be retained in the complete final codec inventory"); + Ensure(IsValidCodecHashText(rawCodec["codecHash"]?.GetValue()), + "nested UnsafeBlit leaf must retain its final CodecHash"); + + var changed = RunContractGenerator(ContractSource("long"), baselineResult.Json); + + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing a nested UnsafeBlit leaf inside a collection must fail baseline comparison"); + return Task.CompletedTask; + } +} From 8da886771d7d683825c3e972b9d1d2f0211ae379 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:42:44 +0800 Subject: [PATCH 348/399] fix: hash effective DTO null semantics --- src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs index 7d023721c..407d293ba 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecIdentity.cs @@ -113,8 +113,9 @@ private static RpcHashValue HashGeneratedDtoPlan( parts.Add(member.FieldId.ToString(InvariantCulture)); parts.Add(member.Kind.ToString()); parts.Add(member.Required ? "required" : "optional"); - parts.Add(member.Nullable ? "nullable" : "non-nullable"); - parts.Add(member.NonNullableReference ? "non-null-ref" : "other-null-semantics"); + parts.Add(member.Required && member.NonNullableReference + ? "required-non-null-ref" + : "no-required-reference-null-rejection"); switch (member.WireStrategy) { case FinalDtoMemberWireStrategy.String: From 01987f363cf9699095a9999c59047508bf3a805b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:43:23 +0800 Subject: [PATCH 349/399] test: accept retained final payload identities --- .../ContractManifestGeneratorTestHelpers.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs index 8de7a9552..db7d24f99 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestGeneratorTestHelpers.cs @@ -208,7 +208,7 @@ private static bool IsValidCodecHashText(string? value) private static void EnsurePayloadIdentity( System.Text.Json.Nodes.JsonNode node, - bool expectOpaqueCodecHash, + bool requireCodecHash, bool? stream, string scenario) { @@ -217,15 +217,15 @@ private static void EnsurePayloadIdentity( $"{scenario} wire type"); Ensure(!value.ContainsKey("wireFormatId"), $"{scenario} must not contain legacy wireFormatId"); - if (expectOpaqueCodecHash) + if (requireCodecHash) { Ensure(IsValidCodecHashText(value["codecHash"]?.GetValue()), - $"{scenario} opaque CodecHash"); + $"{scenario} CodecHash"); } - else + else if (value.TryGetPropertyValue("codecHash", out var codecHashNode) && codecHashNode is not null) { - Ensure(!value.ContainsKey("codecHash"), - $"{scenario} native payload position does not need a second identity field"); + Ensure(IsValidCodecHashText(codecHashNode.GetValue()), + $"{scenario} final CodecHash when present"); } if (stream is not null) Ensure(value["stream"]?.GetValue() == stream, $"{scenario} stream shape"); From 70593c2792649ce7e35c3e7362f93b4dd40418af Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:43:37 +0800 Subject: [PATCH 350/399] test: cover effective DTO null identity --- .../RpcNullableIdentityRegressionTests.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcNullableIdentityRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcNullableIdentityRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcNullableIdentityRegressionTests.cs new file mode 100644 index 000000000..831934c55 --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcNullableIdentityRegressionTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task OptionalReferenceNullableAnnotationShouldNotChangeFinalIdentity() + { + var nullable = GenerateNullableMemberIdentityManifest(required: false, nullable: true); + var nonNullable = GenerateNullableMemberIdentityManifest(required: false, nullable: false); + + Ensure( + ExtractGeneratedCodecIdentity(nullable, "NullableMemberPayload") == + ExtractGeneratedCodecIdentity(nonNullable, "NullableMemberPayload"), + "optional reference nullable annotations must not perturb DTO CodecHash when generated null behavior is unchanged"); + Ensure( + ExtractGeneratedRpcAssemblyHash(nullable) == ExtractGeneratedRpcAssemblyHash(nonNullable), + "optional reference nullable annotations must not perturb RpcAssemblyHash when RPC semantics are unchanged"); + return Task.CompletedTask; + } + + [Test] + public Task RequiredReferenceNullRejectionShouldChangeFinalIdentity() + { + var nullable = GenerateNullableMemberIdentityManifest(required: true, nullable: true); + var nonNullable = GenerateNullableMemberIdentityManifest(required: true, nullable: false); + + Ensure( + ExtractGeneratedCodecIdentity(nullable, "NullableMemberPayload") != + ExtractGeneratedCodecIdentity(nonNullable, "NullableMemberPayload"), + "required non-null reference rejection is an effective decode semantic and must change DTO CodecHash"); + Ensure( + ExtractGeneratedRpcAssemblyHash(nullable) != ExtractGeneratedRpcAssemblyHash(nonNullable), + "required non-null reference rejection must propagate into RpcAssemblyHash"); + return Task.CompletedTask; + } + + private static string GenerateNullableMemberIdentityManifest(bool required, bool nullable) + { + var requiredAttribute = required ? "[SharpLink.Sdk.RpcRequired]" : string.Empty; + var memberType = nullable ? "string?" : "string"; + var source = BuildSource($$""" +#nullable enable +[SharpLink.Sdk.RpcSerializable] +public sealed class NullableMemberPayload +{ + {{requiredAttribute}} + public {{memberType}} Name { get; set; } = null!; +} + +[SharpLink.Sdk.RpcContract] +public interface INullableMemberIdentityContract : SharpLink.Sdk.IService +{ + ValueTask Echo( + NullableMemberPayload value, + CancellationToken cancellationToken); +} +"""); + + return RunGeneratorAndGetSources(source) + .Single(static generated => + generated.Contains("ISharpLinkGeneratedAssemblyManifest", StringComparison.Ordinal)); + } +} From ceb19f17504405860e6e72a85eea199d2c6cff9c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:57:11 +0800 Subject: [PATCH 351/399] test: align optional DTO nullability identity expectation --- .../RpcAnalyzerTests.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index a116eaf51..f8b8382a5 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -414,9 +414,9 @@ public interface IResponseFingerprintContract : SharpLink.Sdk.IService } [Test] - public Task DtoMemberNullabilityMustParticipateInRuntimeCodecHash() + public Task OptionalDtoMemberNullabilityAnnotationShouldNotPerturbRuntimeCodecHash() { - var required = BuildSource(""" + var nonNullable = BuildSource(""" #nullable enable [SharpLink.Sdk.RpcContract] public interface IDtoSchemaContract : SharpLink.Sdk.IService @@ -425,7 +425,7 @@ public interface IDtoSchemaContract : SharpLink.Sdk.IService } public sealed class Payload { public string Name { get; set; } = string.Empty; } """); - var optional = BuildSource(""" + var nullable = BuildSource(""" #nullable enable [SharpLink.Sdk.RpcContract] public interface IDtoSchemaContract : SharpLink.Sdk.IService @@ -435,10 +435,10 @@ public interface IDtoSchemaContract : SharpLink.Sdk.IService public sealed class Payload { public string? Name { get; set; } } """); - var requiredHash = GetFirstGeneratedCodecHash(required); - var optionalHash = GetFirstGeneratedCodecHash(optional); - Ensure(!string.Equals(requiredHash, optionalHash, StringComparison.Ordinal), - "required and nullable DTO members must not publish the same runtime CodecHash"); + var nonNullableHash = GetFirstGeneratedCodecHash(nonNullable); + var nullableHash = GetFirstGeneratedCodecHash(nullable); + Ensure(string.Equals(nonNullableHash, nullableHash, StringComparison.Ordinal), + "optional nullable annotations must not change runtime CodecHash when generated null behavior is identical"); return Task.CompletedTask; } @@ -1221,7 +1221,7 @@ public sealed class HelloService : IHelloService } """); - EnsureHasRuleContaining(source, "SHARPLINK020", "99"); + EnsureHasRule(source, "SHARPLINK020"); return Task.CompletedTask; } @@ -2096,7 +2096,7 @@ public Task InvalidAdapterRegistrationShapesShouldReportSharplink042() public sealed class ValidAdapter : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "valid.adapter/v1"; - public string WireFormatId => "valid-wire/v1"; + public string WireFormatId => "wire/v1"; public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } From 078de9814c77577ee046ed4813f465be9fac9e41 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:03:41 +0800 Subject: [PATCH 352/399] test: restore unrelated generator fixture expectations --- test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs index f8b8382a5..3216e3dc0 100644 --- a/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs +++ b/test/SharpLink.Generator.Tests/RpcAnalyzerTests.cs @@ -1221,7 +1221,7 @@ public sealed class HelloService : IHelloService } """); - EnsureHasRule(source, "SHARPLINK020"); + EnsureHasRuleContaining(source, "SHARPLINK020", "99"); return Task.CompletedTask; } @@ -2096,7 +2096,7 @@ public Task InvalidAdapterRegistrationShapesShouldReportSharplink042() public sealed class ValidAdapter : SharpLink.Abstractions.IRpcCodecAdapter { public string AdapterId => "valid.adapter/v1"; - public string WireFormatId => "wire/v1"; + public string WireFormatId => "valid-wire/v1"; public SharpLink.Abstractions.IRpcCodecAdapterScope CreateScope() => throw new NotImplementedException(); } From de2b694d34c9b895ad597690827b2bf7c54667ba Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:59:22 +0800 Subject: [PATCH 353/399] fix: materialize generated codecs in owner scope --- .../Codec/RpcManifestCodecProvider.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs index b2da25aab..6306f0795 100644 --- a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs +++ b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs @@ -120,11 +120,30 @@ public IRpcCodec GetCodec() private IRpcCodec ResolveOwned(Type targetType, RpcGeneratedCodecRegistration registration) { _owner.ThrowIfDisposed(); - var codec = _resolved.GetOrAdd(targetType, _ => registration.GetCodec(this)); + var codec = _resolved.GetOrAdd( + targetType, + _ => registration.GetCodec(GetRegistrationProvider(registration))); _owner.ThrowIfDisposed(); return Cast(codec, targetType); } + private static IRpcCodecProvider GetRegistrationProvider(RpcGeneratedCodecRegistration registration) + { + var owner = registration.Owner; + owner.ThrowIfDisposed(); + var targetType = registration.Factory.TargetType; + if (owner.ContractCodecs.TryGetValue(targetType, out var contractRegistration) && + ReferenceEquals(contractRegistration, registration)) + { + return RpcGeneratedCodecResolver.GetProvider(owner); + } + + // A context-global registration is part of the provider manifest's global generated graph. + // In particular, a referenced codec selected by another Contract must never receive that + // consumer Contract's policy provider while constructing its own nested dependencies. + return owner.BaseProvider; + } + private bool IsGeneratedDependencyAllowed( Type targetType, RpcGeneratedCodecRegistration registration, From 5ef10659db1c2e10a348867fb83089f307f4474c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:59:54 +0800 Subject: [PATCH 354/399] test: keep referenced codec construction in provider scope --- ...eferencedCodecOwnerScopeRegressionTests.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs diff --git a/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs new file mode 100644 index 000000000..ae58191c2 --- /dev/null +++ b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs @@ -0,0 +1,112 @@ +using System.Buffers; +using System.Reflection; +using SharpLink.Abstractions; +using SharpLink.Runtime; +using SharpLink.StaticCodecOwnerTest.Contracts; + +namespace SharpLink.UnitTests.Runtime; + +public sealed class RpcReferencedCodecOwnerScopeRegressionTests +{ + [Test] + public void ReferencedGeneratedCodecShouldResolveNestedCodecThroughProviderOwner() + { + using var context = new SharpLinkRuntimeContextBuilder() + .Build(includeGeneratedAssemblyCatalog: false); + + var providerManifest = new ProviderManifest(typeof(ReferencedPayload).Assembly); + var providerRegistration = context.PrepareGeneratedManifest(providerManifest); + context.PublishGeneratedCodecs(providerRegistration.Codecs, providerRegistration); + context.AdoptGeneratedManifest(providerRegistration); + + var consumerManifest = new ConsumerManifest(typeof(IContractA).Assembly); + var consumerRegistration = context.PrepareGeneratedManifest(consumerManifest); + context.AdoptGeneratedManifest(consumerRegistration); + + var payloadCodec = RpcGeneratedCodecResolver + .GetProvider(context, consumerManifest.OwnerAssembly) + .GetCodec() as ReferencedPayloadCodec; + + Ensure(payloadCodec is not null, + "the consumer must resolve the exact referenced provider registration"); + Ensure(payloadCodec!.Child is ReferencedChildCodec, + "the referenced payload factory must resolve its nested child through the provider manifest's global graph, not the consumer Contract policy"); + } + + private sealed class ReferencedPayload { } + private sealed class ReferencedChild { } + + private sealed class ReferencedChildCodec : IRpcCodec + { + public void Serialize(in ReferencedChild value, IBufferWriter buffer) { } + public ReferencedChild Deserialize(in ReadOnlySequence buffer) => new(); + } + + private sealed class ReferencedPayloadCodec(IRpcCodec child) : IRpcCodec + { + internal IRpcCodec Child { get; } = child; + public void Serialize(in ReferencedPayload value, IBufferWriter buffer) { } + public ReferencedPayload Deserialize(in ReadOnlySequence buffer) => new(); + } + + private sealed class NativeFactory(Func> create) + : ITestGeneratedCodecFactory + { + public Type TargetType => typeof(T); + public string? AdapterId => null; + public IRpcCodecAdapter? Adapter => null; + + public IRpcCodec Create(IRpcCodecProvider provider, IRpcCodecAdapterScope? adapterScope) + { + if (adapterScope is not null) + throw new ArgumentException("native regression factory does not accept an Adapter scope", nameof(adapterScope)); + return create(provider); + } + + public bool IsCompatibleCodec(IRpcCodec codec) => codec is IRpcCodec; + } + + private sealed class ProviderManifest(Assembly ownerAssembly) : ITestGeneratedManifest + { + public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; + public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; + public string GeneratorVersion => "referenced-provider-owner-scope-regression"; + public Assembly OwnerAssembly { get; } = ownerAssembly; + public string CompileTimeDescriptor => "referenced-provider-owner-scope-regression"; + public IReadOnlyList Contracts => []; + public IReadOnlyList Services => []; + public IReadOnlyList Codecs { get; } = + [ + new NativeFactory(static _ => new ReferencedChildCodec()), + new NativeFactory(static provider => + new ReferencedPayloadCodec(provider.GetCodec())) + ]; + public IReadOnlyList ContractCodecs => []; + public IReadOnlyList Dependencies => []; + } + + private sealed class ConsumerManifest(Assembly ownerAssembly) + : ITestGeneratedManifest, ISharpLinkReferencedCodecDependencyManifest + { + public int ApiVersion => SharpLinkGeneratedManifestVersions.Api; + public int ProtocolVersion => SharpLinkGeneratedManifestVersions.Protocol; + public string GeneratorVersion => "referenced-consumer-owner-scope-regression"; + public Assembly OwnerAssembly { get; } = ownerAssembly; + public string CompileTimeDescriptor => "referenced-consumer-owner-scope-regression"; + public IReadOnlyList Contracts => []; + public IReadOnlyList Services => []; + public IReadOnlyList Codecs => []; + public IReadOnlyList ContractCodecs => []; + public IReadOnlyList Dependencies => []; + public IReadOnlyList ReferencedCodecDependencies { get; } = + [ + new(typeof(ReferencedPayload), TestGeneratedIdentity.CodecHash) + ]; + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} From 221d77073b0ee55fca4144de43bbbd20bafb4f2b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:00:33 +0800 Subject: [PATCH 355/399] fix: persist baseline compatibility semantics --- .../RpcGenerator.ContractManifest.Infrastructure.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs index 7a09d0299..c5452ef83 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs @@ -272,6 +272,10 @@ private sealed class ContractManifestMethod public long Id { get; set; } public string Shape { get; set; } = string.Empty; public string Fingerprint { get; set; } = string.Empty; + public bool Cancellable { get; set; } + public bool Idempotent { get; set; } + public bool HasTimeout { get; set; } + public long? TimeoutTicks { get; set; } public List Request { get; set; } = []; public ContractManifestValue Response { get; set; } = new(); [JsonIgnore] public Location? SourceLocation { get; set; } @@ -292,6 +296,7 @@ private sealed class ContractManifestDto { public string Name { get; set; } = string.Empty; public string Fingerprint { get; set; } = string.Empty; + public string Shape { get; set; } = string.Empty; public List Members { get; set; } = []; [JsonIgnore] public Location? SourceLocation { get; set; } } @@ -313,6 +318,7 @@ private sealed class ContractManifestMember public string? CodecHash { get; set; } public bool Nullable { get; set; } public bool Required { get; set; } + public bool RejectNull { get; set; } public bool ExplicitId { get; set; } [JsonIgnore] public Location? SourceLocation { get; set; } } From 2ad435ec6b25f36289150aad0967c90454e01657 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:01:20 +0800 Subject: [PATCH 356/399] fix: emit effective baseline semantics --- src/SharpLink.Generator/RpcGenerator.ContractManifest.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index 4a8f6051e..d59368a48 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -256,6 +256,10 @@ private static ContractManifestDocument CreateContractManifest( Id = method.Hash, Shape = GetMethodKind(method), Fingerprint = method.Fingerprint, + Cancellable = method.HasCancellationToken, + Idempotent = method.IsIdempotent, + HasTimeout = method.HasTimeoutAttribute, + TimeoutTicks = method.TimeoutTicks, SourceLocation = method.Location }; foreach (var parameter in method.Parameters.Where(static parameter => @@ -309,6 +313,7 @@ private static ContractManifestDocument CreateContractManifest( { Name = RemoveGlobalPrefix(codec.TypeName), Fingerprint = codec.SchemaId, + Shape = codec.IsReferenceType ? "reference" : "value", SourceLocation = codec.Location }; foreach (var member in codec.Members.OrderBy(static item => item.FieldId)) @@ -322,6 +327,7 @@ private static ContractManifestDocument CreateContractManifest( CodecHash = GetContractCodecHash(member.TypeName, contractCodecHashes), Nullable = member.Nullable, Required = member.Required, + RejectNull = member.Required && member.NonNullableReference, ExplicitId = member.HasExplicitId, SourceLocation = member.Location }); From e25522a77e113589257683743a0ca17b09acb3a4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:02:05 +0800 Subject: [PATCH 357/399] fix: compare effective baseline semantics --- ...enerator.ContractManifest.Compatibility.cs | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs index 5ad86e1ff..355eb747a 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -166,6 +166,18 @@ private static IEnumerable CompareContractManif $"RPC shape changed from {oldMethod.Shape} to {newMethod.Shape}", "add a new method for the new Unary/Streaming shape")); } + if (oldMethod.Cancellable != newMethod.Cancellable || + oldMethod.Idempotent != newMethod.Idempotent || + oldMethod.HasTimeout != newMethod.HasTimeout || + oldMethod.TimeoutTicks != newMethod.TimeoutTicks) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + newMethod.SourceLocation, + $"{newContract.Name}.{newMethod.Name}", + $"method behavior changed from cancellable={oldMethod.Cancellable}/idempotent={oldMethod.Idempotent}/timeout={oldMethod.HasTimeout}:{oldMethod.TimeoutTicks} to cancellable={newMethod.Cancellable}/idempotent={newMethod.Idempotent}/timeout={newMethod.HasTimeout}:{newMethod.TimeoutTicks}", + "restore the previous cancellation, idempotency, and normalized timeout semantics or add a new method route")); + } CompareValues(oldMethod.Request, newMethod.Request, $"{newContract.Name}.{newMethod.Name} request", newMethod.SourceLocation, diagnostics); CompareValues([oldMethod.Response], [newMethod.Response], @@ -178,6 +190,15 @@ private static IEnumerable CompareContractManif { if (!currentDtos.TryGetValue(oldDto.Name, out var newDto)) continue; + if (!string.Equals(oldDto.Shape, newDto.Shape, StringComparison.Ordinal)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + newDto.SourceLocation, + newDto.Name, + $"DTO envelope changed from {oldDto.Shape} to {newDto.Shape}", + "restore the previous reference/value DTO shape or publish a new payload type")); + } var newById = newDto.Members.ToDictionary(static item => item.Id); var newByName = newDto.Members.ToDictionary(static item => item.Name, StringComparer.Ordinal); var matchedNewIds = new HashSet(); @@ -197,6 +218,15 @@ private static IEnumerable CompareContractManif $"member {oldMember.Id} changed from {oldMember.Type}/{oldMember.WireType}/{oldMember.CodecHash} to {newMember.Type}/{newMember.WireType}/{newMember.CodecHash}", "restore the old wire type or semantic Codec identity, or add a new optional member ID")); } + if (oldMember.RejectNull != newMember.RejectNull) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + newMember.SourceLocation, + $"{newDto.Name}.{newMember.Name}", + $"required-reference null rejection changed from {oldMember.RejectNull} to {newMember.RejectNull}", + "restore the previous effective required-reference nullability contract or publish a new payload type")); + } if (!oldMember.Required && newMember.Required) { diagnostics.Add(Change( @@ -261,13 +291,6 @@ private static IEnumerable CompareContractManif } } - var directlyDescribedCodecTypes = new HashSet( - baseline.Contracts - .SelectMany(static contract => contract.Methods) - .SelectMany(static method => method.Request.Append(method.Response)) - .Select(static value => value.Type) - .Concat(baseline.Dtos.SelectMany(static dto => dto.Members).Select(static member => member.Type)), - StringComparer.Ordinal); var currentCodecs = current.Codecs.ToDictionary(static codec => codec.Type, StringComparer.Ordinal); foreach (var oldCodec in baseline.Codecs) { @@ -285,8 +308,6 @@ private static IEnumerable CompareContractManif string.Equals(newCodec.Kind, "Final", StringComparison.Ordinal); if (!identityBound || string.Equals(oldCodec.CodecHash, newCodec.CodecHash, StringComparison.Ordinal)) continue; - if (directlyDescribedCodecTypes.Contains(oldCodec.Type)) - continue; diagnostics.Add(Change( ContractCompatibilityKind.WireType, From b38d37a49e08bc77b7dd012d1fdd6ecb21fc043f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:03:11 +0800 Subject: [PATCH 358/399] test: cover contract compatibility projection gaps --- ...tCompatibilityProjectionRegressionTests.cs | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs new file mode 100644 index 000000000..d7ac76dee --- /dev/null +++ b/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs @@ -0,0 +1,233 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task RemovedDirectLeafMustNotSuppressSurvivingNestedIdentityChange() + { + static string Source(string rawFieldType, bool includeDirectMember) + { + var directMember = includeDirectMember + ? "public Raw Direct { get; set; }" + : string.Empty; + return BuildSource($$""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct Raw +{ + public {{rawFieldType}} Value; +} + +[SharpLink.Sdk.RpcSerializable] +public sealed class A +{ + {{directMember}} +} + +[SharpLink.Sdk.RpcSerializable] +public sealed class B +{ + public List Nested { get; set; } = new(); +} + +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + ValueTask EchoA(A value, CancellationToken cancellationToken); + ValueTask EchoB(B value, CancellationToken cancellationToken); +} +"""); + } + + var baseline = RunContractGenerator(Source("int", includeDirectMember: true)); + var changed = RunContractGenerator(Source("long", includeDirectMember: false), baseline.Json); + + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "removing an optional direct use must not suppress a still-reachable nested final CodecHash change"); + return Task.CompletedTask; + } + + [Test] + public Task RequiredReferenceNullRejectionChangeShouldFailContractBaseline() + { + static string Source(bool nullable) => BuildSource($$""" +#nullable enable +[SharpLink.Sdk.RpcSerializable] +public sealed class Payload +{ + [SharpLink.Sdk.RpcRequired] + public string{{(nullable ? "?" : string.Empty)}} Name { get; set; } = string.Empty; +} + +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + ValueTask Echo(Payload value, CancellationToken cancellationToken); +} +"""); + + var baseline = RunContractGenerator(Source(nullable: false)); + var baselineRoot = System.Text.Json.Nodes.JsonNode.Parse(baseline.Json)!.AsObject(); + var baselineMember = baselineRoot["dtos"]!.AsArray().Single()!["members"]!.AsArray().Single()!.AsObject(); + Ensure(baselineMember["rejectNull"]?.GetValue() == true, + "required non-nullable references must persist the effective runtime null-rejection semantic"); + + var changed = RunContractGenerator(Source(nullable: true), baseline.Json); + var changedRoot = System.Text.Json.Nodes.JsonNode.Parse(changed.Json)!.AsObject(); + var changedMember = changedRoot["dtos"]!.AsArray().Single()!["members"]!.AsArray().Single()!.AsObject(); + Ensure(changedMember["rejectNull"]?.GetValue() == false, + "required nullable references must persist the absence of runtime null rejection"); + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing effective required-reference null rejection must fail baseline comparison"); + return Task.CompletedTask; + } + + [Test] + public Task DtoReferenceValueEnvelopeChangeShouldFailContractBaseline() + { + static string Source(string declarationKind) => BuildSource($$""" +[SharpLink.Sdk.RpcSerializable] +public {{declarationKind}} Payload +{ + [SharpLink.Sdk.RpcMember(1)] + public int Value { get; set; } +} + +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + ValueTask Echo(Payload value, CancellationToken cancellationToken); +} +"""); + + var baseline = RunContractGenerator(Source("class")); + var baselineRoot = System.Text.Json.Nodes.JsonNode.Parse(baseline.Json)!.AsObject(); + Ensure(baselineRoot["dtos"]!.AsArray().Single()!["shape"]?.GetValue() == "reference", + "reference DTOs must persist their presence-framed envelope shape"); + + var changed = RunContractGenerator(Source("struct"), baseline.Json); + var changedRoot = System.Text.Json.Nodes.JsonNode.Parse(changed.Json)!.AsObject(); + Ensure(changedRoot["dtos"]!.AsArray().Single()!["shape"]?.GetValue() == "value", + "value DTOs must persist their non-presence-framed envelope shape"); + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "class-to-struct DTO envelope changes must fail baseline comparison"); + return Task.CompletedTask; + } + + [Test] + public Task TimeoutBehaviorChangeShouldFailContractBaseline() + { + static string Source(int seconds) => BuildSource($$""" +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + [SharpLink.Sdk.Timeout({{seconds}}d)] + ValueTask Echo(int value, CancellationToken cancellationToken); +} +"""); + + var baseline = RunContractGenerator(Source(5)); + var baselineMethod = System.Text.Json.Nodes.JsonNode.Parse(baseline.Json)!.AsObject()["contracts"]! + .AsArray().Single()!["methods"]!.AsArray().Single()!.AsObject(); + Ensure(baselineMethod["hasTimeout"]?.GetValue() == true && + baselineMethod["timeoutTicks"]?.GetValue() == TimeSpan.FromSeconds(5).Ticks, + "baseline must persist normalized timeout behavior independently from payload identity"); + + var changed = RunContractGenerator(Source(10), baseline.Json); + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing normalized method timeout behavior must fail baseline comparison"); + return Task.CompletedTask; + } + + [Test] + public Task IdempotencyBehaviorChangeShouldFailContractBaseline() + { + static string Source(bool idempotent) => BuildSource($$""" +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + {{(idempotent ? "[SharpLink.Sdk.Idempotent]" : string.Empty)}} + ValueTask Echo(int value, CancellationToken cancellationToken); +} +"""); + + var baseline = RunContractGenerator(Source(idempotent: false)); + var changed = RunContractGenerator(Source(idempotent: true), baseline.Json); + + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing method idempotency behavior must fail baseline comparison"); + return Task.CompletedTask; + } + + [Test] + public Task CancellabilityBehaviorChangeShouldFailContractBaseline() + { + static string Source(bool cancellable) => BuildSource(cancellable + ? """ +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + ValueTask Echo(int value, CancellationToken cancellationToken); +} +""" + : """ +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + [SharpLink.Sdk.NonCancellable] + ValueTask Echo(int value); +} +"""); + + var baseline = RunContractGenerator(Source(cancellable: true)); + var changed = RunContractGenerator(Source(cancellable: false), baseline.Json); + + Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing method cancellability behavior must fail baseline comparison"); + return Task.CompletedTask; + } + + [Test] + public Task ExplicitFullyOverlappingIdenticalAliasShouldPreserveUnsafeBlitIdentity() + { + static string Source(bool includeAlias) => BuildSource($$""" +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)] +public struct Raw +{ + [System.Runtime.InteropServices.FieldOffset(0)] + public int Value; + {{(includeAlias ? "[System.Runtime.InteropServices.FieldOffset(0)] public int Alias;" : string.Empty)}} +} + +[SharpLink.Sdk.RpcContract] +public interface IProjectionService : SharpLink.Sdk.IService +{ + ValueTask Echo(Raw value, CancellationToken cancellationToken); +} +"""); + + var baseline = RunContractGenerator(Source(includeAlias: false)); + var changedWithoutBaseline = RunContractGenerator(Source(includeAlias: true)); + var baselineHash = GetFinalCodecHash(baseline.Json, "Raw"); + var changedHash = GetFinalCodecHash(changedWithoutBaseline.Json, "Raw"); + Ensure(string.Equals(baselineHash, changedHash, StringComparison.Ordinal), + "a fully overlapping identical explicit alias must not change UnsafeBlit physical identity"); + + var changed = RunContractGenerator(Source(includeAlias: true), baseline.Json); + Ensure(!changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "an identical fully overlapping explicit alias must remain baseline-compatible"); + return Task.CompletedTask; + } + + private static string GetFinalCodecHash(string json, string typeName) + { + var root = System.Text.Json.Nodes.JsonNode.Parse(json)!.AsObject(); + return root["codecs"]!.AsArray() + .Select(static item => item!.AsObject()) + .Single(item => item["type"]!.GetValue() == typeName)["codecHash"]! + .GetValue(); + } +} From 2de459d45d57b26a556558b827fd82ef84aab391 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:03:47 +0800 Subject: [PATCH 359/399] fix: canonicalize identical explicit layout aliases --- .../RpcGenerator.FinalCodecPlan.Physical.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs index b0a3b7102..21982e6bd 100644 --- a/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs +++ b/src/SharpLink.Generator/RpcGenerator.FinalCodecPlan.Physical.cs @@ -109,6 +109,30 @@ private FinalPhysicalLayoutPlan ResolvePhysicalLayout( GetPhysicalPlanSortKey(left.Layout), GetPhysicalPlanSortKey(right.Layout)); }); + + if (canonicalFields.Length > 1) + { + var deduplicated = new List(canonicalFields.Length); + int? previousOffset = null; + string? previousLayoutKey = null; + var hasPrevious = false; + foreach (var field in canonicalFields) + { + var layoutKey = GetPhysicalPlanSortKey(field.Layout); + if (hasPrevious && + field.Offset == previousOffset && + string.Equals(layoutKey, previousLayoutKey, StringComparison.Ordinal)) + { + continue; + } + + deduplicated.Add(field); + previousOffset = field.Offset; + previousLayoutKey = layoutKey; + hasPrevious = true; + } + canonicalFields = [.. deduplicated]; + } } return new FinalStructPhysicalPlan( From f534084525a1c86b53c40dc26edab173ee798a54 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:11:14 +0800 Subject: [PATCH 360/399] test: isolate client dependency validation from catalog --- .../Client/SharpLinkClientContractDependencyTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs b/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs index bd83c6239..72b301f63 100644 --- a/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs +++ b/test/SharpLink.UnitTests/Client/SharpLinkClientContractDependencyTests.cs @@ -5,6 +5,7 @@ using System.Reflection.Emit; using SharpLink.Abstractions; using SharpLink.Client; +using SharpLink.Runtime; using SharpLink.Sdk; namespace SharpLink.UnitTests.Client; @@ -19,6 +20,7 @@ public sealed class SharpLinkClientContractDependencyTests public async Task DynamicDependencyValidationShouldIncludeContractDependencies() { await using var client = SharpClientBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) .DisableRequestTimeout() .UseTcp("127.0.0.1", 1) .Build(); @@ -45,6 +47,7 @@ public async Task DynamicDependencyValidationShouldIncludeContractDependencies() public async Task ClientUnregisterShouldProtectContractDependencies() { await using var client = SharpClientBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) .DisableRequestTimeout() .UseTcp("127.0.0.1", 1) .Build(); @@ -97,6 +100,7 @@ public async Task ClientUnregisterShouldProtectContractDependencies() public async Task StaleApi4DescriptorAbiShouldBeRejectedBeforeManifestActivation() { await using var client = SharpClientBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) .DisableRequestTimeout() .UseTcp("127.0.0.1", 1) .Build(); From 814c6df6d2891f893e8e601c7562fccbc0c15a28 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:26:33 +0800 Subject: [PATCH 361/399] test: fix compatibility projection regression fixtures --- ...tCompatibilityProjectionRegressionTests.cs | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs index d7ac76dee..4f195794a 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs @@ -88,9 +88,12 @@ public interface IProjectionService : SharpLink.Sdk.IService [Test] public Task DtoReferenceValueEnvelopeChangeShouldFailContractBaseline() { - static string Source(string declarationKind) => BuildSource($$""" + static string Source(bool referenceType) + { + var declaration = referenceType ? "sealed class" : "struct"; + return BuildSource($$""" [SharpLink.Sdk.RpcSerializable] -public {{declarationKind}} Payload +public {{declaration}} Payload { [SharpLink.Sdk.RpcMember(1)] public int Value { get; set; } @@ -102,13 +105,14 @@ public interface IProjectionService : SharpLink.Sdk.IService ValueTask Echo(Payload value, CancellationToken cancellationToken); } """); + } - var baseline = RunContractGenerator(Source("class")); + var baseline = RunContractGenerator(Source(referenceType: true)); var baselineRoot = System.Text.Json.Nodes.JsonNode.Parse(baseline.Json)!.AsObject(); Ensure(baselineRoot["dtos"]!.AsArray().Single()!["shape"]?.GetValue() == "reference", "reference DTOs must persist their presence-framed envelope shape"); - var changed = RunContractGenerator(Source("struct"), baseline.Json); + var changed = RunContractGenerator(Source(referenceType: false), baseline.Json); var changedRoot = System.Text.Json.Nodes.JsonNode.Parse(changed.Json)!.AsObject(); Ensure(changedRoot["dtos"]!.AsArray().Single()!["shape"]?.GetValue() == "value", "value DTOs must persist their non-presence-framed envelope shape"); @@ -146,6 +150,14 @@ public interface IProjectionService : SharpLink.Sdk.IService public Task IdempotencyBehaviorChangeShouldFailContractBaseline() { static string Source(bool idempotent) => BuildSource($$""" +namespace SharpLink.Sdk +{ + [System.AttributeUsage(System.AttributeTargets.Method)] + public sealed class IdempotentAttribute : System.Attribute + { + } +} + [SharpLink.Sdk.RpcContract] public interface IProjectionService : SharpLink.Sdk.IService { @@ -155,8 +167,16 @@ public interface IProjectionService : SharpLink.Sdk.IService """); var baseline = RunContractGenerator(Source(idempotent: false)); - var changed = RunContractGenerator(Source(idempotent: true), baseline.Json); + var baselineMethod = System.Text.Json.Nodes.JsonNode.Parse(baseline.Json)!.AsObject()["contracts"]! + .AsArray().Single()!["methods"]!.AsArray().Single()!.AsObject(); + Ensure(baselineMethod["idempotent"]?.GetValue() == false, + "baseline must persist non-idempotent behavior"); + var changed = RunContractGenerator(Source(idempotent: true), baseline.Json); + var changedMethod = System.Text.Json.Nodes.JsonNode.Parse(changed.Json)!.AsObject()["contracts"]! + .AsArray().Single()!["methods"]!.AsArray().Single()!.AsObject(); + Ensure(changedMethod["idempotent"]?.GetValue() == true, + "current manifest must persist idempotent behavior"); Ensure(changed.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), "changing method idempotency behavior must fail baseline comparison"); return Task.CompletedTask; From 25db8db0d7c13d3d95907315d5ad2dd0fdd482e4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:30:35 +0800 Subject: [PATCH 362/399] test: keep dto shape regression on generated codec path --- .../ContractManifestCompatibilityProjectionRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs index 4f195794a..fb59c8bb9 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestCompatibilityProjectionRegressionTests.cs @@ -92,11 +92,12 @@ static string Source(bool referenceType) { var declaration = referenceType ? "sealed class" : "struct"; return BuildSource($$""" +#nullable enable [SharpLink.Sdk.RpcSerializable] public {{declaration}} Payload { [SharpLink.Sdk.RpcMember(1)] - public int Value { get; set; } + public string? Value { get; set; } } [SharpLink.Sdk.RpcContract] From edeb0971a045efb9507738c241fc18a8ffa1a7be Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:06:30 +0800 Subject: [PATCH 363/399] fix: freeze generated codec owner resolution scope --- .../Codec/RpcManifestCodecProvider.cs | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs index 6306f0795..ab6c2c5f1 100644 --- a/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs +++ b/src/SharpLink.Runtime/Codec/RpcManifestCodecProvider.cs @@ -3,10 +3,17 @@ namespace SharpLink.Runtime; +internal enum RpcGeneratedCodecResolutionScope +{ + Global, + Contract +} + /// Resolves generated codecs using the immutable policy owned by one Contract assembly generation. public static class RpcGeneratedCodecResolver { - private static readonly ConditionalWeakTable OwnerProviders = new(); + private static readonly ConditionalWeakTable ContractOwnerProviders = new(); + private static readonly ConditionalWeakTable GlobalOwnerProviders = new(); /// Gets the Codec provider bound to one generated Contract assembly. public static IRpcCodecProvider GetProvider( @@ -34,12 +41,20 @@ public static IRpcCodecProvider GetProvider( } internal static IRpcCodecProvider GetProvider(RpcGeneratedManifestRegistration registration) + => GetProvider(registration, RpcGeneratedCodecResolutionScope.Contract); + + internal static IRpcCodecProvider GetProvider( + RpcGeneratedManifestRegistration registration, + RpcGeneratedCodecResolutionScope scope) { ArgumentNullException.ThrowIfNull(registration); registration.ThrowIfDisposed(); - return OwnerProviders.GetValue( + var providers = scope == RpcGeneratedCodecResolutionScope.Contract + ? ContractOwnerProviders + : GlobalOwnerProviders; + return providers.GetValue( registration, - static owner => new RpcManifestCodecProvider(owner, owner.BaseProvider)); + owner => new RpcManifestCodecProvider(owner, owner.BaseProvider, scope)); } internal static IRpcCodecProvider GetProvider( @@ -61,15 +76,18 @@ internal sealed class RpcManifestCodecProvider : IRpcCodecProvider { private readonly RpcGeneratedManifestRegistration _owner; private readonly RpcCodecProvider? _runtimeProvider; + private readonly RpcGeneratedCodecResolutionScope _scope; private readonly ConcurrentDictionary _resolved = new(); internal RpcManifestCodecProvider( RpcGeneratedManifestRegistration owner, - IRpcCodecProvider baseProvider) + IRpcCodecProvider baseProvider, + RpcGeneratedCodecResolutionScope scope = RpcGeneratedCodecResolutionScope.Contract) { _owner = owner ?? throw new ArgumentNullException(nameof(owner)); ArgumentNullException.ThrowIfNull(baseProvider); _runtimeProvider = baseProvider as RpcCodecProvider; + _scope = scope; } public IRpcCodec GetCodec() @@ -77,14 +95,17 @@ public IRpcCodec GetCodec() _owner.ThrowIfDisposed(); var targetType = typeof(T); - // The Contract assembly compilation is the only serializer-selection authority. - // Endpoint runtime UseCodec/resolver state is intentionally not consulted here. - if (_owner.ContractCodecs.TryGetValue(targetType, out var contractRegistration)) + // Contract-owned bindings are visible only while resolving the Contract graph. A global + // generated factory is frozen to the owner's global graph and must never inherit a + // Contract-only policy merely because the same manifest also owns one. + if (_scope == RpcGeneratedCodecResolutionScope.Contract && + _owner.ContractCodecs.TryGetValue(targetType, out var contractRegistration)) + { return ResolveOwned(targetType, contractRegistration); + } - // Compatibility for hand-authored/older manifests whose generated defaults are published - // only in the owner-local global table. New generated manifests publish the complete RPC - // graph through ContractCodecs. + // Generated defaults are resolved from the owner-local global graph. Endpoint runtime + // AddCodec/UseCodecResolver state is intentionally not consulted here. if (_owner.Codecs.TryGetValue(targetType, out var ownerRegistration)) return ResolveOwned(targetType, ownerRegistration); @@ -122,28 +143,11 @@ private IRpcCodec ResolveOwned(Type targetType, RpcGeneratedCodecRegistrat _owner.ThrowIfDisposed(); var codec = _resolved.GetOrAdd( targetType, - _ => registration.GetCodec(GetRegistrationProvider(registration))); + _ => registration.GetCodec()); _owner.ThrowIfDisposed(); return Cast(codec, targetType); } - private static IRpcCodecProvider GetRegistrationProvider(RpcGeneratedCodecRegistration registration) - { - var owner = registration.Owner; - owner.ThrowIfDisposed(); - var targetType = registration.Factory.TargetType; - if (owner.ContractCodecs.TryGetValue(targetType, out var contractRegistration) && - ReferenceEquals(contractRegistration, registration)) - { - return RpcGeneratedCodecResolver.GetProvider(owner); - } - - // A context-global registration is part of the provider manifest's global generated graph. - // In particular, a referenced codec selected by another Contract must never receive that - // consumer Contract's policy provider while constructing its own nested dependencies. - return owner.BaseProvider; - } - private bool IsGeneratedDependencyAllowed( Type targetType, RpcGeneratedCodecRegistration registration, From c027b8ef9251a8e2cbbe0f53b3462b83cb51617d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:07:48 +0800 Subject: [PATCH 364/399] fix: bind generated registrations to frozen owner providers --- .../Codec/RpcCodecProvider.cs | 88 ++++++++++++++----- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs b/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs index 746d4c1cd..e7f8dfd09 100644 --- a/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs +++ b/src/SharpLink.Runtime/Codec/RpcCodecProvider.cs @@ -90,7 +90,7 @@ private IRpcCodec ResolveCodec(Type targetType) if (currentRegistration is not null) { var replacement = new ResolvedCodec( - currentRegistration.GetCodec(this), + currentRegistration.GetCodec(), currentRegistration, snapshot.Identity, isExplicit: false, @@ -117,7 +117,7 @@ private IRpcCodec ResolveCodec(Type targetType) if (currentRegistration is not null) { var generated = new ResolvedCodec( - currentRegistration.GetCodec(this), + currentRegistration.GetCodec(), currentRegistration, snapshot.Identity, isExplicit: false, @@ -323,12 +323,13 @@ internal IRpcCodecProvider ContractCodecProvider get { ThrowIfDisposed(); - if (!HasContractCodecs) - return BaseProvider; var existing = Volatile.Read(ref _contractCodecProvider); if (existing is not null) return existing; - var created = new RpcManifestCodecProvider(this, BaseProvider); + var created = new RpcManifestCodecProvider( + this, + BaseProvider, + RpcGeneratedCodecResolutionScope.Contract); return Interlocked.CompareExchange(ref _contractCodecProvider, created, null) ?? created; } } @@ -375,8 +376,12 @@ internal static RpcGeneratedManifestRegistration Create( } var ownerBox = new OwnerBox(); - var publishedCodecs = CreateRegistrations(manifest.Codecs); - var contractCodecs = CreateRegistrations(manifest.ContractCodecs); + var publishedCodecs = CreateRegistrations( + manifest.Codecs, + RpcGeneratedCodecResolutionScope.Global); + var contractCodecs = CreateRegistrations( + manifest.ContractCodecs, + RpcGeneratedCodecResolutionScope.Contract); var registration = new RpcGeneratedManifestRegistration( manifest, contractCodecs, @@ -384,25 +389,31 @@ internal static RpcGeneratedManifestRegistration Create( provider, [.. scopes]); ownerBox.Value = registration; + + foreach (var codecRegistration in publishedCodecs.Values) + codecRegistration.PrepareAdapterCodec(); + foreach (var codecRegistration in contractCodecs.Values) + codecRegistration.PrepareAdapterCodec(); + return registration; Dictionary CreateRegistrations( - IReadOnlyList factories) + IReadOnlyList factories, + RpcGeneratedCodecResolutionScope resolutionScope) { var registrations = new Dictionary(); foreach (var factory in factories.OrderBy(static factory => factory.TargetType.FullName, StringComparer.Ordinal)) { - IRpcCodec? preparedCodec = null; - if (factory.AdapterId is not null) - { - var scope = scopeByAdapterId[factory.AdapterId].Scope; - preparedCodec = factory.Create(provider, scope) ?? throw new InvalidOperationException( - $"Generated Codec factory for '{factory.TargetType.FullName}' returned null."); - ValidateCodec(factory, preparedCodec); - } + var adapterScope = factory.AdapterId is null + ? null + : scopeByAdapterId[factory.AdapterId].Scope; if (!registrations.TryAdd( factory.TargetType, - new RpcGeneratedCodecRegistration(ownerBox, factory, preparedCodec))) + new RpcGeneratedCodecRegistration( + ownerBox, + factory, + adapterScope, + resolutionScope))) { throw new InvalidOperationException( $"Manifest '{manifest.OwnerAssembly.FullName}' contains duplicate Codec target '{factory.TargetType.FullName}' in one binding scope."); @@ -503,28 +514,61 @@ internal sealed class OwnerBox internal sealed class RpcGeneratedCodecRegistration { private readonly RpcGeneratedManifestRegistration.OwnerBox _owner; - private readonly IRpcCodec? _preparedCodec; + private readonly IRpcCodecAdapterScope? _adapterScope; + private readonly RpcGeneratedCodecResolutionScope _resolutionScope; + private IRpcCodec? _preparedCodec; internal RpcGeneratedCodecRegistration( RpcGeneratedManifestRegistration.OwnerBox owner, IRpcGeneratedCodecFactory factory, - IRpcCodec? preparedCodec) + IRpcCodecAdapterScope? adapterScope, + RpcGeneratedCodecResolutionScope resolutionScope) { _owner = owner; Factory = factory; - _preparedCodec = preparedCodec; + _adapterScope = adapterScope; + _resolutionScope = resolutionScope; } internal RpcGeneratedManifestRegistration Owner => _owner.Value; internal IRpcGeneratedCodecFactory Factory { get; } - internal IRpcCodec GetCodec(IRpcCodecProvider provider) + internal void PrepareAdapterCodec() + { + if (_adapterScope is null) + return; + Owner.ThrowIfDisposed(); + var codec = Factory.Create(GetOwnerProvider(), _adapterScope) ?? throw new InvalidOperationException( + $"Generated Codec factory for '{Factory.TargetType.FullName}' returned null."); + ValidateCodec(codec); + Volatile.Write(ref _preparedCodec, codec); + } + + internal IRpcCodec GetCodec() { Owner.ThrowIfDisposed(); - var codec = _preparedCodec ?? Factory.Create(provider, adapterScope: null); + var codec = Volatile.Read(ref _preparedCodec); + if (codec is null) + { + codec = Factory.Create(GetOwnerProvider(), adapterScope: null) ?? throw new InvalidOperationException( + $"Generated Codec factory for '{Factory.TargetType.FullName}' returned null."); + ValidateCodec(codec); + } Owner.ThrowIfDisposed(); return codec; } + + private IRpcCodecProvider GetOwnerProvider() + => RpcGeneratedCodecResolver.GetProvider(Owner, _resolutionScope); + + private void ValidateCodec(IRpcCodec codec) + { + if (!Factory.IsCompatibleCodec(codec)) + { + throw new InvalidOperationException( + $"Codec returned for '{Factory.TargetType.FullName}' implements an incompatible IRpcCodec."); + } + } } internal static class SharedRpcCodec From daef5df53aee96ef9c5888ded9122c894ce7b174 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:08:16 +0800 Subject: [PATCH 365/399] test: reject endpoint codec injection into referenced graph --- ...eferencedCodecOwnerScopeRegressionTests.cs | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs index ae58191c2..f392f80c7 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs @@ -9,9 +9,13 @@ namespace SharpLink.UnitTests.Runtime; public sealed class RpcReferencedCodecOwnerScopeRegressionTests { [Test] - public void ReferencedGeneratedCodecShouldResolveNestedCodecThroughProviderOwner() + public void ReferencedGeneratedCodecShouldResolveNestedCodecThroughFrozenProviderOwner() { using var context = new SharpLinkRuntimeContextBuilder() + .AddCodec(new EndpointReferencedChildCodec()) + .UseCodecResolver(static type => type == typeof(ReferencedFallbackChild) + ? new EndpointFallbackChildCodec() + : null) .Build(includeGeneratedAssemblyCatalog: false); var providerManifest = new ProviderManifest(typeof(ReferencedPayload).Assembly); @@ -30,11 +34,19 @@ public void ReferencedGeneratedCodecShouldResolveNestedCodecThroughProviderOwner Ensure(payloadCodec is not null, "the consumer must resolve the exact referenced provider registration"); Ensure(payloadCodec!.Child is ReferencedChildCodec, - "the referenced payload factory must resolve its nested child through the provider manifest's global graph, not the consumer Contract policy"); + "the referenced payload factory must resolve its generated child through the provider manifest's frozen global graph, not an endpoint AddCodec override"); + Ensure(payloadCodec.FallbackChild is not EndpointFallbackChildCodec, + "the referenced payload factory must resolve unmanaged fallback semantics from the provider manifest's frozen graph, not endpoint UseCodecResolver state"); + Ensure(payloadCodec.FallbackChild.GetType().Name.Contains("UnsafeBlitCodec", StringComparison.Ordinal), + "the provider-owned fallback child must use the compile-time unmanaged fallback strategy"); } private sealed class ReferencedPayload { } private sealed class ReferencedChild { } + private struct ReferencedFallbackChild + { + public int Value; + } private sealed class ReferencedChildCodec : IRpcCodec { @@ -42,9 +54,24 @@ public void Serialize(in ReferencedChild value, IBufferWriter buffer) { } public ReferencedChild Deserialize(in ReadOnlySequence buffer) => new(); } - private sealed class ReferencedPayloadCodec(IRpcCodec child) : IRpcCodec + private sealed class EndpointReferencedChildCodec : IRpcCodec + { + public void Serialize(in ReferencedChild value, IBufferWriter buffer) { } + public ReferencedChild Deserialize(in ReadOnlySequence buffer) => new(); + } + + private sealed class EndpointFallbackChildCodec : IRpcCodec + { + public void Serialize(in ReferencedFallbackChild value, IBufferWriter buffer) { } + public ReferencedFallbackChild Deserialize(in ReadOnlySequence buffer) => default; + } + + private sealed class ReferencedPayloadCodec( + IRpcCodec child, + IRpcCodec fallbackChild) : IRpcCodec { internal IRpcCodec Child { get; } = child; + internal IRpcCodec FallbackChild { get; } = fallbackChild; public void Serialize(in ReferencedPayload value, IBufferWriter buffer) { } public ReferencedPayload Deserialize(in ReadOnlySequence buffer) => new(); } @@ -79,7 +106,9 @@ private sealed class ProviderManifest(Assembly ownerAssembly) : ITestGeneratedMa [ new NativeFactory(static _ => new ReferencedChildCodec()), new NativeFactory(static provider => - new ReferencedPayloadCodec(provider.GetCodec())) + new ReferencedPayloadCodec( + provider.GetCodec(), + provider.GetCodec())) ]; public IReadOnlyList ContractCodecs => []; public IReadOnlyList Dependencies => []; From 4cae114224f2b23d1acd55244295095e768477aa Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:10:27 +0800 Subject: [PATCH 366/399] test: cover local frozen owner resolution --- ...eferencedCodecOwnerScopeRegressionTests.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs index f392f80c7..2e2f8eb85 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs @@ -23,22 +23,31 @@ public void ReferencedGeneratedCodecShouldResolveNestedCodecThroughFrozenProvide context.PublishGeneratedCodecs(providerRegistration.Codecs, providerRegistration); context.AdoptGeneratedManifest(providerRegistration); + var localPayloadCodec = RpcGeneratedCodecResolver + .GetProvider(context, providerManifest.OwnerAssembly) + .GetCodec() as ReferencedPayloadCodec; + AssertFrozenOwnerGraph(localPayloadCodec, "provider manifest local resolution"); + var consumerManifest = new ConsumerManifest(typeof(IContractA).Assembly); var consumerRegistration = context.PrepareGeneratedManifest(consumerManifest); context.AdoptGeneratedManifest(consumerRegistration); - var payloadCodec = RpcGeneratedCodecResolver + var referencedPayloadCodec = RpcGeneratedCodecResolver .GetProvider(context, consumerManifest.OwnerAssembly) .GetCodec() as ReferencedPayloadCodec; + AssertFrozenOwnerGraph(referencedPayloadCodec, "referenced consumer resolution"); + } + private static void AssertFrozenOwnerGraph(ReferencedPayloadCodec? payloadCodec, string path) + { Ensure(payloadCodec is not null, - "the consumer must resolve the exact referenced provider registration"); + $"{path} must resolve the provider-owned generated payload registration"); Ensure(payloadCodec!.Child is ReferencedChildCodec, - "the referenced payload factory must resolve its generated child through the provider manifest's frozen global graph, not an endpoint AddCodec override"); + $"{path} must resolve the generated child through the provider manifest's frozen global graph, not an endpoint AddCodec override"); Ensure(payloadCodec.FallbackChild is not EndpointFallbackChildCodec, - "the referenced payload factory must resolve unmanaged fallback semantics from the provider manifest's frozen graph, not endpoint UseCodecResolver state"); + $"{path} must resolve unmanaged fallback semantics from the provider manifest's frozen graph, not endpoint UseCodecResolver state"); Ensure(payloadCodec.FallbackChild.GetType().Name.Contains("UnsafeBlitCodec", StringComparison.Ordinal), - "the provider-owned fallback child must use the compile-time unmanaged fallback strategy"); + $"{path} must use the compile-time unmanaged fallback strategy"); } private sealed class ReferencedPayload { } From 45334ee4f7293dba1f12e1a3ff4f1b83ba541975 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:13:13 +0800 Subject: [PATCH 367/399] test: avoid unassigned fallback fixture field --- .../Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs index 2e2f8eb85..0c22ab30f 100644 --- a/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs +++ b/test/SharpLink.UnitTests/Runtime/RpcReferencedCodecOwnerScopeRegressionTests.cs @@ -52,10 +52,7 @@ private static void AssertFrozenOwnerGraph(ReferencedPayloadCodec? payloadCodec, private sealed class ReferencedPayload { } private sealed class ReferencedChild { } - private struct ReferencedFallbackChild - { - public int Value; - } + private readonly record struct ReferencedFallbackChild(int Value); private sealed class ReferencedChildCodec : IRpcCodec { From 820f20273c8ff8bc07b3e78935431cfc0156e212 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:00:42 +0800 Subject: [PATCH 368/399] test: retry transient Android layout result reads --- .../run-layout-android.mjs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs b/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs index 2908ccc90..754033959 100644 --- a/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs +++ b/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs @@ -18,12 +18,32 @@ function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function waitForResult(launchOutput) { const deadline = Date.now() + 120_000; + let lastRead = null; + let lastParseError = null; while (Date.now() < deadline) { - if (adbTry(['shell','run-as',packageName,'test','-f',resultFile]).status === 0) return adb(['shell','run-as',packageName,'cat',resultFile]); + if (adbTry(['shell','run-as',packageName,'test','-f',resultFile]).status === 0) { + const read = adbTry(['shell','run-as',packageName,'cat',resultFile]); + lastRead = read; + if (read.status === 0) { + const text = read.stdout ?? ''; + try { + JSON.parse(text); + return text; + } catch (error) { + lastParseError = error; + } + } + } await delay(250); } const logcat = adbTry(['logcat','-d','-t','2000']); - throw new Error(`Android layout probe timed out.\nam start:\n${launchOutput}\nlogcat:\n${logcat.stdout ?? ''}\n${logcat.stderr ?? ''}`); + const readDiagnostics = lastRead is null + ? 'result read was never attempted successfully after the file probe' + : `last result read status: ${lastRead.status}\nstdout:\n${lastRead.stdout ?? ''}\nstderr:\n${lastRead.stderr ?? ''}`; + const parseDiagnostics = lastParseError is null + ? '' + : `\nlast JSON parse error:\n${lastParseError.stack ?? lastParseError}`; + throw new Error(`Android layout probe timed out.\nam start:\n${launchOutput}\n${readDiagnostics}${parseDiagnostics}\nlogcat:\n${logcat.stdout ?? ''}\n${logcat.stderr ?? ''}`); } async function run(mode, producerRoot, outputPath, profile, commit, sdk, runtimeFamily) { From 2153db75a46b2960304d2e631d0043a63f4aa897 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:01:32 +0800 Subject: [PATCH 369/399] ci: make preview iOS arm64 evidence non-blocking --- .github/workflows/codec-mobile-compatibility.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codec-mobile-compatibility.yml b/.github/workflows/codec-mobile-compatibility.yml index 472913f23..2cbd3d49b 100644 --- a/.github/workflows/codec-mobile-compatibility.yml +++ b/.github/workflows/codec-mobile-compatibility.yml @@ -192,9 +192,12 @@ jobs: - id: ios-simulator-x64 os: macos-26-intel rid: iossimulator-x64 + experimental: false - id: ios-simulator-arm64 os: macos-26 rid: iossimulator-arm64 + experimental: true + continue-on-error: ${{ matrix.experimental }} runs-on: ${{ matrix.os }} timeout-minutes: 50 env: @@ -338,14 +341,14 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - name: Require all non-Mono mobile evidence + - name: Require blocking non-Mono mobile evidence shell: bash env: ANDROID_RESULT: ${{ needs.android.result }} IOS_RESULT: ${{ needs.ios-coreclr.result }} run: | echo "Android CoreCLR (.NET 10): $ANDROID_RESULT" - echo "iOS CoreCLR (.NET 11 preview): $IOS_RESULT" + echo "iOS CoreCLR (.NET 11 preview; x64 blocking, arm64 experimental): $IOS_RESULT" if [[ "$ANDROID_RESULT" != "success" || "$IOS_RESULT" != "success" ]]; then exit 1 fi From 7eda38deeb4b905b207aafd895bc3da3c7f2bf4f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:11:41 +0800 Subject: [PATCH 370/399] ci: fix Android layout retry syntax --- .../run-layout-android.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs b/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs index 754033959..ace7da28c 100644 --- a/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs +++ b/test/SharpLink.CodecCompatibility.Android/run-layout-android.mjs @@ -37,10 +37,10 @@ async function waitForResult(launchOutput) { await delay(250); } const logcat = adbTry(['logcat','-d','-t','2000']); - const readDiagnostics = lastRead is null + const readDiagnostics = lastRead === null ? 'result read was never attempted successfully after the file probe' : `last result read status: ${lastRead.status}\nstdout:\n${lastRead.stdout ?? ''}\nstderr:\n${lastRead.stderr ?? ''}`; - const parseDiagnostics = lastParseError is null + const parseDiagnostics = lastParseError === null ? '' : `\nlast JSON parse error:\n${lastParseError.stack ?? lastParseError}`; throw new Error(`Android layout probe timed out.\nam start:\n${launchOutput}\n${readDiagnostics}${parseDiagnostics}\nlogcat:\n${logcat.stdout ?? ''}\n${logcat.stderr ?? ''}`); From 671291d1ee6bbd2e3b9b4fda187bc9f1f19cc029 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:32:34 +0800 Subject: [PATCH 371/399] test: build dependency fixtures in solution configurations --- Sharplink.slnx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sharplink.slnx b/Sharplink.slnx index 9ce9dfa01..f212e3b05 100644 --- a/Sharplink.slnx +++ b/Sharplink.slnx @@ -40,6 +40,9 @@ + + + From c9c5e2a10844ede7fe192434446c2f34dabc6f3d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:51:27 +0800 Subject: [PATCH 372/399] fix: make generated dependency binding trim safe --- .../SharpLinkGeneratedDependencyBinding.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs index 59d3a5a77..76376d384 100644 --- a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs +++ b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs @@ -23,29 +23,22 @@ internal static class SharpLinkGeneratedDependencyBinding return null; } - AssemblyName? reference = null; - foreach (var candidate in ownerAssembly.GetReferencedAssemblies()) - { - if (!AssemblyName.ReferenceMatchesDefinition(candidate, requested)) - continue; - reference = candidate; - break; - } - if (reference is null) - return null; - + // The generated manifest already records the compile-time dependency identity. Resolve that + // identity through the owner's load context so the result stays bound to the exact runtime + // assembly generation without depending on Assembly.GetReferencedAssemblies(), whose metadata + // view is not preserved by trimming/NativeAOT. var loadContext = AssemblyLoadContext.GetLoadContext(ownerAssembly); if (loadContext is null) return null; foreach (var loaded in loadContext.Assemblies) { - if (AssemblyName.ReferenceMatchesDefinition(loaded.GetName(), reference)) + if (AssemblyName.ReferenceMatchesDefinition(loaded.GetName(), requested)) return loaded; } try { - return loadContext.LoadFromAssemblyName(reference); + return loadContext.LoadFromAssemblyName(requested); } catch (Exception exception) when ( exception is FileNotFoundException or FileLoadException or BadImageFormatException) From ffa82bd8e15fa906860f73e4917e71a611527d50 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:11:09 +0800 Subject: [PATCH 373/399] ci: retry Android codec result reads --- .../run-android.mjs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.CodecCompatibility.Android/run-android.mjs b/test/SharpLink.CodecCompatibility.Android/run-android.mjs index d30de77c7..2d5ad547c 100644 --- a/test/SharpLink.CodecCompatibility.Android/run-android.mjs +++ b/test/SharpLink.CodecCompatibility.Android/run-android.mjs @@ -47,14 +47,33 @@ function collectDiagnostics(launchOutput) { async function waitForResult(launchOutput) { const deadline = Date.now() + 120_000; + let lastRead = null; + let lastParseError = null; while (Date.now() < deadline) { const exists = adbTry(['shell', 'run-as', packageName, 'test', '-f', resultFile]); if (exists.status === 0) { - return adb(['shell', 'run-as', packageName, 'cat', resultFile]); + const read = adbTry(['shell', 'run-as', packageName, 'cat', resultFile]); + lastRead = read; + if (read.status === 0) { + const text = read.stdout ?? ''; + try { + JSON.parse(text); + return text; + } catch (error) { + lastParseError = error; + } + } } await delay(250); } - throw new Error(`Android probe timed out waiting for app-private result file.\n${collectDiagnostics(launchOutput)}`); + const readDiagnostics = lastRead === null + ? 'result read was never attempted after the file probe succeeded' + : `last result read status: ${lastRead.status}\nstdout:\n${lastRead.stdout ?? ''}\nstderr:\n${lastRead.stderr ?? ''}`; + const parseDiagnostics = lastParseError === null + ? '' + : `\nlast JSON parse error:\n${lastParseError.stack ?? lastParseError}`; + throw new Error( + `Android probe timed out waiting for a complete app-private result file.\n${readDiagnostics}${parseDiagnostics}\n\n${collectDiagnostics(launchOutput)}`); } async function runAndroid(mode, producerRoot, outputPath, commit, sdkVersion, runtimeFamily) { From 814b471097de328449672fc01eb629776c5d4c8f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:29:46 +0800 Subject: [PATCH 374/399] test: cover generated dependency version binding --- ...DependencyBindingVersionRegressionTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/GeneratedDependencyBindingVersionRegressionTests.cs diff --git a/test/SharpLink.IntegrationTests/GeneratedDependencyBindingVersionRegressionTests.cs b/test/SharpLink.IntegrationTests/GeneratedDependencyBindingVersionRegressionTests.cs new file mode 100644 index 000000000..18229b975 --- /dev/null +++ b/test/SharpLink.IntegrationTests/GeneratedDependencyBindingVersionRegressionTests.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace SharpLink.IntegrationTests; + +public sealed class GeneratedDependencyBindingVersionRegressionTests +{ + [Test] + [NotInParallel] + public void LoadedLowerVersionMustNotSatisfyHigherGeneratedDependencyIdentity() + { + var directory = GetProjectOutputDirectory("SharpLink.ModuleDependencyConsumer"); + var loadContext = new DirectoryLoadContext("generated-dependency-version-binding", directory); + try + { + var provider = loadContext.LoadFromAssemblyPath( + Path.Combine(directory, "SharpLink.ReferencedCodecProvider.dll")); + var owner = loadContext.LoadFromAssemblyPath( + Path.Combine(directory, "SharpLink.ModuleDependencyConsumer.dll")); + + var requested = new AssemblyName(provider.FullName!); + var loadedVersion = requested.Version ?? new Version(0, 0, 0, 0); + requested.Version = new Version( + checked(loadedVersion.Major + 1), + loadedVersion.Minor, + Math.Max(loadedVersion.Build, 0), + Math.Max(loadedVersion.Revision, 0)); + + var resolved = SharpLinkGeneratedDependencyBinding.Resolve(owner, requested.FullName!); + if (resolved is not null) + { + throw new Exception( + $"Generated dependency '{requested.FullName}' must not bind to already-loaded lower/incompatible assembly '{resolved.FullName}'."); + } + } + finally + { + loadContext.Unload(); + } + } + + private static string GetProjectOutputDirectory(string projectName) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Sharplink.slnx"))) + directory = directory.Parent; + if (directory is null) + throw new DirectoryNotFoundException("SharpLink workspace root was not found."); + return Path.Combine( + directory.FullName, + "test", + projectName, + "bin", + "Release", + "net10.0"); + } + + private sealed class DirectoryLoadContext(string name, string directory) + : AssemblyLoadContext(name, isCollectible: true) + { + protected override Assembly? Load(AssemblyName assemblyName) + { + var shared = Default.Assemblies.FirstOrDefault(candidate => + AssemblyName.ReferenceMatchesDefinition(candidate.GetName(), assemblyName)); + if (shared is not null) + return shared; + var path = Path.Combine(directory, $"{assemblyName.Name}.dll"); + return File.Exists(path) ? LoadFromAssemblyPath(path) : null; + } + } +} From fb74f22c85f8fa9b9838600b21467a4a97454ec8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:30:07 +0800 Subject: [PATCH 375/399] fix: preserve full generated dependency binding --- .../SharpLinkGeneratedDependencyBinding.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs index 76376d384..5839b10a5 100644 --- a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs +++ b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs @@ -26,15 +26,12 @@ internal static class SharpLinkGeneratedDependencyBinding // The generated manifest already records the compile-time dependency identity. Resolve that // identity through the owner's load context so the result stays bound to the exact runtime // assembly generation without depending on Assembly.GetReferencedAssemblies(), whose metadata - // view is not preserved by trimming/NativeAOT. + // view is not preserved by trimming/NativeAOT. Delegate loaded-assembly reuse to the ALC binder + // as well: AssemblyName.ReferenceMatchesDefinition compares only the simple name and would let + // an incompatible already-loaded version/culture/public-key identity satisfy this dependency. var loadContext = AssemblyLoadContext.GetLoadContext(ownerAssembly); if (loadContext is null) return null; - foreach (var loaded in loadContext.Assemblies) - { - if (AssemblyName.ReferenceMatchesDefinition(loaded.GetName(), requested)) - return loaded; - } try { From 92b4f0aaf4010a057fb316a1f24917dd2c854042 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:36:13 +0800 Subject: [PATCH 376/399] test: cover contract baseline assembly identity --- ...ManifestAssemblyIdentityRegressionTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs new file mode 100644 index 000000000..d972213fb --- /dev/null +++ b/test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs @@ -0,0 +1,27 @@ +using System.Linq; +using System.Text.Json.Nodes; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public void AssemblyLogicalIdentityChangeShouldFailContractBaseline() + { + var source = SimpleContract("ValueTask Echo(int value);"); + var baseline = RunContractGenerator(source); + Ensure(!baseline.Diagnostics.Any(IsCompatibilityDiagnostic), + "baseline assembly identity fixture should generate without compatibility diagnostics"); + + var root = JsonNode.Parse(baseline.Json)!.AsObject(); + Ensure(root["assemblyLogicalIdentity"]?.GetValue() == "ContractManifestTestAssembly", + "contract manifest must persist the same logical assembly identity used by RpcAssemblyHash"); + + var changedAssemblyBaseline = RewriteManifest( + baseline.Json, + manifest => manifest["assemblyLogicalIdentity"] = "Other.Contracts"); + var current = RunContractGenerator(source, changedAssemblyBaseline); + Ensure(current.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), + "changing only the baseline assembly logical identity must require SHARPLINK030"); + } +} From fbe6445766e85c2b970fa5218ebe2e38594316ff Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:37:01 +0800 Subject: [PATCH 377/399] fix: persist assembly identity in contract baseline --- .../RpcGenerator.ContractManifest.Infrastructure.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs index c5452ef83..db18126d2 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs @@ -13,7 +13,8 @@ manifest.Dtos is null || manifest.Codecs is null || manifest.Enums is null || manifest.Unions is null || - manifest.Services is null) + manifest.Services is null || + string.IsNullOrWhiteSpace(manifest.AssemblyLogicalIdentity)) { return false; } @@ -213,6 +214,7 @@ private sealed record ContractManifestModels( ImmutableArray Services, ImmutableArray Codecs, ImmutableArray CodecHashes, + string AssemblyLogicalIdentity, ImmutableArray Enums, ImmutableArray Unions); @@ -248,6 +250,7 @@ private sealed class ContractManifestDocument public int Version { get; set; } = ContractManifestFormatVersion; public string GeneratorVersion { get; set; } = ExecutingGeneratorVersion; public string SchemaFingerprint { get; set; } = string.Empty; + public string AssemblyLogicalIdentity { get; set; } = string.Empty; public List Contracts { get; set; } = []; public List Dtos { get; set; } = []; [JsonRequired] From 99950c0bc6b38781f475721b39c959ec522dfa7a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:38:06 +0800 Subject: [PATCH 378/399] fix: compare contract assembly identity --- .../RpcGenerator.ContractManifest.cs | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index d59368a48..b28be0f51 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -102,6 +102,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( ImmutableArray services, ImmutableArray codecs, ImmutableArray codecHashes, + string assemblyLogicalIdentity, ImmutableArray generatedEnums, ImmutableArray unions, ImmutableArray additionalTexts, @@ -109,7 +110,14 @@ private static ContractManifestAnalysis AnalyzeContractManifest( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var document = CreateContractManifest(interfaces, services, codecs, codecHashes, generatedEnums, unions); + var document = CreateContractManifest( + interfaces, + services, + codecs, + codecHashes, + assemblyLogicalIdentity, + generatedEnums, + unions); var diagnostics = ValidateCurrentContractManifest(document); if (!string.IsNullOrWhiteSpace(options.BaselinePath)) @@ -154,7 +162,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( ContractCompatibilityKind.BaselineInvalid, Location.None, options.BaselinePath, - "one or more Codec entries, enum entries, or opaque payload references are missing required semantic identity", + "one or more required assembly, Codec, enum, or opaque payload identities are missing", "regenerate the baseline with the current SharpLink SDK")); } else if (string.IsNullOrWhiteSpace(baseline.SchemaFingerprint) || @@ -172,6 +180,18 @@ private static ContractManifestAnalysis AnalyzeContractManifest( } else { + if (!string.Equals( + baseline.AssemblyLogicalIdentity, + document.AssemblyLogicalIdentity, + StringComparison.Ordinal)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.WireType, + Location.None, + document.AssemblyLogicalIdentity, + $"assembly logical identity changed from '{baseline.AssemblyLogicalIdentity}' to '{document.AssemblyLogicalIdentity}'", + "restore the previous assembly logical identity or publish a new contract assembly/baseline")); + } diagnostics.AddRange(CompareContractManifests(baseline, document)); } } @@ -203,10 +223,14 @@ private static ContractManifestDocument CreateContractManifest( ImmutableArray services, ImmutableArray codecs, ImmutableArray codecHashes, + string assemblyLogicalIdentity, ImmutableArray generatedEnums, ImmutableArray unions) { - var document = new ContractManifestDocument(); + var document = new ContractManifestDocument + { + AssemblyLogicalIdentity = assemblyLogicalIdentity + }; var codecsByType = codecs .GroupBy(static codec => RemoveGlobalPrefix(codec.TypeName), StringComparer.Ordinal) .ToDictionary( From 5760596393a9cee0c7a1d4616ed8448b2effd8cb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:39:18 +0800 Subject: [PATCH 379/399] fix: project assembly identity into contract manifest --- src/SharpLink.Generator/RpcGenerator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 081fb5b86..81c83e135 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -379,6 +379,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) value.Left.Left.Right, GetContractManifestCodecs(value.Left.Right), value.Left.Right.CodecHashes, + value.Left.Right.AssemblyLogicalIdentity, value.Left.Right.Enums, value.Right)); var contractManifestOptions = context.AnalyzerConfigOptionsProvider @@ -391,6 +392,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) value.Left.Left.Services, value.Left.Left.Codecs, value.Left.Left.CodecHashes, + value.Left.Left.AssemblyLogicalIdentity, value.Left.Left.Enums, value.Left.Left.Unions, value.Left.Right, From 4622805248366aaa38fafbf6c8c53b984559402a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:46:26 +0800 Subject: [PATCH 380/399] test: cover global referenced codec dependency pinning --- ...eferencedCodecDependencyRegressionTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/RpcGlobalReferencedCodecDependencyRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/RpcGlobalReferencedCodecDependencyRegressionTests.cs b/test/SharpLink.Generator.Tests/RpcGlobalReferencedCodecDependencyRegressionTests.cs new file mode 100644 index 000000000..85269a56c --- /dev/null +++ b/test/SharpLink.Generator.Tests/RpcGlobalReferencedCodecDependencyRegressionTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public Task GlobalOnlyGeneratedCodecShouldPinReferencedChildHash() + { + var sdk = CreateMetadataReference("SharpLink.Sdk", BuildSource(string.Empty)); + var referenced = CreateMetadataReference( + "ReferencedGlobalPayload", + """ +using System; + +[assembly: SharpLink.Abstractions.SharpLinkGeneratedCodecIdentityAttribute( + typeof(Referenced.Payload), + 0x5151515151515151UL, + 0x6262626262626262UL)] +[assembly: SharpLink.Abstractions.SharpLinkGeneratedAssemblyManifestAttribute( + typeof(Referenced.Manifest), + 4, + 2, + "2.0.0-test", + "sharplink-2.0-api4-rpcchannel-codec-provider-v4")] + +namespace SharpLink.Abstractions +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class SharpLinkGeneratedCodecIdentityAttribute : Attribute + { + public SharpLinkGeneratedCodecIdentityAttribute(Type targetType, ulong high, ulong low) { } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] + public sealed class SharpLinkGeneratedAssemblyManifestAttribute : Attribute + { + public SharpLinkGeneratedAssemblyManifestAttribute( + Type manifestType, + int apiVersion, + int protocolVersion, + string generatorVersion, + string abiIdentity) { } + } +} + +namespace Referenced +{ + public sealed class Payload { public int Value { get; set; } } + public sealed class Manifest { } +} +"""); + + const string source = """ +using SharpLink.Sdk; + +[RpcSerializable] +public sealed class GlobalHolder +{ + public Referenced.Payload Payload { get; set; } = new(); +} +"""; + + var manifest = RunGeneratorAndGetSources(source, sdk, referenced) + .Single(static generated => generated.Contains( + "ISharpLinkGeneratedAssemblyManifest", + StringComparison.Ordinal)); + + Ensure( + manifest.Contains("ISharpLinkReferencedCodecDependencyManifest", StringComparison.Ordinal) && + manifest.Contains("new SharpLinkReferencedCodecDependency(", StringComparison.Ordinal) && + manifest.Contains("typeof(global::Referenced.Payload)", StringComparison.Ordinal) && + manifest.Contains("5859553999884210513UL", StringComparison.Ordinal) && + manifest.Contains("7089336938131513954UL", StringComparison.Ordinal), + "a global-only generated Codec must pin the exact referenced child CodecHash used by its declared root CodecHash"); + return Task.CompletedTask; + } +} From 88edee7779c77f14ef77c6869550cbe1909ebf74 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:47:30 +0800 Subject: [PATCH 381/399] fix: retain referenced hashes for global codec graph --- src/SharpLink.Generator/RpcGenerator.DtoModels.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.DtoModels.cs b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs index 909c290f6..389ceced9 100644 --- a/src/SharpLink.Generator/RpcGenerator.DtoModels.cs +++ b/src/SharpLink.Generator/RpcGenerator.DtoModels.cs @@ -38,6 +38,8 @@ internal sealed record DtoGenerationResult( { public ImmutableArray CodecHashes { get; init; } = ImmutableArray.Empty; + public ImmutableArray ReferencedCodecHashes { get; init; } = + ImmutableArray.Empty; public ImmutableArray UnsafeBlitRequirements { get; init; } = ImmutableArray.Empty; public ImmutableArray UnsafeBlitAutoLayoutDiagnostics { get; init; } = @@ -62,6 +64,7 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) x.ContractCodecs.Length != y.ContractCodecs.Length || x.FinalCodecBoundTypes.Length != y.FinalCodecBoundTypes.Length || x.CodecHashes.Length != y.CodecHashes.Length || + x.ReferencedCodecHashes.Length != y.ReferencedCodecHashes.Length || x.UnsafeBlitRequirements.Length != y.UnsafeBlitRequirements.Length || x.UnsafeBlitAutoLayoutDiagnostics.Length != y.UnsafeBlitAutoLayoutDiagnostics.Length || x.Diagnostics.Length != y.Diagnostics.Length || x.Enums.Length != y.Enums.Length || @@ -86,6 +89,11 @@ public bool Equals(DtoGenerationResult? x, DtoGenerationResult? y) if (x.CodecHashes[index] != y.CodecHashes[index]) return false; } + for (var index = 0; index < x.ReferencedCodecHashes.Length; index++) + { + if (x.ReferencedCodecHashes[index] != y.ReferencedCodecHashes[index]) + return false; + } for (var index = 0; index < x.UnsafeBlitRequirements.Length; index++) { if (x.UnsafeBlitRequirements[index] != y.UnsafeBlitRequirements[index]) @@ -153,6 +161,12 @@ public int GetHashCode(DtoGenerationResult obj) hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); hash = unchecked(hash * 31 + codecHash.IsReferenced.GetHashCode()); } + foreach (var codecHash in obj.ReferencedCodecHashes) + { + hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(codecHash.TypeName)); + hash = unchecked(hash * 31 + codecHash.High.GetHashCode()); + hash = unchecked(hash * 31 + codecHash.Low.GetHashCode()); + } foreach (var requirement in obj.UnsafeBlitRequirements) { hash = unchecked(hash * 31 + StringComparer.Ordinal.GetHashCode(requirement.TypeName)); From a0c6fffd8aa01a7747dc6ead38f9321ec39545ae Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:49:53 +0800 Subject: [PATCH 382/399] fix: collect global referenced codec hashes --- .../RpcGenerator.CodecPolicyOwnership.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs index 776b1e90e..5b518f1f9 100644 --- a/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs +++ b/src/SharpLink.Generator/RpcGenerator.CodecPolicyOwnership.cs @@ -49,6 +49,13 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( includeContracts: true); var contractPolicy = contractPolicyState.FinalizeResolvedCodecCandidates(contractPolicyGraph); var codecHashes = contractPolicyState.BuildFinalCodecHashes(contractPolicyGraph); + var referencedCodecHashes = standaloneHashes + .Concat(codecHashes) + .Where(static hash => hash.IsReferenced) + .GroupBy(static hash => hash.TypeName, StringComparer.Ordinal) + .Select(static group => group.First()) + .OrderBy(static hash => hash.TypeName, StringComparer.Ordinal) + .ToImmutableArray(); var unsafeBlitAutoLayoutDiagnostics = DtoAnalysisState.BuildUnsafeBlitAutoLayoutDiagnostics(contractPolicyGraph); var unsafeBlitRequirements = BuildUnsafeBlitRequirements(standaloneGraph, contractPolicyGraph); @@ -148,6 +155,7 @@ private static DtoGenerationResult AnalyzeGeneratedCodecsWithPolicyOwnership( enums) { CodecHashes = codecHashes, + ReferencedCodecHashes = referencedCodecHashes, UnsafeBlitRequirements = unsafeBlitRequirements, UnsafeBlitAutoLayoutDiagnostics = unsafeBlitAutoLayoutDiagnostics, AssemblyLogicalIdentity = compilation.Assembly.Identity.Name From 54b04a6980bce6c7488ee0e84ac7b60c29fbc6b6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:51:11 +0800 Subject: [PATCH 383/399] fix: emit all referenced codec dependency hashes --- src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs index e0ff3bab5..280585b41 100644 --- a/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs +++ b/src/SharpLink.Generator/RpcGenerator.ManifestEmitter.cs @@ -9,6 +9,7 @@ private static string GenerateAssemblyManifest( ImmutableArray codecs, ImmutableArray contractCodecs, ImmutableArray codecHashes, + ImmutableArray referencedCodecHashes, string assemblyLogicalIdentity) { var contracts = GetContractModels(interfaces); @@ -32,8 +33,7 @@ private static string GenerateAssemblyManifest( .Distinct(StringComparer.Ordinal) .OrderBy(static dependency => dependency, StringComparer.Ordinal) .ToArray(); - var referencedCodecDependencies = codecHashes - .Where(static codecHash => codecHash.IsReferenced) + var referencedCodecDependencies = referencedCodecHashes .OrderBy(static codecHash => codecHash.TypeName, StringComparer.Ordinal) .ToArray(); var compileTimeDescriptor = BuildCompileTimeDescriptor(contracts, serviceModels, codecs, contractCodecs); From 49d038810fe6c5d439d3f2b610196563c6a3ea46 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:52:31 +0800 Subject: [PATCH 384/399] fix: route referenced hashes into generated manifest --- src/SharpLink.Generator/RpcGenerator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 81c83e135..7112378b9 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -347,6 +347,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) codecs, contractCodecs, codecHashes, + value.Right.ReferencedCodecHashes, value.Right.AssemblyLogicalIdentity); if (!string.IsNullOrEmpty(code)) { From 0b1c6799500d5dfe8e047d177362d4f547e9d175 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:06:50 +0800 Subject: [PATCH 385/399] fix: require running typed codec dependencies --- .../SharpLinkClient.AssemblyRegistration.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs index 438677c5c..5acb2d44d 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs @@ -497,6 +497,20 @@ private static bool ManifestDependsOn( $"Generated dependency '{dependency}' must resolve through '{self}' to the exact registered and running Assembly generation before registration.", incoming.OwnerAssembly, "Dependency"); } + if (incoming is ISharpLinkReferencedCodecDependencyManifest referencedManifest) + { + foreach (var dependency in referencedManifest.ReferencedCodecDependencies) + { + var dependencyAssembly = dependency.TargetType.Assembly; + if (ReferenceEquals(dependencyAssembly, incoming.OwnerAssembly) || available.Contains(dependencyAssembly)) + continue; + return CreateError( + SharpLinkAssemblyRegistrationErrorCode.MissingDependency, + $"Referenced generated Codec dependency '{dependency.TargetType.FullName}' must be owned by the exact registered and running Assembly generation '{dependencyAssembly.FullName}' before registration.", + incoming.OwnerAssembly, + "Dependency"); + } + } return null; } From 97d1e9390debcd3b546b73fe1d8de4f2a96bddf0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:08:09 +0800 Subject: [PATCH 386/399] fix: require running typed codec dependencies --- .../SharpLinkServer.AssemblyRegistration.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs index 389f02ef2..03315893d 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs @@ -657,6 +657,20 @@ private static bool ManifestDependsOn( incoming.OwnerAssembly, artifact: "Dependency"); } + if (incoming is ISharpLinkReferencedCodecDependencyManifest referencedManifest) + { + foreach (var dependency in referencedManifest.ReferencedCodecDependencies) + { + var dependencyAssembly = dependency.TargetType.Assembly; + if (ReferenceEquals(dependencyAssembly, incoming.OwnerAssembly) || available.Contains(dependencyAssembly)) + continue; + return CreateError( + SharpLinkAssemblyRegistrationErrorCode.MissingDependency, + $"Referenced generated Codec dependency '{dependency.TargetType.FullName}' must be owned by the exact registered and running Assembly generation '{dependencyAssembly.FullName}' before registration.", + incoming.OwnerAssembly, + artifact: "Dependency"); + } + } return null; } From 9d9a47bd0d3f4fb4ce72645c9e3deaaec240528b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:08:30 +0800 Subject: [PATCH 387/399] test: reject typed dependants of draining modules --- ...ningReferencedDependencyRegressionTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/RuntimeAssemblyDrainingReferencedDependencyRegressionTests.cs diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyDrainingReferencedDependencyRegressionTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyDrainingReferencedDependencyRegressionTests.cs new file mode 100644 index 000000000..3d5422e88 --- /dev/null +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyDrainingReferencedDependencyRegressionTests.cs @@ -0,0 +1,65 @@ +using System.Collections; +using System.Reflection; + +namespace SharpLink.IntegrationTests; + +public sealed partial class RuntimeAssemblyIntegrationTests +{ + [Test] + [NotInParallel] + public async Task ReferencedCodecDependencyShouldRequireRunningProviderOnClientAndServer() + { + await using var harness = await DynamicHarness.CreateAsync(); + var directory = GetProjectOutputDirectory("SharpLink.ReferencedCodecConsumer"); + var loadContext = new PluginLoadContext("referenced-codec-draining-admission", directory); + try + { + var provider = loadContext.LoadFromAssemblyPath( + Path.Combine(directory, "SharpLink.ReferencedCodecProvider.dll")); + var consumer = loadContext.LoadFromAssemblyPath( + Path.Combine(directory, "SharpLink.ReferencedCodecConsumer.dll")); + + Ensure(harness.Client.RegisterAssembly(provider).Succeeded, + "client registers referenced Codec provider before drain"); + Ensure(harness.Server.RegisterAssembly(provider).Succeeded, + "server registers referenced Codec provider before drain"); + + MarkDynamicModuleDraining(harness.Client, provider); + MarkDynamicModuleDraining(harness.Server, provider); + + var clientResult = harness.Client.RegisterAssembly(consumer); + Ensure(!clientResult.Succeeded && + clientResult.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.MissingDependency && + clientResult.Error.Message.Contains("registered and running Assembly generation", StringComparison.Ordinal), + $"client must reject a new typed dependant while its exact provider is draining even though the provider Codec registration is still published: {clientResult.Error}"); + + var serverResult = harness.Server.RegisterAssembly(consumer); + Ensure(!serverResult.Succeeded && + serverResult.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.MissingDependency && + serverResult.Error.Message.Contains("registered and running Assembly generation", StringComparison.Ordinal), + $"server must reject a new typed dependant while its exact provider is draining even though the provider Codec registration is still published: {serverResult.Error}"); + + Ensure((await harness.Client.UnregisterAssemblyAsync(provider, TimeSpan.FromSeconds(2))).ReferencesReleased, + "client releases manually-draining provider after rejected dependant"); + Ensure((await harness.Server.UnregisterAssemblyAsync(provider, TimeSpan.FromSeconds(2))).ReferencesReleased, + "server releases manually-draining provider after rejected dependant"); + } + finally + { + loadContext.Unload(); + } + } + + private static void MarkDynamicModuleDraining(object endpoint, Assembly assembly) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var field = endpoint.GetType().GetField("_dynamicModules", flags) + ?? throw new InvalidOperationException($"Dynamic module registry was not available from '{endpoint.GetType()}'."); + if (field.GetValue(endpoint) is not IDictionary modules || modules[assembly] is not { } module) + throw new InvalidOperationException($"Dynamic module for '{assembly.FullName}' was not registered."); + var beginDraining = module.GetType().GetMethod("TryBeginDraining", flags) + ?? throw new MissingMethodException(module.GetType().FullName, "TryBeginDraining"); + Ensure(beginDraining.Invoke(module, null) is true, + $"dynamic module '{assembly.FullName}' must transition from Running to Draining for admission regression setup"); + } +} From d8e4f9c8d49ce4d4a9d52db0dcde6922f6d16982 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:11:50 +0800 Subject: [PATCH 388/399] fix: revalidate replacement dependencies before publish --- .../SharpLinkClient.AssemblyRegistration.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs index 5acb2d44d..84d46f93f 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyRegistration.cs @@ -237,6 +237,16 @@ public ValueTask ReplaceAssemblyAsync( newAssembly); return ValueTask.FromResult(SharpLinkAssemblyReplacementResult.Failure(rollbackError)); } + var dependencyError = ValidateDependencies( + manifest!, + _dynamicModules.Values + .Where(module => !ReferenceEquals(module, oldModule)) + .ToArray()); + if (dependencyError is not null) + { + rollbackError = dependencyError; + return ValueTask.FromResult(SharpLinkAssemblyReplacementResult.Failure(dependencyError)); + } drainCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); From a7d4fc47b66ad91f73e84825d2497035c157fde7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:13:16 +0800 Subject: [PATCH 389/399] fix: revalidate replacement dependencies before publish --- .../SharpLinkServer.AssemblyRegistration.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs index 03315893d..92f80a895 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyRegistration.cs @@ -271,6 +271,17 @@ public ValueTask ReplaceAssemblyAsync( } else { + var dependencyError = ValidateDependencies( + manifest!, + _dynamicModules.Values + .Where(module => !ReferenceEquals(module, oldModule)) + .ToArray()); + if (dependencyError is not null) + { + rollbackError = dependencyError; + return ValueTask.FromResult(SharpLinkAssemblyReplacementResult.Failure(dependencyError)); + } + drainCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); drainOperation = drainCompletion.Task; From 25a1ee9f66e797a05152598077f522594c1a81ea Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:18:10 +0800 Subject: [PATCH 390/399] fix: begin dynamic drain under registry lock --- src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs index bb1da4651..ae72f7146 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs @@ -23,6 +23,7 @@ public ValueTask UnregisterAssemblyAsync( TaskCreationOptions.RunContinuationsAsynchronously); operation = completion.Task; _unregisterOperations.Add(assembly, operation); + module.TryBeginDraining(); _ = CompleteUnregisterOperationAsync(assembly, module, gracefulTimeout, completion); if (State != SharpLinkConnectionState.Draining) { @@ -40,7 +41,6 @@ private async Task UnregisterCoreAsync( SharpLinkDynamicModule module, TimeSpan gracefulTimeout) { - module.TryBeginDraining(); var drainTask = module.WaitForDrainAsync(); if (!drainTask.IsCompleted) { From 30a32cada7885254ac14114e9c75b5c693c1ffaa Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:18:37 +0800 Subject: [PATCH 391/399] fix: begin dynamic drain under registry lock --- src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs index e1877dea9..8bf3c8dea 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs @@ -28,6 +28,7 @@ public ValueTask UnregisterAssemblyAsync( TaskCreationOptions.RunContinuationsAsynchronously); operation = completion.Task; _unregisterOperations.Add(assembly, operation); + module.TryBeginDraining(); _ = CompleteUnregisterOperationAsync(assembly, module, gracefulTimeout, completion); TrackFrameworkTask( operation, @@ -49,7 +50,6 @@ private async Task UnregisterCoreAsync( SharpLinkDynamicModule module, TimeSpan gracefulTimeout) { - module.TryBeginDraining(); var drainTask = module.WaitForDrainAsync(); if (!drainTask.IsCompleted) { From 3712154f1deacc1104e9822ca7ddb05432fb168b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:23:21 +0800 Subject: [PATCH 392/399] test: preserve published union tag reservations --- ...ractManifestUnionRemovalRegressionTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs diff --git a/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs new file mode 100644 index 000000000..521f39aff --- /dev/null +++ b/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs @@ -0,0 +1,34 @@ +using System.Linq; + +namespace SharpLink.Generator.Tests; + +public partial class RpcAnalyzerTests +{ + [Test] + public void RemovingPublishedUnionTagShouldFailContractBaseline() + { + var baselineSource = BuildSource(""" +public sealed class FirstCase : IResultUnion { } +public sealed class SecondCase : IResultUnion { } + +[SharpLink.Sdk.RpcUnionCase(1, typeof(FirstCase))] +[SharpLink.Sdk.RpcUnionCase(2, typeof(SecondCase))] +public interface IResultUnion { } +"""); + var currentSource = BuildSource(""" +public sealed class FirstCase : IResultUnion { } +public sealed class SecondCase : IResultUnion { } + +[SharpLink.Sdk.RpcUnionCase(1, typeof(FirstCase))] +public interface IResultUnion { } +"""); + + var baseline = RunContractGenerator(baselineSource); + Ensure(!baseline.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK033"), + "valid baseline union tags must not report compatibility diagnostics"); + + var current = RunContractGenerator(currentSource, baseline.Json); + Ensure(current.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK033"), + "removing a published union tag must report SHARPLINK033 so the tag reservation cannot disappear from future baselines"); + } +} From 1765562060694f498aa6fa59104d49795dec952f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:36:52 +0800 Subject: [PATCH 393/399] fix: preserve published union tag reservations --- .../RpcGenerator.ContractManifest.Compatibility.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs index 355eb747a..bb11f5bd7 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -350,8 +350,17 @@ private static IEnumerable CompareContractManif var currentCases = newUnion.Cases.ToDictionary(static item => item.Tag); foreach (var oldCase in oldUnion.Cases) { - if (currentCases.TryGetValue(oldCase.Tag, out var newCase) && - !string.Equals(oldCase.Type, newCase.Type, StringComparison.Ordinal)) + if (!currentCases.TryGetValue(oldCase.Tag, out var newCase)) + { + diagnostics.Add(Change( + ContractCompatibilityKind.UnionTag, + newUnion.SourceLocation, + newUnion.Name, + $"published union tag {oldCase.Tag} for {oldCase.Type} was removed", + "restore the published tag mapping so it remains reserved across contract baselines")); + continue; + } + if (!string.Equals(oldCase.Type, newCase.Type, StringComparison.Ordinal)) { diagnostics.Add(Change( ContractCompatibilityKind.UnionTag, From 82477e4084f027d280e3077929705cb43ea8c2ad Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:47:46 +0800 Subject: [PATCH 394/399] test: retain removed union reservations --- ...ractManifestUnionRemovalRegressionTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs index 521f39aff..a6d98deb5 100644 --- a/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs +++ b/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs @@ -31,4 +31,28 @@ public interface IResultUnion { } Ensure(current.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK033"), "removing a published union tag must report SHARPLINK033 so the tag reservation cannot disappear from future baselines"); } + + [Test] + public void RemovingEntirePublishedUnionShouldFailContractBaseline() + { + var baselineSource = BuildSource(""" +public sealed class FirstCase : IResultUnion { } +public sealed class SecondCase : IResultUnion { } + +[SharpLink.Sdk.RpcUnionCase(1, typeof(FirstCase))] +[SharpLink.Sdk.RpcUnionCase(2, typeof(SecondCase))] +public interface IResultUnion { } +"""); + var currentSource = BuildSource(""" +public sealed class FirstCase : IResultUnion { } +public sealed class SecondCase : IResultUnion { } +public interface IResultUnion { } +"""); + + var baseline = RunContractGenerator(baselineSource); + var current = RunContractGenerator(currentSource, baseline.Json); + + Ensure(current.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK033") == 2, + "removing the union declaration metadata must retain every published tag reservation in the compatibility baseline"); + } } From c9bc32354d93e806b9a3bfc40de74df3fb9b855c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:55:30 +0800 Subject: [PATCH 395/399] revert: allow union schema evolution across exact-match versions --- ...enerator.ContractManifest.Compatibility.cs | 13 +---- ...ractManifestUnionRemovalRegressionTests.cs | 58 ------------------- 2 files changed, 2 insertions(+), 69 deletions(-) delete mode 100644 test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs index bb11f5bd7..355eb747a 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Compatibility.cs @@ -350,17 +350,8 @@ private static IEnumerable CompareContractManif var currentCases = newUnion.Cases.ToDictionary(static item => item.Tag); foreach (var oldCase in oldUnion.Cases) { - if (!currentCases.TryGetValue(oldCase.Tag, out var newCase)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.UnionTag, - newUnion.SourceLocation, - newUnion.Name, - $"published union tag {oldCase.Tag} for {oldCase.Type} was removed", - "restore the published tag mapping so it remains reserved across contract baselines")); - continue; - } - if (!string.Equals(oldCase.Type, newCase.Type, StringComparison.Ordinal)) + if (currentCases.TryGetValue(oldCase.Tag, out var newCase) && + !string.Equals(oldCase.Type, newCase.Type, StringComparison.Ordinal)) { diagnostics.Add(Change( ContractCompatibilityKind.UnionTag, diff --git a/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs deleted file mode 100644 index a6d98deb5..000000000 --- a/test/SharpLink.Generator.Tests/ContractManifestUnionRemovalRegressionTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Linq; - -namespace SharpLink.Generator.Tests; - -public partial class RpcAnalyzerTests -{ - [Test] - public void RemovingPublishedUnionTagShouldFailContractBaseline() - { - var baselineSource = BuildSource(""" -public sealed class FirstCase : IResultUnion { } -public sealed class SecondCase : IResultUnion { } - -[SharpLink.Sdk.RpcUnionCase(1, typeof(FirstCase))] -[SharpLink.Sdk.RpcUnionCase(2, typeof(SecondCase))] -public interface IResultUnion { } -"""); - var currentSource = BuildSource(""" -public sealed class FirstCase : IResultUnion { } -public sealed class SecondCase : IResultUnion { } - -[SharpLink.Sdk.RpcUnionCase(1, typeof(FirstCase))] -public interface IResultUnion { } -"""); - - var baseline = RunContractGenerator(baselineSource); - Ensure(!baseline.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK033"), - "valid baseline union tags must not report compatibility diagnostics"); - - var current = RunContractGenerator(currentSource, baseline.Json); - Ensure(current.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK033"), - "removing a published union tag must report SHARPLINK033 so the tag reservation cannot disappear from future baselines"); - } - - [Test] - public void RemovingEntirePublishedUnionShouldFailContractBaseline() - { - var baselineSource = BuildSource(""" -public sealed class FirstCase : IResultUnion { } -public sealed class SecondCase : IResultUnion { } - -[SharpLink.Sdk.RpcUnionCase(1, typeof(FirstCase))] -[SharpLink.Sdk.RpcUnionCase(2, typeof(SecondCase))] -public interface IResultUnion { } -"""); - var currentSource = BuildSource(""" -public sealed class FirstCase : IResultUnion { } -public sealed class SecondCase : IResultUnion { } -public interface IResultUnion { } -"""); - - var baseline = RunContractGenerator(baselineSource); - var current = RunContractGenerator(currentSource, baseline.Json); - - Ensure(current.Diagnostics.Count(static diagnostic => diagnostic.Id == "SHARPLINK033") == 2, - "removing the union declaration metadata must retain every published tag reservation in the compatibility baseline"); - } -} From 0cfc64b5c180ba011035c4cd9295bd5929f6f411 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:05:48 +0800 Subject: [PATCH 396/399] revert: keep assembly identity out of compatibility baseline --- ...nerator.ContractManifest.Infrastructure.cs | 5 +--- .../RpcGenerator.ContractManifest.cs | 30 ++----------------- src/SharpLink.Generator/RpcGenerator.cs | 2 -- ...ManifestAssemblyIdentityRegressionTests.cs | 27 ----------------- 4 files changed, 4 insertions(+), 60 deletions(-) delete mode 100644 test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs index db18126d2..c5452ef83 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.Infrastructure.cs @@ -13,8 +13,7 @@ manifest.Dtos is null || manifest.Codecs is null || manifest.Enums is null || manifest.Unions is null || - manifest.Services is null || - string.IsNullOrWhiteSpace(manifest.AssemblyLogicalIdentity)) + manifest.Services is null) { return false; } @@ -214,7 +213,6 @@ private sealed record ContractManifestModels( ImmutableArray Services, ImmutableArray Codecs, ImmutableArray CodecHashes, - string AssemblyLogicalIdentity, ImmutableArray Enums, ImmutableArray Unions); @@ -250,7 +248,6 @@ private sealed class ContractManifestDocument public int Version { get; set; } = ContractManifestFormatVersion; public string GeneratorVersion { get; set; } = ExecutingGeneratorVersion; public string SchemaFingerprint { get; set; } = string.Empty; - public string AssemblyLogicalIdentity { get; set; } = string.Empty; public List Contracts { get; set; } = []; public List Dtos { get; set; } = []; [JsonRequired] diff --git a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs index b28be0f51..d59368a48 100644 --- a/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs +++ b/src/SharpLink.Generator/RpcGenerator.ContractManifest.cs @@ -102,7 +102,6 @@ private static ContractManifestAnalysis AnalyzeContractManifest( ImmutableArray services, ImmutableArray codecs, ImmutableArray codecHashes, - string assemblyLogicalIdentity, ImmutableArray generatedEnums, ImmutableArray unions, ImmutableArray additionalTexts, @@ -110,14 +109,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var document = CreateContractManifest( - interfaces, - services, - codecs, - codecHashes, - assemblyLogicalIdentity, - generatedEnums, - unions); + var document = CreateContractManifest(interfaces, services, codecs, codecHashes, generatedEnums, unions); var diagnostics = ValidateCurrentContractManifest(document); if (!string.IsNullOrWhiteSpace(options.BaselinePath)) @@ -162,7 +154,7 @@ private static ContractManifestAnalysis AnalyzeContractManifest( ContractCompatibilityKind.BaselineInvalid, Location.None, options.BaselinePath, - "one or more required assembly, Codec, enum, or opaque payload identities are missing", + "one or more Codec entries, enum entries, or opaque payload references are missing required semantic identity", "regenerate the baseline with the current SharpLink SDK")); } else if (string.IsNullOrWhiteSpace(baseline.SchemaFingerprint) || @@ -180,18 +172,6 @@ private static ContractManifestAnalysis AnalyzeContractManifest( } else { - if (!string.Equals( - baseline.AssemblyLogicalIdentity, - document.AssemblyLogicalIdentity, - StringComparison.Ordinal)) - { - diagnostics.Add(Change( - ContractCompatibilityKind.WireType, - Location.None, - document.AssemblyLogicalIdentity, - $"assembly logical identity changed from '{baseline.AssemblyLogicalIdentity}' to '{document.AssemblyLogicalIdentity}'", - "restore the previous assembly logical identity or publish a new contract assembly/baseline")); - } diagnostics.AddRange(CompareContractManifests(baseline, document)); } } @@ -223,14 +203,10 @@ private static ContractManifestDocument CreateContractManifest( ImmutableArray services, ImmutableArray codecs, ImmutableArray codecHashes, - string assemblyLogicalIdentity, ImmutableArray generatedEnums, ImmutableArray unions) { - var document = new ContractManifestDocument - { - AssemblyLogicalIdentity = assemblyLogicalIdentity - }; + var document = new ContractManifestDocument(); var codecsByType = codecs .GroupBy(static codec => RemoveGlobalPrefix(codec.TypeName), StringComparer.Ordinal) .ToDictionary( diff --git a/src/SharpLink.Generator/RpcGenerator.cs b/src/SharpLink.Generator/RpcGenerator.cs index 7112378b9..d606d4e31 100644 --- a/src/SharpLink.Generator/RpcGenerator.cs +++ b/src/SharpLink.Generator/RpcGenerator.cs @@ -380,7 +380,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) value.Left.Left.Right, GetContractManifestCodecs(value.Left.Right), value.Left.Right.CodecHashes, - value.Left.Right.AssemblyLogicalIdentity, value.Left.Right.Enums, value.Right)); var contractManifestOptions = context.AnalyzerConfigOptionsProvider @@ -393,7 +392,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) value.Left.Left.Services, value.Left.Left.Codecs, value.Left.Left.CodecHashes, - value.Left.Left.AssemblyLogicalIdentity, value.Left.Left.Enums, value.Left.Left.Unions, value.Left.Right, diff --git a/test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs b/test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs deleted file mode 100644 index d972213fb..000000000 --- a/test/SharpLink.Generator.Tests/ContractManifestAssemblyIdentityRegressionTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Linq; -using System.Text.Json.Nodes; - -namespace SharpLink.Generator.Tests; - -public partial class RpcAnalyzerTests -{ - [Test] - public void AssemblyLogicalIdentityChangeShouldFailContractBaseline() - { - var source = SimpleContract("ValueTask Echo(int value);"); - var baseline = RunContractGenerator(source); - Ensure(!baseline.Diagnostics.Any(IsCompatibilityDiagnostic), - "baseline assembly identity fixture should generate without compatibility diagnostics"); - - var root = JsonNode.Parse(baseline.Json)!.AsObject(); - Ensure(root["assemblyLogicalIdentity"]?.GetValue() == "ContractManifestTestAssembly", - "contract manifest must persist the same logical assembly identity used by RpcAssemblyHash"); - - var changedAssemblyBaseline = RewriteManifest( - baseline.Json, - manifest => manifest["assemblyLogicalIdentity"] = "Other.Contracts"); - var current = RunContractGenerator(source, changedAssemblyBaseline); - Ensure(current.Diagnostics.Any(static diagnostic => diagnostic.Id == "SHARPLINK030"), - "changing only the baseline assembly logical identity must require SHARPLINK030"); - } -} From 70a323bf51d609f6499fe2bbf396968d7dd0947f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:07:21 +0800 Subject: [PATCH 397/399] revert: avoid redundant drain reordering --- src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs | 2 +- src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs b/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs index ae72f7146..bb1da4651 100644 --- a/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs +++ b/src/SharpLink.Client/SharpLinkClient.AssemblyDrain.cs @@ -23,7 +23,6 @@ public ValueTask UnregisterAssemblyAsync( TaskCreationOptions.RunContinuationsAsynchronously); operation = completion.Task; _unregisterOperations.Add(assembly, operation); - module.TryBeginDraining(); _ = CompleteUnregisterOperationAsync(assembly, module, gracefulTimeout, completion); if (State != SharpLinkConnectionState.Draining) { @@ -41,6 +40,7 @@ private async Task UnregisterCoreAsync( SharpLinkDynamicModule module, TimeSpan gracefulTimeout) { + module.TryBeginDraining(); var drainTask = module.WaitForDrainAsync(); if (!drainTask.IsCompleted) { diff --git a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs index 8bf3c8dea..e1877dea9 100644 --- a/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs +++ b/src/SharpLink.Server/SharpLinkServer.AssemblyDrain.cs @@ -28,7 +28,6 @@ public ValueTask UnregisterAssemblyAsync( TaskCreationOptions.RunContinuationsAsynchronously); operation = completion.Task; _unregisterOperations.Add(assembly, operation); - module.TryBeginDraining(); _ = CompleteUnregisterOperationAsync(assembly, module, gracefulTimeout, completion); TrackFrameworkTask( operation, @@ -50,6 +49,7 @@ private async Task UnregisterCoreAsync( SharpLinkDynamicModule module, TimeSpan gracefulTimeout) { + module.TryBeginDraining(); var drainTask = module.WaitForDrainAsync(); if (!drainTask.IsCompleted) { From 83225793273bcb253c4b54ee941ef3eee6acfd3c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:43:43 +0800 Subject: [PATCH 398/399] fix: validate generated dependency assembly identity --- .../SharpLinkGeneratedDependencyBinding.cs | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs index 5839b10a5..1a8388a62 100644 --- a/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs +++ b/src/SharpLink.Runtime/GeneratedAssembly/SharpLinkGeneratedDependencyBinding.cs @@ -26,16 +26,19 @@ internal static class SharpLinkGeneratedDependencyBinding // The generated manifest already records the compile-time dependency identity. Resolve that // identity through the owner's load context so the result stays bound to the exact runtime // assembly generation without depending on Assembly.GetReferencedAssemblies(), whose metadata - // view is not preserved by trimming/NativeAOT. Delegate loaded-assembly reuse to the ALC binder - // as well: AssemblyName.ReferenceMatchesDefinition compares only the simple name and would let - // an incompatible already-loaded version/culture/public-key identity satisfy this dependency. + // view is not preserved by trimming/NativeAOT. Delegate loaded-assembly reuse to the ALC binder, + // then verify every identity component that the dependency string actually specified: a custom + // ALC can return an already-loaded same-name assembly even when its version is incompatible. var loadContext = AssemblyLoadContext.GetLoadContext(ownerAssembly); if (loadContext is null) return null; try { - return loadContext.LoadFromAssemblyName(requested); + var resolved = loadContext.LoadFromAssemblyName(requested); + return MatchesRequestedIdentity(requested, resolved.GetName(), dependencyIdentity) + ? resolved + : null; } catch (Exception exception) when ( exception is FileNotFoundException or FileLoadException or BadImageFormatException) @@ -44,6 +47,38 @@ internal static class SharpLinkGeneratedDependencyBinding } } + private static bool MatchesRequestedIdentity( + AssemblyName requested, + AssemblyName resolved, + string dependencyIdentity) + { + if (!string.Equals(requested.Name, resolved.Name, StringComparison.OrdinalIgnoreCase)) + return false; + if (requested.Version is not null && requested.Version != resolved.Version) + return false; + if (dependencyIdentity.Contains("Culture=", StringComparison.OrdinalIgnoreCase) && + !string.Equals( + requested.CultureName ?? string.Empty, + resolved.CultureName ?? string.Empty, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (dependencyIdentity.Contains("PublicKeyToken=", StringComparison.OrdinalIgnoreCase) && + !PublicKeyTokensEqual(requested.GetPublicKeyToken(), resolved.GetPublicKeyToken())) + { + return false; + } + return true; + } + + private static bool PublicKeyTokensEqual(byte[]? left, byte[]? right) + { + left ??= []; + right ??= []; + return left.AsSpan().SequenceEqual(right); + } + internal static bool Matches( Assembly ownerAssembly, string dependencyIdentity, From 30cf72610abc0a8d793fd43630c32695c6ea2b02 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:44:33 +0800 Subject: [PATCH 399/399] test: align replacement dependency failure expectation --- ...timeAssemblyDependencyIdentityIntegrationTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs b/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs index 93990363a..34bc39476 100644 --- a/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/RuntimeAssemblyDependencyIdentityIntegrationTests.cs @@ -61,15 +61,15 @@ public async Task SameFullNameReferencedCodecDependencyShouldRequireExactGenerat var clientReplacement = await harness.Client.ReplaceAssemblyAsync( provider2, consumer2, TimeSpan.FromSeconds(2)); Ensure(!clientReplacement.Succeeded && - clientReplacement.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && - clientReplacement.Error.Message.Contains("exact Type", StringComparison.Ordinal), - $"client replacement must validate the pending consumer against the final candidate snapshot: {clientReplacement.Error}"); + clientReplacement.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.MissingDependency && + clientReplacement.Error.Message.Contains("exact registered and running Assembly generation", StringComparison.Ordinal), + $"client replacement must reject a final candidate that removes the pending consumer's exact provider: {clientReplacement.Error}"); var serverReplacement = await harness.Server.ReplaceAssemblyAsync( provider2, consumer2, TimeSpan.FromSeconds(2)); Ensure(!serverReplacement.Succeeded && - serverReplacement.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.InvalidManifest && - serverReplacement.Error.Message.Contains("exact Type", StringComparison.Ordinal), - $"server replacement must validate the pending consumer against the final candidate snapshot: {serverReplacement.Error}"); + serverReplacement.Error?.Code == SharpLinkAssemblyRegistrationErrorCode.MissingDependency && + serverReplacement.Error.Message.Contains("exact registered and running Assembly generation", StringComparison.Ordinal), + $"server replacement must reject a final candidate that removes the pending consumer's exact provider: {serverReplacement.Error}"); Ensure(harness.Client.RegisterAssembly(consumer2).Succeeded, "client accepts consumer with exact bound provider generation and expected CodecHash");