Repository: dotnet/runtime
Pull request #114689: WebSocket: observe exceptions in WaitForServerToCloseConnectionAsync
Author: login: antonfirsov
Labels: name: area-System.Net
Created: 2025-04-15T13:31:46Z | Merged: 2025-04-23T17:28:59Z

Fixes #80116

Includes a refactor renaming and moving the methods responsible for logging and observing the exceptions. The renaming is for consistency with `HttpConnectionBase.LogExceptions` in `System.Net.Http`.

Linked issue #80116: Unobserved Task Exception after closing ClientWebSocket
### Description

We randomly get the following unobserved task exception in our logs:

```
Unobserved Task Exception: 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. (Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..)
 ---> System.IO.IOException: Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..
 ---> System.Net.Sockets.SocketException (995): Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen.
   --- End of inner exception stack trace ---
   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.Net.Security.SslStream.EnsureFullTlsFrameAsync[TIOAdapter](TIOAdapter adapter)
   at System.Net.Security.SslStream.ReadAsyncInternal[TIOAdapter](TIOAdapter adapter, Memory`1 buffer)
   at System.Net.Http.HttpConnection.ReadBufferedAsyncCore(Memory`1 destination)
   at System.Net.Http.HttpConnection.RawConnectionStream.ReadAsync(Memory`1 buffer, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
```

I managed to narrow this down to the ClientWebSocket close handshake and can reproduce this (see Reproduction Steps below). It happens when the closing of the connection takes too long after the close handshake finished. I think the issue is here:

https://github.com/dotnet/runtime/blob/ac2ffdf4ff87e7e3a5506a8ef69ce633f6fcda85/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs#L985

When `WaitAsync()` throws a `TimeoutException`, it gets ignored, but the exception from the original `finalReadTask` (which may happen later) is never observed in this case.

### Reproduction Steps

```c#
using System.Net.WebSockets;

namespace WebApplication2
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            TaskScheduler.UnobservedTaskException += (obj, args) =>
            {
                Console.Error.WriteLine("Unhandled exception: " + args.Exception);
            };

            var builder = WebApplication.CreateBuilder(args);
            var app = builder.Build();

            app.UseWebSockets();

            app.Use(async (HttpContext context, RequestDelegate next) =>
            {
                WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
                await webSocket.ReceiveAsync(new ArraySegment<byte>(new byte[4096]), CancellationToken.None);
                await webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "normal", CancellationToken.None);
                await Task.Delay(10_000);
            });

            await app.StartAsync();

            ClientWebSocket clientWebSocket = new ClientWebSocket();
            await clientWebSocket.ConnectAsync(new Uri("ws://localhost:5065"), CancellationToken.None);
            await clientWebSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "normal", CancellationToken.None);
            await clientWebSocket.ReceiveAsync(new ArraySegment<byte>(new byte[4096]), CancellationToken.None);
            clientWebSocket.Dispose();
            await Task.Delay(5_000);
            GC.Collect(2);
            GC.WaitForPendingFinalizers();
            await Task.Delay(100_000);
        }
    }
}
```

### Expected behavior

No unobserved task exception.

### Actual behavior

Unobserved task exception:

```
Unobserved Task Exception: 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. (Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..)
 ---> System.IO.IOException: Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..
 ---> System.Net.Sockets.SocketException (995): Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen.
   --- End of inner exception stack trace ---
   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.Net.Security.SslStream.EnsureFullTlsFrameAsync[TIOAdapter](TIOAdapter adapter)
   at System.Net.Security.SslStream.ReadAsyncInternal[TIOAdapter](TIOAdapter adapter, Memory`1 buffer)
   at System.Net.Http.HttpConnection.ReadBufferedAsyncCore(Memory`1 destination)
   at System.Net.Http.HttpConnection.RawConnectionStream.ReadAsync(Memory`1 buffer, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
```

### Regression?

_No response_

### Known Workarounds

_No response_

### Configuration

.NET 6.0.11 on Windows 11 x64.

### Other information

_No response_
Linked issue comments: user: login: ghost
created_at: 2023-01-03T14:31:07Z
body: Tagging subscribers to this area: @dotnet/ncl
See info in [area-owners.md](https://github.com/dotnet/runtime/blob/main/docs/area-owners.md) if you want to be subscribed.
<details>
<summary>Issue Details</summary>
<hr />

### Description

We randomly get the following unobserved task exception in our logs:

```
Unobserved Task Exception: 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. (Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..)
 ---> System.IO.IOException: Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..
 ---> System.Net.Sockets.SocketException (995): Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen.
   --- End of inner exception stack trace ---
   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.Net.Security.SslStream.EnsureFullTlsFrameAsync[TIOAdapter](TIOAdapter adapter)
   at System.Net.Security.SslStream.ReadAsyncInternal[TIOAdapter](TIOAdapter adapter, Memory`1 buffer)
   at System.Net.Http.HttpConnection.ReadBufferedAsyncCore(Memory`1 destination)
   at System.Net.Http.HttpConnection.RawConnectionStream.ReadAsync(Memory`1 buffer, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
```

I managed to narrow this down to the ClientWebSocket close handshake and can reproduce this (see Reproduction Steps below). It happens when the closing of the connection takes too long after the close handshake finished. I think the issue is here:

https://github.com/dotnet/runtime/blob/ac2ffdf4ff87e7e3a5506a8ef69ce633f6fcda85/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs#L985

When `WaitAsync()` throws a `TimeoutException`, it gets ignored, but the exception from the original `finalReadTask` (which may happen later) is never observed in this case.

### Reproduction Steps

```
using System.Net.WebSockets;

namespace WebApplication2
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            TaskScheduler.UnobservedTaskException += (obj, args) =>
            {
                Console.Error.WriteLine("Unhandled exception: " + args.Exception);
            };

            var builder = WebApplication.CreateBuilder(args);
            var app = builder.Build();

            app.UseWebSockets();

            app.Use(async (HttpContext context, RequestDelegate next) =>
            {
                WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
                await webSocket.ReceiveAsync(new ArraySegment<byte>(new byte[4096]), CancellationToken.None);
                await webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "normal", CancellationToken.None);
                await Task.Delay(10_000);
            });

            await app.StartAsync();

            ClientWebSocket clientWebSocket = new ClientWebSocket();
            await clientWebSocket.ConnectAsync(new Uri("ws://localhost:5065"), CancellationToken.None);
            await clientWebSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "normal", CancellationToken.None);
            await clientWebSocket.ReceiveAsync(new ArraySegment<byte>(new byte[4096]), CancellationToken.None);
            clientWebSocket.Dispose();
            await Task.Delay(5_000);
            GC.Collect(2);
            GC.WaitForPendingFinalizers();
            await Task.Delay(100_000);
        }
    }
}
```

### Expected behavior

No unobserved task exception.

### Actual behavior

Unobserved task exception:

```
Unobserved Task Exception: 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. (Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..)
 ---> System.IO.IOException: Unable to read data from the transport connection: Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen..
 ---> System.Net.Sockets.SocketException (995): Der E/A-Vorgang wurde wegen eines Threadendes oder einer Anwendungsanforderung abgebrochen.
   --- End of inner exception stack trace ---
   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.Net.Security.SslStream.EnsureFullTlsFrameAsync[TIOAdapter](TIOAdapter adapter)
   at System.Net.Security.SslStream.ReadAsyncInternal[TIOAdapter](TIOAdapter adapter, Memory`1 buffer)
   at System.Net.Http.HttpConnection.ReadBufferedAsyncCore(Memory`1 destination)
   at System.Net.Http.HttpConnection.RawConnectionStream.ReadAsync(Memory`1 buffer, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
```

### Regression?

_No response_

### Known Workarounds

_No response_

### Configuration

.NET 6.0.11 on Windows 11 x64.

### Other information

_No response_

<table>
  <tr>
    <th align="left">Author:</th>
    <td>mus65</td>
  </tr>
  <tr>
    <th align="left">Assignees:</th>
    <td>-</td>
  </tr>
  <tr>
    <th align="left">Labels:</th>
    <td>

`area-System.Net`

</td>
  </tr>
  <tr>
    <th align="left">Milestone:</th>
    <td>-</td>
  </tr>
</table>
</details>
author_association: NONE
user: login: MihaZupan
created_at: 2023-01-03T14:47:23Z
body: Thanks for the great repro, this does look like a bug.
Introduced by #56282 (in .NET 6.0).

This should be fairly easy to fix (that task should be awaited in the `catch` block after the call to `Abort`).
@mus65 would you be interested in contributing a PR?
author_association: MEMBER
user: login: MihaZupan
created_at: 2023-01-03T14:48:25Z
body: Triage: `UnobservedTaskException` are annoying and this one should be simple to fix, moving to 8.0 for now.
author_association: MEMBER
user: login: mus65
created_at: 2023-01-04T15:05:08Z
body: @MihaZupan But the await would need to wrapped in another try/catch, right? Otherwise we would throw here which currently cant' happen.

Would this change require a test case? If not, I can make a PR. I'm not sure If I could write a consistent test case for this (especially since I assume I can't use ASP .NET Core in runtime tests).
author_association: CONTRIBUTOR
user: login: MihaZupan
created_at: 2023-01-04T15:43:07Z
body: I think the cleanest option would be to revert to how this was written before #56282, something like
```c#
using var finalCts = new CancellationTokenSource(WaitForCloseTimeoutMs);
using (finalCts.Token.UnsafeRegister(static s => ((ManagedWebSocket)s!).Abort(), this))
{
    try
    {
        await finalReadTask;
    }
    catch { }
}
```

Re: tests, it's generally harder to write ones that verify that all task exceptions are observed. While having a regression test to make sure we don't reintroduce the same mistake in the future would be nice, we'd take a PR even without it.

We don't use ASP.NET in our tests, but we have our own "loopback server". You can see some examples of how that might look like here: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.WebSockets.Client/tests/SendReceiveTest.cs.
author_association: MEMBER
user: login: stephentoub
created_at: 2023-01-04T15:53:57Z
body: > I think the cleanest option would be to revert to how this was written before https://github.com/dotnet/runtime/pull/56282, something like

finalReadTask is a ValueTask.  Wouldn't your suggestion result in it possibly being consume twice?  Or have I misunderstood what you're suggesting?
author_association: MEMBER
user: login: stephentoub
created_at: 2023-01-04T15:55:42Z
body: > While having a regression test to make sure we don't reintroduce the same mistake in the future would be nice, we'd take a PR even without it.

I think a PR needs a test.  If it's worth fixing this to ensure we're not raising the event, then it's worth having a test for it.
author_association: MEMBER
user: login: MihaZupan
created_at: 2023-01-04T16:05:16Z
body: > finalReadTask is a ValueTask. Wouldn't your suggestion result in it possibly being consume twice? Or have I misunderstood what you're suggesting?

I meant we'd replace the current logic (`WaitAsync`) with setting up the CTS and `await` the value task once like in my example above. I don't think that could lead to awaiting twice?
author_association: MEMBER
user: login: stephentoub
created_at: 2023-01-04T16:08:59Z
body: > I meant we'd replace the current logic (WaitAsync) with setting up the CTS and await the value task once like in my example above. I don't think that could lead to awaiting twice?

Ah, replacing it would be fine from the perspective of waiting for it twice.

However, there is no guarantee that Dispose'ing of the Stream will result in the task returned from ReadAsync completing.  That's why it's written the way it is.
author_association: MEMBER
user: login: MihaZupan
created_at: 2023-01-04T19:52:47Z
body: > However, there is no guarantee that Dispose'ing of the Stream will result in the task returned from ReadAsync completing. That's why it's written the way it is.

We're relying on this being the case in [HttpConnection](https://github.com/dotnet/runtime/blob/47f6a48320b52cb371e9d113f90fb3e81e4a49a2/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs#L888) though, no?
author_association: MEMBER
user: login: stephentoub
created_at: 2023-01-04T20:04:47Z
body: Yes, but only because at the time it was written we had no other option, e.g. there was no cancellation story for NetworkStream / Socket.  I believe there's an open issue to switch HttpConnection over to using CancellationTokens threaded through all the calls instead of this approach with disposing streams, which is not thread-safe and not guaranteed to work, especially with ConnectCallback able to supply an arbitrary Stream.
author_association: MEMBER
user: login: wfurt
created_at: 2024-06-12T22:44:54Z
body: triage: not critical for 9.0, moving to future
author_association: MEMBER
user: login: flagbug
created_at: 2024-06-25T10:20:15Z
body: We're seeing the same issue in our application and our Sentry error reporting is picking up these unobserved task exceptions. Took forever to determine that this is not a problem in our own code, but in the framework 😬 
author_association: NONE
user: login: LeadAssimilator
created_at: 2024-09-13T09:23:09Z
body: Same issue with Udp/TcpClient. After closing it, every pending receive operation started with BeginReceive will emit the aforementioned unobserved task exception.

