From 211dc61e48f0b255630393ce0f356a168fb91ae8 Mon Sep 17 00:00:00 2001 From: Tommaso Cittadino Date: Mon, 20 Apr 2026 09:27:32 +0200 Subject: [PATCH 1/2] Propagate sync-faulted FlushAsync exceptions through the WebSocket error path When ManagedWebSocket.SendFrameLockAcquiredNonCancelableAsync ran with a synchronously-completed write and FlushAsync returned a synchronously- faulted Task, the original code wrapped the Task in a ValueTask and returned it directly. Awaiting callers then observed the raw stream exception (e.g. IOException) rather than the WebSocketException wrapper that every other error path in the send flow produces. Calling GetAwaiter().GetResult() on the completed flush Task brings that exception inside the method's existing try/catch, which maps it to a WebSocketException(ConnectionClosedPrematurely) just like WaitForWriteTaskAsync does for the asynchronous completion paths. --- .../src/System/Net/WebSockets/ManagedWebSocket.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 553a254c2770d4..9436a7f2731d59 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 @@ -510,15 +510,16 @@ private ValueTask SendFrameLockAcquiredNonCancelableAsync(MessageOpcode opcode, if (writeTask.IsCompleted) { writeTask.GetAwaiter().GetResult(); - ValueTask flushTask = new ValueTask(_stream.FlushAsync()); + Task flushTask = _stream.FlushAsync(); if (flushTask.IsCompleted) { - return flushTask; + flushTask.GetAwaiter().GetResult(); + return ValueTask.CompletedTask; } else { releaseSendBufferAndSemaphore = false; - return WaitForWriteTaskAsync(flushTask, shouldFlush: false); + return WaitForWriteTaskAsync(new ValueTask(flushTask), shouldFlush: false); } } From a6834166e982384e4fd12ffa391b820022c7fbc1 Mon Sep 17 00:00:00 2001 From: Tommaso Cittadino Date: Mon, 20 Apr 2026 16:03:19 +0200 Subject: [PATCH 2/2] Add test for sync-faulted FlushAsync exception wrapping Extends WebSocketTestStream with an optional FlushException property that lets a test cause FlushAsync to return a synchronously-faulted Task. This follows the same fault-injection pattern already used by DelayForNextRead/DelayForNextSend/IgnoreCancellationToken. Adds WebSocketTests.SendAsync_FlushAsyncSyncFaulted_WrapsExceptionInWebSocketException which verifies the fix: when the underlying stream's WriteAsync completes synchronously and FlushAsync returns a synchronously-faulted Task, the caller observes a WebSocketException(ConnectionClosedPrematurely) with the original exception as InnerException, matching the behavior of the other error paths in the send flow. --- .../tests/WebSocketTestStream.cs | 15 +++++++++++++++ .../tests/WebSocketTests.cs | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/libraries/System.Net.WebSockets/tests/WebSocketTestStream.cs b/src/libraries/System.Net.WebSockets/tests/WebSocketTestStream.cs index 64ec09f88807f2..e5a87210a43166 100644 --- a/src/libraries/System.Net.WebSockets/tests/WebSocketTestStream.cs +++ b/src/libraries/System.Net.WebSockets/tests/WebSocketTestStream.cs @@ -86,6 +86,12 @@ public Span NextAvailableBytes /// public bool IgnoreCancellationToken { get; set; } + /// + /// If set, causes FlushAsync to return a synchronously-faulted Task with this exception. + /// Used to exercise sync-completion-faulted code paths in the WebSocket send flow. + /// + public Exception? FlushException { get; set; } + public override bool CanRead => true; public override bool CanSeek => false; @@ -226,6 +232,15 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) + { + if (FlushException is not null) + { + return Task.FromException(FlushException); + } + return base.FlushAsync(cancellationToken); + } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); diff --git a/src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs b/src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs index 41c7fe341d266d..cf01437fc3d97d 100644 --- a/src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs +++ b/src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs @@ -182,6 +182,23 @@ public async Task ThrowWhenContinuationWithDifferentCompressionFlags() client.SendAsync(Memory.Empty, WebSocketMessageType.Binary, WebSocketMessageFlags.EndOfMessage, default)); } + [Fact] + public async Task SendAsync_FlushAsyncSyncFaulted_WrapsExceptionInWebSocketException() + { + var underlying = new IOException("flush failed"); + using var stream = new WebSocketTestStream { FlushException = underlying }; + using WebSocket ws = WebSocket.CreateFromStream( + stream, isServer: false, subProtocol: null, keepAliveInterval: Timeout.InfiniteTimeSpan); + + var buffer = new ArraySegment(new byte[] { 1, 2, 3 }); + + WebSocketException ex = await Assert.ThrowsAsync( + () => ws.SendAsync(buffer, WebSocketMessageType.Binary, endOfMessage: true, CancellationToken.None)); + + Assert.Equal(WebSocketError.ConnectionClosedPrematurely, ex.WebSocketErrorCode); + Assert.Same(underlying, ex.InnerException); + } + [Fact] public async Task ReceiveAsync_ServerUnmaskedFrame_ThrowsWebSocketException() {