From 562dc837097b449c344c4ac9e0d4386da8019744 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 15:11:45 -0500 Subject: [PATCH 01/12] Akka.Serialization.V2 PoC: [AkkaUnion] closed union fields (manifest-discriminated) Part 2 of the design in #8384: a union-typed field is a closed, explicitly enumerated set of [AkkaSerializable] member types, encoded structurally inline as { 1: manifest, 2: member fields } and discriminated by each member's serializer-owned manifest -- the typed alternative to [AkkaEnvelopePayload] for payload sets known at compile time. - AkkaUnionAttribute(params Type[] memberTypes) on [AkkaField] properties - FieldKind.Union + per-field generated Write/Read/SizeOf dispatch helpers - Exact-runtime-type write dispatch (undeclared subtypes fail, STJ FailSerialization semantics); unknown manifests throw on read; unknown union-frame keys skip for forward compat - Diagnostics AKKASG015-019 (member not serializable / missing manifest / manifest collision / not assignable / invalid member set) - Fix latent codegen bug: locals for properties named after C# keywords (e.g. 'Event') are now @-escaped in generated code --- .../AkkaSerializerGenerator.cs | 477 ++++++++++++++++-- .../GeneratedUnionSpec.cs | 324 ++++++++++++ src/core/Akka.Serialization.V2/Attributes.cs | 34 ++ 3 files changed, 801 insertions(+), 34 deletions(-) create mode 100644 src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 5a806fcb320..7f8e3cc6d5a 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -12,6 +12,7 @@ using System.Linq; using System.Text; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Akka.Serialization.V2.Generators; @@ -23,6 +24,7 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator private const string SerializableAttributeFullName = "Akka.Serialization.V2.AkkaSerializableAttribute"; private const string FieldAttributeFullName = "Akka.Serialization.V2.AkkaFieldAttribute"; private const string EnvelopePayloadAttributeFullName = "Akka.Serialization.V2.AkkaEnvelopePayloadAttribute"; + private const string UnionAttributeFullName = "Akka.Serialization.V2.AkkaUnionAttribute"; private const string FormatterAttributeFullName = "Akka.Serialization.V2.AkkaSerializerFormatterAttribute"; private const string FormatterInterfaceFullName = "Akka.Serialization.V2.IAkkaMessagePackFormatter`1"; private const string ExtendedActorSystemFullName = "Akka.Actor.ExtendedActorSystem"; @@ -140,6 +142,46 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor UnionMemberNotSerializable = new( + "AKKASG015", + "Union member type is not serializable", + "Union member '{0}' on property '{1}' of type '{2}' must be an [AkkaSerializable] class or struct handled by this serializer", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnionMemberMissingManifest = new( + "AKKASG016", + "Union member manifest is required", + "Union member '{0}' on property '{1}' of type '{2}' must specify Manifest in its [AkkaSerializable] attribute: the manifest is the union discriminator", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnionMemberManifestCollision = new( + "AKKASG017", + "Union member manifests must be unique", + "Union on property '{0}' of type '{1}' has multiple members with manifest '{2}': {3}", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnionMemberNotAssignable = new( + "AKKASG018", + "Union member is not assignable to the field type", + "Union member '{0}' on property '{1}' of type '{2}' is not implicitly convertible to the field type '{3}'", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidUnionMemberSet = new( + "AKKASG019", + "Union member set is invalid", + "Union on property '{0}' of type '{1}' has an invalid member set: {2}", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { var serializers = context.SyntaxProvider @@ -208,10 +250,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .ToImmutableArray(); var reachableMessages = CollectReachableMessages(topLevelMessages, resolvedMessagesByType); - if (!ValidateMessages(ctx, serializer, topLevelMessages, reachableMessages)) + if (!ValidateMessages(ctx, serializer, topLevelMessages, reachableMessages, resolvedMessagesByType)) continue; - ctx.AddSource(serializer.ClassName + ".AkkaSerialization.g.cs", Generate(serializer, topLevelMessages, reachableMessages)); + ctx.AddSource(serializer.ClassName + ".AkkaSerialization.g.cs", Generate(serializer, topLevelMessages, reachableMessages, resolvedMessagesByType)); } }); } @@ -402,7 +444,8 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ { var symbol = (INamedTypeSymbol)context.TargetSymbol; var attribute = context.Attributes[0]; - var knownTypes = KnownTypes.From(context.SemanticModel.Compilation); + var compilation = context.SemanticModel.Compilation; + var knownTypes = KnownTypes.From(compilation); var manifest = string.Empty; var allowEmpty = false; foreach (var argument in attribute.NamedArguments) @@ -425,8 +468,20 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ var isNullable = member.NullableAnnotation == NullableAnnotation.Annotated || IsNullableValueType(member.Type); var isEnvelopePayload = member.GetAttributes() .Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.EnvelopePayloadAttribute)); - var mapping = isEnvelopePayload ? new TypeMapping(FieldKind.EnvelopePayload) : MapType(member.Type, knownTypes); - fields.Add(new FieldInfo(index, member.Name, member.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), mapping, isNullable)); + var unionMembers = ExtractUnionMembers(member, knownTypes, compilation, out var hasUnionAttribute); + + // Precedence: [AkkaEnvelopePayload] always wins (matching its documented precedence over + // formatter registrations), then [AkkaUnion], then ordinary inference. + var mapping = isEnvelopePayload ? new TypeMapping(FieldKind.EnvelopePayload) + : hasUnionAttribute ? new TypeMapping(FieldKind.Union) + : MapType(member.Type, knownTypes); + fields.Add(new FieldInfo( + index, + member.Name, + member.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + mapping, + isNullable, + unionMembers: isEnvelopePayload ? default : unionMembers)); } return new MessageInfo( @@ -438,6 +493,66 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ allowEmpty); } + /// + /// Extracts the declared member set of an [AkkaUnion] property. Symbol-dependent facts + /// (assignability to the field's static type, unbound-generic detection) are captured here; + /// facts that need the whole message set (serializability, manifests) are validated later in + /// against the serializer's message dictionary. Malformed + /// arguments (null, not a type, unbound generic) are recorded as unsupported entries so a + /// diagnostic fires instead of the member silently vanishing. + /// + private static ImmutableArray ExtractUnionMembers( + IPropertySymbol member, + KnownTypes knownTypes, + Compilation compilation, + out bool hasUnionAttribute) + { + hasUnionAttribute = false; + if (knownTypes.UnionAttribute == null) + return ImmutableArray.Empty; + + var unionAttribute = member.GetAttributes() + .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.UnionAttribute)); + if (unionAttribute == null || unionAttribute.ConstructorArguments.Length != 1) + return ImmutableArray.Empty; + + hasUnionAttribute = true; + var arguments = unionAttribute.ConstructorArguments[0].Values; + var builder = ImmutableArray.CreateBuilder(arguments.Length); + foreach (var argument in arguments) + { + if (argument.Value is not INamedTypeSymbol memberType || memberType.IsUnboundGenericType) + { + var displayName = argument.Value is ITypeSymbol typeSymbol + ? typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : ""; + builder.Add(new UnionMemberInfo(displayName, isValueType: false, isAssignable: false, isSupported: false)); + continue; + } + + builder.Add(new UnionMemberInfo( + GetMessageDictionaryKey(memberType), + memberType.IsValueType, + compilation.HasImplicitConversion(memberType, member.Type), + isSupported: true)); + } + + return builder.ToImmutable(); + } + + /// + /// The key a type is looked up under in the serializer's message dictionary. Non-generic types + /// use the arity-less (the existing key for every + /// [AkkaSerializable] message); closed generic constructions use the full display string + /// (e.g. global::Ns.Wrapper<global::Ns.Foo>) so distinct constructions stay distinct. + /// + private static string GetMessageDictionaryKey(INamedTypeSymbol type) + { + return type.IsGenericType + ? type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : GetFullyQualifiedTypeName(type); + } + private static ImmutableDictionary ResolveMessages( ImmutableDictionary allMessagesByType, ImmutableArray formatters) @@ -494,8 +609,18 @@ private static ImmutableArray CollectReachableMessages( messages.Add(message); var referencedObjectTypes = new HashSet(StringComparer.Ordinal); foreach (var field in message.Fields) + { CollectObjectTypeNames(field.Mapping, referencedObjectTypes); + // Union members are reachable exactly like nested Object fields: each member needs + // its Write/Read/SizeOf methods generated for the union dispatch to call into. + foreach (var unionMember in field.UnionMembers) + { + if (unionMember.IsSupported) + referencedObjectTypes.Add(unionMember.TypeFullName); + } + } + foreach (var typeName in referencedObjectTypes) { if (allMessagesByType.TryGetValue(typeName, out var nestedMessage)) @@ -521,7 +646,7 @@ private static void CollectObjectTypeNames(TypeMapping mapping, HashSet CollectObjectTypeNames(argument, into); } - private static bool ValidateMessages(SourceProductionContext context, SerializerInfo serializer, ImmutableArray topLevelMessages, ImmutableArray reachableMessages) + private static bool ValidateMessages(SourceProductionContext context, SerializerInfo serializer, ImmutableArray topLevelMessages, ImmutableArray reachableMessages, ImmutableDictionary messagesByType) { var isValid = true; foreach (var message in topLevelMessages.Where(message => string.IsNullOrWhiteSpace(message.Manifest))) @@ -571,12 +696,81 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer context.ReportDiagnostic(Diagnostic.Create(UnsupportedEnumUnderlyingType, Location.None, field.Name, message.FullyQualifiedName, field.Mapping.TypeFullName, field.Mapping.EnumUnderlyingTypeName)); isValid = false; } + + foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Union)) + { + if (!ValidateUnionField(context, message, field, messagesByType)) + isValid = false; + } + } + + return isValid; + } + + private static bool ValidateUnionField( + SourceProductionContext context, + MessageInfo message, + FieldInfo field, + ImmutableDictionary messagesByType) + { + var isValid = true; + + if (field.UnionMembers.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidUnionMemberSet, Location.None, field.Name, message.FullyQualifiedName, "at least one member type is required")); + return false; + } + + foreach (var duplicate in field.UnionMembers + .GroupBy(member => member.TypeFullName, StringComparer.Ordinal) + .Where(group => group.Count() > 1)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidUnionMemberSet, Location.None, field.Name, message.FullyQualifiedName, $"member type '{duplicate.Key}' is declared more than once")); + isValid = false; + } + + var manifests = new Dictionary>(StringComparer.Ordinal); + foreach (var member in field.UnionMembers) + { + if (!member.IsSupported || !messagesByType.TryGetValue(member.TypeFullName, out var memberMessage)) + { + context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotSerializable, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName)); + isValid = false; + continue; + } + + if (!member.IsAssignable) + { + context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotAssignable, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName, field.TypeFullName)); + isValid = false; + } + + if (string.IsNullOrWhiteSpace(memberMessage.Manifest)) + { + context.ReportDiagnostic(Diagnostic.Create(UnionMemberMissingManifest, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName)); + isValid = false; + continue; + } + + if (!manifests.TryGetValue(memberMessage.Manifest, out var typesWithManifest)) + { + typesWithManifest = new List(); + manifests[memberMessage.Manifest] = typesWithManifest; + } + + typesWithManifest.Add(member.TypeFullName); + } + + foreach (var collision in manifests.Where(pair => pair.Value.Distinct(StringComparer.Ordinal).Count() > 1)) + { + context.ReportDiagnostic(Diagnostic.Create(UnionMemberManifestCollision, Location.None, field.Name, message.FullyQualifiedName, collision.Key, string.Join(", ", collision.Value))); + isValid = false; } return isValid; } - private static string Generate(SerializerInfo serializer, ImmutableArray topLevelMessages, ImmutableArray reachableMessages) + private static string Generate(SerializerInfo serializer, ImmutableArray topLevelMessages, ImmutableArray reachableMessages, ImmutableDictionary messagesByType) { var usedFormatters = CollectUsedFormatters(reachableMessages); @@ -622,6 +816,7 @@ private static string Generate(SerializerInfo serializer, ImmutableArray, 2: }. The manifest is the discriminator -- the same + // serializer-owned manifest the member would carry as a top-level message -- so a value reads + // identically whether it arrived through union dispatch or ordinary manifest dispatch. Contrast + // with [AkkaEnvelopePayload]'s { 1: serializerId, 2: manifest, 3: opaque bytes }: the union + // omits the serializer id (every member is owned by this serializer) and inlines the member's + // fields directly instead of double-buffering them into a length-prefixed blob. + // + // Write dispatch matches the runtime type EXACTLY (value.GetType() == typeof(Member)) rather + // than pattern matching, so an undeclared subtype of a declared member fails serialization + // instead of silently truncating to its base -- the same default System.Text.Json applies to + // [JsonDerivedType] sets (UnknownDerivedTypeHandling.FailSerialization). The size path returns + // UnknownSize for an undeclared type instead of throwing; the write path throws. + // --------------------------------------------------------------------------------------------- + + private static string GetUnionHelperName(MessageInfo message, FieldInfo field) + { + return "Union_" + GetMessageMethodName(message) + "_" + field.Name; + } + + private static void GenerateUnionHelpers(StringBuilder sb, MessageInfo message, ImmutableDictionary messagesByType) + { + foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Union)) + { + var helperName = GetUnionHelperName(message, field); + var members = field.UnionMembers + .Where(member => member.IsSupported && messagesByType.ContainsKey(member.TypeFullName)) + .Select(member => (Member: member, Message: messagesByType[member.TypeFullName])) + .ToImmutableArray(); + + GenerateUnionWrite(sb, field, helperName, members); + GenerateUnionRead(sb, field, helperName, members); + GenerateUnionSize(sb, field, helperName, members); + } + } + + private static void GenerateUnionWrite(StringBuilder sb, FieldInfo field, string helperName, ImmutableArray<(UnionMemberInfo Member, MessageInfo Message)> members) + { + sb.Append(" private void Write").Append(helperName) + .Append("(ref global::MessagePack.MessagePackWriter writer, ").Append(field.TypeFullName).AppendLine(" value)"); + sb.AppendLine(" {"); + sb.AppendLine(" var runtimeType = value.GetType();"); + foreach (var (member, memberMessage) in members) + { + sb.Append(" if (runtimeType == typeof(").Append(member.TypeFullName).AppendLine("))"); + sb.AppendLine(" {"); + sb.AppendLine(" writer.WriteMapHeader(2);"); + sb.AppendLine(" writer.Write(1);"); + sb.Append(" writer.Write(\"").Append(Escape(memberMessage.Manifest)).AppendLine("\");"); + sb.AppendLine(" writer.Write(2);"); + sb.Append(" Write").Append(GetMessageMethodName(memberMessage)).Append("(ref writer, (").Append(member.TypeFullName).AppendLine(")value);"); + sb.AppendLine(" return;"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException($\"Type [{runtimeType}] is not a declared union member for field [") + .Append(Escape(field.Name)).AppendLine("].\");"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + private static void GenerateUnionRead(StringBuilder sb, FieldInfo field, string helperName, ImmutableArray<(UnionMemberInfo Member, MessageInfo Message)> members) + { + sb.Append(" private ").Append(field.TypeFullName).Append(" Read").Append(helperName) + .AppendLine("(ref global::MessagePack.MessagePackReader reader)"); + sb.AppendLine(" {"); + sb.AppendLine(" var fieldCount = reader.ReadMapHeader();"); + sb.AppendLine(" string? manifest = null;"); + sb.Append(" ").Append(field.TypeFullName).AppendLine("? result = default;"); + sb.AppendLine(" var hasPayload = false;"); + sb.AppendLine(" for (var entryIndex = 0; entryIndex < fieldCount; entryIndex++)"); + sb.AppendLine(" {"); + sb.AppendLine(" var fieldId = reader.ReadInt32();"); + sb.AppendLine(" switch (fieldId)"); + sb.AppendLine(" {"); + sb.AppendLine(" case 1:"); + sb.AppendLine(" manifest = reader.ReadString();"); + sb.AppendLine(" break;"); + sb.AppendLine(" case 2:"); + sb.AppendLine(" switch (manifest)"); + sb.AppendLine(" {"); + foreach (var (_, memberMessage) in members) + { + sb.Append(" case \"").Append(Escape(memberMessage.Manifest)).AppendLine("\":"); + sb.Append(" result = Read").Append(GetMessageMethodName(memberMessage)).AppendLine("(ref reader);"); + sb.AppendLine(" break;"); + } + + sb.AppendLine(" case null:"); + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException(\"Union manifest must precede the payload for field [") + .Append(Escape(field.Name)).AppendLine("].\");"); + sb.AppendLine(" default:"); + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException($\"Unknown union manifest [{manifest}] for field [") + .Append(Escape(field.Name)).AppendLine("].\");"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" hasPayload = true;"); + sb.AppendLine(" break;"); + sb.AppendLine(" default:"); + sb.AppendLine(" reader.Skip();"); + sb.AppendLine(" break;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" if (!hasPayload || result is null)"); + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException(\"Missing union payload for field [") + .Append(Escape(field.Name)).AppendLine("].\");"); + sb.AppendLine(" return result;"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + private static void GenerateUnionSize(StringBuilder sb, FieldInfo field, string helperName, ImmutableArray<(UnionMemberInfo Member, MessageInfo Message)> members) + { + sb.Append(" private int SizeOf").Append(helperName) + .Append('(').Append(field.TypeFullName).AppendLine(" value)"); + sb.AppendLine(" {"); + sb.AppendLine(" var runtimeType = value.GetType();"); + foreach (var (member, memberMessage) in members) + { + sb.Append(" if (runtimeType == typeof(").Append(member.TypeFullName).AppendLine("))"); + sb.AppendLine(" {"); + sb.Append(" var payloadSize = SizeOf").Append(GetMessageMethodName(memberMessage)).Append("((").Append(member.TypeFullName).AppendLine(")value);"); + sb.AppendLine(" if (payloadSize < 0)"); + sb.AppendLine(" return global::Akka.Serialization.SerializerV2.UnknownSize;"); + sb.Append(" return checked(SizeOfMapHeader(2) + SizeOfInt32(1) + SizeOfString(\"").Append(Escape(memberMessage.Manifest)) + .AppendLine("\") + SizeOfInt32(2) + payloadSize);"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + sb.AppendLine(" return global::Akka.Serialization.SerializerV2.UnknownSize;"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + private static void GenerateWriteField(StringBuilder sb, MessageInfo message, FieldInfo field, NameAlloc alloc) { var value = "message." + field.Name; sb.Append(" writer.Write(").Append(field.Index).AppendLine(");"); @@ -972,14 +1314,14 @@ private static void GenerateWriteField(StringBuilder sb, FieldInfo field, NameAl sb.Append(" if (").Append(value).AppendLine(" is null)"); sb.AppendLine(" writer.WriteNil();"); sb.AppendLine(" else"); - GenerateWriteFieldValue(sb, field, value + ".Value", " ", alloc); + GenerateWriteFieldValue(sb, message, field, value + ".Value", " ", alloc); return; } - GenerateWriteFieldValue(sb, field, value, " ", alloc); + GenerateWriteFieldValue(sb, message, field, value, " ", alloc); } - private static void GenerateWriteFieldValue(StringBuilder sb, FieldInfo field, string value, string indent, NameAlloc alloc) + private static void GenerateWriteFieldValue(StringBuilder sb, MessageInfo message, FieldInfo field, string value, string indent, NameAlloc alloc) { if (IsCollectionKind(field.Mapping.Kind)) { @@ -1059,10 +1401,25 @@ private static void GenerateWriteFieldValue(StringBuilder sb, FieldInfo field, s sb.Append(indent).Append(GetFormatterFieldName(field.Formatter!)).Append(".Write(ref writer, ").Append(value).AppendLine(");"); } break; + case FieldKind.Union: + // Union fields are always reference-like (the static type is an interface or + // abstract base), so only the nullable-reference guard is needed here. + if (field.IsNullable) + { + sb.Append(indent).Append("if (").Append(value).AppendLine(" is null)"); + sb.Append(indent).AppendLine(" writer.WriteNil();"); + sb.Append(indent).AppendLine("else"); + sb.Append(indent).Append(" Write").Append(GetUnionHelperName(message, field)).Append("(ref writer, ").Append(value).AppendLine(");"); + } + else + { + sb.Append(indent).Append("Write").Append(GetUnionHelperName(message, field)).Append("(ref writer, ").Append(value).AppendLine(");"); + } + break; } } - private static void GenerateReadField(StringBuilder sb, FieldInfo field, NameAlloc alloc) + private static void GenerateReadField(StringBuilder sb, MessageInfo message, FieldInfo field, NameAlloc alloc) { var target = ToCamelCase(field.Name); @@ -1072,7 +1429,7 @@ private static void GenerateReadField(StringBuilder sb, FieldInfo field, NameAll // non-nullable collection slot exactly as it does for any other non-nullable reference field. if (IsCollectionKind(field.Mapping.Kind)) { - GenerateReadFieldValue(sb, field, target, " ", alloc); + GenerateReadFieldValue(sb, message, field, target, " ", alloc); return; } @@ -1081,11 +1438,12 @@ private static void GenerateReadField(StringBuilder sb, FieldInfo field, NameAll sb.AppendLine(" if (reader.TryReadNil())"); sb.Append(" ").Append(target).AppendLine(" = null;"); sb.AppendLine(" else"); - GenerateReadFieldValue(sb, field, target, " ", alloc); + GenerateReadFieldValue(sb, message, field, target, " ", alloc); return; } var isNullableReferenceLikeSlot = field.Mapping.Kind == FieldKind.EnvelopePayload + || field.Mapping.Kind == FieldKind.Union || (field.Mapping.Kind == FieldKind.Object && IsReferenceLike(field)) || (field.Mapping.Kind == FieldKind.Formatted && IsReferenceLike(field)); @@ -1094,14 +1452,14 @@ private static void GenerateReadField(StringBuilder sb, FieldInfo field, NameAll sb.AppendLine(" if (reader.TryReadNil())"); sb.Append(" ").Append(target).AppendLine(" = null;"); sb.AppendLine(" else"); - GenerateReadFieldValue(sb, field, target, " ", alloc); + GenerateReadFieldValue(sb, message, field, target, " ", alloc); return; } - GenerateReadFieldValue(sb, field, target, " ", alloc); + GenerateReadFieldValue(sb, message, field, target, " ", alloc); } - private static void GenerateReadFieldValue(StringBuilder sb, FieldInfo field, string target, string indent, NameAlloc alloc) + private static void GenerateReadFieldValue(StringBuilder sb, MessageInfo message, FieldInfo field, string target, string indent, NameAlloc alloc) { if (IsCollectionKind(field.Mapping.Kind)) { @@ -1157,6 +1515,9 @@ private static void GenerateReadFieldValue(StringBuilder sb, FieldInfo field, st case FieldKind.Formatted: sb.Append(indent).Append(target).Append(" = ").Append(GetFormatterFieldName(field.Formatter!)).AppendLine(".Read(ref reader);"); break; + case FieldKind.Union: + sb.Append(indent).Append(target).Append(" = Read").Append(GetUnionHelperName(message, field)).AppendLine("(ref reader);"); + break; } } @@ -1726,6 +2087,7 @@ private static string DefaultValue(FieldInfo field) FieldKind.Decimal => "0m", FieldKind.ActorRef => "global::Akka.Actor.ActorRefs.NoSender", FieldKind.EnvelopePayload => "null", + FieldKind.Union => "null", // A required (non-nullable) [AkkaSerializable] struct nested field gets a non-nullable // local (see GetLocalType/IsReferenceLike): "null" would not compile for it, so fall // back to "default" the same way every other non-reference-like kind does below. @@ -1758,7 +2120,9 @@ private static bool IsReferenceLike(FieldInfo field) if (field.Mapping.Kind == FieldKind.Object) return !field.Mapping.IsValueType; - return field.Mapping.Kind is FieldKind.String or FieldKind.ByteArray or FieldKind.ActorRef or FieldKind.EnvelopePayload; + // Union fields are always reference-like: the static type is an interface or abstract base + // (a struct cannot be the static type of a multi-member union). + return field.Mapping.Kind is FieldKind.String or FieldKind.ByteArray or FieldKind.ActorRef or FieldKind.EnvelopePayload or FieldKind.Union; } private static bool IsNullableValueField(FieldInfo field) @@ -1855,7 +2219,16 @@ private static string GetAccessibilityKeyword(Accessibility accessibility) private static string ToCamelCase(string value) { - return string.IsNullOrEmpty(value) ? value : char.ToLowerInvariant(value[0]) + value.Substring(1); + if (string.IsNullOrEmpty(value)) + return value; + + var name = char.ToLowerInvariant(value[0]) + value.Substring(1); + + // A property named 'Event', 'Lock', 'Object', etc. camel-cases to a reserved C# keyword, + // which cannot be used as a local identifier. Escape with '@' (a runtime no-op). The escaped + // form composes safely with the suffixes appended elsewhere ('@eventSize', '@eventBytes'): + // '@' is legal on any identifier, keyword or not. + return SyntaxFacts.GetKeywordKind(name) == SyntaxKind.None ? name : "@" + name; } private static string Escape(string value) @@ -1904,6 +2277,7 @@ private KnownTypes(Compilation compilation) { FieldAttribute = compilation.GetTypeByMetadataName(FieldAttributeFullName); EnvelopePayloadAttribute = compilation.GetTypeByMetadataName(EnvelopePayloadAttributeFullName); + UnionAttribute = compilation.GetTypeByMetadataName(UnionAttributeFullName); SerializableAttribute = compilation.GetTypeByMetadataName(SerializableAttributeFullName); Guid = compilation.GetTypeByMetadataName("System.Guid"); DateTimeOffset = compilation.GetTypeByMetadataName("System.DateTimeOffset"); @@ -1915,6 +2289,7 @@ private KnownTypes(Compilation compilation) public INamedTypeSymbol? FieldAttribute { get; } public INamedTypeSymbol? EnvelopePayloadAttribute { get; } + public INamedTypeSymbol? UnionAttribute { get; } public INamedTypeSymbol? SerializableAttribute { get; } public INamedTypeSymbol? Guid { get; } public INamedTypeSymbol? DateTimeOffset { get; } @@ -1956,7 +2331,7 @@ public MessageInfo WithFields(ImmutableArray fields) private sealed class FieldInfo { - public FieldInfo(int index, string name, string typeFullName, TypeMapping mapping, bool isNullable, FormatterInfo? formatter = null) + public FieldInfo(int index, string name, string typeFullName, TypeMapping mapping, bool isNullable, FormatterInfo? formatter = null, ImmutableArray unionMembers = default) { Index = index; Name = name; @@ -1964,6 +2339,7 @@ public FieldInfo(int index, string name, string typeFullName, TypeMapping mappin Mapping = mapping; IsNullable = isNullable; Formatter = formatter; + UnionMembers = unionMembers.IsDefault ? ImmutableArray.Empty : unionMembers; } public int Index { get; } @@ -1973,9 +2349,12 @@ public FieldInfo(int index, string name, string typeFullName, TypeMapping mappin public bool IsNullable { get; } public FormatterInfo? Formatter { get; } + /// Declared members for a field; empty otherwise. + public ImmutableArray UnionMembers { get; } + public FieldInfo WithFormatter(TypeMapping mapping, FormatterInfo formatter) { - return new FieldInfo(Index, Name, TypeFullName, mapping, IsNullable, formatter); + return new FieldInfo(Index, Name, TypeFullName, mapping, IsNullable, formatter, UnionMembers); } } @@ -2104,6 +2483,36 @@ private enum FieldKind List, ReadOnlyList, Dictionary, - UnsupportedEnumUnderlyingType + UnsupportedEnumUnderlyingType, + Union + } + + /// + /// A single declared member of an [AkkaUnion] field. Carries only strings/bools (no + /// references) so it stays cheap across incremental generator passes. + /// Facts requiring symbol access (assignability, unbound-generic detection) are captured at + /// extraction time; facts requiring the whole-compilation message set (serializability, + /// manifests) are resolved later against the serializer's message dictionary. + /// + private sealed class UnionMemberInfo + { + public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, bool isSupported) + { + TypeFullName = typeFullName; + IsValueType = isValueType; + IsAssignable = isAssignable; + IsSupported = isSupported; + } + + /// Message-dictionary key for the member type (arity-aware for generics). + public string TypeFullName { get; } + + public bool IsValueType { get; } + + /// Whether the member type is implicitly convertible to the field's static type. + public bool IsAssignable { get; } + + /// False when the attribute argument was null, not a type, or an unbound generic. + public bool IsSupported { get; } } } diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs new file mode 100644 index 00000000000..8a3f989ea18 --- /dev/null +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs @@ -0,0 +1,324 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2026 .NET Foundation +// +//----------------------------------------------------------------------- + +#nullable enable +using System; +using System.Buffers; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using MessagePack; +using Xunit; + +namespace Akka.Serialization.V2.Tests; + +/// +/// Specs for [AkkaUnion] fields: closed, explicitly-enumerated member sets encoded +/// structurally inline and discriminated by each member's serializer-owned manifest -- the typed +/// alternative to [AkkaEnvelopePayload] for payload sets known at compile time. +/// +public sealed class GeneratedUnionSpec : IAsyncLifetime +{ + private ActorSystem _system = null!; + private UnionTestSerializer _serializer = null!; + + public ValueTask InitializeAsync() + { + _system = ActorSystem.Create("generated-union-spec"); + _serializer = new UnionTestSerializer((ExtendedActorSystem)_system); + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await _system.Terminate(); + } + + [Fact(DisplayName = "Union field should round-trip a class member as its concrete type")] + public void Union_field_should_round_trip_class_member() + { + var message = new UnionEnvelope("env-1", new OrderPlaced("order-1", 3)); + + var result = RoundTrip(message); + + result.Should().Be(message); + result.Event.Should().BeOfType(); + } + + [Fact(DisplayName = "Union field should round-trip a nested-only member whose manifest exists purely as the union discriminator")] + public void Union_field_should_round_trip_nested_only_member() + { + // OrderCancelled does NOT implement IUnionTestProtocol: it is never a top-level message, + // so its manifest exists solely to discriminate it inside the union. + var message = new UnionEnvelope("env-2", new OrderCancelled("order-2", "customer request")); + + var result = RoundTrip(message); + + result.Should().Be(message); + result.Event.Should().BeOfType(); + } + + [Fact(DisplayName = "Union field should round-trip a struct member through boxing")] + public void Union_field_should_round_trip_struct_member() + { + var message = new UnionEnvelope("env-3", new OrderNote("order-3", "expedite")); + + var result = RoundTrip(message); + + result.Should().Be(message); + result.Event.Should().BeOfType(); + } + + [Fact(DisplayName = "Union wire format should be a manifest-discriminated 2-entry map with inline member fields")] + public void Union_wire_format_should_be_manifest_discriminated_map() + { + var message = new UnionEnvelope("env-4", new OrderPlaced("order-4", 7)); + var bytes = _serializer.ToBinary(message); + + var reader = new MessagePackReader(new ReadOnlySequence(bytes)); + reader.ReadMapHeader().Should().Be(2); + reader.ReadInt32().Should().Be(1); + reader.ReadString().Should().Be("env-4"); + reader.ReadInt32().Should().Be(2); + + // The union frame: { 1: manifest, 2: inline member field map }. No serializer id, no + // length-prefixed opaque byte blob -- contrast with the [AkkaEnvelopePayload] frame. + reader.ReadMapHeader().Should().Be(2); + reader.ReadInt32().Should().Be(1); + reader.ReadString().Should().Be(OrderPlaced.ManifestName); + reader.ReadInt32().Should().Be(2); + reader.ReadMapHeader().Should().Be(2); + reader.ReadInt32().Should().Be(1); + reader.ReadString().Should().Be("order-4"); + reader.ReadInt32().Should().Be(2); + reader.ReadInt32().Should().Be(7); + reader.Consumed.Should().Be(bytes.Length); + } + + [Fact(DisplayName = "Union SizeHint should be exact for every member kind")] + public void Union_size_hint_should_be_exact() + { + var classMember = new UnionEnvelope("env-5", new OrderPlaced("order-5", 11)); + var structMember = new UnionEnvelope("env-6", new OrderNote("order-6", "gift wrap")); + + _serializer.SizeHint(classMember).Should().Be(_serializer.ToBinary(classMember).Length); + _serializer.SizeHint(structMember).Should().Be(_serializer.ToBinary(structMember).Length); + } + + [Fact(DisplayName = "Nullable union field should round-trip null and non-null values")] + public void Nullable_union_field_should_round_trip_null_and_values() + { + var withValue = new OptionalUnionMessage("id-1", new OrderCancelled("order-7", "oops")); + var withNull = new OptionalUnionMessage("id-2", null); + + RoundTrip(withValue).Should().Be(withValue); + RoundTrip(withNull).Should().Be(withNull); + } + + [Fact(DisplayName = "Union write should fail serialization for an undeclared runtime type")] + public void Union_write_should_fail_for_undeclared_runtime_type() + { + var message = new UnionEnvelope("env-7", new UndeclaredEvent()); + + var write = () => _serializer.ToBinary(message); + + write.Should().Throw() + .WithMessage("*not a declared union member*"); + } + + [Fact(DisplayName = "Union write should fail serialization for an undeclared subtype of a declared member")] + public void Union_write_should_fail_for_undeclared_subtype_of_declared_member() + { + // Exact-runtime-type dispatch: a subtype of a declared member must NOT silently serialize + // as its base (it would drop the subtype's state on the wire). + var message = new UnionEnvelope("env-8", new DerivedOrderCancelled("order-8", "reason", "extra")); + + var write = () => _serializer.ToBinary(message); + + write.Should().Throw() + .WithMessage("*not a declared union member*"); + } + + [Fact(DisplayName = "Union SizeHint should report UnknownSize for an undeclared runtime type")] + public void Union_size_hint_should_report_unknown_for_undeclared_type() + { + var message = new UnionEnvelope("env-9", new UndeclaredEvent()); + + _serializer.SizeHint(message).Should().Be(global::Akka.Serialization.SerializerV2.UnknownSize); + } + + [Fact(DisplayName = "Union read should fail on an unknown manifest")] + public void Union_read_should_fail_on_unknown_manifest() + { + var bytes = BuildUnionEnvelopeBytes((ref MessagePackWriter writer) => + { + writer.WriteMapHeader(2); + writer.Write(1); + writer.Write("no-such-manifest-v1"); + writer.Write(2); + writer.WriteMapHeader(0); + }); + + var read = () => _serializer.FromBinary(bytes, "union-envelope-v1"); + + read.Should().Throw() + .WithMessage("*Unknown union manifest*no-such-manifest-v1*"); + } + + [Fact(DisplayName = "Union read should fail when the payload precedes the manifest")] + public void Union_read_should_fail_when_payload_precedes_manifest() + { + var bytes = BuildUnionEnvelopeBytes((ref MessagePackWriter writer) => + { + writer.WriteMapHeader(2); + writer.Write(2); + writer.WriteMapHeader(0); + writer.Write(1); + writer.Write(OrderPlaced.ManifestName); + }); + + var read = () => _serializer.FromBinary(bytes, "union-envelope-v1"); + + read.Should().Throw() + .WithMessage("*manifest must precede*"); + } + + [Fact(DisplayName = "Union read should fail when the payload is missing entirely")] + public void Union_read_should_fail_when_payload_missing() + { + var bytes = BuildUnionEnvelopeBytes((ref MessagePackWriter writer) => + { + writer.WriteMapHeader(1); + writer.Write(1); + writer.Write(OrderPlaced.ManifestName); + }); + + var read = () => _serializer.FromBinary(bytes, "union-envelope-v1"); + + read.Should().Throw() + .WithMessage("*Missing union payload*"); + } + + [Fact(DisplayName = "Union read should skip unknown keys in the union frame for forward compatibility")] + public void Union_read_should_skip_unknown_union_frame_keys() + { + var bytes = BuildUnionEnvelopeBytes((ref MessagePackWriter writer) => + { + writer.WriteMapHeader(3); + writer.Write(1); + writer.Write(OrderPlaced.ManifestName); + writer.Write(2); + writer.WriteMapHeader(2); + writer.Write(1); + writer.Write("order-10"); + writer.Write(2); + writer.Write(10); + writer.Write(99); + writer.Write("future-union-metadata"); + }); + + var result = _serializer.FromBinary(bytes, "union-envelope-v1") + .Should().BeOfType().Subject; + + result.Event.Should().Be(new OrderPlaced("order-10", 10)); + } + + [Fact(DisplayName = "Union member that is also a top-level message should keep one manifest across both roles")] + public void Union_member_should_reuse_manifest_across_roles() + { + // OrderPlaced is both a top-level protocol message and a union member. The SAME manifest + // drives ordinary serializer dispatch and union dispatch -- one identity, two paths. + var topLevel = new OrderPlaced("order-11", 2); + + _serializer.Manifest(topLevel).Should().Be(OrderPlaced.ManifestName); + var roundTripped = _serializer.FromBinary(_serializer.ToBinary(topLevel), OrderPlaced.ManifestName); + roundTripped.Should().Be(topLevel); + } + + /// + /// Builds raw bytes with a caller-controlled union frame so read-side + /// failure modes (unknown manifest, ordering, missing payload) can be exercised directly. + /// + private static byte[] BuildUnionEnvelopeBytes(WriteUnionFrame writeUnionFrame) + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteMapHeader(2); + writer.Write(1); + writer.Write("crafted"); + writer.Write(2); + writeUnionFrame(ref writer); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private delegate void WriteUnionFrame(ref MessagePackWriter writer); + + private TMessage RoundTrip(TMessage message) + where TMessage : class, IUnionTestProtocol + { + var bytes = _serializer.ToBinary(message); + var manifest = _serializer.Manifest(message); + return _serializer.FromBinary(bytes, manifest).Should().BeOfType().Subject; + } +} + +public interface IUnionTestProtocol +{ +} + +/// The union's static field type: an ordinary interface, not a protocol marker. +public interface IOrderEvent +{ +} + +[AkkaSerializer(Name = "union-test", SerializerId = 120303)] +public sealed partial class UnionTestSerializer : MessagePackSerializer +{ + public static partial SerializerRegistration CreateRegistration(); +} + +[AkkaSerializable(Manifest = "union-envelope-v1")] +public sealed record UnionEnvelope( + [property: AkkaField(1)] string EnvelopeId, + [property: AkkaField(2), AkkaUnion(typeof(OrderPlaced), typeof(OrderCancelled), typeof(OrderNote))] + IOrderEvent Event) : IUnionTestProtocol; + +[AkkaSerializable(Manifest = "optional-union-v1")] +public sealed record OptionalUnionMessage( + [property: AkkaField(1)] string Id, + [property: AkkaField(2), AkkaUnion(typeof(OrderPlaced), typeof(OrderCancelled))] + IOrderEvent? MaybeEvent) : IUnionTestProtocol; + +/// A union member that is ALSO a top-level protocol message: one manifest, two roles. +[AkkaSerializable(Manifest = ManifestName)] +public sealed record OrderPlaced( + [property: AkkaField(1)] string OrderId, + [property: AkkaField(2)] int Quantity) : IOrderEvent, IUnionTestProtocol +{ + public const string ManifestName = "order-placed-v1"; +} + +/// A nested-only union member: its manifest exists purely as the union discriminator. +[AkkaSerializable(Manifest = "order-cancelled-v1")] +public record OrderCancelled( + [property: AkkaField(1)] string OrderId, + [property: AkkaField(2)] string Reason) : IOrderEvent; + +/// A value-type union member: dispatched by exact boxed runtime type. +[AkkaSerializable(Manifest = "order-note-v1")] +public readonly record struct OrderNote( + [property: AkkaField(1)] string OrderId, + [property: AkkaField(2)] string Note) : IOrderEvent; + +/// Implements the field's static type but is NOT a declared union member. +public sealed record UndeclaredEvent : IOrderEvent; + +/// An undeclared SUBTYPE of a declared member: must fail, not truncate to its base. +public sealed record DerivedOrderCancelled(string OrderId, string Reason, string Extra) + : OrderCancelled(OrderId, Reason); diff --git a/src/core/Akka.Serialization.V2/Attributes.cs b/src/core/Akka.Serialization.V2/Attributes.cs index 0f7fac87ed2..3977f499426 100644 --- a/src/core/Akka.Serialization.V2/Attributes.cs +++ b/src/core/Akka.Serialization.V2/Attributes.cs @@ -74,6 +74,40 @@ public sealed class AkkaEnvelopePayloadAttribute : Attribute { } +/// +/// Marks an property whose static type is an interface or abstract +/// base as a closed, explicitly-enumerated union of concrete +/// member types. +/// +/// +/// Unlike (a runtime serializer boundary for payloads +/// whose concrete type may live in an assembly unknown at compile time), a union field is encoded +/// structurally inline: the generator emits compile-time dispatch over the declared member set, +/// discriminated by each member's . Every member +/// must be [AkkaSerializable], declare a manifest unique within this union, and be assignable +/// to the field's static type. A runtime value whose exact type is not a declared member fails +/// serialization. When both this attribute and are +/// present, the envelope payload marker wins (consistent with its precedence over formatter +/// registrations). +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +public sealed class AkkaUnionAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The closed set of concrete member types this field may hold. + public AkkaUnionAttribute(params Type[] memberTypes) + { + MemberTypes = memberTypes; + } + + /// + /// The closed set of concrete member types this field may hold. + /// + public Type[] MemberTypes { get; } +} + /// /// Registers a hand-written for a foreign type that /// cannot be annotated with (for example, a core Akka type From 5308542aca09fb643d20ef24bae96e78758a5254 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 15:44:47 -0500 Subject: [PATCH 02/12] Akka.Serialization.V2 PoC: [AkkaSerializableInstantiation] closed generic messages Part 1 of the design in #8384, modeled on System.Text.Json source gen closed construction registration: a generic [AkkaSerializable] type is never serialized as an open definition -- each closed construction is registered on the [AkkaSerializer] module with its own manifest, and its generic fields are extracted from the constructed symbol (Roslyn substitutes member types, so a "T Payload" property arrives concretely typed with no type-parameter special-casing). - AkkaSerializableInstantiationAttribute(Type closedType) { Manifest } - Arity-aware member naming (Wrapper folds type arguments into generated identifiers) with AKKASG024 collision detection instead of silent dropping - Arity-aware Object field mapping: a Wrapper-typed field resolves to its registered instantiation (or fails AKKASG023) instead of colliding on the arity-less name - Generic definitions become non-serialized placeholders; AKKASG022 fires when one implements a serializer protocol with no registered constructions - AKKASG020/021 validate instantiation targets and duplicates - Combined scenario green: EventWrapper with a union payload round-trips fully typed, inline, exact-sized --- .../AkkaSerializerGenerator.cs | 350 +++++++++++++++++- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 241 ++++++++++++ .../GeneratedClosedGenericSpec.cs | 181 +++++++++ src/core/Akka.Serialization.V2/Attributes.cs | 45 +++ 4 files changed, 807 insertions(+), 10 deletions(-) create mode 100644 src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 7f8e3cc6d5a..699c8ce052f 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -25,6 +25,7 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator private const string FieldAttributeFullName = "Akka.Serialization.V2.AkkaFieldAttribute"; private const string EnvelopePayloadAttributeFullName = "Akka.Serialization.V2.AkkaEnvelopePayloadAttribute"; private const string UnionAttributeFullName = "Akka.Serialization.V2.AkkaUnionAttribute"; + private const string InstantiationAttributeFullName = "Akka.Serialization.V2.AkkaSerializableInstantiationAttribute"; private const string FormatterAttributeFullName = "Akka.Serialization.V2.AkkaSerializerFormatterAttribute"; private const string FormatterInterfaceFullName = "Akka.Serialization.V2.IAkkaMessagePackFormatter`1"; private const string ExtendedActorSystemFullName = "Akka.Actor.ExtendedActorSystem"; @@ -182,6 +183,46 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor InvalidInstantiationTarget = new( + "AKKASG020", + "Instantiation target is not a closed generic serializable construction", + "[AkkaSerializableInstantiation] target '{0}' on serializer '{1}' must be a closed generic construction of a generic type annotated with [AkkaSerializable]", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateInstantiation = new( + "AKKASG021", + "Duplicate instantiation registration", + "Serializer '{0}' registers the closed construction '{1}' more than once", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor GenericSerializableRequiresInstantiation = new( + "AKKASG022", + "Generic serializable type requires closed instantiations", + "Generic [AkkaSerializable] type '{0}' implements protocol '{1}' of serializer '{2}' but has no [AkkaSerializableInstantiation] registrations; a source generator cannot serialize an open generic, so register each closed construction with [AkkaSerializableInstantiation(typeof(...), Manifest = ...)]", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnregisteredClosedGenericField = new( + "AKKASG023", + "Closed generic field type is not registered", + "Property '{0}' on type '{1}' uses closed generic [AkkaSerializable] type '{2}', which must be registered on serializer '{3}' with [AkkaSerializableInstantiation]", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateGeneratedName = new( + "AKKASG024", + "Generated member name collision", + "Serializer '{0}' produces the same generated member name '{1}' for distinct message types {2}; rename one of the types to disambiguate", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { var serializers = context.SyntaxProvider @@ -238,10 +279,30 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!ValidateFormatters(ctx, serializer)) continue; - var allMessages = pair.Right + if (!ValidateInstantiations(ctx, serializer)) + continue; + + var declaredMessages = pair.Right .Where(message => message != null) .Cast() .ToImmutableArray(); + + // Generic definitions are placeholders: never serialized, never top-level, never in + // the message dictionary (their arity-less key could even collide with a same-named + // non-generic type). They exist only for the AKKASG022 check below. + var genericDefinitions = declaredMessages + .Where(message => message.IsGenericDefinition) + .ToImmutableArray(); + + if (!ValidateGenericDefinitions(ctx, serializer, genericDefinitions)) + continue; + + var allMessages = declaredMessages + .Where(message => !message.IsGenericDefinition) + .Concat(serializer.Instantiations + .Where(instantiation => instantiation.Message != null) + .Select(instantiation => instantiation.Message!)) + .ToImmutableArray(); var allMessagesByType = allMessages.ToImmutableDictionary(message => message.FullyQualifiedName); var resolvedMessagesByType = ResolveMessages(allMessagesByType, serializer.Formatters); var topLevelMessages = allMessages @@ -294,6 +355,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var formatterInterfaceType = compilation.GetTypeByMetadataName(FormatterInterfaceFullName); var extendedActorSystemType = compilation.GetTypeByMetadataName(ExtendedActorSystemFullName); var formatters = ExtractFormatters(symbol, formatterAttributeType, formatterInterfaceType, extendedActorSystemType); + var instantiations = ExtractInstantiations(symbol, compilation); return new SerializerInfo( GetNamespace(symbol), @@ -304,7 +366,98 @@ public void Initialize(IncrementalGeneratorInitializationContext context) protocolType, protocolTypeFullName, symbol.DeclaredAccessibility, - formatters); + formatters, + instantiations); + } + + /// + /// Extracts [AkkaSerializableInstantiation] registrations from the serializer class. A + /// valid target is a CLOSED generic construction (no unbound generics, no type parameters + /// anywhere in its arguments) whose definition is annotated [AkkaSerializable]; its + /// is built from the constructed symbol, so all field types arrive + /// already substituted. Invalid targets are recorded with a null message so AKKASG020 fires. + /// + private static ImmutableArray ExtractInstantiations(INamedTypeSymbol symbol, Compilation compilation) + { + var attributeType = compilation.GetTypeByMetadataName(InstantiationAttributeFullName); + if (attributeType == null) + return ImmutableArray.Empty; + + var attributes = symbol.GetAttributes() + .Where(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeType)) + .ToImmutableArray(); + if (attributes.IsEmpty) + return ImmutableArray.Empty; + + var knownTypes = KnownTypes.From(compilation); + var builder = ImmutableArray.CreateBuilder(attributes.Length); + foreach (var attribute in attributes) + { + if (attribute.ConstructorArguments.Length != 1) + continue; + + var manifest = string.Empty; + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == "Manifest" && argument.Value.Value is string value) + manifest = value; + } + + var target = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + var serializableAttribute = target?.OriginalDefinition.GetAttributes() + .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute)); + var isValidTarget = target is { IsGenericType: true, IsUnboundGenericType: false } + && IsFullyClosed(target) + && serializableAttribute != null; + + if (!isValidTarget) + { + var displayName = attribute.ConstructorArguments[0].Value is ITypeSymbol typeSymbol + ? typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : ""; + builder.Add(new InstantiationInfo(displayName, message: null)); + continue; + } + + // AllowEmpty travels with the definition's [AkkaSerializable]; the manifest is + // per-construction (each closed form needs its own identity) and comes from the + // registration attribute. + var allowEmpty = serializableAttribute!.NamedArguments + .Any(argument => argument.Key == "AllowEmpty" && argument.Value.Value is true); + var message = ExtractMessageCore( + target!, + GetMessageDictionaryKey(target!), + manifest, + allowEmpty, + knownTypes, + compilation, + definitionFullName: GetFullyQualifiedTypeName(target!.OriginalDefinition)); + builder.Add(new InstantiationInfo(message.FullyQualifiedName, message)); + } + + return builder.ToImmutable(); + } + + /// + /// Whether every type argument of a construction (recursively) is a concrete type -- i.e. no + /// type parameter appears anywhere. Wrapper<Foo> and + /// Wrapper<Pair<Foo, Bar>> qualify; Wrapper<T> inside another + /// generic declaration does not. + /// + private static bool IsFullyClosed(INamedTypeSymbol type) + { + foreach (var argument in type.TypeArguments) + { + switch (argument) + { + case ITypeParameterSymbol: + return false; + case INamedTypeSymbol { IsGenericType: true } nested when !IsFullyClosed(nested): + return false; + } + } + + return true; } private static ImmutableArray ExtractFormatters( @@ -440,6 +593,60 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ return isValid; } + private static bool ValidateInstantiations(SourceProductionContext context, SerializerInfo serializer) + { + if (serializer.Instantiations.IsDefaultOrEmpty) + return true; + + var isValid = true; + foreach (var instantiation in serializer.Instantiations.Where(instantiation => instantiation.Message == null)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidInstantiationTarget, Location.None, instantiation.TargetDisplayName, serializer.ClassName)); + isValid = false; + } + + foreach (var duplicate in serializer.Instantiations + .Where(instantiation => instantiation.Message != null) + .GroupBy(instantiation => instantiation.TargetDisplayName, StringComparer.Ordinal) + .Where(group => group.Count() > 1)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateInstantiation, Location.None, serializer.ClassName, duplicate.Key)); + isValid = false; + } + + return isValid; + } + + /// + /// Fires AKKASG022 when a generic [AkkaSerializable] definition implements this + /// serializer's protocol interface but no closed construction of it is registered: without a + /// registration the type would silently never serialize, which is exactly the confusing + /// broken-codegen failure mode this diagnostic replaces. + /// + private static bool ValidateGenericDefinitions(SourceProductionContext context, SerializerInfo serializer, ImmutableArray genericDefinitions) + { + if (genericDefinitions.IsDefaultOrEmpty || serializer.ProtocolType == null) + return true; + + var isValid = true; + foreach (var definition in genericDefinitions) + { + if (!definition.Protocols.Any(protocol => SymbolEqualityComparer.Default.Equals(protocol, serializer.ProtocolType))) + continue; + + var hasInstantiation = serializer.Instantiations.Any(instantiation => + instantiation.Message != null && + string.Equals(instantiation.Message.DefinitionFullName, definition.FullyQualifiedName, StringComparison.Ordinal)); + if (hasInstantiation) + continue; + + context.ReportDiagnostic(Diagnostic.Create(GenericSerializableRequiresInstantiation, Location.None, definition.FullyQualifiedName, serializer.ProtocolTypeFullName, serializer.ClassName)); + isValid = false; + } + + return isValid; + } + private static MessageInfo? ExtractMessage(GeneratorAttributeSyntaxContext context) { var symbol = (INamedTypeSymbol)context.TargetSymbol; @@ -456,6 +663,44 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ allowEmpty = allowEmptyValue; } + // A generic [AkkaSerializable] DEFINITION is never a message itself -- a source generator + // cannot reify an open generic. Only its registered closed constructions serialize + // ([AkkaSerializableInstantiation], AKKASG020/022). Extract it as a flagged placeholder + // carrying its protocols (for the AKKASG022 check) but no fields: a T-typed field would map + // as Unsupported and produce a misleading AKKASG003 against the definition. + if (symbol.IsGenericType) + { + return new MessageInfo( + symbol.Name, + GetFullyQualifiedTypeName(symbol), + manifest, + ImmutableArray.Empty, + symbol.AllInterfaces.ToImmutableArray(), + allowEmpty: true, + isGenericDefinition: true, + definitionFullName: GetFullyQualifiedTypeName(symbol)); + } + + return ExtractMessageCore(symbol, GetFullyQualifiedTypeName(symbol), manifest, allowEmpty, knownTypes, compilation, definitionFullName: string.Empty); + } + + /// + /// Builds the for a concrete serializable type: either an ordinary + /// non-generic [AkkaSerializable] declaration or a registered closed generic + /// construction. For a closed construction, returns + /// SUBSTITUTED members -- a property declared as T Payload surfaces here with its + /// concrete type argument -- so ordinary field inference applies with no type-parameter + /// special-casing. + /// + private static MessageInfo ExtractMessageCore( + INamedTypeSymbol symbol, + string fullyQualifiedName, + string manifest, + bool allowEmpty, + KnownTypes knownTypes, + Compilation compilation, + string definitionFullName) + { var fields = new List(); foreach (var member in symbol.GetMembers().OfType()) { @@ -486,11 +731,13 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ return new MessageInfo( symbol.Name, - GetFullyQualifiedTypeName(symbol), + fullyQualifiedName, manifest, fields.OrderBy(f => f.Index).ToImmutableArray(), symbol.AllInterfaces.ToImmutableArray(), - allowEmpty); + allowEmpty, + isGenericDefinition: false, + definitionFullName: definitionFullName); } /// @@ -702,6 +949,34 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer if (!ValidateUnionField(context, message, field, messagesByType)) isValid = false; } + + // An Object mapping that resolves to no known message would generate a call to a + // nonexistent Write/Read/SizeOf method. The only way to hit this from within one + // compilation is a closed generic [AkkaSerializable] field type whose construction was + // never registered with [AkkaSerializableInstantiation] (non-generic [AkkaSerializable] + // types are always extracted). AKKASG023 names the fix. + foreach (var field in message.Fields) + { + var objectTypeNames = new HashSet(StringComparer.Ordinal); + CollectObjectTypeNames(field.Mapping, objectTypeNames); + foreach (var objectTypeName in objectTypeNames.Where(typeName => !messagesByType.ContainsKey(typeName))) + { + context.ReportDiagnostic(Diagnostic.Create(UnregisteredClosedGenericField, Location.None, field.Name, message.FullyQualifiedName, objectTypeName, serializer.ClassName)); + isValid = false; + } + } + } + + // Flattening generic constructions into generated member names is collision-prone in + // principle (mirrors System.Text.Json's DuplicateTypeName handling): detect and fail + // instead of silently emitting duplicate members. + foreach (var collision in reachableMessages + .GroupBy(GetMessageMethodName, StringComparer.Ordinal) + .Where(group => group.Select(m => m.FullyQualifiedName).Distinct(StringComparer.Ordinal).Count() > 1)) + { + var typeNames = string.Join(", ", collision.Select(m => m.FullyQualifiedName)); + context.ReportDiagnostic(Diagnostic.Create(DuplicateGeneratedName, Location.None, serializer.ClassName, collision.Key, typeNames)); + isValid = false; } return isValid; @@ -1928,8 +2203,13 @@ private static TypeMapping MapTypeCore(ITypeSymbol type, KnownTypes knownTypes) if (type is IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Byte }) return new TypeMapping(FieldKind.ByteArray); - if (type is INamedTypeSymbol namedType && namedType.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute))) - return new TypeMapping(FieldKind.Object, GetFullyQualifiedTypeName(namedType), namedType.IsValueType); + // OriginalDefinition covers both shapes: for a non-generic type it is the type itself; for a + // closed generic construction (Wrapper) the [AkkaSerializable] attribute lives on the + // definition. The mapping name is arity-aware (GetMessageDictionaryKey) so a closed + // construction resolves to its registered [AkkaSerializableInstantiation] message -- or, if + // unregistered, fails AKKASG023 instead of silently dropping its type arguments. + if (type is INamedTypeSymbol namedType && namedType.OriginalDefinition.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute))) + return new TypeMapping(FieldKind.Object, GetMessageDictionaryKey(namedType), namedType.IsValueType); var mapping = type.SpecialType switch { @@ -2173,11 +2453,24 @@ private static string SanitizeTypeName(string typeFullName) // Escape literal underscores FIRST so sanitization is collision-free: // 'My.Ns.Foo_Bar' -> 'My_Ns_Foo__Bar' and 'My.Ns.Foo.Bar' -> 'My_Ns_Foo_Bar' stay // distinct instead of both collapsing to 'My_Ns_Foo_Bar' (duplicate generated members). + // + // Closed generic constructions fold their type arguments into the member name (mirroring + // System.Text.Json's GetTypeInfoPropertyName recursion): 'Wrapper' -> 'Wrapper_Of_Foo_End_'. + // The markers contain single underscores, which cannot collide with escaped literal + // underscores (always doubled). Residual ambiguity across marker sequences is caught by the + // AKKASG024 generated-name collision check rather than made impossible here. return typeFullName .Replace("global::", string.Empty) + .Replace(" ", string.Empty) .Replace("_", "__") .Replace(".", "_") - .Replace("+", "_"); + .Replace("+", "_") + .Replace("<", "_Of_") + .Replace(">", "_End_") + .Replace(",", "_And_") + .Replace("[", "_Arr_") + .Replace("]", "_RArr_") + .Replace("?", "_Opt_"); } private static string GetMessageMethodName(MessageInfo message) @@ -2247,7 +2540,8 @@ public SerializerInfo( INamedTypeSymbol? protocolType, string protocolTypeFullName, Accessibility declaredAccessibility, - ImmutableArray formatters) + ImmutableArray formatters, + ImmutableArray instantiations) { Namespace = ns; ClassName = className; @@ -2258,6 +2552,7 @@ public SerializerInfo( ProtocolTypeFullName = protocolTypeFullName; DeclaredAccessibility = declaredAccessibility; Formatters = formatters; + Instantiations = instantiations; } public string Namespace { get; } @@ -2269,6 +2564,7 @@ public SerializerInfo( public string ProtocolTypeFullName { get; } public Accessibility DeclaredAccessibility { get; } public ImmutableArray Formatters { get; } + public ImmutableArray Instantiations { get; } } private sealed class KnownTypes @@ -2306,7 +2602,7 @@ public static KnownTypes From(Compilation compilation) private sealed class MessageInfo { - public MessageInfo(string simpleName, string fullyQualifiedName, string manifest, ImmutableArray fields, ImmutableArray protocols, bool allowEmpty) + public MessageInfo(string simpleName, string fullyQualifiedName, string manifest, ImmutableArray fields, ImmutableArray protocols, bool allowEmpty, bool isGenericDefinition = false, string definitionFullName = "") { SimpleName = simpleName; FullyQualifiedName = fullyQualifiedName; @@ -2314,6 +2610,8 @@ public MessageInfo(string simpleName, string fullyQualifiedName, string manifest Fields = fields; Protocols = protocols; AllowEmpty = allowEmpty; + IsGenericDefinition = isGenericDefinition; + DefinitionFullName = definitionFullName; } public string SimpleName { get; } @@ -2323,12 +2621,44 @@ public MessageInfo(string simpleName, string fullyQualifiedName, string manifest public ImmutableArray Protocols { get; } public bool AllowEmpty { get; } + /// + /// True for a generic [AkkaSerializable] DEFINITION (e.g. Wrapper<T>): + /// a placeholder that is never serialized, never top-level, and never reachable -- it exists + /// only so AKKASG022 can fire when the definition implements a serializer protocol but has + /// no registered closed constructions. + /// + public bool IsGenericDefinition { get; } + + /// + /// For a registered closed construction: the arity-less fully-qualified name of its generic + /// definition, linking the instantiation back to the definition for the AKKASG022 check. + /// Empty for ordinary non-generic messages. + /// + public string DefinitionFullName { get; } + public MessageInfo WithFields(ImmutableArray fields) { - return new MessageInfo(SimpleName, FullyQualifiedName, Manifest, fields, Protocols, AllowEmpty); + return new MessageInfo(SimpleName, FullyQualifiedName, Manifest, fields, Protocols, AllowEmpty, IsGenericDefinition, DefinitionFullName); } } + /// + /// A single [AkkaSerializableInstantiation] registration. is null + /// when the target was invalid (not a type, non-generic, unbound, or its definition lacks + /// [AkkaSerializable]) so AKKASG020 fires instead of the registration silently vanishing. + /// + private sealed class InstantiationInfo + { + public InstantiationInfo(string targetDisplayName, MessageInfo? message) + { + TargetDisplayName = targetDisplayName; + Message = message; + } + + public string TargetDisplayName { get; } + public MessageInfo? Message { get; } + } + private sealed class FieldInfo { public FieldInfo(int index, string name, string typeFullName, TypeMapping mapping, bool isNullable, FormatterInfo? formatter = null, ImmutableArray unionMembers = default) diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 442941bd243..42744997425 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -1006,6 +1006,247 @@ public sealed record OkEnumMessage( diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); } + [Fact(DisplayName = "Generator should report AKKASG015 when a union member is not serializable")] + public void Generator_should_report_AKKASG015_when_union_member_not_serializable() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + public interface IEvent + { + } + + public sealed record NotSerializable(string Value) : IEvent; + + [AkkaSerializer(Name = "sample", SerializerId = 120701)] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer( + [property: AkkaField(1), AkkaUnion(typeof(NotSerializable))] IEvent Event) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG015" && diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact(DisplayName = "Generator should report AKKASG016 when a union member has no manifest")] + public void Generator_should_report_AKKASG016_when_union_member_has_no_manifest() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + public interface IEvent + { + } + + [AkkaSerializable] + public sealed record ManifestlessMember([property: AkkaField(1)] string Value) : IEvent; + + [AkkaSerializer(Name = "sample", SerializerId = 120702)] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer( + [property: AkkaField(1), AkkaUnion(typeof(ManifestlessMember))] IEvent Event) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG016" && diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact(DisplayName = "Generator should report AKKASG018 when a union member is not assignable to the field type")] + public void Generator_should_report_AKKASG018_when_union_member_not_assignable() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + public interface IEvent + { + } + + [AkkaSerializable(Manifest = "unrelated-v1")] + public sealed record Unrelated([property: AkkaField(1)] string Value); + + [AkkaSerializer(Name = "sample", SerializerId = 120703)] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer( + [property: AkkaField(1), AkkaUnion(typeof(Unrelated))] IEvent Event) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG018" && diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact(DisplayName = "Generator should report AKKASG020 when an instantiation target is an unbound generic")] + public void Generator_should_report_AKKASG020_when_instantiation_target_is_unbound() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Payload) : IProtocol; + + [AkkaSerializer(Name = "sample", SerializerId = 120704)] + [AkkaSerializableInstantiation(typeof(Wrapper<>), Manifest = "wrap-v1")] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG020" && diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact(DisplayName = "Generator should report AKKASG020 when an instantiation target is not generic")] + public void Generator_should_report_AKKASG020_when_instantiation_target_is_not_generic() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable(Manifest = "plain-v1")] + public sealed record Plain([property: AkkaField(1)] string Value) : IProtocol; + + [AkkaSerializer(Name = "sample", SerializerId = 120705)] + [AkkaSerializableInstantiation(typeof(Plain), Manifest = "plain-again-v1")] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG020" && diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact(DisplayName = "Generator should report AKKASG022 when a generic protocol message has no instantiations")] + public void Generator_should_report_AKKASG022_when_generic_message_has_no_instantiations() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Payload) : IProtocol; + + [AkkaSerializer(Name = "sample", SerializerId = 120706)] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG022" && diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact(DisplayName = "Generator should report AKKASG023 when a closed generic field type is not registered")] + public void Generator_should_report_AKKASG023_when_closed_generic_field_not_registered() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Payload); + + [AkkaSerializable] + public sealed record Payload([property: AkkaField(1)] string Value); + + [AkkaSerializer(Name = "sample", SerializerId = 120707)] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer( + [property: AkkaField(1)] Wrapper Inner) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG023" && diagnostic.Severity == DiagnosticSeverity.Error); + } + private static ImmutableArray RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12); diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs new file mode 100644 index 00000000000..8c2edefc326 --- /dev/null +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs @@ -0,0 +1,181 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2026 .NET Foundation +// +//----------------------------------------------------------------------- + +#nullable enable +using System.Buffers; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using MessagePack; +using Xunit; + +namespace Akka.Serialization.V2.Tests; + +/// +/// Specs for [AkkaSerializableInstantiation]: registering CLOSED generic constructions of a +/// generic [AkkaSerializable] type on the serializer module, the same closed-construction +/// registration model System.Text.Json's source generator uses for [JsonSerializable]. +/// Each registered construction behaves as its own top-level message with its own manifest, with +/// generic fields resolved against the concrete type arguments. +/// +public sealed class GeneratedClosedGenericSpec : IAsyncLifetime +{ + private ActorSystem _system = null!; + private ClosedGenericTestSerializer _serializer = null!; + + public ValueTask InitializeAsync() + { + _system = ActorSystem.Create("generated-closed-generic-spec"); + _serializer = new ClosedGenericTestSerializer((ExtendedActorSystem)_system); + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await _system.Terminate(); + } + + [Fact(DisplayName = "Registered closed construction should round-trip with its generic field typed inline")] + public void Closed_construction_should_round_trip_typed_inline() + { + var message = new Wrapper("wrap-1", new OrderRequest("order-1", 5), 3); + + var result = RoundTrip(message); + + result.Should().Be(message); + result.Payload.Should().BeOfType(); + } + + [Fact(DisplayName = "Distinct closed constructions of the same generic should dispatch by distinct manifests")] + public void Distinct_constructions_should_dispatch_by_distinct_manifests() + { + var requestWrapper = new Wrapper("wrap-2", new OrderRequest("order-2", 7), null); + var receiptWrapper = new Wrapper("wrap-3", new OrderReceipt("receipt-1"), 1); + + _serializer.Manifest(requestWrapper).Should().Be("wrap-request-v1"); + _serializer.Manifest(receiptWrapper).Should().Be("wrap-receipt-v1"); + + RoundTrip(requestWrapper).Should().Be(requestWrapper); + RoundTrip(receiptWrapper).Should().Be(receiptWrapper); + } + + [Fact(DisplayName = "Closed construction wire format should inline the substituted payload with no discriminator")] + public void Closed_construction_wire_format_should_inline_payload() + { + // Contrast with both [AkkaEnvelopePayload] ({id, manifest, bytes}) and [AkkaUnion] + // ({manifest, fields}): a T-typed field in a registered construction is statically known, + // so it encodes as a plain nested field map with NO discriminator of any kind. + var message = new Wrapper("wrap-4", new OrderRequest("order-4", 9), null); + var bytes = _serializer.ToBinary(message); + + var reader = new MessagePackReader(new ReadOnlySequence(bytes)); + reader.ReadMapHeader().Should().Be(3); + reader.ReadInt32().Should().Be(1); + reader.ReadString().Should().Be("wrap-4"); + reader.ReadInt32().Should().Be(2); + reader.ReadMapHeader().Should().Be(2); + reader.ReadInt32().Should().Be(1); + reader.ReadString().Should().Be("order-4"); + reader.ReadInt32().Should().Be(2); + reader.ReadInt32().Should().Be(9); + reader.ReadInt32().Should().Be(3); + reader.TryReadNil().Should().BeTrue(); + reader.Consumed.Should().Be(bytes.Length); + } + + [Fact(DisplayName = "Closed construction SizeHint should be exact")] + public void Closed_construction_size_hint_should_be_exact() + { + var message = new Wrapper("wrap-5", new OrderReceipt("receipt-5"), 42); + + _serializer.SizeHint(message).Should().Be(_serializer.ToBinary(message).Length); + } + + [Fact(DisplayName = "Closed construction should be usable as a nested field of an ordinary message")] + public void Closed_construction_should_nest_inside_ordinary_message() + { + var message = new WrapperCarrier("carrier-1", new Wrapper("wrap-6", new OrderReceipt("receipt-6"), null)); + + RoundTrip(message).Should().Be(message); + } + + [Fact(DisplayName = "Generic wrapper with a union payload should round-trip the combined scenario fully typed")] + public void Generic_wrapper_with_union_payload_should_round_trip() + { + // THE motivating scenario from the design issue: a generic wrapper registered as a closed + // construction whose payload field is a closed manifest-discriminated union. Fully typed, + // inline, exact-sized -- no object boundary anywhere. + var placed = new EventWrapper("evt-1", new OrderPlaced("order-10", 2)); + var cancelled = new EventWrapper("evt-2", new OrderCancelled("order-11", "late")); + + var placedResult = RoundTrip(placed); + placedResult.Should().Be(placed); + placedResult.Body.Should().BeOfType(); + + var cancelledResult = RoundTrip(cancelled); + cancelledResult.Should().Be(cancelled); + cancelledResult.Body.Should().BeOfType(); + + _serializer.SizeHint(placed).Should().Be(_serializer.ToBinary(placed).Length); + } + + private TMessage RoundTrip(TMessage message) + where TMessage : class, IClosedGenericTestProtocol + { + var bytes = _serializer.ToBinary(message); + var manifest = _serializer.Manifest(message); + return _serializer.FromBinary(bytes, manifest).Should().BeOfType().Subject; + } +} + +public interface IClosedGenericTestProtocol +{ +} + +[AkkaSerializer(Name = "closed-generic-test", SerializerId = 120404)] +[AkkaSerializableInstantiation(typeof(Wrapper), Manifest = "wrap-request-v1")] +[AkkaSerializableInstantiation(typeof(Wrapper), Manifest = "wrap-receipt-v1")] +[AkkaSerializableInstantiation(typeof(EventWrapper), Manifest = "event-wrap-v1")] +public sealed partial class ClosedGenericTestSerializer : MessagePackSerializer +{ + public static partial SerializerRegistration CreateRegistration(); +} + +/// +/// A generic protocol message. The open definition carries the [AkkaField] indices but is never +/// serialized itself -- only the closed constructions registered on the serializer are. +/// +[AkkaSerializable] +public sealed record Wrapper( + [property: AkkaField(1)] string WrapperId, + [property: AkkaField(2)] T Payload, + [property: AkkaField(3)] int? Priority) : IClosedGenericTestProtocol; + +/// +/// The combined scenario: a generic wrapper whose payload property is a closed union. At +/// instantiation time (T := IOrderEvent) the union members are validated against the substituted +/// static type. +/// +[AkkaSerializable] +public sealed record EventWrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2), AkkaUnion(typeof(OrderPlaced), typeof(OrderCancelled))] T Body) + : IClosedGenericTestProtocol; + +[AkkaSerializable(Manifest = "carrier-v1")] +public sealed record WrapperCarrier( + [property: AkkaField(1)] string CarrierId, + [property: AkkaField(2)] Wrapper Inner) : IClosedGenericTestProtocol; + +[AkkaSerializable] +public sealed record OrderRequest( + [property: AkkaField(1)] string OrderId, + [property: AkkaField(2)] int Quantity); + +[AkkaSerializable] +public sealed record OrderReceipt( + [property: AkkaField(1)] string ReceiptId); diff --git a/src/core/Akka.Serialization.V2/Attributes.cs b/src/core/Akka.Serialization.V2/Attributes.cs index 3977f499426..b64bfc69ba9 100644 --- a/src/core/Akka.Serialization.V2/Attributes.cs +++ b/src/core/Akka.Serialization.V2/Attributes.cs @@ -108,6 +108,51 @@ public AkkaUnionAttribute(params Type[] memberTypes) public Type[] MemberTypes { get; } } +/// +/// Registers a CLOSED generic construction of a generic +/// type (for example typeof(Wrapper<OrderPlaced>)) with a generated serializer. +/// +/// +/// A Roslyn source generator cannot reify open generics: it can only emit concrete serialization +/// code for closed constructions it can see at compile time (the same rule System.Text.Json's +/// source generator enforces by rejecting unbound generics in [JsonSerializable]). C# does +/// not allow attributes on a closed construction directly, so registration lives here, on the +/// [AkkaSerializer] partial class — analogous to . +/// Each registered construction behaves exactly like its own top-level [AkkaSerializable] +/// message: it needs a distinct , participates in ordinary manifest dispatch, +/// and its generic fields are resolved against the concrete type arguments. The generic type +/// definition itself must still be annotated [AkkaSerializable] (that is where the +/// [AkkaField] indices live), but the open definition is never serialized — only its +/// registered closed constructions are. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class AkkaSerializableInstantiationAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// A closed generic construction of a generic type annotated with + /// . + /// + public AkkaSerializableInstantiationAttribute(Type closedType) + { + ClosedType = closedType; + } + + /// + /// The closed generic construction being registered. + /// + public Type ClosedType { get; } + + /// + /// Stable serializer-owned manifest for this closed construction. Required when the + /// construction implements the serializer's protocol interface (top-level dispatch); also the + /// union discriminator when the construction is an member. + /// + public string? Manifest { get; init; } +} + /// /// Registers a hand-written for a foreign type that /// cannot be annotated with (for example, a core Akka type From cfa4e45388be93df3d1fe86109b73303dcb96054 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 15:44:54 -0500 Subject: [PATCH 03/12] Akka.Serialization.V2 PoC: type-level [AkkaUnion] declarations DX fix from the PoC audit: per-field union declarations were a drift hazard (every field of the same interface repeated the member list; lists drifting apart silently split wire compat). The union member set is now declared ONCE on the union base type (interface or abstract class), mirroring [JsonDerivedType] and the case list of the proposed C# language unions -- every field of that type inherits it. A field-level [AkkaUnion] remains as a per-field override (e.g. to narrow the accepted set for one schema). Type-level lookup runs against the SUBSTITUTED static type inside registered closed constructions, so a generic wrapper needs no union declaration at all: EventWrapper instantiated with T := IOrderEvent inherits the union declared on IOrderEvent. --- .../AkkaSerializerGenerator.cs | 27 ++++++++++++++---- .../GeneratedClosedGenericSpec.cs | 9 +++--- .../GeneratedUnionSpec.cs | 28 +++++++++++++++++-- src/core/Akka.Serialization.V2/Attributes.cs | 24 +++++++++++----- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 699c8ce052f..65ebdb3f493 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -741,12 +741,18 @@ private static MessageInfo ExtractMessageCore( } /// - /// Extracts the declared member set of an [AkkaUnion] property. Symbol-dependent facts - /// (assignability to the field's static type, unbound-generic detection) are captured here; - /// facts that need the whole message set (serializability, manifests) are validated later in - /// against the serializer's message dictionary. Malformed - /// arguments (null, not a type, unbound generic) are recorded as unsupported entries so a - /// diagnostic fires instead of the member silently vanishing. + /// Extracts the declared member set of a union field. The member set comes from the field's + /// own [AkkaUnion] when present (a per-field override), otherwise from an + /// [AkkaUnion] on the field's STATIC TYPE -- the natural declaration site, where the + /// union is stated once for every field of that interface/abstract base. For a field declared + /// as a type parameter inside a registered closed construction, the type-level lookup runs + /// against the SUBSTITUTED type argument, so T Body with T := IOrderEvent picks + /// up the union declared on IOrderEvent. + /// Symbol-dependent facts (assignability to the field's static type, unbound-generic detection) + /// are captured here; facts that need the whole message set (serializability, manifests) are + /// validated later in against the serializer's message + /// dictionary. Malformed arguments (null, not a type, unbound generic) are recorded as + /// unsupported entries so a diagnostic fires instead of the member silently vanishing. /// private static ImmutableArray ExtractUnionMembers( IPropertySymbol member, @@ -758,8 +764,17 @@ private static ImmutableArray ExtractUnionMembers( if (knownTypes.UnionAttribute == null) return ImmutableArray.Empty; + // Field-level override wins; otherwise inherit the type-level declaration from the field's + // static type. OriginalDefinition covers a generic union base, where the attribute lives on + // the definition. var unionAttribute = member.GetAttributes() .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.UnionAttribute)); + if (unionAttribute == null && member.Type is INamedTypeSymbol fieldType) + { + unionAttribute = fieldType.OriginalDefinition.GetAttributes() + .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.UnionAttribute)); + } + if (unionAttribute == null || unionAttribute.ConstructorArguments.Length != 1) return ImmutableArray.Empty; diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs index 8c2edefc326..f9ec4611713 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs @@ -156,14 +156,15 @@ public sealed record Wrapper( [property: AkkaField(3)] int? Priority) : IClosedGenericTestProtocol; /// -/// The combined scenario: a generic wrapper whose payload property is a closed union. At -/// instantiation time (T := IOrderEvent) the union members are validated against the substituted -/// static type. +/// The combined scenario: a generic wrapper whose payload property is a closed union. The +/// definition declares NO union of its own -- at instantiation time (T := IOrderEvent) the +/// substituted static type's TYPE-LEVEL [AkkaUnion] declaration on IOrderEvent is picked up, and +/// its members are validated against the substituted type. /// [AkkaSerializable] public sealed record EventWrapper( [property: AkkaField(1)] string Id, - [property: AkkaField(2), AkkaUnion(typeof(OrderPlaced), typeof(OrderCancelled))] T Body) + [property: AkkaField(2)] T Body) : IClosedGenericTestProtocol; [AkkaSerializable(Manifest = "carrier-v1")] diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs index 8a3f989ea18..55a05298129 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs @@ -120,6 +120,19 @@ public void Nullable_union_field_should_round_trip_null_and_values() RoundTrip(withNull).Should().Be(withNull); } + [Fact(DisplayName = "Field-level union override should narrow the type-level member set")] + public void Field_level_union_override_should_narrow_type_level_set() + { + // OrderNote is a member of the TYPE-LEVEL union on IOrderEvent, but OptionalUnionMessage + // overrides the field with a narrower set that excludes it: the override must win. + var message = new OptionalUnionMessage("id-3", new OrderNote("order-12", "excluded")); + + var write = () => _serializer.ToBinary(message); + + write.Should().Throw() + .WithMessage("*not a declared union member*"); + } + [Fact(DisplayName = "Union write should fail serialization for an undeclared runtime type")] public void Union_write_should_fail_for_undeclared_runtime_type() { @@ -272,7 +285,12 @@ public interface IUnionTestProtocol { } -/// The union's static field type: an ordinary interface, not a protocol marker. +/// +/// The union's static field type, carrying the TYPE-LEVEL member declaration: stated once here, +/// inherited by every field of this type (mirroring [JsonDerivedType] and the case list of the +/// proposed C# language unions). Individual fields may override with their own [AkkaUnion]. +/// +[AkkaUnion(typeof(OrderPlaced), typeof(OrderCancelled), typeof(OrderNote))] public interface IOrderEvent { } @@ -283,12 +301,16 @@ public sealed partial class UnionTestSerializer : MessagePackSerializerInherits the full member set from the type-level union on . [AkkaSerializable(Manifest = "union-envelope-v1")] public sealed record UnionEnvelope( [property: AkkaField(1)] string EnvelopeId, - [property: AkkaField(2), AkkaUnion(typeof(OrderPlaced), typeof(OrderCancelled), typeof(OrderNote))] - IOrderEvent Event) : IUnionTestProtocol; + [property: AkkaField(2)] IOrderEvent Event) : IUnionTestProtocol; +/// +/// Field-level OVERRIDE: narrows the type-level member set to exclude for +/// this one field only. +/// [AkkaSerializable(Manifest = "optional-union-v1")] public sealed record OptionalUnionMessage( [property: AkkaField(1)] string Id, diff --git a/src/core/Akka.Serialization.V2/Attributes.cs b/src/core/Akka.Serialization.V2/Attributes.cs index b64bfc69ba9..af2405a0da8 100644 --- a/src/core/Akka.Serialization.V2/Attributes.cs +++ b/src/core/Akka.Serialization.V2/Attributes.cs @@ -75,22 +75,32 @@ public sealed class AkkaEnvelopePayloadAttribute : Attribute } /// -/// Marks an property whose static type is an interface or abstract -/// base as a closed, explicitly-enumerated union of concrete -/// member types. +/// Declares a closed, explicitly-enumerated union of concrete +/// member types for an interface or abstract base -- or, +/// applied to an property, overrides the member set for that one +/// field. /// /// +/// +/// The natural declaration site is the union's base TYPE (the interface or abstract class), where +/// the member set is stated once -- mirroring System.Text.Json's [JsonDerivedType] +/// and the case list of the proposed C# language unions. Every field whose static type carries a +/// type-level union inherits its member set. A field-level application overrides the type-level +/// set for that field only (for example, to narrow the members a particular schema accepts). +/// +/// /// Unlike (a runtime serializer boundary for payloads /// whose concrete type may live in an assembly unknown at compile time), a union field is encoded /// structurally inline: the generator emits compile-time dispatch over the declared member set, /// discriminated by each member's . Every member -/// must be [AkkaSerializable], declare a manifest unique within this union, and be assignable +/// must be [AkkaSerializable], declare a manifest unique within the union, and be assignable /// to the field's static type. A runtime value whose exact type is not a declared member fails /// serialization. When both this attribute and are -/// present, the envelope payload marker wins (consistent with its precedence over formatter -/// registrations). +/// present on a field, the envelope payload marker wins (consistent with its precedence over +/// formatter registrations). +/// /// -[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Interface | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class AkkaUnionAttribute : Attribute { /// From c20879414b0f243a701e06b016a47f3f75921210 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 16:16:37 -0500 Subject: [PATCH 04/12] Akka.Serialization.V2 PoC: audit follow-ups (names, sealing advisory, helper dedupe) Three DX-audit follow-ups from #8384/#8385: - Generated member names now fold STJ-style (WriteWrapperOrderRequest, ReadUnionEnvelope) instead of namespace-qualified sanitized FQNs with _Of_/_End_ markers -- these names appear in stack traces. Collisions from flattening are detected by AKKASG024 and fail compilation, the same trade System.Text.Json makes with DuplicateTypeName. - New advisory AKKASG025 (Info): a non-sealed union member works, but an undeclared subtype of it fails at write time under exact-runtime-type dispatch -- surfaced without failing the build. - Union dispatch helpers are deduplicated by (static type, member set) signature: all fields sharing a type-level union declaration now share one Write/Read/SizeOf helper trio (Union_IOrderEvent); field-level overrides with distinct member sets get numbered variants (Union_IOrderEvent_2). Helper exception text references the union type rather than a field name. --- .../AkkaSerializerGenerator.cs | 247 +++++++++++++----- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 50 ++++ 2 files changed, 231 insertions(+), 66 deletions(-) diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 65ebdb3f493..b4aba1eb929 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -215,6 +215,14 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor UnionMemberNotSealed = new( + "AKKASG025", + "Union member type is not sealed", + "Union member '{0}' on property '{1}' of type '{2}' is not sealed; union write dispatch matches the exact runtime type, so an undeclared subtype of '{0}' fails serialization -- consider sealing it", + "Akka.Serialization.V2", + DiagnosticSeverity.Info, + isEnabledByDefault: true); + private static readonly DiagnosticDescriptor DuplicateGeneratedName = new( "AKKASG024", "Generated member name collision", @@ -788,7 +796,7 @@ private static ImmutableArray ExtractUnionMembers( var displayName = argument.Value is ITypeSymbol typeSymbol ? typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) : ""; - builder.Add(new UnionMemberInfo(displayName, isValueType: false, isAssignable: false, isSupported: false)); + builder.Add(new UnionMemberInfo(displayName, isValueType: false, isAssignable: false, isSupported: false, isSealed: false)); continue; } @@ -796,7 +804,8 @@ private static ImmutableArray ExtractUnionMembers( GetMessageDictionaryKey(memberType), memberType.IsValueType, compilation.HasImplicitConversion(memberType, member.Type), - isSupported: true)); + isSupported: true, + isSealed: memberType.IsSealed || memberType.IsValueType)); } return builder.ToImmutable(); @@ -1035,6 +1044,11 @@ private static bool ValidateUnionField( isValid = false; } + // Advisory only (Info): an unsealed member works, but an undeclared subtype of it fails + // at write time under exact-runtime-type dispatch -- worth surfacing, not worth failing. + if (!member.IsSealed) + context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotSealed, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName)); + if (string.IsNullOrWhiteSpace(memberMessage.Manifest)) { context.ReportDiagnostic(Diagnostic.Create(UnionMemberMissingManifest, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName)); @@ -1101,14 +1115,16 @@ private static string Generate(SerializerInfo serializer, ImmutableArray unionHelpers) { sb.Append(" private int SizeOf").Append(GetMessageMethodName(message)) .Append('(').Append(message.FullyQualifiedName).AppendLine(" message)"); @@ -1273,14 +1289,14 @@ private static void GenerateSizeMessage(StringBuilder sb, MessageInfo message) sb.Append(" var size = SizeOfMapHeader(").Append(message.Fields.Length).AppendLine(");"); var alloc = new NameAlloc(); foreach (var field in message.Fields) - GenerateSizeField(sb, message, field, alloc); + GenerateSizeField(sb, unionHelpers, field, alloc); sb.AppendLine(" return size;"); sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine(); } - private static void GenerateSizeField(StringBuilder sb, MessageInfo message, FieldInfo field, NameAlloc alloc) + private static void GenerateSizeField(StringBuilder sb, ImmutableDictionary unionHelpers, FieldInfo field, NameAlloc alloc) { var value = "message." + field.Name; var localName = ToCamelCase(field.Name) + "Size"; @@ -1300,7 +1316,7 @@ private static void GenerateSizeField(StringBuilder sb, MessageInfo message, Fie } sb.Append(" var ").Append(localName).Append(" = "); - GenerateSizeExpression(sb, message, field, value); + GenerateSizeExpression(sb, unionHelpers, field, value); sb.AppendLine(";"); sb.Append(" if (").Append(localName).AppendLine(" < 0)"); sb.AppendLine(" return global::Akka.Serialization.SerializerV2.UnknownSize;"); @@ -1331,7 +1347,7 @@ private static bool TryGetInlineSizeExpression(FieldInfo field, string value, ou return true; } - private static void GenerateSizeExpression(StringBuilder sb, MessageInfo message, FieldInfo field, string value) + private static void GenerateSizeExpression(StringBuilder sb, ImmutableDictionary unionHelpers, FieldInfo field, string value) { switch (field.Mapping.Kind) { @@ -1339,10 +1355,10 @@ private static void GenerateSizeExpression(StringBuilder sb, MessageInfo message sb.Append("SizeOfEnvelopePayload(").Append(value).Append(')'); break; case FieldKind.Union when field.IsNullable: - sb.Append(value).Append(" is null ? SizeOfNil() : SizeOfUnion_").Append(GetMessageMethodName(message)).Append('_').Append(field.Name).Append('(').Append(value).Append(')'); + sb.Append(value).Append(" is null ? SizeOfNil() : SizeOf").Append(unionHelpers[BuildUnionSignature(field)].HelperName).Append('(').Append(value).Append(')'); break; case FieldKind.Union: - sb.Append("SizeOfUnion_").Append(GetMessageMethodName(message)).Append('_').Append(field.Name).Append('(').Append(value).Append(')'); + sb.Append("SizeOf").Append(unionHelpers[BuildUnionSignature(field)].HelperName).Append('(').Append(value).Append(')'); break; case FieldKind.Object when IsNullableValueField(field): sb.Append(value).Append(" is null ? SizeOfNil() : SizeOf").Append(GetObjectMethodName(field.Mapping)).Append('(').Append(value).Append(".Value)"); @@ -1388,7 +1404,7 @@ private static string GetScalarSizeExpression(TypeMapping mapping, string value) }; } - private static void GenerateWriteMessage(StringBuilder sb, MessageInfo message) + private static void GenerateWriteMessage(StringBuilder sb, MessageInfo message, ImmutableDictionary unionHelpers) { sb.Append(" private void Write").Append(GetMessageMethodName(message)) .Append("(ref global::MessagePack.MessagePackWriter writer, ").Append(message.FullyQualifiedName).AppendLine(" message)"); @@ -1396,12 +1412,12 @@ private static void GenerateWriteMessage(StringBuilder sb, MessageInfo message) sb.Append(" writer.WriteMapHeader(").Append(message.Fields.Length).AppendLine(");"); var alloc = new NameAlloc(); foreach (var field in message.Fields) - GenerateWriteField(sb, message, field, alloc); + GenerateWriteField(sb, unionHelpers, field, alloc); sb.AppendLine(" }"); sb.AppendLine(); } - private static void GenerateReadMessage(StringBuilder sb, MessageInfo message) + private static void GenerateReadMessage(StringBuilder sb, MessageInfo message, ImmutableDictionary unionHelpers) { sb.Append(" private ").Append(message.FullyQualifiedName).Append(" Read").Append(GetMessageMethodName(message)) .AppendLine("(ref global::MessagePack.MessagePackReader reader)"); @@ -1424,7 +1440,7 @@ private static void GenerateReadMessage(StringBuilder sb, MessageInfo message) foreach (var field in message.Fields) { sb.Append(" case ").Append(field.Index).AppendLine(":"); - GenerateReadField(sb, message, field, alloc); + GenerateReadField(sb, unionHelpers, field, alloc); if (IsRequired(field)) sb.Append(" ").Append(GetHasLocalName(field)).AppendLine(" = true;"); sb.AppendLine(" break;"); @@ -1473,24 +1489,66 @@ private static void GenerateReadMessage(StringBuilder sb, MessageInfo message) // UnknownSize for an undeclared type instead of throwing; the write path throws. // --------------------------------------------------------------------------------------------- - private static string GetUnionHelperName(MessageInfo message, FieldInfo field) + /// + /// The dedup identity of a union dispatch helper: the field's static type plus the ordered + /// member set. Two fields (in the same or different messages) with the same static type and + /// member set -- the common case under type-level [AkkaUnion] declarations -- share one + /// generated Write/Read/SizeOf helper trio instead of emitting duplicates per field. + /// + private static string BuildUnionSignature(FieldInfo field) + { + return field.TypeFullName + "|" + string.Join("|", field.UnionMembers.Select(member => member.TypeFullName)); + } + + /// + /// Plans one helper per distinct union signature across all reachable messages. Helpers are + /// named after the union's folded static type ("Union_IOrderEvent"); when several distinct + /// member sets share a static type (field-level overrides), later ones -- ordered by signature + /// for determinism -- get a numeric suffix. + /// + private static ImmutableDictionary PlanUnionHelpers(ImmutableArray reachableMessages) { - return "Union_" + GetMessageMethodName(message) + "_" + field.Name; + var representatives = new Dictionary(StringComparer.Ordinal); + foreach (var message in reachableMessages) + { + foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Union)) + { + var signature = BuildUnionSignature(field); + if (!representatives.ContainsKey(signature)) + representatives[signature] = field; + } + } + + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var group in representatives.GroupBy(pair => FoldTypeName(pair.Value.TypeFullName), StringComparer.Ordinal)) + { + var ordered = group.OrderBy(pair => pair.Key, StringComparer.Ordinal).ToList(); + for (var i = 0; i < ordered.Count; i++) + { + var helperName = i == 0 ? "Union_" + group.Key : "Union_" + group.Key + "_" + (i + 1); + builder[ordered[i].Key] = (helperName, ordered[i].Value); + } + } + + return builder.ToImmutable(); } - private static void GenerateUnionHelpers(StringBuilder sb, MessageInfo message, ImmutableDictionary messagesByType) + private static void GenerateUnionHelpers( + StringBuilder sb, + ImmutableDictionary unionHelpers, + ImmutableDictionary messagesByType) { - foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Union)) + foreach (var plan in unionHelpers.Values.OrderBy(plan => plan.HelperName, StringComparer.Ordinal)) { - var helperName = GetUnionHelperName(message, field); + var field = plan.Field; var members = field.UnionMembers .Where(member => member.IsSupported && messagesByType.ContainsKey(member.TypeFullName)) .Select(member => (Member: member, Message: messagesByType[member.TypeFullName])) .ToImmutableArray(); - GenerateUnionWrite(sb, field, helperName, members); - GenerateUnionRead(sb, field, helperName, members); - GenerateUnionSize(sb, field, helperName, members); + GenerateUnionWrite(sb, field, plan.HelperName, members); + GenerateUnionRead(sb, field, plan.HelperName, members); + GenerateUnionSize(sb, field, plan.HelperName, members); } } @@ -1514,8 +1572,8 @@ private static void GenerateUnionWrite(StringBuilder sb, FieldInfo field, string sb.AppendLine(); } - sb.Append(" throw new global::System.Runtime.Serialization.SerializationException($\"Type [{runtimeType}] is not a declared union member for field [") - .Append(Escape(field.Name)).AppendLine("].\");"); + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException($\"Type [{runtimeType}] is not a declared union member for union [") + .Append(Escape(field.TypeFullName)).AppendLine("].\");"); sb.AppendLine(" }"); sb.AppendLine(); } @@ -1548,11 +1606,11 @@ private static void GenerateUnionRead(StringBuilder sb, FieldInfo field, string } sb.AppendLine(" case null:"); - sb.Append(" throw new global::System.Runtime.Serialization.SerializationException(\"Union manifest must precede the payload for field [") - .Append(Escape(field.Name)).AppendLine("].\");"); + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException(\"Union manifest must precede the payload for union [") + .Append(Escape(field.TypeFullName)).AppendLine("].\");"); sb.AppendLine(" default:"); - sb.Append(" throw new global::System.Runtime.Serialization.SerializationException($\"Unknown union manifest [{manifest}] for field [") - .Append(Escape(field.Name)).AppendLine("].\");"); + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException($\"Unknown union manifest [{manifest}] for union [") + .Append(Escape(field.TypeFullName)).AppendLine("].\");"); sb.AppendLine(" }"); sb.AppendLine(); sb.AppendLine(" hasPayload = true;"); @@ -1564,8 +1622,8 @@ private static void GenerateUnionRead(StringBuilder sb, FieldInfo field, string sb.AppendLine(" }"); sb.AppendLine(); sb.AppendLine(" if (!hasPayload || result is null)"); - sb.Append(" throw new global::System.Runtime.Serialization.SerializationException(\"Missing union payload for field [") - .Append(Escape(field.Name)).AppendLine("].\");"); + sb.Append(" throw new global::System.Runtime.Serialization.SerializationException(\"Missing union payload for union [") + .Append(Escape(field.TypeFullName)).AppendLine("].\");"); sb.AppendLine(" return result;"); sb.AppendLine(" }"); sb.AppendLine(); @@ -1595,7 +1653,7 @@ private static void GenerateUnionSize(StringBuilder sb, FieldInfo field, string sb.AppendLine(); } - private static void GenerateWriteField(StringBuilder sb, MessageInfo message, FieldInfo field, NameAlloc alloc) + private static void GenerateWriteField(StringBuilder sb, ImmutableDictionary unionHelpers, FieldInfo field, NameAlloc alloc) { var value = "message." + field.Name; sb.Append(" writer.Write(").Append(field.Index).AppendLine(");"); @@ -1604,14 +1662,14 @@ private static void GenerateWriteField(StringBuilder sb, MessageInfo message, Fi sb.Append(" if (").Append(value).AppendLine(" is null)"); sb.AppendLine(" writer.WriteNil();"); sb.AppendLine(" else"); - GenerateWriteFieldValue(sb, message, field, value + ".Value", " ", alloc); + GenerateWriteFieldValue(sb, unionHelpers, field, value + ".Value", " ", alloc); return; } - GenerateWriteFieldValue(sb, message, field, value, " ", alloc); + GenerateWriteFieldValue(sb, unionHelpers, field, value, " ", alloc); } - private static void GenerateWriteFieldValue(StringBuilder sb, MessageInfo message, FieldInfo field, string value, string indent, NameAlloc alloc) + private static void GenerateWriteFieldValue(StringBuilder sb, ImmutableDictionary unionHelpers, FieldInfo field, string value, string indent, NameAlloc alloc) { if (IsCollectionKind(field.Mapping.Kind)) { @@ -1699,17 +1757,17 @@ private static void GenerateWriteFieldValue(StringBuilder sb, MessageInfo messag sb.Append(indent).Append("if (").Append(value).AppendLine(" is null)"); sb.Append(indent).AppendLine(" writer.WriteNil();"); sb.Append(indent).AppendLine("else"); - sb.Append(indent).Append(" Write").Append(GetUnionHelperName(message, field)).Append("(ref writer, ").Append(value).AppendLine(");"); + sb.Append(indent).Append(" Write").Append(unionHelpers[BuildUnionSignature(field)].HelperName).Append("(ref writer, ").Append(value).AppendLine(");"); } else { - sb.Append(indent).Append("Write").Append(GetUnionHelperName(message, field)).Append("(ref writer, ").Append(value).AppendLine(");"); + sb.Append(indent).Append("Write").Append(unionHelpers[BuildUnionSignature(field)].HelperName).Append("(ref writer, ").Append(value).AppendLine(");"); } break; } } - private static void GenerateReadField(StringBuilder sb, MessageInfo message, FieldInfo field, NameAlloc alloc) + private static void GenerateReadField(StringBuilder sb, ImmutableDictionary unionHelpers, FieldInfo field, NameAlloc alloc) { var target = ToCamelCase(field.Name); @@ -1719,7 +1777,7 @@ private static void GenerateReadField(StringBuilder sb, MessageInfo message, Fie // non-nullable collection slot exactly as it does for any other non-nullable reference field. if (IsCollectionKind(field.Mapping.Kind)) { - GenerateReadFieldValue(sb, message, field, target, " ", alloc); + GenerateReadFieldValue(sb, unionHelpers, field, target, " ", alloc); return; } @@ -1728,7 +1786,7 @@ private static void GenerateReadField(StringBuilder sb, MessageInfo message, Fie sb.AppendLine(" if (reader.TryReadNil())"); sb.Append(" ").Append(target).AppendLine(" = null;"); sb.AppendLine(" else"); - GenerateReadFieldValue(sb, message, field, target, " ", alloc); + GenerateReadFieldValue(sb, unionHelpers, field, target, " ", alloc); return; } @@ -1742,14 +1800,14 @@ private static void GenerateReadField(StringBuilder sb, MessageInfo message, Fie sb.AppendLine(" if (reader.TryReadNil())"); sb.Append(" ").Append(target).AppendLine(" = null;"); sb.AppendLine(" else"); - GenerateReadFieldValue(sb, message, field, target, " ", alloc); + GenerateReadFieldValue(sb, unionHelpers, field, target, " ", alloc); return; } - GenerateReadFieldValue(sb, message, field, target, " ", alloc); + GenerateReadFieldValue(sb, unionHelpers, field, target, " ", alloc); } - private static void GenerateReadFieldValue(StringBuilder sb, MessageInfo message, FieldInfo field, string target, string indent, NameAlloc alloc) + private static void GenerateReadFieldValue(StringBuilder sb, ImmutableDictionary unionHelpers, FieldInfo field, string target, string indent, NameAlloc alloc) { if (IsCollectionKind(field.Mapping.Kind)) { @@ -1806,7 +1864,7 @@ private static void GenerateReadFieldValue(StringBuilder sb, MessageInfo message sb.Append(indent).Append(target).Append(" = ").Append(GetFormatterFieldName(field.Formatter!)).AppendLine(".Read(ref reader);"); break; case FieldKind.Union: - sb.Append(indent).Append(target).Append(" = Read").Append(GetUnionHelperName(message, field)).AppendLine("(ref reader);"); + sb.Append(indent).Append(target).Append(" = Read").Append(unionHelpers[BuildUnionSignature(field)].HelperName).AppendLine("(ref reader);"); break; } } @@ -2455,7 +2513,71 @@ private static string GetConstructorArgument(FieldInfo field) private static string GetObjectMethodName(TypeMapping mapping) { - return SanitizeTypeName(mapping.TypeFullName); + return FoldTypeName(mapping.TypeFullName); + } + + /// + /// Folds a fully-qualified type name into a compact generated-member identifier the way + /// System.Text.Json's GetTypeInfoPropertyName does: namespaces are dropped, each type + /// identifier keeps only its simple name, and generic type arguments are concatenated -- + /// global::Ns.Wrapper<global::Ns.OrderRequest> becomes WrapperOrderRequest. + /// These names appear in stack traces (WriteWrapperOrderRequest), so compactness matters. + /// Flattening is collision-prone by construction (same simple name in two namespaces, marker + /// ambiguity); AKKASG024 detects collisions among generated members and fails compilation + /// instead of silently emitting duplicates -- the same trade System.Text.Json makes with its + /// DuplicateTypeName diagnostic. + /// + private static string FoldTypeName(string typeFullName) + { + var sb = new StringBuilder(typeFullName.Length); + var segment = new StringBuilder(); + + void FlushSegment() + { + if (segment.Length == 0) + return; + + sb.Append(char.ToUpperInvariant(segment[0])); + if (segment.Length > 1) + sb.Append(segment.ToString(1, segment.Length - 1)); + segment.Clear(); + } + + var source = typeFullName.Replace("global::", string.Empty); + foreach (var ch in source) + { + switch (ch) + { + case '.': + case '+': + // Keep only the last identifier of a dotted/nested chain: the segment + // accumulated so far was a namespace or containing type. + segment.Clear(); + break; + case '<': + case '>': + case ',': + case ' ': + FlushSegment(); + break; + case '[': + FlushSegment(); + break; + case ']': + sb.Append("Array"); + break; + case '?': + FlushSegment(); + sb.Append("Nullable"); + break; + default: + segment.Append(ch); + break; + } + } + + FlushSegment(); + return sb.ToString(); } private static string GetFormatterFieldName(FormatterInfo formatter) @@ -2465,32 +2587,21 @@ private static string GetFormatterFieldName(FormatterInfo formatter) private static string SanitizeTypeName(string typeFullName) { - // Escape literal underscores FIRST so sanitization is collision-free: - // 'My.Ns.Foo_Bar' -> 'My_Ns_Foo__Bar' and 'My.Ns.Foo.Bar' -> 'My_Ns_Foo_Bar' stay - // distinct instead of both collapsing to 'My_Ns_Foo_Bar' (duplicate generated members). - // - // Closed generic constructions fold their type arguments into the member name (mirroring - // System.Text.Json's GetTypeInfoPropertyName recursion): 'Wrapper' -> 'Wrapper_Of_Foo_End_'. - // The markers contain single underscores, which cannot collide with escaped literal - // underscores (always doubled). Residual ambiguity across marker sequences is caught by the - // AKKASG024 generated-name collision check rather than made impossible here. + // Used only for formatter field names, whose target types are validated non-generic + // (AKKASG011) -- generated METHOD names go through FoldTypeName instead. Escape literal + // underscores FIRST so sanitization is collision-free: 'My.Ns.Foo_Bar' -> 'My_Ns_Foo__Bar' + // and 'My.Ns.Foo.Bar' -> 'My_Ns_Foo_Bar' stay distinct instead of both collapsing to + // 'My_Ns_Foo_Bar' (duplicate generated members). return typeFullName .Replace("global::", string.Empty) - .Replace(" ", string.Empty) .Replace("_", "__") .Replace(".", "_") - .Replace("+", "_") - .Replace("<", "_Of_") - .Replace(">", "_End_") - .Replace(",", "_And_") - .Replace("[", "_Arr_") - .Replace("]", "_RArr_") - .Replace("?", "_Opt_"); + .Replace("+", "_"); } private static string GetMessageMethodName(MessageInfo message) { - return SanitizeTypeName(message.FullyQualifiedName); + return FoldTypeName(message.FullyQualifiedName); } private static string GetFullyQualifiedTypeName(INamedTypeSymbol symbol) @@ -2841,12 +2952,13 @@ private enum FieldKind /// private sealed class UnionMemberInfo { - public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, bool isSupported) + public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, bool isSupported, bool isSealed) { TypeFullName = typeFullName; IsValueType = isValueType; IsAssignable = isAssignable; IsSupported = isSupported; + IsSealed = isSealed; } /// Message-dictionary key for the member type (arity-aware for generics). @@ -2859,5 +2971,8 @@ public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, /// False when the attribute argument was null, not a type, or an unbound generic. public bool IsSupported { get; } + + /// Whether undeclared subtypes are impossible (sealed class, struct). Advisory AKKASG025 fires otherwise. + public bool IsSealed { get; } } } diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 42744997425..93b8ec3c535 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -1247,6 +1247,56 @@ public sealed record Outer( diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG023" && diagnostic.Severity == DiagnosticSeverity.Error); } + [Fact(DisplayName = "Generator should report advisory AKKASG025 when a union member is not sealed")] + public void Generator_should_report_AKKASG025_when_union_member_not_sealed() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaUnion(typeof(OpenMember), typeof(SealedMember))] + public interface IEvent + { + } + + [AkkaSerializable(Manifest = "open-v1")] + public record OpenMember([property: AkkaField(1)] string Value) : IEvent; + + [AkkaSerializable(Manifest = "sealed-v1")] + public sealed record SealedMember([property: AkkaField(1)] string Value) : IEvent; + + [AkkaSerializer(Name = "sample", SerializerId = 120708)] + public sealed partial class SampleSerializer : MessagePackSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer( + [property: AkkaField(1)] IEvent Event) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + // Advisory only: fires for the unsealed member, stays Info severity, and does NOT fail the + // build -- the union still generates and no errors are produced. + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG025" && + diagnostic.Severity == DiagnosticSeverity.Info && + diagnostic.GetMessage(null).Contains("OpenMember", StringComparison.Ordinal)); + diagnostics.Should().NotContain(diagnostic => + diagnostic.Id == "AKKASG025" && + diagnostic.GetMessage(null).Contains("SealedMember", StringComparison.Ordinal)); + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + } + private static ImmutableArray RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12); From e9bfbef43acc6a68b092f31d9a1acb5d1a7e9533 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 16:57:38 -0500 Subject: [PATCH 05/12] Akka.Serialization.V2 PoC: consolidate the attribute surface Design-review feedback on #8385: too many attribute names, protocol interface hanging off the base type. Two consolidations, no behavior change: - [AkkaSerializer(Name, SerializerId)]: the protocol marker moves into the serializer attribute. It was always pure generator input (TProtocol appeared exactly once in the runtime -- the class declaration), so the base class is now non-generic MessagePackSerializer. - [AkkaSerializable] replaces [AkkaSerializableInstantiation]: closed generic constructions are registered with a generic form of the SAME attribute that marks every other serializable type. One rule: everything on the wire is [AkkaSerializable]; a closed construction has no declaration site of its own, so its marking lives on the serializer class. Bonus pit-of-success: the AKKASG020 unbound-generic case is now unrepresentable ([AkkaSerializable>] is not legal C#), so that failure mode moved from analyzer diagnostic to compiler error; its test is deleted. Also migrates the Artery control-message serializer (name/id/binding unchanged, wire compatible), benchmarks, and stale crefs. Known trade-off: non-generic MessagePackSerializer now collides with MessagePack's own MessagePackSerializer under dual top-level usings (CS0104) -- flagged for review, worked around with full qualification where it bit. 134/134 tests, Akka.Remote clean, API approvals pass. --- ...eneratedMessagePackSerializerBenchmarks.cs | 8 +- .../RealPayload/V2/RealBenchmarkMessages.cs | 6 +- .../Artery/ArteryControlMessageSerializer.cs | 4 +- .../Artery/SystemMessageDeliveryMessages.cs | 4 +- .../AkkaSerializerGenerator.cs | 52 ++---- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 172 +++++++----------- .../CollectionFieldSpec.cs | 4 +- .../FieldlessAndStructFieldSpec.cs | 4 +- .../FormatterEscapeHatchSpec.cs | 12 +- .../GeneratedClosedGenericSpec.cs | 12 +- .../GeneratedMessagePackSerializerSpec.cs | 4 +- .../GeneratedUnionSpec.cs | 4 +- src/core/Akka.Serialization.V2/Attributes.cs | 51 +++--- .../MessagePackSerializer.cs | 2 +- .../Akka.Serialization.V2/MessagePackSizes.cs | 2 +- 15 files changed, 141 insertions(+), 200 deletions(-) diff --git a/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs b/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs index af931c6ebd2..eaa450df914 100644 --- a/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs +++ b/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs @@ -307,8 +307,8 @@ public interface IOrderBenchmarkProtocol { } -[AkkaSerializer(Name = "order-benchmark", SerializerId = 120001)] -public sealed partial class OrderBenchmarkSerializer : MessagePackSerializer +[AkkaSerializer(Name = "order-benchmark", SerializerId = 120001)] +public sealed partial class OrderBenchmarkSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -317,8 +317,8 @@ public interface IEnvelopeBenchmarkProtocol { } -[AkkaSerializer(Name = "envelope-benchmark", SerializerId = 120002)] -public sealed partial class EnvelopeBenchmarkSerializer : MessagePackSerializer +[AkkaSerializer(Name = "envelope-benchmark", SerializerId = 120002)] +public sealed partial class EnvelopeBenchmarkSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs b/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs index 423f759dacd..64dea58d954 100644 --- a/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs +++ b/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs @@ -15,7 +15,7 @@ namespace RemotePingPong.RealPayload.V2 { /// /// Protocol marker for the "real payload" (`--payload real`) benchmark message, MessagePack/V2 arm. - /// Only used to satisfy 's generic parameter and + /// Only used to satisfy 's generic parameter and /// 's protocol-type registration -- the /// harness's actual HOCON wiring binds directly on (see /// RemotePingPong.Program.RealPayloadSerializationConfig), so this interface has no runtime @@ -121,8 +121,8 @@ public sealed record Reading( /// range (0-40, see akka.conf) and the ids used elsewhere in this repo (e.g. Artery control's 23, /// the V2 test suite's ~120101-120202) to avoid any collision with a real Akka.NET serializer. /// - [AkkaSerializer(Name = "real-benchmark-v2", SerializerId = 987001)] - internal sealed partial class RealBenchmarkSerializer : MessagePackSerializer + [AkkaSerializer(Name = "real-benchmark-v2", SerializerId = 987001)] + internal sealed partial class RealBenchmarkSerializer : MessagePackSerializer { /// /// Generated by Akka.Serialization.V2.Generators.AkkaSerializerGenerator. Not used by diff --git a/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs b/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs index 3bc1b3009b3..cb3c367f0b6 100644 --- a/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs +++ b/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs @@ -51,9 +51,9 @@ namespace Akka.Remote.Artery /// on , serialization-identifiers entry) requires no edits. /// /// - [AkkaSerializer(Name = "artery-control", SerializerId = 23)] + [AkkaSerializer(Name = "artery-control", SerializerId = 23)] [AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] - internal sealed partial class ArteryControlMessageSerializer : MessagePackSerializer + internal sealed partial class ArteryControlMessageSerializer : MessagePackSerializer { /// /// The manifest for . diff --git a/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs b/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs index 2861754d131..85403575e71 100644 --- a/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs +++ b/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs @@ -95,7 +95,7 @@ internal sealed record Nack( /// /// /// How the inner message is nested (documented per the task). is - /// encoded via + /// encoded via /// / decoded via ReadEnvelopePayload -- an EXISTING helper on the V2 MessagePack serializer /// base class that already implements exactly "nest a serializer id + manifest + raw bytes, /// recursively through Serialization.FindSerializerFor/Deserialize" for wrapping an @@ -120,7 +120,7 @@ internal sealed record Nack( /// Sourcegen swap field-id note. is marked /// , routing it through the generated serializer's /// WriteEnvelopePayload/ReadEnvelopePayload calls -- the SAME - /// base-class helpers the + /// base-class helpers the /// prior hand-rolled used, confirming the generator /// has no gap here. The generator emits the record's constructor call positionally by ASCENDING /// index (not by declared parameter order), so field indices diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index b4aba1eb929..05055d70263 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -20,12 +20,12 @@ namespace Akka.Serialization.V2.Generators; [Generator] public sealed class AkkaSerializerGenerator : IIncrementalGenerator { - private const string SerializerAttributeFullName = "Akka.Serialization.V2.AkkaSerializerAttribute"; + private const string SerializerAttributeFullName = "Akka.Serialization.V2.AkkaSerializerAttribute`1"; private const string SerializableAttributeFullName = "Akka.Serialization.V2.AkkaSerializableAttribute"; private const string FieldAttributeFullName = "Akka.Serialization.V2.AkkaFieldAttribute"; private const string EnvelopePayloadAttributeFullName = "Akka.Serialization.V2.AkkaEnvelopePayloadAttribute"; private const string UnionAttributeFullName = "Akka.Serialization.V2.AkkaUnionAttribute"; - private const string InstantiationAttributeFullName = "Akka.Serialization.V2.AkkaSerializableInstantiationAttribute"; + private const string GenericSerializableAttributeFullName = "Akka.Serialization.V2.AkkaSerializableAttribute`1"; private const string FormatterAttributeFullName = "Akka.Serialization.V2.AkkaSerializerFormatterAttribute"; private const string FormatterInterfaceFullName = "Akka.Serialization.V2.IAkkaMessagePackFormatter`1"; private const string ExtendedActorSystemFullName = "Akka.Actor.ExtendedActorSystem"; @@ -186,7 +186,7 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor InvalidInstantiationTarget = new( "AKKASG020", "Instantiation target is not a closed generic serializable construction", - "[AkkaSerializableInstantiation] target '{0}' on serializer '{1}' must be a closed generic construction of a generic type annotated with [AkkaSerializable]", + "[AkkaSerializable] registration '{0}' on serializer '{1}' must be a closed generic construction of a generic type annotated with [AkkaSerializable]", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -202,7 +202,7 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor GenericSerializableRequiresInstantiation = new( "AKKASG022", "Generic serializable type requires closed instantiations", - "Generic [AkkaSerializable] type '{0}' implements protocol '{1}' of serializer '{2}' but has no [AkkaSerializableInstantiation] registrations; a source generator cannot serialize an open generic, so register each closed construction with [AkkaSerializableInstantiation(typeof(...), Manifest = ...)]", + "Generic [AkkaSerializable] type '{0}' implements protocol '{1}' of serializer '{2}' but has no [AkkaSerializable] registrations; a source generator cannot serialize an open generic, so register each closed construction with [AkkaSerializable(Manifest = ...)] on the serializer class", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -210,7 +210,7 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor UnregisteredClosedGenericField = new( "AKKASG023", "Closed generic field type is not registered", - "Property '{0}' on type '{1}' uses closed generic [AkkaSerializable] type '{2}', which must be registered on serializer '{3}' with [AkkaSerializableInstantiation]", + "Property '{0}' on type '{1}' uses closed generic [AkkaSerializable] type '{2}', which must be registered on serializer '{3}' with [AkkaSerializable]", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -332,7 +332,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var symbol = (INamedTypeSymbol)context.TargetSymbol; var attribute = context.Attributes[0]; var compilation = context.SemanticModel.Compilation; - var messagePackSerializer = compilation.GetTypeByMetadataName("Akka.Serialization.V2.MessagePackSerializer`1"); string? name = null; var serializerId = 0; @@ -344,20 +343,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) serializerId = id; } - var baseType = symbol.BaseType; - string protocolTypeFullName = string.Empty; - INamedTypeSymbol? protocolType = null; - while (baseType != null) - { - if (messagePackSerializer != null && SymbolEqualityComparer.Default.Equals(baseType.OriginalDefinition, messagePackSerializer)) - { - protocolType = baseType.TypeArguments[0] as INamedTypeSymbol; - protocolTypeFullName = baseType.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - break; - } - - baseType = baseType.BaseType; - } + var protocolType = attribute.AttributeClass?.TypeArguments.FirstOrDefault() as INamedTypeSymbol; + var protocolTypeFullName = protocolType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? string.Empty; var formatterAttributeType = compilation.GetTypeByMetadataName(FormatterAttributeFullName); var formatterInterfaceType = compilation.GetTypeByMetadataName(FormatterInterfaceFullName); @@ -379,7 +366,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } /// - /// Extracts [AkkaSerializableInstantiation] registrations from the serializer class. A + /// Extracts [AkkaSerializable<T>] registrations from the serializer class. A /// valid target is a CLOSED generic construction (no unbound generics, no type parameters /// anywhere in its arguments) whose definition is annotated [AkkaSerializable]; its /// is built from the constructed symbol, so all field types arrive @@ -387,12 +374,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) /// private static ImmutableArray ExtractInstantiations(INamedTypeSymbol symbol, Compilation compilation) { - var attributeType = compilation.GetTypeByMetadataName(InstantiationAttributeFullName); - if (attributeType == null) + var genericSerializableAttribute = compilation.GetTypeByMetadataName(GenericSerializableAttributeFullName); + if (genericSerializableAttribute == null) return ImmutableArray.Empty; var attributes = symbol.GetAttributes() - .Where(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeType)) + .Where(attr => attr.AttributeClass is { IsGenericType: true } ac && SymbolEqualityComparer.Default.Equals(ac.OriginalDefinition, genericSerializableAttribute)) .ToImmutableArray(); if (attributes.IsEmpty) return ImmutableArray.Empty; @@ -401,9 +388,6 @@ private static ImmutableArray ExtractInstantiations(INamedTyp var builder = ImmutableArray.CreateBuilder(attributes.Length); foreach (var attribute in attributes) { - if (attribute.ConstructorArguments.Length != 1) - continue; - var manifest = string.Empty; foreach (var argument in attribute.NamedArguments) { @@ -411,7 +395,7 @@ private static ImmutableArray ExtractInstantiations(INamedTyp manifest = value; } - var target = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + var target = attribute.AttributeClass!.TypeArguments[0] as INamedTypeSymbol; var serializableAttribute = target?.OriginalDefinition.GetAttributes() .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute)); var isValidTarget = target is { IsGenericType: true, IsUnboundGenericType: false } @@ -420,9 +404,7 @@ private static ImmutableArray ExtractInstantiations(INamedTyp if (!isValidTarget) { - var displayName = attribute.ConstructorArguments[0].Value is ITypeSymbol typeSymbol - ? typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - : ""; + var displayName = attribute.AttributeClass!.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); builder.Add(new InstantiationInfo(displayName, message: null)); continue; } @@ -673,7 +655,7 @@ private static bool ValidateGenericDefinitions(SourceProductionContext context, // A generic [AkkaSerializable] DEFINITION is never a message itself -- a source generator // cannot reify an open generic. Only its registered closed constructions serialize - // ([AkkaSerializableInstantiation], AKKASG020/022). Extract it as a flagged placeholder + // ([AkkaSerializable], AKKASG020/022). Extract it as a flagged placeholder // carrying its protocols (for the AKKASG022 check) but no fields: a T-typed field would map // as Unsupported and produce a misleading AKKASG003 against the definition. if (symbol.IsGenericType) @@ -977,7 +959,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer // An Object mapping that resolves to no known message would generate a call to a // nonexistent Write/Read/SizeOf method. The only way to hit this from within one // compilation is a closed generic [AkkaSerializable] field type whose construction was - // never registered with [AkkaSerializableInstantiation] (non-generic [AkkaSerializable] + // never registered with [AkkaSerializable] (non-generic [AkkaSerializable] // types are always extracted). AKKASG023 names the fix. foreach (var field in message.Fields) { @@ -2279,7 +2261,7 @@ private static TypeMapping MapTypeCore(ITypeSymbol type, KnownTypes knownTypes) // OriginalDefinition covers both shapes: for a non-generic type it is the type itself; for a // closed generic construction (Wrapper) the [AkkaSerializable] attribute lives on the // definition. The mapping name is arity-aware (GetMessageDictionaryKey) so a closed - // construction resolves to its registered [AkkaSerializableInstantiation] message -- or, if + // construction resolves to its registered [AkkaSerializable] message -- or, if // unregistered, fails AKKASG023 instead of silently dropping its type arguments. if (type is INamedTypeSymbol namedType && namedType.OriginalDefinition.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute))) return new TypeMapping(FieldKind.Object, GetMessageDictionaryKey(namedType), namedType.IsValueType); @@ -2769,7 +2751,7 @@ public MessageInfo WithFields(ImmutableArray fields) } /// - /// A single [AkkaSerializableInstantiation] registration. is null + /// A single [AkkaSerializable<T>] registration. is null /// when the target was invalid (not a type, non-generic, unbound, or its definition lacks /// [AkkaSerializable]) so AKKASG020 fires instead of the registration silently vanishing. /// diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 93b8ec3c535..05cffa011c4 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -37,8 +37,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120501)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120501)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -73,8 +73,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120502)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120502)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -118,9 +118,9 @@ public sealed class NotAFormatter { } - [AkkaSerializer(Name = "sample", SerializerId = 120601)] + [AkkaSerializer(Name = "sample", SerializerId = 120601)] [AkkaSerializerFormatter(typeof(Foreign), typeof(NotAFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -158,9 +158,9 @@ public abstract class AbstractFormatter : IAkkaMessagePackFormatter public abstract int SizeOf(Foreign value); } - [AkkaSerializer(Name = "sample", SerializerId = 120602)] + [AkkaSerializer(Name = "sample", SerializerId = 120602)] [AkkaSerializerFormatter(typeof(Foreign), typeof(AbstractFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -205,10 +205,10 @@ public sealed class SecondFormatter : IAkkaMessagePackFormatter public int SizeOf(Foreign value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120603)] + [AkkaSerializer(Name = "sample", SerializerId = 120603)] [AkkaSerializerFormatter(typeof(Foreign), typeof(FirstFormatter))] [AkkaSerializerFormatter(typeof(Foreign), typeof(SecondFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -250,9 +250,9 @@ public NoUsableCtorFormatter(string unused) public int SizeOf(Foreign value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120604)] + [AkkaSerializer(Name = "sample", SerializerId = 120604)] [AkkaSerializerFormatter(typeof(Foreign), typeof(NoUsableCtorFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -290,9 +290,9 @@ public sealed class ForeignFormatter : IAkkaMessagePackFormatter public int SizeOf(Foreign value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120605)] + [AkkaSerializer(Name = "sample", SerializerId = 120605)] [AkkaSerializerFormatter(typeof(Foreign), typeof(ForeignFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -329,9 +329,9 @@ public sealed class IntArrayFormatter : IAkkaMessagePackFormatter public int SizeOf(int[] value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120606)] + [AkkaSerializer(Name = "sample", SerializerId = 120606)] [AkkaSerializerFormatter(typeof(int[]), typeof(IntArrayFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -368,9 +368,9 @@ public sealed class IntListFormatter : IAkkaMessagePackFormatter> public int SizeOf(List value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120607)] + [AkkaSerializer(Name = "sample", SerializerId = 120607)] [AkkaSerializerFormatter(typeof(List), typeof(IntListFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -414,9 +414,9 @@ public sealed class ResultFormatter : IAkkaMessagePackFormatter public int SizeOf(Result value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120608)] + [AkkaSerializer(Name = "sample", SerializerId = 120608)] [AkkaSerializerFormatter(typeof(Result), typeof(ResultFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -448,9 +448,9 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120609)] + [AkkaSerializer(Name = "sample", SerializerId = 120609)] [AkkaSerializerFormatter(null!, typeof(AddressFormatter))] - public sealed partial class SampleSerializer : MessagePackSerializer + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -478,8 +478,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120701)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120701)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -513,8 +513,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120703)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120703)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -543,8 +543,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120702)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120702)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -580,8 +580,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120704)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120704)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -613,8 +613,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120901)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120901)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -659,14 +659,14 @@ public interface IProtocolB { } - [AkkaSerializer(Name = "sample-a", SerializerId = 120902)] - public sealed partial class SampleSerializerA : MessagePackSerializer + [AkkaSerializer(Name = "sample-a", SerializerId = 120902)] + public sealed partial class SampleSerializerA : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } - [AkkaSerializer(Name = "sample-b", SerializerId = 120903)] - public sealed partial class SampleSerializerB : MessagePackSerializer + [AkkaSerializer(Name = "sample-b", SerializerId = 120903)] + public sealed partial class SampleSerializerB : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -702,14 +702,14 @@ public interface IProtocolB { } - [AkkaSerializer(Name = "sample-a", SerializerId = 120904)] - public sealed partial class SampleSerializerA : MessagePackSerializer + [AkkaSerializer(Name = "sample-a", SerializerId = 120904)] + public sealed partial class SampleSerializerA : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } - [AkkaSerializer(Name = "sample-b", SerializerId = 120904)] - public sealed partial class SampleSerializerB : MessagePackSerializer + [AkkaSerializer(Name = "sample-b", SerializerId = 120904)] + public sealed partial class SampleSerializerB : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -748,8 +748,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121001)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 121001)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -793,8 +793,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121002)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 121002)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -827,8 +827,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121003)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 121003)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -860,8 +860,8 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121004)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 121004)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -897,8 +897,8 @@ public enum LongStatus : long Big = long.MaxValue } - [AkkaSerializer(Name = "sample", SerializerId = 121005)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 121005)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -939,8 +939,8 @@ public enum UIntStatus : uint Big = uint.MaxValue } - [AkkaSerializer(Name = "sample", SerializerId = 121006)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 121006)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -987,8 +987,8 @@ public enum ShortStatus : short B = 1 } - [AkkaSerializer(Name = "sample", SerializerId = 121007)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 121007)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1026,8 +1026,8 @@ public interface IEvent public sealed record NotSerializable(string Value) : IEvent; - [AkkaSerializer(Name = "sample", SerializerId = 120701)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120701)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1063,8 +1063,8 @@ public interface IEvent [AkkaSerializable] public sealed record ManifestlessMember([property: AkkaField(1)] string Value) : IEvent; - [AkkaSerializer(Name = "sample", SerializerId = 120702)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120702)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1100,8 +1100,8 @@ public interface IEvent [AkkaSerializable(Manifest = "unrelated-v1")] public sealed record Unrelated([property: AkkaField(1)] string Value); - [AkkaSerializer(Name = "sample", SerializerId = 120703)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120703)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1116,38 +1116,6 @@ public sealed record Outer( diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG018" && diagnostic.Severity == DiagnosticSeverity.Error); } - [Fact(DisplayName = "Generator should report AKKASG020 when an instantiation target is an unbound generic")] - public void Generator_should_report_AKKASG020_when_instantiation_target_is_unbound() - { - const string source = """ - #nullable enable - using Akka.Actor; - using Akka.Serialization.V2; - - namespace DiagnosticSample; - - public interface IProtocol - { - } - - [AkkaSerializable] - public sealed record Wrapper( - [property: AkkaField(1)] string Id, - [property: AkkaField(2)] T Payload) : IProtocol; - - [AkkaSerializer(Name = "sample", SerializerId = 120704)] - [AkkaSerializableInstantiation(typeof(Wrapper<>), Manifest = "wrap-v1")] - public sealed partial class SampleSerializer : MessagePackSerializer - { - public static partial SerializerRegistration CreateRegistration(); - } - """; - - var diagnostics = RunGenerator(source); - - diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG020" && diagnostic.Severity == DiagnosticSeverity.Error); - } - [Fact(DisplayName = "Generator should report AKKASG020 when an instantiation target is not generic")] public void Generator_should_report_AKKASG020_when_instantiation_target_is_not_generic() { @@ -1165,9 +1133,9 @@ public interface IProtocol [AkkaSerializable(Manifest = "plain-v1")] public sealed record Plain([property: AkkaField(1)] string Value) : IProtocol; - [AkkaSerializer(Name = "sample", SerializerId = 120705)] - [AkkaSerializableInstantiation(typeof(Plain), Manifest = "plain-again-v1")] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120705)] + [AkkaSerializable(Manifest = "plain-again-v1")] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1197,8 +1165,8 @@ public sealed record Wrapper( [property: AkkaField(1)] string Id, [property: AkkaField(2)] T Payload) : IProtocol; - [AkkaSerializer(Name = "sample", SerializerId = 120706)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120706)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1231,8 +1199,8 @@ public sealed record Wrapper( [AkkaSerializable] public sealed record Payload([property: AkkaField(1)] string Value); - [AkkaSerializer(Name = "sample", SerializerId = 120707)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120707)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1272,8 +1240,8 @@ public record OpenMember([property: AkkaField(1)] string Value) : IEvent; [AkkaSerializable(Manifest = "sealed-v1")] public sealed record SealedMember([property: AkkaField(1)] string Value) : IEvent; - [AkkaSerializer(Name = "sample", SerializerId = 120708)] - public sealed partial class SampleSerializer : MessagePackSerializer + [AkkaSerializer(Name = "sample", SerializerId = 120708)] + public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1325,7 +1293,7 @@ private static IEnumerable CreateMetadataReferences() var explicitAssemblies = new[] { typeof(ActorSystem).Assembly, - typeof(AkkaSerializerAttribute).Assembly, + typeof(AkkaSerializerAttribute<>).Assembly, typeof(SerializerV2).Assembly, typeof(ImmutableHashSet<>).Assembly, Assembly.GetExecutingAssembly() diff --git a/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs b/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs index 1af40e320cd..fef85813574 100644 --- a/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs @@ -466,8 +466,8 @@ public interface ICollectionProtocol { } -[AkkaSerializer(Name = "collection-test", SerializerId = 120105)] -public sealed partial class CollectionTestSerializer : MessagePackSerializer +[AkkaSerializer(Name = "collection-test", SerializerId = 120105)] +public sealed partial class CollectionTestSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs b/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs index 472153d3702..f57d3c55de8 100644 --- a/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs @@ -250,9 +250,9 @@ public sealed record GapOptionalHandshakeMessage( public const string ManifestName = "gap-optional-handshake-v1"; } -[AkkaSerializer(Name = "gap-fix", SerializerId = 120801)] +[AkkaSerializer(Name = "gap-fix", SerializerId = 120801)] [AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] -public sealed partial class GapFixSerializer : MessagePackSerializer +public sealed partial class GapFixSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs index 0a2bbc83ac0..a0f8baccf28 100644 --- a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs @@ -368,11 +368,11 @@ public sealed record MirrorActorPathMessage( public const string ManifestName = "mirror-actor-path-v1"; } -[AkkaSerializer(Name = "control-mirror", SerializerId = 120102)] +[AkkaSerializer(Name = "control-mirror", SerializerId = 120102)] [AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] [AkkaSerializerFormatter(typeof(TestUniqueAddress), typeof(TestUniqueAddressFormatter))] [AkkaSerializerFormatter(typeof(ActorPath), typeof(ActorPathFormatter))] -public sealed partial class ControlMirrorSerializer : MessagePackSerializer +public sealed partial class ControlMirrorSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -427,9 +427,9 @@ public sealed record SystemTaggedMessage( public const string ManifestName = "system-tagged-v1"; } -[AkkaSerializer(Name = "system-formatter", SerializerId = 120103)] +[AkkaSerializer(Name = "system-formatter", SerializerId = 120103)] [AkkaSerializerFormatter(typeof(SystemTaggedValue), typeof(SystemTaggedValueFormatter))] -public sealed partial class SystemFormatterSerializer : MessagePackSerializer +public sealed partial class SystemFormatterSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -445,8 +445,8 @@ internal sealed record InternalMirrorMessage( public const string ManifestName = "internal-mirror-v1"; } -[AkkaSerializer(Name = "internal-mirror", SerializerId = 120104)] -internal sealed partial class InternalMirrorSerializer : MessagePackSerializer +[AkkaSerializer(Name = "internal-mirror", SerializerId = 120104)] +internal sealed partial class InternalMirrorSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs index f9ec4611713..1270e3ed711 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs @@ -16,7 +16,7 @@ namespace Akka.Serialization.V2.Tests; /// -/// Specs for [AkkaSerializableInstantiation]: registering CLOSED generic constructions of a +/// Specs for the generic [AkkaSerializable<T>] form: registering CLOSED generic constructions of a /// generic [AkkaSerializable] type on the serializer module, the same closed-construction /// registration model System.Text.Json's source generator uses for [JsonSerializable]. /// Each registered construction behaves as its own top-level message with its own manifest, with @@ -136,11 +136,11 @@ public interface IClosedGenericTestProtocol { } -[AkkaSerializer(Name = "closed-generic-test", SerializerId = 120404)] -[AkkaSerializableInstantiation(typeof(Wrapper), Manifest = "wrap-request-v1")] -[AkkaSerializableInstantiation(typeof(Wrapper), Manifest = "wrap-receipt-v1")] -[AkkaSerializableInstantiation(typeof(EventWrapper), Manifest = "event-wrap-v1")] -public sealed partial class ClosedGenericTestSerializer : MessagePackSerializer +[AkkaSerializer(Name = "closed-generic-test", SerializerId = 120404)] +[AkkaSerializable>(Manifest = "wrap-request-v1")] +[AkkaSerializable>(Manifest = "wrap-receipt-v1")] +[AkkaSerializable>(Manifest = "event-wrap-v1")] +public sealed partial class ClosedGenericTestSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs index 7d40229ce4c..83505f10906 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs @@ -645,8 +645,8 @@ public interface IGeneratedTestProtocol { } -[AkkaSerializer(Name = "generated-test", SerializerId = 120101)] -public sealed partial class GeneratedTestSerializer : MessagePackSerializer +[AkkaSerializer(Name = "generated-test", SerializerId = 120101)] +public sealed partial class GeneratedTestSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs index 55a05298129..7e685c5cc12 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs @@ -295,8 +295,8 @@ public interface IOrderEvent { } -[AkkaSerializer(Name = "union-test", SerializerId = 120303)] -public sealed partial class UnionTestSerializer : MessagePackSerializer +[AkkaSerializer(Name = "union-test", SerializerId = 120303)] +public sealed partial class UnionTestSerializer : MessagePackSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2/Attributes.cs b/src/core/Akka.Serialization.V2/Attributes.cs index af2405a0da8..7e7b726ae31 100644 --- a/src/core/Akka.Serialization.V2/Attributes.cs +++ b/src/core/Akka.Serialization.V2/Attributes.cs @@ -11,10 +11,18 @@ namespace Akka.Serialization.V2; /// -/// Marks a partial serializer module that the source generator should implement. +/// Marks a partial serializer module that the source generator should implement, bound to the +/// protocol marker interface . /// +/// +/// selects which messages +/// this serializer dispatches at the top level (those implementing the marker) and becomes the +/// type this serializer is bound to in Akka's serialization-bindings via the generated +/// registration. It is purely generator input: the base class +/// is non-generic and carries no protocol knowledge of its own. +/// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class AkkaSerializerAttribute : Attribute +public sealed class AkkaSerializerAttribute : Attribute { /// /// Logical serializer alias used for Akka serializer registration. @@ -120,41 +128,24 @@ public AkkaUnionAttribute(params Type[] memberTypes) /// /// Registers a CLOSED generic construction of a generic -/// type (for example typeof(Wrapper<OrderPlaced>)) with a generated serializer. +/// type (for example [AkkaSerializable<Wrapper<OrderPlaced>>]) with a generated +/// serializer. Everything on the wire is marked [AkkaSerializable]: ordinary types carry +/// the non-generic form on their own declaration; a closed generic construction has no +/// declaration site of its own, so its marking lives here, on the [AkkaSerializer] class. /// /// /// A Roslyn source generator cannot reify open generics: it can only emit concrete serialization /// code for closed constructions it can see at compile time (the same rule System.Text.Json's -/// source generator enforces by rejecting unbound generics in [JsonSerializable]). C# does -/// not allow attributes on a closed construction directly, so registration lives here, on the -/// [AkkaSerializer] partial class — analogous to . -/// Each registered construction behaves exactly like its own top-level [AkkaSerializable] -/// message: it needs a distinct , participates in ordinary manifest dispatch, -/// and its generic fields are resolved against the concrete type arguments. The generic type -/// definition itself must still be annotated [AkkaSerializable] (that is where the -/// [AkkaField] indices live), but the open definition is never serialized — only its -/// registered closed constructions are. +/// source generator enforces by rejecting unbound generics in [JsonSerializable]). Each +/// registered construction behaves exactly like its own top-level message: it needs a distinct +/// , participates in ordinary manifest dispatch, and its generic fields are +/// resolved against the concrete type arguments. The generic type definition itself must still be +/// annotated [AkkaSerializable] (that is where the [AkkaField] indices live), but +/// the open definition is never serialized — only its registered closed constructions are. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] -public sealed class AkkaSerializableInstantiationAttribute : Attribute +public sealed class AkkaSerializableAttribute : Attribute { - /// - /// Initializes a new instance of the class. - /// - /// - /// A closed generic construction of a generic type annotated with - /// . - /// - public AkkaSerializableInstantiationAttribute(Type closedType) - { - ClosedType = closedType; - } - - /// - /// The closed generic construction being registered. - /// - public Type ClosedType { get; } - /// /// Stable serializer-owned manifest for this closed construction. Required when the /// construction implements the serializer's protocol interface (top-level dispatch); also the diff --git a/src/core/Akka.Serialization.V2/MessagePackSerializer.cs b/src/core/Akka.Serialization.V2/MessagePackSerializer.cs index df2f0d1cc5a..7bc8dbece4d 100644 --- a/src/core/Akka.Serialization.V2/MessagePackSerializer.cs +++ b/src/core/Akka.Serialization.V2/MessagePackSerializer.cs @@ -16,7 +16,7 @@ namespace Akka.Serialization.V2; /// /// Base class for source-generated MessagePack serializers scoped to a protocol marker type. /// -public abstract class MessagePackSerializer : global::Akka.Serialization.SerializerV2 +public abstract class MessagePackSerializer : global::Akka.Serialization.SerializerV2 { protected MessagePackSerializer(ExtendedActorSystem system) : base(system) { diff --git a/src/core/Akka.Serialization.V2/MessagePackSizes.cs b/src/core/Akka.Serialization.V2/MessagePackSizes.cs index 2c426595d46..48b3ec00fa1 100644 --- a/src/core/Akka.Serialization.V2/MessagePackSizes.cs +++ b/src/core/Akka.Serialization.V2/MessagePackSizes.cs @@ -14,7 +14,7 @@ namespace Akka.Serialization.V2; /// -/// Exact-size MessagePack encoding math shared by , +/// Exact-size MessagePack encoding math shared by , /// the built-in foreign-type formatters (, ), /// and external hand-written implementations. /// From 8c7b33e0b5caa539e67125fbeeb93a0e312e4812 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 17:07:12 -0500 Subject: [PATCH 06/12] Akka.Serialization.V2 PoC: rename base class MessagePackSerializer to AkkaSerializer Design review on #8385: de-generifying the base class made it collide with MessagePack.MessagePackSerializer (CS0104) under dual top-level usings, and the old name leaked the wire format into the type users inherit. AkkaSerializer collides with nothing, echoes the [AkkaSerializer] attribute, and keeps MessagePack an implementation detail: [AkkaSerializer(Name = "my-protocol", SerializerId = 1234)] public sealed partial class MyProtocolSerializer : AkkaSerializer { } File renamed to AkkaSerializer.cs; all global::-qualified CS0104 workarounds simplified back to the plain name. MessagePack LIBRARY references and spec/benchmark class names intentionally untouched. --- ...eneratedMessagePackSerializerBenchmarks.cs | 4 +- .../RealPayload/V2/RealBenchmarkMessages.cs | 2 +- .../Artery/ArteryControlMessageSerializer.cs | 2 +- .../Artery/SystemMessageDeliveryMessages.cs | 4 +- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 68 +++++++++---------- .../CollectionFieldSpec.cs | 2 +- .../FieldlessAndStructFieldSpec.cs | 2 +- .../FormatterEscapeHatchSpec.cs | 6 +- .../GeneratedClosedGenericSpec.cs | 2 +- .../GeneratedMessagePackSerializerSpec.cs | 2 +- .../GeneratedUnionSpec.cs | 2 +- ...agePackSerializer.cs => AkkaSerializer.cs} | 6 +- src/core/Akka.Serialization.V2/Attributes.cs | 2 +- .../Akka.Serialization.V2/MessagePackSizes.cs | 2 +- 14 files changed, 53 insertions(+), 53 deletions(-) rename src/core/Akka.Serialization.V2/{MessagePackSerializer.cs => AkkaSerializer.cs} (98%) diff --git a/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs b/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs index eaa450df914..e7433b9a7b8 100644 --- a/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs +++ b/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs @@ -308,7 +308,7 @@ public interface IOrderBenchmarkProtocol } [AkkaSerializer(Name = "order-benchmark", SerializerId = 120001)] -public sealed partial class OrderBenchmarkSerializer : global::Akka.Serialization.V2.MessagePackSerializer +public sealed partial class OrderBenchmarkSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -318,7 +318,7 @@ public interface IEnvelopeBenchmarkProtocol } [AkkaSerializer(Name = "envelope-benchmark", SerializerId = 120002)] -public sealed partial class EnvelopeBenchmarkSerializer : global::Akka.Serialization.V2.MessagePackSerializer +public sealed partial class EnvelopeBenchmarkSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs b/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs index 64dea58d954..f4a671ddcd9 100644 --- a/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs +++ b/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs @@ -122,7 +122,7 @@ public sealed record Reading( /// the V2 test suite's ~120101-120202) to avoid any collision with a real Akka.NET serializer. /// [AkkaSerializer(Name = "real-benchmark-v2", SerializerId = 987001)] - internal sealed partial class RealBenchmarkSerializer : MessagePackSerializer + internal sealed partial class RealBenchmarkSerializer : AkkaSerializer { /// /// Generated by Akka.Serialization.V2.Generators.AkkaSerializerGenerator. Not used by diff --git a/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs b/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs index cb3c367f0b6..0c900750bc9 100644 --- a/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs +++ b/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs @@ -53,7 +53,7 @@ namespace Akka.Remote.Artery /// [AkkaSerializer(Name = "artery-control", SerializerId = 23)] [AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] - internal sealed partial class ArteryControlMessageSerializer : MessagePackSerializer + internal sealed partial class ArteryControlMessageSerializer : AkkaSerializer { /// /// The manifest for . diff --git a/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs b/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs index 85403575e71..ca4816f003b 100644 --- a/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs +++ b/src/core/Akka.Remote/Artery/SystemMessageDeliveryMessages.cs @@ -95,7 +95,7 @@ internal sealed record Nack( /// /// /// How the inner message is nested (documented per the task). is - /// encoded via + /// encoded via /// / decoded via ReadEnvelopePayload -- an EXISTING helper on the V2 MessagePack serializer /// base class that already implements exactly "nest a serializer id + manifest + raw bytes, /// recursively through Serialization.FindSerializerFor/Deserialize" for wrapping an @@ -120,7 +120,7 @@ internal sealed record Nack( /// Sourcegen swap field-id note. is marked /// , routing it through the generated serializer's /// WriteEnvelopePayload/ReadEnvelopePayload calls -- the SAME - /// base-class helpers the + /// base-class helpers the /// prior hand-rolled used, confirming the generator /// has no gap here. The generator emits the record's constructor call positionally by ASCENDING /// index (not by declared parameter order), so field indices diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 05cffa011c4..360a8fb9d61 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -38,7 +38,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 120501)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -74,7 +74,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 120502)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -120,7 +120,7 @@ public sealed class NotAFormatter [AkkaSerializer(Name = "sample", SerializerId = 120601)] [AkkaSerializerFormatter(typeof(Foreign), typeof(NotAFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -160,7 +160,7 @@ public abstract class AbstractFormatter : IAkkaMessagePackFormatter [AkkaSerializer(Name = "sample", SerializerId = 120602)] [AkkaSerializerFormatter(typeof(Foreign), typeof(AbstractFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -208,7 +208,7 @@ public sealed class SecondFormatter : IAkkaMessagePackFormatter [AkkaSerializer(Name = "sample", SerializerId = 120603)] [AkkaSerializerFormatter(typeof(Foreign), typeof(FirstFormatter))] [AkkaSerializerFormatter(typeof(Foreign), typeof(SecondFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -252,7 +252,7 @@ public NoUsableCtorFormatter(string unused) [AkkaSerializer(Name = "sample", SerializerId = 120604)] [AkkaSerializerFormatter(typeof(Foreign), typeof(NoUsableCtorFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -292,7 +292,7 @@ public sealed class ForeignFormatter : IAkkaMessagePackFormatter [AkkaSerializer(Name = "sample", SerializerId = 120605)] [AkkaSerializerFormatter(typeof(Foreign), typeof(ForeignFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -331,7 +331,7 @@ public sealed class IntArrayFormatter : IAkkaMessagePackFormatter [AkkaSerializer(Name = "sample", SerializerId = 120606)] [AkkaSerializerFormatter(typeof(int[]), typeof(IntArrayFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -370,7 +370,7 @@ public sealed class IntListFormatter : IAkkaMessagePackFormatter> [AkkaSerializer(Name = "sample", SerializerId = 120607)] [AkkaSerializerFormatter(typeof(List), typeof(IntListFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -416,7 +416,7 @@ public sealed class ResultFormatter : IAkkaMessagePackFormatter [AkkaSerializer(Name = "sample", SerializerId = 120608)] [AkkaSerializerFormatter(typeof(Result), typeof(ResultFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -450,7 +450,7 @@ public interface IProtocol [AkkaSerializer(Name = "sample", SerializerId = 120609)] [AkkaSerializerFormatter(null!, typeof(AddressFormatter))] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -479,7 +479,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 120701)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -514,7 +514,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 120703)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -544,7 +544,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 120702)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -581,7 +581,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 120704)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -614,7 +614,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 120901)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -660,13 +660,13 @@ public interface IProtocolB } [AkkaSerializer(Name = "sample-a", SerializerId = 120902)] - public sealed partial class SampleSerializerA : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializerA : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } [AkkaSerializer(Name = "sample-b", SerializerId = 120903)] - public sealed partial class SampleSerializerB : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializerB : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -703,13 +703,13 @@ public interface IProtocolB } [AkkaSerializer(Name = "sample-a", SerializerId = 120904)] - public sealed partial class SampleSerializerA : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializerA : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } [AkkaSerializer(Name = "sample-b", SerializerId = 120904)] - public sealed partial class SampleSerializerB : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializerB : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -749,7 +749,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 121001)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -794,7 +794,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 121002)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -828,7 +828,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 121003)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -861,7 +861,7 @@ public interface IProtocol } [AkkaSerializer(Name = "sample", SerializerId = 121004)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -898,7 +898,7 @@ public enum LongStatus : long } [AkkaSerializer(Name = "sample", SerializerId = 121005)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -940,7 +940,7 @@ public enum UIntStatus : uint } [AkkaSerializer(Name = "sample", SerializerId = 121006)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -988,7 +988,7 @@ public enum ShortStatus : short } [AkkaSerializer(Name = "sample", SerializerId = 121007)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1027,7 +1027,7 @@ public interface IEvent public sealed record NotSerializable(string Value) : IEvent; [AkkaSerializer(Name = "sample", SerializerId = 120701)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1064,7 +1064,7 @@ public interface IEvent public sealed record ManifestlessMember([property: AkkaField(1)] string Value) : IEvent; [AkkaSerializer(Name = "sample", SerializerId = 120702)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1101,7 +1101,7 @@ public interface IEvent public sealed record Unrelated([property: AkkaField(1)] string Value); [AkkaSerializer(Name = "sample", SerializerId = 120703)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1135,7 +1135,7 @@ public sealed record Plain([property: AkkaField(1)] string Value) : IProtocol; [AkkaSerializer(Name = "sample", SerializerId = 120705)] [AkkaSerializable(Manifest = "plain-again-v1")] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1166,7 +1166,7 @@ public sealed record Wrapper( [property: AkkaField(2)] T Payload) : IProtocol; [AkkaSerializer(Name = "sample", SerializerId = 120706)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1200,7 +1200,7 @@ public sealed record Wrapper( public sealed record Payload([property: AkkaField(1)] string Value); [AkkaSerializer(Name = "sample", SerializerId = 120707)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -1241,7 +1241,7 @@ public record OpenMember([property: AkkaField(1)] string Value) : IEvent; public sealed record SealedMember([property: AkkaField(1)] string Value) : IEvent; [AkkaSerializer(Name = "sample", SerializerId = 120708)] - public sealed partial class SampleSerializer : global::Akka.Serialization.V2.MessagePackSerializer + public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs b/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs index fef85813574..5219b040638 100644 --- a/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs @@ -467,7 +467,7 @@ public interface ICollectionProtocol } [AkkaSerializer(Name = "collection-test", SerializerId = 120105)] -public sealed partial class CollectionTestSerializer : MessagePackSerializer +public sealed partial class CollectionTestSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs b/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs index f57d3c55de8..b7ca8bfe28d 100644 --- a/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs @@ -252,7 +252,7 @@ public sealed record GapOptionalHandshakeMessage( [AkkaSerializer(Name = "gap-fix", SerializerId = 120801)] [AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] -public sealed partial class GapFixSerializer : MessagePackSerializer +public sealed partial class GapFixSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs index a0f8baccf28..b492de4ed35 100644 --- a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs @@ -372,7 +372,7 @@ public sealed record MirrorActorPathMessage( [AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] [AkkaSerializerFormatter(typeof(TestUniqueAddress), typeof(TestUniqueAddressFormatter))] [AkkaSerializerFormatter(typeof(ActorPath), typeof(ActorPathFormatter))] -public sealed partial class ControlMirrorSerializer : MessagePackSerializer +public sealed partial class ControlMirrorSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -429,7 +429,7 @@ public sealed record SystemTaggedMessage( [AkkaSerializer(Name = "system-formatter", SerializerId = 120103)] [AkkaSerializerFormatter(typeof(SystemTaggedValue), typeof(SystemTaggedValueFormatter))] -public sealed partial class SystemFormatterSerializer : MessagePackSerializer +public sealed partial class SystemFormatterSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } @@ -446,7 +446,7 @@ internal sealed record InternalMirrorMessage( } [AkkaSerializer(Name = "internal-mirror", SerializerId = 120104)] -internal sealed partial class InternalMirrorSerializer : MessagePackSerializer +internal sealed partial class InternalMirrorSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs index 1270e3ed711..9bb120c47db 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs @@ -140,7 +140,7 @@ public interface IClosedGenericTestProtocol [AkkaSerializable>(Manifest = "wrap-request-v1")] [AkkaSerializable>(Manifest = "wrap-receipt-v1")] [AkkaSerializable>(Manifest = "event-wrap-v1")] -public sealed partial class ClosedGenericTestSerializer : MessagePackSerializer +public sealed partial class ClosedGenericTestSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs index 83505f10906..e12a23eba07 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs @@ -646,7 +646,7 @@ public interface IGeneratedTestProtocol } [AkkaSerializer(Name = "generated-test", SerializerId = 120101)] -public sealed partial class GeneratedTestSerializer : MessagePackSerializer +public sealed partial class GeneratedTestSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs index 7e685c5cc12..dd384981d91 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs @@ -296,7 +296,7 @@ public interface IOrderEvent } [AkkaSerializer(Name = "union-test", SerializerId = 120303)] -public sealed partial class UnionTestSerializer : MessagePackSerializer +public sealed partial class UnionTestSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } diff --git a/src/core/Akka.Serialization.V2/MessagePackSerializer.cs b/src/core/Akka.Serialization.V2/AkkaSerializer.cs similarity index 98% rename from src/core/Akka.Serialization.V2/MessagePackSerializer.cs rename to src/core/Akka.Serialization.V2/AkkaSerializer.cs index 7bc8dbece4d..25703ba8be9 100644 --- a/src/core/Akka.Serialization.V2/MessagePackSerializer.cs +++ b/src/core/Akka.Serialization.V2/AkkaSerializer.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (C) 2009-2022 Lightbend Inc. // Copyright (C) 2013-2026 .NET Foundation // @@ -16,9 +16,9 @@ namespace Akka.Serialization.V2; /// /// Base class for source-generated MessagePack serializers scoped to a protocol marker type. /// -public abstract class MessagePackSerializer : global::Akka.Serialization.SerializerV2 +public abstract class AkkaSerializer : global::Akka.Serialization.SerializerV2 { - protected MessagePackSerializer(ExtendedActorSystem system) : base(system) + protected AkkaSerializer(ExtendedActorSystem system) : base(system) { } diff --git a/src/core/Akka.Serialization.V2/Attributes.cs b/src/core/Akka.Serialization.V2/Attributes.cs index 7e7b726ae31..c13a2844c74 100644 --- a/src/core/Akka.Serialization.V2/Attributes.cs +++ b/src/core/Akka.Serialization.V2/Attributes.cs @@ -18,7 +18,7 @@ namespace Akka.Serialization.V2; /// selects which messages /// this serializer dispatches at the top level (those implementing the marker) and becomes the /// type this serializer is bound to in Akka's serialization-bindings via the generated -/// registration. It is purely generator input: the base class +/// registration. It is purely generator input: the base class /// is non-generic and carries no protocol knowledge of its own. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] diff --git a/src/core/Akka.Serialization.V2/MessagePackSizes.cs b/src/core/Akka.Serialization.V2/MessagePackSizes.cs index 48b3ec00fa1..f75acec39a5 100644 --- a/src/core/Akka.Serialization.V2/MessagePackSizes.cs +++ b/src/core/Akka.Serialization.V2/MessagePackSizes.cs @@ -14,7 +14,7 @@ namespace Akka.Serialization.V2; /// -/// Exact-size MessagePack encoding math shared by , +/// Exact-size MessagePack encoding math shared by , /// the built-in foreign-type formatters (, ), /// and external hand-written implementations. /// From f10d32ba1a78e2d9ce910d890c961c55d9a010b4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 17:24:53 -0500 Subject: [PATCH 07/12] Akka.Serialization.V2 PoC: purge instantiation vocabulary from diagnostics and generator internals The [AkkaSerializable] consolidation killed the attribute that gave "instantiation" its meaning here; this finishes the job so user-facing and internal language match the mechanism's real name: - AKKASG020 retitled "Closed generic registration is invalid" (InvalidClosedGenericRegistration) - AKKASG021 retitled "Duplicate closed generic registration" (DuplicateClosedGenericRegistration) - AKKASG022 retitled "Generic serializable type requires closed generic registrations" (GenericSerializableRequiresRegistration) - Generator internals renamed: ExtractClosedGenericRegistrations, ValidateClosedGenericRegistrations, ClosedGenericRegistrationInfo, SerializerInfo.ClosedGenericRegistrations - Two diagnostics-spec test names/DisplayNames updated to match Plain-English generics usage ("at instantiation time, T := Foo") retained where it describes type substitution rather than the registration mechanism. Verified: full Akka.slnx build 0 errors/0 warnings; V2 suite 134/134; Artery integration specs (ArteryControlMessageSerializerSpec, SystemMessageAckerStageSpec, ArteryHandshakeSpec) 51/51. --- .../AkkaSerializerGenerator.cs | 74 +++++++++---------- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 8 +- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 05055d70263..4543a4fde7e 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -183,25 +183,25 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - private static readonly DiagnosticDescriptor InvalidInstantiationTarget = new( + private static readonly DiagnosticDescriptor InvalidClosedGenericRegistration = new( "AKKASG020", - "Instantiation target is not a closed generic serializable construction", + "Closed generic registration is invalid", "[AkkaSerializable] registration '{0}' on serializer '{1}' must be a closed generic construction of a generic type annotated with [AkkaSerializable]", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); - private static readonly DiagnosticDescriptor DuplicateInstantiation = new( + private static readonly DiagnosticDescriptor DuplicateClosedGenericRegistration = new( "AKKASG021", - "Duplicate instantiation registration", + "Duplicate closed generic registration", "Serializer '{0}' registers the closed construction '{1}' more than once", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); - private static readonly DiagnosticDescriptor GenericSerializableRequiresInstantiation = new( + private static readonly DiagnosticDescriptor GenericSerializableRequiresRegistration = new( "AKKASG022", - "Generic serializable type requires closed instantiations", + "Generic serializable type requires closed generic registrations", "Generic [AkkaSerializable] type '{0}' implements protocol '{1}' of serializer '{2}' but has no [AkkaSerializable] registrations; a source generator cannot serialize an open generic, so register each closed construction with [AkkaSerializable(Manifest = ...)] on the serializer class", "Akka.Serialization.V2", DiagnosticSeverity.Error, @@ -287,7 +287,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!ValidateFormatters(ctx, serializer)) continue; - if (!ValidateInstantiations(ctx, serializer)) + if (!ValidateClosedGenericRegistrations(ctx, serializer)) continue; var declaredMessages = pair.Right @@ -307,9 +307,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var allMessages = declaredMessages .Where(message => !message.IsGenericDefinition) - .Concat(serializer.Instantiations - .Where(instantiation => instantiation.Message != null) - .Select(instantiation => instantiation.Message!)) + .Concat(serializer.ClosedGenericRegistrations + .Where(registration => registration.Message != null) + .Select(registration => registration.Message!)) .ToImmutableArray(); var allMessagesByType = allMessages.ToImmutableDictionary(message => message.FullyQualifiedName); var resolvedMessagesByType = ResolveMessages(allMessagesByType, serializer.Formatters); @@ -350,7 +350,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var formatterInterfaceType = compilation.GetTypeByMetadataName(FormatterInterfaceFullName); var extendedActorSystemType = compilation.GetTypeByMetadataName(ExtendedActorSystemFullName); var formatters = ExtractFormatters(symbol, formatterAttributeType, formatterInterfaceType, extendedActorSystemType); - var instantiations = ExtractInstantiations(symbol, compilation); + var closedGenericRegistrations = ExtractClosedGenericRegistrations(symbol, compilation); return new SerializerInfo( GetNamespace(symbol), @@ -362,7 +362,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) protocolTypeFullName, symbol.DeclaredAccessibility, formatters, - instantiations); + closedGenericRegistrations); } /// @@ -372,20 +372,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) /// is built from the constructed symbol, so all field types arrive /// already substituted. Invalid targets are recorded with a null message so AKKASG020 fires. /// - private static ImmutableArray ExtractInstantiations(INamedTypeSymbol symbol, Compilation compilation) + private static ImmutableArray ExtractClosedGenericRegistrations(INamedTypeSymbol symbol, Compilation compilation) { var genericSerializableAttribute = compilation.GetTypeByMetadataName(GenericSerializableAttributeFullName); if (genericSerializableAttribute == null) - return ImmutableArray.Empty; + return ImmutableArray.Empty; var attributes = symbol.GetAttributes() .Where(attr => attr.AttributeClass is { IsGenericType: true } ac && SymbolEqualityComparer.Default.Equals(ac.OriginalDefinition, genericSerializableAttribute)) .ToImmutableArray(); if (attributes.IsEmpty) - return ImmutableArray.Empty; + return ImmutableArray.Empty; var knownTypes = KnownTypes.From(compilation); - var builder = ImmutableArray.CreateBuilder(attributes.Length); + var builder = ImmutableArray.CreateBuilder(attributes.Length); foreach (var attribute in attributes) { var manifest = string.Empty; @@ -405,7 +405,7 @@ private static ImmutableArray ExtractInstantiations(INamedTyp if (!isValidTarget) { var displayName = attribute.AttributeClass!.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - builder.Add(new InstantiationInfo(displayName, message: null)); + builder.Add(new ClosedGenericRegistrationInfo(displayName, message: null)); continue; } @@ -422,7 +422,7 @@ private static ImmutableArray ExtractInstantiations(INamedTyp knownTypes, compilation, definitionFullName: GetFullyQualifiedTypeName(target!.OriginalDefinition)); - builder.Add(new InstantiationInfo(message.FullyQualifiedName, message)); + builder.Add(new ClosedGenericRegistrationInfo(message.FullyQualifiedName, message)); } return builder.ToImmutable(); @@ -583,24 +583,24 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ return isValid; } - private static bool ValidateInstantiations(SourceProductionContext context, SerializerInfo serializer) + private static bool ValidateClosedGenericRegistrations(SourceProductionContext context, SerializerInfo serializer) { - if (serializer.Instantiations.IsDefaultOrEmpty) + if (serializer.ClosedGenericRegistrations.IsDefaultOrEmpty) return true; var isValid = true; - foreach (var instantiation in serializer.Instantiations.Where(instantiation => instantiation.Message == null)) + foreach (var registration in serializer.ClosedGenericRegistrations.Where(registration => registration.Message == null)) { - context.ReportDiagnostic(Diagnostic.Create(InvalidInstantiationTarget, Location.None, instantiation.TargetDisplayName, serializer.ClassName)); + context.ReportDiagnostic(Diagnostic.Create(InvalidClosedGenericRegistration, Location.None, registration.TargetDisplayName, serializer.ClassName)); isValid = false; } - foreach (var duplicate in serializer.Instantiations - .Where(instantiation => instantiation.Message != null) - .GroupBy(instantiation => instantiation.TargetDisplayName, StringComparer.Ordinal) + foreach (var duplicate in serializer.ClosedGenericRegistrations + .Where(registration => registration.Message != null) + .GroupBy(registration => registration.TargetDisplayName, StringComparer.Ordinal) .Where(group => group.Count() > 1)) { - context.ReportDiagnostic(Diagnostic.Create(DuplicateInstantiation, Location.None, serializer.ClassName, duplicate.Key)); + context.ReportDiagnostic(Diagnostic.Create(DuplicateClosedGenericRegistration, Location.None, serializer.ClassName, duplicate.Key)); isValid = false; } @@ -624,13 +624,13 @@ private static bool ValidateGenericDefinitions(SourceProductionContext context, if (!definition.Protocols.Any(protocol => SymbolEqualityComparer.Default.Equals(protocol, serializer.ProtocolType))) continue; - var hasInstantiation = serializer.Instantiations.Any(instantiation => - instantiation.Message != null && - string.Equals(instantiation.Message.DefinitionFullName, definition.FullyQualifiedName, StringComparison.Ordinal)); - if (hasInstantiation) + var hasRegistration = serializer.ClosedGenericRegistrations.Any(registration => + registration.Message != null && + string.Equals(registration.Message.DefinitionFullName, definition.FullyQualifiedName, StringComparison.Ordinal)); + if (hasRegistration) continue; - context.ReportDiagnostic(Diagnostic.Create(GenericSerializableRequiresInstantiation, Location.None, definition.FullyQualifiedName, serializer.ProtocolTypeFullName, serializer.ClassName)); + context.ReportDiagnostic(Diagnostic.Create(GenericSerializableRequiresRegistration, Location.None, definition.FullyQualifiedName, serializer.ProtocolTypeFullName, serializer.ClassName)); isValid = false; } @@ -2649,7 +2649,7 @@ public SerializerInfo( string protocolTypeFullName, Accessibility declaredAccessibility, ImmutableArray formatters, - ImmutableArray instantiations) + ImmutableArray closedGenericRegistrations) { Namespace = ns; ClassName = className; @@ -2660,7 +2660,7 @@ public SerializerInfo( ProtocolTypeFullName = protocolTypeFullName; DeclaredAccessibility = declaredAccessibility; Formatters = formatters; - Instantiations = instantiations; + ClosedGenericRegistrations = closedGenericRegistrations; } public string Namespace { get; } @@ -2672,7 +2672,7 @@ public SerializerInfo( public string ProtocolTypeFullName { get; } public Accessibility DeclaredAccessibility { get; } public ImmutableArray Formatters { get; } - public ImmutableArray Instantiations { get; } + public ImmutableArray ClosedGenericRegistrations { get; } } private sealed class KnownTypes @@ -2739,7 +2739,7 @@ public MessageInfo(string simpleName, string fullyQualifiedName, string manifest /// /// For a registered closed construction: the arity-less fully-qualified name of its generic - /// definition, linking the instantiation back to the definition for the AKKASG022 check. + /// definition, linking the registration back to the definition for the AKKASG022 check. /// Empty for ordinary non-generic messages. /// public string DefinitionFullName { get; } @@ -2755,9 +2755,9 @@ public MessageInfo WithFields(ImmutableArray fields) /// when the target was invalid (not a type, non-generic, unbound, or its definition lacks /// [AkkaSerializable]) so AKKASG020 fires instead of the registration silently vanishing. /// - private sealed class InstantiationInfo + private sealed class ClosedGenericRegistrationInfo { - public InstantiationInfo(string targetDisplayName, MessageInfo? message) + public ClosedGenericRegistrationInfo(string targetDisplayName, MessageInfo? message) { TargetDisplayName = targetDisplayName; Message = message; diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 360a8fb9d61..2a2f6dab2d4 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -1116,8 +1116,8 @@ public sealed record Outer( diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG018" && diagnostic.Severity == DiagnosticSeverity.Error); } - [Fact(DisplayName = "Generator should report AKKASG020 when an instantiation target is not generic")] - public void Generator_should_report_AKKASG020_when_instantiation_target_is_not_generic() + [Fact(DisplayName = "Generator should report AKKASG020 when a closed generic registration target is not generic")] + public void Generator_should_report_AKKASG020_when_closed_generic_registration_target_is_not_generic() { const string source = """ #nullable enable @@ -1146,8 +1146,8 @@ public sealed partial class SampleSerializer : AkkaSerializer diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG020" && diagnostic.Severity == DiagnosticSeverity.Error); } - [Fact(DisplayName = "Generator should report AKKASG022 when a generic protocol message has no instantiations")] - public void Generator_should_report_AKKASG022_when_generic_message_has_no_instantiations() + [Fact(DisplayName = "Generator should report AKKASG022 when a generic protocol message has no closed generic registrations")] + public void Generator_should_report_AKKASG022_when_generic_message_has_no_closed_generic_registrations() { const string source = """ #nullable enable From e0c71bfbbf915a4c85668750aeb6de9196046910 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 15 Jul 2026 23:28:00 +0000 Subject: [PATCH 08/12] Build: pin SDK-implicit FSharp.Core and bump FsharpVersion to 10.1.302 Azure hosted images rolled a newer .NET SDK whose implicit FSharp.Core (10.1.302) conflicts with the repo-pinned 10.1.301, failing restore with NU1605 on every branch. Pin FSharp.Core explicitly in Akka.FSharp so the resolved version no longer depends on the build agent's SDK. --- Directory.Build.props | 2 +- src/core/Akka.FSharp/Akka.FSharp.fsproj | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e83ea2449c3..f203b31d0a7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,7 +35,7 @@ 3.1.7 4.13.0 net10.0 - 10.1.301 + 10.1.302 net10.0 5.10.3 true diff --git a/src/core/Akka.FSharp/Akka.FSharp.fsproj b/src/core/Akka.FSharp/Akka.FSharp.fsproj index 42c7d9470ed..dc2ab44e930 100644 --- a/src/core/Akka.FSharp/Akka.FSharp.fsproj +++ b/src/core/Akka.FSharp/Akka.FSharp.fsproj @@ -21,6 +21,11 @@ + + \ No newline at end of file From c507f0f73f947a3d4941acf3191a96be27873f3d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 16 Jul 2026 00:14:37 +0000 Subject: [PATCH 09/12] Serialization.V2: name-bound hybrid construction on deserialize + AKKASG026-028 Read-side reconstruction previously bound constructor arguments positionally in [AkkaField]-index order with no validation: same-typed properties whose index order differed from ctor-parameter order silently swapped values on round-trip, and defaulted ctor params not covered by a field silently reset. - Emit NAMED constructor arguments (ordinal, then unique case-insensitive param-name matching with implicit-conversion checks) + object-initializer assignments for settable/init-only leftovers (STJ-style hybrid), so init-only POCO and mixed shapes now serialize - AKKASG026 (Error): no eligible constructor / leftover property unsettable - AKKASG027 (Warning): chosen ctor has defaulted param no [AkkaField] covers - AKKASG028 (Error): [AkkaField] on static or getter-inaccessible property - Prefix generator-owned read locals (__fieldCount/__entryIndex/__fieldId, __has guards) so adversarial property names (FieldCount, Foo+HasFoo) can no longer collide (CS0128/CS0136) 146/146 V2 tests (12 new); wire format unchanged (byte assertions intact); Akka.Remote builds clean. --- .../AkkaSerializerGenerator.cs | 395 +++++++++++++++++- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 256 ++++++++++++ .../GeneratedMessagePackSerializerSpec.cs | 136 ++++++ 3 files changed, 773 insertions(+), 14 deletions(-) diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 4543a4fde7e..53225b3851d 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -231,6 +231,30 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor NoMatchingConstructor = new( + "AKKASG026", + "No matching constructor", + "[AkkaSerializable] type '{0}' cannot be reconstructed on deserialize: {1}", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ConstructorParameterNotCovered = new( + "AKKASG027", + "Constructor parameter not covered by [AkkaField]", + "Constructor parameter '{0}' of [AkkaSerializable] type '{1}' has a default value and is not covered by any [AkkaField] property; it silently resets to its default value on every deserialize", + "Akka.Serialization.V2", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor FieldPropertyNotAccessible = new( + "AKKASG028", + "[AkkaField] must be on an accessible instance property", + "[AkkaField] property '{0}' on type '{1}' {2}", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { var serializers = context.SyntaxProvider @@ -668,7 +692,9 @@ private static bool ValidateGenericDefinitions(SourceProductionContext context, symbol.AllInterfaces.ToImmutableArray(), allowEmpty: true, isGenericDefinition: true, - definitionFullName: GetFullyQualifiedTypeName(symbol)); + definitionFullName: GetFullyQualifiedTypeName(symbol), + invalidFields: ImmutableArray.Empty, + constructionPlan: ConstructionPlan.Empty); } return ExtractMessageCore(symbol, GetFullyQualifiedTypeName(symbol), manifest, allowEmpty, knownTypes, compilation, definitionFullName: string.Empty); @@ -692,6 +718,8 @@ private static MessageInfo ExtractMessageCore( string definitionFullName) { var fields = new List(); + var fieldSymbols = new List(); + var invalidFields = ImmutableArray.CreateBuilder(); foreach (var member in symbol.GetMembers().OfType()) { var fieldAttribute = member.GetAttributes() @@ -699,6 +727,22 @@ private static MessageInfo ExtractMessageCore( if (fieldAttribute == null || fieldAttribute.ConstructorArguments.Length != 1) continue; + // A static or getter-inaccessible [AkkaField] property can never be read by the + // generated Write path (`message.Property`) -- record it as invalid (AKKASG028) instead + // of emitting uncompilable code, and exclude it from both ordinary field extraction and + // constructor selection below. + if (member.IsStatic) + { + invalidFields.Add(new InvalidFieldInfo(member.Name, "is static; [AkkaField] requires an instance property")); + continue; + } + + if (member.GetMethod == null || !IsAccessibleFromGeneratedCode(member.GetMethod.DeclaredAccessibility)) + { + invalidFields.Add(new InvalidFieldInfo(member.Name, "has no accessible getter")); + continue; + } + var index = (int)fieldAttribute.ConstructorArguments[0].Value!; var isNullable = member.NullableAnnotation == NullableAnnotation.Annotated || IsNullableValueType(member.Type); var isEnvelopePayload = member.GetAttributes() @@ -717,8 +761,11 @@ private static MessageInfo ExtractMessageCore( mapping, isNullable, unionMembers: isEnvelopePayload ? default : unionMembers)); + fieldSymbols.Add(member); } + var constructionPlan = SelectConstructor(symbol, fields, fieldSymbols, compilation); + return new MessageInfo( symbol.Name, fullyQualifiedName, @@ -727,7 +774,152 @@ private static MessageInfo ExtractMessageCore( symbol.AllInterfaces.ToImmutableArray(), allowEmpty, isGenericDefinition: false, - definitionFullName: definitionFullName); + definitionFullName: definitionFullName, + invalidFields: invalidFields.ToImmutable(), + constructionPlan: constructionPlan); + } + + /// + /// Whether a member with this accessibility, declared on the message type, can be referenced + /// from the generated serializer partial class. The generated class is never nested inside the + /// message type and never derives from it, so only Public/Internal/ProtectedOrInternal (the + /// internal-or-protected union, satisfied by same-assembly access) are reachable -- Protected and + /// PrivateProtected both require a subtype relationship the generated code does not have. + /// + private static bool IsAccessibleFromGeneratedCode(Accessibility accessibility) + { + return accessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; + } + + /// + /// Selects the constructor used to reconstruct on deserialize and + /// plans how each valid [AkkaField] property is supplied: as a NAMED constructor argument (when + /// it maps to a parameter of the chosen constructor) or as an object-initializer assignment + /// (when it does not, provided it has an accessible 'set'/'init' accessor). See + /// . / are + /// parallel: fieldSymbols[i] is the property backing fields[i]. + /// + private static ConstructionPlan SelectConstructor( + INamedTypeSymbol symbol, + IReadOnlyList fields, + IReadOnlyList fieldSymbols, + Compilation compilation) + { + var candidates = new List<(IMethodSymbol Ctor, ImmutableArray Arguments, ImmutableArray UncoveredDefaulted, int DeclarationOrder)>(); + var declarationOrder = 0; + foreach (var ctor in symbol.InstanceConstructors) + { + var order = declarationOrder++; + if (!IsAccessibleFromGeneratedCode(ctor.DeclaredAccessibility)) + continue; + + var argumentsBuilder = ImmutableArray.CreateBuilder(); + var uncoveredDefaultedBuilder = ImmutableArray.CreateBuilder(); + var eligible = true; + foreach (var parameter in ctor.Parameters) + { + var matchIndex = MatchParameterToFieldIndex(parameter, fields, fieldSymbols, compilation); + if (matchIndex >= 0) + { + argumentsBuilder.Add(new ConstructorArgumentPlan(parameter.Name, fields[matchIndex].Name)); + continue; + } + + // A parameter without a default value MUST map to a field, or the constructor + // cannot reconstruct the type at all -- not eligible. A defaulted, unmapped + // parameter keeps the constructor eligible but is remembered for AKKASG027: its + // value silently resets to the default on every deserialize. + if (!parameter.HasExplicitDefaultValue) + { + eligible = false; + break; + } + + uncoveredDefaultedBuilder.Add(parameter.Name); + } + + if (!eligible) + continue; + + candidates.Add((ctor, argumentsBuilder.ToImmutable(), uncoveredDefaultedBuilder.ToImmutable(), order)); + } + + if (candidates.Count == 0) + { + return new ConstructionPlan( + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Create("no accessible constructor maps every non-default parameter to an [AkkaField] property by name with an assignable type")); + } + + // Most field-mapped parameters wins (fewer leftover properties needing initializer + // assignment); ties break on fewest total parameters, then declaration order, for a + // deterministic choice across identical-shaped candidates. + var chosen = candidates + .OrderByDescending(candidate => candidate.Arguments.Length) + .ThenBy(candidate => candidate.Ctor.Parameters.Length) + .ThenBy(candidate => candidate.DeclarationOrder) + .First(); + + var mappedFieldNames = new HashSet(chosen.Arguments.Select(argument => argument.FieldName), StringComparer.Ordinal); + var initializerFieldNames = ImmutableArray.CreateBuilder(); + var errors = ImmutableArray.CreateBuilder(); + for (var i = 0; i < fields.Count; i++) + { + var field = fields[i]; + if (mappedFieldNames.Contains(field.Name)) + continue; + + var property = fieldSymbols[i]; + var hasAccessibleSetter = property.SetMethod != null && IsAccessibleFromGeneratedCode(property.SetMethod.DeclaredAccessibility); + if (!hasAccessibleSetter) + { + errors.Add($"property '{field.Name}' is not covered by the selected constructor and has no accessible 'set' or 'init' accessor"); + continue; + } + + initializerFieldNames.Add(field.Name); + } + + return new ConstructionPlan(chosen.Arguments, initializerFieldNames.ToImmutable(), chosen.UncoveredDefaulted, errors.ToImmutable()); + } + + /// + /// Matches a constructor parameter to a field by name -- ordinal (case-sensitive) first, then a + /// UNIQUE case-insensitive match; an ambiguous case-insensitive match (more than one field name + /// differs only by case) counts as no match at all rather than guessing. A name match still + /// requires the property's type to be implicitly convertible to the parameter's type. Returns + /// the matched field's index into , or -1 for no match. + /// + private static int MatchParameterToFieldIndex( + IParameterSymbol parameter, + IReadOnlyList fields, + IReadOnlyList fieldSymbols, + Compilation compilation) + { + for (var i = 0; i < fields.Count; i++) + { + if (string.Equals(fields[i].Name, parameter.Name, StringComparison.Ordinal)) + return compilation.HasImplicitConversion(fieldSymbols[i].Type, parameter.Type) ? i : -1; + } + + var matchIndex = -1; + for (var i = 0; i < fields.Count; i++) + { + if (!string.Equals(fields[i].Name, parameter.Name, StringComparison.OrdinalIgnoreCase)) + continue; + + if (matchIndex >= 0) + return -1; + + matchIndex = i; + } + + if (matchIndex < 0) + return -1; + + return compilation.HasImplicitConversion(fieldSymbols[matchIndex].Type, parameter.Type) ? matchIndex : -1; } /// @@ -932,6 +1124,32 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer isValid = false; } + // Structural [AkkaField] problems found during extraction (static property, or a + // getter the generated Write path could not call): these properties never made it into + // message.Fields, so they cannot double-report through any of the checks below. + foreach (var invalidField in message.InvalidFields) + { + context.ReportDiagnostic(Diagnostic.Create(FieldPropertyNotAccessible, Location.None, invalidField.PropertyName, message.FullyQualifiedName, invalidField.Reason)); + isValid = false; + } + + // Read-side reconstruction: either no constructor could be selected, or the selected + // constructor leaves [AkkaField] properties uncovered with no accessible setter to fall + // back on -- both make deserialize impossible to generate. + foreach (var error in message.ConstructionPlan.Errors) + { + context.ReportDiagnostic(Diagnostic.Create(NoMatchingConstructor, Location.None, message.FullyQualifiedName, error)); + isValid = false; + } + + // Advisory only: the selected constructor still works (its defaulted parameter is simply + // never supplied), but the parameter's value silently reverts to its default on every + // deserialize because no [AkkaField] property feeds it. + foreach (var parameterName in message.ConstructionPlan.UncoveredDefaultedParameters) + { + context.ReportDiagnostic(Diagnostic.Create(ConstructorParameterNotCovered, Location.None, parameterName, message.FullyQualifiedName)); + } + foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Unsupported)) { context.ReportDiagnostic(Diagnostic.Create(UnsupportedFieldType, Location.None, field.Name, message.FullyQualifiedName, field.TypeFullName)); @@ -1404,7 +1622,11 @@ private static void GenerateReadMessage(StringBuilder sb, MessageInfo message, I sb.Append(" private ").Append(message.FullyQualifiedName).Append(" Read").Append(GetMessageMethodName(message)) .AppendLine("(ref global::MessagePack.MessagePackReader reader)"); sb.AppendLine(" {"); - sb.AppendLine(" var fieldCount = reader.ReadMapHeader();"); + // Generator-owned locals are prefixed "__" so they cannot collide with a per-field local + // (ToCamelCase(field.Name)/GetHasLocalName below), no matter what the [AkkaField] property + // is named -- including adversarial names like "FieldCount" or "EntryIndex" that would + // otherwise camel-case straight into these identifiers (CS0128/CS0136). + sb.AppendLine(" var __fieldCount = reader.ReadMapHeader();"); var alloc = new NameAlloc(); foreach (var field in message.Fields) { @@ -1414,10 +1636,10 @@ private static void GenerateReadMessage(StringBuilder sb, MessageInfo message, I sb.Append(" var ").Append(GetHasLocalName(field)).AppendLine(" = false;"); } - sb.AppendLine(" for (var entryIndex = 0; entryIndex < fieldCount; entryIndex++)"); + sb.AppendLine(" for (var __entryIndex = 0; __entryIndex < __fieldCount; __entryIndex++)"); sb.AppendLine(" {"); - sb.AppendLine(" var fieldId = reader.ReadInt32();"); - sb.AppendLine(" switch (fieldId)"); + sb.AppendLine(" var __fieldId = reader.ReadInt32();"); + sb.AppendLine(" switch (__fieldId)"); sb.AppendLine(" {"); foreach (var field in message.Fields) { @@ -1446,13 +1668,43 @@ private static void GenerateReadMessage(StringBuilder sb, MessageInfo message, I .Append(Escape(message.FullyQualifiedName)).AppendLine("].\");"); } - sb.Append(" return new ").Append(message.FullyQualifiedName).Append('(') - .Append(string.Join(", ", message.Fields.Select(GetConstructorArgument))) - .AppendLine(");"); + GenerateReadMessageConstruction(sb, message); sb.AppendLine(" }"); sb.AppendLine(); } + /// + /// Emits the final return new T(...) of a read method from : + /// NAMED arguments (escaped where the parameter name is a C# keyword, e.g. @event:) for + /// every constructor-mapped [AkkaField] property, followed by an object initializer for whatever + /// is left over. The plan stores field NAMES rather than references so it + /// stays correct across (formatter resolution can replace a + /// field's mapping without touching its name). + /// + private static void GenerateReadMessageConstruction(StringBuilder sb, MessageInfo message) + { + var fieldsByName = message.Fields.ToDictionary(field => field.Name, StringComparer.Ordinal); + var plan = message.ConstructionPlan; + + sb.Append(" return new ").Append(message.FullyQualifiedName).Append('('); + sb.Append(string.Join(", ", plan.Arguments.Select(argument => + EscapeIfKeyword(argument.ParameterName) + ": " + GetFieldValueExpression(fieldsByName[argument.FieldName])))); + sb.Append(')'); + + if (plan.InitializerFieldNames.Length > 0) + { + sb.Append(" { "); + sb.Append(string.Join(", ", plan.InitializerFieldNames.Select(name => + { + var field = fieldsByName[name]; + return EscapeIfKeyword(field.Name) + " = " + GetFieldValueExpression(field); + }))); + sb.Append(" }"); + } + + sb.AppendLine(";"); + } + // --------------------------------------------------------------------------------------------- // Union emission ([AkkaUnion] fields). // @@ -2482,12 +2734,18 @@ private static bool TryGetNullableValueType(ITypeSymbol type, out ITypeSymbol un return false; } + /// + /// "__has" prefix (not the field's own camelCase local, which lacks it): guarantees no collision + /// with an unrelated property's OWN value local under the pigeon-hole pairing this field name + /// with another property's name, for example fields "Foo" and "HasFoo" -- "Foo"'s has-guard is + /// "__hasFoo", distinct from "HasFoo"'s value local "hasFoo". + /// private static string GetHasLocalName(FieldInfo field) { - return "has" + field.Name; + return "__has" + field.Name; } - private static string GetConstructorArgument(FieldInfo field) + private static string GetFieldValueExpression(FieldInfo field) { var name = ToCamelCase(field.Name); return IsRequired(field) && IsReferenceLike(field) ? name + "!" : name; @@ -2629,7 +2887,19 @@ private static string ToCamelCase(string value) // which cannot be used as a local identifier. Escape with '@' (a runtime no-op). The escaped // form composes safely with the suffixes appended elsewhere ('@eventSize', '@eventBytes'): // '@' is legal on any identifier, keyword or not. - return SyntaxFacts.GetKeywordKind(name) == SyntaxKind.None ? name : "@" + name; + return EscapeIfKeyword(name); + } + + /// + /// '@'-escapes if it is a reserved C# keyword, a runtime no-op that + /// makes the text legal as an identifier. Shared by (per-field read + /// locals) and the constructor-argument label in : a + /// constructor parameter literally named event (matched to an [AkkaField] property named + /// Event) must emit the NAMED argument as @event:, not event:. + /// + private static string EscapeIfKeyword(string identifier) + { + return SyntaxFacts.GetKeywordKind(identifier) == SyntaxKind.None ? identifier : "@" + identifier; } private static string Escape(string value) @@ -2710,7 +2980,17 @@ public static KnownTypes From(Compilation compilation) private sealed class MessageInfo { - public MessageInfo(string simpleName, string fullyQualifiedName, string manifest, ImmutableArray fields, ImmutableArray protocols, bool allowEmpty, bool isGenericDefinition = false, string definitionFullName = "") + public MessageInfo( + string simpleName, + string fullyQualifiedName, + string manifest, + ImmutableArray fields, + ImmutableArray protocols, + bool allowEmpty, + ImmutableArray invalidFields, + ConstructionPlan constructionPlan, + bool isGenericDefinition = false, + string definitionFullName = "") { SimpleName = simpleName; FullyQualifiedName = fullyQualifiedName; @@ -2718,6 +2998,8 @@ public MessageInfo(string simpleName, string fullyQualifiedName, string manifest Fields = fields; Protocols = protocols; AllowEmpty = allowEmpty; + InvalidFields = invalidFields; + ConstructionPlan = constructionPlan; IsGenericDefinition = isGenericDefinition; DefinitionFullName = definitionFullName; } @@ -2729,6 +3011,19 @@ public MessageInfo(string simpleName, string fullyQualifiedName, string manifest public ImmutableArray Protocols { get; } public bool AllowEmpty { get; } + /// + /// [AkkaField] properties excluded from because they are structurally + /// unusable (static, or an inaccessible getter) -- see AKKASG028. Empty for a valid message. + /// + public ImmutableArray InvalidFields { get; } + + /// + /// How the read method reconstructs this type on deserialize: the chosen constructor's + /// NAMED-argument bindings plus any leftover object-initializer assignments, or the reasons + /// no plan could be built (AKKASG026/027). See . + /// + public ConstructionPlan ConstructionPlan { get; } + /// /// True for a generic [AkkaSerializable] DEFINITION (e.g. Wrapper<T>): /// a placeholder that is never serialized, never top-level, and never reachable -- it exists @@ -2744,10 +3039,82 @@ public MessageInfo(string simpleName, string fullyQualifiedName, string manifest /// public string DefinitionFullName { get; } + /// + /// Used by formatter resolution to swap in fields with a resolved . + /// is keyed by field NAME, not by + /// reference, so it stays valid across this substitution without needing to be rebuilt. + /// public MessageInfo WithFields(ImmutableArray fields) { - return new MessageInfo(SimpleName, FullyQualifiedName, Manifest, fields, Protocols, AllowEmpty, IsGenericDefinition, DefinitionFullName); + return new MessageInfo(SimpleName, FullyQualifiedName, Manifest, fields, Protocols, AllowEmpty, InvalidFields, ConstructionPlan, IsGenericDefinition, DefinitionFullName); + } + } + + /// + /// A single [AkkaField] property found unusable during extraction: static, or its getter + /// is not accessible to the generated code. See AKKASG028. + /// + private sealed class InvalidFieldInfo + { + public InvalidFieldInfo(string propertyName, string reason) + { + PropertyName = propertyName; + Reason = reason; } + + public string PropertyName { get; } + + /// Free-text reason, e.g. "is static; ..." or "has no accessible getter". + public string Reason { get; } + } + + /// + /// How a message's constructor is called on deserialize. supplies NAMED + /// constructor arguments (parameter name -> field name); + /// lists [AkkaField] properties assigned afterward via object initializer. Both are non-empty only + /// when ; otherwise explains what could not be satisfied + /// (AKKASG026). is advisory (AKKASG027) and can be + /// non-empty even when is true. + /// + private sealed class ConstructionPlan + { + public static readonly ConstructionPlan Empty = new( + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty); + + public ConstructionPlan( + ImmutableArray arguments, + ImmutableArray initializerFieldNames, + ImmutableArray uncoveredDefaultedParameters, + ImmutableArray errors) + { + Arguments = arguments; + InitializerFieldNames = initializerFieldNames; + UncoveredDefaultedParameters = uncoveredDefaultedParameters; + Errors = errors; + } + + public ImmutableArray Arguments { get; } + public ImmutableArray InitializerFieldNames { get; } + public ImmutableArray UncoveredDefaultedParameters { get; } + + /// Human-readable reasons construction is impossible; empty when valid. + public ImmutableArray Errors { get; } + } + + /// A single NAMED constructor argument: supplied from the field named . + private readonly struct ConstructorArgumentPlan + { + public ConstructorArgumentPlan(string parameterName, string fieldName) + { + ParameterName = parameterName; + FieldName = fieldName; + } + + public string ParameterName { get; } + public string FieldName { get; } } /// diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 2a2f6dab2d4..7bab3da0187 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -1265,6 +1265,262 @@ public sealed record Outer( diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); } + [Fact(DisplayName = "Generator should report AKKASG026 when no constructor maps every required parameter to an [AkkaField] property")] + public void Generator_should_report_AKKASG026_when_no_constructor_matches_fields() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 121101)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "no-ctor-v1")] + public sealed class NoMatchingCtorMessage : IProtocol + { + public NoMatchingCtorMessage(int notAField) + { + } + + [AkkaField(1)] public string Value { get; set; } = string.Empty; + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG026" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("NoMatchingCtorMessage", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should report AKKASG026 when a property not covered by the constructor has no accessible setter")] + public void Generator_should_report_AKKASG026_when_leftover_property_is_unsettable() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 121102)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "unsettable-v1")] + public sealed class UnsettableLeftoverMessage : IProtocol + { + public UnsettableLeftoverMessage(string a) + { + A = a; + } + + [AkkaField(1)] public string A { get; } + + [AkkaField(2)] public string B { get; } = string.Empty; + } + """; + + var diagnostics = RunGenerator(source); + + // "a" maps to "A" (unique case-insensitive match); "B" is left over with no setter at all. + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG026" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("'B'", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should report AKKASG026 when a constructor parameter ambiguously matches multiple fields case-insensitively")] + public void Generator_should_report_AKKASG026_when_case_insensitive_mapping_is_ambiguous() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 121103)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "ambiguous-v1")] + public sealed class AmbiguousMappingMessage : IProtocol + { + public AmbiguousMappingMessage(string id) + { + Id = id; + ID = id; + } + + [AkkaField(1)] public string Id { get; } + + [AkkaField(2)] public string ID { get; } + } + """; + + var diagnostics = RunGenerator(source); + + // "id" matches both "Id" and "ID" case-insensitively -- ambiguous, so it maps to neither; + // the parameter has no default value, so the (only) constructor is not eligible at all. + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG026" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("AmbiguousMappingMessage", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should report AKKASG027 when the selected constructor has a defaulted parameter not covered by any [AkkaField]")] + public void Generator_should_report_AKKASG027_when_defaulted_parameter_uncovered() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 121104)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "defaulted-param-v1")] + public sealed class DefaultedParameterMessage : IProtocol + { + public DefaultedParameterMessage(string id, int maxRetries = 3) + { + Id = id; + MaxRetries = maxRetries; + } + + [AkkaField(1)] public string Id { get; } + + public int MaxRetries { get; } + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG027" && + diagnostic.Severity == DiagnosticSeverity.Warning && + diagnostic.GetMessage(null).Contains("maxRetries", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("DefaultedParameterMessage", StringComparison.Ordinal)); + + // Advisory only: the constructor is still eligible (maxRetries simply keeps its default), so + // the type still generates and compiles cleanly. + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + } + + [Fact(DisplayName = "Generator should report AKKASG028 when an [AkkaField] property is static")] + public void Generator_should_report_AKKASG028_when_field_property_is_static() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 121105)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "static-field-v1")] + public sealed class StaticFieldMessage : IProtocol + { + [AkkaField(1)] public static string StaticValue { get; set; } = string.Empty; + + [AkkaField(2)] public string Value { get; set; } = string.Empty; + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG028" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("StaticValue", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("static", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should report AKKASG028 when an [AkkaField] property has no accessible getter")] + public void Generator_should_report_AKKASG028_when_field_property_is_private() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 121106)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "private-field-v1")] + public sealed class PrivateFieldMessage : IProtocol + { + public PrivateFieldMessage(string other) + { + Other = other; + } + + [AkkaField(1)] private string PrivateValue { get; set; } = string.Empty; + + [AkkaField(2)] public string Other { get; } + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG028" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("PrivateValue", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("accessible getter", StringComparison.Ordinal)); + } + private static ImmutableArray RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12); diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs index e12a23eba07..ffac8d700c1 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs @@ -518,6 +518,67 @@ public void Generated_serializer_should_write_multi_level_nested_value_objects_i reader.Consumed.Should().Be(bytes.Length); } + [Fact(DisplayName = "Generated serializer should not swap same-typed fields whose [AkkaField] index order differs from constructor parameter order")] + public void Generated_serializer_should_not_swap_same_typed_out_of_order_fields() + { + var message = new SwapRiskMessage(Second: "second-value", First: "first-value"); + + var roundTripped = RoundTrip(message); + + roundTripped.First.Should().Be("first-value"); + roundTripped.Second.Should().Be("second-value"); + roundTripped.Should().Be(message); + } + + [Fact(DisplayName = "Generated serializer should round-trip differently-typed fields whose [AkkaField] index order differs from constructor parameter order")] + public void Generated_serializer_should_round_trip_differently_typed_out_of_order_fields() + { + var message = new SwapRiskTypedMessage(Label: "widget", Quantity: 7); + + RoundTrip(message).Should().Be(message); + } + + [Fact(DisplayName = "Generated serializer should round-trip an init-only POCO reconstructed via a parameterless constructor and object initializer")] + public void Generated_serializer_should_round_trip_init_only_poco_with_parameterless_constructor() + { + var message = new InitOnlyPocoMessage { Name = "widget", Age = 7 }; + + var roundTripped = RoundTrip(message); + + roundTripped.Name.Should().Be("widget"); + roundTripped.Age.Should().Be(7); + } + + [Fact(DisplayName = "Generated serializer should round-trip a type reconstructed via a mix of constructor arguments and object-initializer assignments")] + public void Generated_serializer_should_round_trip_mixed_constructor_and_initializer_shape() + { + var message = new MixedShapeMessage("order-9", 3) { Notes = "handle with care" }; + + var roundTripped = RoundTrip(message); + + roundTripped.Id.Should().Be("order-9"); + roundTripped.Quantity.Should().Be(3); + roundTripped.Notes.Should().Be("handle with care"); + } + + [Fact(DisplayName = "Generated serializer should round-trip properties whose names collide with generator-owned read-method locals")] + public void Generated_serializer_should_round_trip_adversarial_property_names() + { + var message = new AdversarialNamesMessage(FieldCount: 3, EntryIndex: 9, Foo: "value", HasFoo: true); + + RoundTrip(message).Should().Be(message); + } + + [Fact(DisplayName = "Generated serializer should round-trip a keyword-named property bound to a lowercase-keyword constructor parameter")] + public void Generated_serializer_should_round_trip_keyword_named_property_and_constructor_parameter() + { + var message = new KeywordNamedMessage("something-happened"); + + var roundTripped = RoundTrip(message); + + roundTripped.Event.Should().Be("something-happened"); + } + private static DateTime ReadDateTime(ref MessagePackReader reader) { var arrayLength = reader.ReadArrayHeader(); @@ -794,3 +855,78 @@ public sealed record WarehouseLocation( [AkkaSerializable] public sealed record CountryInfo( [property: AkkaField(1)] string IsoCode); + +// ------------------------------------------------------------------------------------------- +// Named-argument, hybrid construction coverage (constructor selection replaced positional +// field-index-order emission -- see AkkaSerializerGenerator.SelectConstructor). +// ------------------------------------------------------------------------------------------- + +/// +/// [AkkaField] index order (Second=1... no, Second=2, First=1) is the REVERSE of the primary +/// constructor's declared parameter order (Second, First). Both fields are the same type +/// (string), so a positional emission in index order would compile but silently swap the two +/// values on every deserialize. +/// +[AkkaSerializable(Manifest = "swap-risk-v1")] +public sealed record SwapRiskMessage( + [property: AkkaField(2)] string Second, + [property: AkkaField(1)] string First) : IGeneratedTestProtocol; + +/// Same shape as , but the two out-of-order fields are +/// differently typed: a positional emission would not even compile (CS1503). +[AkkaSerializable(Manifest = "swap-risk-typed-v1")] +public sealed record SwapRiskTypedMessage( + [property: AkkaField(2)] string Label, + [property: AkkaField(1)] int Quantity) : IGeneratedTestProtocol; + +/// No declared constructor: the implicit parameterless constructor is used, and every +/// [AkkaField] property is assigned through an object initializer via its 'init' accessor. +[AkkaSerializable(Manifest = "init-only-poco-v1")] +public sealed class InitOnlyPocoMessage : IGeneratedTestProtocol +{ + [AkkaField(1)] public string Name { get; init; } = string.Empty; + [AkkaField(2)] public int Age { get; init; } +} + +/// Id/Quantity are supplied as NAMED constructor arguments; Notes is not a constructor +/// parameter at all and is assigned afterward through an object-initializer 'set'. +[AkkaSerializable(Manifest = "mixed-shape-v1")] +public sealed class MixedShapeMessage : IGeneratedTestProtocol +{ + public MixedShapeMessage(string id, int quantity) + { + Id = id; + Quantity = quantity; + } + + [AkkaField(1)] public string Id { get; } + [AkkaField(2)] public int Quantity { get; } + [AkkaField(3)] public string? Notes { get; set; } +} + +/// Property names that collide with the generator's OWN read-method locals +/// (__fieldCount/__entryIndex, and the has-guard for a differently-named field) before the C4 +/// hygiene fix: FieldCount/EntryIndex camel-case into "fieldCount"/"entryIndex", and Foo's +/// has-guard used to be "hasFoo" -- the same camelCase local a property literally named "HasFoo" +/// produces for itself. +[AkkaSerializable(Manifest = "adversarial-names-v1")] +public sealed record AdversarialNamesMessage( + [property: AkkaField(1)] int FieldCount, + [property: AkkaField(2)] int EntryIndex, + [property: AkkaField(3)] string Foo, + [property: AkkaField(4)] bool HasFoo) : IGeneratedTestProtocol; + +/// Regression for the pre-existing local-identifier keyword escaping (property "Event" +/// camel-cases to the reserved keyword "event"), extended to NAMED constructor arguments: the +/// hand-written constructor parameter is literally "event" (lowercase), matched to "Event" by a +/// unique case-insensitive lookup, and must be emitted as "@event:" to compile. +[AkkaSerializable(Manifest = "keyword-named-v1")] +public sealed class KeywordNamedMessage : IGeneratedTestProtocol +{ + public KeywordNamedMessage(string @event) + { + Event = @event; + } + + [AkkaField(1)] public string Event { get; } +} From 1b2ea011647ba1d39cfd101337d2ea272d18d5e0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 16 Jul 2026 00:36:43 +0000 Subject: [PATCH 10/12] Serialization.V2: serializer-level validation (AKKASG029, 031-034) - AKKASG029 (Error): concrete type implements the serializer's protocol interface but lacks [AkkaSerializable] -- previously vanished silently from the generated switches and only failed at runtime when first sent. Exempts interfaces/abstract classes (concrete subtypes are checked individually) and [AkkaSerializable]-marked generic definitions (governed by AKKASG022) - AKKASG031 (Error): same protocol interface bound by multiple serializer classes (was silent last-wins at runtime registration) - AKKASG032 (Error): serializer class must be partial, non-generic, and derive from AkkaSerializer (was a wall of raw CS errors in generated code) - AKKASG033 (Error): protocol type must be an interface (a class protocol generated a serializer with empty dispatch switches) - AKKASG034 (Error): closed-generic registration that neither implements the protocol nor is reachable from any top-level message (dead registration; reachable nested registrations remain legitimate) - No AKKASG030: compiler already rejects duplicate [AkkaSerializer] attributes via CS0579 even with different type args -- pinned by test Wires CompilationProvider into the terminal stage for the AKKASG029 scan; cached models already retained ISymbols (pre-existing), so a proper incremental-discipline pass is tracked as follow-up work. 162/162 V2 tests (16 new); Akka.Remote builds clean with no false positives on the Artery serializer. --- .../AkkaSerializerGenerator.cs | 322 ++++++++++- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 546 ++++++++++++++++++ 2 files changed, 865 insertions(+), 3 deletions(-) diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 53225b3851d..0b3bd5923de 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -29,6 +29,7 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator private const string FormatterAttributeFullName = "Akka.Serialization.V2.AkkaSerializerFormatterAttribute"; private const string FormatterInterfaceFullName = "Akka.Serialization.V2.IAkkaMessagePackFormatter`1"; private const string ExtendedActorSystemFullName = "Akka.Actor.ExtendedActorSystem"; + private const string AkkaSerializerBaseTypeFullName = "Akka.Serialization.V2.AkkaSerializer"; private static readonly DiagnosticDescriptor MissingSerializerName = new( "AKKASG001", @@ -255,6 +256,46 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor ProtocolMessageNotSerializable = new( + "AKKASG029", + "Protocol message type is not [AkkaSerializable]", + "Type '{0}' implements protocol '{1}' of serializer '{2}' but is not [AkkaSerializable]; it is invisible to the generated Manifest/Serialize/Deserialize switches and fails only at runtime, when it is first sent", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateProtocolBinding = new( + "AKKASG031", + "Protocol interface bound by multiple serializers", + "Protocol '{0}' is bound by multiple [AkkaSerializer] classes: {1}", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidSerializerShape = new( + "AKKASG032", + "Serializer class shape is invalid", + "[AkkaSerializer] class '{0}' {1}", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ProtocolTypeMustBeInterface = new( + "AKKASG033", + "Protocol type must be an interface", + "[AkkaSerializer<{1}>] class '{0}' specifies a protocol type that is not an interface; dispatch matches messages via AllInterfaces, so a non-interface protocol type silently generates a serializer with empty Manifest/Serialize/Deserialize switches", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ClosedGenericRegistrationNotInProtocol = new( + "AKKASG034", + "Registered closed generic type does not implement the serializer protocol", + "Closed generic construction '{0}' registered on serializer '{1}' does not implement protocol '{2}' and is not referenced by any [AkkaField] property of a message reachable from it; the registration has no effect", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { var serializers = context.SyntaxProvider @@ -273,8 +314,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static info => info != null) .Collect(); - context.RegisterSourceOutput(serializers.Combine(messages), static (ctx, pair) => + // Combined only at this final, terminal stage -- not stored in any cached IncrementalValueProvider + // node upstream of it -- so AKKASG029's whole-compilation scan (ValidateProtocolCoverage) can see + // every source-declared type. This necessarily costs incrementality for the WHOLE generator (the + // Compilation input changes on every edit anywhere in this project), not just that one check: there + // is no cheaper place to compute "does any type implement this protocol interface without + // [AkkaSerializable]" than the terminal stage that already has a Compilation in hand. + context.RegisterSourceOutput(serializers.Combine(messages).Combine(context.CompilationProvider), static (ctx, tuple) => { + var (pair, compilation) = tuple; + var duplicateSerializerIds = pair.Left .Where(s => s != null) .Cast() @@ -288,6 +337,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.ReportDiagnostic(Diagnostic.Create(DuplicateSerializerId, Location.None, duplicate.Key, duplicate.Value)); } + // Same computation as duplicateSerializerIds above, grouped on the protocol interface + // instead of the numeric id: two [AkkaSerializer] classes bound to the same protocol is + // silent last-wins at runtime registration today (AKKASG031). + var duplicateProtocolBindings = pair.Left + .Where(s => s != null) + .Cast() + .Where(s => !string.IsNullOrEmpty(s.ProtocolTypeFullName)) + .GroupBy(s => s.ProtocolTypeFullName, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .ToImmutableDictionary(group => group.Key, group => string.Join(", ", group.Select(s => s.ClassName)), StringComparer.Ordinal); + + foreach (var duplicate in duplicateProtocolBindings) + { + ctx.ReportDiagnostic(Diagnostic.Create(DuplicateProtocolBinding, Location.None, duplicate.Key, duplicate.Value)); + } + foreach (var serializer in pair.Left) { if (serializer == null) @@ -308,6 +373,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (duplicateSerializerIds.ContainsKey(serializer.SerializerId)) continue; + if (duplicateProtocolBindings.ContainsKey(serializer.ProtocolTypeFullName)) + continue; + + if (!ValidateSerializerShape(ctx, serializer)) + continue; + + if (!ValidateProtocolType(ctx, serializer)) + continue; + if (!ValidateFormatters(ctx, serializer)) continue; @@ -329,6 +403,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!ValidateGenericDefinitions(ctx, serializer, genericDefinitions)) continue; + if (!ValidateProtocolCoverage(ctx, serializer, compilation)) + continue; + var allMessages = declaredMessages .Where(message => !message.IsGenericDefinition) .Concat(serializer.ClosedGenericRegistrations @@ -346,6 +423,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!ValidateMessages(ctx, serializer, topLevelMessages, reachableMessages, resolvedMessagesByType)) continue; + if (!ValidateClosedGenericProtocolCoverage(ctx, serializer, reachableMessages)) + continue; + ctx.AddSource(serializer.ClassName + ".AkkaSerialization.g.cs", Generate(serializer, topLevelMessages, reachableMessages, resolvedMessagesByType)); } }); @@ -354,6 +434,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private static SerializerInfo? ExtractSerializer(GeneratorAttributeSyntaxContext context) { var symbol = (INamedTypeSymbol)context.TargetSymbol; + + // Reading only the first attribute is safe: AkkaSerializerAttribute declares + // AllowMultiple = false, and empirically (see AkkaSerializerGeneratorDiagnosticsSpec) + // the C# compiler enforces that against the OPEN generic attribute definition, not each + // closed construction -- [AkkaSerializer][AkkaSerializer] on the same class is + // rejected with CS0579 ("Duplicate 'AkkaSerializer<>' attribute") even though IA and IB + // differ, so at most one [AkkaSerializer] ever reaches this method. No AKKASG030 is + // needed for this case. var attribute = context.Attributes[0]; var compilation = context.SemanticModel.Compilation; string? name = null; @@ -386,7 +474,52 @@ public void Initialize(IncrementalGeneratorInitializationContext context) protocolTypeFullName, symbol.DeclaredAccessibility, formatters, - closedGenericRegistrations); + closedGenericRegistrations, + IsPartial(symbol), + symbol.IsGenericType, + DerivesFromAkkaSerializerBase(symbol, compilation)); + } + + /// + /// Whether EVERY syntax declaration of carries the 'partial' + /// modifier -- a class with a single non-partial declaration, or with any one part missing + /// 'partial', is already a compile error (CS0260) once the generator emits its own partial + /// declaration of the same class; AKKASG032 replaces that cryptic error with a direct one. + /// A declaring reference that is not a cannot occur here + /// in practice (the extraction pipeline only targets + /// nodes, and every partial part of one class shares the same declaration kind) and is + /// conservatively treated as satisfying the check rather than asserted against. + /// + private static bool IsPartial(INamedTypeSymbol symbol) + { + foreach (var reference in symbol.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is ClassDeclarationSyntax declaration && !declaration.Modifiers.Any(SyntaxKind.PartialKeyword)) + return false; + } + + return true; + } + + /// + /// Whether derives (directly or transitively) from + /// -- the generated overrides (Identifier, + /// Manifest, Serialize, Deserialize, SizeHint) require it as a base, + /// or they fail to compile as overrides (CS0115) against whatever base the class actually has. + /// + private static bool DerivesFromAkkaSerializerBase(INamedTypeSymbol symbol, Compilation compilation) + { + var akkaSerializerBaseType = compilation.GetTypeByMetadataName(AkkaSerializerBaseTypeFullName); + if (akkaSerializerBaseType == null) + return false; + + for (var baseType = symbol.BaseType; baseType != null; baseType = baseType.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(baseType, akkaSerializerBaseType)) + return true; + } + + return false; } /// @@ -566,6 +699,138 @@ private static FormatterCtorKind GetFormatterCtorKind(INamedTypeSymbol formatter return hasParameterlessCtor ? FormatterCtorKind.Parameterless : FormatterCtorKind.None; } + /// + /// Fires AKKASG032 for each way the [AkkaSerializer] class declaration itself is unusable as + /// a codegen target: not partial, not derived from the AkkaSerializer base class, or generic. + /// Today each of these produces a wall of raw CS errors (CS0260/CS0759/CS0115/CS0264) pointing + /// at the GENERATED file instead of the user's declaration; this replaces that with one direct + /// diagnostic per violated rule, still on the user's class. + /// + private static bool ValidateSerializerShape(SourceProductionContext context, SerializerInfo serializer) + { + var isValid = true; + + if (!serializer.IsPartial) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidSerializerShape, Location.None, serializer.ClassName, + "must be declared 'partial': the generator emits a second declaration of this class")); + isValid = false; + } + + if (!serializer.DerivesFromAkkaSerializerBase) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidSerializerShape, Location.None, serializer.ClassName, + "must derive from Akka.Serialization.V2.AkkaSerializer: the generated members (Identifier, Manifest, Serialize, Deserialize, SizeHint) are declared as overrides of that base")); + isValid = false; + } + + if (serializer.IsGeneric) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidSerializerShape, Location.None, serializer.ClassName, + "cannot be a generic type: the generator emits one concrete, closed partial class per [AkkaSerializer] declaration")); + isValid = false; + } + + return isValid; + } + + /// + /// Fires AKKASG033 when TProtocol in [AkkaSerializer<TProtocol>] is not an + /// interface. Top-level dispatch matches a message via message.Protocols, which is + /// populated from INamedTypeSymbol.AllInterfaces (see ) + /// -- a class or struct can never appear there, so a non-interface protocol type silently + /// produces a serializer whose Manifest/Serialize/Deserialize switches have no cases at all. + /// + private static bool ValidateProtocolType(SourceProductionContext context, SerializerInfo serializer) + { + if (serializer.ProtocolType == null || serializer.ProtocolType.TypeKind == TypeKind.Interface) + return true; + + context.ReportDiagnostic(Diagnostic.Create(ProtocolTypeMustBeInterface, Location.None, serializer.ClassName, serializer.ProtocolTypeFullName)); + return false; + } + + /// + /// Fires AKKASG029 when a named type declared IN THIS COMPILATION implements the serializer's + /// protocol interface but is not [AkkaSerializable]. Exempt: interfaces and abstract classes + /// (never concrete runtime message types -- their concrete subtypes are checked individually), + /// and [AkkaSerializable]-marked open generic definitions (governed entirely by AKKASG022's + /// registration machinery). An unmarked OPEN GENERIC definition that implements the protocol is + /// still an error here: with no [AkkaSerializable] on the definition, none of its closed + /// constructions could ever be registered with [AkkaSerializable<T>] in the first place. + /// A type this flags is invisible to the generated Manifest/Serialize/Deserialize switches + /// today and only fails at runtime, the first time it is sent. + /// + private static bool ValidateProtocolCoverage(SourceProductionContext context, SerializerInfo serializer, Compilation compilation) + { + if (serializer.ProtocolType == null) + return true; + + var knownTypes = KnownTypes.From(compilation); + if (knownTypes.SerializableAttribute == null) + return true; + + var isValid = true; + foreach (var candidate in GetSourceDeclaredTypes(compilation)) + { + if (candidate.TypeKind is not (TypeKind.Class or TypeKind.Struct)) + continue; + + if (candidate.IsAbstract) + continue; + + if (!candidate.AllInterfaces.Contains(serializer.ProtocolType, SymbolEqualityComparer.Default)) + continue; + + var isMarked = candidate.GetAttributes() + .Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute)); + if (isMarked) + continue; + + context.ReportDiagnostic(Diagnostic.Create(ProtocolMessageNotSerializable, Location.None, + GetFullyQualifiedTypeName(candidate), serializer.ProtocolTypeFullName, serializer.ClassName)); + isValid = false; + } + + return isValid; + } + + /// + /// Every named type declared in 's OWN source (never a referenced + /// assembly: is the assembly being compiled), recursively + /// including nested types. Used only by , transiently, + /// inside the terminal source-production callback -- never stored in a cached provider. + /// + private static IEnumerable GetSourceDeclaredTypes(Compilation compilation) + { + return GetSourceDeclaredTypes(compilation.Assembly.GlobalNamespace); + } + + private static IEnumerable GetSourceDeclaredTypes(INamespaceSymbol ns) + { + foreach (var type in ns.GetTypeMembers()) + { + foreach (var nested in GetSourceDeclaredTypesIncludingSelf(type)) + yield return nested; + } + + foreach (var nestedNamespace in ns.GetNamespaceMembers()) + { + foreach (var type in GetSourceDeclaredTypes(nestedNamespace)) + yield return type; + } + } + + private static IEnumerable GetSourceDeclaredTypesIncludingSelf(INamedTypeSymbol type) + { + yield return type; + foreach (var nested in type.GetTypeMembers()) + { + foreach (var descendant in GetSourceDeclaredTypesIncludingSelf(nested)) + yield return descendant; + } + } + private static bool ValidateFormatters(SourceProductionContext context, SerializerInfo serializer) { if (serializer.Formatters.IsDefaultOrEmpty) @@ -1206,6 +1471,42 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer return isValid; } + /// + /// Fires AKKASG034 when a valid [AkkaSerializable<T>] registration's construction + /// neither implements the serializer's protocol (so it can never become a top-level message) + /// nor is referenced by any [AkkaField] property of a message reachable from a top-level + /// message (so it can never be emitted as a nested Object field either, AKKASG023's mechanism). + /// Such a registration compiles clean today and simply does nothing: + /// never reaches it, so it gets no generated Write/Read/SizeOf methods at all. A construction + /// registered ONLY for nested-field use (legitimate; it need not implement the protocol) is + /// exempt as long as it is actually reachable. + /// + private static bool ValidateClosedGenericProtocolCoverage(SourceProductionContext context, SerializerInfo serializer, ImmutableArray reachableMessages) + { + if (serializer.ClosedGenericRegistrations.IsDefaultOrEmpty || serializer.ProtocolType == null) + return true; + + var reachableNames = new HashSet(reachableMessages.Select(message => message.FullyQualifiedName), StringComparer.Ordinal); + var isValid = true; + foreach (var registration in serializer.ClosedGenericRegistrations) + { + if (registration.Message == null) + continue; + + if (registration.Message.Protocols.Any(protocol => SymbolEqualityComparer.Default.Equals(protocol, serializer.ProtocolType))) + continue; + + if (reachableNames.Contains(registration.Message.FullyQualifiedName)) + continue; + + context.ReportDiagnostic(Diagnostic.Create(ClosedGenericRegistrationNotInProtocol, Location.None, + registration.TargetDisplayName, serializer.ClassName, serializer.ProtocolTypeFullName)); + isValid = false; + } + + return isValid; + } + private static bool ValidateUnionField( SourceProductionContext context, MessageInfo message, @@ -2919,7 +3220,10 @@ public SerializerInfo( string protocolTypeFullName, Accessibility declaredAccessibility, ImmutableArray formatters, - ImmutableArray closedGenericRegistrations) + ImmutableArray closedGenericRegistrations, + bool isPartial, + bool isGeneric, + bool derivesFromAkkaSerializerBase) { Namespace = ns; ClassName = className; @@ -2931,6 +3235,9 @@ public SerializerInfo( DeclaredAccessibility = declaredAccessibility; Formatters = formatters; ClosedGenericRegistrations = closedGenericRegistrations; + IsPartial = isPartial; + IsGeneric = isGeneric; + DerivesFromAkkaSerializerBase = derivesFromAkkaSerializerBase; } public string Namespace { get; } @@ -2943,6 +3250,15 @@ public SerializerInfo( public Accessibility DeclaredAccessibility { get; } public ImmutableArray Formatters { get; } public ImmutableArray ClosedGenericRegistrations { get; } + + /// Whether every syntax declaration of this class carries 'partial'. See AKKASG032. + public bool IsPartial { get; } + + /// Whether the serializer class itself is a generic type definition. See AKKASG032. + public bool IsGeneric { get; } + + /// Whether the class derives (directly or transitively) from Akka.Serialization.V2.AkkaSerializer. See AKKASG032. + public bool DerivesFromAkkaSerializerBase { get; } } private sealed class KnownTypes diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 7bab3da0187..384b4ef77e7 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -1521,6 +1521,552 @@ public PrivateFieldMessage(string other) diagnostic.GetMessage(null).Contains("accessible getter", StringComparison.Ordinal)); } + [Fact(DisplayName = "Generator should report AKKASG029 when a protocol message forgets [AkkaSerializable]")] + public void Generator_should_report_AKKASG029_when_protocol_message_missing_attribute() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130001)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; + + // Forgotten [AkkaSerializable]: silently invisible to the generated switches today. + public sealed record Forgotten(string Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG029" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("Forgotten", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("SampleSerializer", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should not report AKKASG029 for an abstract base implementing the protocol")] + public void Generator_should_not_report_AKKASG029_for_abstract_base() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130002)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + // Never a concrete runtime message type; its concrete subtypes are checked individually. + public abstract class AbstractBase : IProtocol + { + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "AKKASG029"); + } + + [Fact(DisplayName = "Generator should not report AKKASG029 for an [AkkaSerializable]-marked generic definition with a registration")] + public void Generator_should_not_report_AKKASG029_for_marked_generic_definition() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Payload) : IProtocol; + + [AkkaSerializer(Name = "sample", SerializerId = 130003)] + [AkkaSerializable>(Manifest = "wrapper-int-v1")] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + """; + + var diagnostics = RunGenerator(source); + + // Governed entirely by AKKASG022's registration machinery -- this compiles fully clean. + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + } + + [Fact(DisplayName = "Generator should report AKKASG029 for an unmarked generic definition implementing the protocol")] + public void Generator_should_report_AKKASG029_for_unmarked_generic_definition() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130004)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + // No [AkkaSerializable]: none of its closed constructions could ever be registered. + public sealed record UnmarkedWrapper(T Payload) : IProtocol; + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG029" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("UnmarkedWrapper", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator observes the compiler reject multiple [AkkaSerializer] attributes on one class (CS0579, no AKKASG030 needed)")] + public void Generator_observes_compiler_rejects_multiple_serializer_attributes() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocolA + { + } + + public interface IProtocolB + { + } + + [AkkaSerializer(Name = "sample-a", SerializerId = 130005)] + [AkkaSerializer(Name = "sample-b", SerializerId = 130006)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + """; + + var diagnostics = RunGenerator(source); + + // AkkaSerializerAttribute declares AllowMultiple = false; the C# compiler + // enforces that against the OPEN generic attribute definition, not each closed + // construction, so two [AkkaSerializer] attributes with DIFFERENT protocol types on + // one class are already rejected at the language level (CS0579) -- pinning this so a + // future compiler/language change that silently admits it does not go unnoticed. + diagnostics.Should().Contain(diagnostic => diagnostic.Id == "CS0579"); + } + + [Fact(DisplayName = "Generator should report AKKASG031 when two [AkkaSerializer] classes bind the same protocol")] + public void Generator_should_report_AKKASG031_when_protocol_bound_by_multiple_serializers() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample-a", SerializerId = 130007)] + public sealed partial class SerializerA : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializer(Name = "sample-b", SerializerId = 130008)] + public sealed partial class SerializerB : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG031" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("SerializerA", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("SerializerB", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should not report AKKASG031 when two [AkkaSerializer] classes bind different protocols")] + public void Generator_should_not_report_AKKASG031_for_distinct_protocols() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocolA + { + } + + public interface IProtocolB + { + } + + [AkkaSerializer(Name = "sample-a", SerializerId = 130009)] + public sealed partial class SerializerA : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializer(Name = "sample-b", SerializerId = 130010)] + public sealed partial class SerializerB : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-a-v1")] + public sealed record OuterA([property: AkkaField(1)] string Value) : IProtocolA; + + [AkkaSerializable(Manifest = "outer-b-v1")] + public sealed record OuterB([property: AkkaField(1)] string Value) : IProtocolB; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "AKKASG031"); + } + + [Fact(DisplayName = "Generator should report AKKASG032 when the [AkkaSerializer] class is not partial")] + public void Generator_should_report_AKKASG032_when_not_partial() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130011)] + public sealed class NotPartialSerializer : AkkaSerializer + { + public NotPartialSerializer(ExtendedActorSystem system) : base(system) + { + } + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG032" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("NotPartialSerializer", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("partial", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should report AKKASG032 when the [AkkaSerializer] class does not derive from AkkaSerializer")] + public void Generator_should_report_AKKASG032_when_wrong_base_class() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130012)] + public sealed partial class WrongBaseSerializer + { + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG032" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("WrongBaseSerializer", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("AkkaSerializer", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should report AKKASG032 when the [AkkaSerializer] class is generic")] + public void Generator_should_report_AKKASG032_when_generic() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130013)] + public sealed partial class GenericSerializer : AkkaSerializer + { + public GenericSerializer(ExtendedActorSystem system) : base(system) + { + } + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG032" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("GenericSerializer", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("generic", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should not report AKKASG032 for a well-formed [AkkaSerializer] class")] + public void Generator_should_not_report_AKKASG032_for_valid_shape() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130014)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "AKKASG032"); + } + + [Fact(DisplayName = "Generator should report AKKASG033 when the protocol type is not an interface")] + public void Generator_should_report_AKKASG033_when_protocol_type_not_interface() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public class ClassProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130015)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG033" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("SampleSerializer", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("ClassProtocol", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should not report AKKASG033 when the protocol type is an interface")] + public void Generator_should_not_report_AKKASG033_for_interface_protocol() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer(Name = "sample", SerializerId = 130016)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "AKKASG033"); + } + + [Fact(DisplayName = "Generator should report AKKASG034 when a closed generic registration neither implements the protocol nor is referenced anywhere")] + public void Generator_should_report_AKKASG034_when_registration_is_orphaned() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Payload); + + [AkkaSerializer(Name = "sample", SerializerId = 130017)] + [AkkaSerializable>(Manifest = "wrapper-int-v1")] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG034" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("Wrapper", StringComparison.Ordinal) && + diagnostic.GetMessage(null).Contains("SampleSerializer", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should not report AKKASG034 when a closed generic registration is reachable as a nested field")] + public void Generator_should_not_report_AKKASG034_when_registration_reachable_as_nested_field() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Payload); + + [AkkaSerializable] + public sealed record Payload([property: AkkaField(1)] string Value); + + [AkkaSerializer(Name = "sample", SerializerId = 130018)] + [AkkaSerializable>] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer( + [property: AkkaField(1)] Wrapper Inner) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "AKKASG034"); + } + + [Fact(DisplayName = "Generator should not report AKKASG034 when a closed generic registration implements the protocol")] + public void Generator_should_not_report_AKKASG034_when_registration_implements_protocol() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Payload) : IProtocol; + + [AkkaSerializer(Name = "sample", SerializerId = 130019)] + [AkkaSerializable>(Manifest = "wrapper-int-v1")] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + } + private static ImmutableArray RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12); From 5ea975e3060a7a5e2cee5af7909ade8992913e6b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 16 Jul 2026 00:59:58 +0000 Subject: [PATCH 11/12] Serialization.V2: attribute reshapes make illegal states unrepresentable - [AkkaSerializer] now requires (string name, int serializerId) ctor args; Name/SerializerId are get-only. Serializer identity is always explicit by design: source-generator ordering is nondeterministic and config-defined id collisions are invisible to the generator, so there is no safe default. AKKASG001/002 demoted to constant-validity checks (null/empty/whitespace name, non-positive id) - [AkkaUnion] ctor is now (Type first, params Type[] rest): an empty union set no longer compiles; the 'at least one member' half of AKKASG019 is deleted, the duplicate-member half remains (now with a pinning test). Union extraction rewritten to concatenate both ctor args - [AkkaSerializerFormatter] with TFormatter : IAkkaMessagePackFormatter: interface conformance is now compiler-enforced; AKKASG008's interface half and AKKASG011's null-target half deleted as unrepresentable. Kept: abstract-formatter check (no new() constraint -- ExtendedActorSystem-only ctors are legitimate), AKKASG010, AKKASG011 array/closed-generic halves - Migrated all usages: Artery control-message serializer (name/id/binding byte-identical -- wire compatible), benchmarks, 7 test files 161/161 V2 tests; Akka.Remote, Akka.Benchmarks, RemotePingPong build clean; API approval suite unaffected (V2 not covered) -- 18/18 pass. --- ...eneratedMessagePackSerializerBenchmarks.cs | 4 +- .../RealPayload/V2/RealBenchmarkMessages.cs | 2 +- .../Artery/ArteryControlMessageSerializer.cs | 9 +- .../AkkaSerializerGenerator.cs | 152 ++++++----- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 251 +++++++++--------- .../CollectionFieldSpec.cs | 2 +- .../FieldlessAndStructFieldSpec.cs | 7 +- .../FormatterEscapeHatchSpec.cs | 22 +- .../GeneratedClosedGenericSpec.cs | 2 +- .../GeneratedMessagePackSerializerSpec.cs | 2 +- .../GeneratedUnionSpec.cs | 2 +- .../Akka.Serialization.V2/AddressFormatter.cs | 4 +- src/core/Akka.Serialization.V2/Attributes.cs | 90 ++++--- .../IAkkaMessagePackFormatter.cs | 7 +- 14 files changed, 299 insertions(+), 257 deletions(-) diff --git a/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs b/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs index e7433b9a7b8..b7bf2b4d181 100644 --- a/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs +++ b/src/benchmark/Akka.Benchmarks/Serialization/GeneratedMessagePackSerializerBenchmarks.cs @@ -307,7 +307,7 @@ public interface IOrderBenchmarkProtocol { } -[AkkaSerializer(Name = "order-benchmark", SerializerId = 120001)] +[AkkaSerializer("order-benchmark", 120001)] public sealed partial class OrderBenchmarkSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -317,7 +317,7 @@ public interface IEnvelopeBenchmarkProtocol { } -[AkkaSerializer(Name = "envelope-benchmark", SerializerId = 120002)] +[AkkaSerializer("envelope-benchmark", 120002)] public sealed partial class EnvelopeBenchmarkSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); diff --git a/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs b/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs index f4a671ddcd9..215a3a04f0b 100644 --- a/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs +++ b/src/benchmark/RemotePingPong/RealPayload/V2/RealBenchmarkMessages.cs @@ -121,7 +121,7 @@ public sealed record Reading( /// range (0-40, see akka.conf) and the ids used elsewhere in this repo (e.g. Artery control's 23, /// the V2 test suite's ~120101-120202) to avoid any collision with a real Akka.NET serializer. /// - [AkkaSerializer(Name = "real-benchmark-v2", SerializerId = 987001)] + [AkkaSerializer("real-benchmark-v2", 987001)] internal sealed partial class RealBenchmarkSerializer : AkkaSerializer { /// diff --git a/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs b/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs index 0c900750bc9..689b0cca7d7 100644 --- a/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs +++ b/src/core/Akka.Remote/Artery/ArteryControlMessageSerializer.cs @@ -42,8 +42,9 @@ namespace Akka.Remote.Artery /// /// is a core Akka.Actor type this change may not annotate with /// [AkkaSerializable]; it is instead handled via the built-in - /// escape hatch (), which produces the identical - /// 4-element-array wire format the old hand-rolled WriteAddress/ReadAddress used. + /// escape hatch ([AkkaSerializerFormatter<Address, AddressFormatter>]), which + /// produces the identical 4-element-array wire format the old hand-rolled + /// WriteAddress/ReadAddress used. /// /// /// Class name and serializer identifier (23) are unchanged from the hand-rolled version, so the @@ -51,8 +52,8 @@ namespace Akka.Remote.Artery /// on , serialization-identifiers entry) requires no edits. /// /// - [AkkaSerializer(Name = "artery-control", SerializerId = 23)] - [AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] + [AkkaSerializer("artery-control", 23)] + [AkkaSerializerFormatter] internal sealed partial class ArteryControlMessageSerializer : AkkaSerializer { /// diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index 0b3bd5923de..d44d9eac9fd 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -26,23 +26,27 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator private const string EnvelopePayloadAttributeFullName = "Akka.Serialization.V2.AkkaEnvelopePayloadAttribute"; private const string UnionAttributeFullName = "Akka.Serialization.V2.AkkaUnionAttribute"; private const string GenericSerializableAttributeFullName = "Akka.Serialization.V2.AkkaSerializableAttribute`1"; - private const string FormatterAttributeFullName = "Akka.Serialization.V2.AkkaSerializerFormatterAttribute"; - private const string FormatterInterfaceFullName = "Akka.Serialization.V2.IAkkaMessagePackFormatter`1"; + private const string FormatterAttributeFullName = "Akka.Serialization.V2.AkkaSerializerFormatterAttribute`2"; private const string ExtendedActorSystemFullName = "Akka.Actor.ExtendedActorSystem"; private const string AkkaSerializerBaseTypeFullName = "Akka.Serialization.V2.AkkaSerializer"; - private static readonly DiagnosticDescriptor MissingSerializerName = new( + // AkkaSerializerAttribute(string name, int serializerId) requires both arguments at + // every call site -- there is no longer a way to OMIT Name or SerializerId, so AKKASG001/002 + // no longer guard "missing" registration. They still guard the argument VALUES: a caller can + // still write [AkkaSerializer(null!, 0)] or an empty/whitespace name or a non-positive id, + // and those remain compile-time errors from this generator. + private static readonly DiagnosticDescriptor InvalidSerializerName = new( "AKKASG001", - "Serializer name is required", - "[AkkaSerializer] class '{0}' must specify Name for explicit registration", + "Serializer name must be a non-empty string", + "[AkkaSerializer] class '{0}' specifies an invalid Name: it must not be null, empty, or consist only of whitespace", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); - private static readonly DiagnosticDescriptor MissingSerializerId = new( + private static readonly DiagnosticDescriptor InvalidSerializerId = new( "AKKASG002", - "Serializer id is required for POC generator", - "[AkkaSerializer] class '{0}' must specify SerializerId for the POC generator", + "Serializer id must be a positive integer", + "[AkkaSerializer] class '{0}' specifies SerializerId {1}, which must be a positive, non-zero integer unique within the actor system", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -87,10 +91,16 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + // The `where TFormatter : IAkkaMessagePackFormatter` constraint on + // AkkaSerializerFormatterAttribute now makes interface conformance a + // compile-time error at the attribute usage site, so this narrows to the one thing a generic + // constraint cannot express: TFormatter must not be abstract (an abstract type still satisfies + // the constraint, and there is deliberately no `new()` clause to rule it out, since a formatter + // with only an ExtendedActorSystem constructor is legitimate). private static readonly DiagnosticDescriptor InvalidFormatterType = new( "AKKASG008", - "Formatter type is invalid", - "Formatter '{0}' on serializer '{1}' must be a non-abstract, non-generic class implementing IAkkaMessagePackFormatter<{2}>", + "Formatter type must not be abstract", + "Formatter '{0}' on serializer '{1}' must not be abstract: it cannot be instantiated as the runtime IAkkaMessagePackFormatter<{2}>", "Akka.Serialization.V2", DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -327,7 +337,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var duplicateSerializerIds = pair.Left .Where(s => s != null) .Cast() - .Where(s => s.SerializerId != 0) + .Where(s => s.SerializerId > 0) .GroupBy(s => s.SerializerId) .Where(group => group.Count() > 1) .ToImmutableDictionary(group => group.Key, group => string.Join(", ", group.Select(s => s.ClassName))); @@ -360,13 +370,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (string.IsNullOrWhiteSpace(serializer.Name)) { - ctx.ReportDiagnostic(Diagnostic.Create(MissingSerializerName, Location.None, serializer.ClassName)); + ctx.ReportDiagnostic(Diagnostic.Create(InvalidSerializerName, Location.None, serializer.ClassName)); continue; } - if (serializer.SerializerId == 0) + if (serializer.SerializerId <= 0) { - ctx.ReportDiagnostic(Diagnostic.Create(MissingSerializerId, Location.None, serializer.ClassName)); + ctx.ReportDiagnostic(Diagnostic.Create(InvalidSerializerId, Location.None, serializer.ClassName, serializer.SerializerId)); continue; } @@ -447,11 +457,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) string? name = null; var serializerId = 0; - foreach (var argument in attribute.NamedArguments) + // AkkaSerializerAttribute(string name, int serializerId): both arguments are + // mandatory POSITIONAL constructor arguments now -- the attribute has no settable + // properties left, so `[AkkaSerializer(Name = "x", SerializerId = 1)]` named-property + // syntax cannot compile and NamedArguments can never carry either value. A length other + // than 2 cannot occur for a successfully-compiled use of this attribute. + if (attribute.ConstructorArguments.Length == 2) { - if (argument.Key == "Name" && argument.Value.Value is string value) - name = value; - else if (argument.Key == "SerializerId" && argument.Value.Value is int id) + name = attribute.ConstructorArguments[0].Value as string; + if (attribute.ConstructorArguments[1].Value is int id) serializerId = id; } @@ -459,9 +473,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var protocolTypeFullName = protocolType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? string.Empty; var formatterAttributeType = compilation.GetTypeByMetadataName(FormatterAttributeFullName); - var formatterInterfaceType = compilation.GetTypeByMetadataName(FormatterInterfaceFullName); var extendedActorSystemType = compilation.GetTypeByMetadataName(ExtendedActorSystemFullName); - var formatters = ExtractFormatters(symbol, formatterAttributeType, formatterInterfaceType, extendedActorSystemType); + var formatters = ExtractFormatters(symbol, formatterAttributeType, extendedActorSystemType); var closedGenericRegistrations = ExtractClosedGenericRegistrations(symbol, compilation); return new SerializerInfo( @@ -610,14 +623,17 @@ private static bool IsFullyClosed(INamedTypeSymbol type) private static ImmutableArray ExtractFormatters( INamedTypeSymbol symbol, INamedTypeSymbol? formatterAttributeType, - INamedTypeSymbol? formatterInterfaceType, INamedTypeSymbol? extendedActorSystemType) { if (formatterAttributeType == null) return ImmutableArray.Empty; + // AkkaSerializerFormatterAttribute where TFormatter : + // IAkkaMessagePackFormatter -- a constructed generic attribute, so matching + // requires comparing against OriginalDefinition (the same pattern used for + // AkkaSerializableAttribute in ExtractClosedGenericRegistrations). var formatterAttributes = symbol.GetAttributes() - .Where(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, formatterAttributeType)) + .Where(attr => attr.AttributeClass is { IsGenericType: true } ac && SymbolEqualityComparer.Default.Equals(ac.OriginalDefinition, formatterAttributeType)) .ToImmutableArray(); if (formatterAttributes.IsEmpty) @@ -626,37 +642,39 @@ private static ImmutableArray ExtractFormatters( var builder = ImmutableArray.CreateBuilder(formatterAttributes.Length); foreach (var attribute in formatterAttributes) { - if (attribute.ConstructorArguments.Length != 2) - continue; - - // Never silently drop a registration: malformed arguments (null, or something that is - // not a type at all) are recorded as invalid entries so a diagnostic fires instead of - // the registration silently doing nothing. - var targetTypeSymbol = attribute.ConstructorArguments[0].Value as ITypeSymbol; - var formatterTypeSymbol = attribute.ConstructorArguments[1].Value as ITypeSymbol; - - // Formatter targets must be plain named types: arrays are not INamedTypeSymbol, and - // generic targets (open or closed) would collide on the arity-less fully-qualified - // name used for field matching. Null/non-type targets are equally unsupported. - // All of these are recorded with IsTargetSupported = false so AKKASG011 fires. + // TTarget and TFormatter come from the constructed attribute's own type arguments, not + // ConstructorArguments -- there is no constructor argument carrying either type + // anymore. Both slots are always present for a successfully-compiled two-arity + // construction: neither a null target nor an unbound generic target/formatter can be + // written here (the compiler rejects both at the attribute usage site), so unlike the + // former Type-typed constructor arguments, these can never be "missing" or "not a + // type" -- AKKASG011's former null-target check is unreachable and has been removed. + var targetTypeSymbol = attribute.AttributeClass!.TypeArguments[0]; + var formatterTypeSymbol = attribute.AttributeClass!.TypeArguments[1]; + + // Formatter targets must still be plain named types: arrays are not INamedTypeSymbol, + // and CLOSED generic targets (e.g. List) -- now directly expressible as TTarget -- + // would still collide on the arity-less fully-qualified name used for field matching. + // Both remain recorded with IsTargetSupported = false so AKKASG011 fires. var targetNamedType = targetTypeSymbol as INamedTypeSymbol; var isTargetSupported = targetNamedType is { IsGenericType: false }; var targetTypeFullName = isTargetSupported ? GetFullyQualifiedTypeName(targetNamedType!) - : targetTypeSymbol?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; + : targetTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var formatterNamedType = formatterTypeSymbol as INamedTypeSymbol; var formatterTypeFullName = formatterNamedType != null ? GetFullyQualifiedTypeName(formatterNamedType) - : formatterTypeSymbol?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; + : formatterTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var implementsInterface = isTargetSupported && - formatterInterfaceType != null && - formatterNamedType is { TypeKind: TypeKind.Class, IsAbstract: false, IsGenericType: false } && - formatterNamedType.AllInterfaces.Any(candidate => - SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, formatterInterfaceType) && - candidate.TypeArguments.Length == 1 && - SymbolEqualityComparer.Default.Equals(candidate.TypeArguments[0], targetTypeSymbol)); + // The `where TFormatter : IAkkaMessagePackFormatter` constraint is enforced by + // the compiler at the attribute usage site, so AKKASG008's former interface-conformance + // check can never fire here and has been removed. A generic constraint cannot express + // "and not abstract", though: an abstract TFormatter still satisfies the constraint + // (it has no `new()` clause to rule that out either -- formatters with an + // ExtendedActorSystem-only constructor are legitimate), so AKKASG008 now guards + // abstractness alone. + var isAbstract = formatterNamedType?.IsAbstract ?? false; var ctorKind = formatterNamedType != null ? GetFormatterCtorKind(formatterNamedType, extendedActorSystemType) @@ -664,9 +682,9 @@ private static ImmutableArray ExtractFormatters( builder.Add(new FormatterInfo( targetTypeFullName, - targetTypeSymbol?.IsValueType ?? false, + targetTypeSymbol.IsValueType, formatterTypeFullName, - implementsInterface, + isAbstract, ctorKind, isTargetSupported)); } @@ -846,7 +864,7 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ continue; } - if (!formatter.ImplementsInterface) + if (formatter.IsAbstract) { context.ReportDiagnostic(Diagnostic.Create(InvalidFormatterType, Location.None, formatter.FormatterTypeFullName, serializer.ClassName, formatter.TargetTypeFullName)); isValid = false; @@ -1222,12 +1240,22 @@ private static ImmutableArray ExtractUnionMembers( .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.UnionAttribute)); } - if (unionAttribute == null || unionAttribute.ConstructorArguments.Length != 1) + // AkkaUnionAttribute(Type first, params Type[] rest): TWO constructor arguments now, not + // one -- [0] is the mandatory `first` member, [1] is the `params` array holding the rest. + // The full declared member set is the concatenation of both; reading only + // ConstructorArguments[0].Values (the pre-Seed-2 shape, when the whole set arrived as a + // single `params Type[] memberTypes` array) would silently see only `first` and drop every + // other declared member. + if (unionAttribute == null || unionAttribute.ConstructorArguments.Length != 2) return ImmutableArray.Empty; hasUnionAttribute = true; - var arguments = unionAttribute.ConstructorArguments[0].Values; - var builder = ImmutableArray.CreateBuilder(arguments.Length); + var restArguments = unionAttribute.ConstructorArguments[1].Values; + var arguments = ImmutableArray.CreateBuilder(1 + restArguments.Length); + arguments.Add(unionAttribute.ConstructorArguments[0]); + arguments.AddRange(restArguments); + + var builder = ImmutableArray.CreateBuilder(arguments.Count); foreach (var argument in arguments) { if (argument.Value is not INamedTypeSymbol memberType || memberType.IsUnboundGenericType) @@ -1515,12 +1543,10 @@ private static bool ValidateUnionField( { var isValid = true; - if (field.UnionMembers.Length == 0) - { - context.ReportDiagnostic(Diagnostic.Create(InvalidUnionMemberSet, Location.None, field.Name, message.FullyQualifiedName, "at least one member type is required")); - return false; - } - + // AkkaUnionAttribute(Type first, params Type[] rest) makes an empty member set + // unrepresentable: `first` is a mandatory constructor argument, so [AkkaUnion()] does not + // compile and field.UnionMembers can never be empty here. The "at least one member type is + // required" half of AKKASG019 that used to guard this is gone along with it. foreach (var duplicate in field.UnionMembers .GroupBy(member => member.TypeFullName, StringComparer.Ordinal) .Where(group => group.Count() > 1)) @@ -3550,18 +3576,18 @@ public TypeMapping AsCollectionElement(string declaredTypeName, bool isNullable) /// /// A serializer-scoped hand-written formatter registration extracted from - /// [AkkaSerializerFormatter(typeof(TTarget), typeof(TFormatter))]. Carries only + /// [AkkaSerializerFormatter<TTarget, TFormatter>]. Carries only /// strings/bools/enums (no references) so it stays cheap to hold across /// incremental generator passes. /// private sealed class FormatterInfo { - public FormatterInfo(string targetTypeFullName, bool isTargetValueType, string formatterTypeFullName, bool implementsInterface, FormatterCtorKind ctorKind, bool isTargetSupported) + public FormatterInfo(string targetTypeFullName, bool isTargetValueType, string formatterTypeFullName, bool isAbstract, FormatterCtorKind ctorKind, bool isTargetSupported) { TargetTypeFullName = targetTypeFullName; IsTargetValueType = isTargetValueType; FormatterTypeFullName = formatterTypeFullName; - ImplementsInterface = implementsInterface; + IsAbstract = isAbstract; CtorKind = ctorKind; IsTargetSupported = isTargetSupported; } @@ -3569,7 +3595,13 @@ public FormatterInfo(string targetTypeFullName, bool isTargetValueType, string f public string TargetTypeFullName { get; } public bool IsTargetValueType { get; } public string FormatterTypeFullName { get; } - public bool ImplementsInterface { get; } + + /// + /// Whether TFormatter is an abstract type. The `where TFormatter : + /// IAkkaMessagePackFormatter<TTarget>` constraint does not rule this out (there is no + /// `new()` clause), so it is checked here instead (AKKASG008). + /// + public bool IsAbstract { get; } public FormatterCtorKind CtorKind { get; } public bool IsTargetSupported { get; } } diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 384b4ef77e7..2441d67ab95 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -37,7 +37,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120501)] + [AkkaSerializer("sample", 120501)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -73,7 +73,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120502)] + [AkkaSerializer("sample", 120502)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -98,41 +98,12 @@ public sealed record Inner([property: AkkaField(1)] string Value); diagnostic.Should().NotBeNull(); } - [Fact(DisplayName = "Generator should report AKKASG008 when a registered formatter does not implement IAkkaMessagePackFormatter")] - public void Generator_should_report_AKKASG008_when_formatter_does_not_implement_interface() - { - const string source = """ - #nullable enable - using Akka.Actor; - using Akka.Serialization.V2; - - namespace DiagnosticSample; - - public interface IProtocol - { - } - - public sealed record Foreign(string Value); - - public sealed class NotAFormatter - { - } - - [AkkaSerializer(Name = "sample", SerializerId = 120601)] - [AkkaSerializerFormatter(typeof(Foreign), typeof(NotAFormatter))] - public sealed partial class SampleSerializer : AkkaSerializer - { - public static partial SerializerRegistration CreateRegistration(); - } - - [AkkaSerializable(Manifest = "outer-v1")] - public sealed record Outer([property: AkkaField(1)] Foreign Value) : IProtocol; - """; - - var diagnostics = RunGenerator(source); - - diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG008" && diagnostic.Severity == DiagnosticSeverity.Error); - } + // "Generator should report AKKASG008 when a registered formatter does not implement + // IAkkaMessagePackFormatter" was deleted here: with AkkaSerializerFormatterAttribute constrained `where TFormatter : IAkkaMessagePackFormatter`, a formatter + // that does not implement the interface for the target type is now a compile-time error + // (CS0311) at the attribute usage site -- [AkkaSerializerFormatter] + // cannot compile, so the generator diagnostic this test asserted on can never fire. [Fact(DisplayName = "Generator should report AKKASG008 when a registered formatter is abstract")] public void Generator_should_report_AKKASG008_when_formatter_is_abstract() @@ -158,8 +129,8 @@ public abstract class AbstractFormatter : IAkkaMessagePackFormatter public abstract int SizeOf(Foreign value); } - [AkkaSerializer(Name = "sample", SerializerId = 120602)] - [AkkaSerializerFormatter(typeof(Foreign), typeof(AbstractFormatter))] + [AkkaSerializer("sample", 120602)] + [AkkaSerializerFormatter] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -205,9 +176,9 @@ public sealed class SecondFormatter : IAkkaMessagePackFormatter public int SizeOf(Foreign value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120603)] - [AkkaSerializerFormatter(typeof(Foreign), typeof(FirstFormatter))] - [AkkaSerializerFormatter(typeof(Foreign), typeof(SecondFormatter))] + [AkkaSerializer("sample", 120603)] + [AkkaSerializerFormatter] + [AkkaSerializerFormatter] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -250,8 +221,8 @@ public NoUsableCtorFormatter(string unused) public int SizeOf(Foreign value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120604)] - [AkkaSerializerFormatter(typeof(Foreign), typeof(NoUsableCtorFormatter))] + [AkkaSerializer("sample", 120604)] + [AkkaSerializerFormatter] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -290,8 +261,8 @@ public sealed class ForeignFormatter : IAkkaMessagePackFormatter public int SizeOf(Foreign value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120605)] - [AkkaSerializerFormatter(typeof(Foreign), typeof(ForeignFormatter))] + [AkkaSerializer("sample", 120605)] + [AkkaSerializerFormatter] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -329,8 +300,8 @@ public sealed class IntArrayFormatter : IAkkaMessagePackFormatter public int SizeOf(int[] value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120606)] - [AkkaSerializerFormatter(typeof(int[]), typeof(IntArrayFormatter))] + [AkkaSerializer("sample", 120606)] + [AkkaSerializerFormatter] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -368,8 +339,8 @@ public sealed class IntListFormatter : IAkkaMessagePackFormatter> public int SizeOf(List value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120607)] - [AkkaSerializerFormatter(typeof(List), typeof(IntListFormatter))] + [AkkaSerializer("sample", 120607)] + [AkkaSerializerFormatter, IntListFormatter>] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -414,8 +385,8 @@ public sealed class ResultFormatter : IAkkaMessagePackFormatter public int SizeOf(Result value) => Akka.Serialization.SerializerV2.UnknownSize; } - [AkkaSerializer(Name = "sample", SerializerId = 120608)] - [AkkaSerializerFormatter(typeof(Result), typeof(ResultFormatter))] + [AkkaSerializer("sample", 120608)] + [AkkaSerializerFormatter] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -434,35 +405,12 @@ public sealed record Outer([property: AkkaField(1)] Result Value) : IProtoc diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "CS1503"); } - [Fact(DisplayName = "Generator should report AKKASG011 when a formatter target argument is null")] - public void Generator_should_report_AKKASG011_when_formatter_target_argument_is_null() - { - const string source = """ - #nullable enable - using Akka.Actor; - using Akka.Serialization.V2; - - namespace DiagnosticSample; - - public interface IProtocol - { - } - - [AkkaSerializer(Name = "sample", SerializerId = 120609)] - [AkkaSerializerFormatter(null!, typeof(AddressFormatter))] - public sealed partial class SampleSerializer : AkkaSerializer - { - public static partial SerializerRegistration CreateRegistration(); - } - - [AkkaSerializable(Manifest = "outer-v1")] - public sealed record Outer([property: AkkaField(1)] string Value) : IProtocol; - """; - - var diagnostics = RunGenerator(source); - - diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG011" && diagnostic.Severity == DiagnosticSeverity.Error); - } + // "Generator should report AKKASG011 when a formatter target argument is null" was deleted + // here: TTarget is now a generic type argument of AkkaSerializerFormatterAttribute, and a type argument can never be null -- there is no syntax to write "no type" + // where a type argument is expected, so [AkkaSerializerFormatter] cannot even be + // written, let alone compile. AKKASG011's former null-target check is unreachable and was + // removed from ExtractFormatters along with it. [Fact(DisplayName = "Generator should still report AKKASG004 for a fieldless message that does not opt into AllowEmpty")] public void Generator_should_report_AKKASG004_for_fieldless_message_without_AllowEmpty() @@ -478,7 +426,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120701)] + [AkkaSerializer("sample", 120701)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -513,7 +461,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120703)] + [AkkaSerializer("sample", 120703)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -543,7 +491,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120702)] + [AkkaSerializer("sample", 120702)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -580,7 +528,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120704)] + [AkkaSerializer("sample", 120704)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -613,7 +561,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 120901)] + [AkkaSerializer("sample", 120901)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -659,13 +607,13 @@ public interface IProtocolB { } - [AkkaSerializer(Name = "sample-a", SerializerId = 120902)] + [AkkaSerializer("sample-a", 120902)] public sealed partial class SampleSerializerA : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } - [AkkaSerializer(Name = "sample-b", SerializerId = 120903)] + [AkkaSerializer("sample-b", 120903)] public sealed partial class SampleSerializerB : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -702,13 +650,13 @@ public interface IProtocolB { } - [AkkaSerializer(Name = "sample-a", SerializerId = 120904)] + [AkkaSerializer("sample-a", 120904)] public sealed partial class SampleSerializerA : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } - [AkkaSerializer(Name = "sample-b", SerializerId = 120904)] + [AkkaSerializer("sample-b", 120904)] public sealed partial class SampleSerializerB : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -748,7 +696,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121001)] + [AkkaSerializer("sample", 121001)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -793,7 +741,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121002)] + [AkkaSerializer("sample", 121002)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -827,7 +775,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121003)] + [AkkaSerializer("sample", 121003)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -860,7 +808,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121004)] + [AkkaSerializer("sample", 121004)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -897,7 +845,7 @@ public enum LongStatus : long Big = long.MaxValue } - [AkkaSerializer(Name = "sample", SerializerId = 121005)] + [AkkaSerializer("sample", 121005)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -939,7 +887,7 @@ public enum UIntStatus : uint Big = uint.MaxValue } - [AkkaSerializer(Name = "sample", SerializerId = 121006)] + [AkkaSerializer("sample", 121006)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -987,7 +935,7 @@ public enum ShortStatus : short B = 1 } - [AkkaSerializer(Name = "sample", SerializerId = 121007)] + [AkkaSerializer("sample", 121007)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1026,7 +974,7 @@ public interface IEvent public sealed record NotSerializable(string Value) : IEvent; - [AkkaSerializer(Name = "sample", SerializerId = 120701)] + [AkkaSerializer("sample", 120701)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1063,7 +1011,7 @@ public interface IEvent [AkkaSerializable] public sealed record ManifestlessMember([property: AkkaField(1)] string Value) : IEvent; - [AkkaSerializer(Name = "sample", SerializerId = 120702)] + [AkkaSerializer("sample", 120702)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1100,7 +1048,7 @@ public interface IEvent [AkkaSerializable(Manifest = "unrelated-v1")] public sealed record Unrelated([property: AkkaField(1)] string Value); - [AkkaSerializer(Name = "sample", SerializerId = 120703)] + [AkkaSerializer("sample", 120703)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1116,6 +1064,51 @@ public sealed record Outer( diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG018" && diagnostic.Severity == DiagnosticSeverity.Error); } + [Fact(DisplayName = "Generator should report AKKASG019 when a union member type is declared more than once")] + public void Generator_should_report_AKKASG019_when_union_member_declared_twice() + { + // AkkaUnionAttribute(Type first, params Type[] rest) makes an EMPTY member set + // unrepresentable (the "at least one member type is required" half of AKKASG019 was + // removed along with its impossible-to-write test), but a REPEATED member is still fully + // representable -- [AkkaUnion(typeof(Repeated), typeof(Repeated))] compiles fine -- so this + // half of the check remains. + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + public interface IEvent + { + } + + [AkkaSerializable(Manifest = "repeated-v1")] + public sealed record Repeated([property: AkkaField(1)] string Value) : IEvent; + + [AkkaSerializer("sample", 120704)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer( + [property: AkkaField(1), AkkaUnion(typeof(Repeated), typeof(Repeated))] IEvent Event) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG019" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("declared more than once", StringComparison.Ordinal)); + } + [Fact(DisplayName = "Generator should report AKKASG020 when a closed generic registration target is not generic")] public void Generator_should_report_AKKASG020_when_closed_generic_registration_target_is_not_generic() { @@ -1133,7 +1126,7 @@ public interface IProtocol [AkkaSerializable(Manifest = "plain-v1")] public sealed record Plain([property: AkkaField(1)] string Value) : IProtocol; - [AkkaSerializer(Name = "sample", SerializerId = 120705)] + [AkkaSerializer("sample", 120705)] [AkkaSerializable(Manifest = "plain-again-v1")] public sealed partial class SampleSerializer : AkkaSerializer { @@ -1165,7 +1158,7 @@ public sealed record Wrapper( [property: AkkaField(1)] string Id, [property: AkkaField(2)] T Payload) : IProtocol; - [AkkaSerializer(Name = "sample", SerializerId = 120706)] + [AkkaSerializer("sample", 120706)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1199,7 +1192,7 @@ public sealed record Wrapper( [AkkaSerializable] public sealed record Payload([property: AkkaField(1)] string Value); - [AkkaSerializer(Name = "sample", SerializerId = 120707)] + [AkkaSerializer("sample", 120707)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1240,7 +1233,7 @@ public record OpenMember([property: AkkaField(1)] string Value) : IEvent; [AkkaSerializable(Manifest = "sealed-v1")] public sealed record SealedMember([property: AkkaField(1)] string Value) : IEvent; - [AkkaSerializer(Name = "sample", SerializerId = 120708)] + [AkkaSerializer("sample", 120708)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1279,7 +1272,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121101)] + [AkkaSerializer("sample", 121101)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1318,7 +1311,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121102)] + [AkkaSerializer("sample", 121102)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1361,7 +1354,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121103)] + [AkkaSerializer("sample", 121103)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1406,7 +1399,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121104)] + [AkkaSerializer("sample", 121104)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1454,7 +1447,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121105)] + [AkkaSerializer("sample", 121105)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1492,7 +1485,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 121106)] + [AkkaSerializer("sample", 121106)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1535,7 +1528,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130001)] + [AkkaSerializer("sample", 130001)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1571,7 +1564,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130002)] + [AkkaSerializer("sample", 130002)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1610,7 +1603,7 @@ public sealed record Wrapper( [property: AkkaField(1)] string Id, [property: AkkaField(2)] T Payload) : IProtocol; - [AkkaSerializer(Name = "sample", SerializerId = 130003)] + [AkkaSerializer("sample", 130003)] [AkkaSerializable>(Manifest = "wrapper-int-v1")] public sealed partial class SampleSerializer : AkkaSerializer { @@ -1638,7 +1631,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130004)] + [AkkaSerializer("sample", 130004)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1677,8 +1670,8 @@ public interface IProtocolB { } - [AkkaSerializer(Name = "sample-a", SerializerId = 130005)] - [AkkaSerializer(Name = "sample-b", SerializerId = 130006)] + [AkkaSerializer("sample-a", 130005)] + [AkkaSerializer("sample-b", 130006)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1709,13 +1702,13 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample-a", SerializerId = 130007)] + [AkkaSerializer("sample-a", 130007)] public sealed partial class SerializerA : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } - [AkkaSerializer(Name = "sample-b", SerializerId = 130008)] + [AkkaSerializer("sample-b", 130008)] public sealed partial class SerializerB : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1752,13 +1745,13 @@ public interface IProtocolB { } - [AkkaSerializer(Name = "sample-a", SerializerId = 130009)] + [AkkaSerializer("sample-a", 130009)] public sealed partial class SerializerA : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); } - [AkkaSerializer(Name = "sample-b", SerializerId = 130010)] + [AkkaSerializer("sample-b", 130010)] public sealed partial class SerializerB : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1790,7 +1783,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130011)] + [AkkaSerializer("sample", 130011)] public sealed class NotPartialSerializer : AkkaSerializer { public NotPartialSerializer(ExtendedActorSystem system) : base(system) @@ -1822,7 +1815,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130012)] + [AkkaSerializer("sample", 130012)] public sealed partial class WrongBaseSerializer { } @@ -1851,7 +1844,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130013)] + [AkkaSerializer("sample", 130013)] public sealed partial class GenericSerializer : AkkaSerializer { public GenericSerializer(ExtendedActorSystem system) : base(system) @@ -1883,7 +1876,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130014)] + [AkkaSerializer("sample", 130014)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1912,7 +1905,7 @@ public class ClassProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130015)] + [AkkaSerializer("sample", 130015)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1942,7 +1935,7 @@ public interface IProtocol { } - [AkkaSerializer(Name = "sample", SerializerId = 130016)] + [AkkaSerializer("sample", 130016)] public sealed partial class SampleSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -1976,7 +1969,7 @@ public sealed record Wrapper( [property: AkkaField(1)] string Id, [property: AkkaField(2)] T Payload); - [AkkaSerializer(Name = "sample", SerializerId = 130017)] + [AkkaSerializer("sample", 130017)] [AkkaSerializable>(Manifest = "wrapper-int-v1")] public sealed partial class SampleSerializer : AkkaSerializer { @@ -2018,7 +2011,7 @@ public sealed record Wrapper( [AkkaSerializable] public sealed record Payload([property: AkkaField(1)] string Value); - [AkkaSerializer(Name = "sample", SerializerId = 130018)] + [AkkaSerializer("sample", 130018)] [AkkaSerializable>] public sealed partial class SampleSerializer : AkkaSerializer { @@ -2054,7 +2047,7 @@ public sealed record Wrapper( [property: AkkaField(1)] string Id, [property: AkkaField(2)] T Payload) : IProtocol; - [AkkaSerializer(Name = "sample", SerializerId = 130019)] + [AkkaSerializer("sample", 130019)] [AkkaSerializable>(Manifest = "wrapper-int-v1")] public sealed partial class SampleSerializer : AkkaSerializer { diff --git a/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs b/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs index 5219b040638..a56a1dfbff4 100644 --- a/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/CollectionFieldSpec.cs @@ -466,7 +466,7 @@ public interface ICollectionProtocol { } -[AkkaSerializer(Name = "collection-test", SerializerId = 120105)] +[AkkaSerializer("collection-test", 120105)] public sealed partial class CollectionTestSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); diff --git a/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs b/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs index b7ca8bfe28d..e38609ef8cf 100644 --- a/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/FieldlessAndStructFieldSpec.cs @@ -229,7 +229,8 @@ public sealed record GapPlainMessage( /// /// Mirrors Akka.Remote.Artery's UniqueAddress shape, but handled directly by the /// generator's native nested-Object path (annotated with ) -/// instead of the foreign-type escape hatch. +/// instead of the [AkkaSerializerFormatter<TTarget, TFormatter>] foreign-type escape +/// hatch. /// [AkkaSerializable] public readonly record struct GapUniqueAddress( @@ -250,8 +251,8 @@ public sealed record GapOptionalHandshakeMessage( public const string ManifestName = "gap-optional-handshake-v1"; } -[AkkaSerializer(Name = "gap-fix", SerializerId = 120801)] -[AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] +[AkkaSerializer("gap-fix", 120801)] +[AkkaSerializerFormatter] public sealed partial class GapFixSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); diff --git a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs index b492de4ed35..92b4852352a 100644 --- a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs @@ -18,10 +18,10 @@ namespace Akka.Serialization.V2.Tests; /// -/// Validates the escape hatch: hand-written -/// registrations for foreign types (core Akka types -/// like and ) that the generator cannot annotate -/// with . +/// Validates the [AkkaSerializerFormatter<TTarget, TFormatter>] escape hatch: +/// hand-written registrations for foreign types (core +/// Akka types like and ) that the generator cannot +/// annotate with . /// public sealed class FormatterEscapeHatchSpec : IAsyncLifetime { @@ -368,10 +368,10 @@ public sealed record MirrorActorPathMessage( public const string ManifestName = "mirror-actor-path-v1"; } -[AkkaSerializer(Name = "control-mirror", SerializerId = 120102)] -[AkkaSerializerFormatter(typeof(Address), typeof(AddressFormatter))] -[AkkaSerializerFormatter(typeof(TestUniqueAddress), typeof(TestUniqueAddressFormatter))] -[AkkaSerializerFormatter(typeof(ActorPath), typeof(ActorPathFormatter))] +[AkkaSerializer("control-mirror", 120102)] +[AkkaSerializerFormatter] +[AkkaSerializerFormatter] +[AkkaSerializerFormatter] public sealed partial class ControlMirrorSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -427,8 +427,8 @@ public sealed record SystemTaggedMessage( public const string ManifestName = "system-tagged-v1"; } -[AkkaSerializer(Name = "system-formatter", SerializerId = 120103)] -[AkkaSerializerFormatter(typeof(SystemTaggedValue), typeof(SystemTaggedValueFormatter))] +[AkkaSerializer("system-formatter", 120103)] +[AkkaSerializerFormatter] public sealed partial class SystemFormatterSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); @@ -445,7 +445,7 @@ internal sealed record InternalMirrorMessage( public const string ManifestName = "internal-mirror-v1"; } -[AkkaSerializer(Name = "internal-mirror", SerializerId = 120104)] +[AkkaSerializer("internal-mirror", 120104)] internal sealed partial class InternalMirrorSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs index 9bb120c47db..9420023e09f 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedClosedGenericSpec.cs @@ -136,7 +136,7 @@ public interface IClosedGenericTestProtocol { } -[AkkaSerializer(Name = "closed-generic-test", SerializerId = 120404)] +[AkkaSerializer("closed-generic-test", 120404)] [AkkaSerializable>(Manifest = "wrap-request-v1")] [AkkaSerializable>(Manifest = "wrap-receipt-v1")] [AkkaSerializable>(Manifest = "event-wrap-v1")] diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs index ffac8d700c1..d10a766ddb3 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs @@ -706,7 +706,7 @@ public interface IGeneratedTestProtocol { } -[AkkaSerializer(Name = "generated-test", SerializerId = 120101)] +[AkkaSerializer("generated-test", 120101)] public sealed partial class GeneratedTestSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs index dd384981d91..7f3b207632c 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs @@ -295,7 +295,7 @@ public interface IOrderEvent { } -[AkkaSerializer(Name = "union-test", SerializerId = 120303)] +[AkkaSerializer("union-test", 120303)] public sealed partial class UnionTestSerializer : AkkaSerializer { public static partial SerializerRegistration CreateRegistration(); diff --git a/src/core/Akka.Serialization.V2/AddressFormatter.cs b/src/core/Akka.Serialization.V2/AddressFormatter.cs index 9472c82bf6a..4055ab464ca 100644 --- a/src/core/Akka.Serialization.V2/AddressFormatter.cs +++ b/src/core/Akka.Serialization.V2/AddressFormatter.cs @@ -19,8 +19,8 @@ namespace Akka.Serialization.V2; /// The wire format is byte-identical to Akka.Remote's hand-rolled Artery control-message /// serializer (ArteryControlMessageSerializer.WriteAddress / ReadAddress / /// SizeOfAddress): a 4-element array of [Protocol, System, Host-or-nil, Port-or-nil]. -/// Registering this formatter via lets -/// fields be used on any generated serializer, including ones that must +/// Registering this formatter via [AkkaSerializerFormatter<Address, AddressFormatter>] +/// lets fields be used on any generated serializer, including ones that must /// interoperate with that hand-rolled format. /// public sealed class AddressFormatter : IAkkaMessagePackFormatter
diff --git a/src/core/Akka.Serialization.V2/Attributes.cs b/src/core/Akka.Serialization.V2/Attributes.cs index c13a2844c74..7011cfc2a9b 100644 --- a/src/core/Akka.Serialization.V2/Attributes.cs +++ b/src/core/Akka.Serialization.V2/Attributes.cs @@ -24,15 +24,32 @@ namespace Akka.Serialization.V2; [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class AkkaSerializerAttribute : Attribute { + /// + /// Initializes a new instance of the class. + /// + /// Logical serializer alias used for Akka serializer registration. + /// Explicit Akka serializer identifier. Must be unique in the actor system. + /// + /// Both arguments are mandatory and always explicit -- there is no auto-assignment. A source + /// generator's registration order across a compilation is nondeterministic, and a collision + /// with a serializer id or alias defined elsewhere (for example in HOCON config) is invisible + /// to the generator, so there is no value it could safely default either argument to. + /// + public AkkaSerializerAttribute(string name, int serializerId) + { + Name = name; + SerializerId = serializerId; + } + /// /// Logical serializer alias used for Akka serializer registration. /// - public string? Name { get; init; } + public string Name { get; } /// /// Explicit Akka serializer identifier. Must be unique in the actor system. /// - public int SerializerId { get; init; } + public int SerializerId { get; } } /// @@ -114,9 +131,16 @@ public sealed class AkkaUnionAttribute : Attribute /// /// Initializes a new instance of the class. /// - /// The closed set of concrete member types this field may hold. - public AkkaUnionAttribute(params Type[] memberTypes) + /// + /// The first concrete member type this field may hold. A union is never empty: at least one + /// member is always required, so [AkkaUnion()] (an empty member set) does not compile. + /// + /// Any additional concrete member types this field may hold. + public AkkaUnionAttribute(Type first, params Type[] rest) { + var memberTypes = new Type[rest.Length + 1]; + memberTypes[0] = first; + rest.CopyTo(memberTypes, 1); MemberTypes = memberTypes; } @@ -160,41 +184,31 @@ public sealed class AkkaSerializableAttribute : Attribute /// that cannot reference Akka.Serialization.V2). /// /// -/// Apply to the [AkkaSerializer] partial class. The registration is serializer-scoped: the -/// same foreign type may be handled by different formatters (or not at all) in different -/// serializers. A formatter registration overrides every field-kind resolution the generator would -/// otherwise infer for (including Nullable<T> of a value -/// type), except an -marked field, which always wins. +/// Apply to the [AkkaSerializer] partial class, for example +/// [AkkaSerializerFormatter<Address, AddressFormatter>]. The registration is +/// serializer-scoped: the same foreign type may be handled by different formatters (or not at all) +/// in different serializers. A formatter registration overrides every field-kind resolution the +/// generator would otherwise infer for (including +/// Nullable<T> of a value type), except an +/// -marked field, which always wins. +/// The where TFormatter : IAkkaMessagePackFormatter<TTarget> constraint is enforced by +/// the compiler at the attribute usage site: can never be +/// something that does not implement the formatter interface for . +/// Deliberately no new() constraint: a formatter with only a public constructor taking an +/// (no parameterless constructor) is legitimate (see +/// ). /// +/// The foreign type handled by . +/// +/// A non-abstract class implementing for +/// , with either a public parameterless constructor or a public +/// constructor taking an . When both constructors are +/// present, the generated serializer prefers the +/// constructor: the serializer always has the system in hand, and system context is why a +/// formatter declares that constructor. +/// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] -public sealed class AkkaSerializerFormatterAttribute : Attribute +public sealed class AkkaSerializerFormatterAttribute : Attribute + where TFormatter : IAkkaMessagePackFormatter { - /// - /// Initializes a new instance of the class. - /// - /// The foreign type handled by . - /// - /// A non-abstract, non-generic class implementing - /// for , with either a public parameterless constructor or a - /// public constructor taking an . When both - /// constructors are present, the generated serializer prefers the - /// constructor: the serializer always has the - /// system in hand, and system context is why a formatter declares that constructor. - /// - public AkkaSerializerFormatterAttribute(Type serializedType, Type formatterType) - { - SerializedType = serializedType; - FormatterType = formatterType; - } - - /// - /// The foreign type handled by . - /// - public Type SerializedType { get; } - - /// - /// The formatter type implementing for - /// . - /// - public Type FormatterType { get; } } diff --git a/src/core/Akka.Serialization.V2/IAkkaMessagePackFormatter.cs b/src/core/Akka.Serialization.V2/IAkkaMessagePackFormatter.cs index cee14fcb353..3e948b95b74 100644 --- a/src/core/Akka.Serialization.V2/IAkkaMessagePackFormatter.cs +++ b/src/core/Akka.Serialization.V2/IAkkaMessagePackFormatter.cs @@ -18,9 +18,10 @@ namespace Akka.Serialization.V2; /// /// /// Register an implementation for a specific on a generated serializer -/// using . The generator then routes every field of -/// type (including Nullable<T> for value types) through this -/// formatter instead of requiring a nested definition. +/// using [AkkaSerializerFormatter<T, TFormatter>]. The generator then routes every +/// field of type (including Nullable<T> for value types) +/// through this formatter instead of requiring a nested +/// definition. /// /// /// and must be symmetric: reading the bytes written by From 278e9a4a0a8feaa2ec2a9e260f0d5ff1b70d6895 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 16 Jul 2026 10:55:37 +0000 Subject: [PATCH 12/12] Serialization.V2: remove global:: qualifiers from hand-written code and diagnostic text - Hand-written runtime and test source now uses plain names + usings (every reference resolved unambiguously via enclosing-namespace lookup; no aliases needed) -- zero global:: remains outside the generator - Diagnostic messages no longer print global::-prefixed type names: ToDisplayName strips the prefix at Diagnostic.Create sites only, leaving dictionary keys, equality comparisons, and emitted-code type names untouched (29 AKKASG messages affected, no spec assertions broke) - Emitted-code string literals deliberately KEEP global:: -- generated source compiles inside arbitrary user namespaces where MessagePack/ System/Akka can be shadowed by user types 161/161 V2 tests; wire format unchanged; Akka.Remote builds clean. --- .../AkkaSerializerGenerator.cs | 80 +++++++++++-------- .../FormatterEscapeHatchSpec.cs | 4 +- .../GeneratedMessagePackSerializerSpec.cs | 22 ++--- .../GeneratedUnionSpec.cs | 2 +- .../ManifestContractSpec.cs | 4 +- .../OversizedPayloadDeterminismSpec.cs | 28 +++---- .../ActorPathFormatter.cs | 2 +- .../Akka.Serialization.V2/AkkaSerializer.cs | 31 +++---- .../Akka.Serialization.V2/MessagePackSizes.cs | 2 +- .../SerializerRegistration.cs | 20 ++--- 10 files changed, 106 insertions(+), 89 deletions(-) diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index d44d9eac9fd..ea4dbddcaff 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -360,7 +360,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) foreach (var duplicate in duplicateProtocolBindings) { - ctx.ReportDiagnostic(Diagnostic.Create(DuplicateProtocolBinding, Location.None, duplicate.Key, duplicate.Value)); + ctx.ReportDiagnostic(Diagnostic.Create(DuplicateProtocolBinding, Location.None, ToDisplayName(duplicate.Key), duplicate.Value)); } foreach (var serializer in pair.Left) @@ -764,7 +764,7 @@ private static bool ValidateProtocolType(SourceProductionContext context, Serial if (serializer.ProtocolType == null || serializer.ProtocolType.TypeKind == TypeKind.Interface) return true; - context.ReportDiagnostic(Diagnostic.Create(ProtocolTypeMustBeInterface, Location.None, serializer.ClassName, serializer.ProtocolTypeFullName)); + context.ReportDiagnostic(Diagnostic.Create(ProtocolTypeMustBeInterface, Location.None, serializer.ClassName, ToDisplayName(serializer.ProtocolTypeFullName))); return false; } @@ -806,7 +806,7 @@ private static bool ValidateProtocolCoverage(SourceProductionContext context, Se continue; context.ReportDiagnostic(Diagnostic.Create(ProtocolMessageNotSerializable, Location.None, - GetFullyQualifiedTypeName(candidate), serializer.ProtocolTypeFullName, serializer.ClassName)); + ToDisplayName(GetFullyQualifiedTypeName(candidate)), ToDisplayName(serializer.ProtocolTypeFullName), serializer.ClassName)); isValid = false; } @@ -859,21 +859,21 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ { if (!formatter.IsTargetSupported) { - context.ReportDiagnostic(Diagnostic.Create(FormatterTargetNotSupported, Location.None, formatter.TargetTypeFullName, serializer.ClassName)); + context.ReportDiagnostic(Diagnostic.Create(FormatterTargetNotSupported, Location.None, ToDisplayName(formatter.TargetTypeFullName), serializer.ClassName)); isValid = false; continue; } if (formatter.IsAbstract) { - context.ReportDiagnostic(Diagnostic.Create(InvalidFormatterType, Location.None, formatter.FormatterTypeFullName, serializer.ClassName, formatter.TargetTypeFullName)); + context.ReportDiagnostic(Diagnostic.Create(InvalidFormatterType, Location.None, ToDisplayName(formatter.FormatterTypeFullName), serializer.ClassName, ToDisplayName(formatter.TargetTypeFullName))); isValid = false; continue; } if (formatter.CtorKind == FormatterCtorKind.None) { - context.ReportDiagnostic(Diagnostic.Create(FormatterConstructorNotUsable, Location.None, formatter.FormatterTypeFullName, serializer.ClassName)); + context.ReportDiagnostic(Diagnostic.Create(FormatterConstructorNotUsable, Location.None, ToDisplayName(formatter.FormatterTypeFullName), serializer.ClassName)); isValid = false; } } @@ -883,7 +883,7 @@ private static bool ValidateFormatters(SourceProductionContext context, Serializ .GroupBy(formatter => formatter.TargetTypeFullName, StringComparer.Ordinal) .Where(group => group.Count() > 1)) { - context.ReportDiagnostic(Diagnostic.Create(DuplicateFormatterRegistration, Location.None, serializer.ClassName, duplicate.Key)); + context.ReportDiagnostic(Diagnostic.Create(DuplicateFormatterRegistration, Location.None, serializer.ClassName, ToDisplayName(duplicate.Key))); isValid = false; } @@ -898,7 +898,7 @@ private static bool ValidateClosedGenericRegistrations(SourceProductionContext c var isValid = true; foreach (var registration in serializer.ClosedGenericRegistrations.Where(registration => registration.Message == null)) { - context.ReportDiagnostic(Diagnostic.Create(InvalidClosedGenericRegistration, Location.None, registration.TargetDisplayName, serializer.ClassName)); + context.ReportDiagnostic(Diagnostic.Create(InvalidClosedGenericRegistration, Location.None, ToDisplayName(registration.TargetDisplayName), serializer.ClassName)); isValid = false; } @@ -907,7 +907,7 @@ private static bool ValidateClosedGenericRegistrations(SourceProductionContext c .GroupBy(registration => registration.TargetDisplayName, StringComparer.Ordinal) .Where(group => group.Count() > 1)) { - context.ReportDiagnostic(Diagnostic.Create(DuplicateClosedGenericRegistration, Location.None, serializer.ClassName, duplicate.Key)); + context.ReportDiagnostic(Diagnostic.Create(DuplicateClosedGenericRegistration, Location.None, serializer.ClassName, ToDisplayName(duplicate.Key))); isValid = false; } @@ -937,7 +937,7 @@ private static bool ValidateGenericDefinitions(SourceProductionContext context, if (hasRegistration) continue; - context.ReportDiagnostic(Diagnostic.Create(GenericSerializableRequiresRegistration, Location.None, definition.FullyQualifiedName, serializer.ProtocolTypeFullName, serializer.ClassName)); + context.ReportDiagnostic(Diagnostic.Create(GenericSerializableRequiresRegistration, Location.None, ToDisplayName(definition.FullyQualifiedName), ToDisplayName(serializer.ProtocolTypeFullName), serializer.ClassName)); isValid = false; } @@ -1281,8 +1281,9 @@ private static ImmutableArray ExtractUnionMembers( /// /// The key a type is looked up under in the serializer's message dictionary. Non-generic types /// use the arity-less (the existing key for every - /// [AkkaSerializable] message); closed generic constructions use the full display string - /// (e.g. global::Ns.Wrapper<global::Ns.Foo>) so distinct constructions stay distinct. + /// [AkkaSerializable] message); closed generic constructions use the full, + /// fully-qualified display string (e.g. Ns.Wrapper<Ns.Foo>) so distinct + /// constructions stay distinct. /// private static string GetMessageDictionaryKey(INamedTypeSymbol type) { @@ -1389,7 +1390,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer var isValid = true; foreach (var message in topLevelMessages.Where(message => string.IsNullOrWhiteSpace(message.Manifest))) { - context.ReportDiagnostic(Diagnostic.Create(MissingManifest, Location.None, message.FullyQualifiedName)); + context.ReportDiagnostic(Diagnostic.Create(MissingManifest, Location.None, ToDisplayName(message.FullyQualifiedName))); isValid = false; } @@ -1398,7 +1399,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer .GroupBy(m => m.Manifest, StringComparer.Ordinal) .Where(group => group.Count() > 1)) { - var typeNames = string.Join(", ", duplicate.Select(m => m.FullyQualifiedName)); + var typeNames = string.Join(", ", duplicate.Select(m => ToDisplayName(m.FullyQualifiedName))); context.ReportDiagnostic(Diagnostic.Create(DuplicateManifest, Location.None, serializer.ClassName, duplicate.Key, typeNames)); isValid = false; } @@ -1407,13 +1408,13 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer { if (message.Fields.Length == 0 && !message.AllowEmpty) { - context.ReportDiagnostic(Diagnostic.Create(MissingFields, Location.None, message.FullyQualifiedName)); + context.ReportDiagnostic(Diagnostic.Create(MissingFields, Location.None, ToDisplayName(message.FullyQualifiedName))); isValid = false; } foreach (var duplicate in message.Fields.GroupBy(field => field.Index).Where(group => group.Count() > 1)) { - context.ReportDiagnostic(Diagnostic.Create(DuplicateFieldIndex, Location.None, message.FullyQualifiedName, duplicate.Key)); + context.ReportDiagnostic(Diagnostic.Create(DuplicateFieldIndex, Location.None, ToDisplayName(message.FullyQualifiedName), duplicate.Key)); isValid = false; } @@ -1422,7 +1423,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer // message.Fields, so they cannot double-report through any of the checks below. foreach (var invalidField in message.InvalidFields) { - context.ReportDiagnostic(Diagnostic.Create(FieldPropertyNotAccessible, Location.None, invalidField.PropertyName, message.FullyQualifiedName, invalidField.Reason)); + context.ReportDiagnostic(Diagnostic.Create(FieldPropertyNotAccessible, Location.None, invalidField.PropertyName, ToDisplayName(message.FullyQualifiedName), invalidField.Reason)); isValid = false; } @@ -1431,7 +1432,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer // back on -- both make deserialize impossible to generate. foreach (var error in message.ConstructionPlan.Errors) { - context.ReportDiagnostic(Diagnostic.Create(NoMatchingConstructor, Location.None, message.FullyQualifiedName, error)); + context.ReportDiagnostic(Diagnostic.Create(NoMatchingConstructor, Location.None, ToDisplayName(message.FullyQualifiedName), error)); isValid = false; } @@ -1440,24 +1441,24 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer // deserialize because no [AkkaField] property feeds it. foreach (var parameterName in message.ConstructionPlan.UncoveredDefaultedParameters) { - context.ReportDiagnostic(Diagnostic.Create(ConstructorParameterNotCovered, Location.None, parameterName, message.FullyQualifiedName)); + context.ReportDiagnostic(Diagnostic.Create(ConstructorParameterNotCovered, Location.None, parameterName, ToDisplayName(message.FullyQualifiedName))); } foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Unsupported)) { - context.ReportDiagnostic(Diagnostic.Create(UnsupportedFieldType, Location.None, field.Name, message.FullyQualifiedName, field.TypeFullName)); + context.ReportDiagnostic(Diagnostic.Create(UnsupportedFieldType, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); isValid = false; } foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.MissingSerializableDefinition)) { - context.ReportDiagnostic(Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, field.Name, message.FullyQualifiedName, field.TypeFullName)); + context.ReportDiagnostic(Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); isValid = false; } foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.UnsupportedEnumUnderlyingType)) { - context.ReportDiagnostic(Diagnostic.Create(UnsupportedEnumUnderlyingType, Location.None, field.Name, message.FullyQualifiedName, field.Mapping.TypeFullName, field.Mapping.EnumUnderlyingTypeName)); + context.ReportDiagnostic(Diagnostic.Create(UnsupportedEnumUnderlyingType, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.Mapping.TypeFullName), ToDisplayName(field.Mapping.EnumUnderlyingTypeName))); isValid = false; } @@ -1478,7 +1479,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer CollectObjectTypeNames(field.Mapping, objectTypeNames); foreach (var objectTypeName in objectTypeNames.Where(typeName => !messagesByType.ContainsKey(typeName))) { - context.ReportDiagnostic(Diagnostic.Create(UnregisteredClosedGenericField, Location.None, field.Name, message.FullyQualifiedName, objectTypeName, serializer.ClassName)); + context.ReportDiagnostic(Diagnostic.Create(UnregisteredClosedGenericField, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName), serializer.ClassName)); isValid = false; } } @@ -1491,7 +1492,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer .GroupBy(GetMessageMethodName, StringComparer.Ordinal) .Where(group => group.Select(m => m.FullyQualifiedName).Distinct(StringComparer.Ordinal).Count() > 1)) { - var typeNames = string.Join(", ", collision.Select(m => m.FullyQualifiedName)); + var typeNames = string.Join(", ", collision.Select(m => ToDisplayName(m.FullyQualifiedName))); context.ReportDiagnostic(Diagnostic.Create(DuplicateGeneratedName, Location.None, serializer.ClassName, collision.Key, typeNames)); isValid = false; } @@ -1528,7 +1529,7 @@ private static bool ValidateClosedGenericProtocolCoverage(SourceProductionContex continue; context.ReportDiagnostic(Diagnostic.Create(ClosedGenericRegistrationNotInProtocol, Location.None, - registration.TargetDisplayName, serializer.ClassName, serializer.ProtocolTypeFullName)); + ToDisplayName(registration.TargetDisplayName), serializer.ClassName, ToDisplayName(serializer.ProtocolTypeFullName))); isValid = false; } @@ -1551,7 +1552,7 @@ private static bool ValidateUnionField( .GroupBy(member => member.TypeFullName, StringComparer.Ordinal) .Where(group => group.Count() > 1)) { - context.ReportDiagnostic(Diagnostic.Create(InvalidUnionMemberSet, Location.None, field.Name, message.FullyQualifiedName, $"member type '{duplicate.Key}' is declared more than once")); + context.ReportDiagnostic(Diagnostic.Create(InvalidUnionMemberSet, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), $"member type '{ToDisplayName(duplicate.Key)}' is declared more than once")); isValid = false; } @@ -1560,25 +1561,25 @@ private static bool ValidateUnionField( { if (!member.IsSupported || !messagesByType.TryGetValue(member.TypeFullName, out var memberMessage)) { - context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotSerializable, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName)); + context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotSerializable, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName))); isValid = false; continue; } if (!member.IsAssignable) { - context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotAssignable, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName, field.TypeFullName)); + context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotAssignable, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); isValid = false; } // Advisory only (Info): an unsealed member works, but an undeclared subtype of it fails // at write time under exact-runtime-type dispatch -- worth surfacing, not worth failing. if (!member.IsSealed) - context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotSealed, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName)); + context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotSealed, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName))); if (string.IsNullOrWhiteSpace(memberMessage.Manifest)) { - context.ReportDiagnostic(Diagnostic.Create(UnionMemberMissingManifest, Location.None, member.TypeFullName, field.Name, message.FullyQualifiedName)); + context.ReportDiagnostic(Diagnostic.Create(UnionMemberMissingManifest, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName))); isValid = false; continue; } @@ -1594,7 +1595,7 @@ private static bool ValidateUnionField( foreach (var collision in manifests.Where(pair => pair.Value.Distinct(StringComparer.Ordinal).Count() > 1)) { - context.ReportDiagnostic(Diagnostic.Create(UnionMemberManifestCollision, Location.None, field.Name, message.FullyQualifiedName, collision.Key, string.Join(", ", collision.Value))); + context.ReportDiagnostic(Diagnostic.Create(UnionMemberManifestCollision, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), collision.Key, string.Join(", ", collision.Value.Select(ToDisplayName)))); isValid = false; } @@ -3087,7 +3088,7 @@ private static string GetObjectMethodName(TypeMapping mapping) /// Folds a fully-qualified type name into a compact generated-member identifier the way /// System.Text.Json's GetTypeInfoPropertyName does: namespaces are dropped, each type /// identifier keeps only its simple name, and generic type arguments are concatenated -- - /// global::Ns.Wrapper<global::Ns.OrderRequest> becomes WrapperOrderRequest. + /// Ns.Wrapper<Ns.OrderRequest> becomes WrapperOrderRequest. /// These names appear in stack traces (WriteWrapperOrderRequest), so compactness matters. /// Flattening is collision-prone by construction (same simple name in two namespaces, marker /// ambiguity); AKKASG024 detects collisions among generated members and fails compilation @@ -3171,6 +3172,21 @@ private static string GetMessageMethodName(MessageInfo message) return FoldTypeName(message.FullyQualifiedName); } + /// + /// Strips a leading global:: prefix from a fully-qualified type name string for display + /// in a message ONLY. Every internal use of a fully-qualified name -- + /// dictionary keys, equality/grouping comparisons, and text appended into emitted source -- + /// keeps the raw global::-qualified form produced by + /// and ; this helper must be applied only + /// to the arguments passed to Diagnostic.Create(...) at each reporting call site. + /// + private static string ToDisplayName(string fullyQualifiedName) + { + return fullyQualifiedName.StartsWith("global::", StringComparison.Ordinal) + ? fullyQualifiedName.Substring("global::".Length) + : fullyQualifiedName; + } + private static string GetFullyQualifiedTypeName(INamedTypeSymbol symbol) { var parts = new Stack(); diff --git a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs index 92b4852352a..3cf835811e5 100644 --- a/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/FormatterEscapeHatchSpec.cs @@ -332,7 +332,7 @@ public int SizeOf(TestUniqueAddress value) { var addressSize = _addressFormatter.SizeOf(value.Address); if (addressSize < 0) - return global::Akka.Serialization.SerializerV2.UnknownSize; + return SerializerV2.UnknownSize; return MessagePackSizes.SizeOfArrayHeader(2) + addressSize + MessagePackSizes.SizeOfInt64(value.Uid); } @@ -417,7 +417,7 @@ public SystemTaggedValue Read(ref MessagePackReader reader) return new SystemTaggedValue(separatorIndex >= 0 ? raw.Substring(separatorIndex + 1) : raw); } - public int SizeOf(SystemTaggedValue value) => global::Akka.Serialization.SerializerV2.UnknownSize; + public int SizeOf(SystemTaggedValue value) => SerializerV2.UnknownSize; } [AkkaSerializable(Manifest = SystemTaggedMessage.ManifestName)] diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs index d10a766ddb3..11785b2e8c2 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedMessagePackSerializerSpec.cs @@ -324,10 +324,10 @@ public async Task Generated_serializer_should_round_trip_through_Serialization() [Fact(DisplayName = "Generated MessagePack wrapper should preserve opaque custom serializer payload")] public async Task Generated_MessagePack_wrapper_should_preserve_opaque_custom_serializer_payload() { - var setup = ActorSystemSetup.Create(global::Akka.Serialization.SerializationSetup.Create(extendedSystem => + var setup = ActorSystemSetup.Create(SerializationSetup.Create(extendedSystem => { var generated = GeneratedTestSerializer.CreateRegistration().CreateDetails(extendedSystem); - var custom = global::Akka.Serialization.SerializerDetails.Create( + var custom = SerializerDetails.Create( "custom-protobuf", new CustomProtobufPayloadSerializer(extendedSystem), ImmutableHashSet.Create(typeof(CustomProtobufPayload))); @@ -355,7 +355,7 @@ public async Task Generated_MessagePack_wrapper_should_preserve_opaque_custom_se AssertOpaqueEnvelopeBytes(envelopeBytes, expectedInnerBytes); - var envelopeManifest = global::Akka.Serialization.Serialization.ManifestFor(envelopeSerializer, envelope); + var envelopeManifest = Serialization.ManifestFor(envelopeSerializer, envelope); var recoveredEnvelope = system.Serialization.Deserialize(envelopeBytes, envelopeSerializer.Identifier, envelopeManifest) .Should().BeOfType().Subject; recoveredEnvelope.EnvelopeId.Should().Be("envelope-1"); @@ -375,10 +375,10 @@ public async Task Generated_MessagePack_wrapper_should_preserve_opaque_custom_se [Fact(DisplayName = "Generated serializer should round-trip nested envelope payloads through Akka serialization")] public async Task Generated_serializer_should_round_trip_nested_envelope_payloads_through_Akka_serialization() { - var setup = ActorSystemSetup.Create(global::Akka.Serialization.SerializationSetup.Create(extendedSystem => + var setup = ActorSystemSetup.Create(SerializationSetup.Create(extendedSystem => { var generated = GeneratedTestSerializer.CreateRegistration().CreateDetails(extendedSystem); - var custom = global::Akka.Serialization.SerializerDetails.Create( + var custom = SerializerDetails.Create( "custom-protobuf", new CustomProtobufPayloadSerializer(extendedSystem), ImmutableHashSet.Create(typeof(CustomProtobufPayload))); @@ -392,12 +392,12 @@ public async Task Generated_serializer_should_round_trip_nested_envelope_payload var customEnvelope = new AttributeOuterEnvelope("outer-custom", new AttributeInnerEnvelope("inner-custom", customPayload)); var generatedEnvelope = new AttributeOuterEnvelope("outer-generated", new AttributeInnerEnvelope("inner-generated", generatedPayload)); var envelopeSerializer = system.Serialization.FindSerializerFor(generatedEnvelope) - .Should().BeAssignableTo().Subject; + .Should().BeAssignableTo().Subject; var customRecovered = RoundTripThroughSerialization(system, customEnvelope); customRecovered.Should().Be(customEnvelope); customRecovered.Inner.Payload.Should().BeOfType(); - envelopeSerializer.SizeHint(customEnvelope).Should().Be(global::Akka.Serialization.SerializerV2.UnknownSize); + envelopeSerializer.SizeHint(customEnvelope).Should().Be(SerializerV2.UnknownSize); var generatedRecovered = RoundTripThroughSerialization(system, generatedEnvelope); generatedRecovered.Should().Be(generatedEnvelope); @@ -430,7 +430,7 @@ public async Task Generated_serializer_should_round_trip_a_live_IActorRef_throug var serializer = system.Serialization.FindSerializerFor(message); var bytes = system.Serialization.Serialize(message); - var manifest = global::Akka.Serialization.Serialization.ManifestFor(serializer, message); + var manifest = Serialization.ManifestFor(serializer, message); var deserialized = system.Serialization.Deserialize(bytes, serializer.Identifier, manifest) .Should().BeOfType().Subject; @@ -665,7 +665,7 @@ private TMessage RoundTrip(TMessage message) private static OpaqueSerializedPayload CaptureOpaquePayload(ExtendedActorSystem system, object payload) { var serializer = system.Serialization.FindSerializerFor(payload); - var manifest = global::Akka.Serialization.Serialization.ManifestFor(serializer, payload); + var manifest = Serialization.ManifestFor(serializer, payload); return new OpaqueSerializedPayload(serializer.Identifier, manifest, serializer.ToBinary(payload)); } @@ -678,7 +678,7 @@ private static TMessage RoundTripThroughSerialization(ActorSystem syst { var serializer = system.Serialization.FindSerializerFor(message!); var bytes = system.Serialization.Serialize(message!); - var manifest = global::Akka.Serialization.Serialization.ManifestFor(serializer, message!); + var manifest = Serialization.ManifestFor(serializer, message!); return system.Serialization.Deserialize(bytes, serializer.Identifier, manifest).Should().BeOfType().Subject; } @@ -764,7 +764,7 @@ public sealed record AttributeInnerEnvelope( public sealed record CustomProtobufPayload(string PayloadId, int Value); -public sealed class CustomProtobufPayloadSerializer : global::Akka.Serialization.SerializerWithStringManifest +public sealed class CustomProtobufPayloadSerializer : SerializerWithStringManifest { public const int IdentifierValue = 120202; public const string ManifestName = "custom-protobuf-v1"; diff --git a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs index 7f3b207632c..cd2bf1f4a58 100644 --- a/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/GeneratedUnionSpec.cs @@ -162,7 +162,7 @@ public void Union_size_hint_should_report_unknown_for_undeclared_type() { var message = new UnionEnvelope("env-9", new UndeclaredEvent()); - _serializer.SizeHint(message).Should().Be(global::Akka.Serialization.SerializerV2.UnknownSize); + _serializer.SizeHint(message).Should().Be(SerializerV2.UnknownSize); } [Fact(DisplayName = "Union read should fail on an unknown manifest")] diff --git a/src/core/Akka.Serialization.V2.Tests/ManifestContractSpec.cs b/src/core/Akka.Serialization.V2.Tests/ManifestContractSpec.cs index 1a874e92124..ebf2a0aba0b 100644 --- a/src/core/Akka.Serialization.V2.Tests/ManifestContractSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/ManifestContractSpec.cs @@ -121,7 +121,7 @@ public void Manifest_should_fit_within_Artery_envelope_literal_u16_length_limit( }); } - private void AssertForEachMessage(Action assertion) + private void AssertForEachMessage(Action assertion) { var extendedSystem = (ExtendedActorSystem)_system; AssertForEachSerializer((createSerializer, messages) => @@ -133,7 +133,7 @@ private void AssertForEachMessage(Action, IEnumerable> assertion) + Action, IEnumerable> assertion) { assertion(system => new GeneratedTestSerializer(system), GeneratedTestProtocolMessages()); assertion(system => new ControlMirrorSerializer(system), ControlMirrorProtocolMessages()); diff --git a/src/core/Akka.Serialization.V2.Tests/OversizedPayloadDeterminismSpec.cs b/src/core/Akka.Serialization.V2.Tests/OversizedPayloadDeterminismSpec.cs index 8900939ad57..3942b09a3c7 100644 --- a/src/core/Akka.Serialization.V2.Tests/OversizedPayloadDeterminismSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/OversizedPayloadDeterminismSpec.cs @@ -18,14 +18,14 @@ namespace Akka.Serialization.V2.Tests; /// /// Pins deterministic oversized-payload failure for generated serializers (messagepack-sourcegen -/// task 6.8): a caller-imposed cap ( +/// task 6.8): a caller-imposed cap ( /// with maxCapacity, the transport maximum-frame-size hook) makes an oversized payload fail -/// AT ENCODE TIME with a typed +/// AT ENCODE TIME with a typed /// carrying the attempted size and the cap — never a truncated or corrupt frame observed /// downstream. Writer mechanics themselves are covered by Akka.Tests.Serialization.PooledPayloadWriterSpec; /// this spec pins the SERIALIZER-side contract: the exception surfaces from Serialize, the /// generated serializer stays stateless across the failure, and the writer is reusable after -/// . +/// . /// public sealed class OversizedPayloadDeterminismSpec : IAsyncLifetime { @@ -50,11 +50,11 @@ public async ValueTask DisposeAsync() public void Generated_serializer_should_throw_PayloadSizeExceededException_when_payload_exceeds_writer_cap() { var oversized = new RequiredMessage(new string('x', 100), 42); - using var writer = new global::Akka.Serialization.PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity); + using var writer = new PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity); Action serialize = () => _serializer.Serialize(oversized, writer); - var exception = serialize.Should().Throw().Which; + var exception = serialize.Should().Throw().Which; exception.MaxCapacity.Should().Be(MaxCapacity); exception.AttemptedSize.Should().BeGreaterThan(MaxCapacity); } @@ -63,10 +63,10 @@ public void Generated_serializer_should_throw_PayloadSizeExceededException_when_ public void Generated_serializer_should_remain_usable_after_an_oversized_payload_failure() { var oversized = new RequiredMessage(new string('x', 100), 42); - using (var cappedWriter = new global::Akka.Serialization.PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity)) + using (var cappedWriter = new PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity)) { Action serialize = () => _serializer.Serialize(oversized, cappedWriter); - serialize.Should().Throw(); + serialize.Should().Throw(); } // Generated serializers are stateless: the SAME instance must round-trip a small message @@ -83,10 +83,10 @@ public void Capped_writer_should_be_reusable_via_Reset_after_an_oversized_payloa { var oversized = new RequiredMessage(new string('x', 100), 42); var small = new RequiredMessage("a", 1); - using var writer = new global::Akka.Serialization.PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity); + using var writer = new PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity); Action serialize = () => _serializer.Serialize(oversized, writer); - serialize.Should().Throw(); + serialize.Should().Throw(); // The transport dead-letter-then-reuse pattern: Reset the SAME writer, encode the next // (smaller) message into it, and decode it back from the written bytes. @@ -102,10 +102,10 @@ public void Capped_writer_should_be_reusable_via_Reset_after_an_oversized_payloa public void Capped_writer_should_never_exceed_its_cap_when_an_oversized_payload_fails_mid_encode() { var oversized = new RequiredMessage(new string('x', 100), 42); - using var writer = new global::Akka.Serialization.PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity); + using var writer = new PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: MaxCapacity); Action serialize = () => _serializer.Serialize(oversized, writer); - serialize.Should().Throw(); + serialize.Should().Throw(); // The cap is a hard encode-time boundary: no oversized partial frame ever exists to hand // to a transport. @@ -122,12 +122,12 @@ public async Task Envelope_payload_serialization_should_throw_when_staged_payloa var payload = new RequiredMessage(new string('x', 100), 42); var envelope = new AttributeOuterEnvelope("outer-1", new AttributeInnerEnvelope("inner-1", payload)); var serializer = system.Serialization.FindSerializerFor(envelope) - .Should().BeAssignableTo().Subject; + .Should().BeAssignableTo().Subject; - using var writer = new global::Akka.Serialization.PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: 64); + using var writer = new PooledPayloadWriter(initialCapacityHint: 16, maxCapacity: 64); Action serialize = () => serializer.Serialize(envelope, writer); - var exception = serialize.Should().Throw().Which; + var exception = serialize.Should().Throw().Which; exception.MaxCapacity.Should().Be(64); exception.AttemptedSize.Should().BeGreaterThan(64); } diff --git a/src/core/Akka.Serialization.V2/ActorPathFormatter.cs b/src/core/Akka.Serialization.V2/ActorPathFormatter.cs index 65eaf228477..13fae50e86c 100644 --- a/src/core/Akka.Serialization.V2/ActorPathFormatter.cs +++ b/src/core/Akka.Serialization.V2/ActorPathFormatter.cs @@ -92,7 +92,7 @@ public int SizeOf(ActorPath value) private string GetSerializationFormat(ActorPath value) { - var info = global::Akka.Serialization.Serialization.CurrentTransportInformation; + var info = Serialization.CurrentTransportInformation; if (info is not null) return value.ToSerializationFormatWithAddress(info.Address); diff --git a/src/core/Akka.Serialization.V2/AkkaSerializer.cs b/src/core/Akka.Serialization.V2/AkkaSerializer.cs index 25703ba8be9..26eb3610cb0 100644 --- a/src/core/Akka.Serialization.V2/AkkaSerializer.cs +++ b/src/core/Akka.Serialization.V2/AkkaSerializer.cs @@ -8,6 +8,7 @@ #nullable enable using System; using System.Buffers; +using System.Runtime.Serialization; using Akka.Actor; using MessagePack; @@ -16,7 +17,7 @@ namespace Akka.Serialization.V2; /// /// Base class for source-generated MessagePack serializers scoped to a protocol marker type. /// -public abstract class AkkaSerializer : global::Akka.Serialization.SerializerV2 +public abstract class AkkaSerializer : SerializerV2 { protected AkkaSerializer(ExtendedActorSystem system) : base(system) { @@ -30,15 +31,15 @@ public override byte[] ToBinary(object obj) return writer.WrittenMemory.ToArray(); } - protected global::Akka.Actor.IActorRef? ReadActorRef(ref MessagePackReader reader) + protected IActorRef? ReadActorRef(ref MessagePackReader reader) { var path = reader.ReadString(); return string.IsNullOrEmpty(path) ? ActorRefs.NoSender : system.Provider.ResolveActorRef(path); } - protected static void WriteActorRef(ref MessagePackWriter writer, global::Akka.Actor.IActorRef? actorRef) + protected static void WriteActorRef(ref MessagePackWriter writer, IActorRef? actorRef) { - writer.Write(global::Akka.Serialization.Serialization.SerializedActorPath(actorRef)); + writer.Write(Serialization.SerializedActorPath(actorRef)); } protected void WriteEnvelopePayload(ref MessagePackWriter writer, object? payload) @@ -50,14 +51,14 @@ protected void WriteEnvelopePayload(ref MessagePackWriter writer, object? payloa } var serializer = system.Serialization.FindSerializerFor(payload); - var manifest = global::Akka.Serialization.Serialization.ManifestFor(serializer, payload); + var manifest = Serialization.ManifestFor(serializer, payload); - if (serializer is global::Akka.Serialization.SerializerV2 serializerV2) + if (serializer is SerializerV2 serializerV2) { using var buffer = new AkkaPooledBufferWriter(); var bytesWritten = serializerV2.Serialize(payload, buffer); if (bytesWritten != buffer.WrittenCount) - throw new global::System.Runtime.Serialization.SerializationException( + throw new SerializationException( $"Serializer [{serializer.GetType()}] reported [{bytesWritten}] bytes but wrote [{buffer.WrittenCount}] bytes."); writer.WriteMapHeader(3); @@ -112,9 +113,9 @@ protected void WriteEnvelopePayload(ref MessagePackWriter writer, object? payloa } if (serializerId is null) - throw new global::System.Runtime.Serialization.SerializationException("Missing envelope payload serializer id."); + throw new SerializationException("Missing envelope payload serializer id."); if (bytes is null) - throw new global::System.Runtime.Serialization.SerializationException("Missing envelope payload bytes."); + throw new SerializationException("Missing envelope payload bytes."); return system.Serialization.Deserialize(bytes.Value, serializerId.Value, manifest); } @@ -125,14 +126,14 @@ protected int SizeOfEnvelopePayload(object? payload) return SizeOfNil(); var serializer = system.Serialization.FindSerializerFor(payload); - if (serializer is not global::Akka.Serialization.SerializerV2 serializerV2) - return global::Akka.Serialization.SerializerV2.UnknownSize; + if (serializer is not SerializerV2 serializerV2) + return SerializerV2.UnknownSize; var payloadSize = serializerV2.SizeHint(payload); if (payloadSize < 0) - return global::Akka.Serialization.SerializerV2.UnknownSize; + return SerializerV2.UnknownSize; - var manifest = global::Akka.Serialization.Serialization.ManifestFor(serializer, payload); + var manifest = Serialization.ManifestFor(serializer, payload); return checked( SizeOfMapHeader(3) + SizeOfInt32(1) + SizeOfInt32(serializer.Identifier) + @@ -168,7 +169,7 @@ protected int SizeOfEnvelopePayload(object? payload) protected static int SizeOfDecimal(decimal value) => MessagePackSizes.SizeOfDecimal(value); - protected static int SizeOfActorRef(global::Akka.Actor.IActorRef? actorRef) => MessagePackSizes.SizeOfActorRef(actorRef); + protected static int SizeOfActorRef(IActorRef? actorRef) => MessagePackSizes.SizeOfActorRef(actorRef); protected static int SizeOfBinHeader(int byteCount) => MessagePackSizes.SizeOfBinHeader(byteCount); @@ -240,7 +241,7 @@ protected TPayload ReadEnvelopePayload(ref MessagePackReader reader) if (payload is TPayload typed) return typed; - throw new global::System.Runtime.Serialization.SerializationException( + throw new SerializationException( $"Envelope payload [{payload?.GetType().FullName ?? ""}] is not assignable to [{typeof(TPayload).FullName}]."); } diff --git a/src/core/Akka.Serialization.V2/MessagePackSizes.cs b/src/core/Akka.Serialization.V2/MessagePackSizes.cs index f75acec39a5..8bd2fb40f64 100644 --- a/src/core/Akka.Serialization.V2/MessagePackSizes.cs +++ b/src/core/Akka.Serialization.V2/MessagePackSizes.cs @@ -163,7 +163,7 @@ public static int SizeOfDecimal(decimal value) /// The actor reference; and null encode as an empty string. public static int SizeOfActorRef(IActorRef? actorRef) { - return SizeOfString(global::Akka.Serialization.Serialization.SerializedActorPath(actorRef)); + return SizeOfString(Serialization.SerializedActorPath(actorRef)); } /// diff --git a/src/core/Akka.Serialization.V2/SerializerRegistration.cs b/src/core/Akka.Serialization.V2/SerializerRegistration.cs index 6ac5f64de24..57ee3acdf4f 100644 --- a/src/core/Akka.Serialization.V2/SerializerRegistration.cs +++ b/src/core/Akka.Serialization.V2/SerializerRegistration.cs @@ -17,11 +17,11 @@ namespace Akka.Serialization.V2; /// public sealed class SerializerRegistration { - private readonly Func _createSerializer; + private readonly Func _createSerializer; private SerializerRegistration( string alias, - Func createSerializer, + Func createSerializer, ImmutableHashSet useFor) { Alias = alias; @@ -35,7 +35,7 @@ private SerializerRegistration( public static SerializerRegistration Create( string alias, - Func createSerializer, + Func createSerializer, ImmutableHashSet useFor) { if (string.IsNullOrWhiteSpace(alias)) @@ -44,21 +44,21 @@ public static SerializerRegistration Create( return new SerializerRegistration(alias, createSerializer ?? throw new ArgumentNullException(nameof(createSerializer)), useFor); } - public global::Akka.Serialization.SerializerDetails CreateDetails(ExtendedActorSystem system) + public SerializerDetails CreateDetails(ExtendedActorSystem system) { - return global::Akka.Serialization.SerializerDetails.Create(Alias, _createSerializer(system), UseFor); + return SerializerDetails.Create(Alias, _createSerializer(system), UseFor); } - public global::Akka.Serialization.SerializationSetup CreateSetup() + public SerializationSetup CreateSetup() { - return global::Akka.Serialization.SerializationSetup.Create(system => ImmutableHashSet.Create(CreateDetails(system))); + return SerializationSetup.Create(system => ImmutableHashSet.Create(CreateDetails(system))); } - public static global::Akka.Serialization.SerializationSetup CreateSetup(params SerializerRegistration[] registrations) + public static SerializationSetup CreateSetup(params SerializerRegistration[] registrations) { - return global::Akka.Serialization.SerializationSetup.Create(system => + return SerializationSetup.Create(system => { - var builder = ImmutableHashSet.CreateBuilder(); + var builder = ImmutableHashSet.CreateBuilder(); foreach (var registration in registrations) { builder.Add(registration.CreateDetails(system));