Skip to content

Fix ManagedWebSocket send cancellation leaving state Open - #133199

Merged
steveisok merged 2 commits into
mainfrom
steveisok-fix-websocket-send-cancellation-state
Sep 14, 2026
Merged

steveisok merged 2 commits into
mainfrom
steveisok-fix-websocket-send-cancellation-state

Conversation

@steveisok

Copy link
Copy Markdown
Member

Summary

Fixes #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 #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 #132031 — does not touch ConnectAsync cancellation tests (#130439) or Known Build Error metadata.

Note

This PR description and the underlying investigation/fix were generated with GitHub Copilot assistance.

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>
Copilot AI lite review requested due to automatic review settings September 3, 2026 19:55
@azure-pipelines

Copy link
Copy Markdown
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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

New issues introduced by this change (1)
Severity Finding
Medium severity src/​libraries/​System.Net.WebSockets/​tests/​WebSocketTests.csawait 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 in SendFrameFallbackAsync, ensuring cancellation during mutex wait transitions the socket to Aborted.
  • 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 { } around await firstSend will also swallow TimeoutException if 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

Comment thread src/libraries/System.Net.WebSockets/tests/WebSocketTests.cs Outdated
@rzikm
rzikm requested a review from a team September 4, 2026 06:31
@wfurt

wfurt commented Sep 4, 2026

Copy link
Copy Markdown
Member

seems like test build is failing @rzikm

@rzikm

rzikm commented Sep 8, 2026

Copy link
Copy Markdown
Member

@steveisok Looks like the changes fail to build, can you take a look?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 11, 2026 22:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Update the affected tests with bounded waits and preserve timeout failures.

Review tier: Lite
Findings: 1 Medium severity

Open findings (1)

@rzikm rzikm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, Thanks!

@steveisok
steveisok merged commit a652cdf into main Sep 14, 2026
79 of 82 checks passed
@steveisok
steveisok deleted the steveisok-fix-websocket-send-cancellation-state branch September 14, 2026 12:03
@steveisok

Copy link
Copy Markdown
Member Author

@rzikm backport worthy to 11?

@rzikm

rzikm commented Sep 16, 2026

Copy link
Copy Markdown
Member

@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).

@rzikm

rzikm commented Sep 16, 2026

Copy link
Copy Markdown
Member

PR to disable test: #134041

@steveisok

Copy link
Copy Markdown
Member Author

@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).

It's not quite normal servicing yet. Certainly more scrutiny, but there is still a more flexible window until RC2 snaps.

jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 18, 2026
)

## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

4 participants