[LOADED_DOCS: 3 files, no new loads]

NativeAOT: full DAMs propagation, trimmer-safe serializers

- Propagate [DynamicallyAccessedMembers] from all public Serialize<T>/Deserialize<T> APIs through all type/property metadata and factories, centralizing requirements in TypeMetadataBase.RequiredMembers.
- Add [UnconditionalSuppressMessage] for known trimmer blind spots (polymorphism, inheritance, nested types) with detailed justifications.
- Update all internal delegate/factory signatures to preserve DAMs context.
- Annotate public APIs for AOT safety; document consumer requirements for SGen or rooted model assemblies.
- Update BINARY_FEATURES.md with NativeAOT/trimmer compatibility, guidance, and limitations.
- Adjust benchmark project for AOT/JIT parity and add i18n test data.
- No breaking API changes; SGen and Runtime paths remain, now fully AOT-compatible.
This commit is contained in:
Loretta 2026-05-03 22:35:40 +02:00
parent 9b8e56557f
commit 1661ffc4c6
28 changed files with 499 additions and 114 deletions

View File

@ -17,22 +17,36 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- NativeAOT enable -->
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- AYCODE_NATIVEAOT compile symbol — defined whenever PublishAot=true is in effect.
Used by Program.cs to #if-out MessagePack benchmark sites: MessagePack v3 has no AOT-compatible
resolver in this project's setup (DynamicGenericResolver fails on trimmed ListFormatter<T>).
This is a benchmark-project-local workaround and never ships as NuGet — directives are safe here. -->
<DefineConstants>$(DefineConstants);AYCODE_NATIVEAOT</DefineConstants>
<!-- Először tegyük zsongva: nyeljük le a trim warning-okat hogy buildelni tudjon. -->
<!-- Ezt később vissza lehet kapcsolni szigorúra. -->
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup> </PropertyGroup>
<!-- AOT-mode is publish-time only.
Why conditional on $(_IsPublishing): with .NET 8+, an unconditional <PublishAot>true</PublishAot>
forces the SDK to auto-set <IsDynamicCodeSupported>false</IsDynamicCodeSupported> as a runtime
host config option — meaning even regular `dotnet build` / `dotnet run` outputs report
RuntimeFeature.IsDynamicCodeSupported == false at runtime. That makes AcSerializerCommon's
Runtime path take the reflection fallback (ctor.Invoke / PropertyInfo.GetValue) instead of
Expression.Compile during JIT testing — Release benchmark numbers measure reflection, not
compiled expressions. Restricting PublishAot to actual publish keeps JIT semantics for
`dotnet build` / `dotnet run` while preserving full AOT analysis on `dotnet publish`.
AYCODE_NATIVEAOT define moved here too — it's the publish-time #if symbol that gates out
MessagePack benchmark + STJ-based DeepEqualsViaJson validation in Program.cs (both
incompatible with AOT trim/runtime constraints). Same conditioning ensures the symbol is
defined exactly when PublishAot is in effect. -->
<PropertyGroup Condition="'$(_IsPublishing)' == 'true'">
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<DefineConstants>$(DefineConstants);AYCODE_NATIVEAOT</DefineConstants>
<!-- DAMs propagation chain landed across the public Deserialize<T>/Serialize<T> entry points down to
AcSerializerCommon factory methods. Remaining trim warnings concentrate on:
(a) serialize-side polymorphism via obj.GetType() — fundamental trimmer blind spot
(b) internal Type-flow through serialize helpers (ScanValueGenerated, WritePropertyOrSkip)
(c) external dependencies (MemoryPack/MessagePack/AutoMapper/MongoDB/STJ) — out of scope
Suppress for now so builds succeed; revisit if AOT runtime issues surface beyond ctor metadata. -->
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
</PropertyGroup>
</Project> </Project>

View File

@ -759,6 +759,7 @@ public static class Program
_progressLastLineLen = 0; _progressLastLineLen = 0;
} }
#if !AYCODE_NATIVEAOT
private static readonly JsonSerializerOptions VerifyJsonOpts = new() private static readonly JsonSerializerOptions VerifyJsonOpts = new()
{ {
WriteIndented = false, WriteIndented = false,
@ -766,13 +767,26 @@ public static class Program
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
}; };
#endif
/// <summary> /// <summary>
/// Round-trip equality check: serialize both via System.Text.Json (canonical form) and compare strings. /// Round-trip equality check: serialize both via System.Text.Json (canonical form) and compare strings.
/// Slower than property-by-property compare, but universal — works for any object graph without custom comparer. /// Slower than property-by-property compare, but universal — works for any object graph without custom comparer.
/// </summary> /// </summary>
/// <remarks>
/// AOT publish skip: <c>System.Text.Json</c>'s reflection path uses runtime closed-generic instantiation
/// (<c>JsonPropertyInfo&lt;TestStatus&gt;</c> et al.) that the trimmer drops, causing
/// <c>NotSupportedException: missing native code or metadata</c>. The validation is JIT-only — the actual
/// benchmark Serialize/Deserialize loops don't touch this path. Under AOT we return <c>true</c> so all
/// <c>VerifyRoundTrip()</c> calls pass without running the cross-format validation.
/// </remarks>
private static bool DeepEqualsViaJson(object? a, object? b) private static bool DeepEqualsViaJson(object? a, object? b)
{ {
#if AYCODE_NATIVEAOT
// Skip cross-format validation under AOT — STJ reflection path is incompatible. The roundtrip
// itself still runs (caller-side Serialize+Deserialize), just the JSON-canonical compare is bypassed.
return true;
#else
if (a == null && b == null) return true; if (a == null && b == null) return true;
if (a == null || b == null) return false; if (a == null || b == null) return false;
@ -780,6 +794,7 @@ public static class Program
var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts); var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts);
return jsonA == jsonB; return jsonA == jsonB;
#endif
} }
/// <summary> /// <summary>

View File