Just ran into this on maui ios where the unobserved task handler was attempting to break if a debugger was attached, which due to some other bug, just hangs 95%+ of the time - what fun that was tracking down.

This bug should have been fixed by now...having the runtime generate these kinds of errors just wastes everyone's time and needlessly increases SNR. We rely on the UnobservedTaskException to point out where we are being idiotic, but if the runtime is being an idiot too, then we really can't reliably use it as a safety check.
author_association: NONE
user: login: kirsan31
created_at: 2025-03-05T13:11:43Z
body: Same problem with simple `Socket.Close` call during `Socket.BeginReceive` (.NET9):
```cs
UnobservedTaskException 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)
```

Took some time to investigate the root cause :( ...

And [documentation](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.beginreceive?view=net-9.0) clearly says that all must be ok:
> Close the [Socket](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket?view=net-9.0) to cancel a pending [BeginReceive](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.beginreceive?view=net-9.0). When the [Close](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.close?view=net-9.0) method is called while an asynchronous operation is in progress, the callback provided to the [BeginReceive](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.beginreceive?view=net-9.0) method is called. A subsequent call to the [EndReceive](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.endreceive?view=net-9.0) method will throw an [ObjectDisposedException](https://learn.microsoft.com/en-us/dotnet/api/system.objectdisposedexception?view=net-9.0) (before .NET 7) or a [SocketException](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socketexception?view=net-9.0) (on .NET 7+) to indicate that the operation has been cancelled.
author_association: NONE
user: login: LeadAssimilator
created_at: 2025-04-25T07:46:54Z
body: Unless I'm missing something, the fix doesn't seem to address the additionally reported instances of this problem that affect Socket when using BeginReceive and all classes that use Socket such as Tcp/UdpClient?
author_association: NONE
user: login: antonfirsov
created_at: 2025-04-25T19:34:42Z
body: This issue is tracking a specific bug in `ClientWebSocket`. If you think there is a problem in other types please open a separate issue and provide a minimal, self-contained repro.

Note that with [APM](https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/asynchronous-programming-model-apm) `BeginOperation()` should be always followed by a matching `EndOperation()` call. In case the `EndOperation()` call is missing, getting an unobserved exception is expected. (It's the same as not awaiting a `Task`.)
author_association: CONTRIBUTOR

Diff:
diff --git a/src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs b/src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs
index 14affae6bd39e5..063ee71169d17e 100644
--- a/src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs
+++ b/src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs
@@ -12,6 +12,7 @@
 
 using Xunit;
 using Xunit.Abstractions;
+using Microsoft.DotNet.RemoteExecutor;
 
 namespace System.Net.WebSockets.Client.Tests
 {
@@ -523,5 +524,42 @@ await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
 
             }), new LoopbackServer.Options { WebSocketEndpoint = true });
         }
+
+        // Regression test for https://github.com/dotnet/runtime/issues/80116.
+        [OuterLoop("Uses Task.Delay")]
+        [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
+        public async Task CloseHandshake_ExceptionsAreObserved()
+        {
+            await RemoteExecutor.Invoke(static (typeName) =>
+            {
+                CloseTest test = (CloseTest)Activator.CreateInstance(typeof(CloseTest).Assembly.GetType(typeName), new object[] { null });
+                using CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeOutMilliseconds);
+
+                Exception unobserved = null;
+                TaskScheduler.UnobservedTaskException += (obj, args) =>
+                {
+                    unobserved = args.Exception;
+                };
+
+                TaskCompletionSource clientCompleted = new TaskCompletionSource();
+
+                return LoopbackWebSocketServer.RunAsync(async (clientWs, ct) =>
+                {
+                    await clientWs.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", ct);
+                    await clientWs.ReceiveAsync(new byte[16], ct);
+                    await Task.Delay(1500);
+                    GC.Collect(2);
+                    GC.WaitForPendingFinalizers();
+                    clientCompleted.SetResult();
+                    Assert.Null(unobserved);
+                },
+                async (serverWs, ct) =>
+                {
+                    await serverWs.ReceiveAsync(new byte[16], ct);
+                    await serverWs.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", ct);
+                    await clientCompleted.Task;
+                }, new LoopbackWebSocketServer.Options(HttpVersion.Version11, true, test.GetInvoker()), timeoutCts.Token);
+            }, GetType().FullName).DisposeAsync();
+        }
     }
 }
