diff --git a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs index f0739da..127c897 100644 --- a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs +++ b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs @@ -18,32 +18,12 @@ public class AcBinarySourceGenerator : IIncrementalGenerator { private const string AttributeName = "AyCode.Core.Serializers.Attributes.AcBinarySerializableAttribute"; - // ──────────────────────────────────────────────────────────────────────────────────────────── - // TEMPORARY (2026-05-08) — A/B test feature gates for hot-path overhead measurement. - // - // The generated SGen `WriteProperties` / `ScanObject` methods emit two kinds of overhead-blocks - // that are unconditionally present today but rarely exercised in typical workloads: - // - // 1. PropertyFilter guard (`UsePropertyFilter`) — every non-markerless property emit-site - // checks `context.HasPropertyFilter` + filter-context allocation + lambda-call. - // The benchmark workload never sets a property-filter → branch is always false → - // pure overhead (CPU cycles + i-cache pressure on the hot path). - // - // 2. Polymorphic object-with-type-name emit (`UsePolymorphType`) — `System.Object` declared - // properties emit `ObjectWithTypeName` marker + `WriteStringUtf8(AssemblyQualifiedName)` - // under `!context.UseMetadata`. Same: rarely used in typical DTO graphs. - // - // Setting either to `false` skips the corresponding emit at compile time → leaner generated - // code. The bench measures the actual delta vs MemPack apples-to-apples (which has neither - // of these features). - // - // Long-term: these flags will move to `[AcBinarySerializable(UsePropertyFilter = false, ...)]` - // attribute properties so consumers can opt out per type. Until then, keep both `false` for - // benchmark-vs-MemPack measurements; flip to `true` for production where the features are needed. - // ──────────────────────────────────────────────────────────────────────────────────────────── - // UsePropertyFilter const removed — replaced by `[AcBinarySerializable(EnablePropertyFilterFeature = ...)]` - // attribute flag, propagated through SerializableClassInfo.EnablePropertyFilter to EmitProp/EmitScanProp. - private const bool UsePolymorphType = true; + // Feature gates on the SGen-emitted writer / scan code are driven by `[AcBinarySerializable]` + // attribute flags. Two such gates are wired through SerializableClassInfo: + // • EnablePropertyFilter → omits the per-property `HasPropertyFilter` branch when false. + // • EnablePolymorphDetect → omits the `ObjectWithTypeName + AQN` prefix on `System.Object`- + // declared properties when false (then ACBIN002 guards misuse). + // Both default `true`; opt-out is per type via the attribute ctor parameters. private static readonly DiagnosticDescriptor CircularReferenceWarning = new( id: "ACBIN001", @@ -56,26 +36,26 @@ public class AcBinarySourceGenerator : IIncrementalGenerator /// /// ACCORE-BIN-I-T7K3 compile-time guard: a property declared as System.Object requires /// polymorphic-prefix emit (ObjectWithTypeName) so the deserializer can resolve the - /// concrete runtime type. When is false, the prefix is - /// suppressed and the wire silently corrupts on round-trip (FixObj slot byte against - /// typeof(object) at read-time → 0-byte object wrapper → reader position drifts → - /// downstream DECIMAL_DRIFT / IndexOutOfRangeException). + /// concrete runtime type. When the type opts out of the feature via + /// [AcBinarySerializable(enablePolymorphDetectFeature: false)], the prefix is suppressed + /// and the wire silently corrupts on round-trip (FixObj slot byte against typeof(object) + /// at read-time → 0-byte object wrapper → reader position drifts → downstream + /// DECIMAL_DRIFT / IndexOutOfRangeException). /// /// Surface the misconfiguration at build time so the silent corruption is structurally /// impossible. Three escape hatches for the developer: - /// 1. Enable the polymorphic feature ( = true, or — once the - /// planned [AcBinarySerializable(EnablePolymorphicFeature = true)] flag lands — set - /// it on the type). + /// 1. Enable the polymorph-detect feature on the type + /// ([AcBinarySerializable(...enablePolymorphDetectFeature: true)] — default true). /// 2. Change the property type to a concrete type (no polymorphism needed). - /// 3. Mark the property with [AcBinaryIgnore] — ignored properties are filtered - /// out at property enumeration, so this diagnostic does not fire for them. + /// 3. Mark the property with [AcBinaryIgnore] — ignored properties are filtered out + /// at property enumeration, so this diagnostic does not fire for them. /// private static readonly DiagnosticDescriptor PolymorphicPropertyWithFeatureDisabledError = new( id: "ACBIN002", - title: "Polymorphic property requires polymorphic feature enabled", - messageFormat: "Type '{0}' contains property '{1}' declared as System.Object, but polymorphic serialization in the source generator is disabled (UsePolymorphType=false). " + + title: "Polymorphic property requires EnablePolymorphDetectFeature", + messageFormat: "Type '{0}' contains property '{1}' declared as System.Object, but EnablePolymorphDetectFeature is disabled on the type. " + "The generated writer would silently corrupt the wire on round-trip. " + - "To fix: (1) enable polymorphic serialization, (2) change '{1}' to a concrete type, or (3) exclude it with [AcBinaryIgnore].", + "To fix: (1) enable EnablePolymorphDetectFeature on [AcBinarySerializable], (2) change '{1}' to a concrete type, or (3) exclude it with [AcBinaryIgnore].", category: "AcBinarySerializer", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -111,6 +91,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator var enableInternString = true; var enableMetadata = true; var enablePropertyFilter = true; + var enablePolymorphDetect = true; var binarySerializableAttr = typeSymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == AttributeName); if (binarySerializableAttr != null) @@ -124,10 +105,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator enableInternString = all; enableMetadata = all; enablePropertyFilter = all; + enablePolymorphDetect = all; } else if (binarySerializableAttr.ConstructorArguments.Length == 4) { - // Four bool ctor: (metadata, idTracking, refHandling, internString) — filter defaults to true + // Four bool ctor: (metadata, idTracking, refHandling, internString) — filter + polymorph default to true enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!; enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!; enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!; @@ -135,13 +117,23 @@ public class AcBinarySourceGenerator : IIncrementalGenerator } else if (binarySerializableAttr.ConstructorArguments.Length == 5) { - // Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter) + // Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter) — polymorph defaults to true enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!; enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!; enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!; enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!; enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!; } + else if (binarySerializableAttr.ConstructorArguments.Length == 6) + { + // Six bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter, polymorphDetect) + enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!; + enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!; + enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!; + enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!; + enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!; + enablePolymorphDetect = (bool)binarySerializableAttr.ConstructorArguments[5].Value!; + } } foreach (var p in GetAllSerializablePropertySymbols(typeSymbol)) @@ -423,7 +415,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator var typeNameHash = ComputeFnvHash(typeSymbol.Name); var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray(); var selfScanFlags = ComputeNeedsScan(typeSymbol); - return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan); + return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, enablePolymorphDetect, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan); } private static void Execute(ImmutableArray classes, SourceProductionContext context) @@ -447,15 +439,16 @@ public class AcBinarySourceGenerator : IIncrementalGenerator /// /// ACCORE-BIN-I-T7K3 guard: emits /// (ACBIN002) for every System.Object-declared property on any - /// [AcBinarySerializable] type while is false. - /// Short-circuits when the feature is enabled — no per-property work needed. + /// [AcBinarySerializable] type whose EnablePolymorphDetectFeature is false. + /// Per-class gating: types with the feature enabled (default) skip the check entirely; only + /// opt-out types are scanned for misuse. /// private static void DetectAndReportPolymorphicMisuse(List classes, SourceProductionContext spc) { - if (UsePolymorphType) return; // Feature enabled → polymorphic prefix is emitted, no misuse possible. - foreach (var ci in classes) { + if (ci.EnablePolymorphDetect) continue; // Feature enabled → polymorphic prefix is emitted, no misuse possible. + foreach (var p in ci.Properties) { if (p.IsObjectDeclaredType) @@ -603,7 +596,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator foreach (var p in ci.Properties) { sb.AppendLine(); - EmitProp(sb, p, " ", ci.FullTypeName, ci.EnableMetadata, ci.EnablePropertyFilter); + EmitProp(sb, p, " ", ci.FullTypeName, ci.EnableMetadata, ci.EnablePropertyFilter, ci.EnablePolymorphDetect); } sb.AppendLine(); @@ -757,7 +750,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine(" }"); } - private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName, bool enableMetadata, bool enablePropertyFilter) + private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName, bool enableMetadata, bool enablePropertyFilter, bool enablePolymorphDetect) { var a = $"obj.{p.Name}"; @@ -825,14 +818,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator // System.Object property: runtime type unknown at compile time. // Write ObjectWithTypeName prefix so deserializer can resolve the concrete type. // Use value.GetType() for runtime type dispatch (not typeof(object)). - // Gated by `UsePolymorphType` (TEMPORARY const) — `false` skips the type-name emit - // entirely (deser will use the property's declared type, which is `object` so the - // round-trip would fail on polymorphic instances; safe ONLY when the workload is - // known not to use polymorphic object-typed properties — true for the benchmark). + // Gated by `[AcBinarySerializable(enablePolymorphDetectFeature: ...)]` — `false` + // skips the type-name emit entirely. Misuse (false + non-null object prop at runtime) + // is caught at build time by ACBIN002 in DetectAndReportPolymorphicMisuse. sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); sb.AppendLine($"{i}else"); sb.AppendLine($"{i}{{"); - if (UsePolymorphType) + if (enablePolymorphDetect) { sb.AppendLine($"{i} if (!context.UseMetadata)"); sb.AppendLine($"{i} {{"); @@ -3065,6 +3057,12 @@ internal sealed class SerializableClassInfo /// guard emission in WriteProperties / ScanObject. When false, the filter check is omitted entirely /// → leaner generated code on the hot path (typical for high-throughput types that never use a filter). public bool EnablePropertyFilter { get; } + /// True if EnablePolymorphDetectFeature is enabled — controls ObjectWithTypeName + AQN + /// prefix emit on System.Object-declared properties. When false, the prefix is suppressed + /// AND ACBIN002 fires at build time if such a property exists on this type (guarding against silent + /// wire corruption). Opt-out is intentional: dev guarantees no polymorphic object property + /// will be serialized on this type, or all such properties are excluded via [AcBinaryIgnore]. + public bool EnablePolymorphDetect { get; } /// When true, type subtree has IId types needing scan (active in OnlyId + All). public bool NeedsIdScan { get; } /// When true, type subtree has non-IId ref tracking (active only in All mode). @@ -3075,8 +3073,8 @@ internal sealed class SerializableClassInfo public bool NeedsRefScan => NeedsIdScan || NeedsAllRefScan; /// Derived: any scan axis active. public bool NeedsScan => NeedsIdScan || NeedsAllRefScan || NeedsInternScan; - public SerializableClassInfo(string ns, string cn, string ftn, List p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool needsIdScan, bool needsAllRefScan, bool needsInternScan) - { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; } + public SerializableClassInfo(string ns, string cn, string ftn, List p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool enablePolymorphDetect, bool needsIdScan, bool needsAllRefScan, bool needsInternScan) + { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; EnablePolymorphDetect = enablePolymorphDetect; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; } } internal sealed class PropInfo diff --git a/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs b/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs index 2bbd17c..fe30016 100644 --- a/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs +++ b/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs @@ -4,12 +4,32 @@ namespace AyCode.Core.Serializers.Attributes; /// /// Marks a class or struct for Source Generator based binary serialization. -/// When applied, the Source Generator will create optimized serialize/deserialize methods -/// at compile time, eliminating the need for runtime reflection or compiled expressions. +/// When applied, the Source Generator creates optimized serialize/deserialize methods at compile +/// time, eliminating the need for runtime reflection or compiled expressions. /// /// -/// If this attribute is not present, the serializer falls back to the existing -/// compiled expression based approach which works for all types. +/// If this attribute is not present, the serializer falls back to the existing +/// compiled-expression-based approach which works for all types. +/// +/// Feature flag semantics — each Enable*Feature ctor parameter controls whether +/// the corresponding code-block is emitted by the source generator, NOT whether the feature +/// is active at runtime: +/// +/// true (default): the SGen-emitted writer / reader / scan code includes +/// the feature's branch. Runtime activation is then governed by the matching +/// AcBinarySerializerOptions setting (e.g. UseStringInterning, +/// ReferenceHandling, PropertyFilter, UseMetadata). The developer keeps the +/// choice per-call by setting the options accordingly. +/// false: the corresponding code-block is omitted from the generated +/// output entirely. Slightly leaner hot-path (fewer branches, less i-cache pressure on the JIT'd +/// bodies), but the feature is permanently off for this type regardless of runtime options. +/// A matching AcBinarySerializerOptions setting is silently ignored for this specific type. +/// +/// +/// Set a flag to false only when you can guarantee no consumer will ever need that +/// feature on this type (typical for high-throughput message DTOs where wire size and serialize +/// CPU dominate, and a feature like PropertyFilter is genuinely never used). Otherwise leave +/// it at the default true. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] public sealed class AcBinarySerializableAttribute : Attribute