| File: ApplicationModel\TerminalClientStream.cs | Web Access |
| Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace Aspire.Hosting.ApplicationModel; /// <summary> /// Limits Hex1b's access to a caller-owned transport to the lifetime of one viewer attachment. /// </summary> internal sealed class TerminalClientStream(Stream transport) : Stream { private readonly object _gate = new(); private readonly CancellationTokenSource _disconnectCts = new(); private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _operations; private bool _closing; public Task Released => _released.Task; public override bool CanRead => true; public override bool CanWrite => true; public override bool CanSeek => false; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) { EnterOperation(); try { using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disconnectCts.Token); return await transport.ReadAsync(buffer, linked.Token).ConfigureAwait(false); } finally { ExitOperation(); } } public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) { EnterOperation(); try { using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disconnectCts.Token); await transport.WriteAsync(buffer, linked.Token).ConfigureAwait(false); } finally { ExitOperation(); } } public override async Task FlushAsync(CancellationToken cancellationToken) { EnterOperation(); try { using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disconnectCts.Token); await transport.FlushAsync(linked.Token).ConfigureAwait(false); } finally { ExitOperation(); } } public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); public override void Flush() => FlushAsync(CancellationToken.None).GetAwaiter().GetResult(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); private void EnterOperation() { lock (_gate) { ObjectDisposedException.ThrowIf(_closing, this); _operations++; } } private void ExitOperation() { lock (_gate) { if (--_operations == 0 && _closing) { _drained.TrySetResult(); } } } public override ValueTask DisposeAsync() { lock (_gate) { if (_closing) { return new ValueTask(Released); } _closing = true; if (_operations == 0) { _drained.TrySetResult(); } } _ = ReleaseAsync(); return new ValueTask(Released); } private async Task ReleaseAsync() { try { try { await _disconnectCts.CancelAsync().ConfigureAwait(false); } finally { // Hex1b 0.165.0 disposes each session's stream without joining its read/write pumps. // OnClientDisconnected also runs in parallel with that disposal, so neither the callback // nor the Dispose call alone is a transport-release signal. Reject new operations and // drain existing ones before allowing the gRPC handler to dispose its actual transport. // https://github.com/mitchdenny/hex1b/blob/39947cb9455dd39b8de6326c643baaf4e4324962/src/Hex1b/Hmp1/Hmp1PresentationAdapter.cs await _drained.Task.ConfigureAwait(false); _disconnectCts.Dispose(); } _released.TrySetResult(); } catch (Exception ex) { // Disposal can also be initiated by Hex1b, which suppresses transport cleanup errors. // Preserve those errors for the attachment owner instead. _released.TrySetException(ex); } } protected override void Dispose(bool disposing) { if (disposing) { DisposeAsync().AsTask().GetAwaiter().GetResult(); } base.Dispose(disposing); } }