using System.Buffers; using System.Text; using System.Threading.Channels; using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Terminal; /// Where terminal frames are sent. /// /// Implementations must serialise sends: a WebSocket does not permit concurrent writes, and the pump /// deliberately does not know whether its transport is a socket, a test double or something else. /// public interface ITerminalTransport { /// Sends one binary frame. ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken); } /// Tuning for one session's output path. public sealed class TerminalPumpOptions { /// How many bytes to read from the channel at once. public int ReadBufferBytes { get; init; } = 32 * 1024; /// /// How long to accumulate output before sending it. /// /// /// xterm cannot render faster than the display refreshes, so flushing more often than once a frame /// is work whose result is overwritten before anyone sees it. 16 ms is one frame at 60 Hz, and the /// added latency on an echoed keystroke is below the threshold of perception. /// public TimeSpan FlushInterval { get; init; } = TimeSpan.FromMilliseconds(16); /// How far behind the renderer may fall, in bytes. public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes; } /// /// Moves bytes between one SSH shell channel and the renderer, under flow control. /// /// /// /// Credit is reserved before reading, never after. That ordering is the whole design: because the pump /// cannot read more than the renderer has room for, the coalescing buffer is bounded by the credit /// window rather than by how fast the remote can talk. Reserving after reading would leave an /// unbounded queue between the socket and the screen, which is the failure mode this exists to /// prevent. /// /// /// When credit runs out the pump stops reading. SSH's own receive window then closes, the remote /// sshd blocks on write, and the process producing output blocks in turn — backpressure all the /// way to the source, with no custom protocol. /// /// public sealed class TerminalSessionPump : IAsyncDisposable { private readonly uint sessionId; private readonly ISshShellSession session; private readonly ITerminalTransport transport; private readonly TimeProvider clock; private readonly TerminalPumpOptions options; private readonly Channel pending = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); private readonly CancellationTokenSource lifetime = new(); private int disposed; /// Identifies this session in every frame. /// The shell channel. /// Where frames go. /// Time source, so the flush interval is testable. /// Tuning, or null for the defaults. public TerminalSessionPump( uint sessionId, ISshShellSession session, ITerminalTransport transport, TimeProvider clock, TerminalPumpOptions? options = null) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(transport); ArgumentNullException.ThrowIfNull(clock); this.sessionId = sessionId; this.session = session; this.transport = transport; this.clock = clock; this.options = options ?? new TerminalPumpOptions(); Credits = new CreditWindow(this.options.WindowBytes); } /// This session's flow-control window. public CreditWindow Credits { get; } /// Total bytes read from the remote, for the throughput harness. public long BytesRead { get; private set; } /// Total frames sent to the renderer, for the throughput harness. public long FramesSent { get; private set; } /// /// Runs until the remote closes the channel or the token is cancelled. /// /// /// The read and flush loops are separate tasks because a read blocks until bytes arrive: combining /// them would mean output sitting unflushed until the next byte happened to show up, so a prompt /// would appear only after the user pressed a key. /// public async Task RunAsync(CancellationToken cancellationToken) { using var linked = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, lifetime.Token); await SendAsync(TerminalServerOpcode.SessionOpened, default, linked.Token).ConfigureAwait(false); var reader = ReadLoopAsync(linked.Token); var flusher = FlushLoopAsync(linked.Token); string reason; try { await reader.ConfigureAwait(false); reason = "The remote closed the session."; } catch (OperationCanceledException) { reason = "The session was closed."; } catch (Exception exception) { reason = exception.Message; } // Stop the flusher, but only after draining what the reader already produced — the last thing // a remote writes is often the most important, and dropping it makes a clean exit look like a // crash. pending.Writer.TryComplete(); try { await flusher.ConfigureAwait(false); } catch (OperationCanceledException) { // Cancelled during shutdown; the drain below is best-effort anyway. } await SendAsync( TerminalServerOpcode.SessionClosed, Encoding.UTF8.GetBytes(reason), CancellationToken.None) .ConfigureAwait(false); } /// Forwards keystrokes to the remote. public ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => session.WriteAsync(data, cancellationToken); /// Tells the remote the terminal was resized. public void Resize(TerminalSize size) => session.Resize(size); /// Returns credit for bytes the renderer reported rendering. public void Acknowledge(uint rendered) => Credits.Return(rendered > int.MaxValue ? int.MaxValue : (int)rendered); /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref disposed, 1) == 1) { return; } await lifetime.CancelAsync().ConfigureAwait(false); // Unblocks anything waiting on credit that will now never be acknowledged. Credits.Reset(); pending.Writer.TryComplete(); lifetime.Dispose(); await session.DisposeAsync().ConfigureAwait(false); } private async Task ReadLoopAsync(CancellationToken cancellationToken) { var buffer = ArrayPool.Shared.Rent(options.ReadBufferBytes); try { while (!cancellationToken.IsCancellationRequested) { await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false); var granted = Credits.TryReserve(options.ReadBufferBytes); if (granted == 0) { continue; } int read; try { read = await session .ReadAsync(buffer.AsMemory(0, granted), cancellationToken) .ConfigureAwait(false); } catch { Credits.Return(granted); throw; } // Hand back what was reserved but not used, so a short read does not permanently // shrink the window. Credits.Return(granted - read); if (read == 0) { return; } BytesRead += read; await pending.Writer.WriteAsync(buffer[..read], cancellationToken).ConfigureAwait(false); } } finally { ArrayPool.Shared.Return(buffer); } } private async Task FlushLoopAsync(CancellationToken cancellationToken) { var segments = new List(); while (await pending.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { if (!pending.Reader.TryRead(out var first)) { continue; } segments.Clear(); segments.Add(first); // Coalesce for one frame's worth of time, then send everything at once. Whatever the // remote produced in that window becomes a single write to the terminal. await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false); while (pending.Reader.TryRead(out var more)) { segments.Add(more); } await SendOutputAsync(segments, cancellationToken).ConfigureAwait(false); } // The channel completed. Anything the reader wrote before finishing still has to go out. segments.Clear(); while (pending.Reader.TryRead(out var trailing)) { segments.Add(trailing); } if (segments.Count > 0) { await SendOutputAsync(segments, CancellationToken.None).ConfigureAwait(false); } } private async ValueTask SendOutputAsync(List segments, CancellationToken cancellationToken) { var total = 0; foreach (var segment in segments) { total += segment.Length; } var frame = new byte[TerminalFrame.HeaderLength + total]; TerminalFrame.Write(frame, (byte)TerminalServerOpcode.Output, sessionId, default); var offset = TerminalFrame.HeaderLength; foreach (var segment in segments) { segment.CopyTo(frame, offset); offset += segment.Length; } FramesSent++; await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); } private async ValueTask SendAsync( TerminalServerOpcode opcode, ReadOnlyMemory payload, CancellationToken cancellationToken) { FramesSent++; await transport .SendAsync(TerminalFrame.Create((byte)opcode, sessionId, payload.Span), cancellationToken) .ConfigureAwait(false); } }