Summary
In Aspire.Tools.Service.AspireServerService.SendMessageAsync (src/Dotnet.Watch/AspireService/AspireServerService.cs), the _webSocketAccess semaphore is released in a finally block even when WaitAsync throws before the semaphore was acquired. Because _webSocketAccess is created as new SemaphoreSlim(1) (so maxCount == int.MaxValue), the stray Release() raises the permit count above 1. A later send then acquires the "extra" permit while another send is still in flight, producing two concurrent WebSocket.SendAsync calls on the same socket — which is unsupported and throws InvalidOperationException ("There is already one outstanding 'SendAsync' call for this WebSocket instance") or corrupts the frame stream.
Code
SendMessageAsync:
private readonly SemaphoreSlim _webSocketAccess = new(1); // maxCount == int.MaxValue
...
var success = false;
try
{
using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, _shutdownCancellationSource.Token, connection.HttpRequestAborted);
await _webSocketAccess.WaitAsync(cancelTokenSource.Token); // can throw before acquiring
await connection.Socket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token);
success = true;
}
finally
{
if (!success)
{
_socketConnectionManager.RemoveSocketConnection(connection);
}
_webSocketAccess.Release(); // runs even when WaitAsync threw before acquiring -> over-release
}
Why it is a bug
If cancelTokenSource is canceled (server shutdown, connection.HttpRequestAborted, or the caller's cancellationToken) while WaitAsync is still waiting, WaitAsync throws OperationCanceledException without having decremented the semaphore. Control passes to the finally, which calls Release() and increments the count past 1. Since the semaphore is constructed with the single-argument SemaphoreSlim(1) constructor, maxCount is int.MaxValue, so this does not throw SemaphoreFullException — it silently over-releases. From that point the semaphore no longer enforces single-writer access, so a subsequent notification can send while another send is still in progress.
This is reachable on the normal Ctrl+C / shutdown / client-disconnect paths (the same cancellation paths involved in #55229).
Suggested fix
Only release when the wait actually succeeded:
var success = false;
var acquired = false;
try
{
using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, _shutdownCancellationSource.Token, connection.HttpRequestAborted);
await _webSocketAccess.WaitAsync(cancelTokenSource.Token);
acquired = true;
await connection.Socket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token);
success = true;
}
finally
{
if (!success)
{
_socketConnectionManager.RemoveSocketConnection(connection);
}
if (acquired)
{
_webSocketAccess.Release();
}
}
Notes
Found by code review while investigating an unrelated Visual Studio hang, so I don't have a deterministic repro, but the defect is clear from the source. The Aspire.Tools.Service package is consumed by Visual Studio and C# Dev Kit in addition to dotnet watch, so the same code path runs in all three hosts.
Summary
In
Aspire.Tools.Service.AspireServerService.SendMessageAsync(src/Dotnet.Watch/AspireService/AspireServerService.cs), the_webSocketAccesssemaphore is released in afinallyblock even whenWaitAsyncthrows before the semaphore was acquired. Because_webSocketAccessis created asnew SemaphoreSlim(1)(somaxCount == int.MaxValue), the strayRelease()raises the permit count above 1. A later send then acquires the "extra" permit while another send is still in flight, producing two concurrentWebSocket.SendAsynccalls on the same socket — which is unsupported and throwsInvalidOperationException("There is already one outstanding 'SendAsync' call for this WebSocket instance") or corrupts the frame stream.Code
SendMessageAsync:Why it is a bug
If
cancelTokenSourceis canceled (server shutdown,connection.HttpRequestAborted, or the caller'scancellationToken) whileWaitAsyncis still waiting,WaitAsyncthrowsOperationCanceledExceptionwithout having decremented the semaphore. Control passes to thefinally, which callsRelease()and increments the count past 1. Since the semaphore is constructed with the single-argumentSemaphoreSlim(1)constructor,maxCountisint.MaxValue, so this does not throwSemaphoreFullException— it silently over-releases. From that point the semaphore no longer enforces single-writer access, so a subsequent notification can send while another send is still in progress.This is reachable on the normal Ctrl+C / shutdown / client-disconnect paths (the same cancellation paths involved in #55229).
Suggested fix
Only release when the wait actually succeeded:
Notes
Found by code review while investigating an unrelated Visual Studio hang, so I don't have a deterministic repro, but the defect is clear from the source. The
Aspire.Tools.Servicepackage is consumed by Visual Studio and C# Dev Kit in addition todotnet watch, so the same code path runs in all three hosts.