dotnet / dotnet/runtime

NetworkStream.BeginRead produces UnobservedTaskException on socket disposal (.NET 10)

Open
#126,148 3 comments 2 reactions 0 assignees View on GitHub
area-System.Threading.Tasks
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

On .NET 6+ (confirmed on .NET 8/9/10 - Windows/Ubuntu24.04), calling NetworkStream.BeginRead followed by disposing the socket/stream before the EndRead callback fires produces a TaskScheduler.UnobservedTaskException with SocketException (995). This is a regression from .NET Framework 4.8 behavior where the same pattern works without issues.

The root cause is that Stream.BeginRead on .NET 6+ internally converts a ValueTask (from Socket.ReceiveAsync) into a Task via TaskToAsyncResult. When the socket is disposed while this operation is pending:

The internal AwaitableSocketAsyncEventArgs completes with SocketError.OperationAborted
The wrapped Task inside TaskToAsyncResult faults with SocketException(995):

```
TaskScheduler.UnobservedTaskException
System.AggregateException: A Task's exception(s) were not observed either by Waiting on the Task or accessing its Exception property. As a result, the unobserved exception was rethrown by the finalizer thread. (The I/O operation has been aborted because of either a thread exit or an application request.)
---> System.Net.Sockets.SocketException (995): The I/O operation has been aborted because of either a thread exit or an application request.
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
at System.Threading.Tasks.ValueTask`1.ValueTaskSourceAsTask.<>c.<.cctor>b__4_0(Object state)
```

If EndRead is never called (or is called but the stream is already disposed/nulled), the internal Task's exception is never observed
When the Task is garbage collected, TaskScheduler.UnobservedTaskException fires
This does not occur on:

.NET Framework 4.8: BeginRead uses the old APM pattern directly (OverlappedAsyncResult) — no Task wrapping involved.

To verify that you can observe that Task of the IAsyncResult of NetworkStream.BeginRead manually via reflection.

```
private void ObserveAsyncResultTask(IAsyncResult asyncResult)
{
// On .NET 10, TaskToAsyncResult holds the Task in a private field "_task".
var field = asyncResult.GetType().GetField(
"_task",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);

task = field?.GetValue(asyncResult) as Task;
task?..ContinueWith(
t =>
{
Console.Write("UnhandledException!!");
},
TaskContinuationOptions.OnlyOnFaulted);
}
```

### Reproduction Steps

Run this file based app to reproduce that issue on .NET 10
`dotnet run `
```
// => works
// #:property TargetFramework=net4.8
// => fails with unobserved exception
#:property TargetFramework=net10.0
#:property PublishAot=false
#:property LangVersion=13.0

using System;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;

class Program
{
static bool unobservedExceptionFired = false;

static void Main(string[] args)
{
Run().GetAwaiter().GetResult();
}

static async Task Run()
{
TaskScheduler.UnobservedTaskException += (s, e) =>
{
unobservedExceptionFired = true;
Console.WriteLine($"UnobservedTaskException: {e.Exception}");
};

var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;

var client = new TcpClient();
client.Connect(IPAddress.Loopback, port);

var serverSocket = listener.AcceptSocket();

var stream = client.GetStream();
var buffer = new byte[1024];

// Start an async read — this will be pending (no data to read).
// Critically: the callback does NOT call EndRead, simulating a common
// shutdown pattern where the callback checks a "stopping" flag and
// returns early without calling EndRead.
IAsyncResult asyncResult = stream.BeginRead(buffer, 0, buffer.Length, ar =>
{
// Simulate early return during shutdown — EndRead is never called.
// On .NET 6+, this leaves the internal ValueTask-backed Task unobserved.
// On .NET Framework 4.8, there is no internal Task, so this is harmless.
}, null);

// Simulate shutdown: dispose the socket while BeginRead is pending.
// This causes the internal async operation to complete with
// SocketException(995) "The I/O operation has been aborted".
client.Client.Dispose();
client.Dispose();
serverSocket.Close();
serverSocket.Dispose();
listener.Stop();

// Release all references so the IAsyncResult/Task can be GC'd
asyncResult = null;
stream = null;
client = null;

// Force GC to finalize the unobserved Task and trigger UnobservedTaskException
await Task.Delay(500);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
await Task.Delay(1000);

Console.WriteLine(unobservedExceptionFired
? "BUG REPRODUCED: UnobservedTaskException was fired"
: "No exception (not reproduced)");
}
}
```

### Expected behavior

Same as in .NET Framework 4.8 => NO UnobservedTaskException

### Actual behavior

UnobservedTaskException

### Regression?

works in .NET Framework 4.8

### Known Workarounds

_No response_

### Configuration

Windows:
.NET: .NET 10.0.100
OS: Windows 11
ARCH: x64

Linux:
.NET: .NET 10.0.104
OS: Ubuntu 24.04
ARCH: x64

### Other information

_No response_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.