WebSocket transport: a sender queued across Close fails as closed (#4832)

## Problem


`WebSocketByteTransportTests.CloseFailsQueuedSendInsteadOfOrphaningItAsync`
fails intermittently. One example is test-linux-net10.0 (3/16) in run
39114639155 on #4831, an unrelated MachineTool change. The queued send
failed with a raw `WebSocketException` ("…transitioned into an invalid
state") instead of `ServiceResultException` with `BadConnectionClosed`.

## Cause

This is a product race in `WebSocketByteTransportBase`.

- `Close()` cancels the shared send token. That wakes both the in-flight
send and the queued `SemaphoreSlim.WaitAsync`.
- `SemaphoreSlim` removes a cancelled waiter on a later continuation. If
the in-flight sender's `finally` releases the lock first, the release
*grants* the queued waiter despite its cancelled token.
- `EnterSendAsync` then returned normally, and the queued sender called
`SendAsync` on the already aborted socket.
- That socket error is not an `OperationCanceledException`, so it
reached the caller untranslated.

## Fix

After acquiring the lock, `EnterSendAsync` checks whether the transport
closed. If it did, it gives the lock back and throws
`BadConnectionClosed`. `Close()` sets the closed flag before it cancels,
so every waiter woken by `Close()` sees it.

## Tests

- The existing test body moved into a helper. New
`CloseFailsQueuedSendEvenWhenTheLockIsGrantedAsync` repeats the
interleaving 2000 times (about 140 ms).
- Without the fix it fails 3/3 runs within about 100 ms, with the CI
exception. With the fix it passes 3/3.
- `Opc.Ua.Core.Tests` `~WebSocket` (net10.0): 39/39 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
romanett
2026-10-18 12:37:56 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0005b0d5e6
commit 014f99a53a
2 changed files with 32 additions and 1 deletions
@@ -393,6 +393,17 @@ namespace Opc.Ua.Bindings
try
{
await m_sendLock.WaitAsync(linkedCts.Token).ConfigureAwait(false);
// SemaphoreSlim can still grant a waiter whose token was cancelled when a
// release reaches it before the cancellation removes it. A sender queued
// across Close would then write to the aborted socket and fail with the
// socket's own error, so it gives the lock back and fails as closed.
if (Volatile.Read(ref m_closed) != 0)
{
m_sendLock.Release();
throw ServiceResultException.Create(
StatusCodes.BadConnectionClosed,
"The WebSocket transport is closed.");
}
return linkedCts;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
@@ -503,7 +503,27 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
/// sender forever.
/// </summary>
[Test]
public async Task CloseFailsQueuedSendInsteadOfOrphaningItAsync()
public Task CloseFailsQueuedSendInsteadOfOrphaningItAsync()
{
return CloseWithQueuedSendAsync();
}
/// <summary>
/// Close can wake the in-flight sender, whose lock release then grants
/// the queued waiter before its cancellation removes it. The queued
/// sender must still fail as closed rather than write to the aborted
/// socket. The interleaving depends on the thread pool, so it repeats.
/// </summary>
[Test]
public async Task CloseFailsQueuedSendEvenWhenTheLockIsGrantedAsync()
{
for (int iteration = 0; iteration < 2000; iteration++)
{
await CloseWithQueuedSendAsync().ConfigureAwait(false);
}
}
private async Task CloseWithQueuedSendAsync()
{
using var socket = new BlockingSendWebSocket();
var transport = new WebSocketServerByteTransport(