Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,7 @@ internal WebSocketDeflater(int windowBits, bool persisted)

public void Dispose()
{
if (_stream is not null)
{
_stream.Dispose();
_stream = null;
}
_stream?.Dispose();
}

public void ReleaseBuffer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,7 @@ internal WebSocketInflater(int windowBits, bool persisted)

public void Dispose()
{
if (_stream is not null)
{
_stream.Dispose();
_stream = null;
}
_stream?.Dispose();
ReleaseBuffer();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,36 @@ private void DisposeCore()
_disposed = true;
_keepAliveTimer?.Dispose();
_stream.Dispose();
_inflater?.Dispose();
_deflater?.Dispose();

if (_state < WebSocketState.Aborted)
{
_state = WebSocketState.Closed;
}

DisposeSafe(_inflater, _receiveMutex);
DisposeSafe(_deflater, _sendMutex);
}
}

private static void DisposeSafe(IDisposable? resource, AsyncMutex mutex)
{
if (resource is not null)
{
Task lockTask = mutex.EnterAsync(CancellationToken.None);

if (lockTask.IsCompleted)
{
resource.Dispose();

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.

Should we double-check for null here and below?

Suggested change
resource.Dispose();
resource?.Dispose();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is not needed, because the resource can never become null. We only need to check once at the beginning of the method.

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.

Ah, I see, that's what you removed in the first place, right? once assigned, it will now stay assigned

mutex.Exit();
}
else
{
lockTask.GetAwaiter().UnsafeOnCompleted(() =>

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.

@stephentoub could you please take a look at the dispose pattern here?

{
resource.Dispose();
mutex.Exit();
});

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.

This will end up queueing the clean up of resources such that it could end up happening after Dispose completes. We typically try hard to avoid that, as it means the consumer calling Dispose can't trust that everything has quiesced.

This change is to handle an erroneous case, right? The ManagedWebSocket is being disposed of while there are outstanding operations? And the actual resources involved here are all guarded at some level by SafeHandles, yes? Assuming that's all the case, I'd be inclined to say that Dispose should Dispose of the resources if it's safe to do (e.g. if it can immediately acquire the mutex), but otherwise it should just avoid disposing the resources and let finalization handle it in this erroneous case.

@zlatanov zlatanov Jun 24, 2023 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree with what you said, however there are cases when calling Dispose is not an erroneous case. Such is an Abort() call triggered for example by a cancellation token backed by a timeout. We could have different implementation for Abort and Dispose I guess, but that will only complicate things.

Another thing is that even if we do what you say, we still need to release the mutex with an asynchronous continuation. If we don't do this, we risk some user code never completing. Here is a simple example of how that might happen:

// User code calls send twice, concurrently in different threads
// Thread 1:
await socket.SendAsync();

// Thread 2:
await socket.SendAsync();

// Thread 1 takes the lock and starts the send operation.
// Before Thread 2 is queued in the AsyncMutex, 
// an Abort() or Dispose() happens in another thread which triggers the new code which 
// tries to acquire the lock and fails and doesn't release the resources, but also doesn't release the lock.

// Thread 2 is queued to wait

In this case since, because we don't release the AsyncMutex, Thread 2 code will never complete.

This also can be avoided, if we implement TryLock method on the AsyncMutex so we can have a fully synchronous path for lock / unlock.

If you decide we should go down that path, I would advise that we implement the disposal of the unmanaged resources in the Send / Receive methods when they exit - we can check if _disposed is true. The reason for this is that usually the websocket without compression takes about 500 bytes of memory. Using compression this increases up ot 500KB depending on the window size. I am sure that there is user code out there that doesn't release strong references to websockets early, this would incur a tremendous penalty on the memory used by an application. I am even sure that there is code out there leaking websockets, and 500 bytes is not very much but 500KB is.

This can even be DDOS'ed by having many websockets connect to an endpoint and have then behave in a such a way to trigger Aborts by the server side.

My point being is that this implementation, in the end, will have the same net result as the current code, but the current code is far simpler to understand.

@stephentoub stephentoub Jun 24, 2023 •

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.

This also can be avoided, if we implement TryLock method on the AsyncMutex so we can have a fully synchronous path for lock / unlock.

Right, that's what I had in mind.

But taking a step back, I'm not clear on how we end up in this situation in the first place. Disposing of anything shouldn't lead to AVs... that's one of the main purposes of SafeHandle, where it defers releasing the underlying handle while a resource is still in use. Are we missing DangerousAddRef/Release calls around unsafe use of the underlying handle?

@zlatanov zlatanov Jun 24, 2023 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The issue here is not the SafeHandle, it's that it is being disposed while being used by another thread to inflate / deflate.

Edit: Actually, let me take a look, I misunderstood what you meant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@stephentoub You were correct. I've pushed a new implementation which uses DangerousAddRef/Release to tackle the issue. Let me know what you think.

}
}
}

Expand Down Expand Up @@ -511,6 +534,8 @@ private async ValueTask SendFrameFallbackAsync(MessageOpcode opcode, bool endOfM
/// <summary>Writes a frame into the send buffer, which can then be sent over the network.</summary>
private int WriteFrameToSendBuffer(MessageOpcode opcode, bool endOfMessage, bool disableCompression, ReadOnlySpan<byte> payloadBuffer)
{
ObjectDisposedException.ThrowIf(_disposed, typeof(WebSocket));

if (_deflater is not null && !disableCompression)
{
payloadBuffer = _deflater.Deflate(payloadBuffer, endOfMessage);
Expand Down Expand Up @@ -680,6 +705,8 @@ private async ValueTask<TResult> ReceiveAsyncPrivate<TResult>(Memory<byte> paylo
try
{
await _receiveMutex.EnterAsync(cancellationToken).ConfigureAwait(false);
ObjectDisposedException.ThrowIf(_disposed, typeof(WebSocket));

try
{
while (true) // in case we get control frames that should be ignored from the user's perspective
Expand Down
35 changes: 35 additions & 0 deletions src/libraries/System.Net.WebSockets/tests/WebSocketDeflateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,41 @@ public async Task CompressedMessageWithEmptyLastFrame()
Assert.Equal(frame1.Length + frame2.Length, messageSize);
}

[Fact]
public async Task DisposeShouldNotCorruptStateWhileReceiving()
{
WebSocketTestStream stream = new();
using WebSocket server = WebSocket.CreateFromStream(stream, new WebSocketCreationOptions
{
IsServer = true,
KeepAliveInterval = TimeSpan.Zero,
DangerousDeflateOptions = new WebSocketDeflateOptions()
});
using WebSocket client = WebSocket.CreateFromStream(stream.Remote, new WebSocketCreationOptions
{
IsServer = false,
KeepAliveInterval = TimeSpan.Zero,
DangerousDeflateOptions = new WebSocketDeflateOptions()
});

byte[] buffer = new byte[64];

// Send two messages so that the zlib stream has data in its internal dictionary
await SendTextAsync("Hello World", client);
await server.ReceiveAsync(buffer, CancellationToken.None);
buffer.AsSpan().Clear();

stream.DelayForNextRead = TimeSpan.FromSeconds(1);
stream.IgnoreCancellationToken = true;
await SendTextAsync("Hello Worlds", client);

Task<WebSocketReceiveResult> receiveTask = server.ReceiveAsync(buffer, CancellationToken.None);
server.Dispose();

var result = await receiveTask;
Assert.Equal("Hello Worlds", Encoding.UTF8.GetString(buffer.AsSpan(0, result.Count)));
}

private ValueTask SendTextAsync(string text, WebSocket websocket, bool disableCompression = false)
{
WebSocketMessageFlags flags = WebSocketMessageFlags.EndOfMessage;
Expand Down
22 changes: 20 additions & 2 deletions src/libraries/System.Net.WebSockets/tests/WebSocketTestStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ public Span<byte> NextAvailableBytes
/// </summary>
public TimeSpan DelayForNextSend { get; set; }

/// <summary>
/// If set, would cause the next read operation to be delayed
/// and complete asynchronously. Can be used to test cancellation tokens
/// and async code branches.
/// </summary>
public TimeSpan DelayForNextRead { get; set; }

/// <summary>
/// When set, ignores the cancellation token passed to ReadAsync and WriteAsync.
/// </summary>
public bool IgnoreCancellationToken { get; set; }

public override bool CanRead => true;

public override bool CanSeek => false;
Expand All @@ -100,10 +112,16 @@ protected override void Dispose(bool disposing)

public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
if (DelayForNextRead > TimeSpan.Zero)
{
await Task.Delay(DelayForNextRead);
DelayForNextRead = TimeSpan.Zero;
}

using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposed.Token);
try
{
await _inputLock.WaitAsync(cancellation.Token).ConfigureAwait(false);
await _inputLock.WaitAsync(IgnoreCancellationToken ? default : cancellation.Token).ConfigureAwait(false);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
Expand Down Expand Up @@ -199,7 +217,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, Cancella
{
if (DelayForNextSend > TimeSpan.Zero)
{
await Task.Delay(DelayForNextSend, cancellationToken);
await Task.Delay(DelayForNextSend, IgnoreCancellationToken ? default : cancellationToken);
DelayForNextSend = TimeSpan.Zero;
}

Expand Down