Fix ManagedWebSocket send cancellation leaving state Open - #133199
Conversation
SendFrameFallbackAsync awaited the send-mutex acquisition task before entering its try block and before registering the cancellation -> Abort() callback. If cancellation raced with acquiring _sendMutex (e.g. because a keep-alive ping or another in-flight send already held it), the OperationCanceledException from the canceled mutex-wait task propagated out without ever calling Abort(), leaving WebSocketState stuck at Open instead of transitioning to Aborted. This caused the flaky System.Net.WebSockets.Client.Tests.CancelTest_* .SendAsync_Cancel_Success failures tracked by #132031. Move the cancellationToken.Register(... => Abort()) registration to wrap the entire method body, including the mutex-acquisition wait, matching the existing pattern already used by the receive path (ReceiveAsyncPrivate registers cancellation before entering _receiveMutex). Also trace a canceled mutex wait via NetEventSource.TraceException before rethrowing, for diagnostics parity with the write/flush cancellation path. Add two deterministic regression tests to WebSocketTests.cs: - SendAsync_AlreadyCanceledToken_AbortsConnectionAndThrowsOperationCanceledException, using an already-canceled token so AsyncMutex.EnterAsync returns Task.FromCanceled immediately, hitting the pre-lock cancellation path with no contention or timing. - SendAsync_CancelWhileWaitingForSendMutex_AbortsConnectionAndThrowsOperationCanceledException, using a new GatedWriteStream test helper whose WriteAsync blocks on a TaskCompletionSource to deterministically create genuine send-mutex contention (no sleeps), then cancels a second, waiting send. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @karelz, @dotnet/ncl |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The new contention regression test can hang indefinitely (missing fast-fail timeouts and swallowing TimeoutException), which risks wedging CI runs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs — await stream.WriteStarted can hang indefinitely if the first send never reaches WriteAsync… |
What changed in this PR
This PR updates ManagedWebSocket send-path cancellation handling so that cancellation while waiting to acquire the send mutex aborts the connection, aligning send behavior with the receive path and avoiding WebSocketState remaining Open after OperationCanceledException.
Changes:
- Register the cancellation callback for
Abort()before awaiting send-mutex acquisition inSendFrameFallbackAsync, ensuring cancellation during mutex wait transitions the socket toAborted. - Add deterministic regression tests covering (1) already-canceled tokens and (2) cancellation while contended on the send mutex (via a gated write stream helper).
| File | Description |
|---|---|
| src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs | Extends cancellation registration to include the mutex-wait window in the send fallback path, with tracing for canceled lock waits. |
| src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs | Adds two regression tests plus a GatedWriteStream helper to deterministically reproduce send-mutex contention cancellation behavior. |
Suppressed comments (1)
src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs:299
- The
catch { }aroundawait firstSendwill also swallowTimeoutExceptionif the first send never completes, turning a real hang into a test pass/slow-timeout elsewhere. Prefer awaiting with a timeout and only swallowing non-timeout exceptions.
try
{
await firstSend;
}
catch
|
seems like test build is failing @rzikm |
|
@steveisok Looks like the changes fail to build, can you take a look? |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@rzikm backport worthy to 11? |
This changes product code. Do we have customer scenario that is affected by this? if not, then this does not meet a servicing bar (.NET 11 RCs use the same bar as normal servicing releases). |
|
PR to disable test: #134041 |
It's not quite normal servicing yet. Certainly more scrutiny, but there is still a more flexible window until RC2 snaps. |
) ## Summary Fixes dotnet#132031. `ManagedWebSocket.SendFrameFallbackAsync` awaited the send-mutex acquisition task (`lockTask`) before entering its `try` block and before registering the `cancellationToken.Register(... => Abort())` callback. If cancellation raced with acquiring `_sendMutex` (e.g. because a keep-alive ping or another in-flight send already held it), the resulting `OperationCanceledException` propagated out without ever calling `Abort()`, leaving `WebSocketState` stuck at `Open` instead of transitioning to `Aborted`. This caused the flaky `System.Net.WebSockets.Client.Tests.CancelTest_*.SendAsync_Cancel_Success` failures tracked by dotnet#132031 across many PR checks (mostly on Android/JIT-stress legs, where lock contention is more likely). The receive path (`ReceiveAsyncPrivate`, and the close-frame-wait loop) already registers/handles cancellation around its mutex wait — the send path was the odd one out. ## Fix `src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs`: moved the `cancellationToken.Register(static s => ((ManagedWebSocket)s!).Abort(), this)` registration to wrap the entire method body, including `await lockTask`, matching the existing pattern in the receive path. Added a small `try`/`catch` around the lock-wait to trace a canceled wait via `NetEventSource.TraceException` before rethrowing, for diagnostics parity with the write/flush cancellation path. Mutex-release semantics are preserved: `_sendMutex.Exit()` only runs in the inner `try`/`finally`, which is only entered after the lock is actually acquired — so an unacquired mutex is never released, and there's no double-exit. ## Tests Added two deterministic regression tests to `src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs` (no timing sleeps): - `SendAsync_AlreadyCanceledToken_AbortsConnectionAndThrowsOperationCanceledException` — uses an already-canceled token so `AsyncMutex.EnterAsync` returns `Task.FromCanceled` immediately, deterministically hitting the pre-lock cancellation path with no contention needed. - `SendAsync_CancelWhileWaitingForSendMutex_AbortsConnectionAndThrowsOperationCanceledException` — uses a new `GatedWriteStream` test helper whose `WriteAsync` blocks on a `TaskCompletionSource` until released, to deterministically create genuine send-mutex contention: a first send holds the mutex blocked in its write, a second cancelable send must wait for the mutex, and canceling the second send's token while it's waiting is asserted to abort the connection and throw `OperationCanceledException`. Both were traced by hand against the pre-fix code and confirmed to fail (state stays `Open`) without the fix, and pass with it. ## Validation `dotnet build src/libraries/System.Net.WebSockets/src/System.Net.WebSockets.csproj -c Release` — succeeded with 0 warnings/0 errors across all target frameworks (net, -browser, -unix, -windows). Full xunit execution of the affected test projects wasn't performed in this environment due to local disk constraints; the `system-net-review` specialist agent reviewed the diff and traced correctness (mutex release/idempotent `Abort()`/no new races) with no blocking findings. ## Scope Limited to dotnet#132031 — does not touch `ConnectAsync` cancellation tests (dotnet#130439) or Known Build Error metadata. > [!NOTE] > This PR description and the underlying investigation/fix were generated with GitHub Copilot assistance. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>


