| File: Dashboard\GrpcTerminalStream.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. using Aspire.DashboardService.Proto.V1; using Google.Protobuf; using Grpc.Core; namespace Aspire.Hosting.Dashboard; /// <summary> /// Presents a bidirectional <c>AttachTerminal</c> gRPC call as a duplex <see cref="Stream"/>. /// </summary> /// <remarks> /// Hex1b's HMP1 server consumes plain streams (see <c>WithHmp1Server</c>), so tunneling a terminal session over gRPC /// only requires adapting the call's message pairs back into a byte stream. HMP1 framing is preserved end to end and /// is never interpreted here: gRPC message boundaries are unrelated to HMP1 frame boundaries, so reads hand back /// whatever bytes are available and keep the unread remainder of a message for the next read. /// </remarks> internal sealed class GrpcTerminalStream : Stream { private readonly IAsyncStreamReader<TerminalClientFrame> _requestStream; private readonly IServerStreamWriter<TerminalServerFrame> _responseStream; // gRPC response streams do not support concurrent writes. Hex1b writes terminal output from its own pump, so // serialize here rather than relying on the caller to do it. private readonly SemaphoreSlim _writeLock = new(1, 1); private ReadOnlyMemory<byte> _remainder; private bool _completed; public GrpcTerminalStream( IAsyncStreamReader<TerminalClientFrame> requestStream, IServerStreamWriter<TerminalServerFrame> responseStream) { _requestStream = requestStream; _responseStream = responseStream; } 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) { if (buffer.IsEmpty) { return 0; } while (_remainder.IsEmpty) { if (_completed) { return 0; } if (!await _requestStream.MoveNext(cancellationToken).ConfigureAwait(false)) { _completed = true; return 0; } // The selector frame is consumed by AttachTerminal before this stream is created, but a client is free to // send further frames with no payload; those must not be reported as end of stream. _remainder = _requestStream.Current.Data.Memory; } var count = Math.Min(buffer.Length, _remainder.Length); _remainder[..count].CopyTo(buffer); _remainder = _remainder[count..]; return count; } public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) { if (buffer.IsEmpty) { return; } // Copy rather than UnsafeWrap: the caller owns the buffer and may reuse it as soon as this method returns, // and gRPC does not guarantee the payload is serialized before the write task completes. var frame = new TerminalServerFrame { Data = ByteString.CopyFrom(buffer.Span) }; await WriteFrameAsync(frame, cancellationToken).ConfigureAwait(false); } public Task WriteEndedAsync(CancellationToken cancellationToken) => WriteFrameAsync(new TerminalServerFrame { Ended = true }, cancellationToken); private async Task WriteFrameAsync(TerminalServerFrame frame, CancellationToken cancellationToken) { await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { await _responseStream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); } finally { _writeLock.Release(); } } public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).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(); // gRPC flushes per message, so there is nothing to flush here. public override void Flush() { } public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); protected override void Dispose(bool disposing) { if (disposing) { _writeLock.Dispose(); } base.Dispose(disposing); } }