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.LoadIntoBufferAsyncStream.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIruntime-asyncuntriagedNew issue has not been triaged by the area owner

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions