Skip to content

Commit 3956e9e

Browse files
committed
✨ Improve and simplify output writer thread handling
Expose a straight PipeWriter over the websocket by directly adapting it to Stream so we can use the biult-in PipeWriter.Create over a websocket. Note that we depend on knowledge of how the default pipe writer does its writing, which is by just invoking `WriteAsync` and `FlushAsync` on it. So we leave all other `Stream` members unimplemented. The `StreamPipeWriter` invokes `FlushAsync` automatically whenever there are bytes written, so it's impossible to distinguish writing partial messages from full messages. This seems like an acceptable trade-off anyway, so we just assume the same (`WriteAsync` == full EndOfMessage for WebSocket message). Now the `RunAsync` task deals exclusively with the incoming side of the websocket, and it's much easier to reason about it (including whether it's worth creating another thread to keep it running or not). Fixes #5
1 parent 8404e26 commit 3956e9e

5 files changed

Lines changed: 47 additions & 82 deletions

File tree

src/Tests/SimpleWebSocketPipeTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public record SimpleWebSocketPipeTests(ITestOutputHelper Output)
1212
public async Task WhenWebSocketNotOpen_ThenThrowsAsync()
1313
{
1414
IWebSocketPipe pipe = WebSocketPipe.Create(new ClientWebSocket());
15-
await Assert.ThrowsAsync<InvalidOperationException>(() => pipe.RunAsync().AsTask());
15+
await Assert.ThrowsAsync<InvalidOperationException>(() => pipe.RunAsync());
1616
}
1717

1818
[Fact]
@@ -26,7 +26,7 @@ public async Task WhenConnected_ThenRuns()
2626
using var pipe = WebSocketPipe.Create(socket);
2727

2828
await Task.WhenAll(
29-
pipe.RunAsync(server.Cancellation.Token).AsTask(),
29+
pipe.RunAsync(server.Cancellation.Token),
3030
Task.Delay(100).ContinueWith(_ => server.Cancellation.Cancel()));
3131
}
3232

@@ -42,7 +42,7 @@ public async Task WhenServerClosesWebSocket_ThenClientCompletesGracefully()
4242
await server.DisposeAsync();
4343

4444
Task.WaitAny(
45-
run.AsTask(),
45+
run,
4646
Task.Delay(100).ContinueWith(_ => throw new TimeoutException()));
4747
}
4848