diff --git a/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj b/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj
index 98f61386b3eaa8..d5e28ff2f552cb 100644
--- a/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj
+++ b/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj
@@ -3,6 +3,7 @@
 
   <PropertyGroup>
     <StringResourcesPath>../src/Resources/Strings.resx</StringResourcesPath>
+    <IncludeRemoteExecutor>true</IncludeRemoteExecutor>
     <TargetFrameworks>$(NetCoreAppCurrent);$(NetCoreAppCurrent)-browser</TargetFrameworks>
     <DefineConstants>$(DefineConstants);NETSTANDARD</DefineConstants>
   </PropertyGroup>
@@ -46,6 +47,7 @@
     <Compile Include="$(CommonTestPath)System\Net\Http\HuffmanEncoder.cs" Link="Common\System\Net\Http\HuffmanEncoder.cs" />
     <Compile Include="$(CommonTestPath)System\Net\Http\HPackEncoder.cs" Link="Common\System\Net\Http\HPackEncoder.cs" />
     <Compile Include="$(CommonTestPath)System\Net\Http\GenericLoopbackServer.cs" Link="Common\System\Net\Http\GenericLoopbackServer.cs" />
+    <Compile Include="$(CommonTestPath)System\Net\RemoteExecutorExtensions.cs" Link="Common\System\Net\RemoteExecutorExtensions.cs" />
     <Compile Include="$(CommonTestPath)System\Threading\Tasks\TaskTimeoutExtensions.cs" Link="Common\System\Threading\Tasks\TaskTimeoutExtensions.cs" />
     <Compile Include="AbortTest.cs" />
     <Compile Include="AbortTest.Loopback.cs" />
diff --git a/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.KeepAlive.cs b/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.KeepAlive.cs
index 4e38c4fc10042f..1583872c881031 100644
--- a/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.KeepAlive.cs
+++ b/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.KeepAlive.cs
@@ -29,7 +29,7 @@ private void UnsolicitedPongHeartBeat()
         {
             if (NetEventSource.Log.IsEnabled()) NetEventSource.Trace(this);
 
-            Observe(
+            LogExceptions(
                 TrySendKeepAliveFrameAsync(MessageOpcode.Pong));
         }
 
@@ -98,7 +98,7 @@ private void KeepAlivePingHeartBeat()
 
                 if (shouldSendPing)
                 {
-                    Observe(
+                    LogExceptions(
                         SendPingAsync(pingPayload));
                 }
             }
@@ -122,52 +122,6 @@ private async ValueTask SendPingAsync(long pingPayload)
             if (NetEventSource.Log.IsEnabled()) NetEventSource.KeepAlivePingSent(this, pingPayload);
         }
 
