Files
DodoSSH/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs
T
jaap-jan eb354bcdd9 Add the SSH session layer and the terminal data plane
The throughput harness the plan requires before any UI, plus the SSH
plumbing under it. 94 new tests, no WebView involved.

Credit-based flow control is what makes `yes` survivable. A terminal renders
at 60 Hz at best while a remote produces output as fast as the network
allows, and the difference has to accumulate somewhere or be refused.
Credit is reserved *before* reading, never after: because the pump cannot
read more than the renderer has room for, the coalescing buffer is bounded
by the window rather than by how fast the remote can talk. When credit runs
out the pump stops reading, SSH's own receive window closes, and the remote
sshd blocks -- backpressure to the source with no custom protocol.

Verified by falsification, not just by passing: with the credit gate removed
three tests fail, including the throughput harness's bounded-memory
assertion. Acknowledgements are clamped because they cross into JavaScript,
where a buggy or hostile page could otherwise claim to have rendered a
gigabyte and talk the host into an unbounded read.

Host key trust is enforced by *failing* the connection rather than
prompting inside the handshake. SSH.NET raises verification synchronously,
so consulting the user there would block the handshake on a UI round trip
and deadlock the first time the prompt needed the UI thread. Unknown host
and changed key become distinct exceptions the caller resolves
asynchronously. A mismatch has no retry path at all: a dialog offering to
continue is how users are trained to click through the one warning that
actually indicates interception. A legitimately rebuilt server is handled by
removing the pin in settings, away from the moment of connecting.

The data plane serves the renderer page from the same loopback listener as
the socket, which makes Origin predictable -- always http://127.0.0.1:{port}
-- where a WebView virtual-host mapping would give a different origin per
backend and nothing to validate. The token is substituted at serve time, so
it never touches disk and never appears in a URL. Being clear about what
that buys: not protection from a process running as this user, which can
read our memory anyway, but from a page in the user's browser attempting
WebSocket connections to loopback ports, which is a real and routine thing.

Two bugs the tests caught. The accept loop handled connections serially, so
an upgraded WebSocket parked it inside the receive loop and every later
request went unanswered -- the page's own script among them. The suite hung
rather than failed, which is how I found it. And SHA-1 is unavoidable here:
RFC 6455 mandates it for Sec-WebSocket-Accept, where it authenticates
nothing. Suppressed narrowly with that reasoning; the alternative,
HttpListener.AcceptWebSocketAsync, throws PlatformNotSupportedException off
Windows.
2026-07-28 21:58:55 +02:00

309 lines
11 KiB
C#

using System.Buffers;
using System.Text;
using System.Threading.Channels;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal;
/// <summary>Where terminal frames are sent.</summary>
/// <remarks>
/// 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.
/// </remarks>
public interface ITerminalTransport
{
/// <summary>Sends one binary frame.</summary>
ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken);
}
/// <summary>Tuning for one session's output path.</summary>
public sealed class TerminalPumpOptions
{
/// <summary>How many bytes to read from the channel at once.</summary>
public int ReadBufferBytes { get; init; } = 32 * 1024;
/// <summary>
/// How long to accumulate output before sending it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public TimeSpan FlushInterval { get; init; } = TimeSpan.FromMilliseconds(16);
/// <summary>How far behind the renderer may fall, in bytes.</summary>
public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes;
}
/// <summary>
/// Moves bytes between one SSH shell channel and the renderer, under flow control.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// When credit runs out the pump stops reading. SSH's own receive window then closes, the remote
/// <c>sshd</c> blocks on write, and the process producing output blocks in turn — backpressure all the
/// way to the source, with no custom protocol.
/// </para>
/// </remarks>
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<byte[]> pending = Channel.CreateUnbounded<byte[]>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
private readonly CancellationTokenSource lifetime = new();
private int disposed;
/// <param name="sessionId">Identifies this session in every frame.</param>
/// <param name="session">The shell channel.</param>
/// <param name="transport">Where frames go.</param>
/// <param name="clock">Time source, so the flush interval is testable.</param>
/// <param name="options">Tuning, or null for the defaults.</param>
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);
}
/// <summary>This session's flow-control window.</summary>
public CreditWindow Credits { get; }
/// <summary>Total bytes read from the remote, for the throughput harness.</summary>
public long BytesRead { get; private set; }
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
public long FramesSent { get; private set; }
/// <summary>
/// Runs until the remote closes the channel or the token is cancelled.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>Forwards keystrokes to the remote.</summary>
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
session.WriteAsync(data, cancellationToken);
/// <summary>Tells the remote the terminal was resized.</summary>
public void Resize(TerminalSize size) => session.Resize(size);
/// <summary>Returns credit for bytes the renderer reported rendering.</summary>
public void Acknowledge(uint rendered) =>
Credits.Return(rendered > int.MaxValue ? int.MaxValue : (int)rendered);
/// <inheritdoc />
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<byte>.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<byte>.Shared.Return(buffer);
}
}
private async Task FlushLoopAsync(CancellationToken cancellationToken)
{
var segments = new List<byte[]>();
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<byte[]> 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<byte> payload,
CancellationToken cancellationToken)
{
FramesSent++;
await transport
.SendAsync(TerminalFrame.Create((byte)opcode, sessionId, payload.Span), cancellationToken)
.ConfigureAwait(false);
}
}