Summary
Fixes #132031.
ManagedWebSocket.SendFrameFallbackAsyncawaited the send-mutex acquisition task (lockTask) before entering itstryblock and before registering thecancellationToken.Register(... => Abort())callback. If cancellation raced with acquiring_sendMutex(e.g. because a keep-alive ping or another in-flight send already held it), the resultingOperationCanceledExceptionpropagated out without ever callingAbort(), leavingWebSocketStatestuck atOpeninstead of transitioning toAborted.This caused the flaky
System.Net.WebSockets.Client.Tests.CancelTest_*.SendAsync_Cancel_Successfailures tracked by #132031 across many PR checks (mostly on Android/JIT-stress legs, where lock contention is more likely).The receive path (
ReceiveAsyncPrivate, and the close-frame-wait loop) already registers/handles cancellation around its mutex wait — the send path was the odd one out.Fix
src/libraries/System.Net.WebSockets/src/System/Net/WebSockets/ManagedWebSocket.cs: moved thecancellationToken.Register(static s => ((ManagedWebSocket)s!).Abort(), this)registration to wrap the entire method body, includingawait lockTask, matching the existing pattern in the receive path. Added a smalltry/catcharound the lock-wait to trace a canceled wait viaNetEventSource.TraceExceptionbefore rethrowing, for diagnostics parity with the write/flush cancellation path.Mutex-release semantics are preserved:
_sendMutex.Exit()only runs in the innertry/finally, which is only entered after the lock is actually acquired — so an unacquired mutex is never released, and there's no double-exit.Tests
Added two deterministic regression tests to
src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs(no timing sleeps):SendAsync_AlreadyCanceledToken_AbortsConnectionAndThrowsOperationCanceledException— uses an already-canceled token soAsyncMutex.EnterAsyncreturnsTask.FromCanceledimmediately, deterministically hitting the pre-lock cancellation path with no contention needed.SendAsync_CancelWhileWaitingForSendMutex_AbortsConnectionAndThrowsOperationCanceledException— uses a newGatedWriteStreamtest helper whoseWriteAsyncblocks on aTaskCompletionSourceuntil released, to deterministically create genuine send-mutex contention: a first send holds the mutex blocked in its write, a second cancelable send must wait for the mutex, and canceling the second send's token while it's waiting is asserted to abort the connection and throwOperationCanceledException.Both were traced by hand against the pre-fix code and confirmed to fail (state stays
Open) without the fix, and pass with it.Validation
dotnet build src/libraries/System.Net.WebSockets/src/System.Net.WebSockets.csproj -c Release— succeeded with 0 warnings/0 errors across all target frameworks (net, -browser, -unix, -windows).Full xunit execution of the affected test projects wasn't performed in this environment due to local disk constraints; the
system-net-reviewspecialist agent reviewed the diff and traced correctness (mutex release/idempotentAbort()/no new races) with no blocking findings.Scope
Limited to #132031 — does not touch
ConnectAsynccancellation tests (#130439) or Known Build Error metadata.Note
This PR description and the underlying investigation/fix were generated with GitHub Copilot assistance.