-        // "Observe" either a ValueTask result, or any exception, ignoring it
-        // to prevent the unobserved exception event from being raised.
-        private void Observe(ValueTask t)
-        {
-            if (t.IsCompletedSuccessfully)
-            {
-                t.GetAwaiter().GetResult();
-            }
-            else
-            {
-                Observe(t.AsTask());
-            }
-        }
-
-        // "Observe" any exception, ignoring it to prevent the unobserved task
-        // exception event from being raised.
-        private void Observe(Task t)
-        {
-            if (t.IsCompleted)
-            {
-                if (t.IsFaulted)
-                {
-                    LogFaulted(t, this);
-                }
-            }
-            else
-            {
-                t.ContinueWith(
-                    LogFaulted,
-                    this,
-                    CancellationToken.None,
-                    TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
-                    TaskScheduler.Default);
-            }
-
-            static void LogFaulted(Task task, object? thisObj)
-            {
-                Debug.Assert(task.IsFaulted);
-
-                // accessing exception to observe it regardless of whether the tracing is enabled
-                Exception e = task.Exception!.InnerException!;
-
-                if (NetEventSource.Log.IsEnabled()) NetEventSource.TraceException(thisObj, e);
-            }
-        }
-
         private sealed class KeepAlivePingState
         {
             internal const int PingPayloadSize = sizeof(long);
diff --git a/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs b/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs
index f40da1cb06e8c5..594c4bae2b96db 100644
--- a/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs
+++ b/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs
@@ -1144,6 +1144,7 @@ private async ValueTask WaitForServerToCloseConnectionAsync(CancellationToken ca
             // additional data, but at this point we're about to close the connection and we're just stalling
             // to try to get the server to close first.
             ValueTask<int> finalReadTask = _stream.ReadAsync(_receiveBuffer, cancellationToken);
+
             if (finalReadTask.IsCompletedSuccessfully)
             {
                 finalReadTask.GetAwaiter().GetResult();
@@ -1151,16 +1152,19 @@ private async ValueTask WaitForServerToCloseConnectionAsync(CancellationToken ca
             else
             {
                 const int WaitForCloseTimeoutMs = 1_000; // arbitrary amount of time to give the server (same duration as .NET Framework)
+                Task task = finalReadTask.AsTask();
+
                 try
                 {
 #pragma warning disable CA2016 // Token was already provided to the ReadAsync
-                    await finalReadTask.AsTask().WaitAsync(TimeSpan.FromMilliseconds(WaitForCloseTimeoutMs)).ConfigureAwait(false);
+                    await task.WaitAsync(TimeSpan.FromMilliseconds(WaitForCloseTimeoutMs)).ConfigureAwait(false);
 #pragma warning restore CA2016
                 }
                 catch
                 {
+                    // Eat any resulting exceptions. We were going to close the connection, anyway.
+                    LogExceptions(task);
                     Abort();
-                    // Eat any resulting exceptions.  We were going to close the connection, anyway.
                 }
             }
         }
@@ -1851,6 +1855,52 @@ private static bool TryValidateUtf8(ReadOnlySpan<byte> span, bool endOfMessage,
             return !endOfMessage || !state.SequenceInProgress;
         }
 
+        // "Observe" either a ValueTask result, or any exception, logging and ignoring it
+        // to prevent the unobserved exception event from being raised.
+        private void LogExceptions(ValueTask t)
+        {
+            if (t.IsCompletedSuccessfully)
+            {
+                t.GetAwaiter().GetResult();
+            }
+            else
+            {
+                LogExceptions(t.AsTask());
+            }
+        }
+
+        // "Observe" and log any exception, ignoring it to prevent the unobserved task
+        // exception event from being raised.
+        private void LogExceptions(Task t)
+        {
+            if (t.IsCompleted)
+            {
+                if (t.IsFaulted)
+                {
+                    LogFaulted(t, this);
+                }
+            }
+            else
+            {
+                t.ContinueWith(
+                    LogFaulted,
+                    this,
+                    CancellationToken.None,
+                    TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+                    TaskScheduler.Default);
+            }
+
+            static void LogFaulted(Task task, object? thisObj)
+            {
+                Debug.Assert(task.IsFaulted);
+
+                // accessing exception to observe it regardless of whether the tracing is enabled
+                Exception e = task.Exception!.InnerException!;
+
+                if (NetEventSource.Log.IsEnabled()) NetEventSource.TraceException(thisObj, e);
+            }
+        }
+
         private sealed class Utf8MessageState
         {
             internal bool SequenceInProgress;


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors how exceptions are logged and observed in WebSocket connection close handling, aligning with HttpConnectionBase.LogExceptions for consistency.  
- Refactored WaitForServerToCloseConnectionAsync to use LogExceptions instead of Observe.  
- Removed duplicated Observe methods in favor of the new LogExceptions implementations.  
- Added a regression test to ensure that exceptions during the close handshake are properly observed.

### Reviewed Changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated no comments.

| File | Description |
| ---- | ----------- |
| src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs | Updated WaitForServerToCloseConnectionAsync to log exceptions and refactored logging helper methods. |
| src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.KeepAlive.cs | Replaced Observe calls with LogExceptions and removed redundant logging helpers. |
| src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs | Added a regression test for unobserved exceptions during the close handshake. |

<details>
<summary>Files not reviewed (1)</summary>

* **src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs:539**
* The test registers an event handler for TaskScheduler.UnobservedTaskException but does not unsubscribe it, which could potentially affect other tests. Consider unregistering the event handler after the test completes to ensure isolation.
```
TaskScheduler.UnobservedTaskException += (obj, args) =>
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:32:04Z
user: login: MihaZupan
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-15T16:13:54Z
user: login: CarnaViire
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T10:54:07Z
user: login: antonfirsov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:24:28Z
user: login: CarnaViire
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T17:27:11Z

Review comments:
diff_hunk: @@ -523,5 +524,42 @@ await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
 
             }), new LoopbackServer.Options { WebSocketEndpoint = true });
         }
+
+        // Regression test for https://github.com/dotnet/runtime/issues/80116.
+        [OuterLoop("Uses Task.Delay")]
+        [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
+        public async Task CloseHandshake_ExceptionsAreObserved()
+        {
+            await RemoteExecutor.Invoke(static (typeName) =>
+            {
+                CloseTest test = (CloseTest)Activator.CreateInstance(typeof(CloseTest).Assembly.GetType(typeName), new object[] { null });
+                using CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeOutMilliseconds);
+
+                bool unobserved = false;
+                TaskScheduler.UnobservedTaskException += (obj, args) =>
+                {
+                    unobserved = true;
path: src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs
user: login: MihaZupan
body: Nit: consider saving/logging the exception itself and assert that it's null later, that way we can save time later if e.g. this starts capturing unobserved exceptions from the Http stack.
created_at: 2025-04-15T16:10:32Z
author_association: MEMBER
diff_hunk: @@ -523,5 +524,42 @@ await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
 
             }), new LoopbackServer.Options { WebSocketEndpoint = true });
         }
+
+        // Regression test for https://github.com/dotnet/runtime/issues/80116.
+        [OuterLoop("Uses Task.Delay")]
+        [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
+        public async Task CloseHandshake_ExceptionsAreObserved()
+        {
+            await RemoteExecutor.Invoke(static (typeName) =>
+            {
+                CloseTest test = (CloseTest)Activator.CreateInstance(typeof(CloseTest).Assembly.GetType(typeName), new object[] { null });
+                using CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeOutMilliseconds);
+
+                Exception unobserved = null;
+                TaskScheduler.UnobservedTaskException += (obj, args) =>
+                {
+                    unobserved = args.Exception;
+                };
+
+                TaskCompletionSource clientCompleted = new TaskCompletionSource();
+
+                return LoopbackWebSocketServer.RunAsync(async (clientWs, ct) =>
+                {
+                    await clientWs.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", ct);
+                    await clientWs.ReceiveAsync(new byte[16], ct);
+                    await Task.Delay(1500);
+                    GC.Collect(2);
+                    GC.WaitForPendingFinalizers();
+                    clientCompleted.SetResult();
+                    Assert.Null(unobserved);
path: src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs
user: login: CarnaViire
body: Was that enough to consistently get an unobserved task exception before the PR change?
created_at: 2025-04-23T10:54:07Z
author_association: MEMBER
diff_hunk: @@ -523,5 +524,42 @@ await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
 
             }), new LoopbackServer.Options { WebSocketEndpoint = true });
         }
+
+        // Regression test for https://github.com/dotnet/runtime/issues/80116.
+        [OuterLoop("Uses Task.Delay")]
+        [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
+        public async Task CloseHandshake_ExceptionsAreObserved()
+        {
+            await RemoteExecutor.Invoke(static (typeName) =>
+            {
+                CloseTest test = (CloseTest)Activator.CreateInstance(typeof(CloseTest).Assembly.GetType(typeName), new object[] { null });
+                using CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeOutMilliseconds);
+
+                Exception unobserved = null;
+                TaskScheduler.UnobservedTaskException += (obj, args) =>
+                {
+                    unobserved = args.Exception;
+                };
+
+                TaskCompletionSource clientCompleted = new TaskCompletionSource();
+
+                return LoopbackWebSocketServer.RunAsync(async (clientWs, ct) =>
+                {
+                    await clientWs.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", ct);
+                    await clientWs.ReceiveAsync(new byte[16], ct);
+                    await Task.Delay(1500);
+                    GC.Collect(2);
+                    GC.WaitForPendingFinalizers();
+                    clientCompleted.SetResult();
+                    Assert.Null(unobserved);
path: src/libraries/System.Net.WebSockets.Client/tests/CloseTest.cs
user: login: antonfirsov
body: Yes, it's failing without the change.
created_at: 2025-04-23T12:24:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: antonfirsov
created_at: 2025-04-15T13:32:12Z
body: /azp run runtime-libraries-coreclr outerloop
author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-04-15T13:32:21Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: CarnaViire
created_at: 2025-04-17T11:04:59Z
body: While this is good as a workaround, I cannot not mention that this is a problem of `WaitAsync` in general (whenever an `await` finishes due to `WaitAsync` firing, the task being `WaitAsync`-ed can inadvertently leak an unobserved exception) -- I wonder if it's already tracked in the Tasks area? We also might need to check if we have such cases of `WaitAsync` usage anywhere else.
author_association: MEMBER
user: login: antonfirsov
created_at: 2025-04-17T12:11:34Z
body: > While this is good as a workaround, I cannot not mention that this is a problem of `WaitAsync` in general (whenever an `await` finishes due to `WaitAsync` firing, the task being `WaitAsync`-ed can inadvertently leak an unobserved exception) -- I wonder if it's already tracked in the Tasks area? We also might need to check if we have such cases of `WaitAsync` usage anywhere else.

I think we don't want a solution to just swallow the exception with `_ = task.Exception`, but also to enable logging it with a custom logic. For that we would need an API like `WaitAsync(TimeSpan, Action<Task> onTaskFailure)`.

@stephentoub any thoughts?
author_association: CONTRIBUTOR
user: login: stephentoub
created_at: 2025-04-17T12:34:01Z
body: That already exists in the form of ContinueWith(..., TaskContinuationOptions.OnlyOnFaulted), which this PR is already using.
author_association: MEMBER
user: login: CarnaViire
created_at: 2025-04-17T13:58:09Z
body: There seems to be no warning in the API docs for `WaitAsync` 😔 it might be easy to overlook, and expect the task to somehow "magically" be stopped together with the wait.

However, I see a very nice note for `Wait` ([`Wait(CancellationToken)`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.wait?view=net-9.0#system-threading-tasks-task-wait(system-threading-cancellationtoken)), [`Wait(int, CancellationToken)`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.wait?view=net-9.0#system-threading-tasks-task-wait(system-int32-system-threading-cancellationtoken)))
![image](https://github.com/user-attachments/assets/cffd4707-de20-42b7-ae9e-dee3a27d9f5a)

<details>
<summary>(Though this note is a bit hard to notice)</summary>

Because it's placed in the very bottom of remarks that in their turn are only after the example 😅

A screenshot to illustrate the relative position:
![image](https://github.com/user-attachments/assets/81408299-a86c-48d9-a1f2-bae7bdbfe08c)
</details>

-------

1) I think it might be beneficial to add a similar note to `WaitAsync` API docs (and move the `Wait`'s note to a more visible place, and maybe expand it to mention the timeout parameter too)
2) I wonder if there is any kind of analyzer check we can add to highlight it? I know there is something that checks cancellation tokens passed in Task.Run
author_association: MEMBER
user: login: stephentoub
created_at: 2025-04-17T13:59:57Z
body: > I think it might be beneficial to add a similar note to WaitAsync API docs

Sure:
https://github.com/dotnet/dotnet-api-docs/blob/main/xml/System.Threading.Tasks/Task.xml
:)
author_association: MEMBER
user: login: MihaZupan
created_at: 2025-04-17T14:01:53Z
body: Since it's very easy to forget (like we've done) to observe exceptions from the underlying task, would you consider having `.WaitAsync` itself implicitly suppress unhandled exceptions on the task being awaited when hitting the timeout/cancellation, or would that be too much magic for Task? If that were the case, this change wouldn't be needed.
Having the "correct" usage pattern be to hook up `ContinueWith` is unfortunate.
author_association: MEMBER
user: login: stephentoub
created_at: 2025-04-17T14:07:40Z
body: > would you consider having .WaitAsync itself implicitly suppress unhandled exceptions on the task being awaited when hitting the timeout/cancellation, or would that be too much magic for Task?

That assumes a very specific use case, one where you don't care at all about unobserved exceptions from these tasks. As Anton highlighted, we probably want here not to ignore the exception but to log it in some way. And in general the only time unobserved exceptions are visible is when you subscribe to the unobserved exceptions event, in which case you may very well want to know about exceptions that occur after you stop waiting for it.
author_association: MEMBER
user: login: antonfirsov
created_at: 2025-04-17T14:54:40Z
body: > That already exists in the form of ContinueWith(..., TaskContinuationOptions.OnlyOnFaulted)

Callers still need a [lot of ceremonial code](https://github.com/dotnet/runtime/blob/fb99139be920e5adfe1fda130c06ea5b2c3f0e7b/src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs#L1876-L1891) around that API. Can be handled by a documentation example instead of a new API I guess.
author_association: CONTRIBUTOR
user: login: antonfirsov
created_at: 2025-04-23T17:28:31Z
body: CI failures are unrelated.
author_association: CONTRIBUTOR