-
Notifications
You must be signed in to change notification settings - Fork 5.4k
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<System.Int32>.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 <file>
// => 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