Skip to content

NativeAOT drops reachable interface GVM implementation behind shape dispatch #134698

Description

@AArnott

Description

A NativeAOT application using a source-generated PolyType shape fails at runtime because the compiler does not retain a reachable generic virtual interface method implementation. The concrete implementation is created by a statically visible factory; there is no reflection or dynamic loading involved.

This was discovered while adding NativeAOT test execution to AArnott/Nerdbank.JsonRpc. It reduces to one POCO and one direct call to the published Nerdbank.Json package.

Reproduction steps

Create Repro.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <PublishAot>true</PublishAot>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Nerdbank.Json" Version="0.1.1041-alpha" />
    <PackageReference Include="PolyType" Version="1.4.1" />
  </ItemGroup>
</Project>

Create Program.cs:

using Nerdbank.Json;
using PolyType;

Poco value = new() { Number = 42 };
Console.WriteLine(new JsonSerializer().Serialize(value, GetShape<Poco, Poco>()));

static ITypeShape<T> GetShape<T, TProvider>()
    where TProvider : IShapeable<T>
    => TProvider.GetTypeShape();

[GenerateShape]
internal partial class Poco
{
    public int Number { get; init; }
}

Then run:

dotnet publish -c Release -r win-x64
.\bin\Release\net10.0\win-x64\publish\Repro.exe

Expected behavior

The executable prints serialized JSON for Poco.

Actual behavior

Publishing succeeds without trimming/AOT warnings. Running the executable fail-fasts:

Process terminated. Failed to create generic virtual method implementation

Declaring type: Nerdbank.Json.JsonStandardVisitor
Method name: PolyType.Abstractions.ITypeShapeFunc.Invoke
Instantiation:
  Argument 00000000: Poco

   at System.RuntimeExceptionHelpers.FailFast(...)
   at Internal.Runtime.TypeLoader.TypeLoaderEnvironment.ResolveGenericVirtualMethodTarget(...)
   at System.Runtime.TypeLoaderExports.CacheMiss(...)
   at System.Runtime.TypeLoaderExports.GVMLookupForSlotSlow(...)
   at PolyType.Utilities.TypeGenerationContext.GetOrAdd(ITypeShape, Object)
   at PolyType.Utilities.TypeCache.AddValue(ITypeShape)
   at PolyType.Utilities.TypeCache.GetOrAdd(ITypeShape)
   at Nerdbank.Json.ConverterCache.GetOrAddConverter[T](ITypeShape`1)
   at Nerdbank.Json.JsonSerializer.Serialize[T](...)
   at Program.<Main>$(String[] args)

Reduction and A/B results

The relevant reachable path is:

// Nerdbank.Json configures PolyType's cache with a concrete factory:
ValueBuilderFactory = ctx => new JsonStandardVisitor(this, ctx);

// PolyType later dispatches through the erased shape:
value = typeShape.Invoke(ValueBuilder, state);

// Generated/source shape dispatch reaches this interface GVM:
object? ITypeShapeFunc.Invoke<T>(ITypeShape<T> typeShape, object? state)
    => owner.CreateConverter(typeShape, this);

Observed A/B behavior:

  • Adding <TrimmerRootAssembly Include="Nerdbank.Json" /> makes the executable pass, but broadly rooting a shipping library is not a viable consumer requirement.
  • Replacing the Invoke<T> implementation with the direct expression below gets past the missing-GVM failure and enters normal converter traversal:

object? ITypeShapeFunc.Invoke(ITypeShape typeShape, object? state)
=> typeShape.Accept(this, state);

  • Keeping the complete converter-selection logic in Invoke<T> still fails. A minimal direct Accept body succeeds, while the shipping implementation's delegation to a generic helper fails.
  • Moving the visitor to a nested type, making the implementation public, eagerly constructing the cache, and using a static cache factory do not fix it.
  • Publishing Nerdbank.Json itself as net10.0 does not fix it.
  • PolyType's own JsonSerializerTS example uses the same MultiProviderTypeCache/ITypeShapeFunc.Invoke<T> pattern and passes NativeAOT. Its implementation directly calls typeShape.Accept(this), matching the successful side of the A/B test.

This looks like an ILC GVM reachability/code-generation issue rather than reflection usage or a missing user type shape. It is distinct from #113664, which fixed dynamic static generic virtual dispatch and is already present in .NET 10.

Configuration

Reproduced on Windows x64 with:

  • .NET SDK 10.0.401 / Microsoft.DotNet.ILCompiler 10.0.8
  • .NET SDK 11.0.100-preview.5.26302.115 / matching .NET 11 ILCompiler
  • Nerdbank.Json 0.1.1041-alpha
  • PolyType 1.4.1

The original test suite reproduces on Windows x64, Linux x64, and macOS arm64 under .NET 10 NativeAOT. Its IL executions pass on all three platforms.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions