Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
68bc59b
Introduce opt-in Native AOT analysis and static type registration APIs
mikasoukhov Nov 7, 2026
0a4035d
Introduce opt-in Native AOT analysis and static type registration APIs
mikasoukhov Nov 7, 2026
2218126
Introduce opt-in Native AOT analysis and static type registration APIs
mikasoukhov Nov 7, 2026
ed39fa4
Introduce opt-in Native AOT analysis and static type registration APIs
mikasoukhov Nov 7, 2026
e025578
Keep net6 compatibility and avoid Reflection.Emit in AOT
mikasoukhov Nov 7, 2026
3839c76
Keep net6 compatibility and avoid Reflection.Emit in AOT
mikasoukhov Nov 7, 2026
8be79b1
Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit
mikasoukhov Nov 7, 2026
8a361bf
Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit
mikasoukhov Nov 7, 2026
cbcf8ce
Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit
mikasoukhov Nov 7, 2026
0f08952
Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit
mikasoukhov Nov 7, 2026
cd96f4d
Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit
mikasoukhov Nov 7, 2026
a74c5b6
Add AOT-safe JSON contracts, tests, smoke app, and dedicated CI audit
mikasoukhov Nov 7, 2026
f73ba11
Fix missing imports in standalone native AOT smoke application
mikasoukhov Nov 7, 2026
a0686f1
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
71d6209
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
70db274
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
d92db4d
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
f73853e
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
7c73824
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
7aa0572
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
f837a80
Route existing array, settings and JSON code through AOT-safe registr…
mikasoukhov Nov 8, 2026
b52d71b
Allow explicit statically compiled array factories and test their use
mikasoukhov Nov 8, 2026
4991813
Test existing array factories, FastActivator, SettingsStorage and non…
mikasoukhov Nov 8, 2026
dbb43a1
Exercise real settings, legacy JSON, array factories and activators i…
mikasoukhov Nov 8, 2026
71864eb
Use concurrent structural generic registry, eliminating per-call allo…
mikasoukhov Nov 8, 2026
d9c77f2
Compare registered generic type arguments by exact Type identity
mikasoukhov Nov 8, 2026
4365b0d
Skip unsupported constructor introspection in AOT and preserve JSON f…
mikasoukhov Nov 8, 2026
fd11694
Skip unsupported constructor introspection in AOT and preserve JSON f…
mikasoukhov Nov 8, 2026
93c9f73
Support statically registered implementation discovery when assembly …
mikasoukhov Nov 8, 2026
c239b5c
Test static plugin discovery in existing reflection API and native sm…
mikasoukhov Nov 8, 2026
285134b
Test static plugin discovery in existing reflection API and native sm…
mikasoukhov Nov 8, 2026
ef92306
Import LINQ for native plugin discovery smoke assertion
mikasoukhov Nov 8, 2026
c6a8d8b
Preserve net6 API compatibility for JSON metadata overloads and valid…
mikasoukhov Nov 8, 2026
3b75e48
Preserve net6 API compatibility for JSON metadata overloads and valid…
mikasoukhov Nov 8, 2026
6596bb0
Preserve net6 API compatibility for JSON metadata overloads and valid…
mikasoukhov Nov 8, 2026
8aad2cd
Implement explicit AOT field accessors and wire-order metadata for le…
mikasoukhov Nov 8, 2026
2b16804
Validate source-defined JSON array field order and write/read under J…
mikasoukhov Nov 8, 2026
d1ab698
Validate source-defined JSON array field order and write/read under J…
mikasoukhov Nov 8, 2026
f4b3501
Validate source-defined JSON array field order and write/read under J…
mikasoukhov Nov 8, 2026
c6ce806
Provide native static array factories for common framework types by d…
mikasoukhov Nov 8, 2026
01dfd51
Validate built-in static array paths under regular and native builds
mikasoukhov Nov 8, 2026
af1f9c3
Validate built-in static array paths under regular and native builds
mikasoukhov Nov 8, 2026
80a9f06
Avoid MetadataToken-dependent static initialization of JArray convert…
mikasoukhov Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/aot-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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: |
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: |
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
15 changes: 15 additions & 0 deletions AotSmoke/AotSmoke.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\common.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<PublishAot>true</PublishAot>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Serialization\Serialization.csproj" />
</ItemGroup>
</Project>
130 changes: 130 additions & 0 deletions AotSmoke/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
namespace Ecng.AotSmoke;

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Text.Json.Serialization;

using Ecng.Common;
using Ecng.Serialization;
using Ecng.Reflection;

using Newtonsoft.Json;

internal sealed record QuoteSnapshot(decimal Price, decimal Volume);

[JsonSerializable(typeof(QuoteSnapshot))]
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<int>("quantity");
public void Save(SettingsStorage storage) => storage.Set("quantity", Quantity);
}
#pragma warning restore CS0618

internal interface IDiscoveryProbe { }
internal sealed class DiscoveryProbe : IDiscoveryProbe { }

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 async Task Main()
{
// Static references ensure these generic instantiations exist in the native binary.
TypeHelper.RegisterConstructedGenericType(typeof(Dictionary<string, QuoteSnapshot>));
var genericType = typeof(Dictionary<,>).Make(typeof(string), typeof(QuoteSnapshot));
if (genericType != typeof(Dictionary<string, QuoteSnapshot>))
throw new InvalidOperationException("Closed generic type lookup failed.");

TypeHelper.RegisterFactory<QuoteSnapshot>(args => new QuoteSnapshot((decimal)args[0], (decimal)args[1]));
var quote = typeof(QuoteSnapshot).CreateInstance<QuoteSnapshot>(100m, 2m);
if (quote.Price != 100m || quote.Volume != 2m)
throw new InvalidOperationException("Registered factory failed.");

Converter.RegisterTypeName<QuoteSnapshot>("ecng.aot.quote");
if ("ECNG.AOT.QUOTE".To<Type>() != 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.");

ReflectionHelper.RegisterImplementation<IDiscoveryProbe, DiscoveryProbe>();
var implementations = typeof(DiscoveryProbe).Assembly
.FindImplementations<IDiscoveryProbe>(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<QuoteSnapshot>();
var typedArray = typeof(QuoteSnapshot).CreateArray(2);
if (typedArray is not QuoteSnapshot[] || typedArray.Length != 2)
throw new InvalidOperationException("Registered array allocation failed.");

if (typeof(int).CreateArray(3) is not int[] { Length: 3 })
throw new InvalidOperationException("Standard array factory failed.");
TypeHelper.RegisterConstructedGenericType(typeof(List<int>));
TypeHelper.RegisterFactory<List<int>>(_ => new List<int>());
var listType = typeof(List<>).Make(typeof(int));
if (listType.CreateInstance() is not List<int>)
throw new InvalidOperationException("Registered list factory failed.");

FastActivator<PrivateAotCtor>.RegisterFactory(() => PrivateAotCtor.Create(9));
if (FastActivator<PrivateAotCtor>.CreateObject().Value != 9)
throw new InvalidOperationException("FastActivator override failed.");

TypeHelper.RegisterFactory<StoredQuote>(_ => new StoredQuote());
var storage = new SettingsStorage()
.Set("quote", new SettingsStorage().Set("quantity", 7));
if (storage.GetValue<StoredQuote>("quote").Quantity != 7)
throw new InvalidOperationException("Existing SettingsStorage factory path failed.");

// Exercise the existing Ecng JSON collection deserializer with the registered
// List<T> and array factory. This uses the legacy wire format on purpose.
var legacySerializer = new Ecng.Serialization.JsonSerializer<List<int>>();
using var stream = new MemoryStream();
await legacySerializer.SerializeAsync(new List<int> { 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.
JArrayToObjectConverter<WireQuoteRow>.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<WireQuoteRow>()
.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.");
}
}
36 changes: 36 additions & 0 deletions AotSmoke/README.md
Original file line number Diff line number Diff line change
@@ -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<T>`
- Closed generic registration with `TypeHelper.RegisterConstructedGenericType`
- Name-to-type registration with `Converter.RegisterTypeName<T>`
- 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.
57 changes: 56 additions & 1 deletion Common/ArrayHelper.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,58 @@
namespace Ecng.Common;

using System.Collections.Concurrent;

/// <summary>
/// Utility class for common array operations.
/// </summary>
public static class ArrayHelper
{
private static readonly ConcurrentDictionary<Type, Func<int, Array>> _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<object>();
RegisterArrayType<string>();
RegisterArrayType<bool>();
RegisterArrayType<byte>();
RegisterArrayType<sbyte>();
RegisterArrayType<char>();
RegisterArrayType<short>();
RegisterArrayType<ushort>();
RegisterArrayType<int>();
RegisterArrayType<uint>();
RegisterArrayType<long>();
RegisterArrayType<ulong>();
RegisterArrayType<float>();
RegisterArrayType<double>();
RegisterArrayType<decimal>();
RegisterArrayType<DateTime>();
RegisterArrayType<DateTimeOffset>();
RegisterArrayType<TimeSpan>();
RegisterArrayType<Guid>();
}

/// <summary>
/// Registers a statically compiled array factory for <typeparamref name="T"/>.
/// This avoids runtime array type construction on Native AOT.
/// </summary>
public static void RegisterArrayType<T>()
=> _registeredArrayFactories.TryAdd(typeof(T), static length => new T[length]);

/// <summary>
/// Registers a custom statically compiled factory for one-dimensional arrays.
/// Only one factory may be registered for each element type.
/// </summary>
public static void RegisterArrayFactory<T>(Func<int, T[]> factory)
{
ArgumentNullException.ThrowIfNull(factory);

if (!_registeredArrayFactories.TryAdd(typeof(T), length => factory(length)))
throw new InvalidOperationException($"An array factory for '{typeof(T)}' is already registered.");
}

/// <summary>
/// Sets all of the elements in the specified <see cref="Array"/> to zero, false, or null, depending on the element type.
/// </summary>
Expand Down Expand Up @@ -38,7 +86,14 @@
/// <returns>A new array of the specified type and length.</returns>
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);

Check warning on line 96 in Common/ArrayHelper.cs

View workflow job for this annotation

GitHub Actions / aot

Using member 'System.Array.CreateInstance(Type, Int32)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The code for an array of the specified type might not be available.

Check warning on line 96 in Common/ArrayHelper.cs

View workflow job for this annotation

GitHub Actions / aot

Using member 'System.Array.CreateInstance(Type, Int32)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The code for an array of the specified type might not be available.
}

/// <summary>
Expand Down
23 changes: 22 additions & 1 deletion Common/Converter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ public static class Converter
/// </summary>
public static Func<string, Type> TypeFallback { get; set; }

/// <summary>
/// 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.
/// </summary>
public static void RegisterTypeName<T>(string name)
=> RegisterTypeName(name, typeof(T));

/// <summary>
/// Pre-registers a name for a known CLR type.
/// </summary>
public static void RegisterTypeName(string name, Type type)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("A type name is required.", nameof(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.");
}

/// <summary>
/// Fallback handler for Enum conversion from string when value is not found.
/// Returns the resolved enum value, or null to throw exception.
Expand Down Expand Up @@ -835,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);
Expand Down
Loading
Loading