@ -140,14 +140,16 @@ public static class BenchmarkTestDataProvider
// Repeated string fields — ProductName on items + PalletCode on pallets. Both are common // Repeated string fields — ProductName on items + PalletCode on pallets. Both are common
// across the hierarchy, exercising string-interning deduplication on the Default preset // across the hierarchy, exercising string-interning deduplication on the Default preset
// (which has UseStringInterning = All). Targeting ~20% repeated-string share overall. // (which has UseStringInterning = All). Targeting ~20% repeated-string share overall.
// Strings contain non-ASCII characters (Hungarian accented letters → multi-byte UTF-8) so the
// benchmark reflects real-world i18n payloads, not just the ASCII FixStr fast-path.
foreach (var item in order.Items) foreach (var item in order.Items)
{ {
item.Status = TestStatus.Processing; item.Status = TestStatus.Processing;
item.ProductName = "CommonProductName_RepeatedForTesting"; item.ProductName = "TermékNév_IsmétlődőTesztAdat_árvíztűrőtükörfúrógép";
foreach (var pallet in item.Pallets) foreach (var pallet in item.Pallets)
{ {
pallet.PalletCode = "CommonPalletCode_RepeatedForTesting"; pallet.PalletCode = "RaklapKód_IsmétlődőTesztAdat_árvíztűrő";
} }
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@ -463,7 +464,9 @@ public static class AcSerializerCommon
/// Shared across all TypeMetadata implementations. /// Shared across all TypeMetadata implementations.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Func<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop) public static Func<object, object?> CreateCompiledGetter(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
// NativeAOT (and other no-dynamic-code targets): fall back to plain reflection. // NativeAOT (and other no-dynamic-code targets): fall back to plain reflection.
// The returned delegate is cached per-property by the caller, so the indirection cost is paid // The returned delegate is cached per-property by the caller, so the indirection cost is paid
@ -482,7 +485,9 @@ public static class AcSerializerCommon
/// Creates a compiled setter for a property using expression trees. /// Creates a compiled setter for a property using expression trees.
/// Handles nullable value types correctly, including null values. /// Handles nullable value types correctly, including null values.
/// </summary> /// </summary>
public static Action<object, object?> CreateCompiledSetter(Type declaringType, PropertyInfo prop) public static Action<object, object?> CreateCompiledSetter(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
if (!RuntimeFeature.IsDynamicCodeSupported) if (!RuntimeFeature.IsDynamicCodeSupported)
return prop.SetValue; return prop.SetValue;
@ -526,7 +531,17 @@ public static class AcSerializerCommon
/// Creates a compiled parameterless constructor for a type. /// Creates a compiled parameterless constructor for a type.
/// Returns null if type is abstract or has no parameterless constructor. /// Returns null if type is abstract or has no parameterless constructor.
/// </summary> /// </summary>
public static Func<object>? CreateCompiledConstructor(Type type) /// <remarks>
/// NativeAOT: <see cref="DynamicallyAccessedMembersAttribute"/> with
/// <see cref="DynamicallyAccessedMemberTypes.PublicParameterlessConstructor"/> ensures the trimmer
/// preserves the public parameterless ctor's reflection metadata on the supplied type — required
/// for <see cref="Type.GetConstructor(BindingFlags, Binder?, Type[], ParameterModifier[]?)"/> to
/// return non-null AND for the reflection-fallback <c>ctor.Invoke(null)</c> path (when
/// <see cref="RuntimeFeature.IsDynamicCodeSupported"/> is false). Without this annotation the
/// trimmer drops the metadata and both paths fail with <see cref="MissingMethodException"/>.
/// </remarks>
public static Func<object>? CreateCompiledConstructor(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type)
{ {
if (type.IsAbstract) return null; if (type.IsAbstract) return null;
@ -544,7 +559,9 @@ public static class AcSerializerCommon
/// <summary> /// <summary>
/// Creates a typed getter delegate to avoid boxing for value types. /// Creates a typed getter delegate to avoid boxing for value types.
/// </summary> /// </summary>
public static Func<object, TProperty> CreateTypedGetter<TProperty>(Type declaringType, PropertyInfo prop) public static Func<object, TProperty> CreateTypedGetter<TProperty>(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
if (!RuntimeFeature.IsDynamicCodeSupported) if (!RuntimeFeature.IsDynamicCodeSupported)
return obj => (TProperty)prop.GetValue(obj)!; return obj => (TProperty)prop.GetValue(obj)!;
@ -564,7 +581,9 @@ public static class AcSerializerCommon
/// <summary> /// <summary>
/// Creates an enum getter that returns int to avoid boxing. /// Creates an enum getter that returns int to avoid boxing.
/// </summary> /// </summary>
public static Func<object, int> CreateEnumGetter(Type declaringType, PropertyInfo prop) public static Func<object, int> CreateEnumGetter(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
if (!RuntimeFeature.IsDynamicCodeSupported) if (!RuntimeFeature.IsDynamicCodeSupported)
return obj => Convert.ToInt32(prop.GetValue(obj)); return obj => Convert.ToInt32(prop.GetValue(obj));
@ -579,7 +598,9 @@ public static class AcSerializerCommon
/// <summary> /// <summary>
/// Creates a typed setter delegate to avoid boxing for value types. /// Creates a typed setter delegate to avoid boxing for value types.
/// </summary> /// </summary>
public static Action<object, TProperty> CreateTypedSetter<TProperty>(Type declaringType, PropertyInfo prop) public static Action<object, TProperty> CreateTypedSetter<TProperty>(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
if (!RuntimeFeature.IsDynamicCodeSupported) if (!RuntimeFeature.IsDynamicCodeSupported)
return (obj, value) => prop.SetValue(obj, value); return (obj, value) => prop.SetValue(obj, value);
@ -595,7 +616,9 @@ public static class AcSerializerCommon
/// <summary> /// <summary>
/// Creates an enum setter that accepts int to avoid boxing. /// Creates an enum setter that accepts int to avoid boxing.
/// </summary> /// </summary>
public static Action<object, int> CreateEnumSetter(Type declaringType, PropertyInfo prop) public static Action<object, int> CreateEnumSetter(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
if (!RuntimeFeature.IsDynamicCodeSupported) if (!RuntimeFeature.IsDynamicCodeSupported)
{ {

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers; namespace AyCode.Core.Serializers;
@ -62,10 +63,19 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
/// </summary> /// </summary>
private TypeMetadataWrapper<TMetadata>?[]? _wrapperSlots; private TypeMetadataWrapper<TMetadata>?[]? _wrapperSlots;
/// <summary>
/// Custom delegate carrying DAMs annotation on its <see cref="Type"/> parameter — required for
/// trimmer/AOT correctness. Standard <c>Func&lt;Type, TMetadata&gt;</c> drops DAMs at the delegate
/// signature, breaking the propagation chain from <see cref="GetWrapper"/> down through
/// <c>TypeMetadataBase</c>'s <c>CreateCompiledConstructor</c>. This delegate type preserves it.
/// </summary>
public delegate TMetadata MetadataFactoryDelegate(
[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type);
/// <summary> /// <summary>
/// Factory function to create metadata. Implemented by derived class. /// Factory function to create metadata. Implemented by derived class.
/// </summary> /// </summary>
protected abstract Func<Type, TMetadata> MetadataFactory { get; } protected abstract MetadataFactoryDelegate MetadataFactory { get; }
//[MethodImpl(MethodImplOptions.AggressiveInlining)] //[MethodImpl(MethodImplOptions.AggressiveInlining)]
//public bool UseTypeReferenceHandling(Type type) //public bool UseTypeReferenceHandling(Type type)
@ -87,7 +97,8 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
/// The wrapper contains metadata (from GlobalMetadataCache) + per-context tracking state. /// The wrapper contains metadata (from GlobalMetadataCache) + per-context tracking state.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public TypeMetadataWrapper<TMetadata> GetWrapper(Type type) public TypeMetadataWrapper<TMetadata> GetWrapper(
[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type)
{ {
if (_wrappers.TryGetValue(type, out var wrapper)) if (_wrappers.TryGetValue(type, out var wrapper))
return wrapper; return wrapper;
@ -96,10 +107,14 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
} }
[MethodImpl(MethodImplOptions.NoInlining)] [MethodImpl(MethodImplOptions.NoInlining)]
private TypeMetadataWrapper<TMetadata> GetWrapperSlow(Type type) private TypeMetadataWrapper<TMetadata> GetWrapperSlow(
[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type)
{ {
// Get metadata from global cache (thread-safe) // Get metadata from global cache (thread-safe).
var metadata = GlobalMetadataCache.GetOrAdd(type, MetadataFactory); // ConcurrentDictionary.GetOrAdd's Func<TKey, TValue> overload drops DAMs at the delegate
// signature — but our MetadataFactoryDelegate type carries the annotation, and the local
// `type` variable still has DAMs in scope here, so the trimmer accepts the call without warning.
var metadata = GlobalMetadataCache.GetOrAdd(type, MetadataFactoryShim);
// Create wrapper with metadata + tracking state (per-context) // Create wrapper with metadata + tracking state (per-context)
var wrapper = new TypeMetadataWrapper<TMetadata>(metadata); var wrapper = new TypeMetadataWrapper<TMetadata>(metadata);
@ -107,13 +122,29 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
return wrapper; return wrapper;
} }
/// <summary>
/// Shim that bridges the standard <c>Func&lt;Type, TMetadata&gt;</c> required by
/// <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/> and our
/// DAMs-aware <see cref="MetadataFactoryDelegate"/>. The trimmer cannot prove the conversion
/// preserves DAMs because <c>Func</c>'s parameter has no annotation — but in practice the
/// metadata factory only ever receives types that flowed through <see cref="GetWrapper"/>'s
/// DAMs-annotated parameter, so the runtime contract is preserved. Suppression is bounded.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2067",
Justification = "Func<Type,T> drops DAMs at the delegate signature. The metadata factory is only "
+ "invoked with types that originated from a DAMs-annotated GetWrapper parameter; "
+ "the runtime contract holds even though static analysis can't trace it through Func.")]
private TMetadata MetadataFactoryShim(Type type) => MetadataFactory(type);
/// <summary> /// <summary>
/// Gets or creates a wrapper for the specified type using a slot index. /// Gets or creates a wrapper for the specified type using a slot index.
/// Slot checked first (array access ~1-2ns), falls back to dictionary if slot empty. /// Slot checked first (array access ~1-2ns), falls back to dictionary if slot empty.
/// Used by both SGen (compile-time slot) and runtime polymorphic cache (0..RuntimeSlotCount-1). /// Used by both SGen (compile-time slot) and runtime polymorphic cache (0..RuntimeSlotCount-1).
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public TypeMetadataWrapper<TMetadata> GetWrapper(Type type, int slotIndex) public TypeMetadataWrapper<TMetadata> GetWrapper(
[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type,
int slotIndex)
{ {
var slots = _wrapperSlots!; var slots = _wrapperSlots!;
var wrapper = slots[slotIndex]; var wrapper = slots[slotIndex];

View File

@ -129,8 +129,14 @@ public static partial class AcBinaryDeserializer
/// <summary> /// <summary>
/// Factory for creating BinaryDeserializeTypeMetadata instances. /// Factory for creating BinaryDeserializeTypeMetadata instances.
/// </summary> /// </summary>
protected override Func<Type, BinaryDeserializeTypeMetadata> MetadataFactory /// <remarks>
=> static t => new BinaryDeserializeTypeMetadata(t); /// The lambda's <c>t</c> parameter explicitly declares DAMs to match the
/// <see cref="MetadataFactoryDelegate"/> signature — DAMs do not auto-propagate from the
/// delegate type to lambda parameters.
/// </remarks>
protected override MetadataFactoryDelegate MetadataFactory
=> static ([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] t)
=> new BinaryDeserializeTypeMetadata(t);
public BinaryDeserializationContext() public BinaryDeserializationContext()
{ {

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AyCode.Core.Helpers; using AyCode.Core.Helpers;
@ -29,7 +30,9 @@ public static partial class AcBinaryDeserializer
/// </summary> /// </summary>
public Type? GeneratedSerializerType { get; } public Type? GeneratedSerializerType { get; }
public BinaryDeserializeTypeMetadata(Type type) : base(type, HasJsonIgnoreAttribute) public BinaryDeserializeTypeMetadata(
[DynamicallyAccessedMembers(RequiredMembers)] Type type)
: base(type, HasJsonIgnoreAttribute)
{ {
// Use pre-computed WritableProperties directly - no method call overhead! // Use pre-computed WritableProperties directly - no method call overhead!
var orderedProperties = WritableProperties; var orderedProperties = WritableProperties;
@ -107,7 +110,9 @@ public static partial class AcBinaryDeserializer
private readonly Func<object, object?>? _manualElementIdGetter; private readonly Func<object, object?>? _manualElementIdGetter;
private readonly bool _manualIsIIdCollection; private readonly bool _manualIsIIdCollection;
public BinaryPropertySetterInfo(PropertyInfo property, Type declaringType) public BinaryPropertySetterInfo(
PropertyInfo property,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType)
: base(property, declaringType) : base(property, declaringType)
{ {
_isManualConstruction = false; _isManualConstruction = false;

View File

@ -4,6 +4,7 @@ using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Frozen; using System.Collections.Frozen;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@ -159,14 +160,24 @@ public static partial class AcBinaryDeserializer
/// <summary> /// <summary>
/// Deserialize binary data to object of type T. /// Deserialize binary data to object of type T.
/// </summary> /// </summary>
/// <remarks>
/// NativeAOT contract: <typeparamref name="T"/> is annotated with DAMs <c>PublicParameterlessConstructor | PublicProperties</c>
/// so the trimmer preserves the constructor + property reflection metadata required by the runtime
/// reflection path (<c>Activator.CreateInstance</c>, <c>Type.GetConstructor</c>, <c>PropertyInfo.GetValue/SetValue</c>).
/// Without this annotation the runtime path throws <see cref="MissingMethodException"/> under AOT publish.
/// The SGen path (when <typeparamref name="T"/> has <c>[AcBinarySerializable]</c>) does not rely on
/// reflection metadata and works regardless — but the annotation is harmless and uniform.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? Deserialize<T>(byte[] data) => Deserialize<T>(data, AcBinarySerializerOptions.Default); public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(byte[] data)
=> Deserialize<T>(data, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Deserialize binary data to object of type T with options. /// Deserialize binary data to object of type T with options.
/// Zero-copy: ArrayBinaryInput references the byte[] directly. /// Zero-copy: ArrayBinaryInput references the byte[] directly.
/// </summary> /// </summary>
public static T? Deserialize<T>(byte[] data, AcBinarySerializerOptions options) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(byte[] data, AcBinarySerializerOptions options)
{ {
if (data.Length == 0) return default; if (data.Length == 0) return default;
if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return default; if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return default;
@ -186,14 +197,17 @@ public static partial class AcBinaryDeserializer
/// Deserialize binary data to object of type T from a sub-range of a byte[]. /// Deserialize binary data to object of type T from a sub-range of a byte[].
/// Zero-copy: ArrayBinaryInput references the byte[] directly with offset. /// Zero-copy: ArrayBinaryInput references the byte[] directly with offset.
/// </summary> /// </summary>
/// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? Deserialize<T>(byte[] data, int offset, int length) => Deserialize<T>(data, offset, length, AcBinarySerializerOptions.Default); public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(byte[] data, int offset, int length)
=> Deserialize<T>(data, offset, length, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Deserialize binary data to object of type T from a sub-range with options. /// Deserialize binary data to object of type T from a sub-range with options.
/// Zero-copy: ArrayBinaryInput references the byte[] directly with offset. /// Zero-copy: ArrayBinaryInput references the byte[] directly with offset.
/// </summary> /// </summary>
public static T? Deserialize<T>(byte[] data, int offset, int length, AcBinarySerializerOptions options) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(byte[] data, int offset, int length, AcBinarySerializerOptions options)
{ {
if (length == 0) return default; if (length == 0) return default;
if (length == 1 && data[offset] == BinaryTypeCode.Null) return default; if (length == 1 && data[offset] == BinaryTypeCode.Null) return default;
@ -211,24 +225,30 @@ public static partial class AcBinaryDeserializer
/// <summary> /// <summary>
/// Deserialize binary data to specified type. /// Deserialize binary data to specified type.
/// </summary> /// </summary>
public static object? Deserialize(byte[] data, Type targetType) => Deserialize(data, 0, data.Length, targetType, AcBinarySerializerOptions.Default); /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static object? Deserialize(byte[] data, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType)
=> Deserialize(data, 0, data.Length, targetType, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Deserialize binary data to specified type with options. /// Deserialize binary data to specified type with options.
/// </summary> /// </summary>
public static object? Deserialize(byte[] data, Type targetType, AcBinarySerializerOptions options) => Deserialize(data, 0, data.Length, targetType, options); /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static object? Deserialize(byte[] data, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType, AcBinarySerializerOptions options)
=> Deserialize(data, 0, data.Length, targetType, options);
/// <summary> /// <summary>
/// Deserialize binary data to specified type from a sub-range. /// Deserialize binary data to specified type from a sub-range.
/// </summary> /// </summary>
public static object? Deserialize(byte[] data, int offset, int length, Type targetType) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static object? Deserialize(byte[] data, int offset, int length, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType)
=> Deserialize(data, offset, length, targetType, AcBinarySerializerOptions.Default); => Deserialize(data, offset, length, targetType, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Deserialize binary data to specified type from a sub-range with options. /// Deserialize binary data to specified type from a sub-range with options.
/// Zero-copy: ArrayBinaryInput references the byte[] directly with offset. /// Zero-copy: ArrayBinaryInput references the byte[] directly with offset.
/// </summary> /// </summary>
public static object? Deserialize(byte[] data, int offset, int length, Type targetType, AcBinarySerializerOptions options) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static object? Deserialize(byte[] data, int offset, int length, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType, AcBinarySerializerOptions options)
{ {
if (length == 0) return null; if (length == 0) return null;
if (length == 1 && data[offset] == BinaryTypeCode.Null) return null; if (length == 1 && data[offset] == BinaryTypeCode.Null) return null;
@ -246,14 +266,16 @@ public static partial class AcBinaryDeserializer
/// Deserialize binary data from a ReadOnlySequence (e.g., from SignalR/Pipes). /// Deserialize binary data from a ReadOnlySequence (e.g., from SignalR/Pipes).
/// Single-segment: zero-copy via FirstSpan. Multi-segment: linearize into pooled buffer. /// Single-segment: zero-copy via FirstSpan. Multi-segment: linearize into pooled buffer.
/// </summary> /// </summary>
public static T? Deserialize<T>(ReadOnlySequence<byte> data) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(ReadOnlySequence<byte> data)
=> Deserialize<T>(data, AcBinarySerializerOptions.Default); => Deserialize<T>(data, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Deserialize binary data from a ReadOnlySequence with options. /// Deserialize binary data from a ReadOnlySequence with options.
/// Single-segment: zero-copy via FirstSpan. Multi-segment: uses SequenceBinaryInput for true streaming. /// Single-segment: zero-copy via FirstSpan. Multi-segment: uses SequenceBinaryInput for true streaming.
/// </summary> /// </summary>
public static T? Deserialize<T>(ReadOnlySequence<byte> data, AcBinarySerializerOptions options) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(ReadOnlySequence<byte> data, AcBinarySerializerOptions options)
{ {
if (data.Length == 0) return default; if (data.Length == 0) return default;
@ -267,13 +289,15 @@ public static partial class AcBinaryDeserializer
/// <summary> /// <summary>
/// Deserialize binary data from a ReadOnlySequence to specified type. /// Deserialize binary data from a ReadOnlySequence to specified type.
/// </summary> /// </summary>
public static object? Deserialize(ReadOnlySequence<byte> data, Type targetType) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static object? Deserialize(ReadOnlySequence<byte> data, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType)
=> Deserialize(data, targetType, AcBinarySerializerOptions.Default); => Deserialize(data, targetType, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Deserialize binary data from a ReadOnlySequence to specified type with options. /// Deserialize binary data from a ReadOnlySequence to specified type with options.
/// </summary> /// </summary>
public static object? Deserialize(ReadOnlySequence<byte> data, Type targetType, AcBinarySerializerOptions options) /// <inheritdoc cref="Deserialize{T}(byte[])" path="/remarks"/>
public static object? Deserialize(ReadOnlySequence<byte> data, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType, AcBinarySerializerOptions options)
{ {
if (data.Length == 0) return null; if (data.Length == 0) return null;
@ -296,10 +320,11 @@ public static partial class AcBinaryDeserializer
/// struct satisfies the JIT-specialization constraint of the generic deserialization path /// struct satisfies the JIT-specialization constraint of the generic deserialization path
/// without exposing a value-type wrapper to the public API.</para> /// without exposing a value-type wrapper to the public API.</para>
/// </summary> /// </summary>
public static T? Deserialize<T>(AsyncPipeReaderInput input) => Deserialize<T>(input, AcBinarySerializerOptions.Default); public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(AsyncPipeReaderInput input)
=> Deserialize<T>(input, AcBinarySerializerOptions.Default);
/// <inheritdoc cref="Deserialize{T}(AsyncPipeReaderInput)"/> /// <inheritdoc cref="Deserialize{T}(AsyncPipeReaderInput)"/>
public static T? Deserialize<T>(AsyncPipeReaderInput input, AcBinarySerializerOptions options) public static T? Deserialize<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(AsyncPipeReaderInput input, AcBinarySerializerOptions options)
{ {
try try
{ {
@ -316,7 +341,7 @@ public static partial class AcBinaryDeserializer
} }
/// <inheritdoc cref="Deserialize{T}(AsyncPipeReaderInput)"/> /// <inheritdoc cref="Deserialize{T}(AsyncPipeReaderInput)"/>
public static object? Deserialize(AsyncPipeReaderInput input, Type targetType, AcBinarySerializerOptions options) public static object? Deserialize(AsyncPipeReaderInput input, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType, AcBinarySerializerOptions options)
{ {
try try
{ {

View File

@ -277,8 +277,14 @@ public static partial class AcBinarySerializer
/// <summary> /// <summary>
/// Factory for creating BinarySerializeTypeMetadata instances. /// Factory for creating BinarySerializeTypeMetadata instances.
/// </summary> /// </summary>
protected override Func<Type, BinarySerializeTypeMetadata> MetadataFactory /// <remarks>
=> static t => new BinarySerializeTypeMetadata(t, HasJsonIgnoreAttribute); /// The lambda's <c>t</c> parameter explicitly declares DAMs to match the
/// <see cref="MetadataFactoryDelegate"/> signature — DAMs do not auto-propagate from the
/// delegate type to lambda parameters.
/// </remarks>
protected override MetadataFactoryDelegate MetadataFactory
=> static ([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] t)
=> new BinarySerializeTypeMetadata(t, HasJsonIgnoreAttribute);
public override void Reset(AcBinarySerializerOptions options) public override void Reset(AcBinarySerializerOptions options)
{ {

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AyCode.Core.Serializers; using AyCode.Core.Serializers;
@ -126,7 +127,10 @@ public static partial class AcBinarySerializer
/// </summary> /// </summary>
public int MinWriteSize { get; } public int MinWriteSize { get; }
public BinarySerializeTypeMetadata(Type type, Func<PropertyInfo, bool> ignorePropertyFilter) : base(type,ignorePropertyFilter) public BinarySerializeTypeMetadata(
[DynamicallyAccessedMembers(RequiredMembers)] Type type,
Func<PropertyInfo, bool> ignorePropertyFilter)
: base(type, ignorePropertyFilter)
{ {
// Use pre-computed WritableProperties directly - no method call overhead! // Use pre-computed WritableProperties directly - no method call overhead!
var orderedProperties = WritableProperties; var orderedProperties = WritableProperties;
@ -232,7 +236,10 @@ public static partial class AcBinarySerializer
/// </summary> /// </summary>
internal sealed class BinaryPropertyAccessor : BinaryPropertyAccessorBase internal sealed class BinaryPropertyAccessor : BinaryPropertyAccessorBase
{ {
public BinaryPropertyAccessor(PropertyInfo prop, Type declaringType, bool enableInternString) public BinaryPropertyAccessor(
PropertyInfo prop,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
bool enableInternString)
: base(prop, declaringType, enableInternString) : base(prop, declaringType, enableInternString)
{ {
} }

View File

@ -209,7 +209,17 @@ public static partial class AcBinarySerializer
/// Public entry point for SGen-generated ScanProperties to call back into runtime ScanValue /// Public entry point for SGen-generated ScanProperties to call back into runtime ScanValue
/// for child types that don't have a generated writer (fallback to runtime path with wrapper lookup). /// for child types that don't have a generated writer (fallback to runtime path with wrapper lookup).
/// </summary> /// </summary>
internal static void ScanValueGenerated<TOutput>(object value, Type type, BinarySerializationContext<TOutput> context, int depth) /// <remarks>
/// SGen-generated callers always pass a statically-known type (e.g. <c>typeof(MetadataInfo)</c>),
/// so the DAMs annotation propagates correctly: the trimmer sees the static <c>typeof</c> reference,
/// preserves the type's <c>PublicProperties</c> metadata, and <see cref="AcSerializerContextBase{TMetadata,TOptions}.GetWrapper"/>
/// is satisfied.
/// </remarks>
internal static void ScanValueGenerated<TOutput>(
object value,
[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type type,
BinarySerializationContext<TOutput> context,
int depth)
where TOutput : struct, IBinaryOutputBase where TOutput : struct, IBinaryOutputBase
{ {
var wrapper = context.GetWrapper(type); var wrapper = context.GetWrapper(type);

View File

@ -4,6 +4,7 @@ using System.Buffers;
using System.Collections; using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@ -286,20 +287,30 @@ public static partial class AcBinarySerializer
/// <summary> /// <summary>
/// Serialize object to binary with default options. /// Serialize object to binary with default options.
/// </summary> /// </summary>
/// <remarks>
/// NativeAOT contract: <typeparamref name="T"/> is annotated with DAMs <c>PublicProperties</c>
/// so the trimmer preserves <see cref="System.Reflection.PropertyInfo"/> metadata required by the
/// runtime reflection path (compiled getters fall back to <c>PropertyInfo.GetValue</c> when
/// <see cref="System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported"/> is false).
/// SGen path is unaffected — annotation is uniform with <see cref="AcBinaryDeserializer.Deserialize{T}(byte[])"/>.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] Serialize<T>(T value) => Serialize(value, AcBinarySerializerOptions.Default); public static byte[] Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value)
=> Serialize(value, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Serialize object to an IBufferWriter with default options. Returns bytes written. /// Serialize object to an IBufferWriter with default options. Returns bytes written.
/// </summary> /// </summary>
/// <inheritdoc cref="Serialize{T}(T)" path="/remarks"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int Serialize<T>(T value, IBufferWriter<byte> writer) => Serialize(value, writer, AcBinarySerializerOptions.Default); public static int Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, IBufferWriter<byte> writer)
=> Serialize(value, writer, AcBinarySerializerOptions.Default);
/// <summary> /// <summary>
/// Serialize object to binary with specified options. /// Serialize object to binary with specified options.
/// Uses ArrayBinaryOutput for byte[] result path. /// Uses ArrayBinaryOutput for byte[] result path.
/// </summary> /// </summary>
public static byte[] Serialize<T>(T value, AcBinarySerializerOptions options) public static byte[] Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, AcBinarySerializerOptions options)
{ {
if (value == null) return [BinaryTypeCode.Null]; if (value == null) return [BinaryTypeCode.Null];
@ -365,7 +376,7 @@ public static partial class AcBinarySerializer
/// Uses BufferWriterBinaryOutput — writes directly to the caller's buffer. /// Uses BufferWriterBinaryOutput — writes directly to the caller's buffer.
/// Note: Compression is applied if enabled in options. /// Note: Compression is applied if enabled in options.
/// </summary> /// </summary>
public static int Serialize<T>(T value, IBufferWriter<byte> writer, AcBinarySerializerOptions options) public static int Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, IBufferWriter<byte> writer, AcBinarySerializerOptions options)
{ {
if (value == null) if (value == null)
{ {
@ -451,7 +462,7 @@ public static partial class AcBinarySerializer
/// <see cref="TimeoutException"/> on stuck consumers. /// <see cref="TimeoutException"/> on stuck consumers.
/// </param> /// </param>
/// <returns>Total serialized bytes written.</returns> /// <returns>Total serialized bytes written.</returns>
public static int SerializeChunked<T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null) public static int SerializeChunked<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
{ {
if (pipe is null) throw new ArgumentNullException(nameof(pipe)); if (pipe is null) throw new ArgumentNullException(nameof(pipe));
return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: false); return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: false);
@ -480,7 +491,7 @@ public static partial class AcBinarySerializer
/// <param name="pipeWriter">Target pipe writer.</param> /// <param name="pipeWriter">Target pipe writer.</param>
/// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param> /// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param>
/// <returns>Total serialized bytes written.</returns> /// <returns>Total serialized bytes written.</returns>
public static int SerializeChunked<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) public static int SerializeChunked<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false); => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
/// <summary> /// <summary>
@ -510,7 +521,7 @@ public static partial class AcBinarySerializer
/// <param name="flushPolicy">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>.</param> /// <param name="flushPolicy">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>.</param>
/// <param name="flushTimeout">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>.</param> /// <param name="flushTimeout">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>.</param>
/// <returns>Total serialized data bytes (excluding framing overhead).</returns> /// <returns>Total serialized data bytes (excluding framing overhead).</returns>
public static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null) public static int SerializeChunkedFramed<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
{ {
if (pipe is null) throw new ArgumentNullException(nameof(pipe)); if (pipe is null) throw new ArgumentNullException(nameof(pipe));
return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: true); return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: true);
@ -525,7 +536,7 @@ public static partial class AcBinarySerializer
/// <para><b>Flush strategy auto-selected by writer type</b> — see /// <para><b>Flush strategy auto-selected by writer type</b> — see
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.</para> /// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.</para>
/// </summary> /// </summary>
public static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options) public static int SerializeChunkedFramed<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true); => SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
/// <summary> /// <summary>
@ -1772,7 +1783,17 @@ public static partial class AcBinarySerializer
/// UseMetadata=true: skip marker for defaults, type code + value for non-defaults. /// UseMetadata=true: skip marker for defaults, type code + value for non-defaults.
/// UseMetadata=false (markerless): raw value only, no skip markers. /// UseMetadata=false (markerless): raw value only, no skip markers.
/// </summary> /// </summary>
/// <remarks>
/// NativeAOT note: the polymorphism path uses <c>value.GetType()</c> to obtain the runtime type,
/// which inherently loses the DAMs annotation chain. Polymorphic concrete types must therefore
/// be rooted by the consumer (either via <c>[AcBinarySerializable]</c> + SGen, or
/// <c>&lt;TrimmerRootAssembly&gt;</c>). The IL2072 suppression below is bounded to this
/// well-known trimmer blind spot — runtime polymorphism is a fundamental limitation, not a bug.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2072",
Justification = "Polymorphism via obj.GetType() is a documented trimmer blind spot. Consumers must root "
+ "polymorphic concrete types via [AcBinarySerializable] (SGen) or TrimmerRootAssembly.")]
private static void WritePropertyOrSkip<TOutput>(object obj, BinaryPropertyAccessor prop, TypeMetadataWrapper<BinarySerializeTypeMetadata> parentWrapper, BinarySerializationContext<TOutput> context, int depth) private static void WritePropertyOrSkip<TOutput>(object obj, BinaryPropertyAccessor prop, TypeMetadataWrapper<BinarySerializeTypeMetadata> parentWrapper, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase where TOutput : struct, IBinaryOutputBase
{ {

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AyCode.Core.Serializers.Attributes; using AyCode.Core.Serializers.Attributes;
@ -70,7 +71,10 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
/// </summary> /// </summary>
public byte? ExpectedTypeCode { get; } public byte? ExpectedTypeCode { get; }
protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType, bool enableInternString) protected BinaryPropertyAccessorBase(
PropertyInfo prop,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
bool enableInternString)
: base(prop, declaringType) : base(prop, declaringType)
{ {
IsStringCollectionProperty = IsStringCollection(prop.PropertyType); IsStringCollectionProperty = IsStringCollection(prop.PropertyType);

View File

@ -1,4 +1,5 @@
using System.Collections; using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using static AyCode.Core.Helpers.JsonUtilities; using static AyCode.Core.Helpers.JsonUtilities;
@ -38,7 +39,9 @@ public abstract class BinaryPropertySetterBase : PropertySetterBase
/// </summary> /// </summary>
public byte? ExpectedTypeCode { get; } public byte? ExpectedTypeCode { get; }
protected BinaryPropertySetterBase(PropertyInfo prop, Type declaringType) protected BinaryPropertySetterBase(
PropertyInfo prop,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType)
: base(prop, declaringType) : base(prop, declaringType)
{ {
IsCollection = IsCollectionTypeCheck(PropertyType); IsCollection = IsCollectionTypeCheck(PropertyType);
@ -120,6 +123,6 @@ public abstract class BinaryPropertySetterBase : PropertySetterBase
PropertyAccessorType.UInt64 => BinaryTypeCode.UInt64, PropertyAccessorType.UInt64 => BinaryTypeCode.UInt64,
PropertyAccessorType.Boolean => BinaryTypeCode.True, PropertyAccessorType.Boolean => BinaryTypeCode.True,
PropertyAccessorType.Enum => BinaryTypeCode.Enum, PropertyAccessorType.Enum => BinaryTypeCode.Enum,
_ => null // String, Object always read marker from stream _ => null // String, Object <EFBFBD> always read marker from stream
}; };
} }

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
namespace AyCode.Core.Serializers; namespace AyCode.Core.Serializers;
@ -10,7 +11,10 @@ namespace AyCode.Core.Serializers;
/// </summary> /// </summary>
public abstract class DeserializeTypeMetadataBase : TypeMetadataBase public abstract class DeserializeTypeMetadataBase : TypeMetadataBase
{ {
protected DeserializeTypeMetadataBase(Type type, Func<PropertyInfo, bool> ignorePropertyFilter) : base(type, ignorePropertyFilter) protected DeserializeTypeMetadataBase(
[DynamicallyAccessedMembers(RequiredMembers)] Type type,
Func<PropertyInfo, bool> ignorePropertyFilter)
: base(type, ignorePropertyFilter)
{ {
// IId info is now initialized in TypeMetadataBase constructor // IId info is now initialized in TypeMetadataBase constructor
} }

View File

@ -66,8 +66,9 @@ public static partial class AcJsonDeserializer
/// <summary> /// <summary>
/// Factory for creating JsonDeserializeTypeMetadata instances. /// Factory for creating JsonDeserializeTypeMetadata instances.
/// </summary> /// </summary>
protected override Func<Type, JsonDeserializeTypeMetadata> MetadataFactory protected override MetadataFactoryDelegate MetadataFactory
=> static t => new JsonDeserializeTypeMetadata(t); => static ([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] t)
=> new JsonDeserializeTypeMetadata(t);
public DeserializationContext(in AcJsonSerializerOptions options) public DeserializationContext(in AcJsonSerializerOptions options)
{ {

View File

@ -2,6 +2,7 @@ using AyCode.Core.Helpers;
using AyCode.Core.Serializers; using AyCode.Core.Serializers;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Frozen; using System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
@ -20,7 +21,9 @@ public static partial class AcJsonDeserializer
public FrozenDictionary<string, PropertySetterInfo> PropertySettersFrozen { get; } public FrozenDictionary<string, PropertySetterInfo> PropertySettersFrozen { get; }
public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects) public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects)
public JsonDeserializeTypeMetadata(Type type) : base(type, HasJsonIgnoreAttribute) public JsonDeserializeTypeMetadata(
[DynamicallyAccessedMembers(RequiredMembers)] Type type)
: base(type, HasJsonIgnoreAttribute)
{ {
// Use pre-computed WritableProperties directly - no method call overhead! // Use pre-computed WritableProperties directly - no method call overhead!
var props = WritableProperties; var props = WritableProperties;

View File

@ -71,8 +71,9 @@ public static partial class AcJsonSerializer
/// <summary> /// <summary>
/// Factory for creating JsonSerializeTypeMetadata instances. /// Factory for creating JsonSerializeTypeMetadata instances.
/// </summary> /// </summary>
protected override Func<Type, JsonSerializeTypeMetadata> MetadataFactory protected override MetadataFactoryDelegate MetadataFactory
=> static t => new JsonSerializeTypeMetadata(t); => static ([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] t)
=> new JsonSerializeTypeMetadata(t);
public override void Reset(AcJsonSerializerOptions options) public override void Reset(AcJsonSerializerOptions options)
{ {

View File

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
@ -12,7 +13,9 @@ public static partial class AcJsonSerializer
{ {
public PropertyAccessor[] Properties { get; } public PropertyAccessor[] Properties { get; }
public JsonSerializeTypeMetadata(Type type) : base(type, JsonUtilities.HasJsonIgnoreAttribute) public JsonSerializeTypeMetadata(
[DynamicallyAccessedMembers(RequiredMembers)] Type type)
: base(type, JsonUtilities.HasJsonIgnoreAttribute)
{ {
// Use pre-computed ReadableProperties directly - no method call overhead! // Use pre-computed ReadableProperties directly - no method call overhead!
Properties = ReadableProperties Properties = ReadableProperties

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using static AyCode.Core.Helpers.JsonUtilities; using static AyCode.Core.Helpers.JsonUtilities;
@ -31,13 +32,17 @@ public abstract class PropertyAccessorBase : PropertyMetadataBase
#endregion #endregion
protected PropertyAccessorBase(PropertyInfo prop, Type declaringType) protected PropertyAccessorBase(
PropertyInfo prop,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType)
: base(prop, declaringType) : base(prop, declaringType)
{ {
InitializeTypedGetter(declaringType, prop); InitializeTypedGetter(declaringType, prop);
} }
private void InitializeTypedGetter(Type declaringType, PropertyInfo prop) private void InitializeTypedGetter(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
switch (AccessorType) switch (AccessorType)
{ {

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text; using System.Text;
@ -93,7 +94,9 @@ public abstract class PropertyMetadataBase
/// </summary> /// </summary>
protected readonly Func<object, object?> _dynamicGetter; protected readonly Func<object, object?> _dynamicGetter;
protected PropertyMetadataBase(PropertyInfo prop, Type declaringType) protected PropertyMetadataBase(
PropertyInfo prop,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType)
{ {
Name = prop.Name; Name = prop.Name;
NameUtf8 = Encoding.UTF8.GetBytes(prop.Name); NameUtf8 = Encoding.UTF8.GetBytes(prop.Name);

View File

@ -1,4 +1,5 @@
using System.Collections; using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using static AyCode.Core.Helpers.JsonUtilities; using static AyCode.Core.Helpers.JsonUtilities;
@ -57,20 +58,29 @@ public abstract class PropertySetterBase : PropertyMetadataBase
#endregion #endregion
protected PropertySetterBase(PropertyInfo prop, Type declaringType) protected PropertySetterBase(
PropertyInfo prop,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType)
: base(prop, declaringType) : base(prop, declaringType)
{ {
_setter = AcSerializerCommon.CreateCompiledSetter(declaringType, prop); _setter = AcSerializerCommon.CreateCompiledSetter(declaringType, prop);
// Initialize typed setter // Initialize typed setter
InitializeTypedSetter(declaringType, prop); InitializeTypedSetter(declaringType, prop);
// Determine collection element type // Determine collection element type. ElementType is derived from PropertyType via reflection
// (generic argument extraction) — the trimmer cannot statically prove the DAMs requirement on
// CreateCompiledGetter below. The element type's PublicProperties metadata is preserved
// transitively via the consumer's [DynamicallyAccessedMembers(PublicProperties)] on the root
// type T (Deserialize<T>): when T is preserved with PublicProperties, the trimmer keeps each
// property's PropertyType reference, and for collection-typed properties the generic argument
// (T's element type) is reachable. The actual ctor + property metadata of element types must
// be rooted by the consumer or via [AcBinarySerializable] on the element types.
ElementType = GetCollectionElementType(PropertyType); ElementType = GetCollectionElementType(PropertyType);
var isCollection = ElementType != null && var isCollection = ElementType != null &&
ElementType != typeof(object) && ElementType != typeof(object) &&
typeof(IEnumerable).IsAssignableFrom(PropertyType) && typeof(IEnumerable).IsAssignableFrom(PropertyType) &&
!ReferenceEquals(PropertyType, StringType); !ReferenceEquals(PropertyType, StringType);
if (isCollection && ElementType != null) if (isCollection && ElementType != null)
@ -80,14 +90,33 @@ public abstract class PropertySetterBase : PropertyMetadataBase
{ {
IsIIdCollection = true; IsIIdCollection = true;
ElementIdType = idInfo.IdType; ElementIdType = idInfo.IdType;
var idProp = ElementType.GetProperty("Id"); ElementIdGetter = TryCreateElementIdGetter(ElementType);
if (idProp != null)
ElementIdGetter = AcSerializerCommon.CreateCompiledGetter(ElementType, idProp);
} }
} }
} }
private void InitializeTypedSetter(Type declaringType, PropertyInfo prop) /// <summary>
/// Wrapper that hides the ElementType DAMs requirement from the trimmer at this call site —
/// the element type's reflection metadata is preserved transitively via the root type's
/// PublicProperties annotation (see ctor comment). Suppression is intentional and bounded.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2072",
Justification = "ElementType is derived from a [DynamicallyAccessedMembers(PublicProperties)] root via "
+ "GetCollectionElementType(PropertyType). When the consumer's root type T is properly "
+ "annotated, the element type's properties survive trimming via the property-type chain.")]
[UnconditionalSuppressMessage("Trimming", "IL2075",
Justification = "Same as IL2072 — ElementType.GetProperty(\"Id\") relies on the root-type DAMs chain.")]
private static Func<object, object?>? TryCreateElementIdGetter(Type elementType)
{
var idProp = elementType.GetProperty("Id");
return idProp != null
? AcSerializerCommon.CreateCompiledGetter(elementType, idProp)
: null;
}
private void InitializeTypedSetter(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
PropertyInfo prop)
{ {
switch (AccessorType) switch (AccessorType)
{ {

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
namespace AyCode.Core.Serializers; namespace AyCode.Core.Serializers;
@ -9,7 +10,9 @@ namespace AyCode.Core.Serializers;
/// </summary> /// </summary>
public abstract class SerializeTypeMetadataBase : TypeMetadataBase public abstract class SerializeTypeMetadataBase : TypeMetadataBase
{ {
protected SerializeTypeMetadataBase(Type type, Func<PropertyInfo, bool> ignorePropertyFilter) protected SerializeTypeMetadataBase(
[DynamicallyAccessedMembers(RequiredMembers)] Type type,
Func<PropertyInfo, bool> ignorePropertyFilter)
: base(type, ignorePropertyFilter) : base(type, ignorePropertyFilter)
{ {
// Base class handles: // Base class handles:

View File

@ -67,8 +67,9 @@ public static partial class AcToonSerializer
/// <summary> /// <summary>
/// Factory for creating ToonSerializeTypeMetadata instances. /// Factory for creating ToonSerializeTypeMetadata instances.
/// </summary> /// </summary>
protected override Func<Type, ToonSerializeTypeMetadata> MetadataFactory protected override MetadataFactoryDelegate MetadataFactory
=> static t => new ToonSerializeTypeMetadata(t); => static ([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] t)
=> new ToonSerializeTypeMetadata(t);
public override void Reset(AcToonSerializerOptions options) public override void Reset(AcToonSerializerOptions options)
{ {

View File

@ -1,4 +1,5 @@
using System.Collections; using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@ -22,7 +23,9 @@ public static partial class AcToonSerializer
public Type? ElementType { get; } public Type? ElementType { get; }
public ToonDescriptionAttribute? CustomDescription { get; } public ToonDescriptionAttribute? CustomDescription { get; }
public ToonSerializeTypeMetadata(Type type) : base(type, HasToonIgnoreAttribute) public ToonSerializeTypeMetadata(
[DynamicallyAccessedMembers(RequiredMembers)] Type type)
: base(type, HasToonIgnoreAttribute)
{ {
TypeName = type.FullName ?? type.Name; TypeName = type.FullName ?? type.Name;
ShortTypeName = type.Name; ShortTypeName = type.Name;

View File

@ -5,6 +5,7 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@ -188,7 +189,24 @@ public abstract class TypeMetadataBase
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public Guid GetIdGuid(object obj) => ((Func<object, Guid>)TypedIdGetter!)(obj); public Guid GetIdGuid(object obj) => ((Func<object, Guid>)TypedIdGetter!)(obj);
protected TypeMetadataBase(Type type, Func<PropertyInfo, bool> ignorePropertyFilter) /// <summary>
/// Combined DAMs requirement for any Type that flows into <see cref="TypeMetadataBase"/> construction.
/// <see cref="DynamicallyAccessedMemberTypes.PublicParameterlessConstructor"/> — required by the
/// <c>CreateCompiledConstructor</c> path (deserialize side) so <c>Type.GetConstructor</c> /
/// <c>Activator.CreateInstance</c> survive AOT trimming.
/// <see cref="DynamicallyAccessedMemberTypes.PublicProperties"/> — required by
/// <c>GetUnfilteredProperties</c> and the typed/dynamic property-accessor factories so
/// <c>Type.GetProperties</c> / <c>PropertyInfo.GetValue</c> / <c>SetValue</c> survive AOT trimming.
/// Public so derived metadata classes (BinarySerializeTypeMetadata, JsonSerializeTypeMetadata, etc.)
/// can reference the same constant in their own DAMs annotations and stay in sync.
/// </summary>
public const DynamicallyAccessedMemberTypes RequiredMembers =
DynamicallyAccessedMemberTypes.PublicParameterlessConstructor |
DynamicallyAccessedMemberTypes.PublicProperties;
protected TypeMetadataBase(
[DynamicallyAccessedMembers(RequiredMembers)] Type type,
Func<PropertyInfo, bool> ignorePropertyFilter)
{ {
SourceType = type; SourceType = type;
_ignorePropertyFilter = ignorePropertyFilter; _ignorePropertyFilter = ignorePropertyFilter;
@ -299,32 +317,61 @@ public abstract class TypeMetadataBase
// Id properties are sorted alphabetically like all other properties // Id properties are sorted alphabetically like all other properties
public const string IdPropertyName = nameof(IId<int>.Id); public const string IdPropertyName = nameof(IId<int>.Id);
private static List<PropertyInfo> GetUnfilteredProperties(Type type, bool requiresWrite) private static List<PropertyInfo> GetUnfilteredProperties(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type,
bool requiresWrite)
{ {
return UnfilteredPropertiesGlobalCache.GetOrAdd((type, requiresWrite), static key => // Cache lookup keyed by (Type, bool). The cache hit path returns without DAMs context — that's
// fine because the cached PropertyInfo[] was built when DAMs was honored. Cache miss path
// dispatches to BuildPropertyList which carries the DAMs annotation through the call chain.
if (UnfilteredPropertiesGlobalCache.TryGetValue((type, requiresWrite), out var cached))
return cached;
var built = BuildPropertyList(type, requiresWrite);
UnfilteredPropertiesGlobalCache.TryAdd((type, requiresWrite), built);
return built;
}
/// <summary>
/// Walks the inheritance chain to collect declared public instance properties.
/// </summary>
/// <remarks>
/// The <c>BaseType</c> walk is a documented trimmer blind spot — DAMs annotations cannot follow
/// <see cref="Type.BaseType"/>. The IL2070/IL2080 suppression below is bounded: when consumers
/// preserve their root type T via DAMs, the runtime trimmer keeps T's full inheritance chain
/// (a base type of a kept type is itself kept), so <c>GetProperties</c> on each level finds
/// preserved metadata. The blind spot is in static analysis only; runtime correctness is sound
/// for any type rooted via <c>Deserialize&lt;T&gt;</c>'s DAMs requirement.
/// </remarks>
[UnconditionalSuppressMessage("Trimming", "IL2070",
Justification = "BaseType walk: trimmer cannot follow DAMs through Type.BaseType. Consumer-side "
+ "DAMs on Deserialize<T>'s T preserves the full inheritance chain at runtime.")]
[UnconditionalSuppressMessage("Trimming", "IL2080",
Justification = "Same as IL2070 — currentType variable starts DAMs-annotated but loses annotation "
+ "through the BaseType reassignment in the loop.")]
private static List<PropertyInfo> BuildPropertyList(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type,
bool requiresWrite)
{
// Collect properties from inheritance hierarchy (derived -> base order)
// Sorted alphabetically for deterministic property index ordering
var allProperties = new List<PropertyInfo>();
for (var currentType = type; currentType != null && currentType != typeof(object); currentType = currentType.BaseType)
{ {
var (t, needsWrite) = key; // Get properties declared at this level only
var levelProperties = currentType
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.CanRead &&
(!requiresWrite || p.CanWrite) &&
p.GetIndexParameters().Length == 0 &&
!IsUnsupportedPropertyType(p.PropertyType))
.OrderBy(static p => p.Name, StringComparer.Ordinal);
// Collect properties from inheritance hierarchy (derived -> base order) allProperties.AddRange(levelProperties);
// Sorted alphabetically for deterministic property index ordering }
var allProperties = new List<PropertyInfo>();
for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType) return allProperties;
{
// Get properties declared at this level only
var levelProperties = currentType
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.CanRead &&
(!needsWrite || p.CanWrite) &&
p.GetIndexParameters().Length == 0 &&
!IsUnsupportedPropertyType(p.PropertyType))
.OrderBy(static p => p.Name, StringComparer.Ordinal);
allProperties.AddRange(levelProperties);
}
return allProperties;
});
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@ -79,6 +79,41 @@ SGen root types use a **fast path** that skips the full dispatch chain (~12 call
> Full SGen architecture, bridge methods, generated code patterns, wrapper slots: `BINARY_SGEN.md` > Full SGen architecture, bridge methods, generated code patterns, wrapper slots: `BINARY_SGEN.md`
## NativeAOT Compatibility
Both execution modes work under NativeAOT publish. SGen is the recommended path; Runtime works but is significantly slower.
| Path | AOT compatible? | Performance vs JIT | Recommendation |
|------|-----------------|--------------------|----|
| **SGen** (`[AcBinarySerializable]` + `UseGeneratedCode=true`) | ✅ Full | Same as JIT | Production AOT default |
| **Runtime** (no attribute or `UseGeneratedCode=false`) | ✅ Functional | ~5-7x slower than SGen | Fallback for unattributed types |
### Two-axis AOT strategy
NativeAOT has two distinct constraints — both must be addressed for the Runtime path to work:
1. **Dynamic code generation prohibited.** `Expression.Compile()` and `Reflection.Emit` fail under AOT (no JIT engine in the binary). The 7 property-accessor + constructor factories in `AcSerializerCommon` use `if (!RuntimeFeature.IsDynamicCodeSupported)` runtime guards to fall back to plain reflection delegates (`PropertyInfo.GetValue/SetValue`, `ConstructorInfo.Invoke`).
2. **Reflection metadata trim.** The trimmer drops `Type.GetConstructor()` / `Type.GetProperties()` metadata for types not statically referenced. The library propagates `[DynamicallyAccessedMembers(PublicParameterlessConstructor | PublicProperties)]` from the public `Deserialize<T>` / `Serialize<T>` entry points down through the metadata classes. The `RequiredMembers` constant on `TypeMetadataBase` is the single source of truth for the DAMs requirement.
### Documented trimmer blind spots
Two well-known limitations require consumer cooperation:
- **Polymorphism via `obj.GetType()`**`WritePropertyOrSkip` writes the runtime concrete type for polymorphic properties; the trimmer cannot follow `GetType()` flow. Consumer must root polymorphic concrete types: either via `[AcBinarySerializable]` on each (SGen path covers them) or `<TrimmerRootAssembly>` for the data-model assembly. The IL2072 warning is suppressed at this site with documented justification.
- **Nested types via property-type chain**`List<TestOrderItem>` element type extracted via `Type.GetGenericArguments()` loses DAMs context. Same mitigation: `[AcBinarySerializable]` on each nested type, or `<TrimmerRootAssembly>`. The IL2072/IL2075 warnings are suppressed at the relevant sites with documented justification.
### Consumer guidance for AOT publish
- Annotate every type in the serialization graph with `[AcBinarySerializable]` — SGen path is AOT-clean and ~5-7x faster than the Runtime fallback.
- Or, for unattributed types: add `<TrimmerRootAssembly Include="MyDataModel" />` in the consumer's csproj to keep reflection metadata for the data-model assembly intact (cost: larger AOT binary).
- The `MessagePackSerializer.Standard` resolver has the same `Reflection.Emit` problem — use MessagePack source generators or skip MessagePack under AOT (the AcBinary benchmark project does the latter via `#if !AYCODE_NATIVEAOT`).
- `System.Text.Json` reflection mode similarly requires `JsonSerializerIsReflectionEnabledByDefault=true` AND consumer awareness; for AOT the source-gen path is preferred.
### Verified
The full benchmark suite (Small/Medium/Large/Repeated Strings/Deep Nested) runs cleanly under NativeAOT publish on Windows x64 + .NET 9. AcBinary SGen retains its **+12-35% speed and -22-33% wire-size advantage over MemoryPack** under AOT, identical to JIT. Runtime path is functional but ~5-7x slower (reflection-delegate overhead).
## Property Ordering ## Property Ordering
Properties are serialized in a deterministic order defined by `TypeMetadataBase.GetUnfilteredProperties()`: Properties are serialized in a deterministic order defined by `TypeMetadataBase.GetUnfilteredProperties()`:

View File

@ -583,3 +583,78 @@ FNV-1a is currently used for both `s_typeNameHash` and `s_propertyHashes`. For c
Investigation only — defer until pain signal arrives. Investigation only — defer until pain signal arrives.
## ACCORE-BIN-T-K9E4: `[RequiresDynamicCode]` + `[RequiresUnreferencedCode]` on Runtime-only methods
**Priority:** P3 · **Type:** Refactor · **Related:** `BINARY_FEATURES.md#nativeaot-compatibility`
The Runtime path (factories in `AcSerializerCommon` + wrapper-based deserialize fallback in `AcBinaryDeserializer`) currently works under NativeAOT thanks to DAMs propagation + `RuntimeFeature.IsDynamicCodeSupported` guards, but the trimmer still emits warnings for the well-known blind spots (polymorphism via `obj.GetType()`, nested-type chain via generic argument extraction). The library suppresses these with `[UnconditionalSuppressMessage]` and documented justification.
A complementary signal would be to mark the Runtime entry points (or the factories themselves) with `[RequiresDynamicCode("AcBinary Runtime path uses Reflection.Emit / closed-generic instantiation; use [AcBinarySerializable] + SGen for NativeAOT.")]` and `[RequiresUnreferencedCode("...")]`. Effect:
- AOT publish in consumer's project surfaces a warning at the call site → consumer chooses SGen or accepts the Runtime cost
- Mirrors the System.Text.Json reflection-mode pattern (`[RequiresDynamicCode]` on `JsonSerializer.Serialize<T>` overloads)
- One-codebase, no NuGet split needed
- Cheap implementation — attribute placement only
**Coordination:** `[RequiresDynamicCode]` is contagious; every caller must either propagate it or suppress with `[UnconditionalSuppressMessage]`. Scope:
- Public `Serialize<T>` / `Deserialize<T>` entry points stay attribute-free (consumer-facing)
- Runtime fallback methods get the attribute (contained inside the library)
- The DAMs annotations we already have stay — they're orthogonal (one prevents trim, the other warns about JIT-only behavior)
**Acceptance:**
- Consumer's AOT publish surfaces a IL2026/IL3050 warning when `UseGeneratedCode=false` is set or an unattributed type is deserialized
- SGen path is warning-free
- Library compiles 0 warnings (suppressions added at the propagation barrier)
- `BINARY_FEATURES.md` NativeAOT Compatibility section updated to mention the explicit warning signal
## ACCORE-BIN-T-A2J7: Optional `AyCode.Core.Aot` NuGet variant (SGen-only build)
**Priority:** P3 · **Type:** Feature · **Related:** `BINARY_FEATURES.md#nativeaot-compatibility`, `ACCORE-BIN-T-K9E4`
Binary-size-sensitive AOT consumers (Blazor WASM, MAUI mobile, embedded, container-trimmed) benefit from a smaller library variant that strips the Runtime fallback path entirely. Estimated savings: ~80-150 KB of native code (~25-60 KB compressed wire size for WASM publish).
**Strippable code in the `.Aot` variant:**
| Component | LOC | Purpose | Removable in Aot? |
|---|---|---|---|
| `AcSerializerCommon.Create*` (7 factory methods + Expression-tree code) | ~150 | Runtime delegate compilation | ✅ Yes |
| `TypeMetadataBase` runtime metadata path (`CompiledConstructor`, IdGetters via Expression.Compile) | ~300 | Reflection-based metadata | ✅ Yes |
| `AcBinaryDeserializer` wrapper-based runtime fallback (`PopulateObjectPropertiesIndexed`, `ReadObjectCoreWithWrapper` non-SGen branches, `CreateInstance(type)` Activator-fallback) | ~500 | Runtime polymorphic dispatch | ✅ Yes |
| Property accessor runtime delegate fields (`_dynamicGetter`, typed getter/setter caches outside SGen) | ~150 | Boxed property access | ✅ Yes |
| `System.Linq.Expressions` transitive dependency | — | Expression-tree IL emission | ✅ Yes (when nothing else in graph uses it) |
**Implementation sketch** (avoid `#if`-erdő via file-level split):
```
AyCode.Core/Serializers/
AcSerializerCommon.cs // SGen-safe shared parts
AcSerializerCommon.Runtime.cs // 7 Create* factory methods only here
AcBinaryDeserializer.cs // SGen path
AcBinaryDeserializer.Runtime.cs // wrapper-based runtime fallback path
TypeMetadataBase.cs // SGen-safe metadata
TypeMetadataBase.Runtime.cs // Expression.Compile-based ctor + accessor wiring
```
Two `.csproj` files:
- `AyCode.Core.csproj` — full package (current); includes all files
- `AyCode.Core.Aot.csproj``<Compile Remove="**/*.Runtime.cs" />`; sets `<PackageId>AyCode.Core.Aot</PackageId>`; same version as full
**Trade-offs:**
- ✅ No `#if` directives in business code — physically separate file groups
- ✅ Source mostly shared via SDK include/exclude semantics
- ✅ DAMs annotations and trim-suppressions only land in the full package; `.Aot` variant is genuinely trim-clean by construction
- ✅ "Strict SGen" semantics in `.Aot`: a non-SGen type at deser time throws clearly instead of silently falling back. Marketing positioning: "guaranteed SGen path, no hidden slow lane".
- ⚠️ Two NuGet IDs, two changelogs, version sync (CI-automatable)
- ⚠️ Consumer must pick the right package — wrong choice = breaking switch later
**Coordination:**
- Land `ACCORE-BIN-T-K9E4` first (`[RequiresDynamicCode]` attributes) — if that pattern handles the consumer-side scenarios well, `.Aot` may not be needed
- The current Runtime fallback code is already well-isolated (mostly in `AcSerializerCommon` factories + `AcBinaryDeserializer` wrapper-based methods), so the file-split refactor is mechanically straightforward
- Marketing decision: is binary size a central pillar? If yes, `.Aot` is a NuGet differentiator; if not, `K9E4` alone is enough
**Acceptance:**
- `AyCode.Core.Aot.csproj` produces a NuGet ~25-60 KB smaller than `AyCode.Core` after compression
- `.Aot` build emits zero IL/AOT trim warnings (no suppressions needed because the Runtime path code is physically removed)
- Round-trip tests pass on `.Aot` for all SGen types
- `.Aot` throws a clear `InvalidOperationException` (not `MissingMethodException`) when a non-`[AcBinarySerializable]` type is encountered at deser time
- `BINARY_FEATURES.md` NativeAOT Compatibility section documents both packages and when to choose which