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.
Description
A
Streamsubclass that overrides only the array-basedReadAsync(byte[], int, int, CancellationToken)throwsNullReferenceExceptionwhen consumed through the base-classStream.ReadAsync(Memory<byte>, CancellationToken)forwarder (e.g. viaStream.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:
Stream.ReadAsync(Memory<byte>)is a tail-call forwarder: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-maccatalystconsole app (no third-party / platform API code — onlyStream,MemoryStream,CopyToAsync). The Apple mobile runtime packs ship a pure-IL (JIT-compiled) CoreLib built with runtime-async enabled.Program.cs:repro.csproj:Build and run the app binary directly:
Expected behavior
CopyToAsynccompletes and printsOK, 6 bytes.Actual behavior
NullReferenceExceptionthrown from insideStream.ReadAsync(Memory<byte>).Does NOT reproduce on desktop
A plain
net11.0desktop console app pinned to the exact same runtime pack version does not reproduce, even self-contained and withDOTNET_ReadyToRun=0,DOTNET_RuntimeAsync=1,DOTNET_TieredCompilation=0. The desktoposx-arm64runtime 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
HttpClientrequest throughNSUrlSessionHandlerthat buffers its response (HttpContent.LoadIntoBufferAsync→Stream.CopyToAsyncover the native response stream) throwsNullReferenceException. ~34 networking tests fail. A genuine-asyncoverride ofReadAsync(Memory<byte>)(with a realawait, not a tail-call forwarder) works around it, since genuine async methods are compiled correctly.Configuration
11.0.0-preview.6.26323.106(dotnet/dotnet VMR commita3bc9fe168e83785ed89c54c29aea18b80f3838b)maccatalyst-arm64(CoreCLR), Apple Silicon macOSOther 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.