Skip to content

Runtime-async miscompiles Stream.ReadAsync(Memory<byte>) tail-call forwarder into NullReferenceException (regression, likely from #128384) #129813

Description

@rolfbjarne

Description

A Stream subclass that overrides only the array-based ReadAsync(byte[], int, int, CancellationToken) throws NullReferenceException when consumed through the base-class Stream.ReadAsync(Memory<byte>, CancellationToken) forwarder (e.g. via Stream.CopyToAsync) — but only on the runtime-async-enabled CoreCLR runtime (the Apple mobile runtime packs: maccatalyst/ios/tvos).

The NRE originates inside the BCL forwarder itself:

System.NullReferenceException: Object reference not set to an instance of an object.
   at System.IO.Stream.ReadAsync(Memory`1 buffer, CancellationToken cancellationToken)
   at System.IO.Stream.<CopyToAsync>g__Core|30_0(Stream source, Stream destination, Int32 bufferSize, CancellationToken cancellationToken)
   at Program.<Main>$(String[] args)

Stream.ReadAsync(Memory<byte>) is a tail-call forwarder:

public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
    if (MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> array))
        return new ValueTask<int>(ReadAsync(array.Array!, array.Offset, array.Count, cancellationToken));
    ...
}

I believe this is a codegen regression from #128384 ("Compile runtime async versions of synchronous task-returning methods"). When the JIT compiles a runtime-async variant from the IL of these synchronous Task/ValueTask-returning forwarder methods, the tail-call forwarding appears to be miscompiled into an NRE.

Reproduction

This reproduces with a plain net11.0-maccatalyst console app (no third-party / platform API code — only Stream, MemoryStream, CopyToAsync). The Apple mobile runtime packs ship a pure-IL (JIT-compiled) CoreLib built with runtime-async enabled.

Program.cs:

int failures = 0;

try {
    var src = new ChunkStream (new byte[] { 1, 2, 3 }, new byte[] { 4, 5, 6 });
    using var dest = new MemoryStream ();
    await src.CopyToAsync (dest);
    Console.WriteLine ($"OK, {dest.Length} bytes");
} catch (Exception e) {
    failures++;
    Console.WriteLine ($"FAILED: {e.GetType ().Name}: {e.Message}");
    Console.WriteLine (e.StackTrace);
}

Console.WriteLine (failures == 0 ? "ALL PASSED (not reproduced)" : "reproduced");

// A Stream that overrides ONLY the array-based ReadAsync.
class ChunkStream : Stream {
    readonly Queue<byte[]> chunks = new ();
    public ChunkStream (params byte[][] data) { foreach (var d in data) chunks.Enqueue (d); }
    public override async Task<int> ReadAsync (byte[] buffer, int offset, int count, CancellationToken cancellationToken)
    {
        await Task.Yield ();
        if (chunks.Count == 0) return 0;
        var chunk = chunks.Dequeue ();
        var n = Math.Min (count, chunk.Length);
        Array.Copy (chunk, 0, buffer, offset, n);
        return n;
    }
    public override int Read (byte[] buffer, int offset, int count) => ReadAsync (buffer, offset, count).Result;
    public override bool CanRead => true;
    public override bool CanSeek => false;
    public override bool CanWrite => false;
    public override long Length => throw new NotSupportedException ();
    public override long Position { get => throw new NotSupportedException (); set => throw new NotSupportedException (); }
    public override void Flush () { }
    public override long Seek (long o, SeekOrigin s) => throw new NotSupportedException ();
    public override void SetLength (long v) => throw new NotSupportedException ();
    public override void Write (byte[] b, int o, int c) => throw new NotSupportedException ();
}

repro.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0-maccatalyst</TargetFramework>
    <RuntimeIdentifier>maccatalyst-arm64</RuntimeIdentifier>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <ApplicationId>com.example.asyncrepro</ApplicationId>
  </PropertyGroup>
</Project>

Build and run the app binary directly:

dotnet build -c Debug
./bin/Debug/net11.0-maccatalyst/maccatalyst-arm64/repro.app/Contents/MacOS/repro

Expected behavior

CopyToAsync completes and prints OK, 6 bytes.

Actual behavior

NullReferenceException thrown from inside Stream.ReadAsync(Memory<byte>).

Does NOT reproduce on desktop

A plain net11.0 desktop console app pinned to the exact same runtime pack version does not reproduce, even self-contained and with DOTNET_ReadyToRun=0, DOTNET_RuntimeAsync=1, DOTNET_TieredCompilation=0. The desktop osx-arm64 runtime pack ships a ReadyToRun-precompiled CoreLib and does not have runtime-async codegen enabled; only the Apple-mobile runtime packs do. So this only manifests where the runtime-async JIT path is actually used.

Impact

Discovered in dotnet/macios: every HttpClient request through NSUrlSessionHandler that buffers its response (HttpContent.LoadIntoBufferAsync → Stream.CopyToAsync over the native response stream) throws NullReferenceException. ~34 networking tests fail. A genuine-async override of ReadAsync(Memory<byte>) (with a real await, not a tail-call forwarder) works around it, since genuine async methods are compiled correctly.

Configuration

Other notes

MemoryStream.WriteAsync(ReadOnlyMemory<byte>) (another tail-call forwarder) shows the same NRE in some call paths and is similarly unpatchable from outside the runtime.

Activity

  1. dotnet-policy-service commented on Jun 24, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-io
    See info in area-owners.md if you want to be subscribed.

  2. rolfbjarne commented on Jun 24, 2026

    @rolfbjarne
    MemberAuthor

    CC @jakobbotsch - from the PR that introduced this regression (according to Copilot)

  3. jakobbotsch commented on Jun 24, 2026

    @jakobbotsch
    Member

    I tried to reproduce it but the repro fails for me:

    jakobbotsch@Jakobs-MacBook-Pro rtasync-issue % dotnet build -c Debug
    Restore complete (0,2s)
        info NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
      repro net11.0-maccatalyst maccatalyst-arm64 failed with 2 error(s) (5,4s) → bin/Debug/net11.0-maccatalyst/maccatalyst-arm64/repro.dll
        ILLink : error IL1032: Root assembly with name 'obj/Debug/net11.0-maccatalyst/maccatalyst-arm64/aot-instances.dll' could not be found.
        /Users/jakobbotsch/.nuget/packages/microsoft.net.illink.tasks/11.0.0-preview.6.26323.106/build/Microsoft.NET.ILLink.targets(108,5): error NETSDK1144: Optimizing assemblies for size failed.
    
    Build failed with 2 error(s) in 6,0s
    
  4. jakobbotsch commented on Jun 24, 2026

    @jakobbotsch
    Member

    I added <UseMonoRuntime>false</UseMonoRuntime> and now it reproduces.

    When I look at the System.Private.CoreLib.dll that is bundled with the app at bin/Debug/net11.0-maccatalyst/maccatalyst-arm64/repro.app/Contents/MonoBundle/System.Private.CoreLib.dll , I see e.g.

    [CompilerGenerated]
    [MethodImpl(MethodImplOptions.Async)]
    internal static Task <CopyToAsync>g__Core|30_0(Stream source, Stream destination, int bufferSize, CancellationToken cancellationToken)
    {
    	throw null;
    }

    which is what throws the exception. @janvorli points out that this might be a trimming issue, I'll see if I can find some people to involve...

  5. jakobbotsch commented on Jun 24, 2026

    @jakobbotsch
    Member

    Probably not trimming but rather #125647.
    It is certainly caused by #128384. That PR disabled prejitting of async variants (temporarily -- reenabling it is tracked by #129524). Hopefully we'll have that fixed soon, but we can also consider reverting #128384.

    @kotlarmilos would it make sense to avoid stripping the bodies of methods that crossgen2 failed to compile, since those bodies will be needed at runtime? If not we should at least give some kind of warning or error from crossgen2, or fail at runtime when we try to use interpreter to run something with stripped body.

  6. dotnet-policy-service commented on Jun 25, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

  7. removed
    untriagedNew issue has not been triaged by the area owner
    on Jun 26, 2026
  8. added this to the 11.0.0 milestone on Jun 26, 2026
  9. jkotas commented on Jun 26, 2026

    @jkotas
    Member

    would it make sense to avoid stripping the bodies of methods that crossgen2 failed to compil

    crossgen2 does that today: https://github.com/search?q=repo%3Adotnet%2Fruntime%20compiledMethodDefs&type=code . The problem is that crossgen2 assumes that it is ok to strip the IL if we have compiled given (non-generic) method definition once. It is not the case for the async methods.

    @jakobbotsch The simplest fix is to treat async methods the same way as generics and suppress IL stripping for them here:

    && factory.OptimizationFlags.CompiledMethodDefs.Contains(_method)

  10. 1 remaining item

  11. jakobbotsch commented on Jun 26, 2026

    @jakobbotsch
    Member

    When I try the repro here with /p:PublishReadyToRunStripILBodies=false we no longer crash, but the program does not finish either. It seems we never continue after await Task.Yield(). I suspect it is related to calling between interpreter and crossgen2 for async code, but I am struggling to get SOS to work on maccatalyst so I am trying to come up with a separate repro.

  12. rolfbjarne commented on Jun 29, 2026

    @rolfbjarne
    MemberAuthor

    would it make sense to avoid stripping the bodies of methods that crossgen2 failed to compil

    crossgen2 does that today: github.com/search?q=repo%3Adotnet%2Fruntime%20compiledMethodDefs&type=code . The problem is that crossgen2 assumes that it is ok to strip the IL if we have compiled given (non-generic) method definition once. It is not the case for the async methods.
    @jakobbotsch The simplest fix is to treat async methods the same way as generics and suppress IL stripping for them here:
    runtime/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/CopiedMethodILNode.cs
    Line 59 in c369fd6
    && factory.OptimizationFlags.CompiledMethodDefs.Contains(_method)

    Thanks! Opened #129884

    Could this be backported to P6?

  13. jakobbotsch commented on Jun 29, 2026

    @jakobbotsch
    Member

    Not sure if there is time, but we can certainly try.

  14. jakobbotsch commented on Jun 29, 2026

    @jakobbotsch
    Member

    When I try the repro here with /p:PublishReadyToRunStripILBodies=false we no longer crash, but the program does not finish either. It seems we never continue after await Task.Yield(). I suspect it is related to calling between interpreter and crossgen2 for async code, but I am struggling to get SOS to work on maccatalyst so I am trying to come up with a separate repro.

    So I chatted a bit with @kotlarmilos and it looks like this just has to do with the default UIKitSynchronizationContext used for maccatalyst. The C#-generated Main() above blocks the main thread while Task.Yield() posts back to the main thread, leading to a dead lock.

    So this should be fixed by #129884.

  15. locked and limited conversation to collaborators on Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIruntime-async

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions