From 52b76dfce288fc79bfd41219d66d1d8beae1b7f4 Mon Sep 17 00:00:00 2001 From: Paul Bleess <8421069+pableess@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:17:52 -0500 Subject: [PATCH 1/6] phase 1 --- CHANGELOG.md | 38 ++ CONTRIBUTING.md | 69 ++++ Directory.Build.props | 4 +- README.md | 14 +- benchmarks/Yamux.Benchmark/Program.cs | 408 +++++++++++++++++--- docs/Performance.md | 47 +++ samples/Sample/Program.cs | 2 +- src/ISessionChannel.cs | 90 +++-- src/ITransport.cs | 27 +- src/Internal/ChannelManager.cs | 26 +- src/Internal/ChannelStream.cs | 2 +- src/Internal/ConnectionReader.cs | 47 ++- src/Internal/ConnectionWriter.cs | 144 ------- src/Internal/FrameReader.cs | 67 +++- src/Internal/IChannelSessionAdapter.cs | 4 +- src/Internal/PingManager.cs | 13 +- src/Internal/RemoteDataWindow.cs | 5 +- src/Internal/ReusableValueTaskSourcePool.cs | 65 +--- src/Internal/SessionFrameWriter.cs | 176 +++++++++ src/PipeExtensions.cs | 14 +- src/PipePeer.cs | 26 +- src/Session.cs | 113 ++++-- src/SessionChannel.cs | 75 +++- src/SessionOptions.cs | 57 ++- src/SocketExtensions.cs | 16 +- src/SocketPeer.cs | 22 +- src/Statistics.cs | 5 +- src/StreamExtensions.cs | 16 +- src/StreamPeer.cs | 27 +- src/Yamux.csproj | 2 + test/Yamux.Tests/ErrorPathTests.cs | 221 +++++++++++ test/Yamux.Tests/ProtocolEdgeCaseTests.cs | 231 +++++++++++ test/Yamux.Tests/ProtocolUnitTests.cs | 5 +- test/Yamux.Tests/SessionTests.cs | 77 ++-- test/Yamux.Tests/SocketTransportTests.cs | 262 +++++++++++++ test/Yamux.Tests/StressTests.cs | 201 ++++++++++ test/Yamux.Tests/Yamux.Tests.csproj | 1 - 37 files changed, 2153 insertions(+), 466 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/Performance.md delete mode 100644 src/Internal/ConnectionWriter.cs create mode 100644 src/Internal/SessionFrameWriter.cs create mode 100644 test/Yamux.Tests/ErrorPathTests.cs create mode 100644 test/Yamux.Tests/ProtocolEdgeCaseTests.cs create mode 100644 test/Yamux.Tests/SocketTransportTests.cs create mode 100644 test/Yamux.Tests/StressTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eed942b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - Unreleased + +### Added +- Public `Session` constructor accepting `ITransport`, `isClient`, `leaveOpen`, and `SessionOptions` +- `ITransport.FlushAsync()` default interface method for transport-level flushing +- `ISessionChannel.FlushWritesAsync()` properly flushes to underlying transport +- `IAsyncDisposable` support on `ISessionChannel` +- `SessionOptions.SessionCloseTimeout` for configurable graceful shutdown +- `SessionOptions.MaxIncomingFrameSize` for defensive frame size validation +- `SessionOptions.ReadTimeout` for transport read timeout +- Graceful session shutdown: channels drain before force-close +- `ReusableValueTaskSourcePool` integrated into `ConnectionWriter` for TCS pooling +- RST frame sent for data frames on unknown streams (Yamux spec compliance) +- Channel immediately removed from `ChannelManager` on RST receipt +- `Nerdbank.Streams` and in-memory transport benchmarks +- New test suites: `SocketTransportTests`, `StressTests`, `ProtocolEdgeCaseTests`, `ErrorPathTests` + +### Changed +- **Breaking:** Renamed extension parameter `keepOpen` to `leaveOpen` (.NET convention) +- **Breaking:** `CancellationToken? cancel` changed to `CancellationToken cancellationToken = default` on all public APIs +- **Breaking:** `FlushWritesAsync` return type changed from `Task` to `ValueTask` +- `Session` constructor access changed from `internal` to `public` +- Ping response is now awaited instead of fire-and-forget +- `ConfigureAwait(false)` added throughout library internals + +### Fixed +- RTT calculation now correctly uses `Stopwatch.Frequency` +- `ConnectionReader` cancellation no longer falls through to garbage parse +- Channels properly removed from `ChannelManager` on all disposal paths +- `CloseOpenChannelsAsync` handles already-disposed channels gracefully +- `EnqueueFrame` now properly returns the TCS to pool on failure + +### Security +- Max incoming frame size validation prevents memory exhaustion from malicious peers \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..95c3c44 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# Contributing to Yamux + +## Build + +```bash +dotnet build +``` + +## Test + +```bash +dotnet test +``` + +To run specific test categories: + +```bash +dotnet test --filter "FullyQualifiedName~SessionTests" +dotnet test --filter "FullyQualifiedName~SocketTransportTests" +``` + +## Benchmarks + +The benchmark project compares Yamux against raw TCP, Go Yamux, and Nerdbank.Streams. + +```bash +dotnet run -c Release --project benchmarks/Yamux.Benchmark +``` + +To compare with the Go implementation, first build the Go server: + +```bash +pwsh benchmarks/build-go-server.ps1 +``` + +Then run the comparison: + +```bash +pwsh benchmarks/compare.ps1 +``` + +## Project Structure + +``` +src/Yamux.csproj — Main library +src/Protocol/ — Yamux wire protocol (frames, constants, enums) +src/Internal/ — Internal implementation (reader, writer, channel manager, etc.) +test/Yamux.Tests/ — xUnit test suite +benchmarks/Yamux.Benchmark/ — BenchmarkDotNet benchmarks +samples/Sample/ — Live statistics sample app +samples/FileTransfer/ — Multi-file transfer sample +docs/ — API documentation +``` + +## Code Style + +- Follow existing code patterns and naming conventions +- Use `ConfigureAwait(false)` in all library code +- Name CancellationToken parameters `cancellationToken` +- Prefer `ValueTask` over `Task` for hot-path async operations +- XML doc comments on all public APIs + +## Pull Requests + +1. Fork the repository +2. Create a feature branch +3. Make changes and add tests +4. Ensure `dotnet build` and `dotnet test` pass +5. Submit a PR with a clear description \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 13b9139..286113e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - net9.0 - 0.0.5 + net9.0;net10.0 + 0.0.6 rc1 Paul Bleess MIT diff --git a/README.md b/README.md index 4b1e468..7e1b9b8 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # Yamux (dotnet) -> This library is not yet production ready. Api may change prior to 1.0 release. Please use with caution and provide feedback. -> - Yamux (dotnet) is a .NET 9 library implementing the [Yamux multiplexing protocol](https://github.com/hashicorp/yamux/blob/master/spec.md), enabling multiple reliable, ordered, and independent streams (channels) over a single underlying connection (such as TCP). This is useful for building high-performance network applications, tunneling, or protocols that require multiplexed communication. ## Features - Full-duplex, multiplexed streams over a single connection -- Channel-based abstraction (`SessionChannel`, `IDuplexSessionChannel`) -- Configurable flow control and window sizing -- Automatic window tuning for optimal throughput +- Channel-based abstraction (`IDuplexSessionChannel`, `IReadOnlySessionChannel`, `IWriteOnlySessionChannel`) +- Configurable flow control with automatic window tuning - Keep-alive and round-trip time (RTT) measurement - Bandwidth and statistics tracking - - Low allocations and high-performance design (uses System.IO.Pipelines to reduce buffer copies) +- OpenTelemetry-compatible metrics via `System.Diagnostics.Metrics` +- Low allocations and high-performance design (uses `System.IO.Pipelines` to reduce buffer copies) +- AOT-compatible +- Graceful session shutdown with channel drain +- Pluggable transport layer (`Stream`, `Socket`, `IDuplexPipe`) - .NET 9, async/await friendly ## Getting Started diff --git a/benchmarks/Yamux.Benchmark/Program.cs b/benchmarks/Yamux.Benchmark/Program.cs index 8aaf49b..d36ec72 100644 --- a/benchmarks/Yamux.Benchmark/Program.cs +++ b/benchmarks/Yamux.Benchmark/Program.cs @@ -2,11 +2,13 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Engines; using BenchmarkDotNet.Running; +using Nerdbank.Streams; using System.Diagnostics; using System.IO.Pipelines; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Threading; namespace Yamux.Benchmark { @@ -15,20 +17,9 @@ public class Program public static async Task Main(string[] args) { var summary = BenchmarkRunner.Run(); - - ////debugging locally - //Yamux yamux = new Yamux(); - //yamux.Setup(); - - //yamux.Streams = 5; - //yamux.MBs = 50; - //await yamux.YamuxStreamAsync(); - - //yamux.Cleanup(); - } - [SimpleJob(RunStrategy.Throughput, launchCount: 1, warmupCount: 3, invocationCount: 5)] + [SimpleJob(RunStrategy.Throughput, launchCount: 1, warmupCount: 1, invocationCount: 2)] [MemoryDiagnoser] public class Yamux { @@ -77,17 +68,33 @@ public void Cleanup() } [Benchmark(Baseline = true)] - public async Task SocketBaselineAsync() + public async Task SocketBaselineAsync() { + var sw = new Stopwatch(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + + void SignalReady() + { + if (Interlocked.Increment(ref readyCount) == 2) + { + sw.Start(); + ready.TrySetResult(); + } + } + + int iterations = MBs * 32; + long totalBytes = (long)iterations * 1024 * 32; + var serverTask = Task.Run(async () => { - // accept a connection var sock = await _serverSock!.AcceptAsync(); using Stream serverStream = new NetworkStream(sock, true); - // every 32 iterations is a MB of data (in 32KB chunks) - int iterations = MBs * 32; + SignalReady(); + await ready.Task; + for (int i = 0; i < iterations; i++) { await serverStream.WriteAsync(_buffer); @@ -103,6 +110,9 @@ public async Task SocketBaselineAsync() using Stream client = new NetworkStream(sock!, true); + SignalReady(); + await ready.Task; + byte[] readBuffer = new byte[1024 * 32]; try { @@ -114,26 +124,45 @@ public async Task SocketBaselineAsync() } catch (Exception) { - // end of stream throw; } }); await Task.WhenAll(serverTask, clientTask); + sw.Stop(); + + return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; } [Benchmark] - public async Task YamuxStreamAsync() + public async Task YamuxSocketAsync() { + var sw = new Stopwatch(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + + void SignalReady() + { + if (Interlocked.Increment(ref readyCount) == 2) + { + sw.Start(); + ready.TrySetResult(); + } + } + + int iterationsPerStream = (MBs * 32) / Streams; + long totalBytes = (long)iterationsPerStream * Streams * 1024 * 32; + var serverTask = Task.Run(async () => { - // accept a connection var sock = await _serverSock!.AcceptAsync(); - var session = sock.AsYamuxSession(false, keepOpen: false); + var session = sock.AsYamuxSession(false, leaveOpen: false); session.Start(); + SignalReady(); + await ready.Task; + List channels = new List(); - int iterationsPerStream = (MBs * 32) / Streams; for (int i = 0; i < Streams; i++) { @@ -141,13 +170,11 @@ public async Task YamuxStreamAsync() { using var channel = await session.OpenChannelAsync(false); - // every 32 iterations is a MB of data (in 32KB chunks) for (int i = 0; i < iterationsPerStream; i++) { await channel.WriteAsync(_buffer); } - // since we didn't wait for an ack before writing data, we need to make sure the remote party acknowledged before we send a close var timeout = (await channel.WhenRemoteAckAsync(TimeSpan.FromSeconds(3)) == false) ; if (timeout) { @@ -173,9 +200,12 @@ public async Task YamuxStreamAsync() MaxDataFrameSize = 1024 * 64, } }; - var session = sock!.AsYamuxSession(true, options: opt, keepOpen: false); + var session = sock!.AsYamuxSession(true, options: opt, leaveOpen: false); session.Start(); + SignalReady(); + await ready.Task; + List channels = new List(); Task RunChannelAsync(IReadOnlySessionChannel channel) @@ -197,10 +227,9 @@ Task RunChannelAsync(IReadOnlySessionChannel channel) }); } - // accept streams and read until complete until the session is closed for (int i = 0; i < Streams; i++) { - var channel = await session.AcceptReadOnlyChannelAsync(null); + var channel = await session.AcceptReadOnlyChannelAsync(); channels.Add(RunChannelAsync(channel)); } @@ -210,31 +239,284 @@ Task RunChannelAsync(IReadOnlySessionChannel channel) }); await Task.WhenAll(serverTask, clientTask); + sw.Stop(); + + return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; } [Benchmark] - public async Task CsharpToGoAsync() + public async Task CsharpToGoAsync() { using var goServer = GoServerProcess.Start(_goServerPath!); + int iterationsPerStream = (MBs * 32) / Streams; + long totalBytes = (long)iterationsPerStream * Streams * 1024 * 32; + + var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, goServer.Port)); + + var opt = new SessionOptions + { + EnableKeepAlive = false, + DefaultChannelOptions = new SessionChannelOptions + { + MaxDataFrameSize = 1024 * 64, + } + }; + var session = sock!.AsYamuxSession(true, options: opt, leaveOpen: false); + session.Start(); + + var sw = Stopwatch.StartNew(); + + List channels = new List(); + + for (int i = 0; i < Streams; i++) + { + channels.Add(Task.Run(async () => + { + using var channel = await session.OpenChannelAsync(false); + + for (int j = 0; j < iterationsPerStream; j++) + { + await channel.WriteAsync(_buffer); + } + + var timeout = (await channel.WhenRemoteAckAsync(TimeSpan.FromSeconds(3)) == false); + if (timeout) + { + throw new TimeoutException("Timed out waiting for remote ack"); + } + + channel.Close(); + })); + } + + await Task.WhenAll(channels); + + sw.Stop(); + sock.Close(); + + return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; + } + + [Benchmark] + public async Task NerdbankStreamsAsync() + { + (var stream1, var stream2) = FullDuplexStream.CreatePair(); + + var sw = new Stopwatch(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + + void SignalReady() + { + if (Interlocked.Increment(ref readyCount) == 2) + { + sw.Start(); + ready.TrySetResult(); + } + } + + int iterationsPerStream = (MBs * 32) / Streams; + long totalBytes = (long)iterationsPerStream * Streams * 1024 * 32; + + var serverTask = Task.Run(async () => + { + var mux = await MultiplexingStream.CreateAsync(stream2, new MultiplexingStream.Options + { + ProtocolMajorVersion = 1, + }, CancellationToken.None); + + SignalReady(); + await ready.Task; + + List channels = new List(); + + for (int i = 0; i < Streams; i++) + { + channels.Add(Task.Run(async () => + { + var channel = await mux.AcceptChannelAsync("", new MultiplexingStream.ChannelOptions()); + using var channelStream = channel.AsStream(); + + byte[] readBuffer = new byte[1024 * 32]; + int totalBytes = iterationsPerStream * 1024 * 32; + while (totalBytes > 0) + { + var read = await channelStream.ReadAsync(readBuffer, 0, readBuffer.Length); + if (read == 0) break; + totalBytes -= read; + } + })); + } + + await Task.WhenAll(channels); + }); + + var clientTask = Task.Run(async () => + { + var mux = await MultiplexingStream.CreateAsync(stream1, new MultiplexingStream.Options + { + ProtocolMajorVersion = 1, + }, CancellationToken.None); + + SignalReady(); + await ready.Task; + + List channels = new List(); + + for (int i = 0; i < Streams; i++) + { + channels.Add(Task.Run(async () => + { + var channel = await mux.OfferChannelAsync("", new MultiplexingStream.ChannelOptions()); + using var channelStream = channel.AsStream(); + + for (int j = 0; j < iterationsPerStream; j++) + { + await channelStream.WriteAsync(_buffer); + } + channelStream.Close(); + })); + } + + await Task.WhenAll(channels); + }); + + await Task.WhenAll(serverTask, clientTask); + sw.Stop(); + + return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; + } + + [Benchmark] + public async Task NerdbankSocketAsync() + { + var sw = new Stopwatch(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + + void SignalReady() + { + if (Interlocked.Increment(ref readyCount) == 2) + { + sw.Start(); + ready.TrySetResult(); + } + } + + int iterationsPerStream = (MBs * 32) / Streams; + long totalBytes = (long)iterationsPerStream * Streams * 1024 * 32; + + var serverTask = Task.Run(async () => + { + var sock = await _serverSock!.AcceptAsync(); + + using Stream stream = new NetworkStream(sock, true); + var mux = await MultiplexingStream.CreateAsync(stream, new MultiplexingStream.Options + { + ProtocolMajorVersion = 1, + }, CancellationToken.None); + + SignalReady(); + await ready.Task; + + List channels = new List(); + + for (int i = 0; i < Streams; i++) + { + channels.Add(Task.Run(async () => + { + var channel = await mux.OfferChannelAsync("", new MultiplexingStream.ChannelOptions()); + using var channelStream = channel.AsStream(); + + for (int j = 0; j < iterationsPerStream; j++) + { + await channelStream.WriteAsync(_buffer); + } + channelStream.Close(); + })); + } + + await Task.WhenAll(channels); + }); + var clientTask = Task.Run(async () => { var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); - await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, goServer.Port)); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port)); - var opt = new SessionOptions + using Stream stream = new NetworkStream(sock, true); + var mux = await MultiplexingStream.CreateAsync(stream, new MultiplexingStream.Options { - EnableKeepAlive = false, - DefaultChannelOptions = new SessionChannelOptions + ProtocolMajorVersion = 1, + }, CancellationToken.None); + + SignalReady(); + await ready.Task; + + List channels = new List(); + + for (int i = 0; i < Streams; i++) + { + channels.Add(Task.Run(async () => { - MaxDataFrameSize = 1024 * 64, - } - }; - var session = sock!.AsYamuxSession(true, options: opt, keepOpen: false); + var channel = await mux.AcceptChannelAsync("", new MultiplexingStream.ChannelOptions()); + using var channelStream = channel.AsStream(); + + byte[] readBuffer = new byte[1024 * 32]; + int totalBytes = iterationsPerStream * 1024 * 32; + while (totalBytes > 0) + { + var read = await channelStream.ReadAsync(readBuffer, 0, readBuffer.Length); + if (read == 0) break; + totalBytes -= read; + } + })); + } + + await Task.WhenAll(channels); + }); + + await Task.WhenAll(serverTask, clientTask); + sw.Stop(); + + return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; + } + + [Benchmark] + public async Task YamuxInMemoryAsync() + { + var clientPipe = new Pipe(); + var serverPipe = new Pipe(); + var clientTransport = new DuplexPipe(serverPipe.Reader, clientPipe.Writer); + var serverTransport = new DuplexPipe(clientPipe.Reader, serverPipe.Writer); + + var sw = new Stopwatch(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + + void SignalReady() + { + if (Interlocked.Increment(ref readyCount) == 2) + { + sw.Start(); + ready.TrySetResult(); + } + } + + int iterationsPerStream = (MBs * 32) / Streams; + long totalBytes = (long)iterationsPerStream * Streams * 1024 * 32; + + var serverTask = Task.Run(async () => + { + var session = serverTransport.AsYamuxSession(false); session.Start(); + SignalReady(); + await ready.Task; + List channels = new List(); - int iterationsPerStream = (MBs * 32) / Streams; for (int i = 0; i < Streams; i++) { @@ -247,22 +529,50 @@ public async Task CsharpToGoAsync() await channel.WriteAsync(_buffer); } - var timeout = (await channel.WhenRemoteAckAsync(TimeSpan.FromSeconds(3)) == false); - if (timeout) + channel.Close(); + })); + } + + await Task.WhenAll(channels); + }); + + var clientTask = Task.Run(async () => + { + var session = clientTransport.AsYamuxSession(true); + session.Start(); + + SignalReady(); + await ready.Task; + + List channels = new List(); + + for (int i = 0; i < Streams; i++) + { + channels.Add(Task.Run(async () => + { + var channel = await session.AcceptReadOnlyChannelAsync(); + + byte[] readBuffer = new byte[1024 * 32]; + + ReadResult res; + do { - throw new TimeoutException("Timed out waiting for remote ack"); - } + res = await channel.Input.ReadAtLeastAsync(1024); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCompleted); channel.Close(); + channel.Dispose(); })); } await Task.WhenAll(channels); - - sock.Close(); }); - await clientTask; + await Task.WhenAll(serverTask, clientTask); + sw.Stop(); + + return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; } } @@ -308,5 +618,17 @@ public void Dispose() _process.Dispose(); } } + + private sealed class DuplexPipe : IDuplexPipe + { + public DuplexPipe(PipeReader input, PipeWriter output) + { + Input = input; + Output = output; + } + + public PipeReader Input { get; } + public PipeWriter Output { get; } + } } } diff --git a/docs/Performance.md b/docs/Performance.md new file mode 100644 index 0000000..8356808 --- /dev/null +++ b/docs/Performance.md @@ -0,0 +1,47 @@ +# Performance + +## Benchmark Results + +Benchmarks compare Yamux against raw TCP, the reference Go implementation, and Nerdbank.Streams. + +### Methodology + +- 32KB chunk size for all tests +- Parameters: 1/50/500 MB total data, 1/5/20 concurrent streams +- TCP benchmarks use loopback (127.0.0.1) +- In-memory benchmarks use `System.IO.Pipelines` +- Go comparison uses the HashiCorp Yamux reference implementation (v0.1.1) +- Nerdbank.Streams uses protocol version 1 + +### Expected Results + +Benchmarks should show: + +1. **SocketBaselineAsync** — Raw TCP throughput (theoretical maximum) +2. **YamuxStreamAsync** — .NET Yamux over TCP with multi-stream parallelism +3. **CsharpToGoAsync** — C# Yamux client vs Go Yamux server (interop benchmark) +4. **NerdbankStreamsAsync** — Nerdbank.Streams over in-memory streams +5. **YamuxInMemoryAsync** — Yamux over in-memory pipes (theoretical max) + +### Running Benchmarks + +```bash +dotnet run -c Release --project benchmarks/Yamux.Benchmark +``` + +## Allocation Characteristics + +The library is designed to minimize GC pressure: + +- **`ReusableValueTaskSourcePool`** pools `TaskCompletionSource` instances used for write completion signaling +- **`System.IO.Pipelines`** provides zero-copy buffer management for reading +- **`Frame` struct** is stack-allocated; payload ownership is tracked via `IDisposable` +- **`StreamIdGenerator`** uses `Interlocked.Add` for lock-free ID generation +- **`RemoteDataWindow`** uses pooled waiter objects for async flow control + +## Throughput Tuning + +- **`MaxDataFrameSize`** — Larger frames reduce per-frame overhead but increase latency. Default 16KB. +- **`ReceiveWindowSize`** — Larger windows improve throughput for high-BDP connections. Default 256KB. +- **`AutoTuneReceiveWindowSize`** — Automatically scales window up to `ReceiveWindowUpperBound` based on RTT. +- **`ReceiveWindowUpperBound`** — Caps auto-tuning. Default 16MB. \ No newline at end of file diff --git a/samples/Sample/Program.cs b/samples/Sample/Program.cs index 9bbddb3..e91b7b0 100644 --- a/samples/Sample/Program.cs +++ b/samples/Sample/Program.cs @@ -120,7 +120,7 @@ await AnsiConsole.Live(table).StartAsync(async ctx => channelTasks.Add(task); } } - catch (SessionException e) when (e.ErrorCode == SessionErrorCode.SessionShutdown) + catch (SessionException e) when (e.ErrorCode is SessionErrorCode.SessionShutdown or SessionErrorCode.StreamClosed or SessionErrorCode.StreamError) { AnsiConsole.MarkupLine("[yellow]Client disconnected[/]"); } diff --git a/src/ISessionChannel.cs b/src/ISessionChannel.cs index 1b2295b..8de52cd 100644 --- a/src/ISessionChannel.cs +++ b/src/ISessionChannel.cs @@ -1,107 +1,113 @@ -using System; -using System.Collections.Generic; -using System.IO.Pipelines; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.IO.Pipelines; namespace Yamux; -public interface ISessionChannel : IDisposable +/// +/// Represents a Yamux session channel (logical stream) multiplexed over a single connection. +/// +public interface ISessionChannel : IDisposable, IAsyncDisposable { /// - /// Gets the ID for the stream + /// Gets the unique ID for this channel. /// public uint Id { get; } /// - /// Gets if the channel has been fully closed + /// Gets whether the channel has been fully closed. /// public bool IsClosed { get; } /// - /// Aborts the channel immediately, sending a RST to the remote peer if the channel is not already closed + /// Aborts the channel immediately, sending a RST to the remote peer if the channel is not already closed. /// public void Abort(); /// - /// Closes the channel. This closes the channel for writing, but more data could still be read from the channel until the - /// remote peer acknowledges the close. If you would like to wait until the remote peer has acknowledged the close, you can either continue reading - /// until the pipe is completed, or call WaitForRemoteClose() or WhenRemoteCloseAsync() - /// + /// Closes the channel for writing, sending a FIN to the remote peer. + /// More data may still be read from the channel until the remote peer acknowledges the close. + /// To wait for the remote peer's acknowledgment, continue reading until the pipe is completed, + /// or call or . /// public void Close(); /// - /// Waits until the remote peer has closed the channel + /// Waits until the remote peer has closed the channel. /// - /// A maximum amount of time to wait - /// true if the remote peer has closed, false if the operation has timed out + /// The maximum amount of time to wait. + /// true if the remote peer closed the channel; false if the operation timed out. public bool WaitForRemoteClose(TimeSpan timeout); /// /// Returns a task that completes when the remote peer has closed the channel. - /// Use with caution as remote peer may fail to send proper close acknowledgement + /// Use with caution, as the remote peer may fail to send a proper close acknowledgment. /// - /// - /// True if the remote peer was closed, false if the timeout was reached + /// The maximum amount of time to wait. + /// true if the remote peer closed the channel; false if the timeout was reached. public Task WhenRemoteCloseAsync(TimeSpan timeout); /// - /// Waits until the remote peer has acknowledged the channel open - /// Only use when channel was accepted without waiting for ack + /// Waits until the remote peer has acknowledged the channel open. + /// Only useful when a channel was accepted without waiting for acknowledgment. /// - /// A maximum amount of time to wait - /// true if the remote peer has acknowledged, false if the operation has timed out + /// The maximum amount of time to wait. + /// true if the remote peer acknowledged; false if the operation timed out. public bool WaitForRemoteAck(TimeSpan timeout); /// /// Returns a task that completes when the remote peer has acknowledged the channel. - /// Use with caution as remote peer may fail to send proper acknowledgement + /// Use with caution, as the remote peer may fail to send a proper acknowledgment. /// - /// - /// True if the remote peer has acknowledged before the timeout, false if the timeout was reached + /// The maximum amount of time to wait. + /// true if the remote peer acknowledged before the timeout; false if the timeout was reached. public Task WhenRemoteAckAsync(TimeSpan timeout); /// - /// Ensures that all written data is flushed in the underlying session connection + /// Ensures that all written data has been flushed to the underlying transport. /// - /// - /// - public Task FlushWritesAsync(CancellationToken? cancel); + /// A cancellation token to cancel the flush operation. + public ValueTask FlushWritesAsync(CancellationToken cancellationToken = default); /// - /// Gets the statistics for the channel, if statistics gathering is enabled + /// Gets the statistics for the channel, if statistics gathering is enabled. /// public Statistics? Stats { get; } } +/// +/// Represents a session channel that supports writing data to the remote peer. +/// public interface IWriteOnlySessionChannel : ISessionChannel { /// - /// Writes data to the channel - /// The task will not complete until all of the data has been written to the session connection, which may require waiting to recieve window update(s) - /// This is not an atomic operation as partial data may be written in the case of a failure + /// Writes data to the channel. The task will not complete until all data has been + /// passed to the underlying transport, which may require waiting for window updates from the remote peer. + /// This is not an atomic operation — partial data may be written in the case of a failure. /// - /// - /// - /// - public ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken? token = null); + /// The data to write. + /// A cancellation token to cancel the write operation. + public ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); } +/// +/// Represents a session channel that supports reading data from the remote peer. +/// public interface IReadOnlySessionChannel : ISessionChannel { /// - /// Gets the pipe reader for the input channel + /// Gets the for reading data from this channel. /// public PipeReader Input { get; } } +/// +/// Represents a full-duplex session channel that supports both reading and writing. +/// public interface IDuplexSessionChannel : ISessionChannel, IWriteOnlySessionChannel, IReadOnlySessionChannel { /// - /// Creates a new duplex stream for the channel. + /// Creates a wrapper for reading from and writing to this channel. /// - /// + /// Whether to leave the channel open when the stream is disposed. + /// A that reads from and writes to this channel. public Stream AsStream(bool leaveOpen = false); } diff --git a/src/ITransport.cs b/src/ITransport.cs index 5726223..9de5982 100644 --- a/src/ITransport.cs +++ b/src/ITransport.cs @@ -8,27 +8,34 @@ namespace Yamux { /// - /// Adapter interface for sending and receiving data to and from the yamux peer + /// Adapter interface for sending and receiving data to and from the yamux peer. /// public interface ITransport : IDisposable { /// - /// Reads data from the peer copies it to the provided buffer + /// Reads data from the peer and copies it to the provided buffer. /// - /// - /// - public ValueTask ReadAsync(Memory data, CancellationToken cancel); + /// The destination buffer. + /// A cancellation token to cancel the read operation. + /// The number of bytes read, or 0 if the connection was closed. + public ValueTask ReadAsync(Memory data, CancellationToken cancellationToken); /// - /// Sends raw data to the peer + /// Sends raw data to the peer. /// - /// - /// - public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancel); + /// The data to send. + /// A cancellation token to cancel the write operation. + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken); /// - /// Closes the peer connection + /// Closes the peer connection. /// public void Close(); + + /// + /// Flushes any buffered data to the underlying transport. + /// + /// A cancellation token to cancel the flush operation. + public ValueTask FlushAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; } } diff --git a/src/Internal/ChannelManager.cs b/src/Internal/ChannelManager.cs index 98dfa9e..1a0d602 100644 --- a/src/Internal/ChannelManager.cs +++ b/src/Internal/ChannelManager.cs @@ -44,14 +44,14 @@ public ChannelManager(IChannelSessionAdapter adapter, SessionChannelOptions defa return channel; } - public async ValueTask GetOrCreateAsync(uint id, Flags flags, ConnectionWriter writer, CancellationToken cancel) + public async ValueTask GetOrCreateAsync(uint id, Flags flags, SessionFrameWriter writer, CancellationToken cancellationToken) { if (_channels.TryGetValue(id, out var channel)) { if (flags.HasFlag(Flags.SYN)) { Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, $"[Err] yamux: duplicate stream declared: {id}"); - await writer.WriteAsync(Frame.CreateWindowUpdateFrame(id, Flags.RST, 0), cancel); + await writer.WriteAsync(Frame.CreateWindowUpdateFrame(id, Flags.RST, 0), cancellationToken).ConfigureAwait(false); return null; } return channel; @@ -70,20 +70,20 @@ public ChannelManager(IChannelSessionAdapter adapter, SessionChannelOptions defa if (reject) { - await writer.WriteAsync(Frame.CreateWindowUpdateFrame(id, Flags.RST, 0), cancel); + await writer.WriteAsync(Frame.CreateWindowUpdateFrame(id, Flags.RST, 0), cancellationToken).ConfigureAwait(false); return null; } if (_channels.Count >= _maxChannels) { - await writer.WriteAsync(Frame.CreateWindowUpdateFrame(id, Flags.RST, 0), cancel); + await writer.WriteAsync(Frame.CreateWindowUpdateFrame(id, Flags.RST, 0), cancellationToken).ConfigureAwait(false); return null; } var newChannel = new SessionChannel(_adapter, id, _defaultOptions, ChannelRemoteState.Open); _channels.TryAdd(id, newChannel); - await _acceptQueue.Writer.WriteAsync(newChannel, cancel); + await _acceptQueue.Writer.WriteAsync(newChannel, cancellationToken).ConfigureAwait(false); return newChannel; } @@ -147,9 +147,9 @@ public void FailAllConnects(Exception? ex = null) _connects.Clear(); } - public async ValueTask WaitForAcceptAsync(CancellationToken cancel) + public async ValueTask WaitForAcceptAsync(CancellationToken cancellationToken) { - var channel = await _acceptQueue.Reader.ReadAsync(cancel); + var channel = await _acceptQueue.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); return channel; } @@ -186,11 +186,19 @@ public async Task CloseOpenChannelsAsync(TimeSpan timeout) var tasks = new Task[channels.Length]; for (int i = 0; i < channels.Length; i++) { - channels[i].Close(); + try + { + channels[i].Close(); + } + catch (ObjectDisposedException) + { + tasks[i] = Task.FromResult(true); + continue; + } tasks[i] = channels[i].WhenRemoteCloseAsync(timeout); } - var results = await Task.WhenAll(tasks); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); return results.All(r => r); } diff --git a/src/Internal/ChannelStream.cs b/src/Internal/ChannelStream.cs index b9d0256..28936f2 100644 --- a/src/Internal/ChannelStream.cs +++ b/src/Internal/ChannelStream.cs @@ -28,7 +28,7 @@ public ChannelStream(SessionChannel outputChannel, bool leaveOpen) public override void Flush() => throw new NotSupportedException(); - public override Task FlushAsync(CancellationToken cancellationToken) => _outputChannel.FlushWritesAsync(cancellationToken); + public override Task FlushAsync(CancellationToken cancellationToken) => _outputChannel.FlushWritesAsync(cancellationToken).AsTask(); public override long Seek(long offset, SeekOrigin origin) { diff --git a/src/Internal/ConnectionReader.cs b/src/Internal/ConnectionReader.cs index 14f6f0a..4f840f3 100644 --- a/src/Internal/ConnectionReader.cs +++ b/src/Internal/ConnectionReader.cs @@ -14,7 +14,7 @@ public ConnectionReader(ITransport peer) _peer = peer ?? throw new ArgumentNullException(nameof(peer)); } - public async IAsyncEnumerable ReadFramesAsync([EnumeratorCancellation] CancellationToken cancel) + public async IAsyncEnumerable ReadFramesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { byte[] headerBuffer = new byte[FrameHeader.FrameHeaderSize]; @@ -24,17 +24,16 @@ public async IAsyncEnumerable ReadFramesAsync([EnumeratorCancellati { try { - bytesRead = await this.ReadAll(headerBuffer, cancel); + bytesRead = await this.ReadAll(headerBuffer, cancellationToken).ConfigureAwait(false); if (bytesRead == 0) { - // remote closed the connection throw new SessionException(SessionErrorCode.StreamClosed, "Connection closed by remote", SessionTermination.Normal); } } catch (OperationCanceledException) { - // TODO: add debug tracing + yield break; } if (!_stoppingToken.IsCancellationRequested) @@ -44,30 +43,48 @@ public async IAsyncEnumerable ReadFramesAsync([EnumeratorCancellati } } - public async ValueTask ReadFramePayloadAsync(Memory data, CancellationToken cancel) + public async ValueTask ReadFramePayloadAsync(Memory data, CancellationToken cancellationToken) { - return await this.ReadAll(data, cancel); + return await this.ReadAll(data, cancellationToken).ConfigureAwait(false); } - private async ValueTask ReadAll(Memory data, CancellationToken cancel) + private async ValueTask ReadAll(Memory data, CancellationToken cancellationToken) { if (data.IsEmpty) return 0; int requested = data.Length; int bytesRead = 0; - do + try { - var read = await _peer.ReadAsync(data.Slice(bytesRead, requested - bytesRead), cancel); - if (read == 0) + do { - throw new SessionException(SessionErrorCode.StreamClosed, "Remote connection closed"); + var read = await _peer.ReadAsync(data.Slice(bytesRead, requested - bytesRead), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + throw new SessionException(SessionErrorCode.StreamClosed, "Remote connection closed"); + } + bytesRead += read; } - bytesRead += read; - } - while (bytesRead < requested); + while (bytesRead < requested); - return bytesRead; + return bytesRead; + } + catch (OperationCanceledException) + { + throw; + } + catch (SessionException) + { + throw; + } + catch (Exception ex) + { + throw new SessionException( + SessionErrorCode.StreamClosed, + "Underlying transport error", + ex); + } } public void Stop() diff --git a/src/Internal/ConnectionWriter.cs b/src/Internal/ConnectionWriter.cs deleted file mode 100644 index 8f5ed8b..0000000 --- a/src/Internal/ConnectionWriter.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Diagnostics; -using System.Threading.Channels; -using Yamux.Protocol; - -namespace Yamux.Internal -{ -internal class ConnectionWriter - { - private readonly ITransport _peer; - private readonly Channel<(Frame frame, TaskCompletionSource tcs)> _writeQueue; - private readonly Statistics? _stats; - private YamuxMetrics? _metrics; - private Task? _runTask; - - internal void SetMetrics(YamuxMetrics? metrics) => _metrics = metrics; - - public ConnectionWriter(ITransport connection, Statistics? stats) - { - _peer = connection ?? throw new ArgumentNullException(nameof(connection)); - _stats = stats; - _metrics = null; - - _writeQueue = Channel.CreateBounded<(Frame, TaskCompletionSource)>(new BoundedChannelOptions(100) - { - FullMode = BoundedChannelFullMode.Wait, - SingleReader = true, - }); - } - - public void Start() - { - // start the writer loop - _runTask = Task.Run(async () => - { - byte[] headerBuffer = new byte[FrameHeader.FrameHeaderSize]; - - try - { - while (await _writeQueue.Reader.WaitToReadAsync()) - { - if (_writeQueue.Reader.TryRead(out var item)) - { - using var _ = item.frame; // dispose buffer owner after write - - try - { - item.frame.Header.WriteTo(headerBuffer); - - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) - Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: writing frame - {0}, payload size = {1}", item.frame.Header.FrameType, item.frame.Header.Length); - await _peer.WriteAsync(headerBuffer, default); - _metrics?.FramesSent.Add(1); - if (!item.frame.Payload.IsEmpty) - { - await _peer.WriteAsync(item.frame.Payload, default); - - _stats?.UpdateSent((uint)item.frame.Payload.Length); - _metrics?.BytesSent.Add(item.frame.Payload.Length); - - } - item.tcs.TrySetResult(); - } - catch (OperationCanceledException cancelEx) - { - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) - Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: write operation canceled - {0}", cancelEx.Message); - item.tcs.TrySetException(cancelEx); - } - catch (Exception ex) - { - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) - Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); - item.tcs.TrySetException(ex); - } - } - } - } - catch (OperationCanceledException) - { - // ignore cancellation - } - catch (Exception ex) - { - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) - Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); - } - }); - } - - public async ValueTask WriteAsync(Frame frame, CancellationToken cancel) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) - Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Enqueuing frame for write - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); - - await _writeQueue.Writer.WriteAsync((frame, tcs), cancel); - - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) - Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Frame enqueued for write - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); - - // wait for the write to complete - await tcs.Task; - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) - Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Write completed for frame - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); - } - - public void EnqueueFrame(Frame frame) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - if (_writeQueue.Writer.TryWrite((frame, tcs))) - { - return; - } - - // Channel is full - fire-and-forget the write asynchronously - _ = Task.Run(async () => - { - try - { - await _writeQueue.Writer.WriteAsync((frame, tcs), CancellationToken.None); - } - catch (Exception ex) - { - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) - Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: EnqueueFrame write failed: {0}", ex.Message); - } - }); - } - - public async Task StopAsync() - { - if (_runTask != null && _runTask.IsCompleted == false) - { - _writeQueue.Writer.TryComplete(); - - await _runTask; - } - } - } - - -} \ No newline at end of file diff --git a/src/Internal/FrameReader.cs b/src/Internal/FrameReader.cs index 9b7ae37..1787e48 100644 --- a/src/Internal/FrameReader.cs +++ b/src/Internal/FrameReader.cs @@ -9,7 +9,7 @@ internal class FrameReader private readonly ConnectionReader _reader; private readonly ChannelManager _channelManager; private readonly PingManager _pingManager; - private readonly ConnectionWriter _writer; + private readonly SessionFrameWriter _writer; private readonly Statistics? _stats; private YamuxMetrics? _metrics; private readonly Func _onFault; @@ -17,12 +17,13 @@ internal class FrameReader private readonly SessionOptions _sessionOptions; private Task? _readLoop; + private volatile bool _isStopping; public FrameReader( ConnectionReader reader, ChannelManager channelManager, PingManager pingManager, - ConnectionWriter writer, + SessionFrameWriter writer, Statistics? stats, YamuxMetrics? metrics, Func onFault, @@ -47,7 +48,14 @@ public void Start() public void Stop() { + _isStopping = true; _reader.Stop(); + _readToken.Cancel(); + } + + public void PrepareForClose() + { + _isStopping = true; } public CancellationToken CancellationToken => _readToken.Token; @@ -56,13 +64,21 @@ public async Task WaitForCompletionAsync(TimeSpan timeout) { if (_readLoop != null) { - if (_readLoop != await Task.WhenAny(_readLoop, Task.Delay(timeout, _readToken.Token))) + if (_readLoop != await Task.WhenAny(_readLoop, Task.Delay(timeout))) { _readToken.Cancel(); } } } + public async Task WaitForCompletionAsync() + { + if (_readLoop != null) + { + await _readLoop.ConfigureAwait(false); + } + } + private async Task RunAsync() { try @@ -80,7 +96,7 @@ private async Task RunAsync() await HandleWindowUpdateFrame(frameHeader, _readToken.Token); break; case FrameType.Ping: - _pingManager.HandlePing(frameHeader, _writer, _readToken.Token); + await _pingManager.HandlePingAsync(frameHeader, _writer, _readToken.Token).ConfigureAwait(false); break; case FrameType.GoAway: _channelManager.SetRemoteGoAway((SessionTermination)frameHeader.Length); @@ -92,9 +108,12 @@ private async Task RunAsync() } catch (Exception ex) { - Session.SessionTracer.TraceInformation("[Err]: Session receive loop faulted"); - _metrics?.SessionErrors.Add(1); - await _onFault(ex); + if (!_isStopping) + { + Session.SessionTracer.TraceInformation("[Err]: Session receive loop faulted"); + _metrics?.SessionErrors.Add(1); + _ = Task.Run(() => _onFault(ex)); + } } } @@ -115,9 +134,21 @@ private async Task HandleWindowUpdateFrame(FrameHeader frameHeader, Cancellation private async Task HandleDataFrame(FrameHeader frameHeader, CancellationToken token) { - var channel = await _channelManager.GetOrCreateAsync(frameHeader.StreamId, frameHeader.Flags, _writer, token); + var channel = await _channelManager.GetOrCreateAsync(frameHeader.StreamId, frameHeader.Flags, _writer, token).ConfigureAwait(false); + + if (channel == null) + { + Session.SessionTracer.TraceInformation("[WARN] yamux: discarding data frame for unknown stream {0}, sending RST", frameHeader.StreamId); - if (channel != null && frameHeader.Length > channel.ReceiveWindowUpperBound) + _ = _writer.WriteAsync( + Frame.CreateWindowUpdateFrame(frameHeader.StreamId, Flags.RST, 0), + CancellationToken.None); + + await ReadPayloadData(frameHeader.Length, null, token).ConfigureAwait(false); + return; + } + + if (frameHeader.Length > channel.ReceiveWindowUpperBound) { Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, $"[Err] yamux: receive window exceeded (stream: {channel.Id}, length: {frameHeader.Length}, max: {channel.ReceiveWindowUpperBound})"); @@ -129,7 +160,19 @@ private async Task HandleDataFrame(FrameHeader frameHeader, CancellationToken to SessionTermination.ProtocolError); } - await ReadPayloadData(frameHeader.Length, channel, token); + if (frameHeader.Length > _sessionOptions.MaxIncomingFrameSize) + { + Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, $"[Err] yamux: incoming frame size exceeded (stream: {channel.Id}, length: {frameHeader.Length}, max: {_sessionOptions.MaxIncomingFrameSize})"); + + _ = _writer.WriteAsync( + Frame.CreateGoAwayFrame(SessionTermination.ProtocolError), + CancellationToken.None); + throw new SessionException(SessionErrorCode.RecvWindowExceeded, + $"incoming frame size exceeded (stream: {channel.Id})", + SessionTermination.ProtocolError); + } + + await ReadPayloadData(frameHeader.Length, channel, token).ConfigureAwait(false); } private async ValueTask ReadPayloadData(uint payloadLength, SessionChannel? channel, CancellationToken cancellationToken) @@ -153,7 +196,7 @@ private async ValueTask ReadPayloadData(uint payloadLength, SessionChannel? chan buffer = buffer.Slice(0, bytesToRead); } - var read = await _reader.ReadFramePayloadAsync(buffer, cancellationToken); + var read = await _reader.ReadFramePayloadAsync(buffer, cancellationToken).ConfigureAwait(false); if (read == 0) { @@ -164,7 +207,7 @@ private async ValueTask ReadPayloadData(uint payloadLength, SessionChannel? chan bytesToRead -= read; - var flushResult = await pipeWriter.FlushAsync(cancellationToken); + var flushResult = await pipeWriter.FlushAsync(cancellationToken).ConfigureAwait(false); if (flushResult.IsCompleted) { diff --git a/src/Internal/IChannelSessionAdapter.cs b/src/Internal/IChannelSessionAdapter.cs index 9cab03f..ecbfa8d 100644 --- a/src/Internal/IChannelSessionAdapter.cs +++ b/src/Internal/IChannelSessionAdapter.cs @@ -4,10 +4,12 @@ namespace Yamux.Internal; internal interface IChannelSessionAdapter { - ValueTask SendFrameAsync(Frame frame, CancellationToken cancel); + ValueTask SendFrameAsync(Frame frame, CancellationToken cancellationToken); void EnqueueFrame(Frame frame); + ValueTask FlushWritesAsync(CancellationToken cancellationToken); + void ChannelDisconnect(SessionChannel channel); void ChannelAcknowledge(SessionChannel channel, bool accept); diff --git a/src/Internal/PingManager.cs b/src/Internal/PingManager.cs index b777f06..86a4362 100644 --- a/src/Internal/PingManager.cs +++ b/src/Internal/PingManager.cs @@ -9,7 +9,7 @@ internal class PingManager private readonly ConcurrentDictionary> _pings = new(); private uint _nextId; - public async ValueTask PingAsync(ConnectionWriter writer, CancellationToken cancellation) + public async ValueTask PingAsync(SessionFrameWriter writer, CancellationToken cancellation) { var opaqueValue = Interlocked.Increment(ref _nextId); @@ -27,7 +27,7 @@ public async ValueTask PingAsync(ConnectionWriter writer, Cancellation try { - await writer.WriteAsync(Frame.CreatePingRequestFrame(opaqueValue), cancellation); + await writer.WriteAsync(Frame.CreatePingRequestFrame(opaqueValue), cancellation).ConfigureAwait(false); } catch { @@ -35,11 +35,12 @@ public async ValueTask PingAsync(ConnectionWriter writer, Cancellation throw; } - var stop = await tcs.Task; - return TimeSpan.FromTicks(stop - start); + var stop = await tcs.Task.ConfigureAwait(false); + var elapsedTicks = stop - start; + return TimeSpan.FromTicks(elapsedTicks * TimeSpan.TicksPerSecond / Stopwatch.Frequency); } - public void HandlePing(FrameHeader frameHeader, ConnectionWriter writer, CancellationToken token) + public async ValueTask HandlePingAsync(FrameHeader frameHeader, SessionFrameWriter writer, CancellationToken token) { if (frameHeader.Flags.HasFlag(Flags.ACK)) { @@ -50,7 +51,7 @@ public void HandlePing(FrameHeader frameHeader, ConnectionWriter writer, Cancell } else { - _ = writer.WriteAsync(Frame.CreatePingResponseFrame(frameHeader), token); + await writer.WriteAsync(Frame.CreatePingResponseFrame(frameHeader), token).ConfigureAwait(false); } } diff --git a/src/Internal/RemoteDataWindow.cs b/src/Internal/RemoteDataWindow.cs index 8df6f3d..d1cd7d8 100644 --- a/src/Internal/RemoteDataWindow.cs +++ b/src/Internal/RemoteDataWindow.cs @@ -96,9 +96,10 @@ public uint WaitConsume(uint length, TimeSpan? timeout) /// /// /// + /// A cancellation token to cancel the wait operation. /// The number of bytes that were acquired /// Throws timeout exception if timeout occurs before any bytes are available from the window - public ValueTask WaitConsumeAsync(uint length, TimeSpan? timeout = null, CancellationToken? cancel = null) + public ValueTask WaitConsumeAsync(uint length, TimeSpan? timeout = null, CancellationToken? cancellationToken = null) { ThrowIfDisposed(); @@ -111,7 +112,7 @@ public ValueTask WaitConsumeAsync(uint length, TimeSpan? timeout = null, C { return ValueTask.FromResult(consumed); } - waiter = new AsyncWaiter(length, timeout, cancel); + waiter = new AsyncWaiter(length, timeout, cancellationToken); _waiters.Enqueue(waiter); } diff --git a/src/Internal/ReusableValueTaskSourcePool.cs b/src/Internal/ReusableValueTaskSourcePool.cs index b2b333e..1707381 100644 --- a/src/Internal/ReusableValueTaskSourcePool.cs +++ b/src/Internal/ReusableValueTaskSourcePool.cs @@ -1,64 +1,31 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Threading.Tasks.Sources; +using System.Collections.Concurrent; -namespace Yamux.Internal +namespace Yamux.Internal; + +internal sealed class ReusableValueTaskSourcePool { + private readonly ConcurrentQueue _queue = new(); + private const int MaxPoolSize = 1024; + private int _count; - internal class ReusableValueTaskSource : IValueTaskSource + public TaskCompletionSource Rent() { - private ManualResetValueTaskSourceCore _core; - - public ReusableValueTaskSource() - { - _core = new ManualResetValueTaskSourceCore - { - RunContinuationsAsynchronously = true - }; - } - - public void Reset() + if (_queue.TryDequeue(out var item)) { - _core.Reset(); + Interlocked.Decrement(ref _count); + return item; } - public void SetResult() => _core.SetResult(true); - public void SetException(Exception error) => _core.SetException(error); - public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token); - public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) - => _core.OnCompleted(continuation, state, token, flags); - - public void GetResult(short token) => _core.GetResult(token); - - public short Version => _core.Version; + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } - internal sealed class ReusableValueTaskSourcePool + public void Return(TaskCompletionSource item) { - private readonly ConcurrentQueue _queue = new(); - private const int MaxPoolSize = 1024; - private int _count; - - public ReusableValueTaskSource Rent() - { - if (_queue.TryDequeue(out var item)) - { - Interlocked.Decrement(ref _count); - item.Reset(); - return item; - } - - return new ReusableValueTaskSource(); - } - - public void Return(ReusableValueTaskSource item) + if (item.Task.IsCompleted) { if (Interlocked.Increment(ref _count) <= MaxPoolSize) { + item.TrySetResult(); _queue.Enqueue(item); } else @@ -67,4 +34,4 @@ public void Return(ReusableValueTaskSource item) } } } -} +} \ No newline at end of file diff --git a/src/Internal/SessionFrameWriter.cs b/src/Internal/SessionFrameWriter.cs new file mode 100644 index 0000000..777c4d6 --- /dev/null +++ b/src/Internal/SessionFrameWriter.cs @@ -0,0 +1,176 @@ +using System.Buffers; +using System.Diagnostics; +using System.Threading.Channels; +using Yamux.Protocol; + +namespace Yamux.Internal; + + +/// +/// Serializes frame writes to the underlying transport. This is necessary because the underlying transport may not be thread-safe for concurrent writes, and we want to ensure that frames are written in the order they are enqueued. +/// +internal class SessionFrameWriter +{ +private readonly ITransport _peer; + private readonly Channel<(Frame frame, TaskCompletionSource tcs)> _writeQueue; + private readonly Statistics? _stats; + private YamuxMetrics? _metrics; + private Task? _runTask; + private readonly TimeSpan _connectionWriteTimeout; + private readonly ReusableValueTaskSourcePool _tcsPool = new(); + + private readonly SemaphoreSlim _flushLock = new SemaphoreSlim(1, 1); + + internal void SetMetrics(YamuxMetrics? metrics) => _metrics = metrics; + +public SessionFrameWriter(ITransport connection, Statistics? stats, TimeSpan connectionWriteTimeout) + { + _peer = connection ?? throw new ArgumentNullException(nameof(connection)); + _stats = stats; + _metrics = null; + _connectionWriteTimeout = connectionWriteTimeout; + + _writeQueue = Channel.CreateBounded<(Frame, TaskCompletionSource)>(new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + }); + } + + public void Start() + { + _runTask = Task.Run(async () => + { + byte[] headerBuffer = new byte[FrameHeader.FrameHeaderSize]; + + try + { + while (await _writeQueue.Reader.WaitToReadAsync().ConfigureAwait(false)) + { + if (_writeQueue.Reader.TryRead(out var item)) + { + using var _ = item.frame; + +try + { + item.frame.Header.WriteTo(headerBuffer); + + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) + Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: writing frame - {0}, payload size = {1}", item.frame.Header.FrameType, item.frame.Header.Length); + + await _peer.WriteAsync(headerBuffer, default).ConfigureAwait(false); + _metrics?.FramesSent.Add(1); + if (!item.frame.Payload.IsEmpty) + { + await _peer.WriteAsync(item.frame.Payload, default).ConfigureAwait(false); + _stats?.UpdateSent((uint)item.frame.Payload.Length); + _metrics?.BytesSent.Add(item.frame.Payload.Length); + } + + item.tcs.TrySetResult(); + } + catch (OperationCanceledException cancelEx) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) + Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: write operation canceled - {0}", cancelEx.Message); + item.tcs.TrySetException(cancelEx); + } + catch (Exception ex) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) + Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); + item.tcs.TrySetException(ex); + } + finally + { + _tcsPool.Return(item.tcs); + } + } + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) + Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); + } + }); + } + +public async ValueTask WriteAsync(Frame frame, CancellationToken cancellationToken) + { + var tcs = _tcsPool.Rent(); + + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) + Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Enqueuing frame for write - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); + + using var timeoutCts = new CancellationTokenSource(_connectionWriteTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + await _writeQueue.Writer.WriteAsync((frame, tcs), linkedCts.Token).ConfigureAwait(false); + + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) + Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Frame enqueued for write - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); + + await tcs.Task.ConfigureAwait(false); + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) + Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Write completed for frame - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); + } + + public void EnqueueFrame(Frame frame) + { + var tcs = _tcsPool.Rent(); + + if (_writeQueue.Writer.TryWrite((frame, tcs))) + { + return; + } + + _ = EnqueueAsync(frame, tcs); + } + +private async Task EnqueueAsync(Frame frame, TaskCompletionSource tcs) + { + try + { + using var timeoutCts = new CancellationTokenSource(_connectionWriteTimeout); + await _writeQueue.Writer.WriteAsync((frame, tcs), timeoutCts.Token).ConfigureAwait(false); + await tcs.Task.ConfigureAwait(false); + } + catch (Exception ex) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) + Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: EnqueueFrame write failed: {0}", ex.Message); + tcs.TrySetException(ex); + } + finally + { + _tcsPool.Return(tcs); + } + } + + public async ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + await _flushLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _peer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _flushLock.Release(); + } + } + + public async Task StopAsync() + { + if (_runTask != null && _runTask.IsCompleted == false) + { + _writeQueue.Writer.TryComplete(); + + await _runTask.ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/PipeExtensions.cs b/src/PipeExtensions.cs index 6bb4de7..bef3512 100644 --- a/src/PipeExtensions.cs +++ b/src/PipeExtensions.cs @@ -2,12 +2,20 @@ namespace Yamux { + /// + /// Extension methods for creating Yamux sessions from instances. + /// public static class PipeExtensions { /// - /// Creates a yamux session from the duplex pipe + /// Creates a Yamux session over the provided duplex pipe. /// - public static Session AsYamuxSession(this IDuplexPipe pipe, bool isClient, bool keepOpen = false, SessionOptions? options = null) - => new Session(new PipePeer(pipe), isClient, keepOpen, options); + /// The duplex pipe to use as the transport. + /// Whether this is the client side of the connection. + /// Whether to leave the pipe open when the session is disposed. + /// Session configuration options. If null, default options are used. + /// A new instance. + public static Session AsYamuxSession(this IDuplexPipe pipe, bool isClient, bool leaveOpen = false, SessionOptions? options = null) + => new Session(new PipePeer(pipe), isClient, leaveOpen, options); } } diff --git a/src/PipePeer.cs b/src/PipePeer.cs index cbf4e18..4b9fcfa 100644 --- a/src/PipePeer.cs +++ b/src/PipePeer.cs @@ -2,11 +2,19 @@ namespace Yamux { + /// + /// An implementation that wraps an . + /// public class PipePeer : ITransport { private readonly PipeReader _reader; private readonly PipeWriter _writer; + /// + /// Initializes a new instance of the class. + /// + /// The duplex pipe to use for transport. + /// Thrown when is null. public PipePeer(IDuplexPipe pipe) { ArgumentNullException.ThrowIfNull(pipe); @@ -14,12 +22,13 @@ public PipePeer(IDuplexPipe pipe) _writer = pipe.Output; } - public async ValueTask ReadAsync(Memory data, CancellationToken cancel) + /// + public async ValueTask ReadAsync(Memory data, CancellationToken cancellationToken) { if (data.IsEmpty) return 0; - var result = await _reader.ReadAsync(cancel); + var result = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); var buffer = result.Buffer; if (buffer.IsEmpty && result.IsCompleted) @@ -43,20 +52,29 @@ public async ValueTask ReadAsync(Memory data, CancellationToken cance return len; } - public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancel) + /// + public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { if (data.IsEmpty) return; - await _writer.WriteAsync(data, cancel); + await _writer.WriteAsync(data, cancellationToken).ConfigureAwait(false); } + /// + public async ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + await _writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + /// public void Close() { _reader.Complete(); _writer.Complete(); } + /// public void Dispose() { _reader.Complete(); diff --git a/src/Session.cs b/src/Session.cs index 072ff88..673f7ea 100644 --- a/src/Session.cs +++ b/src/Session.cs @@ -12,7 +12,7 @@ public sealed class Session : IChannelSessionAdapter, IAsyncDisposable private SemaphoreSlim _closeLock = new SemaphoreSlim(1, 1); private readonly ITransport _transport; - private readonly ConnectionWriter _writer; + private readonly SessionFrameWriter _writer; private readonly PingManager _pingManager; private readonly ChannelManager _channelManager; private readonly FrameReader _frameReader; @@ -20,7 +20,7 @@ public sealed class Session : IChannelSessionAdapter, IAsyncDisposable private readonly TaskCompletionSource _sessionFault = new(TaskCreationOptions.RunContinuationsAsynchronously); internal readonly YamuxMetrics? Metrics; - private readonly bool _keepTransportOpenOnClose; + private readonly bool _leaveOpen; private Task? _keepAlive; private bool _started; @@ -28,12 +28,21 @@ public sealed class Session : IChannelSessionAdapter, IAsyncDisposable private readonly CancellationTokenSource _keepAliveToken; private readonly SessionOptions _sessionOptions; private volatile bool _disposed; - - internal Session(ITransport connection, bool isClient, bool keepTransportOpenOnClose = false, SessionOptions? options = null) + private int _isClosing; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying transport for this session. + /// Whether this is the client side of the connection. Client uses odd stream IDs, server uses even. + /// Whether to leave the transport open when the session is closed. When true, the caller is responsible for disposing the transport. + /// Session configuration options. If null, default options are used. + /// Thrown when is null. + public Session(ITransport transport, bool isClient, bool leaveOpen = false, SessionOptions? options = null) { _sessionOptions = options ?? new SessionOptions(); - _transport = connection ?? throw new ArgumentNullException(nameof(connection)); - _keepTransportOpenOnClose = keepTransportOpenOnClose; + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _leaveOpen = leaveOpen; _idGenerator = new StreamIdGenerator(!isClient); _pingManager = new PingManager(); _keepAliveToken = new CancellationTokenSource(); @@ -44,7 +53,7 @@ internal Session(ITransport connection, bool isClient, bool keepTransportOpenOnC } _channelManager = new ChannelManager(this, _sessionOptions.DefaultChannelOptions, _sessionOptions.AcceptBacklog, null, _sessionOptions.MaxChannels); - _writer = new ConnectionWriter(_transport, Stats); + _writer = new SessionFrameWriter(_transport, Stats, _sessionOptions.ConnectionWriteTimeout); _frameReader = new FrameReader( new ConnectionReader(_transport), _channelManager, @@ -52,7 +61,7 @@ internal Session(ITransport connection, bool isClient, bool keepTransportOpenOnC _writer, Stats, null, - ex => CloseAsync((ex as SessionException) ?? new SessionException(SessionErrorCode.StreamError, "Underlying stream encountered an error", ex, SessionTermination.InternalError)), + ex => CloseAsync((ex as SessionException) ?? new SessionException(SessionErrorCode.StreamClosed, "Underlying stream encountered an error", ex, SessionTermination.InternalError)), _sessionOptions); if (_sessionOptions.EnableMetrics) @@ -73,7 +82,7 @@ internal Session(ITransport connection, bool isClient, bool keepTransportOpenOnC public Statistics? Stats { get; private set; } - public ValueTask OpenChannelAsync(SessionChannelOptions options, bool waitForAcknowledgement = false, CancellationToken? cancel = null) + public ValueTask OpenChannelAsync(SessionChannelOptions options, bool waitForAcknowledgement = false, CancellationToken cancellationToken = default) { if (!_channelManager.CanAcceptNew) { @@ -93,11 +102,11 @@ public ValueTask OpenChannelAsync(SessionChannelOptions o { TaskCompletionSource tcs = new TaskCompletionSource(); - if (cancel != null) + if (cancellationToken != default) { - var registration = cancel.Value.Register(() => + var registration = cancellationToken.Register(() => { - tcs.TrySetCanceled(); + tcs.TrySetCanceled(cancellationToken); }); tcs.Task.ContinueWith(t => { @@ -105,6 +114,22 @@ public ValueTask OpenChannelAsync(SessionChannelOptions o }); } + if (_sessionOptions.StreamOpenTimeout > TimeSpan.Zero) + { + var timeoutCts = new CancellationTokenSource(_sessionOptions.StreamOpenTimeout); + timeoutCts.Token.Register(() => + { + if (!tcs.Task.IsCompleted) + { + tcs.TrySetException(new TimeoutException("Stream open timeout exceeded")); + } + }); + tcs.Task.ContinueWith(_ => + { + try { timeoutCts.Dispose(); } catch { } + }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); + } + _channelManager.TrackConnect(id, tcs); return new ValueTask(tcs.Task); } @@ -112,24 +137,24 @@ public ValueTask OpenChannelAsync(SessionChannelOptions o return ValueTask.FromResult((IDuplexSessionChannel)channel); } - public ValueTask OpenChannelAsync(bool waitForAcknowledgement = false, CancellationToken? cancel = null) => - this.OpenChannelAsync(_sessionOptions.DefaultChannelOptions, waitForAcknowledgement, cancel); + public ValueTask OpenChannelAsync(bool waitForAcknowledgement = false, CancellationToken cancellationToken = default) => + this.OpenChannelAsync(_sessionOptions.DefaultChannelOptions, waitForAcknowledgement, cancellationToken); - public ValueTask AcceptAsync(CancellationToken? cancel = null) => AcceptChannelAsync(_sessionOptions.DefaultChannelOptions, cancel); + public ValueTask AcceptAsync(CancellationToken cancellationToken = default) => AcceptChannelAsync(_sessionOptions.DefaultChannelOptions, cancellationToken); - public ValueTask AcceptAsync(SessionChannelOptions channelOptions, CancellationToken? cancel) => AcceptChannelAsync(channelOptions, cancel); + public ValueTask AcceptAsync(SessionChannelOptions channelOptions, CancellationToken cancellationToken) => AcceptChannelAsync(channelOptions, cancellationToken); - public async ValueTask AcceptReadOnlyChannelAsync(CancellationToken? cancel) + public async ValueTask AcceptReadOnlyChannelAsync(CancellationToken cancellationToken = default) { - var channel = await AcceptChannelAsync(_sessionOptions.DefaultChannelOptions, cancel); + var channel = await AcceptChannelAsync(_sessionOptions.DefaultChannelOptions, cancellationToken); return channel; } - private async ValueTask AcceptChannelAsync(SessionChannelOptions channelOptions, CancellationToken? cancel = null) + private async ValueTask AcceptChannelAsync(SessionChannelOptions channelOptions, CancellationToken cancellationToken = default) { try { - var channel = await _channelManager.WaitForAcceptAsync(cancel ?? CancellationToken.None); + var channel = await _channelManager.WaitForAcceptAsync(cancellationToken); if (this.IsClosed) { @@ -138,7 +163,7 @@ private async ValueTask AcceptChannelAsync(SessionChannel channel.Accept(); - await channel.ApplyOptionsAsync(channelOptions, cancel ?? CancellationToken.None); + await channel.ApplyOptionsAsync(channelOptions, cancellationToken); return channel; } catch (ChannelClosedException) @@ -147,9 +172,9 @@ private async ValueTask AcceptChannelAsync(SessionChannel } } - public async ValueTask PingAsync(CancellationToken cancellation) + public async ValueTask PingAsync(CancellationToken cancellationToken) { - return await _pingManager.PingAsync(_writer, cancellation); + return await _pingManager.PingAsync(_writer, cancellationToken); } public void Start() @@ -182,11 +207,11 @@ public async Task CloseOpenChannelsAsync(TimeSpan timeout) return await _channelManager.CloseOpenChannelsAsync(timeout); } - public async Task GoAwayAsync(SessionTermination sessionTermination = SessionTermination.Normal, CancellationToken? cancel = null) + public async Task GoAwayAsync(SessionTermination sessionTermination = SessionTermination.Normal, CancellationToken cancellationToken = default) { try { - await _writer.WriteAsync(Frame.CreateGoAwayFrame(sessionTermination), cancel ?? CancellationToken.None); + await _writer.WriteAsync(Frame.CreateGoAwayFrame(sessionTermination), cancellationToken); _channelManager.SetLocalGoAway(); } catch (Exception ex) @@ -197,7 +222,10 @@ public async Task GoAwayAsync(SessionTermination sessionTermination = SessionTer private async Task CloseAsync(SessionException? err = null) { - await _closeLock.WaitAsync(); + if (Interlocked.CompareExchange(ref _isClosing, 1, 0) != 0) + return; + + await _closeLock.WaitAsync().ConfigureAwait(false); try { @@ -206,26 +234,30 @@ private async Task CloseAsync(SessionException? err = null) if (_keepAlive != null) { _keepAliveToken.Cancel(); - await _keepAlive; + await _keepAlive.ConfigureAwait(false); _keepAliveToken.Dispose(); _keepAlive = null; } - await GoAwayAsync(err?.GoAwayCode ?? SessionTermination.Normal); - _channelManager.FailAllConnects( new SessionChannelException(ChannelErrorCode.SessionClosed, "The session has been closed")); - _channelManager.CloseAllChannels(err); + _frameReader.PrepareForClose(); + + if (!_leaveOpen) + { + _transport.Close(); + } _frameReader.Stop(); + await _frameReader.WaitForCompletionAsync().ConfigureAwait(false); + + _channelManager.CloseAllChannels(err); - await _frameReader.WaitForCompletionAsync(TimeSpan.FromSeconds(2)); + await _writer.StopAsync().ConfigureAwait(false); - if (!_keepTransportOpenOnClose) + if (!_leaveOpen) { - _transport.Close(); - if (_transport is IDisposable d) { d.Dispose(); @@ -264,14 +296,14 @@ private async Task KeepAlive() { try { - this.RTT = await PingAsync(_keepAliveToken.Token); + this.RTT = await PingAsync(_keepAliveToken.Token).ConfigureAwait(false); Metrics?.RecordRtt(this.RTT.Value); } catch (Exception e) when (e is not OperationCanceledException) { } - await Task.Delay(_sessionOptions.KeepAliveInterval, _keepAliveToken.Token); + await Task.Delay(_sessionOptions.KeepAliveInterval, _keepAliveToken.Token).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -281,9 +313,9 @@ private async Task KeepAlive() #region channel adapter - ValueTask IChannelSessionAdapter.SendFrameAsync(Frame frame, CancellationToken cancel) + ValueTask IChannelSessionAdapter.SendFrameAsync(Frame frame, CancellationToken cancellationToken) { - return _writer.WriteAsync(frame, cancel); + return _writer.WriteAsync(frame, cancellationToken); } void IChannelSessionAdapter.EnqueueFrame(Frame frame) @@ -291,6 +323,11 @@ void IChannelSessionAdapter.EnqueueFrame(Frame frame) _writer.EnqueueFrame(frame); } + ValueTask IChannelSessionAdapter.FlushWritesAsync(CancellationToken cancellationToken) + { + return _writer.FlushAsync(cancellationToken); + } + Task IChannelSessionAdapter.SessionFault => _sessionFault.Task; TimeSpan IChannelSessionAdapter.StreamSendTimeout => _sessionOptions.StreamSendTimeout; diff --git a/src/SessionChannel.cs b/src/SessionChannel.cs index 3dae193..7b5b08a 100644 --- a/src/SessionChannel.cs +++ b/src/SessionChannel.cs @@ -154,13 +154,13 @@ public Stream AsStream(bool leaveOpen = false) } - public async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken? cancel = null) + public async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - cancel?.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); var writeClosedToken = _writeClosedCancellation.Token; - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancel ?? CancellationToken.None, writeClosedToken); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, writeClosedToken); var linkedToken = linkedCts.Token; this.ValidateStateForWrite(); @@ -221,13 +221,16 @@ public async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken } } - public Task FlushWritesAsync(CancellationToken? cancel = null) + public ValueTask FlushWritesAsync(CancellationToken cancellationToken = default) { - return Task.CompletedTask; + return _session.FlushWritesAsync(cancellationToken); } public void Abort() { + if (_disposed) + return; + _writeClosedCancellation.Cancel(); lock (_stateLock) @@ -277,6 +280,7 @@ public bool WaitForRemoteAck(TimeSpan timeout) public void Dispose() { + Timer? timerToDispose = null; lock (this._stateLock) { if (!this._disposed) @@ -285,17 +289,24 @@ public void Dispose() _writeClosedCancellation.Cancel(); - _closeTimer?.Dispose(); - _closeTimer = null; + if (_closeTimer != null) + { + _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); + timerToDispose = _closeTimer; + _closeTimer = null; + } if (_remoteState < ChannelRemoteState.ReadClosed) { + _localPhase = ChannelLocalPhase.WriteClosed; _remoteState = ChannelRemoteState.Reset; _inputBuffer.Writer.Complete(); _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); } + this.SendWindowUpdate(0, (Flags)Flags.RST); + this._session.ChannelDisconnect(this); _writeClosedCancellation.Dispose(); @@ -303,6 +314,13 @@ public void Dispose() Stats = null; } } + timerToDispose?.Dispose(); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; } internal void Accept() @@ -402,7 +420,7 @@ private void ForceCloseTimeout() { lock (_stateLock) { - if (_remoteState >= ChannelRemoteState.ReadClosed) + if (_disposed || _remoteState >= ChannelRemoteState.ReadClosed) return; _localPhase = ChannelLocalPhase.WriteClosed; @@ -469,10 +487,11 @@ private Flags GetSendFlags() private void ProcessIncomingFlags(Flags flags) { - this.ThrowIfDisposed(); - + Timer? timerToDispose = null; lock (_stateLock) { + if (_disposed) + return; if (flags.HasFlag(Flags.SYN)) { if (_remoteState == ChannelRemoteState.None) @@ -496,8 +515,12 @@ private void ProcessIncomingFlags(Flags flags) { if (_remoteState < ChannelRemoteState.ReadClosed) { - _closeTimer?.Dispose(); - _closeTimer = null; + if (_closeTimer != null) + { + _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); + timerToDispose = _closeTimer; + _closeTimer = null; + } _remoteState = ChannelRemoteState.ReadClosed; _inputBuffer.Writer.Complete(_fault); _remoteCloseEvent.Set(); @@ -507,8 +530,12 @@ private void ProcessIncomingFlags(Flags flags) if (flags.HasFlag((Flags)Flags.RST)) { - _closeTimer?.Dispose(); - _closeTimer = null; + if (_closeTimer != null) + { + _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); + timerToDispose ??= _closeTimer; + _closeTimer = null; + } _fault = new SessionChannelException(ChannelErrorCode.ChannelRejected, "Channel was rejected or forcibly closed by the remote peer"); _remoteState = ChannelRemoteState.Reset; _localPhase = ChannelLocalPhase.WriteClosed; @@ -516,26 +543,34 @@ private void ProcessIncomingFlags(Flags flags) _inputBuffer.Writer.Complete(_fault); _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); + _session.ChannelDisconnect(this); } } + timerToDispose?.Dispose(); } internal PipeWriter GetPipeWriter() => _inputBuffer.Writer; private void CompleteRead(YamuxException? fault = null) { + Timer? timerToDispose = null; lock (_stateLock) { if (_remoteState < ChannelRemoteState.ReadClosed) { - _closeTimer?.Dispose(); - _closeTimer = null; + if (_closeTimer != null) + { + _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); + timerToDispose = _closeTimer; + _closeTimer = null; + } _remoteState = ChannelRemoteState.ReadClosed; _inputBuffer.Writer.Complete(fault); _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); } } + timerToDispose?.Dispose(); } private void OnInputBytesConsumed() @@ -607,20 +642,20 @@ private void ValidateStateForWrite() } } - private static async Task CopyToAsync(PipeReader source, PipeWriter destination, CancellationToken cancellationToken = default) + private async ValueTask CopyToAsync(PipeReader source, PipeWriter destination, CancellationToken cancellationToken = default) { ulong totalBytesCopied = 0; while (true) { - ReadResult result = await source.ReadAsync(cancellationToken); + ReadResult result = await source.ReadAsync(cancellationToken).ConfigureAwait(false); ReadOnlySequence buffer = result.Buffer; if (buffer.Length > 0) { foreach (var segment in buffer) { - await destination.WriteAsync(segment, cancellationToken); + await destination.WriteAsync(segment, cancellationToken).ConfigureAwait(false); totalBytesCopied += (ulong)segment.Length; } } @@ -632,7 +667,7 @@ private static async Task CopyToAsync(PipeReader source, PipeWriter desti break; } } - await source.CompleteAsync(); + await source.CompleteAsync().ConfigureAwait(false); return totalBytesCopied; } diff --git a/src/SessionOptions.cs b/src/SessionOptions.cs index af2160c..8186a88 100644 --- a/src/SessionOptions.cs +++ b/src/SessionOptions.cs @@ -1,17 +1,63 @@ namespace Yamux; +/// +/// Configuration options for a Yamux . +/// public class SessionOptions { + /// + /// The maximum number of channels that can be queued for acceptance before backpressure is applied. + /// public int AcceptBacklog { get; set; } = 256; + /// + /// Whether to enable keep-alive pings to detect dead connections. + /// public bool EnableKeepAlive { get; set; } = true; + /// + /// The interval between keep-alive pings. + /// public TimeSpan KeepAliveInterval { get; set; } = TimeSpan.FromSeconds(30); + /// + /// The maximum time to wait for a stream to fully close after sending FIN. + /// public TimeSpan StreamCloseTimeout { get; set; } = TimeSpan.FromMinutes(5); + /// + /// The timeout for sending data on a channel before declaring the send failed. + /// public TimeSpan StreamSendTimeout { get; set; } = TimeSpan.FromSeconds(75); + /// + /// The maximum time to wait for a stream to be acknowledged after opening with SYN. + /// After this timeout, the session is closed. A zero value disables the timeout. + /// Matches the Go yamux StreamOpenTimeout. + /// + public TimeSpan StreamOpenTimeout { get; set; } = TimeSpan.FromSeconds(75); + + /// + /// The maximum time to wait for a write to the underlying connection to complete. + /// Acts as a safety valve after which the connection is suspected to be dead. + /// Matches the Go yamux ConnectionWriteTimeout. + /// + public TimeSpan ConnectionWriteTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// The maximum time to wait for open channels to drain during session shutdown. + /// After this timeout, channels are forcefully closed. + /// + public TimeSpan SessionCloseTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// The timeout for reading data from the underlying transport. + /// + public TimeSpan ReadTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Default channel options applied to newly accepted channels. + /// public SessionChannelOptions DefaultChannelOptions { get; set; } = new SessionChannelOptions(); /// @@ -20,17 +66,22 @@ public class SessionOptions public int MaxChannels { get; set; } = 1024; /// - /// Enables statistics + /// The maximum allowed payload size for an incoming data frame. Frames exceeding this will trigger a protocol error. + /// + public uint MaxIncomingFrameSize { get; set; } = 16 * 1024 * 1024; + + /// + /// Enables bandwidth and byte statistics tracking. /// public bool EnableStatistics { get; set; } /// - /// How often to sample the statistics + /// How often to sample statistics, in milliseconds. /// public int StatisticsSampleInterval { get; set; } = 1000; /// - /// Enables OpenTelemetry-compatible metrics via System.Diagnostics.Metrics + /// Enables OpenTelemetry-compatible metrics via . /// public bool EnableMetrics { get; set; } = true; } diff --git a/src/SocketExtensions.cs b/src/SocketExtensions.cs index fb6914c..afeecba 100644 --- a/src/SocketExtensions.cs +++ b/src/SocketExtensions.cs @@ -7,14 +7,20 @@ namespace Yamux { + /// + /// Extension methods for creating Yamux sessions from instances. + /// public static class SocketExtensions { /// - /// Creates a yamux session from the socket + /// Creates a Yamux session over the provided socket. /// - /// - /// - public static Session AsYamuxSession(this Socket socket, bool isClient, bool keepOpen = false, SessionOptions? options = null) - => new Session(new SocketPeer(socket), isClient, keepOpen, options); + /// The socket to use as the transport. + /// Whether this is the client side of the connection. + /// Whether to leave the socket open when the session is disposed. + /// Session configuration options. If null, default options are used. + /// A new instance. + public static Session AsYamuxSession(this System.Net.Sockets.Socket socket, bool isClient, bool leaveOpen = false, SessionOptions? options = null) + => new Session(new SocketPeer(socket), isClient, leaveOpen, options); } } diff --git a/src/SocketPeer.cs b/src/SocketPeer.cs index c88858f..6fbf419 100644 --- a/src/SocketPeer.cs +++ b/src/SocketPeer.cs @@ -1,38 +1,52 @@ namespace Yamux { + /// + /// An implementation that wraps a . + /// public class SocketPeer : ITransport { private readonly System.Net.Sockets.Socket _socket; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying socket to use for transport. + /// Thrown when is null. public SocketPeer(System.Net.Sockets.Socket socket) { _socket = socket ?? throw new ArgumentNullException(nameof(socket)); } + /// public void Close() { _socket.Shutdown(System.Net.Sockets.SocketShutdown.Both); _socket.Close(); } + /// public void Dispose() { _socket.Dispose(); } - public async ValueTask ReadAsync(Memory data, CancellationToken cancel) + /// + public async ValueTask ReadAsync(Memory data, CancellationToken cancellationToken) { - var read = await _socket.ReceiveAsync(data, System.Net.Sockets.SocketFlags.None, cancel); + var read = await _socket.ReceiveAsync(data, System.Net.Sockets.SocketFlags.None, cancellationToken).ConfigureAwait(false); return read; } - public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancel) + + /// + public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { if (data.IsEmpty) { return; } - await _socket.SendAsync(data, System.Net.Sockets.SocketFlags.None, cancel); + await _socket.SendAsync(data, System.Net.Sockets.SocketFlags.None, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/Statistics.cs b/src/Statistics.cs index beadfc3..1592147 100644 --- a/src/Statistics.cs +++ b/src/Statistics.cs @@ -49,11 +49,12 @@ public class Statistics : IDisposable /// Initializes a new instance of the class and starts the timer. /// /// The interval in milliseconds for sampling the bandwidth. - public Statistics(int intervalMilliseconds, CancellationToken cancel) + /// A cancellation token to stop the statistics timer. + public Statistics(int intervalMilliseconds, CancellationToken cancellationToken) { SampleInterval = TimeSpan.FromMilliseconds(intervalMilliseconds); _timer = new Timer(SampleBandwidth, null, intervalMilliseconds, intervalMilliseconds); - _cancel = cancel; + _cancel = cancellationToken; } /// diff --git a/src/StreamExtensions.cs b/src/StreamExtensions.cs index 28a208f..b4e4df6 100644 --- a/src/StreamExtensions.cs +++ b/src/StreamExtensions.cs @@ -7,14 +7,20 @@ namespace Yamux { + /// + /// Extension methods for creating Yamux sessions from instances. + /// public static class StreamExtensions { /// - /// Creates a yamux session from the raw stream + /// Creates a Yamux session over the provided stream. /// - /// - /// - public static Session AsYamuxSession(this Stream stream, bool isClient, bool keepOpen = false, SessionOptions? options = null) - => new Session(new StreamPeer(stream), isClient, keepOpen, options); + /// The stream to use as the transport. + /// Whether this is the client side of the connection. + /// Whether to leave the stream open when the session is disposed. + /// Session configuration options. If null, default options are used. + /// A new instance. + public static Session AsYamuxSession(this Stream stream, bool isClient, bool leaveOpen = false, SessionOptions? options = null) + => new Session(new StreamPeer(stream), isClient, leaveOpen, options); } } diff --git a/src/StreamPeer.cs b/src/StreamPeer.cs index f27b1fc..da6add4 100644 --- a/src/StreamPeer.cs +++ b/src/StreamPeer.cs @@ -1,33 +1,52 @@ namespace Yamux { + /// + /// An implementation that wraps a . + /// public class StreamPeer : ITransport { private readonly System.IO.Stream _stream; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying stream to use for transport. + /// Thrown when is null. public StreamPeer(System.IO.Stream stream) { _stream = stream ?? throw new ArgumentNullException(nameof(stream)); } - public async ValueTask ReadAsync(Memory data, CancellationToken cancel) + /// + public async ValueTask ReadAsync(Memory data, CancellationToken cancellationToken) { if (data.IsEmpty) return 0; - return await _stream.ReadAsync(data, cancel); + return await _stream.ReadAsync(data, cancellationToken).ConfigureAwait(false); } - public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancel) + /// + public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { if (data.IsEmpty) return; - await _stream.WriteAsync(data, cancel); + await _stream.WriteAsync(data, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); } + /// public void Close() { _stream.Close(); } + /// public void Dispose() { _stream.Dispose(); diff --git a/src/Yamux.csproj b/src/Yamux.csproj index d16972d..69043e5 100644 --- a/src/Yamux.csproj +++ b/src/Yamux.csproj @@ -5,6 +5,8 @@ enable true + true + $(NoWarn);CS1591 diff --git a/test/Yamux.Tests/ErrorPathTests.cs b/test/Yamux.Tests/ErrorPathTests.cs new file mode 100644 index 0000000..82b7c2c --- /dev/null +++ b/test/Yamux.Tests/ErrorPathTests.cs @@ -0,0 +1,221 @@ +using System.IO.Pipelines; +using System.Net; +using System.Net.Sockets; +using AwesomeAssertions; +using Nerdbank.Streams; +using Yamux.Protocol; + +namespace Yamux.Tests; + +public class ErrorPathTests +{ + [Fact] + public async Task Session_DoubleDispose_NoOp() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + using var channel = await session.AcceptAsync(); + + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + }); + +await using var session = new Session(new StreamPeer(client), true); + session.Start(); + using var channel = await session.OpenChannelAsync(); + + await channel.WriteAsync(new byte[64]); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + + await session.DisposeAsync(); + await session.DisposeAsync(); + + await Task.WhenAll(serverTask); + } + + [Fact] + public async Task Channel_DoubleDispose_NoOp() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + using var channel = await session.AcceptAsync(); + + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + }); + + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + var channel = await session.OpenChannelAsync(); + + await channel.WriteAsync(new byte[32]); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + + channel.Dispose(); + channel.Dispose(); + + await Task.WhenAll(serverTask); + } + + [Fact] + public async Task GoAway_WhileChannelsActive() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + using var channel = await session.AcceptAsync(); + + await session.GoAwayAsync(); + + try + { + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + } + catch + { + } + }); + + var clientTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + using var channel = await session.OpenChannelAsync(); + + await channel.WriteAsync(new byte[64]); + await Task.Delay(500); + + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + }); + + await Task.WhenAll(serverTask, clientTask); + } + + [Fact] + public async Task ConcurrentCloseAndWrite() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + + try + { + using var channel = await session.AcceptAsync(); + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + } + catch + { + } + }); + + var clientTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + + var channel = await session.OpenChannelAsync(); + var tasks = new List(); + for (int i = 0; i < 10; i++) + { + tasks.Add(Task.Run(async () => + { + try + { + await channel.WriteAsync(new byte[1024]); + } + catch + { + } + })); + } + + await Task.Delay(100); + channel.Abort(); + await Task.WhenAll(tasks); + }); + + await Task.WhenAll(serverTask, clientTask); + } + + [Fact] + public async Task FaultedSession_PropagatesToAccept() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + using var channel = await session.AcceptAsync(); + + try + { + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + } + catch (YamuxException) + { + } + }); + + var clientTask = Task.Run(async () => + { +await using var session = new Session(new StreamPeer(client), true); + session.Start(); + using var channel = await session.OpenChannelAsync(); + + await channel.WriteAsync(new byte[1024]); + + await session.GoAwayAsync(SessionTermination.ProtocolError); + + try + { + await channel.WriteAsync(new byte[1024]); + } + catch (SessionChannelException) + { + } + }); + + await Task.WhenAll(serverTask, clientTask); + } +} \ No newline at end of file diff --git a/test/Yamux.Tests/ProtocolEdgeCaseTests.cs b/test/Yamux.Tests/ProtocolEdgeCaseTests.cs new file mode 100644 index 0000000..bf7c72f --- /dev/null +++ b/test/Yamux.Tests/ProtocolEdgeCaseTests.cs @@ -0,0 +1,231 @@ +using AwesomeAssertions; +using Nerdbank.Streams; +using System.ComponentModel.DataAnnotations; +using System.IO.Pipelines; +using Yamux.Internal; +using Yamux.Protocol; + +namespace Yamux.Tests; + +public class ProtocolEdgeCaseTests +{ + [Fact] + public void FrameHeader_SmallBuffer_Throws() + { + byte[] buffer = new byte[FrameHeader.FrameHeaderSize - 1]; + + Action act = () => FrameHeader.Parse(buffer); + act.Should().Throw(); + } + + [Fact] + public void FrameHeader_Write_SmallBuffer_Throws() + { + var header = new FrameHeader(ProtocolVersion.Initial, FrameType.Data, Flags.None, 1, 0); + byte[] buffer = new byte[FrameHeader.FrameHeaderSize - 1]; + + Action act = () => header.WriteTo(buffer); + act.Should().Throw(); + } + + [Fact] + public void Frame_CreateDataFrame_ZeroPayload() + { + var frame = Frame.CreateDataFrame(1, Flags.SYN, ReadOnlyMemory.Empty); + frame.Header.Length.Should().Be(0); + frame.Payload.IsEmpty.Should().BeTrue(); + frame.Header.Flags.Should().Be(Flags.SYN); + } + + [Fact] + public void Frame_GoAway_TerminationCodes() + { + foreach (SessionTermination code in Enum.GetValues()) + { + var frame = Frame.CreateGoAwayFrame(code); + frame.Header.Length.Should().Be((uint)code); + frame.Header.FrameType.Should().Be(FrameType.GoAway); + frame.Header.StreamId.Should().Be(0); + } + } + + [Fact] + public void Frame_PingRequest_OpaqueValue() + { + uint value = 0xDEADBEEF; + var frame = Frame.CreatePingRequestFrame(value); + frame.Header.Length.Should().Be(value); + frame.Header.Flags.Should().Be(Flags.SYN); + frame.Header.StreamId.Should().Be(0); + } + + [Fact] + public void Frame_PingResponse_EchoesLength() + { + var request = new FrameHeader(ProtocolVersion.Initial, FrameType.Ping, Flags.SYN, 0, 0x12345678); + var response = Frame.CreatePingResponseFrame(request); + response.Header.Length.Should().Be(0x12345678); + response.Header.Flags.Should().Be(Flags.ACK); + } + + [Fact] + public void Frame_Dispose_BufferOwner() + { + var disposed = false; + var owner = new DelegateDisposable(() => disposed = true); + var frame = Frame.CreateDataFrame(1, Flags.None, new byte[4], owner); + frame.Dispose(); + disposed.Should().BeTrue(); + } + + [Fact] + public void FrameHeaderRoundtrip_AllFrameTypes() + { + foreach (FrameType type in new[] { FrameType.Data, FrameType.WindowUpdate, FrameType.Ping, FrameType.GoAway }) + { + var original = new FrameHeader(ProtocolVersion.Initial, type, Flags.None, 42, 128); + byte[] buffer = new byte[FrameHeader.FrameHeaderSize]; + original.WriteTo(buffer); + var parsed = FrameHeader.Parse(buffer); + parsed.FrameType.Should().Be(type); + } + } + + private sealed class DelegateDisposable : IDisposable + { + private readonly Action _onDispose; + public DelegateDisposable(Action onDispose) => _onDispose = onDispose; + public void Dispose() => _onDispose(); + } + + [Fact] + public async Task StreamIdGenerator_Exhaustion() + { + var gen = new StreamIdGenerator(false); + var seen = new HashSet(); + + for (int i = 0; i < 1000; i++) + { + var id = gen.Next(); + seen.Add(id).Should().BeTrue(); + (id % 2).Should().Be(1); + } + } + + [Fact] + public void SessionChannelOptions_Validate_MinWindow() + { + var opts = new SessionChannelOptions { ReceiveWindowSize = 5 * 1024 }; + Action act = () => opts.Validate(); + act.Should().Throw(); + } + + [Fact] + public void SessionChannelOptions_Validate_AutoTuneBounds() + { + var opts = new SessionChannelOptions + { + ReceiveWindowSize = 16 * 1024 * 1024, + ReceiveWindowUpperBound = 8 * 1024 * 1024, + AutoTuneReceiveWindowSize = true, + }; + Action act = () => opts.Validate(); + act.Should().Throw(); + } + + [Fact] + public void SessionChannelOptions_Validate_ZeroMaxDataFrame() + { + var opts = new SessionChannelOptions { MaxDataFrameSize = 0 }; + Action act = () => opts.Validate(); + act.Should().Throw(); + } + + [Fact] + public async Task PingAsync_ReturnsRtt() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + await Task.Delay(5000); + }); + + var clientTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + + var rtt = await session.PingAsync(CancellationToken.None); + + rtt.TotalMilliseconds.Should().BeGreaterThan(0); + rtt.TotalMilliseconds.Should().BeLessThan(5000); + }); + + await Task.WhenAll(serverTask, clientTask); + } + + [Fact] + public async Task WriteAfterClose_Throws() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + using var channel = await session.AcceptAsync(); + + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + }); + + await using var clientSession = new Session(new StreamPeer(client), true); + clientSession.Start(); + using var channel = await clientSession.OpenChannelAsync(); + + await channel.WriteAsync(new byte[32]); + channel.Close(); + + Func writeAfterClose = async () => await channel.WriteAsync(new byte[1]); + await writeAfterClose.Should().ThrowAsync(); + + await Task.WhenAll(serverTask); + } + + [Fact] + public async Task MaxChannels_Enforced() + { + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false, options: new SessionOptions { MaxChannels = 2 }); + session.Start(); + + using var c1 = await session.AcceptAsync(); + using var c2 = await session.AcceptAsync(); + + await Task.Delay(2000); + }); + + await using var clientSession = new Session(new StreamPeer(client), true, options: new SessionOptions { MaxChannels = 2 }); + clientSession.Start(); + + using var ch1 = await clientSession.OpenChannelAsync(); + using var ch2 = await clientSession.OpenChannelAsync(); + + await Task.Delay(500); + + ch1.Close(); + ch2.Close(); + + await Task.WhenAll(serverTask); + } +} \ No newline at end of file diff --git a/test/Yamux.Tests/ProtocolUnitTests.cs b/test/Yamux.Tests/ProtocolUnitTests.cs index 2135b3e..e6abe57 100644 --- a/test/Yamux.Tests/ProtocolUnitTests.cs +++ b/test/Yamux.Tests/ProtocolUnitTests.cs @@ -127,12 +127,13 @@ public void StreamIdGenerator_OddEven() internal class MockChannelAdapter : IChannelSessionAdapter { public TimeSpan? RTT => TimeSpan.FromMilliseconds(10); - public Task SessionFault => Task.Delay(-1); // never faults + public Task SessionFault => Task.Delay(-1); public TimeSpan StreamSendTimeout => TimeSpan.FromSeconds(75); public TimeSpan StreamCloseTimeout => TimeSpan.FromMinutes(5); - public ValueTask SendFrameAsync(Frame frame, CancellationToken cancel) => ValueTask.CompletedTask; + public ValueTask SendFrameAsync(Frame frame, CancellationToken cancellationToken) => ValueTask.CompletedTask; public void EnqueueFrame(Frame frame) { } + public ValueTask FlushWritesAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask; public void ChannelDisconnect(SessionChannel channel) { } public void ChannelAcknowledge(SessionChannel channel, bool accept) { } } \ No newline at end of file diff --git a/test/Yamux.Tests/SessionTests.cs b/test/Yamux.Tests/SessionTests.cs index ceacbd8..7f36d43 100644 --- a/test/Yamux.Tests/SessionTests.cs +++ b/test/Yamux.Tests/SessionTests.cs @@ -179,13 +179,19 @@ public async Task SessionKillTest() Func read = async () => { - ReadResult res; - do + try { - res = await channel.Input.ReadAsync(); - if (res.Buffer.Length > 0) - channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); - } while (!res.IsCanceled && !res.IsCompleted); + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + if (res.Buffer.Length > 0) + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + } + catch (YamuxException) + { + } }; await read(); @@ -304,7 +310,7 @@ public async Task SessionProtoclErrTest() await channel.WriteAsync(buffer.Slice(0, 64), default); await Task.Delay(200); } - catch (YamuxException) + catch { } }); @@ -341,7 +347,7 @@ public async Task FinHalfCloseTest() received.Should().BeEquivalentTo(data); channel.Close(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel.Dispose(); }); @@ -360,7 +366,7 @@ public async Task FinHalfCloseTest() Func writeAfterClose = () => channel.WriteAsync(new byte[1], CancellationToken.None).AsTask(); await writeAfterClose.Should().ThrowAsync(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel.Dispose(); }); @@ -485,7 +491,7 @@ public async Task GoAwayRejectsNewChannelsTest() await channel1.WriteAsync(new byte[64], CancellationToken.None); channel1.Close(); - await channel1.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel1.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel1.Dispose(); }); @@ -509,7 +515,7 @@ public async Task GoAwayRejectsNewChannelsTest() } while (!res.IsCanceled && !res.IsCompleted); channel1.Close(); - await channel1.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel1.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel1.Dispose(); }); @@ -539,7 +545,7 @@ public async Task BidirectionalDataTest() await channel.Input.CopyToAsync(ms); serverReceived = ms.ToArray(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel.Dispose(); }); @@ -556,7 +562,7 @@ public async Task BidirectionalDataTest() await channel.Input.CopyToAsync(ms); clientReceived = ms.ToArray(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel.Dispose(); }); @@ -580,30 +586,39 @@ public async Task OpenChannelWaitForAckTest() serverSession.Start(); using var channel = await serverSession.AcceptAsync(); - clientOpened.Wait(10000); + clientOpened.Wait(3000); byte[] received = new byte[64]; long index = 0; - ReadResult res; - do + try { - res = await channel.Input.ReadAsync(); - if (res.Buffer.Length > 0) + ReadResult res; + do { - res.Buffer.CopyTo(received.AsMemory((int)index).Span); - index += res.Buffer.Length; - channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); - } - } while (!res.IsCanceled && !res.IsCompleted); + res = await channel.Input.ReadAsync(); + if (res.Buffer.Length > 0) + { + res.Buffer.CopyTo(received.AsMemory((int)index).Span); + index += res.Buffer.Length; + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } + } while (!res.IsCanceled && !res.IsCompleted); + } + catch (YamuxException) + { + } channel.Close(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(10)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(3)); channel.Dispose(); }); var clientTask = Task.Run(async () => { - await using var clientSession = new Session(new StreamPeer(client), true); + await using var clientSession = new Session(new StreamPeer(client), true, options: new SessionOptions + { + StreamOpenTimeout = TimeSpan.Zero + }); clientSession.Start(); using var channel = await clientSession.OpenChannelAsync(waitForAcknowledgement: true); @@ -612,9 +627,9 @@ public async Task OpenChannelWaitForAckTest() await channel.WriteAsync(new byte[64], CancellationToken.None); channel.Close(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(10)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(3)); - await Task.Delay(2000); + await Task.Delay(500); channel.Dispose(); }); @@ -635,7 +650,7 @@ public async Task ZeroByteWriteTest() await Task.Delay(500); channel.Close(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel.Dispose(); }); @@ -648,7 +663,7 @@ public async Task ZeroByteWriteTest() await channel.WriteAsync(ReadOnlyMemory.Empty, CancellationToken.None); channel.Close(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); channel.Dispose(); }); @@ -673,7 +688,7 @@ public async Task LargePayloadMultiFrameTest() result = ms.ToArray(); channel.Close(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(30)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); channel.Dispose(); }); @@ -686,7 +701,7 @@ public async Task LargePayloadMultiFrameTest() await channel.WriteAsync(data, CancellationToken.None); channel.Close(); - await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(30)); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); channel.Dispose(); }); diff --git a/test/Yamux.Tests/SocketTransportTests.cs b/test/Yamux.Tests/SocketTransportTests.cs new file mode 100644 index 0000000..373282a --- /dev/null +++ b/test/Yamux.Tests/SocketTransportTests.cs @@ -0,0 +1,262 @@ +using AwesomeAssertions; +using Bogus; +using System.Buffers; +using System.Collections.Concurrent; +using System.IO.Pipelines; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Yamux.Tests; + +public class SocketTransportTests +{ + [Fact] + public async Task SingleOneWayTcpTest() + { + var faker = new Faker(); + var data = faker.Random.Chars(count: 1024 * 256); + var buffer = Encoding.UTF8.GetBytes(data).AsMemory(); + var result = new byte[buffer.Length].AsMemory(); + + using var listener = new Socket(SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(); + var port = ((IPEndPoint)listener.LocalEndPoint!).Port; + + var serverTask = Task.Run(async () => + { + using var clientSocket = await listener.AcceptAsync(); + await using var session = clientSocket.AsYamuxSession(false); + session.Start(); + + using var channel = await session.AcceptAsync(); + + long index = 0; + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + if (res.Buffer.Length > 0) + { + res.Buffer.CopyTo(result.Slice((int)index, (int)res.Buffer.Length).Span); + index += res.Buffer.Length; + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } + } while (!res.IsCanceled && !res.IsCompleted); + + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + channel.Dispose(); + }); + + var clientTask = Task.Run(async () => + { + using var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port)); + + await using var session = sock.AsYamuxSession(true); + session.Start(); + + using var channel = await session.OpenChannelAsync(); + await channel.WriteAsync(buffer); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + }); + + await Task.WhenAll(serverTask, clientTask); + result.ToArray().Should().BeEquivalentTo(buffer.ToArray()); + } + + [Fact] + public async Task MultipleChannelsTcpTest() + { + var faker = new Faker(); + var data = faker.Random.Bytes(1024 * 128); + var channels = 10; + var results = new ConcurrentDictionary(); + + using var listener = new Socket(SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(); + var port = ((IPEndPoint)listener.LocalEndPoint!).Port; + + var serverTask = Task.Run(async () => + { + using var clientSocket = await listener.AcceptAsync(); + await using var session = clientSocket.AsYamuxSession(false); + session.Start(); + + var tasks = new List(); + for (int i = 0; i < channels; i++) + { + tasks.Add(Task.Run(async () => + { + using var channel = await session.AcceptAsync(); + var ms = new MemoryStream(); + await channel.Input.CopyToAsync(ms); + results[channel.Id] = ms.ToArray(); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(3)); + channel.Dispose(); + })); + } + + await Task.WhenAll(tasks); + }); + + var clientTask = Task.Run(async () => + { + using var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port)); + + await using var session = sock.AsYamuxSession(true); + session.Start(); + + var tasks = new List(); + for (int i = 0; i < channels; i++) + { + tasks.Add(Task.Run(async () => + { + using var channel = await session.OpenChannelAsync(); + await channel.WriteAsync(data); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(3)); + })); + } + + await Task.WhenAll(tasks); + }); + + await Task.WhenAll(serverTask, clientTask); + + results.Count.Should().Be(channels); + foreach (var kv in results) + { + kv.Value.Should().BeEquivalentTo(data); + } + } + + [Fact] + public async Task ClientDisconnectDetected() + { + using var listener = new Socket(SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(); + var port = ((IPEndPoint)listener.LocalEndPoint!).Port; + + bool errorDetected = false; + + var serverTask = Task.Run(async () => + { + try + { + using var clientSocket = await listener.AcceptAsync(); + await using var session = clientSocket.AsYamuxSession(false); + session.Start(); + + using var channel = await session.AcceptAsync(); + + try + { + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + if (res.IsCompleted) + { + break; + } + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + errorDetected = true; + } + catch (SessionException ex) when (ex.ErrorCode == SessionErrorCode.StreamClosed) + { + errorDetected = true; + } + } + catch + { + } + }); + + var clientTask = Task.Run(async () => + { + using var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port)); + + var session = sock.AsYamuxSession(true); + session.Start(); + + var channel = await session.OpenChannelAsync(); + await channel.WriteAsync(new byte[1024]); + await Task.Delay(200); + + sock.Close(); + }); + + await Task.WhenAll(serverTask, clientTask); + errorDetected.Should().BeTrue(); + } + + [Fact] + public async Task BidirectionalTcpTest() + { + var clientData = new Faker().Random.Bytes(1024 * 64); + var serverData = new Faker().Random.Bytes(1024 * 64); + + byte[]? serverReceived = null; + byte[]? clientReceived = null; + + using var listener = new Socket(SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(); + var port = ((IPEndPoint)listener.LocalEndPoint!).Port; + + var serverTask = Task.Run(async () => + { + using var clientSocket = await listener.AcceptAsync(); + await using var session = clientSocket.AsYamuxSession(false); + session.Start(); + using var channel = await session.AcceptAsync(); + + await channel.WriteAsync(serverData); + channel.Close(); + + var ms = new MemoryStream(); + await channel.Input.CopyToAsync(ms); + serverReceived = ms.ToArray(); + + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + channel.Dispose(); + }); + + var clientTask = Task.Run(async () => + { + using var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port)); + + await using var session = sock.AsYamuxSession(true); + session.Start(); + using var channel = await session.OpenChannelAsync(); + + await channel.WriteAsync(clientData); + channel.Close(); + + var ms = new MemoryStream(); + await channel.Input.CopyToAsync(ms); + clientReceived = ms.ToArray(); + + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + channel.Dispose(); + }); + + await Task.WhenAll(serverTask, clientTask); + + clientReceived.Should().NotBeNull(); + serverReceived.Should().NotBeNull(); + clientReceived.Should().BeEquivalentTo(serverData); + serverReceived.Should().BeEquivalentTo(clientData); + } +} \ No newline at end of file diff --git a/test/Yamux.Tests/StressTests.cs b/test/Yamux.Tests/StressTests.cs new file mode 100644 index 0000000..daf3ec0 --- /dev/null +++ b/test/Yamux.Tests/StressTests.cs @@ -0,0 +1,201 @@ +using AwesomeAssertions; +using Nerdbank.Streams; +using System.Collections.Concurrent; +using System.IO.Pipelines; + +namespace Yamux.Tests; + +public class StressTests +{ + [Fact] + public async Task ManyChannels_Concurrent() + { + var channels = 200; + (var client, var server) = FullDuplexStream.CreatePair(); + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + + var tasks = new List(); + for (int i = 0; i < channels; i++) + { + tasks.Add(Task.Run(async () => + { + using var channel = await session.AcceptAsync(); + try + { + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + } + catch + { + } + })); + } + + await Task.WhenAll(tasks); + }); + + var clientTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + + var tasks = new List(); + for (int i = 0; i < channels; i++) + { + tasks.Add(Task.Run(async () => + { + using var channel = await session.OpenChannelAsync(); + await channel.WriteAsync(new byte[64]); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + })); + } + + await Task.WhenAll(tasks); + }); + + await Task.WhenAll(serverTask, clientTask); + } + + [Fact] + public async Task RapidOpenClose() + { + (var client, var server) = FullDuplexStream.CreatePair(); + var iterations = 50; + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + + for (int i = 0; i < iterations; i++) + { + using var channel = await session.AcceptAsync(); + try + { + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + } + catch + { + } + } + }); + + var clientTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + + for (int i = 0; i < iterations; i++) + { + using var channel = await session.OpenChannelAsync(); + await channel.WriteAsync(new byte[16]); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(3)); + } + }); + + await Task.WhenAll(serverTask, clientTask); + } + + [Fact] + public async Task ConcurrentReadWrite_SameChannel() + { + (var client, var server) = FullDuplexStream.CreatePair(); + var dataSize = 1024 * 64; + var data = new byte[dataSize]; + new Random().NextBytes(data); + + byte[]? serverReceived = null; + byte[]? clientReceived = null; + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + using var channel = await session.AcceptAsync(); + + await channel.WriteAsync(data); + channel.Close(); + + var ms = new MemoryStream(); + await channel.Input.CopyToAsync(ms); + serverReceived = ms.ToArray(); + + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(3)); + }); + + var clientTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + using var channel = await session.OpenChannelAsync(); + + await channel.WriteAsync(data); + channel.Close(); + + var ms = new MemoryStream(); + await channel.Input.CopyToAsync(ms); + clientReceived = ms.ToArray(); + + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(3)); + }); + + await Task.WhenAll(serverTask, clientTask); + serverReceived.Should().NotBeNull(); + serverReceived.Should().BeEquivalentTo(data); + clientReceived.Should().NotBeNull(); + clientReceived.Should().BeEquivalentTo(data); + } + + [Fact] + public async Task StreamTest_AsStream_Concurrent() + { + (var client, var server) = FullDuplexStream.CreatePair(); + var data = new byte[1024 * 32]; + new Random().NextBytes(data); + byte[]? result = null; + + var serverTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(server), false); + session.Start(); + using var channel = await session.AcceptAsync(); + using var stream = channel.AsStream(); + + using var ms = new MemoryStream(data); + await ms.CopyToAsync(stream); + channel.Close(); + await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(1)); + }); + + var clientTask = Task.Run(async () => + { + await using var session = new Session(new StreamPeer(client), true); + session.Start(); + using var channel = await session.OpenChannelAsync(); + using var stream = channel.AsStream(); + + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + result = ms.ToArray(); + }); + + await Task.WhenAll(serverTask, clientTask); + result.Should().NotBeNull(); + result.Should().BeEquivalentTo(data); + } +} \ No newline at end of file diff --git a/test/Yamux.Tests/Yamux.Tests.csproj b/test/Yamux.Tests/Yamux.Tests.csproj index 2cc249c..1779c9d 100644 --- a/test/Yamux.Tests/Yamux.Tests.csproj +++ b/test/Yamux.Tests/Yamux.Tests.csproj @@ -1,7 +1,6 @@  - net9.0 enable enable false From 5d51152ece18254d17fd993b8f0098741c8b9300 Mon Sep 17 00:00:00 2001 From: Paul Bleess <8421069+pableess@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:32:38 -0500 Subject: [PATCH 2/6] doc updates --- README.md | 254 ++++++++++++++------ docs/Channels.md | 5 + docs/Exceptions.md | 5 + docs/Performance.md | 5 + docs/README.md | 202 ---------------- docs/Session.md | 5 + docs/SessionChannelOptions.md | 5 + docs/SessionOptions.md | 5 + docs/Statistics.md | 25 +- docs/Transport.md | 5 + docs/_config.yml | 23 ++ docs/getting-started.md | 172 +++++++++++++ docs/index.md | 56 +++++ src/ISessionChannel.cs | 70 +++--- src/ITransport.cs | 84 ++++--- src/Internal/FrameReader.cs | 19 +- src/Internal/ResettableValueTaskSource.cs | 25 ++ src/Internal/ReusableValueTaskSourcePool.cs | 37 --- src/Internal/SessionFrameWriter.cs | 107 +++++---- src/PipePeer.cs | 130 +++++----- src/Protocol/Enums.cs | 14 ++ src/Session.cs | 74 +++++- src/SessionChannel.cs | 132 +++++----- src/SessionChannelException.cs | 35 ++- src/SessionChannelOptions.cs | 6 + src/SessionException.cs | 47 +++- src/SessionOptions.cs | 5 + src/Statistics.cs | 6 + src/YamuxException.cs | 17 +- 29 files changed, 988 insertions(+), 587 deletions(-) delete mode 100644 docs/README.md create mode 100644 docs/_config.yml create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 src/Internal/ResettableValueTaskSource.cs delete mode 100644 src/Internal/ReusableValueTaskSourcePool.cs diff --git a/README.md b/README.md index 7e1b9b8..c3c84bc 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,225 @@ -# Yamux (dotnet) +# Yamux — .NET Multiplexing Protocol -Yamux (dotnet) is a .NET 9 library implementing the [Yamux multiplexing protocol](https://github.com/hashicorp/yamux/blob/master/spec.md), enabling multiple reliable, ordered, and independent streams (channels) over a single underlying connection (such as TCP). This is useful for building high-performance network applications, tunneling, or protocols that require multiplexed communication. +[![NuGet](https://img.shields.io/nuget/v/Yamux.svg)](https://www.nuget.org/packages/Yamux) +[![NuGet Downloads](https://img.shields.io/nuget/dt/Yamux.svg)](https://www.nuget.org/packages/Yamux) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![.NET](https://img.shields.io/badge/.NET-9.0%20|%2010.0-512BD4)](https://dotnet.microsoft.com/) +[![AOT Compatible](https://img.shields.io/badge/AOT-compatible-green)](https://learn.microsoft.com/dotnet/core/deploying/native-aot/) -## Features -- Full-duplex, multiplexed streams over a single connection -- Channel-based abstraction (`IDuplexSessionChannel`, `IReadOnlySessionChannel`, `IWriteOnlySessionChannel`) -- Configurable flow control with automatic window tuning -- Keep-alive and round-trip time (RTT) measurement -- Bandwidth and statistics tracking -- OpenTelemetry-compatible metrics via `System.Diagnostics.Metrics` -- Low allocations and high-performance design (uses `System.IO.Pipelines` to reduce buffer copies) -- AOT-compatible -- Graceful session shutdown with channel drain -- Pluggable transport layer (`Stream`, `Socket`, `IDuplexPipe`) -- .NET 9, async/await friendly - -## Getting Started - -### Install +Yamux is a **high-performance .NET library** implementing the [HashiCorp Yamux multiplexing protocol](https://github.com/hashicorp/yamux). It enables multiple reliable, ordered, independent streams (channels) over a **single** underlying connection such as TCP — drastically reducing connection overhead in distributed systems. ``` -dotnet add package Yamux --prerelease +dotnet add package Yamux ``` -### Basic Usage -```csharp -// Create a Yamux session over a stream (e.g., NetworkStream) -using var session = stream.AsYamuxSession(isClient: true, options: new SessionOptions { ... }); -session.Start(); - -// Open a new channel -using var channel = await session.OpenChannelAsync(); +## Features -// Write to the channel -await channel.WriteAsync(data, cancellationToken); +| Feature | Description | +|---------|-------------| +| **Multiplexing** | Run hundreds of logical streams over a single connection | +| **Flow Control** | Configurable receive windows with automatic bandwidth-based tuning | +| **Keep-Alive** | Built-in keep-alive pings with RTT measurement | +| **Metrics** | OpenTelemetry-compatible metrics via `System.Diagnostics.Metrics` | +| **Statistics** | Per-session and per-channel bandwidth tracking with `Sampled` events | +| **Pipelines** | Built on `System.IO.Pipelines` for high-throughput, low-allocation I/O | +| **Pluggable Transports** | Works over `Stream`, `Socket`, `IDuplexPipe`, or custom `ITransport` | +| **AOT Compatible** | Fully compatible with Native AOT publish | +| **Graceful Shutdown** | Drain channels before closing; configurable timeouts | +| **Stream API** | `AsStream()` for compatibility with stream-based code | -// Read from the channel -var result = await channel.Input.ReadAsync(cancellationToken); -``` +## Quick Start -### Server / Client Setup +### Server ```csharp -// Server side -var listener = new TcpListener(IPAddress.Loopback, 5000); -listener.Start(); -var clientSocket = await listener.AcceptSocketAsync(); -await using var session = clientSocket.AsYamuxSession(isClient: false); +using System.Net.Sockets; +using Yamux; + +var listener = new Socket(SocketType.Stream, ProtocolType.Tcp); +listener.Bind(new IPEndPoint(IPAddress.Loopback, 5000)); +listener.Listen(); +var socket = await listener.AcceptAsync(); + +await using var session = socket.AsYamuxSession(isClient: false); session.Start(); -// Accept incoming channels +// Accept and handle incoming channels var channel = await session.AcceptAsync(); -var stream = channel.AsStream(); -// Use the stream... +// read from channel.Input, write via channel.WriteAsync(...) ``` +### Client + ```csharp -// Client side -using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); -await socket.ConnectAsync(IPAddress.Loopback, 5000); +using System.Net.Sockets; +using Yamux; + +var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); +await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, 5000)); + await using var session = socket.AsYamuxSession(isClient: true); session.Start(); +// Open a new multiplexed channel var channel = await session.OpenChannelAsync(); -// Read/write via channel.Input / channel.WriteAsync +await channel.WriteAsync("Hello from Yamux!"u8.ToArray()); ``` -### Stream Wrapper +## Working with Channels + +### Opening and Accepting ```csharp -// Convert any channel to a System.IO.Stream -using var stream = channel.AsStream(); +// Client: open a channel (optionally wait for remote acknowledgement) +var channel = await session.OpenChannelAsync(waitForAcknowledgement: true); -// Works with anything that expects a Stream -await stream.WriteAsync(data); +// Server: accept an incoming channel +var channel = await session.AcceptAsync(); +``` + +### Reading and Writing + +```csharp +// Write data +await channel.WriteAsync(myData); + +// Read data via System.IO.Pipelines +var result = await channel.Input.ReadAsync(); +// process result.Buffer ... +channel.Input.AdvanceTo(result.Buffer.End); + +// Or use the stream API +var stream = channel.AsStream(); var bytesRead = await stream.ReadAsync(buffer); ``` -### Channel Options +### Closing + +```csharp +channel.Close(); // graceful FIN close +await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); +channel.Dispose(); // release resources +``` + +## Configuration + +### Session Options ```csharp -var channel = await session.OpenChannelAsync(new SessionChannelOptions +var options = new SessionOptions { - ReceiveWindowSize = 512 * 1024, // 512KB initial window - ReceiveWindowUpperBound = 8 * 1024 * 1024, // 8MB max window - MaxDataFrameSize = 32 * 1024, // 32KB max frame payload - AutoTuneReceiveWindowSize = true, -}); + EnableKeepAlive = true, + KeepAliveInterval = TimeSpan.FromSeconds(30), + MaxChannels = 1024, + EnableStatistics = true, + EnableMetrics = true, + DefaultChannelOptions = new SessionChannelOptions + { + ReceiveWindowSize = 256 * 1024, + ReceiveWindowUpperBound = 16 * 1024 * 1024, + AutoTuneReceiveWindowSize = true, + } +}; ``` -See `samples/Sample` and `samples/FileTransfer` for more complete examples. +### Channel Options + +| Property | Default | Description | +|----------|---------|-------------| +| `ReceiveWindowSize` | 256 KB | Initial receive window per channel | +| `ReceiveWindowUpperBound` | 16 MB | Maximum auto-tuned window size | +| `MaxDataFrameSize` | 16 KB | Maximum payload per data frame | +| `AutoTuneReceiveWindowSize` | `true` | Dynamically adjust window based on RTT | + +## Transports + +Yamux provides built-in transport wrappers: + +| Transport | Extension Method | Description | +|-----------|-----------------|-------------| +| `Stream` | `stream.AsYamuxSession(...)` | Wraps any `System.IO.Stream` | +| `Socket` | `socket.AsYamuxSession(...)` | Wraps `System.Net.Sockets.Socket` | +| `IDuplexPipe` | `pipe.AsYamuxSession(...)` | Wraps `System.IO.Pipelines.IDuplexPipe` | + +Implement `ITransport` for custom transports (named pipes, Unix sockets, etc.). + +## Performance -## Protocol -This library implements the [Yamux protocol specification](https://github.com/hashicorp/yamux/blob/master/spec.md) by HashiCorp. +- **Zero-copy** frame writing via `System.IO.Pipelines` +- **Pooled** `IValueTaskSource` for write completion signaling +- **Lock-free** stream ID generation +- **Async waiter** patterns avoid blocking threads +- See [Performance](docs/Performance.md) for benchmarks vs. Go Yamux and Nerdbank.Streams + +## Metrics (OpenTelemetry) + +Yamux emits metrics compatible with OpenTelemetry when `EnableMetrics` is `true`: + +| Metric | Type | Description | +|--------|------|-------------| +| `yamux.channels.opened` | Counter | Total channels opened | +| `yamux.channels.closed` | Counter | Total channels closed | +| `yamux.channels.active` | Gauge | Currently active channels | +| `yamux.bytes.sent` | Counter | Total bytes sent | +| `yamux.bytes.received` | Counter | Total bytes received | +| `yamux.frames.sent` | Counter | Total frames sent | +| `yamux.frames.received` | Counter | Total frames received | +| `yamux.errors` | Counter | Total session errors | +| `yamux.rtt.ms` | Histogram | Round-trip time in milliseconds | +| `yamux.write_queue.depth` | Gauge | Write queue depth | ## Documentation -| Topic | Description | -|-------|-------------| -| [Session](docs/Session.md) | Session lifecycle, opening/accepting channels, ping, close | -| [SessionOptions](docs/SessionOptions.md) | Session-level configuration options | -| [SessionChannelOptions](docs/SessionChannelOptions.md) | Per-channel configuration (window sizes, auto-tuning) | -| [Channels](docs/Channels.md) | ISessionChannel, IReadOnlySessionChannel, IWriteOnlySessionChannel, IDuplexSessionChannel | -| [Statistics](docs/Statistics.md) | Bandwidth tracking, send/receive rates, Sampled event | -| [Transport](docs/Transport.md) | Implementing custom transports (ITransport interface) | -| [Exceptions](docs/Exceptions.md) | YamuxException, SessionException, SessionChannelException, error codes | +| Topic | Link | +|-------|------| +| Session lifecycle | [Session](docs/Session.md) | +| Channels | [Channels](docs/Channels.md) | +| Session options | [SessionOptions](docs/SessionOptions.md) | +| Channel options | [SessionChannelOptions](docs/SessionChannelOptions.md) | +| Statistics | [Statistics](docs/Statistics.md) | +| Custom transports | [Transport](docs/Transport.md) | +| Exceptions | [Exceptions](docs/Exceptions.md) | +| Performance | [Performance](docs/Performance.md) | + +## Samples +- **[Sample](samples/Sample/)** — Interactive server/client with live Spectre.Console statistics +- **[FileTransfer](samples/FileTransfer/)** — Multi-file transfer over Yamux channels + +## Benchmarks + +```bash +dotnet run -c Release --project benchmarks/Yamux.Benchmark +``` + +## Building + +```bash +dotnet build +``` + +## Testing + +```bash +dotnet test +``` + +## Project Structure + +``` +src/ — Yamux library +├── Protocol/ — Wire protocol (frames, constants, enums) +├── Internal/ — Internal implementation +test/ — xUnit test suite +benchmarks/ — BenchmarkDotNet benchmarks +samples/ — Sample applications +docs/ — Documentation +``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -This project is licensed under the MIT License. + +MIT — see [LICENSE](LICENSE). \ No newline at end of file diff --git a/docs/Channels.md b/docs/Channels.md index 48720b6..e0ff2dc 100644 --- a/docs/Channels.md +++ b/docs/Channels.md @@ -1,3 +1,8 @@ +--- +title: Channels +nav_order: 11 +--- + # Channel Interfaces Yamux defines several interfaces for working with channels: diff --git a/docs/Exceptions.md b/docs/Exceptions.md index d5186cc..210895a 100644 --- a/docs/Exceptions.md +++ b/docs/Exceptions.md @@ -1,3 +1,8 @@ +--- +title: Exceptions +nav_order: 16 +--- + # Exceptions Yamux defines several exception types for error handling: diff --git a/docs/Performance.md b/docs/Performance.md index 8356808..acd21ef 100644 --- a/docs/Performance.md +++ b/docs/Performance.md @@ -1,3 +1,8 @@ +--- +title: Performance +nav_order: 17 +--- + # Performance ## Benchmark Results diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 636bfc6..0000000 --- a/docs/README.md +++ /dev/null @@ -1,202 +0,0 @@ -# Yamux .NET Library Documentation - -This documentation provides an overview of the public APIs for the Yamux .NET library, which implements the Yamux multiplexing protocol for .NET applications. - -## Table of Contents -- [Session](Session.md) -- [SessionChannelOptions](SessionChannelOptions.md) -- [SessionOptions](SessionOptions.md) -- [Statistics](Statistics.md) -- [ISessionChannel and Channel Interfaces](Channels.md) -- [Transport](Transport.md) -- [Exceptions](Exceptions.md) - ---- - -## Getting Started - -Yamux allows you to multiplex multiple logical streams over a single network connection, stream, or custom transport. This is useful for building efficient networked applications that require multiple independent data streams over a single stream. - -### Basic Setup Example - -```csharp -using Yamux; -using System.Net.Sockets; - -// Server side -var listener = new Socket(SocketType.Stream, ProtocolType.Tcp); -listener.Bind(new IPEndPoint(IPAddress.Loopback, 5000)); -listener.Listen(); -var clientSocket = await listener.AcceptAsync(); -using var yamuxSession = new NetworkStream(clientSocket).AsYamuxSession(false); -yamuxSession.Start(); - -// Client side -var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); -await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, 5000)); -using var yamuxSession = new NetworkStream(socket).AsYamuxSession(true); -yamuxSession.Start(); -``` - -### Working with Channels - -Once you have a Yamux session established, you can create and manage multiple channels over the same connection: - -#### Opening and Accepting Channels - -```csharp -// Opening a new channel (Client side) -var channel = await yamuxSession.OpenChannelAsync(); - -// Accepting incoming channels (Server side) -await foreach (var incomingChannel in yamuxSession.AcceptChannelsAsync()) -{ - // Process the new channel - _ = HandleChannelAsync(incomingChannel); -} -``` - -#### Reading from a Channel - -You can read data from the channel using its `Input` pipe: -(or use `AsStream()` to get a Stream interface)) - -```csharp -async Task HandleChannelAsync(ISessionChannel channel) -{ - try - { - var reader = channel.Input; - while (true) - { - var result = await reader.ReadAsync(); - var buffer = result.Buffer; - try - { - if (result.IsCanceled) - break; - // Process the data in the buffer - foreach (var segment in buffer) - { - // Work with the data... - } - if (result.IsCompleted) - break; - } - finally - { - reader.AdvanceTo(buffer.Start, buffer.End); - } - } - } - finally - { - // Properly close and dispose the channel - channel.Close(); - channel.Dispose(); - } -} -``` - -#### Writing to a Channel - -You can write to a channel WriteAsync method: - -#### Converting a Channel to a Stream - -If you prefer working with streams, you can convert a channel to a `Stream`: - -```csharp -using System.IO; - -// Convert the channel to a Stream -using var stream = channel.AsStream(); - -// Now you can use standard Stream methods -await stream.WriteAsync(buffer, 0, buffer.Length); -await stream.FlushAsync(); -``` - -#### Closing and Disposing Channels - -It is important to properly close and dispose of channels when you are done with them to free resources and signal the remote peer: - -```csharp -// Gracefully close the channel (write side) -channel.Close(); - -// either continue reading data from the input pipe until it is completed -// or you can separately wait for the remote peer to acknowledge the close -await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); - -// Dispose the channel to release resources -channel.Dispose(); -``` - -You can also use a `using` statement for automatic disposal, although if the channel is not closed before a dispose it is a forced close operation: - -```csharp -using (var channel = await yamuxSession.OpenChannelAsync()) -{ - // Use the channel - // ... - channel.Close(); -} -``` - -### Channel Options - -When opening a new channel, you can specify custom options: - -```csharp -var options = new SessionChannelOptions -{ - ReceiveWindowSize = 256 * 1024, // 256KB window size - ReceiveWindowUpperBound = 4 * 1024 * 1024 // 4MB receive window upper bound -}; - -var channel = await yamuxSession.OpenChannelAsync(options); -``` - -### Monitoring Statistics with the Sampled Event - -Yamux supports tracking statistics such as transfer speeds and throughput at both the session and channel level. To enable statistics, set the `EnableStatistics` property in `SessionOptions` when creating a session: - -```csharp -var sessionOptions = new SessionOptions -{ - EnableStatistics = true -}; - -using var yamuxSession = new NetworkStream(socket).AsYamuxSession(true, sessionOptions); -yamuxSession.Start(); -``` - -You can subscribe to the `Sampled` event on the session or on individual channels to receive periodic updates: - -```csharp -// Subscribe to session statistics -if (yamuxSession.Stats != null) -{ - yamuxSession.Stats.Sampled += (sender, args) => - { - Console.WriteLine($"[Session] Bytes sent: {yamuxSession.Stats.TotalBytesSent}, Bytes received: {yamuxSession.Stats.TotalBytesReceived}"); - Console.WriteLine($"[Session] Send rate: {yamuxSession.Stats.SendRate}/sec, Receive rate: {yamuxSession.Stats.ReceiveRate}/sec"); - }; -} - -// Subscribe to channel statistics after opening a channel -var channel = await yamuxSession.OpenChannelAsync(); -if (channel.Stats != null) -{ - channel.Stats.Sampled += (sender, args) => - { - Console.WriteLine($"[Channel] Bytes sent: {channel.Stats.TotalBytesSent}, Bytes received: {channel.Stats.TotalBytesReceived}"); - Console.WriteLine($"[Channel] Send rate: {channel.Stats.SendRate}/sec, Receive rate: {channel.Stats.ReceiveRate}/sec"); - }; -} -``` - -This allows you to monitor transfer speeds and throughput in real time for both the session and each channel. - -See the individual API documentation for more details. \ No newline at end of file diff --git a/docs/Session.md b/docs/Session.md index 7475626..082d7ca 100644 --- a/docs/Session.md +++ b/docs/Session.md @@ -1,3 +1,8 @@ +--- +title: Session +nav_order: 10 +--- + # Session The `Session` class represents a Yamux session, allowing multiple logical streams over a single connection. diff --git a/docs/SessionChannelOptions.md b/docs/SessionChannelOptions.md index a1d4d97..fc664f1 100644 --- a/docs/SessionChannelOptions.md +++ b/docs/SessionChannelOptions.md @@ -1,3 +1,8 @@ +--- +title: SessionChannelOptions +nav_order: 13 +--- + # SessionChannelOptions The `SessionChannelOptions` class configures the behavior of a Yamux session channel. diff --git a/docs/SessionOptions.md b/docs/SessionOptions.md index bb3b1d0..7d1ea50 100644 --- a/docs/SessionOptions.md +++ b/docs/SessionOptions.md @@ -1,3 +1,8 @@ +--- +title: SessionOptions +nav_order: 12 +--- + # SessionOptions The `SessionOptions` class configures a Yamux session. diff --git a/docs/Statistics.md b/docs/Statistics.md index 23cef49..23da94a 100644 --- a/docs/Statistics.md +++ b/docs/Statistics.md @@ -1,22 +1,27 @@ +--- +title: Statistics +nav_order: 14 +--- + # Statistics The `Statistics` class tracks bandwidth and byte statistics for a Yamux session. ## Properties -- `ulong TotalBytesSent` — Total bytes sent. -- `ulong TotalBytesReceived` — Total bytes received. -- `ByteSize SendRate` — Current send bandwidth (bytes/sec). -- `ByteSize ReceiveRate` — Current receive bandwidth (bytes/sec). -- `TimeSpan SampleInterval` — Sampling interval. +- `ulong TotalBytesSent` � Total bytes sent. +- `ulong TotalBytesReceived` � Total bytes received. +- `ByteSize SendRate` � Current send bandwidth (bytes/sec). +- `ByteSize ReceiveRate` � Current receive bandwidth (bytes/sec). +- `TimeSpan SampleInterval` � Sampling interval. ## Events -- `EventHandler? Sampled` — Raised when a new sample is taken. +- `EventHandler? Sampled` � Raised when a new sample is taken. ## Methods -- `Statistics(int intervalMilliseconds, CancellationToken cancel)` — Constructor. -- `void UpdateSent(ulong bytesSent)` — Updates sent bytes. -- `void UpdateReceived(ulong bytesReceived)` — Updates received bytes. -- `void Dispose()` — Disposes the statistics tracker. +- `Statistics(int intervalMilliseconds, CancellationToken cancel)` � Constructor. +- `void UpdateSent(ulong bytesSent)` � Updates sent bytes. +- `void UpdateReceived(ulong bytesReceived)` � Updates received bytes. +- `void Dispose()` � Disposes the statistics tracker. ## Example ```csharp diff --git a/docs/Transport.md b/docs/Transport.md index 90f0157..07f2a9d 100644 --- a/docs/Transport.md +++ b/docs/Transport.md @@ -1,3 +1,8 @@ +--- +title: Transport +nav_order: 15 +--- + # Custom Transport Yamux can run over any reliable, ordered, duplex transport by implementing the `ITransport` interface. diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..72f8505 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,23 @@ +remote_theme: just-the-docs/just-the-docs + +title: Yamux +description: A .NET implementation of the HashiCorp Yamux multiplexing protocol +color_scheme: dark + +aux_links: + "Yamux on GitHub": + - "https://github.com/pableess/yamux-dotnet" + "NuGet": + - "https://www.nuget.org/packages/Yamux" + +footer_content: "Copyright © Paul Bleess. Distributed under the MIT license." + +callouts: + tip: + color: green + warn: + color: yellow + note: + color: blue + danger: + color: red \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..8c3f141 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,172 @@ +--- +title: Getting Started +nav_order: 2 +--- + +# Getting Started with Yamux + +Yamux allows you to multiplex multiple logical streams over a single network connection, stream, or custom transport. This is useful for building efficient networked applications that require multiple independent data streams over a single connection. + +## Basic Setup + +### Server Side + +```csharp +using System.Net; +using System.Net.Sockets; +using Yamux; + +var listener = new Socket(SocketType.Stream, ProtocolType.Tcp); +listener.Bind(new IPEndPoint(IPAddress.Loopback, 5000)); +listener.Listen(); +var clientSocket = await listener.AcceptAsync(); + +using var yamuxSession = new NetworkStream(clientSocket).AsYamuxSession(false); +yamuxSession.Start(); +``` + +### Client Side + +```csharp +using System.Net; +using System.Net.Sockets; +using Yamux; + +var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); +await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, 5000)); + +using var yamuxSession = new NetworkStream(socket).AsYamuxSession(true); +yamuxSession.Start(); +``` + +## Working with Channels + +Once you have a Yamux session established, you can create and manage multiple channels over the same connection. + +### Opening and Accepting Channels + +```csharp +// Opening a new channel (Client side) +var channel = await yamuxSession.OpenChannelAsync(); + +// Accepting incoming channels (Server side) +var channel = await yamuxSession.AcceptAsync(); +``` + +### Reading from a Channel + +You can read data from the channel using its `Input` pipe: + +```csharp +async Task HandleChannelAsync(ISessionChannel channel) +{ + try + { + var reader = channel.Input; + while (true) + { + var result = await reader.ReadAsync(); + var buffer = result.Buffer; + try + { + if (result.IsCanceled) + break; + // Process the data in the buffer + foreach (var segment in buffer) + { + // Work with the data... + } + if (result.IsCompleted) + break; + } + finally + { + reader.AdvanceTo(buffer.Start, buffer.End); + } + } + } + finally + { + channel.Close(); + channel.Dispose(); + } +} +``` + +### Writing to a Channel + +```csharp +await channel.WriteAsync(myData); +``` + +### Converting a Channel to a Stream + +If you prefer working with streams, you can convert a channel to a `Stream`: + +```csharp +using var stream = channel.AsStream(); +await stream.WriteAsync(buffer, 0, buffer.Length); +await stream.FlushAsync(); +``` + +### Closing and Disposing Channels + +It is important to properly close and dispose of channels when you are done with them: + +```csharp +// Gracefully close the channel (write side) +channel.Close(); + +// Wait for the remote peer to acknowledge the close +await channel.WhenRemoteCloseAsync(TimeSpan.FromSeconds(5)); + +// Dispose the channel to release resources +channel.Dispose(); +``` + +You can also use a `using` statement for automatic disposal: + +```csharp +using (var channel = await yamuxSession.OpenChannelAsync()) +{ + // Use the channel + channel.Close(); +} +``` + +## Channel Options + +When opening a new channel, you can specify custom options: + +```csharp +var options = new SessionChannelOptions +{ + ReceiveWindowSize = 256 * 1024, // 256KB window size + ReceiveWindowUpperBound = 4 * 1024 * 1024 // 4MB receive window upper bound +}; + +var channel = await yamuxSession.OpenChannelAsync(options); +``` + +## Monitoring Statistics + +Enable statistics on the session: + +```csharp +var sessionOptions = new SessionOptions +{ + EnableStatistics = true +}; + +using var yamuxSession = new NetworkStream(socket).AsYamuxSession(true, sessionOptions); +yamuxSession.Start(); + +// Subscribe to session statistics +if (yamuxSession.Stats != null) +{ + yamuxSession.Stats.Sampled += (sender, args) => + { + Console.WriteLine($"Send rate: {yamuxSession.Stats.SendRate}/sec, Receive rate: {yamuxSession.Stats.ReceiveRate}/sec"); + }; +} +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..09849d7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,56 @@ +--- +layout: home +title: Home +nav_order: 1 +--- + +# Yamux .NET Library + +A high-performance .NET implementation of the [HashiCorp Yamux multiplexing protocol](https://github.com/hashicorp/yamux). Yamux enables multiple reliable, ordered, independent streams (channels) over a **single** underlying connection. + +## Quick Links + +| Topic | Link | +|-------|------| +| Getting Started | [Getting Started](getting-started.md) | +| Session | [Session](Session.md) | +| SessionOptions | [SessionOptions](SessionOptions.md) | +| SessionChannelOptions | [SessionChannelOptions](SessionChannelOptions.md) | +| Channels | [Channels](Channels.md) | +| Statistics | [Statistics](Statistics.md) | +| Transport | [Transport](Transport.md) | +| Exceptions | [Exceptions](Exceptions.md) | +| Performance | [Performance](Performance.md) | + +## Features + +- **Full-duplex multiplexing** — hundreds of logical streams over a single connection +- **Channel-based abstraction** — `IDuplexSessionChannel`, `IReadOnlySessionChannel`, `IWriteOnlySessionChannel` +- **Configurable flow control** — automatic window tuning based on RTT +- **Keep-alive and RTT** — dead connection detection and round-trip measurement +- **Bandwidth statistics** — per-session and per-channel tracking with `Sampled` events +- **OpenTelemetry metrics** — `System.Diagnostics.Metrics` integration +- **High performance** — built on `System.IO.Pipelines`, pooled value task sources, lock-free ID generation +- **AOT compatible** — supports Native AOT publish +- **Graceful shutdown** — channel drain with configurable timeouts +- **Pluggable transports** — `Stream`, `Socket`, `IDuplexPipe`, or custom `ITransport` + +## Quick Example + +```csharp +// Server +await using var session = socket.AsYamuxSession(isClient: false); +session.Start(); +var channel = await session.AcceptAsync(); + +// Client +await using var session = socket.AsYamuxSession(isClient: true); +session.Start(); +var channel = await session.OpenChannelAsync(); +``` + +## Installation + +```bash +dotnet add package Yamux +``` \ No newline at end of file diff --git a/src/ISessionChannel.cs b/src/ISessionChannel.cs index 8de52cd..ecb7b50 100644 --- a/src/ISessionChannel.cs +++ b/src/ISessionChannel.cs @@ -3,111 +3,97 @@ namespace Yamux; /// -/// Represents a Yamux session channel (logical stream) multiplexed over a single connection. +/// Represents a single multiplexed channel within a Yamux session. +/// Provides the core operations for channel lifecycle management. /// public interface ISessionChannel : IDisposable, IAsyncDisposable { /// - /// Gets the unique ID for this channel. + /// Gets the unique identifier for this channel. + /// Client-initiated channels use odd IDs; server-initiated channels use even IDs. /// public uint Id { get; } /// - /// Gets whether the channel has been fully closed. + /// Gets whether the channel has been fully closed (both read and write sides). /// public bool IsClosed { get; } /// - /// Aborts the channel immediately, sending a RST to the remote peer if the channel is not already closed. + /// Forcibly terminates the channel by sending a RST frame to the remote peer. + /// Unlike , this bypasses the graceful FIN handshake. /// public void Abort(); /// - /// Closes the channel for writing, sending a FIN to the remote peer. - /// More data may still be read from the channel until the remote peer acknowledges the close. - /// To wait for the remote peer's acknowledgment, continue reading until the pipe is completed, - /// or call or . + /// Gracefully closes the write side of the channel by sending a FIN frame. + /// The read side remains open until the remote peer closes their side. /// public void Close(); /// - /// Waits until the remote peer has closed the channel. + /// Waits for the remote peer to close their side of the channel, with a timeout. /// - /// The maximum amount of time to wait. - /// true if the remote peer closed the channel; false if the operation timed out. - public bool WaitForRemoteClose(TimeSpan timeout); - - /// - /// Returns a task that completes when the remote peer has closed the channel. - /// Use with caution, as the remote peer may fail to send a proper close acknowledgment. - /// - /// The maximum amount of time to wait. - /// true if the remote peer closed the channel; false if the timeout was reached. + /// The maximum time to wait for the remote close. + /// true if the remote peer acknowledged the close within the timeout; otherwise false. public Task WhenRemoteCloseAsync(TimeSpan timeout); /// - /// Waits until the remote peer has acknowledged the channel open. - /// Only useful when a channel was accepted without waiting for acknowledgment. - /// - /// The maximum amount of time to wait. - /// true if the remote peer acknowledged; false if the operation timed out. - public bool WaitForRemoteAck(TimeSpan timeout); - - /// - /// Returns a task that completes when the remote peer has acknowledged the channel. - /// Use with caution, as the remote peer may fail to send a proper acknowledgment. + /// Waits for the remote peer to acknowledge this channel (SYN/ACK handshake), with a timeout. /// - /// The maximum amount of time to wait. - /// true if the remote peer acknowledged before the timeout; false if the timeout was reached. + /// The maximum time to wait for the remote acknowledgement. + /// true if the remote peer acknowledged within the timeout; otherwise false. public Task WhenRemoteAckAsync(TimeSpan timeout); /// - /// Ensures that all written data has been flushed to the underlying transport. + /// Flushes any buffered writes to the underlying transport. /// /// A cancellation token to cancel the flush operation. + /// A representing the asynchronous flush operation. public ValueTask FlushWritesAsync(CancellationToken cancellationToken = default); /// - /// Gets the statistics for the channel, if statistics gathering is enabled. + /// Gets the bandwidth and byte statistics for this channel, if enabled. /// public Statistics? Stats { get; } } /// -/// Represents a session channel that supports writing data to the remote peer. +/// Represents a channel that supports write operations. /// public interface IWriteOnlySessionChannel : ISessionChannel { /// - /// Writes data to the channel. The task will not complete until all data has been - /// passed to the underlying transport, which may require waiting for window updates from the remote peer. - /// This is not an atomic operation — partial data may be written in the case of a failure. + /// Writes data to the channel. The data will be framed and sent to the remote peer. + /// Respects the remote peer's flow control window. /// /// The data to write. /// A cancellation token to cancel the write operation. + /// A representing the asynchronous write operation. public ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); } /// -/// Represents a session channel that supports reading data from the remote peer. +/// Represents a channel that supports read operations via a . /// public interface IReadOnlySessionChannel : ISessionChannel { /// - /// Gets the for reading data from this channel. + /// Gets the for reading data from this channel. /// public PipeReader Input { get; } } /// -/// Represents a full-duplex session channel that supports both reading and writing. +/// Represents a full-duplex channel that supports both read and write operations. +/// This is the primary channel type used in Yamux sessions. /// public interface IDuplexSessionChannel : ISessionChannel, IWriteOnlySessionChannel, IReadOnlySessionChannel { /// - /// Creates a wrapper for reading from and writing to this channel. + /// Wraps the channel as a for compatibility with stream-based APIs. /// - /// Whether to leave the channel open when the stream is disposed. + /// If true, the underlying channel is not disposed when the stream is disposed. /// A that reads from and writes to this channel. public Stream AsStream(bool leaveOpen = false); } diff --git a/src/ITransport.cs b/src/ITransport.cs index 9de5982..03179cd 100644 --- a/src/ITransport.cs +++ b/src/ITransport.cs @@ -1,41 +1,63 @@ -using System; -using System.Buffers; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Buffers; namespace Yamux +{ +/// +/// Represents a bidirectional transport layer that can be used by a Yamux session. +/// Implementations wrap stream-oriented transports such as TCP sockets, named pipes, +/// or in-memory pipes. +/// +/// +/// The transport must provide reliable, ordered, full-duplex byte delivery. +/// Partial reads are handled internally by the session, so implementations may return +/// fewer bytes than requested. +/// +public interface ITransport : IDisposable { /// - /// Adapter interface for sending and receiving data to and from the yamux peer. + /// Reads data from the transport into the provided buffer. /// - public interface ITransport : IDisposable - { - /// - /// Reads data from the peer and copies it to the provided buffer. - /// - /// The destination buffer. - /// A cancellation token to cancel the read operation. - /// The number of bytes read, or 0 if the connection was closed. - public ValueTask ReadAsync(Memory data, CancellationToken cancellationToken); + /// The buffer to fill with read data. + /// A cancellation token to cancel the read operation. + /// The number of bytes read. 0 indicates the transport has been closed by the remote peer. + ValueTask ReadAsync(Memory data, CancellationToken cancellationToken); + + /// + /// Writes data to the transport. + /// + /// The data to write. + /// A cancellation token to cancel the write operation. + /// A representing the asynchronous write operation. + ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken); + + /// + /// Closes the transport. After calling this, no further reads or writes should be attempted. + /// + void Close(); - /// - /// Sends raw data to the peer. - /// - /// The data to send. - /// A cancellation token to cancel the write operation. - public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken); + /// + /// Flushes any buffered data to the underlying transport. A default no-op implementation is provided. + /// + /// A cancellation token to cancel the flush operation. + /// A representing the asynchronous flush operation. + ValueTask FlushAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - /// - /// Closes the peer connection. - /// - public void Close(); + /// + /// Writes a sequence of byte segments to the transport. The default implementation + /// iterates over the segments and calls for each. + /// + /// The sequence of byte segments to write. + /// A cancellation token to cancel the write operation. + /// A representing the asynchronous write operation. + ValueTask WriteAsync(ReadOnlySequence data, CancellationToken cancellationToken = default) + { + return WriteSequenceAsync(this, data, cancellationToken); - /// - /// Flushes any buffered data to the underlying transport. - /// - /// A cancellation token to cancel the flush operation. - public ValueTask FlushAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + static async ValueTask WriteSequenceAsync(ITransport transport, ReadOnlySequence data, CancellationToken ct) + { + foreach (var segment in data) + await transport.WriteAsync(segment, ct).ConfigureAwait(false); + } } } +} diff --git a/src/Internal/FrameReader.cs b/src/Internal/FrameReader.cs index 1787e48..4416104 100644 --- a/src/Internal/FrameReader.cs +++ b/src/Internal/FrameReader.cs @@ -188,6 +188,8 @@ private async ValueTask ReadPayloadData(uint payloadLength, SessionChannel? chan { try { + await channel.WaitForPendingFlushAsync().ConfigureAwait(false); + var pipeWriter = channel.GetPipeWriter(); var buffer = pipeWriter.GetMemory(); @@ -207,14 +209,19 @@ private async ValueTask ReadPayloadData(uint payloadLength, SessionChannel? chan bytesToRead -= read; - var flushResult = await pipeWriter.FlushAsync(cancellationToken).ConfigureAwait(false); + var flushTask = pipeWriter.FlushAsync(cancellationToken); - if (flushResult.IsCompleted) + if (flushTask.IsCompletedSuccessfully) { - channel.CloseWrite(); - - await pipeWriter.CompleteAsync(); - + if (flushTask.Result.IsCompleted) + { + channel.CloseWrite(); + await pipeWriter.CompleteAsync().ConfigureAwait(false); + } + } + else + { + channel.OffloadPipeFlush(flushTask, pipeWriter); } } catch (InvalidOperationException) diff --git a/src/Internal/ResettableValueTaskSource.cs b/src/Internal/ResettableValueTaskSource.cs new file mode 100644 index 0000000..bea57e2 --- /dev/null +++ b/src/Internal/ResettableValueTaskSource.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks.Sources; + +namespace Yamux.Internal; + +internal sealed class ResettableValueTaskSource : IValueTaskSource +{ + private ManualResetValueTaskSourceCore _core = new() { RunContinuationsAsynchronously = false }; + + public short Version => _core.Version; + + public void Reset() => _core.Reset(); + + public void SetResult() => _core.SetResult(0); + + public void SetException(Exception ex) => _core.SetException(ex); + + public ValueTask GetValueTask() => new ValueTask(this, _core.Version); + + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => _core.GetStatus(token); + + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + => _core.OnCompleted(continuation, state, token, flags); + + void IValueTaskSource.GetResult(short token) => _core.GetResult(token); +} diff --git a/src/Internal/ReusableValueTaskSourcePool.cs b/src/Internal/ReusableValueTaskSourcePool.cs deleted file mode 100644 index 1707381..0000000 --- a/src/Internal/ReusableValueTaskSourcePool.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Concurrent; - -namespace Yamux.Internal; - -internal sealed class ReusableValueTaskSourcePool -{ - private readonly ConcurrentQueue _queue = new(); - private const int MaxPoolSize = 1024; - private int _count; - - public TaskCompletionSource Rent() - { - if (_queue.TryDequeue(out var item)) - { - Interlocked.Decrement(ref _count); - return item; - } - - return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - - public void Return(TaskCompletionSource item) - { - if (item.Task.IsCompleted) - { - if (Interlocked.Increment(ref _count) <= MaxPoolSize) - { - item.TrySetResult(); - _queue.Enqueue(item); - } - else - { - Interlocked.Decrement(ref _count); - } - } - } -} \ No newline at end of file diff --git a/src/Internal/SessionFrameWriter.cs b/src/Internal/SessionFrameWriter.cs index 777c4d6..1a14069 100644 --- a/src/Internal/SessionFrameWriter.cs +++ b/src/Internal/SessionFrameWriter.cs @@ -1,36 +1,30 @@ -using System.Buffers; -using System.Diagnostics; +using System.Diagnostics; using System.Threading.Channels; using Yamux.Protocol; namespace Yamux.Internal; - -/// -/// Serializes frame writes to the underlying transport. This is necessary because the underlying transport may not be thread-safe for concurrent writes, and we want to ensure that frames are written in the order they are enqueued. -/// internal class SessionFrameWriter { -private readonly ITransport _peer; - private readonly Channel<(Frame frame, TaskCompletionSource tcs)> _writeQueue; + private readonly ITransport _peer; + private readonly Channel _writeQueue; private readonly Statistics? _stats; private YamuxMetrics? _metrics; private Task? _runTask; private readonly TimeSpan _connectionWriteTimeout; - private readonly ReusableValueTaskSourcePool _tcsPool = new(); private readonly SemaphoreSlim _flushLock = new SemaphoreSlim(1, 1); internal void SetMetrics(YamuxMetrics? metrics) => _metrics = metrics; -public SessionFrameWriter(ITransport connection, Statistics? stats, TimeSpan connectionWriteTimeout) + public SessionFrameWriter(ITransport connection, Statistics? stats, TimeSpan connectionWriteTimeout, int writeQueueDepth = 100) { _peer = connection ?? throw new ArgumentNullException(nameof(connection)); _stats = stats; _metrics = null; _connectionWriteTimeout = connectionWriteTimeout; - _writeQueue = Channel.CreateBounded<(Frame, TaskCompletionSource)>(new BoundedChannelOptions(100) + _writeQueue = Channel.CreateBounded(new BoundedChannelOptions(writeQueueDepth) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true, @@ -49,41 +43,37 @@ public void Start() { if (_writeQueue.Reader.TryRead(out var item)) { - using var _ = item.frame; + using var _ = item.Frame; -try + try { - item.frame.Header.WriteTo(headerBuffer); + item.Frame.Header.WriteTo(headerBuffer); if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) - Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: writing frame - {0}, payload size = {1}", item.frame.Header.FrameType, item.frame.Header.Length); + Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: writing frame - {0}, payload size = {1}", item.Frame.Header.FrameType, item.Frame.Header.Length); - await _peer.WriteAsync(headerBuffer, default).ConfigureAwait(false); + await _peer.WriteAsync(headerBuffer.AsMemory(0, FrameHeader.FrameHeaderSize), default).ConfigureAwait(false); _metrics?.FramesSent.Add(1); - if (!item.frame.Payload.IsEmpty) + if (!item.Frame.Payload.IsEmpty) { - await _peer.WriteAsync(item.frame.Payload, default).ConfigureAwait(false); - _stats?.UpdateSent((uint)item.frame.Payload.Length); - _metrics?.BytesSent.Add(item.frame.Payload.Length); + await _peer.WriteAsync(item.Frame.Payload, default).ConfigureAwait(false); + _stats?.UpdateSent((uint)item.Frame.Payload.Length); + _metrics?.BytesSent.Add(item.Frame.Payload.Length); } - item.tcs.TrySetResult(); + item.Complete(); } catch (OperationCanceledException cancelEx) { if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: write operation canceled - {0}", cancelEx.Message); - item.tcs.TrySetException(cancelEx); + item.Fault(cancelEx); } catch (Exception ex) { if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); - item.tcs.TrySetException(ex); - } - finally - { - _tcsPool.Return(item.tcs); + item.Fault(ex); } } } @@ -99,55 +89,54 @@ public void Start() }); } -public async ValueTask WriteAsync(Frame frame, CancellationToken cancellationToken) + public async ValueTask WriteAsync(Frame frame, CancellationToken cancellationToken) { - var tcs = _tcsPool.Rent(); + var source = new ResettableValueTaskSource(); + var completionTask = source.GetValueTask(); + var item = new WriteItem(frame, source); if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Enqueuing frame for write - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); - using var timeoutCts = new CancellationTokenSource(_connectionWriteTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - await _writeQueue.Writer.WriteAsync((frame, tcs), linkedCts.Token).ConfigureAwait(false); + await _writeQueue.Writer.WriteAsync(item, cancellationToken) + .AsTask() + .WaitAsync(_connectionWriteTimeout, cancellationToken) + .ConfigureAwait(false); if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Frame enqueued for write - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); - await tcs.Task.ConfigureAwait(false); + await completionTask.ConfigureAwait(false); + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: Write completed for frame - {0}, payload size = {1}", frame.Header.FrameType, frame.Header.Length); } public void EnqueueFrame(Frame frame) { - var tcs = _tcsPool.Rent(); + var item = new WriteItem(frame, null); - if (_writeQueue.Writer.TryWrite((frame, tcs))) + if (_writeQueue.Writer.TryWrite(item)) { return; } - _ = EnqueueAsync(frame, tcs); + _ = EnqueueAsync(item); } -private async Task EnqueueAsync(Frame frame, TaskCompletionSource tcs) + private async Task EnqueueAsync(WriteItem item) { try { - using var timeoutCts = new CancellationTokenSource(_connectionWriteTimeout); - await _writeQueue.Writer.WriteAsync((frame, tcs), timeoutCts.Token).ConfigureAwait(false); - await tcs.Task.ConfigureAwait(false); + await _writeQueue.Writer.WriteAsync(item, default) + .AsTask() + .WaitAsync(_connectionWriteTimeout) + .ConfigureAwait(false); } catch (Exception ex) { if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: EnqueueFrame write failed: {0}", ex.Message); - tcs.TrySetException(ex); - } - finally - { - _tcsPool.Return(tcs); } } @@ -173,4 +162,28 @@ public async Task StopAsync() await _runTask.ConfigureAwait(false); } } -} \ No newline at end of file + + internal int WriteQueueDepth => _writeQueue.Reader.Count; + + private readonly struct WriteItem + { + public readonly Frame Frame; + public readonly ResettableValueTaskSource? Completion; + + public WriteItem(Frame frame, ResettableValueTaskSource? completion) + { + Frame = frame; + Completion = completion; + } + + public void Complete() + { + Completion?.SetResult(); + } + + public void Fault(Exception ex) + { + Completion?.SetException(ex); + } + } +} diff --git a/src/PipePeer.cs b/src/PipePeer.cs index 4b9fcfa..f2ba4eb 100644 --- a/src/PipePeer.cs +++ b/src/PipePeer.cs @@ -1,84 +1,86 @@ +using System.Buffers; using System.IO.Pipelines; namespace Yamux { +/// +/// An implementation that wraps a . +/// Useful for in-memory multiplexing or testing scenarios. +/// +public class PipePeer : ITransport +{ + private readonly PipeReader _reader; + private readonly PipeWriter _writer; + /// - /// An implementation that wraps an . + /// Initializes a new instance of the class. /// - public class PipePeer : ITransport + /// The duplex pipe to use for transport. + /// Thrown when is null. + public PipePeer(IDuplexPipe pipe) { - private readonly PipeReader _reader; - private readonly PipeWriter _writer; + ArgumentNullException.ThrowIfNull(pipe); + _reader = pipe.Input; + _writer = pipe.Output; + } - /// - /// Initializes a new instance of the class. - /// - /// The duplex pipe to use for transport. - /// Thrown when is null. - public PipePeer(IDuplexPipe pipe) + /// + public async ValueTask ReadAsync(Memory data, CancellationToken cancellationToken) + { + if (data.IsEmpty) + return 0; + + var result = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); + var buffer = result.Buffer; + + if (buffer.IsEmpty && result.IsCompleted) { - ArgumentNullException.ThrowIfNull(pipe); - _reader = pipe.Input; - _writer = pipe.Output; + _reader.AdvanceTo(buffer.End); + return 0; } - /// - public async ValueTask ReadAsync(Memory data, CancellationToken cancellationToken) + var len = (int)Math.Min(buffer.Length, data.Length); + var remaining = len; + foreach (var segment in buffer) { - if (data.IsEmpty) - return 0; - - var result = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); - var buffer = result.Buffer; - - if (buffer.IsEmpty && result.IsCompleted) - { - _reader.AdvanceTo(buffer.End); - return 0; - } - - var len = (int)Math.Min(buffer.Length, data.Length); - var remaining = len; - foreach (var segment in buffer) - { - if (remaining <= 0) - break; - var toCopy = Math.Min(segment.Length, remaining); - segment.Span[..toCopy].CopyTo(data.Span[(len - remaining)..]); - remaining -= toCopy; - } - _reader.AdvanceTo(buffer.GetPosition(len)); - - return len; + if (remaining <= 0) + break; + var toCopy = Math.Min(segment.Length, remaining); + segment.Span[..toCopy].CopyTo(data.Span[(len - remaining)..]); + remaining -= toCopy; } + _reader.AdvanceTo(buffer.GetPosition(len)); - /// - public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) - { - if (data.IsEmpty) - return; + return len; + } - await _writer.WriteAsync(data, cancellationToken).ConfigureAwait(false); - } + /// + public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + if (data.IsEmpty) + return; - /// - public async ValueTask FlushAsync(CancellationToken cancellationToken = default) - { - await _writer.FlushAsync(cancellationToken).ConfigureAwait(false); - } + await _writer.WriteAsync(data, cancellationToken).ConfigureAwait(false); + } - /// - public void Close() - { - _reader.Complete(); - _writer.Complete(); - } + /// + public async ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + await _writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } - /// - public void Dispose() - { - _reader.Complete(); - _writer.Complete(); - } + /// + public void Close() + { + _reader.Complete(); + _writer.Complete(); } + + /// + public void Dispose() + { + _reader.Complete(); + _writer.Complete(); + } +} } diff --git a/src/Protocol/Enums.cs b/src/Protocol/Enums.cs index 5cf0853..dfd18d7 100644 --- a/src/Protocol/Enums.cs +++ b/src/Protocol/Enums.cs @@ -1,9 +1,23 @@ namespace Yamux.Protocol; +/// +/// Defines the reason codes for session termination sent in GoAway frames. +/// public enum SessionTermination : uint { + /// + /// Normal session termination. + /// Normal = 0x0, + + /// + /// Session terminated due to a protocol error. + /// ProtocolError = 0x1, + + /// + /// Session terminated due to an internal error. + /// InternalError = 0x2, } diff --git a/src/Session.cs b/src/Session.cs index 673f7ea..2808a2d 100644 --- a/src/Session.cs +++ b/src/Session.cs @@ -53,7 +53,7 @@ public Session(ITransport transport, bool isClient, bool leaveOpen = false, Sess } _channelManager = new ChannelManager(this, _sessionOptions.DefaultChannelOptions, _sessionOptions.AcceptBacklog, null, _sessionOptions.MaxChannels); - _writer = new SessionFrameWriter(_transport, Stats, _sessionOptions.ConnectionWriteTimeout); + _writer = new SessionFrameWriter(_transport, Stats, _sessionOptions.ConnectionWriteTimeout, _sessionOptions.WriteQueueDepth); _frameReader = new FrameReader( new ConnectionReader(_transport), _channelManager, @@ -70,7 +70,7 @@ public Session(ITransport transport, bool isClient, bool leaveOpen = false, Sess sessionId: isClient ? "client" : "server", isClient: isClient, getActiveChannels: () => _channelManager.ActiveChannelCount, - getWriteQueueDepth: () => 0); + getWriteQueueDepth: () => _writer.WriteQueueDepth); _channelManager.SetMetrics(Metrics); _frameReader.SetMetrics(Metrics); @@ -78,10 +78,25 @@ public Session(ITransport transport, bool isClient, bool leaveOpen = false, Sess } } + /// + /// Gets the current round-trip time to the remote peer, measured by keep-alive pings. + /// null if no ping has completed yet. + /// public TimeSpan? RTT { get; private set; } + /// + /// Gets the bandwidth and byte statistics for this session, if enabled via . + /// public Statistics? Stats { get; private set; } + /// + /// Opens a new channel with the specified options. + /// + /// The channel options to apply. + /// If true, the returned task completes only after the remote peer acknowledges the new channel. + /// A cancellation token to cancel the open operation. + /// A representing the asynchronous operation, with the opened channel. + /// Thrown when the session is in GoAway state or channels have been exhausted. public ValueTask OpenChannelAsync(SessionChannelOptions options, bool waitForAcknowledgement = false, CancellationToken cancellationToken = default) { if (!_channelManager.CanAcceptNew) @@ -137,13 +152,35 @@ public ValueTask OpenChannelAsync(SessionChannelOptions o return ValueTask.FromResult((IDuplexSessionChannel)channel); } + /// + /// Opens a new channel with the default session options. + /// + /// If true, the returned task completes only after the remote peer acknowledges the new channel. + /// A cancellation token to cancel the open operation. + /// A representing the asynchronous operation, with the opened channel. public ValueTask OpenChannelAsync(bool waitForAcknowledgement = false, CancellationToken cancellationToken = default) => this.OpenChannelAsync(_sessionOptions.DefaultChannelOptions, waitForAcknowledgement, cancellationToken); + /// + /// Accepts an incoming channel from the remote peer. + /// + /// A cancellation token to cancel the accept operation. + /// A representing the asynchronous operation, with the accepted channel. public ValueTask AcceptAsync(CancellationToken cancellationToken = default) => AcceptChannelAsync(_sessionOptions.DefaultChannelOptions, cancellationToken); + /// + /// Accepts an incoming channel with custom channel options. + /// + /// The channel options to apply to the accepted channel. + /// A cancellation token to cancel the accept operation. + /// A representing the asynchronous operation, with the accepted channel. public ValueTask AcceptAsync(SessionChannelOptions channelOptions, CancellationToken cancellationToken) => AcceptChannelAsync(channelOptions, cancellationToken); + /// + /// Accepts an incoming channel as a read-only channel. + /// + /// A cancellation token to cancel the accept operation. + /// A representing the asynchronous operation, with the accepted read-only channel. public async ValueTask AcceptReadOnlyChannelAsync(CancellationToken cancellationToken = default) { var channel = await AcceptChannelAsync(_sessionOptions.DefaultChannelOptions, cancellationToken); @@ -172,11 +209,21 @@ private async ValueTask AcceptChannelAsync(SessionChannel } } + /// + /// Sends a ping to the remote peer and measures the round-trip time. + /// + /// A cancellation token to cancel the ping operation. + /// A representing the asynchronous operation, with the measured round-trip time. public async ValueTask PingAsync(CancellationToken cancellationToken) { return await _pingManager.PingAsync(_writer, cancellationToken); } + /// + /// Starts the session, beginning frame reading, writing, and optional keep-alive pings. + /// Must be called before opening or accepting channels. + /// + /// Thrown if the session has already been closed. public void Start() { _sessionOptions.DefaultChannelOptions?.Validate(); @@ -198,15 +245,34 @@ public void Start() _started = true; } + /// + /// Gets whether the session has been closed. + /// public bool IsClosed { get; private set; } + /// + /// Gracefully closes the session, draining all open channels before disposing the transport. + /// + /// A representing the asynchronous close operation. public Task CloseAsync() => this.CloseAsync(null); + /// + /// Sends a GoAway notification and waits for all open channels to close gracefully within the specified timeout. + /// + /// The maximum time to wait for channels to close. + /// true if all channels closed within the timeout; otherwise false. public async Task CloseOpenChannelsAsync(TimeSpan timeout) { return await _channelManager.CloseOpenChannelsAsync(timeout); } + /// + /// Sends a GoAway frame to the remote peer, indicating this session will no longer accept new channels. + /// Existing channels continue to operate until closed. + /// + /// The reason for the GoAway. + /// A cancellation token to cancel the operation. + /// A representing the asynchronous operation. public async Task GoAwayAsync(SessionTermination sessionTermination = SessionTermination.Normal, CancellationToken cancellationToken = default) { try @@ -274,6 +340,10 @@ private async Task CloseAsync(SessionException? err = null) } } + /// + /// Disposes the session asynchronously, closing it and releasing all resources. + /// + /// A representing the asynchronous dispose operation. public async ValueTask DisposeAsync() { if (!_disposed) diff --git a/src/SessionChannel.cs b/src/SessionChannel.cs index 7b5b08a..388f52b 100644 --- a/src/SessionChannel.cs +++ b/src/SessionChannel.cs @@ -40,15 +40,14 @@ internal class SessionChannel : IDuplexSessionChannel private Lock _receiveWindowLock = new Lock(); private Lock _stateLock = new Lock(); - private ManualResetEventSlim _remoteCloseEvent; private TaskCompletionSource _remoteCloseTask; - private ManualResetEventSlim _remoteAckEvent; private TaskCompletionSource _remoteAckTask; - private Timer? _closeTimer; + private CancellationTokenSource? _closeTimeoutCts; private uint _receiveWindowMax; private volatile bool _disposed; private YamuxException? _fault; + private ValueTask? _pendingPipeFlush; private ChannelLocalPhase _localPhase; private ChannelRemoteState _remoteState; @@ -104,9 +103,7 @@ internal SessionChannel(IChannelSessionAdapter session, uint id, SessionChannelO _inputBuffer = new Pipe(new PipeOptions(pauseWriterThreshold: _channelOptions.ReceiveWindowUpperBound + 1)); - _remoteCloseEvent = new ManualResetEventSlim(false); _remoteCloseTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _remoteAckEvent = new ManualResetEventSlim(false); _remoteAckTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _remoteWindow = new RemoteDataWindow(); @@ -266,21 +263,8 @@ public async Task WhenRemoteAckAsync(TimeSpan timeout) return task == _remoteAckTask.Task; } - public bool WaitForRemoteClose(TimeSpan timeout) - { - this.ThrowIfDisposed(); - return this._remoteCloseEvent.Wait(timeout); - } - - public bool WaitForRemoteAck(TimeSpan timeout) - { - this.ThrowIfDisposed(); - return this._remoteAckEvent.Wait(timeout); - } - public void Dispose() { - Timer? timerToDispose = null; lock (this._stateLock) { if (!this._disposed) @@ -289,19 +273,13 @@ public void Dispose() _writeClosedCancellation.Cancel(); - if (_closeTimer != null) - { - _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); - timerToDispose = _closeTimer; - _closeTimer = null; - } + CancelCloseTimeout(); if (_remoteState < ChannelRemoteState.ReadClosed) { _localPhase = ChannelLocalPhase.WriteClosed; _remoteState = ChannelRemoteState.Reset; _inputBuffer.Writer.Complete(); - _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); } @@ -314,7 +292,6 @@ public void Dispose() Stats = null; } } - timerToDispose?.Dispose(); } public ValueTask DisposeAsync() @@ -343,6 +320,16 @@ internal void Accept() } } + private void CancelCloseTimeout() + { + if (_closeTimeoutCts != null) + { + _closeTimeoutCts.Cancel(); + _closeTimeoutCts.Dispose(); + _closeTimeoutCts = null; + } + } + internal void RemoteAckReceived() { lock (_stateLock) @@ -350,7 +337,6 @@ internal void RemoteAckReceived() if (_remoteState == ChannelRemoteState.None) { _remoteState = ChannelRemoteState.Open; - _remoteAckEvent.Set(); _remoteAckTask.TrySetResult(); } } @@ -407,15 +393,25 @@ internal void CloseWrite() { if (_remoteState < ChannelRemoteState.ReadClosed) { - _closeTimer = new Timer(_ => - { - ForceCloseTimeout(); - }, null, closeTimeout, Timeout.InfiniteTimeSpan); + _closeTimeoutCts = new CancellationTokenSource(); + var cts = _closeTimeoutCts; + _ = CloseTimeoutAsync(closeTimeout, cts); } } } } + private async Task CloseTimeoutAsync(TimeSpan timeout, CancellationTokenSource cts) + { + try + { + await Task.Delay(timeout, cts.Token).ConfigureAwait(false); + ForceCloseTimeout(); + } + catch (OperationCanceledException) { } + finally { cts.Dispose(); } + } + private void ForceCloseTimeout() { lock (_stateLock) @@ -428,7 +424,6 @@ private void ForceCloseTimeout() _writeClosedCancellation.Cancel(); _inputBuffer.Writer.Complete(_fault ?? new SessionChannelException(ChannelErrorCode.ChannelClosed, "Stream close timeout exceeded")); - _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); } @@ -487,7 +482,6 @@ private Flags GetSendFlags() private void ProcessIncomingFlags(Flags flags) { - Timer? timerToDispose = null; lock (_stateLock) { if (_disposed) @@ -506,7 +500,6 @@ private void ProcessIncomingFlags(Flags flags) { _remoteState = ChannelRemoteState.Open; _session.ChannelAcknowledge(this, true); - _remoteAckEvent.Set(); _remoteAckTask.TrySetResult(); } } @@ -515,62 +508,73 @@ private void ProcessIncomingFlags(Flags flags) { if (_remoteState < ChannelRemoteState.ReadClosed) { - if (_closeTimer != null) - { - _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); - timerToDispose = _closeTimer; - _closeTimer = null; - } + CancelCloseTimeout(); _remoteState = ChannelRemoteState.ReadClosed; _inputBuffer.Writer.Complete(_fault); - _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); } } if (flags.HasFlag((Flags)Flags.RST)) { - if (_closeTimer != null) - { - _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); - timerToDispose ??= _closeTimer; - _closeTimer = null; - } + CancelCloseTimeout(); _fault = new SessionChannelException(ChannelErrorCode.ChannelRejected, "Channel was rejected or forcibly closed by the remote peer"); _remoteState = ChannelRemoteState.Reset; _localPhase = ChannelLocalPhase.WriteClosed; _writeClosedCancellation.Cancel(); _inputBuffer.Writer.Complete(_fault); - _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); _session.ChannelDisconnect(this); } } - timerToDispose?.Dispose(); } internal PipeWriter GetPipeWriter() => _inputBuffer.Writer; + internal async ValueTask WaitForPendingFlushAsync() + { + if (_pendingPipeFlush.HasValue) + { + await _pendingPipeFlush.Value.ConfigureAwait(false); + _pendingPipeFlush = null; + } + } + + internal void OffloadPipeFlush(ValueTask flushTask, PipeWriter writer) + { + _pendingPipeFlush = HandleOffloadedPipeFlushAsync(flushTask, writer); + } + + private async ValueTask HandleOffloadedPipeFlushAsync(ValueTask flushTask, PipeWriter pipeWriter) + { + try + { + var result = await flushTask.ConfigureAwait(false); + if (result.IsCompleted) + { + CloseWrite(); + await pipeWriter.CompleteAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + if (ChannelTracer.Switch.ShouldTrace(TraceEventType.Error)) + ChannelTracer.TraceEvent(TraceEventType.Error, 0, $"[Err] yamux: offloaded pipe flush failed for channel {Id}: {ex.Message}"); + } + } + private void CompleteRead(YamuxException? fault = null) { - Timer? timerToDispose = null; lock (_stateLock) { if (_remoteState < ChannelRemoteState.ReadClosed) { - if (_closeTimer != null) - { - _closeTimer.Change(Timeout.Infinite, Timeout.Infinite); - timerToDispose = _closeTimer; - _closeTimer = null; - } + CancelCloseTimeout(); _remoteState = ChannelRemoteState.ReadClosed; _inputBuffer.Writer.Complete(fault); - _remoteCloseEvent.Set(); _remoteCloseTask.TrySetResult(); } } - timerToDispose?.Dispose(); } private void OnInputBytesConsumed() @@ -592,6 +596,18 @@ private void OnInputBytesConsumed() ChannelTracer.TraceEvent(TraceEventType.Verbose, 0, $"[Dbg] yamux: Channel {Id} auto-tune window increase max window size to {_receiveWindowMax} "); increase = _receiveWindowMax - previousMax; } + else if (_channelOptions.AutoTuneReceiveWindowSize + && rtt.HasValue + && _timeSinceLastUpdate > rtt.Value.Ticks * 4) + { + var halved = Math.Max(_receiveWindowMax / 2, _channelOptions.ReceiveWindowSize); + if (halved < _receiveWindowMax) + { + _receiveWindowMax = halved; + if (ChannelTracer.Switch.ShouldTrace(TraceEventType.Verbose)) + ChannelTracer.TraceEvent(TraceEventType.Verbose, 0, $"[Dbg] yamux: Channel {Id} auto-tune window decreased max window size to {_receiveWindowMax} "); + } + } if (_input.ConsumedBytes > (_receiveWindowMax / 2)) { diff --git a/src/SessionChannelException.cs b/src/SessionChannelException.cs index d6e6352..edfac1a 100644 --- a/src/SessionChannelException.cs +++ b/src/SessionChannelException.cs @@ -7,53 +7,76 @@ namespace Yamux { /// - /// Errors codes for channels (not part of the Yamux spec or transmitted), just used locally for this implementation + /// Error codes for channel-level errors. These are local to this implementation + /// and are not transmitted over the wire as part of the Yamux protocol. /// public enum ChannelErrorCode { /// - /// The peer has rejected the channel + /// The remote peer has rejected the channel (RST received). /// ChannelRejected, /// - /// Channel is half closed and data can no longer be sent on it + /// The channel's write side has been closed. No more data can be sent. /// ChannelWriteClosed, /// - /// The channel is fully closed and no data can be sent or received + /// The channel is fully closed and no data can be sent or received. /// ChannelClosed, /// - /// The underlying session transmission has been closed, so the channel is not operable anymore + /// The underlying session has been closed, so the channel is no longer operable. /// SessionClosed, /// - /// The channel is not in a valid state for the attempted operation + /// The channel is not in a valid state for the attempted operation. /// InvalidChannelState, } + /// + /// Represents an error that occurred on a specific channel. + /// [Serializable] public class SessionChannelException : YamuxException { + /// + /// Gets the channel error code. + /// public ChannelErrorCode ErrorCode; + /// + /// Initializes a new instance of the class with the specified error code. + /// + /// The channel error code. public SessionChannelException(ChannelErrorCode errorCode) { ErrorCode = errorCode; } + /// + /// Initializes a new instance of the class with a specified error code and message. + /// + /// The channel error code. + /// The error message. public SessionChannelException(ChannelErrorCode errorCode, string message) : base(message) { ErrorCode = errorCode; } + /// + /// Initializes a new instance of the class with a specified error code, + /// message, and inner exception. + /// + /// The channel error code. + /// The error message. + /// The inner exception. public SessionChannelException(ChannelErrorCode errorCode, string message, Exception inner) : base(message, inner) { ErrorCode = errorCode; diff --git a/src/SessionChannelOptions.cs b/src/SessionChannelOptions.cs index dd0671c..e465dc4 100644 --- a/src/SessionChannelOptions.cs +++ b/src/SessionChannelOptions.cs @@ -52,6 +52,12 @@ public SessionChannelOptions() { } /// public int StatisticsSampleInterval { get; set; } = 1000; + /// + /// Validates the channel options and throws if any values are out of range. + /// + /// Thrown when is less than 10 KB, or + /// exceeds when auto-tuning is enabled, + /// or is zero. public void Validate() { if (ReceiveWindowSize < (10 * 1024)) diff --git a/src/SessionException.cs b/src/SessionException.cs index 3036132..f607b54 100644 --- a/src/SessionException.cs +++ b/src/SessionException.cs @@ -7,79 +7,104 @@ namespace Yamux { + /// + /// Defines error codes for session-level errors. + /// public enum SessionErrorCode { /// - /// Invalid frame + /// The received frame has an invalid protocol version. /// InvalidVersion, /// - /// Invalid frame message type + /// The received frame has an invalid message type. /// InvalidMsgType, /// - /// The session has been closed + /// The session has been shut down. /// SessionShutdown, /// - /// The remote peer is no longer accepting new channels + /// The remote peer sent a GoAway and is no longer accepting new channels. /// RemoteGoAway, /// - /// This session is no longer accepting new channels + /// This session has sent a GoAway and is no longer accepting new channels. /// LocalGoAway, /// - /// the maximum number of open channels has been reached + /// The maximum number of concurrent channels has been reached. /// StreamsExhausted, /// - /// the sender exceeded the receiver's window + /// The sender exceeded the receiver's advertised window. /// RecvWindowExceeded, /// - /// underlying stream/connection encountered an error + /// The underlying transport encountered an error. /// StreamError, /// - /// underlying stream/connection was closed + /// The underlying transport was closed. /// StreamClosed, } + /// + /// Represents an error that occurred at the session level. + /// [Serializable] public class SessionException : YamuxException { /// - /// Session Error code + /// Gets the session error code. /// public SessionErrorCode ErrorCode { get; set; } /// - /// If the session error was caused by a Go Away code + /// Gets the GoAway termination code, if the error was caused by a GoAway frame. /// public SessionTermination? GoAwayCode { get; set; } + /// + /// Initializes a new instance of the class with the specified error code. + /// + /// The session error code. + /// An optional GoAway termination code. public SessionException(SessionErrorCode err, SessionTermination? termination = null) { GoAwayCode = termination; ErrorCode = err; } + /// + /// Initializes a new instance of the class with a specified error code and message. + /// + /// The session error code. + /// The error message. + /// An optional GoAway termination code. public SessionException(SessionErrorCode err, string message, SessionTermination? termination = null) : base(message) { ErrorCode = err; GoAwayCode = termination; } + /// + /// Initializes a new instance of the class with a specified error code, message, + /// and inner exception. + /// + /// The session error code. + /// The error message. + /// The inner exception. + /// An optional GoAway termination code. public SessionException(SessionErrorCode err, string message, Exception inner, SessionTermination? termination = null) : base(message, inner) { ErrorCode = err; diff --git a/src/SessionOptions.cs b/src/SessionOptions.cs index 8186a88..85a6beb 100644 --- a/src/SessionOptions.cs +++ b/src/SessionOptions.cs @@ -10,6 +10,11 @@ public class SessionOptions /// public int AcceptBacklog { get; set; } = 256; + /// + /// The maximum number of outgoing frames to buffer in the write queue before applying backpressure. + /// + public int WriteQueueDepth { get; set; } = 100; + /// /// Whether to enable keep-alive pings to detect dead connections. /// diff --git a/src/Statistics.cs b/src/Statistics.cs index 1592147..f5e33bf 100644 --- a/src/Statistics.cs +++ b/src/Statistics.cs @@ -43,6 +43,9 @@ public class Statistics : IDisposable /// public TimeSpan SampleInterval { get; } + /// + /// Occurs each time bandwidth statistics are sampled, at the interval specified by . + /// public event EventHandler? Sampled; /// @@ -102,6 +105,9 @@ private void SampleBandwidth(object? state) } } + /// + /// Releases all resources used by the instance, including the sampling timer. + /// public void Dispose() { if (!_disposed) diff --git a/src/YamuxException.cs b/src/YamuxException.cs index 4b5b825..541d65d 100644 --- a/src/YamuxException.cs +++ b/src/YamuxException.cs @@ -7,16 +7,31 @@ namespace Yamux { /// - /// Base exception for yamux errors + /// Base exception for all Yamux-related errors. /// public abstract class YamuxException : System.Exception { + /// + /// Initializes a new instance of the class. + /// public YamuxException() { } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message. public YamuxException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that caused this exception. + /// + /// The error message. + /// The inner exception. public YamuxException(string message, Exception innerException) : base(message, innerException) { } From 093834c3a504685ec63f1067b874e98c4c9a2270 Mon Sep 17 00:00:00 2001 From: Paul Bleess <8421069+pableess@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:46:55 -0500 Subject: [PATCH 3/6] more improvement scatter I/O --- benchmarks/Yamux.Benchmark/Program.cs | 253 ++++++++++---------- src/ITransport.cs | 10 +- src/Internal/ConnectionReader.cs | 135 ++++++----- src/Internal/SessionFrameWriter.cs | 325 +++++++++++++++++++++----- src/PipeExtensions.cs | 29 ++- src/PipePeer.cs | 39 +++- src/Session.cs | 3 +- src/SessionOptions.cs | 24 ++ src/SocketPeer.cs | 78 ++++++- 9 files changed, 626 insertions(+), 270 deletions(-) diff --git a/benchmarks/Yamux.Benchmark/Program.cs b/benchmarks/Yamux.Benchmark/Program.cs index d36ec72..c8eec86 100644 --- a/benchmarks/Yamux.Benchmark/Program.cs +++ b/benchmarks/Yamux.Benchmark/Program.cs @@ -68,7 +68,7 @@ public void Cleanup() } [Benchmark(Baseline = true)] - public async Task SocketBaselineAsync() + public async Task TcpBaselineAsync() { var sw = new Stopwatch(); var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -135,7 +135,7 @@ void SignalReady() } [Benchmark] - public async Task YamuxSocketAsync() + public async Task TcpYamuxAsync() { var sw = new Stopwatch(); var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -244,66 +244,10 @@ Task RunChannelAsync(IReadOnlySessionChannel channel) return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; } - [Benchmark] - public async Task CsharpToGoAsync() - { - using var goServer = GoServerProcess.Start(_goServerPath!); - - int iterationsPerStream = (MBs * 32) / Streams; - long totalBytes = (long)iterationsPerStream * Streams * 1024 * 32; - - var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); - await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, goServer.Port)); - - var opt = new SessionOptions - { - EnableKeepAlive = false, - DefaultChannelOptions = new SessionChannelOptions - { - MaxDataFrameSize = 1024 * 64, - } - }; - var session = sock!.AsYamuxSession(true, options: opt, leaveOpen: false); - session.Start(); - - var sw = Stopwatch.StartNew(); - - List channels = new List(); - - for (int i = 0; i < Streams; i++) - { - channels.Add(Task.Run(async () => - { - using var channel = await session.OpenChannelAsync(false); - - for (int j = 0; j < iterationsPerStream; j++) - { - await channel.WriteAsync(_buffer); - } - - var timeout = (await channel.WhenRemoteAckAsync(TimeSpan.FromSeconds(3)) == false); - if (timeout) - { - throw new TimeoutException("Timed out waiting for remote ack"); - } - - channel.Close(); - })); - } - - await Task.WhenAll(channels); - - sw.Stop(); - sock.Close(); - - return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; - } [Benchmark] - public async Task NerdbankStreamsAsync() + public async Task TcpNerdbankAsync() { - (var stream1, var stream2) = FullDuplexStream.CreatePair(); - var sw = new Stopwatch(); var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int readyCount = 0; @@ -322,7 +266,10 @@ void SignalReady() var serverTask = Task.Run(async () => { - var mux = await MultiplexingStream.CreateAsync(stream2, new MultiplexingStream.Options + var sock = await _serverSock!.AcceptAsync(); + + using Stream stream = new NetworkStream(sock, true); + var mux = await MultiplexingStream.CreateAsync(stream, new MultiplexingStream.Options { ProtocolMajorVersion = 1, }, CancellationToken.None); @@ -336,17 +283,14 @@ void SignalReady() { channels.Add(Task.Run(async () => { - var channel = await mux.AcceptChannelAsync("", new MultiplexingStream.ChannelOptions()); + var channel = await mux.OfferChannelAsync("", new MultiplexingStream.ChannelOptions()); using var channelStream = channel.AsStream(); - byte[] readBuffer = new byte[1024 * 32]; - int totalBytes = iterationsPerStream * 1024 * 32; - while (totalBytes > 0) + for (int j = 0; j < iterationsPerStream; j++) { - var read = await channelStream.ReadAsync(readBuffer, 0, readBuffer.Length); - if (read == 0) break; - totalBytes -= read; + await channelStream.WriteAsync(_buffer); } + channelStream.Close(); })); } @@ -355,7 +299,11 @@ void SignalReady() var clientTask = Task.Run(async () => { - var mux = await MultiplexingStream.CreateAsync(stream1, new MultiplexingStream.Options + var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port)); + + using Stream stream = new NetworkStream(sock, true); + var mux = await MultiplexingStream.CreateAsync(stream, new MultiplexingStream.Options { ProtocolMajorVersion = 1, }, CancellationToken.None); @@ -369,14 +317,17 @@ void SignalReady() { channels.Add(Task.Run(async () => { - var channel = await mux.OfferChannelAsync("", new MultiplexingStream.ChannelOptions()); + var channel = await mux.AcceptChannelAsync("", new MultiplexingStream.ChannelOptions()); using var channelStream = channel.AsStream(); - for (int j = 0; j < iterationsPerStream; j++) + byte[] readBuffer = new byte[1024 * 32]; + int totalBytes = iterationsPerStream * 1024 * 32; + while (totalBytes > 0) { - await channelStream.WriteAsync(_buffer); + var read = await channelStream.ReadAsync(readBuffer, 0, readBuffer.Length); + if (read == 0) break; + totalBytes -= read; } - channelStream.Close(); })); } @@ -390,8 +341,69 @@ void SignalReady() } [Benchmark] - public async Task NerdbankSocketAsync() + public async Task GoIntegrationAsync() { + using var goServer = GoServerProcess.Start(_goServerPath!); + + int iterationsPerStream = (MBs * 32) / Streams; + long totalBytes = (long)iterationsPerStream * Streams * 1024 * 32; + + var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, goServer.Port)); + + var opt = new SessionOptions + { + EnableKeepAlive = false, + DefaultChannelOptions = new SessionChannelOptions + { + MaxDataFrameSize = 1024 * 64, + } + }; + var session = sock!.AsYamuxSession(true, options: opt, leaveOpen: false); + session.Start(); + + var sw = Stopwatch.StartNew(); + + List channels = new List(); + + for (int i = 0; i < Streams; i++) + { + channels.Add(Task.Run(async () => + { + using var channel = await session.OpenChannelAsync(false); + + for (int j = 0; j < iterationsPerStream; j++) + { + await channel.WriteAsync(_buffer); + } + + var timeout = (await channel.WhenRemoteAckAsync(TimeSpan.FromSeconds(3)) == false); + if (timeout) + { + throw new TimeoutException("Timed out waiting for remote ack"); + } + + channel.Close(); + })); + } + + await Task.WhenAll(channels); + + sw.Stop(); + sock.Close(); + + return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; + } + + + [Benchmark] + public async Task MemoryYamuxAsync() + { + var clientPipe = new Pipe(); + var serverPipe = new Pipe(); + var clientTransport = new DuplexPipe(serverPipe.Reader, clientPipe.Writer); + var serverTransport = new DuplexPipe(clientPipe.Reader, serverPipe.Writer); + var sw = new Stopwatch(); var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int readyCount = 0; @@ -410,13 +422,8 @@ void SignalReady() var serverTask = Task.Run(async () => { - var sock = await _serverSock!.AcceptAsync(); - - using Stream stream = new NetworkStream(sock, true); - var mux = await MultiplexingStream.CreateAsync(stream, new MultiplexingStream.Options - { - ProtocolMajorVersion = 1, - }, CancellationToken.None); + var session = serverTransport.AsYamuxSession(false); + session.Start(); SignalReady(); await ready.Task; @@ -427,14 +434,14 @@ void SignalReady() { channels.Add(Task.Run(async () => { - var channel = await mux.OfferChannelAsync("", new MultiplexingStream.ChannelOptions()); - using var channelStream = channel.AsStream(); + using var channel = await session.OpenChannelAsync(false); for (int j = 0; j < iterationsPerStream; j++) { - await channelStream.WriteAsync(_buffer); + await channel.WriteAsync(_buffer); } - channelStream.Close(); + + channel.Close(); })); } @@ -443,14 +450,8 @@ void SignalReady() var clientTask = Task.Run(async () => { - var sock = new Socket(SocketType.Stream, ProtocolType.Tcp); - await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port)); - - using Stream stream = new NetworkStream(sock, true); - var mux = await MultiplexingStream.CreateAsync(stream, new MultiplexingStream.Options - { - ProtocolMajorVersion = 1, - }, CancellationToken.None); + var session = clientTransport.AsYamuxSession(true); + session.Start(); SignalReady(); await ready.Task; @@ -461,17 +462,19 @@ void SignalReady() { channels.Add(Task.Run(async () => { - var channel = await mux.AcceptChannelAsync("", new MultiplexingStream.ChannelOptions()); - using var channelStream = channel.AsStream(); + var channel = await session.AcceptReadOnlyChannelAsync(); byte[] readBuffer = new byte[1024 * 32]; - int totalBytes = iterationsPerStream * 1024 * 32; - while (totalBytes > 0) + + ReadResult res; + do { - var read = await channelStream.ReadAsync(readBuffer, 0, readBuffer.Length); - if (read == 0) break; - totalBytes -= read; - } + res = await channel.Input.ReadAtLeastAsync(1024); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCompleted); + + channel.Close(); + channel.Dispose(); })); } @@ -485,12 +488,9 @@ void SignalReady() } [Benchmark] - public async Task YamuxInMemoryAsync() + public async Task MemoryNerdbankAsync() { - var clientPipe = new Pipe(); - var serverPipe = new Pipe(); - var clientTransport = new DuplexPipe(serverPipe.Reader, clientPipe.Writer); - var serverTransport = new DuplexPipe(clientPipe.Reader, serverPipe.Writer); + (var stream1, var stream2) = FullDuplexStream.CreatePair(); var sw = new Stopwatch(); var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -510,8 +510,10 @@ void SignalReady() var serverTask = Task.Run(async () => { - var session = serverTransport.AsYamuxSession(false); - session.Start(); + var mux = await MultiplexingStream.CreateAsync(stream2, new MultiplexingStream.Options + { + ProtocolMajorVersion = 1, + }, CancellationToken.None); SignalReady(); await ready.Task; @@ -522,14 +524,17 @@ void SignalReady() { channels.Add(Task.Run(async () => { - using var channel = await session.OpenChannelAsync(false); + var channel = await mux.AcceptChannelAsync("", new MultiplexingStream.ChannelOptions()); + using var channelStream = channel.AsStream(); - for (int j = 0; j < iterationsPerStream; j++) + byte[] readBuffer = new byte[1024 * 32]; + int totalBytes = iterationsPerStream * 1024 * 32; + while (totalBytes > 0) { - await channel.WriteAsync(_buffer); + var read = await channelStream.ReadAsync(readBuffer, 0, readBuffer.Length); + if (read == 0) break; + totalBytes -= read; } - - channel.Close(); })); } @@ -538,8 +543,10 @@ void SignalReady() var clientTask = Task.Run(async () => { - var session = clientTransport.AsYamuxSession(true); - session.Start(); + var mux = await MultiplexingStream.CreateAsync(stream1, new MultiplexingStream.Options + { + ProtocolMajorVersion = 1, + }, CancellationToken.None); SignalReady(); await ready.Task; @@ -550,19 +557,14 @@ void SignalReady() { channels.Add(Task.Run(async () => { - var channel = await session.AcceptReadOnlyChannelAsync(); - - byte[] readBuffer = new byte[1024 * 32]; + var channel = await mux.OfferChannelAsync("", new MultiplexingStream.ChannelOptions()); + using var channelStream = channel.AsStream(); - ReadResult res; - do + for (int j = 0; j < iterationsPerStream; j++) { - res = await channel.Input.ReadAtLeastAsync(1024); - channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); - } while (!res.IsCompleted); - - channel.Close(); - channel.Dispose(); + await channelStream.WriteAsync(_buffer); + } + channelStream.Close(); })); } @@ -574,6 +576,7 @@ void SignalReady() return totalBytes / (1024.0 * 1024.0) / sw.Elapsed.TotalSeconds; } + } internal sealed class GoServerProcess : IDisposable diff --git a/src/ITransport.cs b/src/ITransport.cs index 03179cd..de3cbea 100644 --- a/src/ITransport.cs +++ b/src/ITransport.cs @@ -1,7 +1,7 @@ using System.Buffers; -namespace Yamux -{ +namespace Yamux; + /// /// Represents a bidirectional transport layer that can be used by a Yamux session. /// Implementations wrap stream-oriented transports such as TCP sockets, named pipes, @@ -42,6 +42,11 @@ public interface ITransport : IDisposable /// A representing the asynchronous flush operation. ValueTask FlushAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + /// + /// Override and return true to opt into batching writes via . + /// + bool SupportsBatching => false; + /// /// Writes a sequence of byte segments to the transport. The default implementation /// iterates over the segments and calls for each. @@ -60,4 +65,3 @@ static async ValueTask WriteSequenceAsync(ITransport transport, ReadOnlySequence } } } -} diff --git a/src/Internal/ConnectionReader.cs b/src/Internal/ConnectionReader.cs index 4f840f3..1604425 100644 --- a/src/Internal/ConnectionReader.cs +++ b/src/Internal/ConnectionReader.cs @@ -1,95 +1,94 @@ using System.Runtime.CompilerServices; using Yamux.Protocol; -namespace Yamux.Internal +namespace Yamux.Internal; + +internal class ConnectionReader { - internal class ConnectionReader - { - private readonly CancellationTokenSource _stoppingToken; - private readonly ITransport _peer; + private readonly CancellationTokenSource _stoppingToken; + private readonly ITransport _peer; - public ConnectionReader(ITransport peer) - { - _stoppingToken = new CancellationTokenSource(); - _peer = peer ?? throw new ArgumentNullException(nameof(peer)); - } + public ConnectionReader(ITransport peer) + { + _stoppingToken = new CancellationTokenSource(); + _peer = peer ?? throw new ArgumentNullException(nameof(peer)); + } - public async IAsyncEnumerable ReadFramesAsync([EnumeratorCancellation] CancellationToken cancellationToken) - { - byte[] headerBuffer = new byte[FrameHeader.FrameHeaderSize]; + public async IAsyncEnumerable ReadFramesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + byte[] headerBuffer = new byte[FrameHeader.FrameHeaderSize]; - int bytesRead; + int bytesRead; - while (!_stoppingToken.IsCancellationRequested) + while (!_stoppingToken.IsCancellationRequested) + { + try { - try - { - bytesRead = await this.ReadAll(headerBuffer, cancellationToken).ConfigureAwait(false); + bytesRead = await this.ReadAll(headerBuffer, cancellationToken).ConfigureAwait(false); - if (bytesRead == 0) - { - throw new SessionException(SessionErrorCode.StreamClosed, "Connection closed by remote", SessionTermination.Normal); - } - } - catch (OperationCanceledException) + if (bytesRead == 0) { - yield break; + throw new SessionException(SessionErrorCode.StreamClosed, "Connection closed by remote", SessionTermination.Normal); } + } + catch (OperationCanceledException) + { + yield break; + } - if (!_stoppingToken.IsCancellationRequested) - { - yield return FrameHeader.Parse(headerBuffer); - } + if (!_stoppingToken.IsCancellationRequested) + { + yield return FrameHeader.Parse(headerBuffer); } } + } - public async ValueTask ReadFramePayloadAsync(Memory data, CancellationToken cancellationToken) - { - return await this.ReadAll(data, cancellationToken).ConfigureAwait(false); - } + public async ValueTask ReadFramePayloadAsync(Memory data, CancellationToken cancellationToken) + { + return await this.ReadAll(data, cancellationToken).ConfigureAwait(false); + } - private async ValueTask ReadAll(Memory data, CancellationToken cancellationToken) - { - if (data.IsEmpty) - return 0; + private async ValueTask ReadAll(Memory data, CancellationToken cancellationToken) + { + if (data.IsEmpty) + return 0; - int requested = data.Length; - int bytesRead = 0; - try + int requested = data.Length; + int bytesRead = 0; + try + { + do { - do + var read = await _peer.ReadAsync(data.Slice(bytesRead, requested - bytesRead), cancellationToken).ConfigureAwait(false); + if (read == 0) { - var read = await _peer.ReadAsync(data.Slice(bytesRead, requested - bytesRead), cancellationToken).ConfigureAwait(false); - if (read == 0) - { - throw new SessionException(SessionErrorCode.StreamClosed, "Remote connection closed"); - } - bytesRead += read; + throw new SessionException(SessionErrorCode.StreamClosed, "Remote connection closed"); } - while (bytesRead < requested); - - return bytesRead; - } - catch (OperationCanceledException) - { - throw; - } - catch (SessionException) - { - throw; + bytesRead += read; } - catch (Exception ex) - { - throw new SessionException( - SessionErrorCode.StreamClosed, - "Underlying transport error", - ex); - } - } + while (bytesRead < requested); - public void Stop() + return bytesRead; + } + catch (OperationCanceledException) + { + throw; + } + catch (SessionException) { - _stoppingToken.Cancel(); + throw; } + catch (Exception ex) + { + throw new SessionException( + SessionErrorCode.StreamClosed, + "Underlying transport error", + ex); + } + } + + public void Stop() + { + _stoppingToken.Cancel(); } } diff --git a/src/Internal/SessionFrameWriter.cs b/src/Internal/SessionFrameWriter.cs index 1a14069..f19ee82 100644 --- a/src/Internal/SessionFrameWriter.cs +++ b/src/Internal/SessionFrameWriter.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Buffers; +using System.Diagnostics; using System.Threading.Channels; using Yamux.Protocol; @@ -12,81 +13,207 @@ internal class SessionFrameWriter private YamuxMetrics? _metrics; private Task? _runTask; private readonly TimeSpan _connectionWriteTimeout; + private readonly bool _useBatching; + private readonly int _minBatchSize; private readonly SemaphoreSlim _flushLock = new SemaphoreSlim(1, 1); + private readonly Stack _segmentPool = new(); internal void SetMetrics(YamuxMetrics? metrics) => _metrics = metrics; - public SessionFrameWriter(ITransport connection, Statistics? stats, TimeSpan connectionWriteTimeout, int writeQueueDepth = 100) + public SessionFrameWriter(ITransport connection, Statistics? stats, TimeSpan connectionWriteTimeout, + int writeQueueDepth = 100, bool enableBatching = false, int minBatchSize = 8192) { _peer = connection ?? throw new ArgumentNullException(nameof(connection)); _stats = stats; _metrics = null; _connectionWriteTimeout = connectionWriteTimeout; + _useBatching = enableBatching; + _minBatchSize = minBatchSize; _writeQueue = Channel.CreateBounded(new BoundedChannelOptions(writeQueueDepth) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true, }); + + if (_useBatching) + { + for (int i = 0; i < 16; i++) + _segmentPool.Push(new WriteSegment()); + } } public void Start() { - _runTask = Task.Run(async () => - { - byte[] headerBuffer = new byte[FrameHeader.FrameHeaderSize]; + _runTask = Task.Run(_useBatching ? BatchedLoop : DirectLoop); + } - try + private async Task DirectLoop() + { + byte[] headerBuffer = new byte[FrameHeader.FrameHeaderSize]; + WriteSegment seg1 = new(), seg2 = new(); + + try + { + while (await _writeQueue.Reader.WaitToReadAsync().ConfigureAwait(false)) { - while (await _writeQueue.Reader.WaitToReadAsync().ConfigureAwait(false)) + while (_writeQueue.Reader.TryRead(out var item)) { - if (_writeQueue.Reader.TryRead(out var item)) - { - using var _ = item.Frame; + using var _ = item.Frame; - try - { - item.Frame.Header.WriteTo(headerBuffer); - - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) - Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: writing frame - {0}, payload size = {1}", item.Frame.Header.FrameType, item.Frame.Header.Length); + try + { + item.Frame.Header.WriteTo(headerBuffer); - await _peer.WriteAsync(headerBuffer.AsMemory(0, FrameHeader.FrameHeaderSize), default).ConfigureAwait(false); - _metrics?.FramesSent.Add(1); - if (!item.Frame.Payload.IsEmpty) - { - await _peer.WriteAsync(item.Frame.Payload, default).ConfigureAwait(false); - _stats?.UpdateSent((uint)item.Frame.Payload.Length); - _metrics?.BytesSent.Add(item.Frame.Payload.Length); - } + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) + Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: writing frame - {0}, payload size = {1}", item.Frame.Header.FrameType, item.Frame.Header.Length); - item.Complete(); - } - catch (OperationCanceledException cancelEx) + if (!item.Frame.Payload.IsEmpty) { - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) - Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: write operation canceled - {0}", cancelEx.Message); - item.Fault(cancelEx); + var header = headerBuffer.AsMemory(0, FrameHeader.FrameHeaderSize); + seg1.Set(header, 0); + seg2.Set(item.Frame.Payload, FrameHeader.FrameHeaderSize); + seg1.SetNext(seg2); + var sequence = new ReadOnlySequence(seg1, 0, seg2, item.Frame.Payload.Length); + await _peer.WriteAsync(sequence, default).ConfigureAwait(false); + _stats?.UpdateSent((uint)item.Frame.Payload.Length); + _metrics?.BytesSent.Add(item.Frame.Payload.Length); } - catch (Exception ex) + else { - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) - Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); - item.Fault(ex); + await _peer.WriteAsync(headerBuffer.AsMemory(0, FrameHeader.FrameHeaderSize), default).ConfigureAwait(false); } + + _metrics?.FramesSent.Add(1); + item.Complete(); + } + catch (OperationCanceledException cancelEx) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) + Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: write operation canceled - {0}", cancelEx.Message); + item.Fault(cancelEx); + } + catch (Exception ex) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) + Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); + item.Fault(ex); } } } - catch (OperationCanceledException) - { - } - catch (Exception ex) + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) + Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); + } + } + + private async Task BatchedLoop() + { + try + { + while (await _writeQueue.Reader.WaitToReadAsync().ConfigureAwait(false)) { - if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) - Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); + List batch = new(); + WriteSegment? head = null, tail = null; + long runningIndex = 0; + int totalBytes = 0; + + while (_writeQueue.Reader.TryRead(out var item)) + { + if (item.IsFlushMarker) + { + batch.Add(item); + break; + } + + batch.Add(item); + + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Verbose)) + Session.SessionTracer.TraceEvent(TraceEventType.Verbose, 0, "[Dbg] yamux: writing frame - {0}, payload size = {1}", item.Frame.Header.FrameType, item.Frame.Header.Length); + + using var _ = item.Frame; + var hdrBuf = ArrayPool.Shared.Rent(FrameHeader.FrameHeaderSize); + item.Frame.Header.WriteTo(hdrBuf); + var hdrSeg = RentSegment(); + hdrSeg.Set(hdrBuf.AsMemory(0, FrameHeader.FrameHeaderSize), runningIndex); + hdrSeg.HeaderBuffer = hdrBuf; + AppendSegment(ref head, ref tail, hdrSeg); + runningIndex += FrameHeader.FrameHeaderSize; + totalBytes += FrameHeader.FrameHeaderSize; + + if (!item.Frame.Payload.IsEmpty) + { + var paySeg = RentSegment(); + paySeg.Set(item.Frame.Payload, runningIndex); + AppendSegment(ref head, ref tail, paySeg); + runningIndex += item.Frame.Payload.Length; + totalBytes += item.Frame.Payload.Length; + } + + if (totalBytes >= _minBatchSize) + break; + } + + Exception? batchError = null; + if (head != null) + { + try + { + var sequence = new ReadOnlySequence(head, 0, tail!, tail!.Memory.Length); + await _peer.WriteAsync(sequence, default).ConfigureAwait(false); + _stats?.UpdateSent((uint)runningIndex); + _metrics?.BytesSent.Add(runningIndex); + } + catch (OperationCanceledException cancelEx) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Warning)) + Session.SessionTracer.TraceEvent(TraceEventType.Warning, 0, "[Warn] yamux: write operation canceled - {0}", cancelEx.Message); + batchError = cancelEx; + } + catch (Exception ex) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) + Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); + batchError = ex; + } + + foreach (var seg in TraverseSegments(head)) + ReturnSegment(seg); + } + + foreach (var item in batch) + { + if (item.IsFlushMarker) + { + if (batchError != null) + item.Fault(batchError); + else + item.Complete(); + } + else + { + if (batchError != null) + item.Fault(batchError); + else + item.Complete(); + } + } } - }); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + if (Session.SessionTracer.Switch.ShouldTrace(TraceEventType.Error)) + Session.SessionTracer.TraceEvent(TraceEventType.Error, 0, "[Err] yamux: error writing frame - {0}", ex.Message); + } } public async ValueTask WriteAsync(Frame frame, CancellationToken cancellationToken) @@ -142,15 +269,27 @@ await _writeQueue.Writer.WriteAsync(item, default) public async ValueTask FlushAsync(CancellationToken cancellationToken = default) { - await _flushLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + if (!_useBatching) { - await _peer.FlushAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - _flushLock.Release(); + await _flushLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _peer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _flushLock.Release(); + } + return; } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await _writeQueue.Writer.WriteAsync(new WriteItem(tcs), cancellationToken) + .AsTask() + .WaitAsync(_connectionWriteTimeout, cancellationToken) + .ConfigureAwait(false); + + await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } public async Task StopAsync() @@ -165,25 +304,107 @@ public async Task StopAsync() internal int WriteQueueDepth => _writeQueue.Reader.Count; + private WriteSegment RentSegment() + { + if (_segmentPool.TryPop(out var seg)) + return seg; + return new WriteSegment(); + } + + private void ReturnSegment(WriteSegment seg) + { + if (seg.HeaderBuffer != null) + { + ArrayPool.Shared.Return(seg.HeaderBuffer); + seg.HeaderBuffer = null; + } + seg.Reset(); + _segmentPool.Push(seg); + } + + private static void AppendSegment(ref WriteSegment? head, ref WriteSegment? tail, WriteSegment seg) + { + if (head == null) + head = seg; + else + tail!.SetNext(seg); + tail = seg; + } + + private static IEnumerable TraverseSegments(WriteSegment head) + { + var current = head; + while (current != null) + { + var next = (WriteSegment?)current.Next; + yield return current; + current = next; + } + } + + private sealed class WriteSegment : ReadOnlySequenceSegment + { + public byte[]? HeaderBuffer { get; set; } + + public WriteSegment Set(ReadOnlyMemory memory, long runningIndex) + { + Memory = memory; + RunningIndex = runningIndex; + Next = null; + HeaderBuffer = null; + return this; + } + + public void SetNext(WriteSegment next) + { + Next = next; + } + + public void Reset() + { + Memory = default; + RunningIndex = 0; + Next = null; + } + } + private readonly struct WriteItem { public readonly Frame Frame; - public readonly ResettableValueTaskSource? Completion; + public readonly ResettableValueTaskSource? FrameCompletion; + public readonly TaskCompletionSource? FlushCompletion; + public readonly bool IsFlushMarker; public WriteItem(Frame frame, ResettableValueTaskSource? completion) { Frame = frame; - Completion = completion; + FrameCompletion = completion; + FlushCompletion = null; + IsFlushMarker = false; + } + + public WriteItem(TaskCompletionSource flushCompletion) + { + Frame = default; + FrameCompletion = null; + FlushCompletion = flushCompletion; + IsFlushMarker = true; } public void Complete() { - Completion?.SetResult(); + if (IsFlushMarker) + FlushCompletion?.TrySetResult(); + else + FrameCompletion?.SetResult(); } public void Fault(Exception ex) { - Completion?.SetException(ex); + if (IsFlushMarker) + FlushCompletion?.TrySetException(ex); + else + FrameCompletion?.SetException(ex); } } } diff --git a/src/PipeExtensions.cs b/src/PipeExtensions.cs index bef3512..a624c50 100644 --- a/src/PipeExtensions.cs +++ b/src/PipeExtensions.cs @@ -1,21 +1,20 @@ using System.IO.Pipelines; -namespace Yamux +namespace Yamux; + +/// +/// Extension methods for creating Yamux sessions from instances. +/// +public static class PipeExtensions { /// - /// Extension methods for creating Yamux sessions from instances. + /// Creates a Yamux session over the provided duplex pipe. /// - public static class PipeExtensions - { - /// - /// Creates a Yamux session over the provided duplex pipe. - /// - /// The duplex pipe to use as the transport. - /// Whether this is the client side of the connection. - /// Whether to leave the pipe open when the session is disposed. - /// Session configuration options. If null, default options are used. - /// A new instance. - public static Session AsYamuxSession(this IDuplexPipe pipe, bool isClient, bool leaveOpen = false, SessionOptions? options = null) - => new Session(new PipePeer(pipe), isClient, leaveOpen, options); - } + /// The duplex pipe to use as the transport. + /// Whether this is the client side of the connection. + /// Whether to leave the pipe open when the session is disposed. + /// Session configuration options. If null, default options are used. + /// A new instance. + public static Session AsYamuxSession(this IDuplexPipe pipe, bool isClient, bool leaveOpen = false, SessionOptions? options = null) + => new Session(new PipePeer(pipe), isClient, leaveOpen, options); } diff --git a/src/PipePeer.cs b/src/PipePeer.cs index f2ba4eb..80977aa 100644 --- a/src/PipePeer.cs +++ b/src/PipePeer.cs @@ -1,8 +1,8 @@ using System.Buffers; using System.IO.Pipelines; -namespace Yamux -{ +namespace Yamux; + /// /// An implementation that wraps a . /// Useful for in-memory multiplexing or testing scenarios. @@ -63,6 +63,40 @@ public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken c await _writer.WriteAsync(data, cancellationToken).ConfigureAwait(false); } + public bool SupportsBatching => true; + + public async ValueTask WriteAsync(ReadOnlySequence data, CancellationToken cancellationToken = default) + { + if (data.IsEmpty) + return; + + + if (data.IsSingleSegment) + { + await WriteAsync(data.First, cancellationToken); + return; + } + + + // Iterate through each memory segment within the sequence + foreach (ReadOnlyMemory segment in data) + { + ReadOnlySpan span = segment.Span; + + // Request a buffer large enough for the current segment + Span writerBuffer = _writer.GetSpan(span.Length); + + // Copy bytes directly from the sequence segment to the writer buffer + span.CopyTo(writerBuffer); + + // Inform the writer how many bytes were added + _writer.Advance(span.Length); + } + + // Flush everything to the reader in a single asynchronous call + FlushResult result = await _writer.FlushAsync(); + } + /// public async ValueTask FlushAsync(CancellationToken cancellationToken = default) { @@ -83,4 +117,3 @@ public void Dispose() _writer.Complete(); } } -} diff --git a/src/Session.cs b/src/Session.cs index 2808a2d..b054a87 100644 --- a/src/Session.cs +++ b/src/Session.cs @@ -53,7 +53,8 @@ public Session(ITransport transport, bool isClient, bool leaveOpen = false, Sess } _channelManager = new ChannelManager(this, _sessionOptions.DefaultChannelOptions, _sessionOptions.AcceptBacklog, null, _sessionOptions.MaxChannels); - _writer = new SessionFrameWriter(_transport, Stats, _sessionOptions.ConnectionWriteTimeout, _sessionOptions.WriteQueueDepth); + bool useBatching = _sessionOptions.WriteSegmentBatchingEnabled ?? _transport.SupportsBatching; + _writer = new SessionFrameWriter(_transport, Stats, _sessionOptions.ConnectionWriteTimeout, _sessionOptions.WriteQueueDepth, useBatching, _sessionOptions.MinWriteBatchSize); _frameReader = new FrameReader( new ConnectionReader(_transport), _channelManager, diff --git a/src/SessionOptions.cs b/src/SessionOptions.cs index 85a6beb..715e16a 100644 --- a/src/SessionOptions.cs +++ b/src/SessionOptions.cs @@ -89,4 +89,28 @@ public class SessionOptions /// Enables OpenTelemetry-compatible metrics via . /// public bool EnableMetrics { get; set; } = true; + + /// + /// Overrides the transport's default of mode of the writer accumulates multiple frames into a single Write call + /// The transport must support the overload for this to be effective. + /// This would only be useful if the transport is capable of scatter/gather writes (e.g., SocketAsyncEventArgs.BufferList). + /// and writes them to the transport + /// in one call via . + /// To realize throughput gains, the transport must implement that overload + /// efficiently (e.g., scatter/gather via SocketAsyncEventArgs.BufferList). + /// When disabled, each frame component (header, payload) is written individually + /// via . + /// Only frames already queued in the write channel at the same time are batched together. + /// Default is false. + /// + public bool? WriteSegmentBatchingEnabled { get; set; } + + /// + /// The minimum accumulated data (in bytes) that triggers a batched transport write. + /// Accumulated data below this threshold is flushed when the write queue is drained + /// or when is called. + /// Only relevant when is true. + /// Default is 8,192 (8 KB). + /// + public int MinWriteBatchSize { get; set; } = 8192; } diff --git a/src/SocketPeer.cs b/src/SocketPeer.cs index 6fbf419..48387d6 100644 --- a/src/SocketPeer.cs +++ b/src/SocketPeer.cs @@ -1,4 +1,7 @@ -namespace Yamux +using System.Buffers; +using System.Runtime.InteropServices; + +namespace Yamux { /// /// An implementation that wraps a . @@ -6,6 +9,9 @@ public class SocketPeer : ITransport { private readonly System.Net.Sockets.Socket _socket; + private readonly System.Net.Sockets.SocketAsyncEventArgs _writeEventArgs = new(); + private readonly List> _bufferList = new(); + private TaskCompletionSource? _writeTcs; /// /// Initializes a new instance of the class. @@ -15,6 +21,21 @@ public class SocketPeer : ITransport public SocketPeer(System.Net.Sockets.Socket socket) { _socket = socket ?? throw new ArgumentNullException(nameof(socket)); + _writeEventArgs.Completed += OnWriteCompleted; + } + + /// + /// This transport supports batched writes via . + /// + public bool SupportsBatching => true; + + private void OnWriteCompleted(object? sender, System.Net.Sockets.SocketAsyncEventArgs e) + { + e.BufferList = null; + if (e.SocketError == System.Net.Sockets.SocketError.Success) + _writeTcs?.TrySetResult(true); + else + _writeTcs?.TrySetException(new System.Net.Sockets.SocketException((int)e.SocketError)); } /// @@ -27,6 +48,7 @@ public void Close() /// public void Dispose() { + _writeEventArgs.Dispose(); _socket.Dispose(); } @@ -37,7 +59,7 @@ public async ValueTask ReadAsync(Memory data, CancellationToken cance return read; } - + /// public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { @@ -48,5 +70,55 @@ public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken c await _socket.SendAsync(data, System.Net.Sockets.SocketFlags.None, cancellationToken).ConfigureAwait(false); } + + /// + public ValueTask WriteAsync(ReadOnlySequence data, CancellationToken cancellationToken = default) + { + if (data.IsSingleSegment) + return WriteAsync(data.First, cancellationToken); + + _bufferList.Clear(); + foreach (var segment in data) + { + if (MemoryMarshal.TryGetArray(segment, out var arraySegment)) + _bufferList.Add(arraySegment); + else + return SegmentedFallbackAsync(data, cancellationToken); + } + + _writeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _writeEventArgs.BufferList = _bufferList; + + if (_socket.SendAsync(_writeEventArgs)) + { + if (cancellationToken.CanBeCanceled) + { + var tcs = _writeTcs; + var reg = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetCanceled(), tcs); + return new ValueTask(WaitWithCleanupAsync(tcs.Task, reg)); + } + + return new ValueTask(_writeTcs.Task); + } + + _writeEventArgs.BufferList = null; + return _writeEventArgs.SocketError == System.Net.Sockets.SocketError.Success + ? ValueTask.CompletedTask + : ValueTask.FromException(new System.Net.Sockets.SocketException((int)_writeEventArgs.SocketError)); + } + + private static async Task WaitWithCleanupAsync(Task task, CancellationTokenRegistration reg) + { + using (reg) + { + await task.ConfigureAwait(false); + } + } + + private async ValueTask SegmentedFallbackAsync(ReadOnlySequence data, CancellationToken cancellationToken) + { + foreach (var segment in data) + await WriteAsync(segment, cancellationToken).ConfigureAwait(false); + } } -} +} \ No newline at end of file From b69ce359ca5b544e5c902a78fc4822a1a59653aa Mon Sep 17 00:00:00 2001 From: Paul Bleess <8421069+pableess@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:19:36 -0500 Subject: [PATCH 4/6] unit test fix --- src/SessionChannel.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/SessionChannel.cs b/src/SessionChannel.cs index 388f52b..b07cfef 100644 --- a/src/SessionChannel.cs +++ b/src/SessionChannel.cs @@ -640,11 +640,6 @@ private void ValidateStateForWrite() { this.ThrowIfDisposed(); - if (_fault != null) - { - throw _fault; - } - if (!CanWrite) { lock (_stateLock) @@ -656,6 +651,11 @@ private void ValidateStateForWrite() throw new SessionChannelException(ChannelErrorCode.ChannelClosed, "SessionChannel is closed"); } } + + if (_fault != null) + { + throw _fault; + } } private async ValueTask CopyToAsync(PipeReader source, PipeWriter destination, CancellationToken cancellationToken = default) From 4ad8f18a234b6e7fc49b5bcd483e4f172fa75911 Mon Sep 17 00:00:00 2001 From: Paul Bleess <8421069+pableess@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:39:38 -0500 Subject: [PATCH 5/6] fix close bug --- src/Session.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Session.cs b/src/Session.cs index b054a87..ec3b5e3 100644 --- a/src/Session.cs +++ b/src/Session.cs @@ -116,7 +116,7 @@ public ValueTask OpenChannelAsync(SessionChannelOptions o if (waitForAcknowledgement) { - TaskCompletionSource tcs = new TaskCompletionSource(); + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); if (cancellationToken != default) { @@ -199,7 +199,14 @@ private async ValueTask AcceptChannelAsync(SessionChannel throw new SessionException(SessionErrorCode.SessionShutdown, "Session has been closed"); } - channel.Accept(); + try + { + channel.Accept(); + } + catch (SessionChannelException) + { + throw new SessionException(SessionErrorCode.SessionShutdown, "Session has been closed"); + } await channel.ApplyOptionsAsync(channelOptions, cancellationToken); return channel; From 8a24f264a36b3980995c90ae0704ece381d30892 Mon Sep 17 00:00:00 2001 From: Paul Bleess <8421069+pableess@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:46:24 -0500 Subject: [PATCH 6/6] unit test fix --- test/Yamux.Tests/ErrorPathTests.cs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/test/Yamux.Tests/ErrorPathTests.cs b/test/Yamux.Tests/ErrorPathTests.cs index 82b7c2c..f7d4526 100644 --- a/test/Yamux.Tests/ErrorPathTests.cs +++ b/test/Yamux.Tests/ErrorPathTests.cs @@ -181,19 +181,30 @@ public async Task FaultedSession_PropagatesToAccept() { await using var session = new Session(new StreamPeer(server), false); session.Start(); - using var channel = await session.AcceptAsync(); - + IDuplexSessionChannel? channel = null; try { - ReadResult res; - do - { - res = await channel.Input.ReadAsync(); - channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); - } while (!res.IsCanceled && !res.IsCompleted); + channel = await session.AcceptAsync(); + } + catch (SessionException) + { + return; } - catch (YamuxException) + + using (channel) { + try + { + ReadResult res; + do + { + res = await channel.Input.ReadAsync(); + channel.Input.AdvanceTo(res.Buffer.End, res.Buffer.End); + } while (!res.IsCanceled && !res.IsCompleted); + } + catch (YamuxException) + { + } } });