File: ServiceClient\GrpcTerminalClientStream.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// 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.Dashboard.ServiceClient;
 
/// <summary>
/// Client-side counterpart of the AppHost's <c>GrpcTerminalStream</c>: presents an <c>AttachTerminal</c> call as a
/// duplex <see cref="Stream"/> carrying opaque HMP1 bytes.
/// </summary>
/// <remarks>
/// The stream carries HMP1 unchanged between the AppHost and the dashboard's Hex1b workload adapter.
/// gRPC message boundaries are unrelated to HMP1 frame boundaries: reads hand back whatever bytes are
/// available and keep the unread remainder of a message for the next read.
/// </remarks>
internal sealed class GrpcTerminalClientStream : Stream
{
    private readonly AsyncDuplexStreamingCall<TerminalClientFrame, TerminalServerFrame> _call;
    private readonly string _terminalId;
    // The linked CTS that scopes the call outlives the method that created it, so the stream owns its disposal.
    private readonly IDisposable? _callScope;
    // gRPC request streams do not support concurrent writes, and the WebSocket pump is not guaranteed to be the only
    // writer, so serialize here rather than relying on the caller.
    private readonly SemaphoreSlim _writeLock = new(1, 1);
    private ReadOnlyMemory<byte> _remainder;
    private bool _completed;
    private bool _disposed;
    private bool _terminalEnded;
 
    public bool TerminalEnded => Volatile.Read(ref _terminalEnded);
 
    public GrpcTerminalClientStream(
        AsyncDuplexStreamingCall<TerminalClientFrame, TerminalServerFrame> call,
        string terminalId,
        IDisposable? callScope = null)
    {
        _call = call;
        _terminalId = terminalId;
        _callScope = callScope;
    }
 
    /// <summary>
    /// Sends the selector frame that tells the AppHost which terminal this call is attaching to. The AppHost
    /// reads exactly one such frame before handing the call to Hex1b, so this must happen before any payload.
    /// </summary>
    public Task SendSelectorAsync(CancellationToken cancellationToken)
    {
        var frame = new TerminalClientFrame
        {
            TerminalId = _terminalId
        };
 
        return _call.RequestStream.WriteAsync(frame, cancellationToken);
    }
 
    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;
            }
 
            bool hasNext;
            try
            {
                hasNext = await _call.ResponseStream.MoveNext(cancellationToken).ConfigureAwait(false);
            }
            catch (RpcException ex) when (cancellationToken.IsCancellationRequested)
            {
                throw new OperationCanceledException("Terminal read was cancelled.", ex, cancellationToken);
            }
            catch (RpcException ex)
            {
                // Hex1b's transport reader handles Stream errors, not gRPC exceptions. Keep the
                // original status available to the HTTP endpoint while preserving the Stream contract.
                throw new IOException("The AppHost terminal transport disconnected.", ex);
            }
 
            if (!hasNext)
            {
                _completed = true;
                return 0;
            }
 
            if (_call.ResponseStream.Current.Ended)
            {
                Volatile.Write(ref _terminalEnded, true);
                _completed = true;
                return 0;
            }
 
            // A zero-length payload is not end of stream; keep waiting for real bytes.
            _remainder = _call.ResponseStream.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.
        //
        // The terminal id is only set on the selector frame; subsequent frames carry opaque transport bytes.
        var frame = new TerminalClientFrame { Data = ByteString.CopyFrom(buffer.Span) };
 
        await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            await _call.RequestStream.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
        }
        catch (RpcException ex) when (cancellationToken.IsCancellationRequested)
        {
            throw new OperationCanceledException("Terminal write was cancelled.", ex, cancellationToken);
        }
        catch (RpcException ex)
        {
            throw new IOException("The AppHost terminal transport disconnected.", ex);
        }
        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 && !_disposed)
        {
            _disposed = true;
 
            // Disposing the call is what tears the tunnel down: the AppHost sees the request stream end and releases
            // the terminal session's attachment. Best effort because the call may already be faulted or cancelled.
            try
            {
                _call.Dispose();
            }
            catch
            {
                // Expected on a call that is already faulted or cancelled, which is the common case here. There is
                // nothing to report: the connection is going away regardless, and the caller is disposing.
            }
 
            _writeLock.Dispose();
            _callScope?.Dispose();
        }
 
        base.Dispose(disposing);
    }
}