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);
}
}