Skip to content

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

@sharpSteff

Description

@sharpSteff

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions