From 68bc59b535a2dc75977616b9971beb168185c819 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:27:06 +0300 Subject: [PATCH 01/43] Introduce opt-in Native AOT analysis and static type registration APIs --- common.props | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/common.props b/common.props index 7319444fe..bc052aa32 100644 --- a/common.props +++ b/common.props @@ -67,6 +67,14 @@ $(StockSharpTargets) + + + true + true + true + + portable From 0a4035dc0dbbda09562bd4b44a1499a57d7d218a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:27:08 +0300 Subject: [PATCH 02/43] Introduce opt-in Native AOT analysis and static type registration APIs --- Common/TypeHelper.cs | 80 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/Common/TypeHelper.cs b/Common/TypeHelper.cs index fc823e121..90373cbfd 100644 --- a/Common/TypeHelper.cs +++ b/Common/TypeHelper.cs @@ -1,6 +1,8 @@ namespace Ecng.Common; using System.Dynamic; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using System.Security.Cryptography; /// @@ -10,6 +12,45 @@ public static class TypeHelper { private static readonly Type _enumType = typeof(Enum); + // Explicitly registered constructors and closed generic types can be used by + // Native AOT without runtime discovery or generation. The JIT fallback stays intact. + private static readonly ConcurrentDictionary> _registeredFactories = new(); + private static readonly object _registeredGenericsSync = new(); + private static readonly Dictionary> _registeredGenerics = []; + + /// + /// Registers an AOT-safe factory for a known type. Registration must happen at startup. + /// + public static void RegisterFactory(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + + if (!_registeredFactories.TryAdd(typeof(T), args => factory(args))) + throw new InvalidOperationException($"A factory for '{typeof(T)}' is already registered."); + } + + /// + /// Registers a constructed generic type that can be resolved without runtime generic instantiation. + /// + public static void RegisterConstructedGenericType(Type constructedType) + { + ArgumentNullException.ThrowIfNull(constructedType); + + if (!constructedType.IsConstructedGenericType) + throw new ArgumentException("Expected a closed generic type.", nameof(constructedType)); + + var definition = constructedType.GetGenericTypeDefinition(); + + lock (_registeredGenericsSync) + { + if (!_registeredGenerics.TryGetValue(definition, out var types)) + _registeredGenerics.Add(definition, types = []); + + if (!types.Contains(constructedType)) + types.Add(constructedType); + } + } + /// /// Creates an instance of the specified type using the given arguments. /// @@ -34,9 +75,11 @@ public static T CreateInstance(this Type type, params object[] args) if (args is null) throw new ArgumentNullException(nameof(args)); - var obj = type is ITypeConstructor ctor - ? ctor.CreateInstance(args) - : Activator.CreateInstance(type, args); + var obj = _registeredFactories.TryGetValue(type, out var factory) + ? factory(args) + : type is ITypeConstructor ctor + ? ctor.CreateInstance(args) + : Activator.CreateInstance(type, args); return obj.To(); } @@ -55,6 +98,37 @@ public static Type Make(this Type type, params Type[] args) if (args is null) throw new ArgumentNullException(nameof(args)); + + lock (_registeredGenericsSync) + { + if (_registeredGenerics.TryGetValue(type, out var types)) + { + foreach (var candidate in types) + { + var candidateArgs = candidate.GenericTypeArguments; + if (candidateArgs.Length != args.Length) + continue; + + var matches = true; + for (var i = 0; i < args.Length; i++) + { + if (candidateArgs[i] != args[i]) + { + matches = false; + break; + } + } + + if (matches) + return candidate; + } + } + } + + // Preserve normal JIT behavior, but give a useful diagnostic on Native AOT. + if (!RuntimeFeature.IsDynamicCodeSupported) + throw new NotSupportedException($"Generic instantiation '{type}' is not registered. Call RegisterConstructedGenericType with the closed type before using Make."); + return type.MakeGenericType(args); } From 2218126dd58618f2ece497d564d06126a3b311ca Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:27:10 +0300 Subject: [PATCH 03/43] Introduce opt-in Native AOT analysis and static type registration APIs --- Common/Converter.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Common/Converter.cs b/Common/Converter.cs index 135fe0549..adeaf00cb 100644 --- a/Common/Converter.cs +++ b/Common/Converter.cs @@ -33,6 +33,26 @@ public static class Converter /// public static Func TypeFallback { get; set; } + /// + /// Pre-registers a name for a statically referenced type, bypassing dynamic assembly discovery. + /// This is useful for AOT apps that know their serializable/configuration types at build time. + /// + public static void RegisterTypeName(string name) + => RegisterTypeName(name, typeof(T)); + + /// + /// Pre-registers a name for a known CLR type. + /// + public static void RegisterTypeName(string name, Type type) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(type); + + var key = name.ToLowerInvariant(); + if (!_typeCache.TryAdd(key, type) && _typeCache[key] != type) + throw new InvalidOperationException($"Type name '{name}' is already registered for another type."); + } + /// /// Fallback handler for Enum conversion from string when value is not found. /// Returns the resolved enum value, or null to throw exception. From ed39fa4873b022af2c5944f8cbd41bda566e8f40 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:27:12 +0300 Subject: [PATCH 04/43] Introduce opt-in Native AOT analysis and static type registration APIs --- Serialization/JsonHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serialization/JsonHelper.cs b/Serialization/JsonHelper.cs index 27f049e43..7dcd7a107 100644 --- a/Serialization/JsonHelper.cs +++ b/Serialization/JsonHelper.cs @@ -8,7 +8,7 @@ /// /// Provides helper methods for JSON serialization and deserialization. /// -public static class JsonHelper +public static partial class JsonHelper { /// /// Provides a UTF-8 encoding without a byte order mark (BOM). From e02557823c34b4c7ea5288daf5a9e6528cf52ec4 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:27:48 +0300 Subject: [PATCH 05/43] Keep net6 compatibility and avoid Reflection.Emit in AOT --- Common/Converter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Common/Converter.cs b/Common/Converter.cs index adeaf00cb..365595ae2 100644 --- a/Common/Converter.cs +++ b/Common/Converter.cs @@ -45,7 +45,8 @@ public static void RegisterTypeName(string name) /// public static void RegisterTypeName(string name, Type type) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("A type name is required.", nameof(name)); ArgumentNullException.ThrowIfNull(type); var key = name.ToLowerInvariant(); From 3839c76e93420442aa2c8ea5588bcffb718021d2 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:27:51 +0300 Subject: [PATCH 06/43] Keep net6 compatibility and avoid Reflection.Emit in AOT --- Common/FastActivator.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Common/FastActivator.cs b/Common/FastActivator.cs index 00ac814c4..0b48d812f 100644 --- a/Common/FastActivator.cs +++ b/Common/FastActivator.cs @@ -1,6 +1,7 @@ namespace Ecng.Common; using System.Reflection.Emit; +using System.Runtime.CompilerServices; /// /// isn't supported. @@ -34,7 +35,7 @@ static FastActivator() var objType = typeof(T); var cinfo = objType.GetConstructor(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); - if ((NotSupported || Scope.Current != null) || objType.IsValueType || cinfo is null) + if (!RuntimeFeature.IsDynamicCodeSupported || (NotSupported || Scope.Current != null) || objType.IsValueType || cinfo is null) { CreateObject = Activator.CreateInstance; } From 8be79b14978bea284daf55ac4355a19031576196 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:28:31 +0300 Subject: [PATCH 07/43] Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit --- Serialization/JsonHelper.Aot.cs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Serialization/JsonHelper.Aot.cs diff --git a/Serialization/JsonHelper.Aot.cs b/Serialization/JsonHelper.Aot.cs new file mode 100644 index 000000000..07cecad83 --- /dev/null +++ b/Serialization/JsonHelper.Aot.cs @@ -0,0 +1,28 @@ +namespace Ecng.Serialization; + +using System.Text.Json.Serialization.Metadata; + +/// +/// Source-generated, trimming-safe JSON alternatives to the existing Newtonsoft-based methods. +/// Callers explicitly provide generated metadata for every serialized type. +/// +public static partial class JsonHelper +{ + /// + /// Deserializes with a source-generated JSON contract, without runtime contract discovery. + /// + public static T FromJson(this string json, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Deserialize(json, typeInfo); + } + + /// + /// Serializes with a source-generated JSON contract, without runtime contract discovery. + /// + public static string ToJson(this T value, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); + } +} From 8a361bfaeeff4bcb8c571a79effe521d2d5addc4 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:28:32 +0300 Subject: [PATCH 08/43] Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit --- Tests/Common/AotCompatibilityTests.cs | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Tests/Common/AotCompatibilityTests.cs diff --git a/Tests/Common/AotCompatibilityTests.cs b/Tests/Common/AotCompatibilityTests.cs new file mode 100644 index 000000000..ec20972fd --- /dev/null +++ b/Tests/Common/AotCompatibilityTests.cs @@ -0,0 +1,70 @@ +namespace Ecng.Tests.Common; + +using System.Text.Json.Serialization; + +using Ecng.Serialization; + +[TestClass] +public class AotCompatibilityTests : BaseTestClass +{ + public sealed record SampleOrder(int Quantity, decimal Price); + + private sealed class FactoryProduct(int value) + { + public int Value { get; } = value; + } + + [TestMethod] + public void ExplicitTypeNameCanResolveWithoutReflection() + { + const string name = "ecng.aot.sample-order"; + + Converter.RegisterTypeName(name); + + name.To().AssertEqual(typeof(SampleOrder)); + name.ToUpperInvariant().To().AssertEqual(typeof(SampleOrder)); + // Registering the same mapping twice is idempotent. + Converter.RegisterTypeName(name, typeof(SampleOrder)); + ThrowsExactly(() => Converter.RegisterTypeName(name, typeof(int))); + } + + [TestMethod] + public void ExplicitFactoryIsUsedBeforeActivator() + { + TypeHelper.RegisterFactory(args => new FactoryProduct((int)args[0])); + + var result = typeof(FactoryProduct).CreateInstance(17); + result.Value.AssertEqual(17); + + ThrowsExactly(() => + TypeHelper.RegisterFactory(_ => new FactoryProduct(0))); + } + + [TestMethod] + public void RegisteredClosedGenericTypeCanBeResolved() + { + TypeHelper.RegisterConstructedGenericType(typeof(Dictionary)); + + var result = typeof(Dictionary<,>).Make(typeof(string), typeof(SampleOrder)); + result.AssertEqual(typeof(Dictionary)); + + // Duplicate registration is harmless. + TypeHelper.RegisterConstructedGenericType(typeof(Dictionary)); + ThrowsExactly(() => TypeHelper.RegisterConstructedGenericType(typeof(Dictionary<,>))); + } + + [TestMethod] + public void SourceGeneratedJsonRoundTrip() + { + var order = new SampleOrder(5, 101.25m); + var json = order.ToJson(TestAotJsonContext.Default.SampleOrder); + var restored = json.FromJson(TestAotJsonContext.Default.SampleOrder); + + restored.AssertEqual(order); + } +} + +[JsonSerializable(typeof(AotCompatibilityTests.SampleOrder))] +internal partial class TestAotJsonContext : JsonSerializerContext +{ +} From cbcf8ceb70fea4b8e3ccd71b737def4fdfed6c07 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:28:35 +0300 Subject: [PATCH 09/43] Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit --- AotSmoke/AotSmoke.csproj | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 AotSmoke/AotSmoke.csproj diff --git a/AotSmoke/AotSmoke.csproj b/AotSmoke/AotSmoke.csproj new file mode 100644 index 000000000..a765b02f8 --- /dev/null +++ b/AotSmoke/AotSmoke.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + true + false + false + false + + + + + + From 0f089520e4a1fe402887648185d5bdce6f05dd27 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:28:37 +0300 Subject: [PATCH 10/43] Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit --- AotSmoke/Program.cs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 AotSmoke/Program.cs diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs new file mode 100644 index 000000000..c4472cef3 --- /dev/null +++ b/AotSmoke/Program.cs @@ -0,0 +1,41 @@ +namespace Ecng.AotSmoke; + +using System.Text.Json.Serialization; + +using Ecng.Common; +using Ecng.Serialization; + +internal sealed record QuoteSnapshot(decimal Price, decimal Volume); + +[JsonSerializable(typeof(QuoteSnapshot))] +internal partial class AotJsonContext : JsonSerializerContext +{ +} + +internal static class Program +{ + private static void Main() + { + // Static references ensure these generic instantiations exist in the native binary. + TypeHelper.RegisterConstructedGenericType(typeof(Dictionary)); + var genericType = typeof(Dictionary<,>).Make(typeof(string), typeof(QuoteSnapshot)); + if (genericType != typeof(Dictionary)) + throw new InvalidOperationException("Closed generic type lookup failed."); + + TypeHelper.RegisterFactory(args => new QuoteSnapshot((decimal)args[0], (decimal)args[1])); + var quote = typeof(QuoteSnapshot).CreateInstance(100m, 2m); + if (quote.Price != 100m || quote.Volume != 2m) + throw new InvalidOperationException("Registered factory failed."); + + Converter.RegisterTypeName("ecng.aot.quote"); + if ("ECNG.AOT.QUOTE".To() != typeof(QuoteSnapshot)) + throw new InvalidOperationException("Registered type name failed."); + + var json = quote.ToJson(AotJsonContext.Default.QuoteSnapshot); + var copy = json.FromJson(AotJsonContext.Default.QuoteSnapshot); + if (copy != quote) + throw new InvalidOperationException("Source-generated JSON round trip failed."); + + Console.WriteLine("Ecng Native AOT smoke test passed."); + } +} From cd96f4db56126140f03051a05772851c3faa964c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:28:39 +0300 Subject: [PATCH 11/43] Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit --- .github/workflows/aot-audit.yml | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/aot-audit.yml diff --git a/.github/workflows/aot-audit.yml b/.github/workflows/aot-audit.yml new file mode 100644 index 000000000..62f1ab33f --- /dev/null +++ b/.github/workflows/aot-audit.yml @@ -0,0 +1,75 @@ +name: Ecng Native AOT audit + +on: + workflow_dispatch: + pull_request: + paths: + - 'common.props' + - 'Common/**' + - 'Serialization/**' + - 'Tests/Common/AotCompatibilityTests.cs' + - 'AotSmoke/**' + - '.github/workflows/aot-audit.yml' + +jobs: + aot: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Install native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + + - name: Analyze Ecng.Common + shell: bash + run: | + set -o pipefail + dotnet build Common/Common.csproj -c Release -f net10.0 \ + -p:EcngAotAudit=true -p:TrimmerSingleWarn=false \ + -bl:common-aot.binlog 2>&1 | tee common-aot.log + + - name: Analyze Ecng.Serialization + shell: bash + run: | + set -o pipefail + dotnet build Serialization/Serialization.csproj -c Release -f net10.0 \ + -p:EcngAotAudit=true -p:TrimmerSingleWarn=false \ + -bl:serialization-aot.binlog 2>&1 | tee serialization-aot.log + + - name: Run compatibility tests + run: > + dotnet test Tests/Tests.csproj -c Release -f net10.0 + --filter 'FullyQualifiedName~AotCompatibilityTests' + --settings Tests/test.runsettings + + - name: Publish native smoke app + shell: bash + run: | + set -o pipefail + dotnet publish AotSmoke/AotSmoke.csproj -f net10.0 -c Release -r linux-x64 \ + -p:EcngAotAudit=true -p:TrimmerSingleWarn=false \ + -bl:publish-aot.binlog -o artifacts/ecng-aot 2>&1 | tee publish-aot.log + + - name: Execute native smoke app + run: ./artifacts/ecng-aot/Ecng.AotSmoke + + - name: Upload audit logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: ecng-aot-diagnostics + path: | + common-aot.log + common-aot.binlog + serialization-aot.log + serialization-aot.binlog + publish-aot.log + publish-aot.binlog + if-no-files-found: warn + retention-days: 14 From a74c5b6f70ead6087964ed67274d679174ae3540 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:28:41 +0300 Subject: [PATCH 12/43] Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit --- AotSmoke/README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 AotSmoke/README.md diff --git a/AotSmoke/README.md b/AotSmoke/README.md new file mode 100644 index 000000000..50fabfc95 --- /dev/null +++ b/AotSmoke/README.md @@ -0,0 +1,36 @@ +# Ecng Native AOT compatibility audit + +The core Ecng libraries keep their normal JIT behavior and NuGet package compatibility. + +## Compile-time diagnostics + +Run the following commands from the repository root: + +```bash +dotnet build Common/Common.csproj -c Release -f net10.0 -p:EcngAotAudit=true +dotnet build Serialization/Serialization.csproj -c Release -f net10.0 -p:EcngAotAudit=true +``` + +This opt-in flag enables AOT, trimming and single-file analyzers on net10.0 builds. +It does not declare that the libraries are globally AOT-compatible. + +## Minimal native smoke application + +```bash +dotnet publish AotSmoke/AotSmoke.csproj -f net10.0 -c Release -r linux-x64 -p:EcngAotAudit=true +./AotSmoke/bin/Release/net10.0/linux-x64/publish/Ecng.AotSmoke +``` + +The smoke app requires Linux Clang and zlib development headers. It demonstrates: + +- Factory registration with `TypeHelper.RegisterFactory` +- Closed generic registration with `TypeHelper.RegisterConstructedGenericType` +- Name-to-type registration with `Converter.RegisterTypeName` +- Source-generated System.Text.Json through `JsonHelper.FromJson/ToJson` overloads + +Old Newtonsoft.Json and dynamic reflection helpers remain available to ordinary JIT applications. +For an AOT process, types must be referenced and registered at build time; arbitrary plugin DLL loading, +dynamic IL compilation and unregistered generic instantiations are not supported by these helpers. + +Successful compilation and a passing smoke test do not establish AOT compatibility +of the full Ecng framework. Review the full IL2xxx/IL3xxx warning log before expanding coverage. From f73ba11994daf384ea5c94c76cc022d04f80a425 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 7 Nov 2026 18:30:48 +0300 Subject: [PATCH 13/43] Fix missing imports in standalone native AOT smoke application --- AotSmoke/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs index c4472cef3..dd67d201e 100644 --- a/AotSmoke/Program.cs +++ b/AotSmoke/Program.cs @@ -1,5 +1,7 @@ namespace Ecng.AotSmoke; +using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using Ecng.Common; From a0686f1a47fa114a254662ecf45f14cad4733b77 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:35 +0300 Subject: [PATCH 14/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Common/ArrayHelper.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Common/ArrayHelper.cs b/Common/ArrayHelper.cs index 7ee3d0a5c..1ffe16419 100644 --- a/Common/ArrayHelper.cs +++ b/Common/ArrayHelper.cs @@ -1,10 +1,21 @@ namespace Ecng.Common; +using System.Collections.Concurrent; + /// /// Utility class for common array operations. /// public static class ArrayHelper { + private static readonly ConcurrentDictionary> _registeredArrayFactories = new(); + + /// + /// Registers a statically compiled array factory for . + /// This avoids runtime array type construction on Native AOT. + /// + public static void RegisterArrayType() + => _registeredArrayFactories.TryAdd(typeof(T), static length => new T[length]); + /// /// Sets all of the elements in the specified to zero, false, or null, depending on the element type. /// @@ -38,7 +49,14 @@ public static void Clear(this Array array, int index, int count) /// A new array of the specified type and length. public static Array CreateArray(this Type type, int count) { - return Array.CreateInstance(type, count); + ArgumentNullException.ThrowIfNull(type); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + + return _registeredArrayFactories.TryGetValue(type, out var factory) + ? factory(count) + : Array.CreateInstance(type, count); } /// From 71d6209ae87e2ef4f3faeaa575213d7636bb6332 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:38 +0300 Subject: [PATCH 15/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Common/Converter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Common/Converter.cs b/Common/Converter.cs index 365595ae2..fc25cc032 100644 --- a/Common/Converter.cs +++ b/Common/Converter.cs @@ -856,7 +856,7 @@ private static bool ArrayCovariance(Array source, Type destinationType, out obje if (destinationType.IsArray) { var elemType = destinationType.GetElementType(); - var destArr = Array.CreateInstance(elemType, source.Length); + var destArr = elemType.CreateArray(source.Length); for (var i = 0; i < source.Length; i++) destArr.SetValue(source.GetValue(i).To(elemType), i); From 70db274ef22bfadbbc5b6d8cd519f505f9160c8d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:40 +0300 Subject: [PATCH 16/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Serialization/JsonSerializer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Serialization/JsonSerializer.cs b/Serialization/JsonSerializer.cs index 0e7d0d0bd..a7342ce46 100644 --- a/Serialization/JsonSerializer.cs +++ b/Serialization/JsonSerializer.cs @@ -343,7 +343,7 @@ private async ValueTask ReadAsync(JsonReader reader, Type type, Cancella var itemType = type.GetItemType(); - var col = (IList)Activator.CreateInstance(typeof(List<>).Make(itemType)); + var col = (IList)typeof(List<>).Make(itemType).CreateInstance(); while (true) { @@ -360,7 +360,7 @@ private async ValueTask ReadAsync(JsonReader reader, Type type, Cancella if (!type.IsArray) return col; - var arr = Array.CreateInstance(itemType, col.Count); + var arr = itemType.CreateArray(col.Count); var idx = 0; foreach (var item in col) From d92db4d92ed3f1f5c7b33d646b0f908d049e13b5 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:42 +0300 Subject: [PATCH 17/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Serialization/SettingsStorage.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Serialization/SettingsStorage.cs b/Serialization/SettingsStorage.cs index e5990a806..64dcbf7bd 100644 --- a/Serialization/SettingsStorage.cs +++ b/Serialization/SettingsStorage.cs @@ -151,7 +151,7 @@ public object GetValue(Type type, string name, object defaultValue = default) var t = type.GetUnderlyingType() ?? type; - var obj = Activator.CreateInstance(t); + var obj = t.CreateInstance(); if (obj is IAsyncPersistable asyncPer) AsyncContext.Run(() => asyncPer.LoadAsync(storage, default)); @@ -174,7 +174,7 @@ public object GetValue(Type type, string name, object defaultValue = default) if (storage is null) return null; - var per = Activator.CreateInstance(elemType); + var per = elemType.CreateInstance(); if (per is IAsyncPersistable asyncPer) AsyncContext.Run(() => asyncPer.LoadAsync(storage, default)); @@ -286,7 +286,7 @@ public async ValueTask GetValueAsync(Type type, string name, object defa private static async ValueTask LoadPersistableAsync(Type type, SettingsStorage storage, CancellationToken cancellationToken) { - var obj = Activator.CreateInstance(type); + var obj = type.CreateInstance(); if (obj is IAsyncPersistable asyncPer) await asyncPer.LoadAsync(storage, cancellationToken).NoWait(); From f73853e0fc5b1c9a60238ad840a47b444d3bda25 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:44 +0300 Subject: [PATCH 18/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Common/FastActivator.cs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Common/FastActivator.cs b/Common/FastActivator.cs index 0b48d812f..9062b37f3 100644 --- a/Common/FastActivator.cs +++ b/Common/FastActivator.cs @@ -28,7 +28,20 @@ public static class FastActivator /// /// Create object handler. /// - public static Func CreateObject { get; } + private static Func _createObject; + + /// Factory used by the fast activator. + public static Func CreateObject => Volatile.Read(ref _createObject); + + /// + /// Replaces the reflection/IL-emit factory with a statically compiled delegate. + /// Use this to support constructors unavailable to Native AOT reflection. + /// + public static void RegisterFactory(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + Volatile.Write(ref _createObject, factory); + } static FastActivator() { @@ -37,7 +50,7 @@ static FastActivator() if (!RuntimeFeature.IsDynamicCodeSupported || (NotSupported || Scope.Current != null) || objType.IsValueType || cinfo is null) { - CreateObject = Activator.CreateInstance; + _createObject = Activator.CreateInstance; } else { @@ -50,14 +63,14 @@ static FastActivator() catch (PlatformNotSupportedException) { NotSupported = true; - CreateObject = Activator.CreateInstance; + _createObject = Activator.CreateInstance; return; } ILGenerator ilGen = dynMethod.GetILGenerator(); ilGen.Emit(OpCodes.Newobj, cinfo); ilGen.Emit(OpCodes.Ret); - CreateObject = (Func)dynMethod.CreateDelegate(typeof(Func)); + _createObject = (Func)dynMethod.CreateDelegate(typeof(Func)); } } } From 7c7382413ebf4885bbf62a9a7e9e47a4500c3f74 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:46 +0300 Subject: [PATCH 19/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Serialization/JArrayToObjectConverter.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Serialization/JArrayToObjectConverter.cs b/Serialization/JArrayToObjectConverter.cs index 2c777e922..f80e906d4 100644 --- a/Serialization/JArrayToObjectConverter.cs +++ b/Serialization/JArrayToObjectConverter.cs @@ -1,6 +1,7 @@ namespace Ecng.Serialization; using System.Reflection.Emit; +using System.Runtime.CompilerServices; using Newtonsoft.Json.Linq; @@ -92,6 +93,12 @@ public class JArrayToObjectConverter : JsonConverter { private static Action CreateFieldSetter(FieldInfo fieldInfo) { + // Reflection.Emit is unavailable in Native AOT. Use retained field metadata + // for the legacy Newtonsoft path instead of failing in the static constructor. + // For reflection-free AOT payloads, use JsonHelper's source-generated overloads. + if (!RuntimeFeature.IsDynamicCodeSupported) + return (target, value) => fieldInfo.SetValue(target, value); + var method = new DynamicMethod( "Set" + fieldInfo.Name, typeof(void), From 7aa05722d1098986fefb348510d5a40f535cbc73 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:47 +0300 Subject: [PATCH 20/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Serialization/JsonHelper.Aot.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Serialization/JsonHelper.Aot.cs b/Serialization/JsonHelper.Aot.cs index 07cecad83..175b2fd2a 100644 --- a/Serialization/JsonHelper.Aot.cs +++ b/Serialization/JsonHelper.Aot.cs @@ -8,6 +8,25 @@ namespace Ecng.Serialization; /// public static partial class JsonHelper { + /// + /// Deserializes using non-generic, source-generated metadata known at build time. + /// The type information replaces dynamic CLR type inspection. + /// + public static object FromJson(this string json, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Deserialize(json, typeInfo); + } + + /// + /// Serializes an object with non-generic, source-generated metadata. + /// + public static string ToJson(this object value, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); + } + /// /// Deserializes with a source-generated JSON contract, without runtime contract discovery. /// From f837a809d3afeda8551583c39f9645e3d873b943 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:52:49 +0300 Subject: [PATCH 21/43] Route existing array, settings and JSON code through AOT-safe registries; guard emit factories --- Serialization/JsonHelper.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Serialization/JsonHelper.cs b/Serialization/JsonHelper.cs index 7dcd7a107..805f2b634 100644 --- a/Serialization/JsonHelper.cs +++ b/Serialization/JsonHelper.cs @@ -1,6 +1,7 @@ namespace Ecng.Serialization; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; @@ -218,7 +219,7 @@ public static string SkipBom(this string str) return str; } - internal static FieldInfo[] GetJsonFields(this Type type) + internal static FieldInfo[] GetJsonFields([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] this Type type) { return [.. type .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) From b52d71b79099cb5a55f4620b2ca14f2afee17da1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:53:24 +0300 Subject: [PATCH 22/43] Allow explicit statically compiled array factories and test their use --- Common/ArrayHelper.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Common/ArrayHelper.cs b/Common/ArrayHelper.cs index 1ffe16419..5eec9e2f4 100644 --- a/Common/ArrayHelper.cs +++ b/Common/ArrayHelper.cs @@ -16,6 +16,18 @@ public static class ArrayHelper public static void RegisterArrayType() => _registeredArrayFactories.TryAdd(typeof(T), static length => new T[length]); + /// + /// Registers a custom statically compiled factory for one-dimensional arrays. + /// Only one factory may be registered for each element type. + /// + public static void RegisterArrayFactory(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + + if (!_registeredArrayFactories.TryAdd(typeof(T), length => factory(length))) + throw new InvalidOperationException($"An array factory for '{typeof(T)}' is already registered."); + } + /// /// Sets all of the elements in the specified to zero, false, or null, depending on the element type. /// From 4991813434884ff7790c28dcf3ed0997d237ab29 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:53:58 +0300 Subject: [PATCH 23/43] Test existing array factories, FastActivator, SettingsStorage and non-generic JSON under AOT registration --- Tests/Common/AotCompatibilityTests.cs | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/Tests/Common/AotCompatibilityTests.cs b/Tests/Common/AotCompatibilityTests.cs index ec20972fd..fd391a98c 100644 --- a/Tests/Common/AotCompatibilityTests.cs +++ b/Tests/Common/AotCompatibilityTests.cs @@ -53,6 +53,70 @@ public void RegisteredClosedGenericTypeCanBeResolved() ThrowsExactly(() => TypeHelper.RegisterConstructedGenericType(typeof(Dictionary<,>))); } + private sealed class ArrayPayload + { + } + + private sealed class PrivateConstructor + { + private PrivateConstructor() { } + public int Value { get; private set; } + public static PrivateConstructor Create(int value) => new() { Value = value }; + } + +#pragma warning disable CS0618 // Verify the existing legacy persistence path. + private sealed class PersistedPayload : IPersistable + { + public int Value { get; private set; } + public void Load(SettingsStorage storage) => Value = storage.GetValue("value"); + public void Save(SettingsStorage storage) => storage.Set("value", Value); + } +#pragma warning restore CS0618 + + [TestMethod] + public void RegisteredArrayFactoryIsUsedByExistingCreateArray() + { + var callCount = 0; + ArrayHelper.RegisterArrayFactory(length => + { + callCount++; + return new ArrayPayload[length]; + }); + + var result = typeof(ArrayPayload).CreateArray(3); + (result is ArrayPayload[]).AssertTrue(); + result.Length.AssertEqual(3); + callCount.AssertEqual(1); + } + + [TestMethod] + public void ExistingFastActivatorSupportsExplicitFactories() + { + FastActivator.RegisterFactory(() => PrivateConstructor.Create(42)); + FastActivator.CreateObject().Value.AssertEqual(42); + } + + [TestMethod] + public void ExistingSettingsStorageUsesRegisteredFactory() + { + TypeHelper.RegisterFactory(_ => new PersistedPayload()); + + var nested = new SettingsStorage().Set("value", 42); + var storage = new SettingsStorage().Set("payload", nested); + + storage.GetValue("payload").Value.AssertEqual(42); + } + + [TestMethod] + public void SourceGeneratedNonGenericJsonRoundTrip() + { + var original = new SampleOrder(4, 112.50m); + System.Text.Json.Serialization.Metadata.JsonTypeInfo metadata = TestAotJsonContext.Default.SampleOrder; + var json = ((object)original).ToJson(metadata); + var restored = (SampleOrder)json.FromJson(metadata); + restored.AssertEqual(original); + } + [TestMethod] public void SourceGeneratedJsonRoundTrip() { From dbb43a1223b85acff81e57a35adf92b93b9098a9 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:54:38 +0300 Subject: [PATCH 24/43] Exercise real settings, legacy JSON, array factories and activators in native smoke binary --- AotSmoke/Program.cs | 74 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs index dd67d201e..a36cff2dc 100644 --- a/AotSmoke/Program.cs +++ b/AotSmoke/Program.cs @@ -2,11 +2,15 @@ namespace Ecng.AotSmoke; using System; using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; using System.Text.Json.Serialization; using Ecng.Common; using Ecng.Serialization; +using Newtonsoft.Json; + internal sealed record QuoteSnapshot(decimal Price, decimal Volume); [JsonSerializable(typeof(QuoteSnapshot))] @@ -14,9 +18,31 @@ internal partial class AotJsonContext : JsonSerializerContext { } +#pragma warning disable CS0618 // Test compatibility with existing persistable settings storage. +internal sealed class StoredQuote : IPersistable +{ + public int Quantity { get; private set; } + public void Load(SettingsStorage storage) => Quantity = storage.GetValue("quantity"); + public void Save(SettingsStorage storage) => storage.Set("quantity", Quantity); +} +#pragma warning restore CS0618 + +internal sealed class WireQuoteRow +{ + public decimal Price; + public decimal Volume; +} + +internal sealed class PrivateAotCtor +{ + private PrivateAotCtor() { } + public int Value { get; private set; } + public static PrivateAotCtor Create(int value) => new() { Value = value }; +} + internal static class Program { - private static void Main() + private static async Task Main() { // Static references ensure these generic instantiations exist in the native binary. TypeHelper.RegisterConstructedGenericType(typeof(Dictionary)); @@ -38,6 +64,52 @@ private static void Main() if (copy != quote) throw new InvalidOperationException("Source-generated JSON round trip failed."); + // Existing array allocation and generic construction paths, not only new APIs. + ArrayHelper.RegisterArrayType(); + var typedArray = typeof(QuoteSnapshot).CreateArray(2); + if (typedArray is not QuoteSnapshot[] || typedArray.Length != 2) + throw new InvalidOperationException("Registered array allocation failed."); + + ArrayHelper.RegisterArrayType(); + TypeHelper.RegisterConstructedGenericType(typeof(List)); + TypeHelper.RegisterFactory>(_ => new List()); + var listType = typeof(List<>).Make(typeof(int)); + if (listType.CreateInstance() is not List) + throw new InvalidOperationException("Registered list factory failed."); + + FastActivator.RegisterFactory(() => PrivateAotCtor.Create(9)); + if (FastActivator.CreateObject().Value != 9) + throw new InvalidOperationException("FastActivator override failed."); + + TypeHelper.RegisterFactory(_ => new StoredQuote()); + var storage = new SettingsStorage() + .Set("quote", new SettingsStorage().Set("quantity", 7)); + if (storage.GetValue("quote").Quantity != 7) + throw new InvalidOperationException("Existing SettingsStorage factory path failed."); + + // Exercise the existing Ecng JSON collection deserializer with the registered + // List and array factory. This uses the legacy wire format on purpose. + var legacySerializer = new Ecng.Serialization.JsonSerializer>(); + using var stream = new MemoryStream(); + await legacySerializer.SerializeAsync(new List { 1, 2, 3 }, stream, default); + stream.Position = 0; + var restoredList = await legacySerializer.DeserializeAsync(stream, default); + if (restoredList.Count != 3 || restoredList[0] != 1 || restoredList[2] != 3) + throw new InvalidOperationException("Existing collection serializer path failed."); + + // The legacy Newtonsoft field setter must not attempt Reflection.Emit on AOT. + using var reader = new JsonTextReader(new StringReader("[101.25,2.5]")); + reader.Read(); + var row = (WireQuoteRow)new JArrayToObjectConverter() + .ReadJson(reader, typeof(WireQuoteRow), null, Newtonsoft.Json.JsonSerializer.CreateDefault()); + if (row.Price != 101.25m || row.Volume != 2.5m) + throw new InvalidOperationException("Legacy JSON-array field setter path failed."); + + var dynamicTypeInfo = (System.Text.Json.Serialization.Metadata.JsonTypeInfo)AotJsonContext.Default.QuoteSnapshot; + var metadataJson = ((object)quote).ToJson(dynamicTypeInfo); + if ((QuoteSnapshot)metadataJson.FromJson(dynamicTypeInfo) != quote) + throw new InvalidOperationException("Non-generic source-generated JSON failed."); + Console.WriteLine("Ecng Native AOT smoke test passed."); } } From 71864eba5101eb9c37fbd674ecd69ee4faa3c95d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:55:11 +0300 Subject: [PATCH 25/43] Use concurrent structural generic registry, eliminating per-call allocations and global lock --- Common/TypeHelper.cs | 57 ++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/Common/TypeHelper.cs b/Common/TypeHelper.cs index 90373cbfd..84c8bc8c9 100644 --- a/Common/TypeHelper.cs +++ b/Common/TypeHelper.cs @@ -15,8 +15,23 @@ public static class TypeHelper // Explicitly registered constructors and closed generic types can be used by // Native AOT without runtime discovery or generation. The JIT fallback stays intact. private static readonly ConcurrentDictionary> _registeredFactories = new(); - private static readonly object _registeredGenericsSync = new(); - private static readonly Dictionary> _registeredGenerics = []; + private sealed class TypeArgumentsComparer : IEqualityComparer + { + public bool Equals(Type[] x, Type[] y) + => ReferenceEquals(x, y) || (x is not null && y is not null && x.AsSpan().SequenceEqual(y)); + + public int GetHashCode(Type[] arguments) + { + var hash = new HashCode(); + foreach (var argument in arguments) + hash.Add(argument); + return hash.ToHashCode(); + } + } + + // Structural type-argument lookup avoids scanning generic candidates and + // allocating GenericTypeArguments arrays on every invocation of Make. + private static readonly ConcurrentDictionary> _registeredGenerics = new(); /// /// Registers an AOT-safe factory for a known type. Registration must happen at startup. @@ -41,14 +56,10 @@ public static void RegisterConstructedGenericType(Type constructedType) var definition = constructedType.GetGenericTypeDefinition(); - lock (_registeredGenericsSync) - { - if (!_registeredGenerics.TryGetValue(definition, out var types)) - _registeredGenerics.Add(definition, types = []); + var types = _registeredGenerics.GetOrAdd(definition, + static _ => new ConcurrentDictionary(new TypeArgumentsComparer())); - if (!types.Contains(constructedType)) - types.Add(constructedType); - } + types.TryAdd(constructedType.GenericTypeArguments, constructedType); } /// @@ -99,31 +110,9 @@ public static Type Make(this Type type, params Type[] args) throw new ArgumentNullException(nameof(args)); - lock (_registeredGenericsSync) - { - if (_registeredGenerics.TryGetValue(type, out var types)) - { - foreach (var candidate in types) - { - var candidateArgs = candidate.GenericTypeArguments; - if (candidateArgs.Length != args.Length) - continue; - - var matches = true; - for (var i = 0; i < args.Length; i++) - { - if (candidateArgs[i] != args[i]) - { - matches = false; - break; - } - } - - if (matches) - return candidate; - } - } - } + if (_registeredGenerics.TryGetValue(type, out var registered) && + registered.TryGetValue(args, out var constructedType)) + return constructedType; // Preserve normal JIT behavior, but give a useful diagnostic on Native AOT. if (!RuntimeFeature.IsDynamicCodeSupported) From d9c77f2a0242e69d6a29a49235bccadfb99119de Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:55:24 +0300 Subject: [PATCH 26/43] Compare registered generic type arguments by exact Type identity --- Common/TypeHelper.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Common/TypeHelper.cs b/Common/TypeHelper.cs index 84c8bc8c9..c0c546d87 100644 --- a/Common/TypeHelper.cs +++ b/Common/TypeHelper.cs @@ -18,7 +18,21 @@ public static class TypeHelper private sealed class TypeArgumentsComparer : IEqualityComparer { public bool Equals(Type[] x, Type[] y) - => ReferenceEquals(x, y) || (x is not null && y is not null && x.AsSpan().SequenceEqual(y)); + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null || y is null || x.Length != y.Length) + return false; + + for (var i = 0; i < x.Length; i++) + { + if (x[i] != y[i]) + return false; + } + + return true; + } public int GetHashCode(Type[] arguments) { From 4365b0d3bb1ba34e8df67725dab53337b21162a1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:55:38 +0300 Subject: [PATCH 27/43] Skip unsupported constructor introspection in AOT and preserve JSON field metadata --- Common/FastActivator.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Common/FastActivator.cs b/Common/FastActivator.cs index 9062b37f3..e93e01336 100644 --- a/Common/FastActivator.cs +++ b/Common/FastActivator.cs @@ -45,10 +45,18 @@ public static void RegisterFactory(Func factory) static FastActivator() { + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // Do not inspect constructors or emit IL in native images. + // RegisterFactory can still supply a precompiled constructor delegate. + _createObject = Activator.CreateInstance; + return; + } + var objType = typeof(T); var cinfo = objType.GetConstructor(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); - if (!RuntimeFeature.IsDynamicCodeSupported || (NotSupported || Scope.Current != null) || objType.IsValueType || cinfo is null) + if ((NotSupported || Scope.Current != null) || objType.IsValueType || cinfo is null) { _createObject = Activator.CreateInstance; } From fd116945d38ccff05d8f260166f523c60d3a235b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:55:40 +0300 Subject: [PATCH 28/43] Skip unsupported constructor introspection in AOT and preserve JSON field metadata --- Serialization/JArrayToObjectConverter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Serialization/JArrayToObjectConverter.cs b/Serialization/JArrayToObjectConverter.cs index f80e906d4..a96b8f745 100644 --- a/Serialization/JArrayToObjectConverter.cs +++ b/Serialization/JArrayToObjectConverter.cs @@ -2,6 +2,7 @@ namespace Ecng.Serialization; using System.Reflection.Emit; using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json.Linq; @@ -88,7 +89,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s /// in declaration order using compiled IL for performance. /// /// The type of the object to convert to. Must have a parameterless constructor. -public class JArrayToObjectConverter : JsonConverter +public class JArrayToObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] T> : JsonConverter where T : new() { private static Action CreateFieldSetter(FieldInfo fieldInfo) From 93c9f73989236fce2c07d8135f30528773753531 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:56:54 +0300 Subject: [PATCH 29/43] Support statically registered implementation discovery when assembly scanning is unavailable in Native AOT --- Reflection/ReflectionHelper.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Reflection/ReflectionHelper.cs b/Reflection/ReflectionHelper.cs index 8d56ff7c2..366af3e44 100644 --- a/Reflection/ReflectionHelper.cs +++ b/Reflection/ReflectionHelper.cs @@ -42,6 +42,23 @@ public static class ReflectionHelper /// public const BindingFlags AllMembers = AllStaticMembers | AllInstanceMembers; + // Native AOT cannot discover arbitrary implementations by enumerating assemblies. + // Statically referenced registration lets existing discovery callers retain their API. + private static readonly ConcurrentDictionary<(Assembly assembly, Type contract), ConcurrentDictionary> _registeredImplementations = new(); + + /// + /// Registers a statically known implementation for Native AOT type discovery. + /// + public static void RegisterImplementation() + where TImplementation : TContract + { + var implementation = typeof(TImplementation); + var key = (implementation.Assembly, typeof(TContract)); + _registeredImplementations + .GetOrAdd(key, static _ => new ConcurrentDictionary()) + .TryAdd(implementation, 0); + } + #region Public Methods /// @@ -1055,6 +1072,19 @@ public static IEnumerable FindImplementations(this Assembly assembly, b extraFilter ??= t => true; + if (!RuntimeFeature.IsDynamicCodeSupported) + { + if (!_registeredImplementations.TryGetValue((assembly, typeof(T)), out var registered)) + return []; + + return registered.Keys.Where(t => + !t.IsAbstract && !t.IsInterface && + (showNonPublic || t.IsPublic || t.IsNestedPublic) && + (showObsolete || !t.IsObsolete()) && + (showNonBrowsable || t.IsBrowsable()) && + extraFilter(t)); + } + return assembly .GetTypes() .Where(t => t.Is() && !t.IsAbstract && !t.IsInterface && From c239b5c8c4f1bd89de287a88fb90772b43ebe652 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:57:10 +0300 Subject: [PATCH 30/43] Test static plugin discovery in existing reflection API and native smoke executable --- AotSmoke/Program.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs index a36cff2dc..cad38c584 100644 --- a/AotSmoke/Program.cs +++ b/AotSmoke/Program.cs @@ -8,6 +8,7 @@ namespace Ecng.AotSmoke; using Ecng.Common; using Ecng.Serialization; +using Ecng.Reflection; using Newtonsoft.Json; @@ -27,6 +28,9 @@ internal sealed class StoredQuote : IPersistable } #pragma warning restore CS0618 +internal interface IDiscoveryProbe { } +internal sealed class DiscoveryProbe : IDiscoveryProbe { } + internal sealed class WireQuoteRow { public decimal Price; @@ -64,6 +68,12 @@ private static async Task Main() if (copy != quote) throw new InvalidOperationException("Source-generated JSON round trip failed."); + ReflectionHelper.RegisterImplementation(); + var implementations = typeof(DiscoveryProbe).Assembly + .FindImplementations(showNonPublic: true); + if (!implementations.Contains(typeof(DiscoveryProbe))) + throw new InvalidOperationException("AOT implementation discovery failed."); + // Existing array allocation and generic construction paths, not only new APIs. ArrayHelper.RegisterArrayType(); var typedArray = typeof(QuoteSnapshot).CreateArray(2); From 285134b6c075e02eed53172aa9f5edb8ac9cf75d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:57:12 +0300 Subject: [PATCH 31/43] Test static plugin discovery in existing reflection API and native smoke executable --- Tests/Common/AotCompatibilityTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Tests/Common/AotCompatibilityTests.cs b/Tests/Common/AotCompatibilityTests.cs index fd391a98c..0ecf279d5 100644 --- a/Tests/Common/AotCompatibilityTests.cs +++ b/Tests/Common/AotCompatibilityTests.cs @@ -3,6 +3,7 @@ namespace Ecng.Tests.Common; using System.Text.Json.Serialization; using Ecng.Serialization; +using Ecng.Reflection; [TestClass] public class AotCompatibilityTests : BaseTestClass @@ -53,6 +54,18 @@ public void RegisteredClosedGenericTypeCanBeResolved() ThrowsExactly(() => TypeHelper.RegisterConstructedGenericType(typeof(Dictionary<,>))); } + private interface IDiscoveryProbe { } + private sealed class DiscoveryProbe : IDiscoveryProbe { } + + [TestMethod] + public void StaticallyRegisteredImplementationsRemainDiscoverable() + { + ReflectionHelper.RegisterImplementation(); + var implementations = typeof(DiscoveryProbe).Assembly + .FindImplementations(showNonPublic: true); + implementations.Contains(typeof(DiscoveryProbe)).AssertTrue(); + } + private sealed class ArrayPayload { } From ef92306e7385f9c6d8d6753e5d131fcc55358dfd Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:57:29 +0300 Subject: [PATCH 32/43] Import LINQ for native plugin discovery smoke assertion --- AotSmoke/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs index cad38c584..e6b3daed1 100644 --- a/AotSmoke/Program.cs +++ b/AotSmoke/Program.cs @@ -3,6 +3,7 @@ namespace Ecng.AotSmoke; using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using System.Text.Json.Serialization; From c6a8d8b8f3475b2833445dc843f2e81b139e5df6 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:59:02 +0300 Subject: [PATCH 33/43] Preserve net6 API compatibility for JSON metadata overloads and validate legacy target in CI --- Tests/Common/AotCompatibilityTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/Common/AotCompatibilityTests.cs b/Tests/Common/AotCompatibilityTests.cs index 0ecf279d5..0463710cf 100644 --- a/Tests/Common/AotCompatibilityTests.cs +++ b/Tests/Common/AotCompatibilityTests.cs @@ -120,6 +120,7 @@ public void ExistingSettingsStorageUsesRegisteredFactory() storage.GetValue("payload").Value.AssertEqual(42); } +#if NET7_0_OR_GREATER [TestMethod] public void SourceGeneratedNonGenericJsonRoundTrip() { @@ -130,6 +131,8 @@ public void SourceGeneratedNonGenericJsonRoundTrip() restored.AssertEqual(original); } +#endif + [TestMethod] public void SourceGeneratedJsonRoundTrip() { From 3b75e4804f2a91b6cec87ab23fab3c61c143214b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:59:04 +0300 Subject: [PATCH 34/43] Preserve net6 API compatibility for JSON metadata overloads and validate legacy target in CI --- .github/workflows/aot-audit.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/aot-audit.yml b/.github/workflows/aot-audit.yml index 62f1ab33f..aa268a4b2 100644 --- a/.github/workflows/aot-audit.yml +++ b/.github/workflows/aot-audit.yml @@ -21,11 +21,16 @@ jobs: - name: Setup .NET 10 uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.x + dotnet-version: | + 6.0.x + 10.0.x - name: Install native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + - name: Compile legacy net6 target + run: dotnet build Serialization/Serialization.csproj -c Release -f net6.0 + - name: Analyze Ecng.Common shell: bash run: | From 6596bb0732fec99b7a6b9898b599e87e187127f2 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 08:59:05 +0300 Subject: [PATCH 35/43] Preserve net6 API compatibility for JSON metadata overloads and validate legacy target in CI --- Serialization/JsonHelper.Aot.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Serialization/JsonHelper.Aot.cs b/Serialization/JsonHelper.Aot.cs index 175b2fd2a..a74f40e28 100644 --- a/Serialization/JsonHelper.Aot.cs +++ b/Serialization/JsonHelper.Aot.cs @@ -8,6 +8,7 @@ namespace Ecng.Serialization; /// public static partial class JsonHelper { +#if NET7_0_OR_GREATER /// /// Deserializes using non-generic, source-generated metadata known at build time. /// The type information replaces dynamic CLR type inspection. @@ -27,6 +28,8 @@ public static string ToJson(this object value, JsonTypeInfo typeInfo) return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); } +#endif + /// /// Deserializes with a source-generated JSON contract, without runtime contract discovery. /// From 8aad2cd4082c91ccca155ba3458658df134c769a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:00:13 +0300 Subject: [PATCH 36/43] Implement explicit AOT field accessors and wire-order metadata for legacy JArray converter --- Serialization/JArrayToObjectConverter.cs | 54 +++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/Serialization/JArrayToObjectConverter.cs b/Serialization/JArrayToObjectConverter.cs index a96b8f745..1f28f49a3 100644 --- a/Serialization/JArrayToObjectConverter.cs +++ b/Serialization/JArrayToObjectConverter.cs @@ -92,6 +92,32 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s public class JArrayToObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] T> : JsonConverter where T : new() { + // Native AOT: explicit, statically compiled field accessors and stable wire order. + // JIT callers may continue to use the original reflection/IL-emit path. + private static (Type fieldType, Func getter, Action setter)[] _registeredFields; + + /// + /// Registers field metadata and precompiled accessors in JSON wire order. + /// Use this instead of relying on reflected fields for Native AOT models. + /// Must be called before reading or writing the first payload. + /// + public static void RegisterAotFields(params (Type fieldType, Func getter, Action setter)[] fields) + { + ArgumentNullException.ThrowIfNull(fields); + if (fields.Length == 0) + throw new ArgumentException("At least one field must be registered.", nameof(fields)); + + foreach (var field in fields) + { + ArgumentNullException.ThrowIfNull(field.fieldType); + ArgumentNullException.ThrowIfNull(field.getter); + ArgumentNullException.ThrowIfNull(field.setter); + } + + if (Interlocked.CompareExchange(ref _registeredFields, [.. fields], null) is not null) + throw new InvalidOperationException($"AOT JSON fields for '{typeof(T)}' are already registered."); + } + private static Action CreateFieldSetter(FieldInfo fieldInfo) { // Reflection.Emit is unavailable in Native AOT. Use retained field metadata @@ -163,6 +189,21 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist var array = JArray.Load(reader); + var registered = Volatile.Read(ref _registeredFields); + if (registered is not null) + { + if (array.Count < registered.Length) + throw new JsonSerializationException($"JSON array has {array.Count} elements but type '{typeof(T).Name}' requires {registered.Length}."); + + for (var i = 0; i < registered.Length; i++) + { + var field = registered[i]; + field.setter(typed, array[i].ToObject(field.fieldType)); + } + + return existingValue; + } + if (array.Count < _fields.Length) throw new JsonSerializationException($"JSON array has {array.Count} elements but type '{typeof(T).Name}' requires {_fields.Length}."); @@ -198,8 +239,17 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s writer.WriteStartArray(); - foreach (var (f, _) in _fields) - serializer.Serialize(writer, f.GetValue(value)); + var registered = Volatile.Read(ref _registeredFields); + if (registered is not null) + { + foreach (var field in registered) + serializer.Serialize(writer, field.getter((T)value)); + } + else + { + foreach (var (f, _) in _fields) + serializer.Serialize(writer, f.GetValue(value)); + } writer.WriteEndArray(); } From 2b16804fd7f4687b8313f568d5d2e6c09d93a814 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:00:32 +0300 Subject: [PATCH 37/43] Validate source-defined JSON array field order and write/read under JIT and native AOT --- AotSmoke/Program.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs index e6b3daed1..b888ae899 100644 --- a/AotSmoke/Program.cs +++ b/AotSmoke/Program.cs @@ -109,6 +109,9 @@ private static async Task Main() throw new InvalidOperationException("Existing collection serializer path failed."); // The legacy Newtonsoft field setter must not attempt Reflection.Emit on AOT. + JArrayToObjectConverter.RegisterAotFields( + (typeof(decimal), row => row.Price, (row, value) => row.Price = (decimal)value), + (typeof(decimal), row => row.Volume, (row, value) => row.Volume = (decimal)value)); using var reader = new JsonTextReader(new StringReader("[101.25,2.5]")); reader.Read(); var row = (WireQuoteRow)new JArrayToObjectConverter() From d1ab6982b0f985e90eef47fa9bd35c9b2b7a2b71 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:00:34 +0300 Subject: [PATCH 38/43] Validate source-defined JSON array field order and write/read under JIT and native AOT --- Tests/Serialization/JsonConvertersTests.cs | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Tests/Serialization/JsonConvertersTests.cs b/Tests/Serialization/JsonConvertersTests.cs index 32ab3143b..4b0f69030 100644 --- a/Tests/Serialization/JsonConvertersTests.cs +++ b/Tests/Serialization/JsonConvertersTests.cs @@ -8,6 +8,38 @@ namespace Ecng.Tests.Serialization; [TestClass] public class JsonConvertersTests : BaseTestClass { + private sealed class AotPriceRow + { + public decimal Price; + public decimal Size; + } + + [TestMethod] + public void RegisteredAotJsonArrayFieldsReadAndWriteInWireOrder() + { + JArrayToObjectConverter.RegisterAotFields( + (typeof(decimal), row => row.Price, (row, v) => row.Price = (decimal)v), + (typeof(decimal), row => row.Size, (row, v) => row.Size = (decimal)v)); + + var converter = new JArrayToObjectConverter(); + using var reader = new JsonTextReader(new StringReader("[101.5,3.25]")); + reader.Read(); + + var row = (AotPriceRow)converter.ReadJson(reader, typeof(AotPriceRow), null, JsonSerializer.CreateDefault()); + row.Price.AssertEqual(101.5m); + row.Size.AssertEqual(3.25m); + + using var writerOutput = new StringWriter(); + using (var writer = new JsonTextWriter(writerOutput)) + { + converter.WriteJson(writer, row, JsonSerializer.CreateDefault()); + writer.Flush(); + } + + writerOutput.ToString().AssertEqual("[101.5,3.25]"); + } + + [TestMethod] public void BoolConverter_ReadWrite() { From f4b3501cdcf17283e5590b29921962b69c5f949d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:00:37 +0300 Subject: [PATCH 39/43] Validate source-defined JSON array field order and write/read under JIT and native AOT --- Serialization/JArrayToObjectConverter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Serialization/JArrayToObjectConverter.cs b/Serialization/JArrayToObjectConverter.cs index 1f28f49a3..3cf6c60f1 100644 --- a/Serialization/JArrayToObjectConverter.cs +++ b/Serialization/JArrayToObjectConverter.cs @@ -114,7 +114,8 @@ public static void RegisterAotFields(params (Type fieldType, Func get ArgumentNullException.ThrowIfNull(field.setter); } - if (Interlocked.CompareExchange(ref _registeredFields, [.. fields], null) is not null) + var snapshot = fields.ToArray(); + if (Interlocked.CompareExchange(ref _registeredFields, snapshot, null) is not null) throw new InvalidOperationException($"AOT JSON fields for '{typeof(T)}' are already registered."); } From c6ce806b2ae62592e2edf73a9ed7fd832958f16b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:01:35 +0300 Subject: [PATCH 40/43] Provide native static array factories for common framework types by default --- Common/ArrayHelper.cs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Common/ArrayHelper.cs b/Common/ArrayHelper.cs index 5eec9e2f4..f126387e2 100644 --- a/Common/ArrayHelper.cs +++ b/Common/ArrayHelper.cs @@ -9,6 +9,31 @@ public static class ArrayHelper { private static readonly ConcurrentDictionary> _registeredArrayFactories = new(); + static ArrayHelper() + { + // Common arrays exist as closed generic instantiations in the native binary. + // Avoid Array.CreateInstance(Type, int) for standard wire/message value types. + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + } + /// /// Registers a statically compiled array factory for . /// This avoids runtime array type construction on Native AOT. From 01dfd51a6380fb4f160280550a048a1c1639b156 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:01:45 +0300 Subject: [PATCH 41/43] Validate built-in static array paths under regular and native builds --- AotSmoke/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs index b888ae899..a8c6ed776 100644 --- a/AotSmoke/Program.cs +++ b/AotSmoke/Program.cs @@ -81,7 +81,8 @@ private static async Task Main() if (typedArray is not QuoteSnapshot[] || typedArray.Length != 2) throw new InvalidOperationException("Registered array allocation failed."); - ArrayHelper.RegisterArrayType(); + if (typeof(int).CreateArray(3) is not int[] { Length: 3 }) + throw new InvalidOperationException("Standard array factory failed."); TypeHelper.RegisterConstructedGenericType(typeof(List)); TypeHelper.RegisterFactory>(_ => new List()); var listType = typeof(List<>).Make(typeof(int)); From af1f9c38f95ea4258470dc6a49d700d88686c92a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:01:47 +0300 Subject: [PATCH 42/43] Validate built-in static array paths under regular and native builds --- Tests/Common/AotCompatibilityTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Tests/Common/AotCompatibilityTests.cs b/Tests/Common/AotCompatibilityTests.cs index 0463710cf..7f4f1fbb2 100644 --- a/Tests/Common/AotCompatibilityTests.cs +++ b/Tests/Common/AotCompatibilityTests.cs @@ -86,6 +86,14 @@ private sealed class PersistedPayload : IPersistable } #pragma warning restore CS0618 + [TestMethod] + public void CommonArrayTypesHaveStaticNativeFactories() + { + (typeof(int).CreateArray(4) is int[] { Length: 4 }).AssertTrue(); + (typeof(decimal).CreateArray(2) is decimal[] { Length: 2 }).AssertTrue(); + (typeof(Guid).CreateArray(1) is Guid[] { Length: 1 }).AssertTrue(); + } + [TestMethod] public void RegisteredArrayFactoryIsUsedByExistingCreateArray() { From 80a9f06da0df8647c4c2a2a638bed1babbd213a1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 8 Nov 2026 09:04:06 +0300 Subject: [PATCH 43/43] Avoid MetadataToken-dependent static initialization of JArray converter in Native AOT --- Serialization/JArrayToObjectConverter.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Serialization/JArrayToObjectConverter.cs b/Serialization/JArrayToObjectConverter.cs index 3cf6c60f1..5bf90ac4c 100644 --- a/Serialization/JArrayToObjectConverter.cs +++ b/Serialization/JArrayToObjectConverter.cs @@ -159,6 +159,15 @@ private static Action CreateFieldSetter(FieldInfo fieldInfo) static JArrayToObjectConverter() { + // Native AOT reflection may not expose MetadataToken, which + // GetJsonFields/OrderByDeclaration depends on. Never evaluate it + // during native static initialization; explicit wire metadata is required. + if (!RuntimeFeature.IsDynamicCodeSupported) + { + _fields = []; + return; + } + _fields = [.. typeof(T) .GetJsonFields() .Select(f => (f, CreateFieldSetter(f)))]; @@ -205,6 +214,9 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist return existingValue; } + if (!RuntimeFeature.IsDynamicCodeSupported) + throw new NotSupportedException($"AOT JSON fields for '{typeof(T)}' are not registered. Call RegisterAotFields before deserializing."); + if (array.Count < _fields.Length) throw new JsonSerializationException($"JSON array has {array.Count} elements but type '{typeof(T).Name}' requires {_fields.Length}."); @@ -248,6 +260,9 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s } else { + if (!RuntimeFeature.IsDynamicCodeSupported) + throw new NotSupportedException($"AOT JSON fields for '{typeof(T)}' are not registered. Call RegisterAotFields before serializing."); + foreach (var (f, _) in _fields) serializer.Serialize(writer, f.GetValue(value)); }