using System.Buffers;
using System.Globalization;
using System.Text;
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);
}
///
/// How one session ended, and the sentence that says so.
///
/// Which of the three ways it was.
///
/// What to show a person. Written into the pane by the renderer, and available to the shell beside the
/// it belongs to.
///
///
/// The pair travels together because either alone is not enough. The enum is what code branches on — whether
/// to offer a reconnect, whether a tab is worth keeping — and the sentence is the detail underneath it, which
/// for a lost connection is the only place the actual cause appears.
///
public sealed record TerminalSessionOutcome(SshSessionEnd End, string Description);
/// 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;
///
/// How much unsent output to hold for a renderer that is behind, before dropping the oldest.
///
///
/// This is the number that bounds a session's memory, and says at length
/// why it rather than is the one that does. Larger drops less and shows a
/// flood's tail later; smaller drops sooner and keeps the terminal closer to live.
///
public int BacklogBytes { get; init; } = OutputBacklog.DefaultCapacityBytes;
}
///
/// Moves bytes between one SSH shell channel and the renderer, under flow control.
///
///
///
/// ◆ The pump reads the shell channel continuously and never stops, which is deliberate rather than
/// careless. It used to stop as soon as the renderer fell behind, on the reasoning that a reader
/// which does not read closes SSH's receive window, blocks the remote sshd on write, and blocks
/// the process producing the output in turn — backpressure all the way to the source, with no custom
/// protocol. That reasoning is wrong for this client's SSH library, and wrong in the direction that costs
/// memory: SSH.NET re-opens the channel window when bytes arrive rather than when they are
/// consumed, and keeps everything it has been handed in a growable array it does not give back. Not
/// reading slowed nothing down; it only moved the flood into a buffer with no ceiling.
/// carries the full account.
///
///
/// So there are two bounds now, each enforced where it can be. The backlog bounds what is waiting to be
/// sent, by dropping its oldest bytes and saying so. The credit window bounds what has been sent and not
/// yet drawn — the buffer inside the WebView that nothing on this side can measure — and the flush loop
/// reserves against it before every frame, holding off while there is none. Reading is no longer part of
/// either, because reading was never what the remote was waiting on.
///
///
public sealed class TerminalSessionPump : IAsyncDisposable
{
///
/// The most output one frame carries.
///
///
/// Under the 85,000-byte threshold at which the runtime allocates on the large object heap, and that is
/// the whole of why the number is here: a frame is allocated per send, and a session doing megabytes a
/// second would otherwise put a quarter-megabyte array on the LOH sixty times a second, which is not
/// compacted and is collected only with a generation 2 pass. Below the threshold the same traffic is
/// generation 0 garbage that costs nothing to collect. It is a ceiling rather than a target — a quiet
/// terminal sends whatever it has, which is a few bytes.
///
private const int MaximumFrameBytes = 64 * 1024;
private readonly uint sessionId;
private readonly ISshShellSession session;
private readonly ITerminalTransport transport;
private readonly TimeProvider clock;
private readonly TerminalPumpOptions options;
private readonly OutputBacklog backlog;
private readonly CancellationTokenSource lifetime = new();
/// One write to the remote at a time, in the order they were asked for.
///
/// The data plane hands each input frame over without waiting for the one before it — a blocked write
/// must not hold up the acknowledgements of every other session on the socket — so without this, two
/// frames' writes run at once. SSH.NET's ShellStream takes no lock around its write buffer, and its
/// flush sends what is there and then empties it by length: bytes added mid-flush are discarded unsent.
/// A snippet's RUN is its text and then a carriage return as two frames, which is exactly the shape that lost or
/// reordered keystrokes. Waiters on a are released in the order they arrived,
/// and the data plane calls this from one receive loop, so arrival order is the order typed.
///
private readonly SemaphoreSlim inputGate = new(1, 1);
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);
backlog = new OutputBacklog(this.options.BacklogBytes);
}
/// 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 bytes this session discarded because the renderer could not keep up with the remote.
///
///
/// Zero for every ordinary session, and the number to look at first when somebody reports missing
/// output. The user is told as it happens — a dropped run puts a line in the pane rather than a gap —
/// so this is for a test or a report rather than for the interface.
///
public long BytesDropped => backlog.DroppedTotal;
/// Bytes read from the remote and not yet sent to the renderer.
public int BytesPending => backlog.Buffered;
/// The last of what the remote wrote, for the phone's command history.
/// See for why a client keeps any of it.
public OutputTail Recent { get; } = new();
/// Total frames sent to the renderer, for the throughput harness.
public long FramesSent { get; private set; }
///
/// How this session ended and what to say about it, once has returned.
///
///
///
/// Meaningless before the run completes, and deliberately not nullable for it: a session that has not
/// ended is not a session that ended in an unknown way, and every caller here already knows whether the
/// run is over — the workspace awaits it before reading this.
///
///
/// The same description that goes to the renderer in the
/// frame, so the line written into the pane and whatever the shell draws around the tab are the one
/// sentence rather than two that can disagree.
///
///
public TerminalSessionOutcome Outcome { get; private set; } =
new(SshSessionEnd.ClosedLocally, "The session was closed.");
///
/// 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);
var outcome = await ReadUntilItEndsAsync(reader).ConfigureAwait(false);
// Before the drain and the last frame, so that anything reading this off a completed run — the
// workspace's own announcement — cannot see the default.
Outcome = outcome;
// 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.
backlog.Complete();
// ◆ AND RELEASE THE FLUSHER IF IT IS PARKED ON CREDIT, WHICH IS NOT THE SAME SIGNAL. Completing
// the backlog wakes anything waiting for output; it says nothing to a flush loop that has output
// and is waiting for the renderer to make room for it. Against a renderer that has stopped
// acknowledging — the page's process killed, the window gone — that acknowledgement never comes,
// and without this the await below would never return: the shell would have ended, the tab would
// never be told, and the session would never be torn down. Resetting rather than waiting is
// right because the window describes a renderer's remaining room and there is nothing left to
// protect it from: what follows is one backlog's worth of tail and then the session is over.
Credits.Reset();
try
{
await flusher.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Cancelled during shutdown; the drain below is best-effort anyway.
}
await SendAsync(
TerminalServerOpcode.SessionClosed,
Encoding.UTF8.GetBytes(outcome.Description),
CancellationToken.None)
.ConfigureAwait(false);
}
///
/// Waits for the read loop to finish, and says which of the three ways it did.
///
///
/// Split out of for length rather than for reuse, and it is the half of that
/// method with no side effects: three branches, one answer, nothing touched.
///
/// The read loop's task.
private async Task ReadUntilItEndsAsync(Task reader)
{
try
{
await reader.ConfigureAwait(false);
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
// it is over is not a question asked of the transport now — by now the server has very likely hung
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
// latched at the moment it happened. See ISshShellSession.TransportFailure.
return session.TransportFailure is { Length: > 0 } failure
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
}
catch (OperationCanceledException)
{
return new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
}
catch (Exception exception)
{
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
// that arrives as the cancellation above — so it is the connection going out from under the read.
return new TerminalSessionOutcome(
SshSessionEnd.ConnectionLost,
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
}
}
/// Forwards keystrokes to the remote, after any still being written.
public async ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken cancellationToken)
{
await inputGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await session.WriteAsync(data, cancellationToken).ConfigureAwait(false);
}
finally
{
inputGate.Release();
}
}
/// 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();
backlog.Complete();
lifetime.Dispose();
await session.DisposeAsync().ConfigureAwait(false);
}
///
/// No credit is consulted here, and the class remark says at length why: the remote is not waiting on
/// this loop, so pausing it buys nothing and costs a buffer inside SSH.NET that grows without bound.
/// What this loop owes is to keep that buffer empty, which means reading as fast as the channel
/// delivers and putting the result somewhere with a ceiling.
///
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
var buffer = ArrayPool.Shared.Rent(options.ReadBufferBytes);
try
{
while (true)
{
// Thrown rather than tested in the loop condition, and the difference is the whole of how
// this session's ending is described. RunAsync reads a read loop that RETURNS as
// the remote having closed the channel, and one that throws OperationCanceledException as
// this client having ended it. A condition on the token would leave by the first door for
// a reason that belongs to the second, and a tab the user closed would report itself as a
// shell that exited on its own.
cancellationToken.ThrowIfCancellationRequested();
var read = await session
.ReadAsync(buffer.AsMemory(0, options.ReadBufferBytes), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return;
}
BytesRead += read;
Recent.Append(buffer.AsSpan(0, read));
// Copied out of the rented buffer, because the backlog holds it until the renderer has
// room and the next read is already about to overwrite this one.
backlog.Append(buffer[..read]);
}
}
finally
{
ArrayPool.Shared.Return(buffer);
}
}
private async Task FlushLoopAsync(CancellationToken cancellationToken)
{
while (await backlog.WaitForOutputAsync(cancellationToken).ConfigureAwait(false))
{
if (backlog.IsCompleted)
{
// The reader has finished and what is left is the tail, which goes out regardless of
// credit. Leaving by this door rather than going round again is load-bearing: with the
// backlog complete, SendWhatCreditAllowsAsync declines to wait for an acknowledgement
// that may never come, so a renderer that has stopped would put this loop into a spin —
// output to send, no credit to send it with, and a wait for more output that returns at
// once because there is some.
break;
}
// Coalesce for one frame's worth of time before the first send, so a burst arriving at an idle
// terminal becomes a single write. Under sustained output the loop below never comes back
// here, and does not need to: credit is what paces it then, and a frame is as large as the
// renderer has room for.
await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false);
await SendWhatCreditAllowsAsync(cancellationToken).ConfigureAwait(false);
}
await DrainAsync().ConfigureAwait(false);
}
/// Sends until the backlog is empty or the renderer has no room left.
private async Task SendWhatCreditAllowsAsync(CancellationToken cancellationToken)
{
while (true)
{
var wanted = Math.Min(backlog.Buffered, MaximumFrameBytes);
if (wanted == 0)
{
return;
}
var granted = Credits.TryReserve(wanted);
if (granted == 0)
{
// The renderer is behind. Nothing more goes out until it acknowledges, and meanwhile the
// backlog holds what it can and drops its oldest for what it cannot — which is the whole
// of how this session's memory stays bounded.
if (backlog.IsCompleted)
{
// Except at the end, where waiting on an acknowledgement that may never come would
// hang the session's teardown behind a renderer that has already gone.
return;
}
await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false);
continue;
}
var sent = await SendFromBacklogAsync(granted, cancellationToken).ConfigureAwait(false);
if (granted > sent)
{
// Whatever was reserved and not used, so a backlog that shrank between the reservation
// and the take does not permanently narrow the window. Guarded rather than called with a
// zero, because returning credit wakes every waiter and replaces the signal they were
// waiting on, and this is the hot path.
Credits.Return(granted - sent);
}
}
}
///
/// Puts the last of the output out, regardless of credit.
///
///
/// The session is over by the time this runs, so there is no renderer left to protect from a flood —
/// and what remains is bounded by the backlog's own capacity rather than by anything the remote can
/// still do. Waiting on credit here instead would mean a shell that exited against a renderer which
/// had gone never finished tearing down.
///
private async Task DrainAsync()
{
while (await SendFromBacklogAsync(MaximumFrameBytes, CancellationToken.None)
.ConfigureAwait(false) > 0)
{
// Until it is empty.
}
}
///
/// Moves up to out of the backlog and onto the wire.
///
/// How many bytes of output were sent, which is zero once the backlog is empty.
private async ValueTask SendFromBacklogAsync(int maxBytes, CancellationToken cancellationToken)
{
// Taken straight into the frame, header space and all. A second buffer here would be an
// allocation and a copy of every byte the terminal ever shows.
var frame = new byte[TerminalFrame.HeaderLength + maxBytes];
var taken = backlog.TakeInto(frame.AsSpan(TerminalFrame.HeaderLength), out var dropped);
if (dropped > 0)
{
// Sent immediately before the bytes it explains, and under its own opcode — see
// TerminalServerOpcode.OutputDropped for why this is not simply spliced into the output.
await SendAsync(TerminalServerOpcode.OutputDropped, DroppedNotice(dropped), cancellationToken)
.ConfigureAwait(false);
}
if (taken == 0)
{
return 0;
}
TerminalFrame.Write(frame, (byte)TerminalServerOpcode.Output, sessionId, default);
FramesSent++;
await transport
.SendAsync(frame.AsMemory(0, TerminalFrame.HeaderLength + taken), cancellationToken)
.ConfigureAwait(false);
return taken;
}
///
/// The line a user sees where output was dropped.
///
///
///
/// It opens with a reset rather than the colour, and that is the load-bearing part: the bytes were cut
/// at an arbitrary offset, so whatever escape sequence the remote was in the middle of has been left
/// half-said. Resetting first means the notice reads as a notice and the output after it starts from a
/// known state, instead of inheriting a colour or a mode from a sequence nobody finished.
///
///
/// Dim grey, matching the renderer's own banners for a session that closed and a view that
/// reconnected — this is the same kind of thing, a sentence from the application rather than from the
/// remote, and it should not read like part of the transcript.
///
///
private static byte[] DroppedNotice(long dropped) =>
Encoding.UTF8.GetBytes(string.Create(
CultureInfo.InvariantCulture,
$"\u001b[0m\r\n\u001b[38;5;244m── {Sized(dropped)} of output was dropped; "
+ $"the terminal could not keep up ──\u001b[0m\r\n"));
///
/// One decimal place from a megabyte up, because the number is an order of magnitude rather than an
/// accounting: what a person needs from it is whether they lost a screenful or a session's worth.
///
private static string Sized(long bytes) => bytes switch
{
< 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes} bytes"),
< 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / 1024.0:0.#} KB"),
< 1024L * 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024):0.#} MB"),
_ => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024 * 1024):0.#} GB"),
};
private async ValueTask SendAsync(
TerminalServerOpcode opcode,
ReadOnlyMemory payload,
CancellationToken cancellationToken)
{
FramesSent++;
await transport
.SendAsync(TerminalFrame.Create((byte)opcode, sessionId, payload.Span), cancellationToken)
.ConfigureAwait(false);
}
}