src/Tests/WebSocketServer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ static WebSocketServer Create(Func<IWebSocketPipe, Task>? pipeBehavior, Func<Web
5151
if (pipeBehavior != null)
5252
{
5353
using var pipe = WebSocketPipe.Create(websocket, options);
54-
await Task.WhenAll(pipeBehavior(pipe), pipe.RunAsync(cts.Token).AsTask());
54+
await Task.WhenAll(pipe.RunAsync(cts.Token), pipeBehavior(pipe));
5555
}
5656
else if (socketBehavior != null)
5757
{

src/WebSocketPipe/IWebSocketPipe.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public interface IWebSocketPipe : IDuplexPipe, IDisposable
4141
/// <param name="closeStatusDescription">Optional close status description to use if the underlying
4242
/// <see cref="WebSocket"/> is closed.</param>
4343
/// <returns></returns>
44-
public ValueTask CompleteAsync(WebSocketCloseStatus? closeStatus = null, string? closeStatusDescription = null);
44+
public Task CompleteAsync(WebSocketCloseStatus? closeStatus = null, string? closeStatusDescription = null);
4545

4646
/// <summary>
4747
/// Starts populating the <see cref="IDuplexPipe.Input"/> with incoming data from the underlying
@@ -54,5 +54,5 @@ public interface IWebSocketPipe : IDuplexPipe, IDisposable
5454
/// <see cref="IDuplexPipe.Output"/> are completed, or an explicit invocation of <see cref="CompleteAsync"/>
5555
/// is executed.
5656
/// </returns>
57-
public ValueTask RunAsync(CancellationToken cancellation = default);
57+
public Task RunAsync(CancellationToken cancellation = default);
5858
}
Lines changed: 40 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.IO;
23
using System.IO.Pipelines;
34
using System.Net.WebSockets;
45
using System.Threading;
@@ -15,23 +16,28 @@ class SimpleWebSocketPipe : IWebSocketPipe
1516
// Wait 250 ms before giving up on a Close, same as SignalR WebSocketHandler
1617
static readonly TimeSpan closeTimeout = TimeSpan.FromMilliseconds(250);
1718

19+
readonly CancellationTokenSource disposeCancellation = new CancellationTokenSource();
1820
readonly Pipe inputPipe;
19-
readonly Pipe outputPipe;
21+
readonly PipeWriter outputWriter;
2022

2123
readonly WebSocket webSocket;
2224
readonly WebSocketPipeOptions options;
2325

2426
bool completed;
2527

2628
public SimpleWebSocketPipe(WebSocket webSocket, WebSocketPipeOptions options)
27-
=> (this.webSocket, this.options, inputPipe, outputPipe)
28-
= (webSocket, options, new Pipe(options.InputPipeOptions), new Pipe(options.OutputPipeOptions));
29+
{
30+
this.webSocket = webSocket;
31+
this.options = options;
32+
inputPipe = new Pipe(options.InputPipeOptions);
33+
outputWriter = PipeWriter.Create(new WebSocketStream(webSocket));
34+
}
2935

3036
bool IsClient => webSocket is ClientWebSocket;
3137

3238
public PipeReader Input => inputPipe.Reader;
3339

34-
public PipeWriter Output => outputPipe.Writer;
40+
public PipeWriter Output => outputWriter;
3541

3642
public WebSocketCloseStatus? CloseStatus => webSocket.CloseStatus;
3743

@@ -41,23 +47,16 @@ public SimpleWebSocketPipe(WebSocket webSocket, WebSocketPipeOptions options)
4147

4248
public string? SubProtocol => webSocket.SubProtocol;
4349

44-
public async ValueTask RunAsync(CancellationToken cancellation = default)
50+
public Task RunAsync(CancellationToken cancellation = default)
4551
{
4652
if (webSocket.State != WebSocketState.Open)
4753
throw new InvalidOperationException($"WebSocket must be opened. State was {webSocket.State}");
4854

49-
var writing = FillInputAsync(cancellation);
50-
var reading = SendOutputAsync(cancellation);
51-
52-
// NOTE: when both are completed, the CompleteAsync will be called automatically
53-
// by both writing and reading, so we ensure CloseWhenCompleted is performed.
54-
55-
// TODO: replace with ValueTask.WhenAll if/when it ships.
56-
// See https://github.com/dotnet/runtime/issues/23625
57-
await Task.WhenAll(reading.AsTask(), writing.AsTask());
55+
var combined = CancellationTokenSource.CreateLinkedTokenSource(cancellation, disposeCancellation.Token);
56+
return ReadInputAsync(combined.Token);
5857
}
5958

60-
public async ValueTask CompleteAsync(WebSocketCloseStatus? closeStatus = null, string? closeStatusDescription = null)
59+
public async Task CompleteAsync(WebSocketCloseStatus? closeStatus = null, string? closeStatusDescription = null)
6160
{
6261
if (completed)
6362
return;
@@ -68,14 +67,11 @@ public async ValueTask CompleteAsync(WebSocketCloseStatus? closeStatus = null, s
6867
await inputPipe.Writer.CompleteAsync();
6968
await inputPipe.Reader.CompleteAsync();
7069

71-
await outputPipe.Writer.CompleteAsync();
72-
await outputPipe.Reader.CompleteAsync();
73-
7470
if (options.CloseWhenCompleted || closeStatus != null)
7571
await CloseAsync(closeStatus ?? WebSocketCloseStatus.NormalClosure, closeStatusDescription ?? "");
7672
}
7773

78-
async ValueTask CloseAsync(WebSocketCloseStatus closeStatus, string closeStatusDescription)
74+
async Task CloseAsync(WebSocketCloseStatus closeStatus, string closeStatusDescription)
7975
{
8076
var state = State;
8177
if (state == WebSocketState.Closed || state == WebSocketState.CloseSent || state == WebSocketState.Aborted)
@@ -90,7 +86,7 @@ async ValueTask CloseAsync(WebSocketCloseStatus closeStatus, string closeStatusD
9086
await Task.WhenAny(closeTask, Task.Delay(closeTimeout));
9187
}
9288

93-
async ValueTask FillInputAsync(CancellationToken cancellation)
89+
async Task ReadInputAsync(CancellationToken cancellation)
9490
{
9591
while (webSocket.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
9692
{
@@ -129,56 +125,31 @@ ex is WebSocketException ||
129125
await CompleteAsync(webSocket.CloseStatus, webSocket.CloseStatusDescription);
130126
}
131127

132-
async ValueTask SendOutputAsync(CancellationToken cancellation)
128+
public void Dispose()
133129
{
134-
while (webSocket.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
135-
{
136-
try
137-
{
138-
var result = await outputPipe.Reader.ReadAsync(cancellation);
139-
if (result.IsCompleted || result.IsCanceled)
140-
break;
141-
142-
if (result.Buffer.IsSingleSegment)
143-
{
144-
await webSocket.SendAsync(result.Buffer.First, WebSocketMessageType.Binary, true, cancellation);
145-
}
146-
else
147-
{
148-
var enumerator = result.Buffer.GetEnumerator();
149-
if (enumerator.MoveNext())
150-
{
151-
// NOTE: we don't use the cancellation here because we don't want to send
152-
// partial messages from an already completely read buffer.
153-
while (true)
154-
{
155-
var current = enumerator.Current;
156-
if (default(ReadOnlyMemory<byte>).Equals(current))
157-
break;
158-
159-
// Peek next to see if we should send an end of message
160-
if (enumerator.MoveNext())
161-
await webSocket.SendAsync(current, WebSocketMessageType.Binary, false, cancellation);
162-
else
163-
await webSocket.SendAsync(current, WebSocketMessageType.Binary, true, cancellation);
164-
}
165-
}
166-
}
167-
168-
outputPipe.Reader.AdvanceTo(result.Buffer.End);
169-
170-
}
171-
catch (Exception ex) when (ex is OperationCanceledException ||
172-
ex is WebSocketException ||
173-
ex is InvalidOperationException)
174-
{
175-
break;
176-
}
177-
}
178-
179-
// Preserve the close status since it might be triggered by a received Close message containing the status and description.
180-
await CompleteAsync(webSocket.CloseStatus, webSocket.CloseStatusDescription);
130+
disposeCancellation.Cancel();
131+
webSocket.Dispose();
181132
}
182133

183-
public void Dispose() => webSocket.Dispose();
134+
class WebSocketStream : Stream
135+
{
136+
readonly WebSocket webSocket;
137+
138+
public WebSocketStream(WebSocket webSocket) => this.webSocket = webSocket;
139+
140+
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
141+
=> webSocket.SendAsync(buffer, WebSocketMessageType.Binary, true, cancellationToken);
142+
143+
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
144+
public override bool CanRead => throw new NotImplementedException();
145+
public override bool CanSeek => throw new NotImplementedException();
146+
public override bool CanWrite => throw new NotImplementedException();
147+
public override long Length => throw new NotImplementedException();
148+
public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
149+
public override void Flush() => throw new NotImplementedException();
150+
public override int Read(byte[] buffer, int offset, int count) => throw new NotImplementedException();
151+
public override long Seek(long offset, SeekOrigin origin) => throw new NotImplementedException();
152+
public override void SetLength(long value) => throw new NotImplementedException();
153+
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
154+
}
184155
}

src/WebSocketPipe/WebSocketPipeOptions.cs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,5 @@ public class WebSocketPipeOptions
2828
/// Allows fine-grained configuration options for the incoming side of the
2929
/// websocket pipe. Defaults to <see cref="PipeOptions.Default"/>.
3030
/// </summary>
31-
public PipeOptions InputPipeOptions { get; set; } = PipeOptions.Default;
32-
33-
/// <summary>
34-
/// Allows fine-grained configuration options for the outgoing side of the
35-
/// websocket pipe. Defaults to <see cref="PipeOptions.Default"/>.
36-
/// </summary>
37-
public PipeOptions OutputPipeOptions { get; set; } = PipeOptions.Default;
31+
public PipeOptions InputPipeOptions { get; set; } = new PipeOptions(useSynchronizationContext: false);
3832
}

0 commit comments

Comments
 (0)