diff --git a/DodoSSH.slnx b/DodoSSH.slnx
index 503db9e..46956a9 100644
--- a/DodoSSH.slnx
+++ b/DodoSSH.slnx
@@ -18,12 +18,14 @@
+
+
diff --git a/src/DodoSSH.Client.Ssh/HostKeyTrust.cs b/src/DodoSSH.Client.Ssh/HostKeyTrust.cs
new file mode 100644
index 0000000..f52c126
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/HostKeyTrust.cs
@@ -0,0 +1,114 @@
+namespace DodoSSH.Client.Ssh;
+
+/// A host key as the server presented it during the handshake.
+/// Host as dialled.
+/// Port as dialled.
+/// Key algorithm, e.g. ssh-ed25519.
+/// OpenSSH-style fingerprint, from .
+public sealed record HostKeyPresentation(string Host, int Port, string Algorithm, string Fingerprint);
+
+///
+/// The trusted host keys a user has accumulated.
+///
+///
+/// Known hosts live in the end-to-end encrypted vault as a synced entity, not in a local file. Trust
+/// then follows the user to every device, and the server cannot tamper with it — which matters,
+/// because a server that could silently drop a pin could downgrade every connection to first-use.
+///
+public interface IKnownHostStore
+{
+ /// Returns the pinned fingerprint for a host and key algorithm, if there is one.
+ ///
+ /// Keyed on algorithm as well as host, because a server legitimately offers several host keys and
+ /// which one is negotiated can change between connections. Pinning only one and rejecting the
+ /// others would make a normal server look hostile.
+ ///
+ ValueTask FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
+
+ /// Records a host key as trusted.
+ ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
+}
+
+///
+/// The host has never been seen, so there is nothing to compare against.
+///
+///
+/// A distinct exception rather than a prompt inside the handshake, and that is a deliberate design
+/// choice. SSH.NET raises host key verification as a synchronous event, so consulting the user from
+/// inside it would mean blocking the handshake thread on a UI round trip — sync-over-async, and a
+/// deadlock the first time the prompt needs the UI thread. Failing the connection and letting the
+/// caller prompt keeps everything asynchronous, at the cost of a second TCP connection the first
+/// time a host is used.
+///
+public sealed class SshHostKeyUnknownException(HostKeyPresentation presentation)
+ : Exception($"The host key for {presentation.Host}:{presentation.Port} is not trusted yet.")
+{
+ /// The key the server offered, to show the user before they trust it.
+ public HostKeyPresentation Presentation { get; } = presentation;
+}
+
+///
+/// The host presented a different key from the one pinned for it.
+///
+///
+/// This must stay a hard block with no "continue anyway" in the connect path. A dialog offering to
+/// proceed is how users are trained to click through the one warning that actually indicates an
+/// interception. A legitimate key change — a rebuilt server — is handled by explicitly removing the
+/// pin in the host's settings, which is a deliberate act performed away from the moment of
+/// connecting.
+///
+public sealed class SshHostKeyMismatchException(HostKeyPresentation presentation, string pinnedFingerprint)
+ : Exception(
+ $"The host key for {presentation.Host}:{presentation.Port} has changed. "
+ + $"Pinned {pinnedFingerprint}, but the server offered {presentation.Fingerprint}.")
+{
+ /// The key the server offered.
+ public HostKeyPresentation Presentation { get; } = presentation;
+
+ /// The key previously trusted for this host.
+ public string PinnedFingerprint { get; } = pinnedFingerprint;
+}
+
+///
+/// A known-host store held in memory.
+///
+///
+/// Stands in until the encrypted local cache lands. Trust is lost when the process exits, so a user
+/// is asked about every host on every launch — noisy, but the noise is the correct failure mode for a
+/// placeholder: it cannot be mistaken for working persistence.
+///
+public sealed class InMemoryKnownHostStore : IKnownHostStore
+{
+ private readonly Dictionary pins = new(StringComparer.Ordinal);
+ private readonly Lock gate = new();
+
+ ///
+ public ValueTask FindAsync(
+ string host,
+ int port,
+ string algorithm,
+ CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ return ValueTask.FromResult(pins.GetValueOrDefault(Key(host, port, algorithm)));
+ }
+ }
+
+ ///
+ public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(presentation);
+
+ lock (gate)
+ {
+ pins[Key(presentation.Host, presentation.Port, presentation.Algorithm)] =
+ presentation.Fingerprint;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static string Key(string host, int port, string algorithm) =>
+ $"{host}:{port}/{algorithm}";
+}
diff --git a/src/DodoSSH.Client.Ssh/SshConnection.cs b/src/DodoSSH.Client.Ssh/SshConnection.cs
new file mode 100644
index 0000000..4a8bd1a
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/SshConnection.cs
@@ -0,0 +1,82 @@
+namespace DodoSSH.Client.Ssh;
+
+/// How to authenticate to a host.
+///
+/// Always decrypted from the vault immediately before use and never persisted outside it. Because
+/// SSH terminates on the client, using a credential requires its plaintext here — which is exactly
+/// why the Connect permission is a UI hint and not an enforceable boundary. See ADR 0001.
+///
+public abstract record SshCredential;
+
+/// Password authentication, and keyboard-interactive where the server prefers it.
+public sealed record SshPasswordCredential(string Password) : SshCredential;
+
+/// Public-key authentication.
+/// The private key in PEM form, as stored in the vault.
+/// Passphrase protecting the key, when it has one.
+public sealed record SshPrivateKeyCredential(byte[] PrivateKeyPem, string? Passphrase) : SshCredential;
+
+/// Everything needed to reach one host.
+/// Hostname or address.
+/// Port.
+/// Remote account.
+/// How to authenticate.
+/// How long to wait for the transport and handshake.
+public sealed record SshConnectionRequest(
+ string Host,
+ int Port,
+ string Username,
+ SshCredential Credential,
+ TimeSpan? ConnectTimeout = null);
+
+/// An interactive shell over a pseudo-terminal.
+public interface ISshShellSession : IAsyncDisposable
+{
+ /// Whether the channel is still usable.
+ bool IsOpen { get; }
+
+ /// Reads whatever output is available, blocking until at least one byte arrives.
+ /// Bytes read, or 0 once the remote closes the channel.
+ ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken);
+
+ /// Sends keystrokes to the remote.
+ ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken);
+
+ ///
+ /// Tells the remote the terminal has been resized.
+ ///
+ ///
+ /// Verified to reach the remote against a real sshd; see PtyAndResizeSpikeTests. Sizes
+ /// that are not are dropped rather than forwarded.
+ ///
+ void Resize(TerminalSize size);
+}
+
+/// An authenticated connection to one host.
+public interface ISshConnection : IAsyncDisposable
+{
+ /// Whether the transport is still up.
+ bool IsConnected { get; }
+
+ /// The host key that was accepted for this connection.
+ HostKeyPresentation HostKey { get; }
+
+ /// Opens an interactive shell with a pseudo-terminal.
+ Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
+}
+
+/// Opens connections, enforcing host key trust before authenticating.
+public interface ISshConnectionFactory
+{
+ ///
+ /// Connects and authenticates.
+ ///
+ ///
+ /// The host has no pinned key. The caller must show the fingerprint, and only on explicit
+ /// confirmation record it via and retry.
+ ///
+ ///
+ /// The presented key differs from the pin. There is no retry path: this is a hard block.
+ ///
+ Task ConnectAsync(SshConnectionRequest request, CancellationToken cancellationToken);
+}
diff --git a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
new file mode 100644
index 0000000..40c51d8
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
@@ -0,0 +1,248 @@
+using System.Text;
+using Renci.SshNet;
+using Renci.SshNet.Common;
+
+namespace DodoSSH.Client.Ssh;
+
+///
+/// Opens SSH connections with SSH.NET, checking host key trust during the handshake.
+///
+///
+/// The pinned fingerprint is looked up before connecting, so the comparison inside SSH.NET's
+/// synchronous HostKeyReceived event is a pure equality check with no I/O and no chance of
+/// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an
+/// exception the caller resolves asynchronously.
+///
+public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory
+{
+ private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
+
+ ///
+ public async Task ConnectAsync(
+ SshConnectionRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var client = new SshClient(BuildConnectionInfo(request));
+ var gate = new HostKeyGate(knownHosts, request, cancellationToken);
+
+ client.HostKeyReceived += gate.OnHostKeyReceived;
+
+ try
+ {
+ await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is SshConnectionException or SshAuthenticationException)
+ {
+ client.Dispose();
+
+ // Translate a refusal we caused ourselves into something the caller can act on. Without
+ // this the user sees "connection lost" for what is really "do you trust this key?".
+ throw gate.TranslateFailure() ?? exception;
+ }
+ catch
+ {
+ client.Dispose();
+ throw;
+ }
+
+ return new SshNetConnection(client, gate.Presented!);
+ }
+
+ ///
+ /// Decides host key trust during the handshake, and remembers enough to explain a refusal.
+ ///
+ ///
+ /// Separate from the connect method because the decision is the security-relevant part and reads
+ /// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
+ /// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
+ ///
+ private sealed class HostKeyGate(
+ IKnownHostStore knownHosts,
+ SshConnectionRequest request,
+ CancellationToken cancellationToken)
+ {
+ /// What the server offered, once the handshake has reached that point.
+ public HostKeyPresentation? Presented { get; private set; }
+
+ private string? pinned;
+ private bool mismatch;
+
+ public void OnHostKeyReceived(object? sender, HostKeyEventArgs e)
+ {
+ var presentation = new HostKeyPresentation(
+ request.Host,
+ request.Port,
+ e.HostKeyName,
+ SshHostKeyFingerprint.Format(e.HostKey));
+
+ Presented = presentation;
+
+ // Looked up here rather than before connecting, because the negotiated algorithm is only
+ // known now and a server may choose a different one than it did last time.
+ //
+ // This is the one place the design cannot stay asynchronous: SSH.NET raises host key
+ // verification synchronously. It is a local store read rather than a UI round trip, and
+ // making the store synchronous instead would rule out a vault-backed implementation.
+ pinned = knownHosts
+ .FindAsync(request.Host, request.Port, e.HostKeyName, cancellationToken)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult();
+
+ if (pinned is null)
+ {
+ // Refused, not prompted. The caller decides, off the handshake thread.
+ e.CanTrust = false;
+ return;
+ }
+
+ var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
+ mismatch = !matches;
+ e.CanTrust = matches;
+ }
+
+ /// The specific exception for a refusal this gate caused, or null if it did not.
+ public Exception? TranslateFailure()
+ {
+ if (Presented is not { } presentation)
+ {
+ return null;
+ }
+
+ if (mismatch && pinned is { } pin)
+ {
+ return new SshHostKeyMismatchException(presentation, pin);
+ }
+
+ return pinned is null ? new SshHostKeyUnknownException(presentation) : null;
+ }
+ }
+
+ ///
+ /// The known-host lookup inside the synchronous event is the one place this design cannot avoid
+ /// blocking. It is a local store read rather than a UI round trip, and the alternative — making
+ /// the store synchronous — would rule out the encrypted vault-backed implementation entirely.
+ ///
+ private static ConnectionInfo BuildConnectionInfo(SshConnectionRequest request)
+ {
+ AuthenticationMethod method = request.Credential switch
+ {
+ SshPasswordCredential password =>
+ new PasswordAuthenticationMethod(request.Username, password.Password),
+
+ SshPrivateKeyCredential key => new PrivateKeyAuthenticationMethod(
+ request.Username,
+ CreatePrivateKeyFile(key)),
+
+ _ => throw new NotSupportedException(
+ $"Credential type {request.Credential.GetType().Name} is not supported."),
+ };
+
+ return new ConnectionInfo(request.Host, request.Port, request.Username, method)
+ {
+ Timeout = request.ConnectTimeout ?? DefaultConnectTimeout,
+ };
+ }
+
+ private static PrivateKeyFile CreatePrivateKeyFile(SshPrivateKeyCredential credential)
+ {
+ using var stream = new MemoryStream(credential.PrivateKeyPem, writable: false);
+
+ return credential.Passphrase is null
+ ? new PrivateKeyFile(stream)
+ : new PrivateKeyFile(stream, credential.Passphrase);
+ }
+}
+
+/// An SSH.NET-backed connection.
+internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hostKey) : ISshConnection
+{
+ ///
+ public bool IsConnected => client.IsConnected;
+
+ ///
+ public HostKeyPresentation HostKey { get; } = hostKey;
+
+ ///
+ public Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var effective = size.IsUsable ? size : TerminalSize.Default;
+
+ // 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
+ // only delays the first byte reaching the screen.
+ var shell = client.CreateShellStream(
+ "xterm-256color",
+ effective.Columns,
+ effective.Rows,
+ effective.PixelWidth,
+ effective.PixelHeight,
+ 4096);
+
+ return Task.FromResult(new SshNetShellSession(shell));
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ client.Dispose();
+ return ValueTask.CompletedTask;
+ }
+}
+
+/// An SSH.NET-backed shell session.
+internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
+{
+ ///
+ public bool IsOpen => shell.CanRead;
+
+ ///
+ ///
+ /// ShellStream does not override ReadAsync, so the base
+ /// implementation runs the blocking read on a thread-pool thread. Every idle session therefore
+ /// parks one thread; see docs/platform-flags.md.
+ ///
+ public ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) =>
+ shell.ReadAsync(buffer, cancellationToken);
+
+ ///
+ public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) =>
+ shell.WriteAsync(data, cancellationToken);
+
+ ///
+ public void Resize(TerminalSize size)
+ {
+ if (!size.IsUsable)
+ {
+ // A collapsed pane or a minimised window produces these. Forwarding one leaves the
+ // remote's idea of the terminal nonsensical until the next resize arrives.
+ return;
+ }
+
+ shell.ChangeWindowSize(size.Columns, size.Rows, size.PixelWidth, size.PixelHeight);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await shell.DisposeAsync().ConfigureAwait(false);
+ }
+}
+
+/// Convenience helpers over a shell session.
+public static class SshShellSessionExtensions
+{
+ /// Writes UTF-8 text to the remote.
+ public static ValueTask WriteTextAsync(
+ this ISshShellSession session,
+ string text,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ return session.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken);
+ }
+}
diff --git a/src/DodoSSH.Client.Ssh/TerminalSize.cs b/src/DodoSSH.Client.Ssh/TerminalSize.cs
new file mode 100644
index 0000000..e43367c
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/TerminalSize.cs
@@ -0,0 +1,35 @@
+using System.Runtime.InteropServices;
+
+namespace DodoSSH.Client.Ssh;
+
+///
+/// A pseudo-terminal's dimensions.
+///
+///
+/// Both the character grid and the pixel extent, because the SSH pty-req and
+/// window-change requests carry both. Pixel dimensions are what let a remote program draw
+/// sixel graphics or size an image correctly; sending zeroes is legal and tells the remote there is
+/// no pixel information, which is not the same as telling it the terminal is zero pixels wide.
+///
+/// Character columns.
+/// Character rows.
+/// Width in pixels, or 0 when unknown.
+/// Height in pixels, or 0 when unknown.
+[StructLayout(LayoutKind.Auto)]
+public readonly record struct TerminalSize(
+ ushort Columns,
+ ushort Rows,
+ ushort PixelWidth = 0,
+ ushort PixelHeight = 0)
+{
+ /// The conventional default, for a session opened before the UI has measured itself.
+ public static TerminalSize Default => new(80, 24);
+
+ /// Whether the size is usable as a terminal.
+ ///
+ /// A zero dimension is worth rejecting rather than forwarding: a resize to 0×0 arrives naturally
+ /// when a pane is collapsed or a window minimised, and passing it on makes the remote's idea of
+ /// the terminal nonsensical until the next resize.
+ ///
+ public bool IsUsable => Columns > 0 && Rows > 0;
+}
diff --git a/src/DodoSSH.Client.Terminal/CreditWindow.cs b/src/DodoSSH.Client.Terminal/CreditWindow.cs
new file mode 100644
index 0000000..70a2970
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/CreditWindow.cs
@@ -0,0 +1,152 @@
+namespace DodoSSH.Client.Terminal;
+
+///
+/// Credit-based flow control over one terminal session's output.
+///
+///
+///
+/// This is what makes yes survivable. A terminal emulator renders at 60 Hz at best, while a
+/// remote process can produce output as fast as the network allows. Without a limit the difference
+/// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the
+/// application's memory grows until it dies.
+///
+///
+/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte
+/// sent to it consumes credit; each byte it reports having actually rendered returns credit. When
+/// credit reaches zero the pump stops reading the SSH channel. That closes SSH's own receive
+/// window, which makes the remote sshd block on write, which propagates the backpressure all
+/// the way to the process producing the output. Nothing buffers without bound because nothing is read
+/// that cannot be delivered.
+///
+///
+/// The window has to be large enough that a normal burst never stalls and small enough to bound
+/// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's
+/// in-flight cost at a quarter of a megabyte.
+///
+///
+public sealed class CreditWindow
+{
+ /// Default window size: 256 KiB.
+ public const int DefaultWindowBytes = 256 * 1024;
+
+ private readonly Lock gate = new();
+ private readonly int windowBytes;
+
+ /// Signalled whenever credit becomes available.
+ private TaskCompletionSource available = CreateSignal();
+
+ private int outstanding;
+
+ /// How many unrendered bytes the renderer may be behind by.
+ public CreditWindow(int windowBytes = DefaultWindowBytes)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(windowBytes, 1);
+
+ this.windowBytes = windowBytes;
+ }
+
+ /// Bytes sent but not yet reported as rendered.
+ public int Outstanding
+ {
+ get
+ {
+ lock (gate)
+ {
+ return outstanding;
+ }
+ }
+ }
+
+ /// Bytes that may be sent right now.
+ public int Available
+ {
+ get
+ {
+ lock (gate)
+ {
+ return windowBytes - outstanding;
+ }
+ }
+ }
+
+ ///
+ /// Reserves up to bytes of credit, returning how many were granted.
+ ///
+ ///
+ /// A partial grant rather than all-or-nothing. Refusing to send 40 KiB because only 30 KiB of
+ /// credit remains would stall a session that could have made progress, and the caller has to
+ /// handle short writes regardless.
+ ///
+ /// Bytes reserved, which is zero when the window is full.
+ public int TryReserve(int wanted)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(wanted, 0);
+
+ lock (gate)
+ {
+ var granted = Math.Min(wanted, windowBytes - outstanding);
+ outstanding += granted;
+
+ return granted;
+ }
+ }
+
+ ///
+ /// Returns credit for bytes the renderer has reported rendering.
+ ///
+ ///
+ /// Clamped rather than trusted. The acknowledgement crosses a process boundary into JavaScript, so
+ /// a buggy or tampered page could acknowledge more than it was ever sent; letting that drive
+ /// outstanding negative would hand it an unbounded window and reintroduce exactly the
+ /// failure this class exists to prevent.
+ ///
+ public void Return(int rendered)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rendered, 0);
+
+ lock (gate)
+ {
+ outstanding -= Math.Min(rendered, outstanding);
+
+ // Released inside the lock so a waiter cannot miss the transition, and completed
+ // asynchronously so a continuation cannot run while the lock is held.
+ available.TrySetResult();
+ available = CreateSignal();
+ }
+ }
+
+ /// Waits until at least one byte of credit is available.
+ public async ValueTask WaitForCreditAsync(CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ Task signal;
+
+ lock (gate)
+ {
+ if (windowBytes - outstanding > 0)
+ {
+ return;
+ }
+
+ signal = available.Task;
+ }
+
+ await signal.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ /// Discards all outstanding credit, for a session being torn down.
+ public void Reset()
+ {
+ lock (gate)
+ {
+ outstanding = 0;
+ available.TrySetResult();
+ available = CreateSignal();
+ }
+ }
+
+ private static TaskCompletionSource CreateSignal() =>
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+}
diff --git a/src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj b/src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj
new file mode 100644
index 0000000..5c6cd27
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Terminal/TerminalAssets.cs b/src/DodoSSH.Client.Terminal/TerminalAssets.cs
new file mode 100644
index 0000000..eecbee9
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/TerminalAssets.cs
@@ -0,0 +1,31 @@
+namespace DodoSSH.Client.Terminal;
+
+/// One file the renderer needs.
+/// MIME type, including a charset for text.
+/// The bytes to serve.
+public sealed record TerminalAsset(string ContentType, byte[] Content);
+
+///
+/// Supplies the renderer's HTML, JavaScript and CSS.
+///
+///
+/// An abstraction so the data plane does not depend on Avalonia's resource system, which keeps the
+/// whole transport testable with in-memory assets. The application implements this over
+/// AvaloniaResource; tests hand over a dictionary.
+///
+public interface ITerminalAssetProvider
+{
+ ///
+ /// Returns the asset for a request path, or null if there is none.
+ ///
+ /// Absolute request path, beginning with a slash.
+ TerminalAsset? Find(string path);
+}
+
+/// Assets held in a dictionary.
+public sealed class InMemoryTerminalAssetProvider(IReadOnlyDictionary assets)
+ : ITerminalAssetProvider
+{
+ ///
+ public TerminalAsset? Find(string path) => assets.GetValueOrDefault(path);
+}
diff --git a/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs
new file mode 100644
index 0000000..241c194
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs
@@ -0,0 +1,535 @@
+using System.Buffers.Text;
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Net.WebSockets;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DodoSSH.Client.Terminal;
+
+///
+/// Serves the renderer page and carries terminal frames, over one loopback socket.
+///
+///
+///
+/// The data plane is a socket rather than the WebView's JavaScript bridge. The bridge is UI-thread
+/// bound string evaluation with no backpressure signal, which at terminal throughput means thousands
+/// of script evaluations per second on the thread that also has to paint. A socket gives ordering,
+/// binary payloads and flow control for free.
+///
+///
+/// The page is served from the same listener as the socket, which is what makes the Origin
+/// header predictable — it is always http://127.0.0.1:{port}. Loading the page through a
+/// WebView virtual-host mapping instead would produce a different origin on each backend and give
+/// nothing to validate against.
+///
+///
+/// What the token and origin check actually defend against. Not a hostile process running as
+/// the same user: that process can already read this one's memory, so nothing here is a boundary
+/// against it. They defend against a web page in the user's browser, which can and does attempt
+/// WebSocket connections to loopback ports, and against a second copy of the application
+/// accidentally attaching to the wrong terminal. Both are real; neither is stopped by the socket
+/// being loopback alone.
+///
+///
+public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
+{
+ /// Subprotocol the renderer must request.
+ public const string SubProtocol = "dodossh.terminal.v1";
+
+ /// Path the renderer page is served from.
+ public const string PagePath = "/terminal";
+
+ /// Path the WebSocket upgrade is accepted on.
+ public const string SocketPath = "/socket";
+
+ /// Placeholder in the page that is replaced with the connection token.
+ public const string TokenPlaceholder = "__DODOSSH_TOKEN__";
+
+ /// Placeholder in the page that is replaced with the socket URL.
+ public const string SocketUrlPlaceholder = "__DODOSSH_SOCKET__";
+
+ /// RFC 6455 §1.3: the fixed GUID mixed into the handshake response.
+ private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+ private const int MaximumRequestBytes = 16 * 1024;
+ private const int ReceiveBufferBytes = 64 * 1024;
+
+ private readonly TcpListener listener;
+ private readonly ITerminalAssetProvider assets;
+ private readonly Dictionary pumps = [];
+ private readonly Lock pumpGate = new();
+ private readonly SemaphoreSlim sendGate = new(1, 1);
+ private readonly CancellationTokenSource lifetime = new();
+ private readonly TaskCompletionSource rendererAttached =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private WebSocket? socket;
+ private int accepted;
+ private int disposed;
+
+ /// Where the renderer's files come from.
+ public TerminalDataPlane(ITerminalAssetProvider assets)
+ {
+ ArgumentNullException.ThrowIfNull(assets);
+
+ this.assets = assets;
+
+ Token = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
+
+ listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+
+ Port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ }
+
+ /// The port the OS assigned.
+ public int Port { get; }
+
+ /// The single-use connection token embedded in the served page.
+ public string Token { get; }
+
+ /// Where the WebView should navigate.
+ public Uri PageUrl => new(
+ string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{PagePath}"),
+ UriKind.Absolute);
+
+ /// Completes once the renderer has attached its socket.
+ public Task RendererAttached => rendererAttached.Task;
+
+ /// Registers a session so inbound frames can be routed to it.
+ public void Register(uint sessionId, TerminalSessionPump pump)
+ {
+ ArgumentNullException.ThrowIfNull(pump);
+
+ lock (pumpGate)
+ {
+ pumps[sessionId] = pump;
+ }
+ }
+
+ /// Forgets a session that has ended.
+ public void Unregister(uint sessionId)
+ {
+ lock (pumpGate)
+ {
+ pumps.Remove(sessionId);
+ }
+ }
+
+ /// Accepts connections until disposed.
+ public async Task RunAsync(CancellationToken cancellationToken)
+ {
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ lifetime.Token);
+
+ while (!linked.Token.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await listener.AcceptTcpClientAsync(linked.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ // Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
+ // lives for the whole session, so handling connections in sequence would leave the accept
+ // loop parked inside the receive loop and every later request unanswered — the page's
+ // script and stylesheet among them. Concurrency needs no coordination here because the
+ // single-attach guard is an interlocked exchange.
+ _ = HandleConnectionAsync(client, linked.Token);
+ }
+ }
+
+ private async Task HandleConnectionAsync(TcpClient client, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await HandleAsync(client, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ when (exception is IOException or SocketException or WebSocketException
+ or OperationCanceledException or ObjectDisposedException)
+ {
+ // A renderer that went away mid-handshake, or a shutdown in progress. Ordinary.
+ }
+ finally
+ {
+ client.Dispose();
+ }
+ }
+
+ ///
+ public async ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken)
+ {
+ var current = socket;
+ if (current is null || current.State != WebSocketState.Open)
+ {
+ // Dropped rather than queued. A terminal whose renderer has gone has nothing to catch up
+ // on, and buffering for one that may never return is the unbounded growth the credit
+ // window exists to prevent.
+ return;
+ }
+
+ // WebSocket forbids concurrent sends, and every session shares this one socket.
+ await sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await current
+ .SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ sendGate.Release();
+ }
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) == 1)
+ {
+ return;
+ }
+
+ await lifetime.CancelAsync().ConfigureAwait(false);
+ rendererAttached.TrySetCanceled();
+
+ listener.Dispose();
+ socket?.Dispose();
+ sendGate.Dispose();
+ lifetime.Dispose();
+ }
+
+ private async Task HandleAsync(TcpClient client, CancellationToken cancellationToken)
+ {
+ var stream = client.GetStream();
+ await using var streamScope = stream.ConfigureAwait(false);
+
+ var request = await ReadRequestAsync(stream, cancellationToken).ConfigureAwait(false);
+ if (request is null)
+ {
+ return;
+ }
+
+ if (string.Equals(request.Path, SocketPath, StringComparison.Ordinal))
+ {
+ await UpgradeAsync(stream, request, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ await ServeAssetAsync(stream, request.Path, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ServeAssetAsync(Stream stream, string path, CancellationToken cancellationToken)
+ {
+ var asset = assets.Find(path);
+
+ if (asset is null)
+ {
+ await WriteResponseAsync(stream, "404 Not Found", "text/plain", "Not found"u8.ToArray(), cancellationToken)
+ .ConfigureAwait(false);
+ return;
+ }
+
+ var content = asset.Content;
+
+ if (string.Equals(path, PagePath, StringComparison.Ordinal))
+ {
+ // The token and socket URL are substituted at serve time rather than being written into
+ // the file, so the token never touches disk and never appears in a URL that could reach a
+ // log or a browser history.
+ var text = Encoding.UTF8.GetString(content)
+ .Replace(TokenPlaceholder, Token, StringComparison.Ordinal)
+ .Replace(
+ SocketUrlPlaceholder,
+ string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{Port}{SocketPath}"),
+ StringComparison.Ordinal);
+
+ content = Encoding.UTF8.GetBytes(text);
+ }
+
+ await WriteResponseAsync(stream, "200 OK", asset.ContentType, content, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async Task UpgradeAsync(
+ Stream stream,
+ HttpRequestLine request,
+ CancellationToken cancellationToken)
+ {
+ if (!IsAcceptableUpgrade(request))
+ {
+ await WriteResponseAsync(
+ stream, "403 Forbidden", "text/plain", "Rejected"u8.ToArray(), cancellationToken)
+ .ConfigureAwait(false);
+ return;
+ }
+
+ if (Interlocked.Exchange(ref accepted, 1) == 1)
+ {
+ // One renderer, one socket. A second attach would be either a bug or something else on the
+ // machine having found the port.
+ await WriteResponseAsync(
+ stream, "409 Conflict", "text/plain", "Already attached"u8.ToArray(), cancellationToken)
+ .ConfigureAwait(false);
+ return;
+ }
+
+ var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
+ var accept = ComputeHandshakeAccept(key);
+
+ var handshake =
+ "HTTP/1.1 101 Switching Protocols\r\n"
+ + "Upgrade: websocket\r\n"
+ + "Connection: Upgrade\r\n"
+ + "Sec-WebSocket-Accept: " + accept + "\r\n"
+ + "Sec-WebSocket-Protocol: " + SubProtocol + "\r\n\r\n";
+
+ await stream.WriteAsync(Encoding.ASCII.GetBytes(handshake), cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+
+ // The handshake is ours; the framing is the BCL's. Hand-rolling masking, fragmentation and
+ // control frames would be a great deal of code for no gain.
+ using var webSocket = WebSocket.CreateFromStream(
+ stream,
+ new WebSocketCreationOptions
+ {
+ IsServer = true,
+ SubProtocol = SubProtocol,
+ KeepAliveInterval = TimeSpan.FromSeconds(30),
+ });
+
+ socket = webSocket;
+ rendererAttached.TrySetResult();
+
+ await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// The origin is checked because a page in the user's browser can attempt a WebSocket connection to
+ /// a loopback port, and the token because that page could otherwise simply guess the path. Neither
+ /// defends against a process running as this user; that one can read our memory regardless.
+ ///
+ private bool IsAcceptableUpgrade(HttpRequestLine request)
+ {
+ var origin = request.Headers.GetValueOrDefault("origin");
+ var expectedOrigin = string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}");
+
+ if (!string.Equals(origin, expectedOrigin, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (request.Headers.GetValueOrDefault("sec-websocket-key") is null)
+ {
+ return false;
+ }
+
+ var protocols = request.Headers.GetValueOrDefault("sec-websocket-protocol") ?? string.Empty;
+
+ var offered = protocols
+ .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+
+ if (!offered.Contains(SubProtocol, StringComparer.Ordinal))
+ {
+ return false;
+ }
+
+ // Carried as a subprotocol rather than a query parameter, which keeps it out of anything that
+ // logs URLs.
+ var presented = offered.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal));
+
+ return presented is not null
+ && CryptographicOperations.FixedTimeEquals(
+ Encoding.ASCII.GetBytes(presented["token.".Length..]),
+ Encoding.ASCII.GetBytes(Token));
+ }
+
+ private async Task ReceiveLoopAsync(WebSocket webSocket, CancellationToken cancellationToken)
+ {
+ var buffer = new byte[ReceiveBufferBytes];
+
+ while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
+ {
+ var result = await webSocket
+ .ReceiveAsync(buffer.AsMemory(), cancellationToken)
+ .ConfigureAwait(false);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ return;
+ }
+
+ if (!result.EndOfMessage)
+ {
+ // A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
+ // so this is either a bug or something hostile; dropping is the safe response.
+ continue;
+ }
+
+ Dispatch(buffer.AsSpan(0, result.Count));
+ }
+ }
+
+ private void Dispatch(ReadOnlySpan frame)
+ {
+ if (!TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload))
+ {
+ return;
+ }
+
+ TerminalSessionPump? pump;
+ lock (pumpGate)
+ {
+ pump = pumps.GetValueOrDefault(sessionId);
+ }
+
+ if (pump is null)
+ {
+ // A frame for a session that has already ended. Ordinary during teardown.
+ return;
+ }
+
+ switch ((TerminalClientOpcode)opcode)
+ {
+ case TerminalClientOpcode.Input:
+ // Fire and forget: a blocked write must not stall acknowledgements for other sessions
+ // sharing this socket.
+ _ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
+ break;
+
+ case TerminalClientOpcode.Acknowledge:
+ if (TerminalFrame.TryReadAcknowledgement(payload, out var rendered))
+ {
+ pump.Acknowledge(rendered);
+ }
+
+ break;
+
+ case TerminalClientOpcode.Resize:
+ if (TerminalFrame.TryReadResize(payload, out var size))
+ {
+ pump.Resize(size);
+ }
+
+ break;
+
+ default:
+ // Unknown opcode from a newer page than this host. Ignored rather than fatal.
+ break;
+ }
+ }
+
+ ///
+ /// Computes the Sec-WebSocket-Accept value RFC 6455 §4.2.2 requires.
+ ///
+ ///
+ ///
+ /// SHA-1 is mandated by the specification and is not doing security work here. The value proves
+ /// only that the server understood the WebSocket handshake rather than being a plain HTTP server
+ /// that echoed the request; it authenticates nothing and protects no data. Substituting SHA-256
+ /// would simply make the handshake fail with every client in existence.
+ ///
+ ///
+ /// The alternative that avoids SHA-1 in our own code is
+ /// HttpListener.AcceptWebSocketAsync, which throws PlatformNotSupportedException
+ /// off Windows — so it would trade a documented suppression for a platform restriction.
+ ///
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Security",
+ "CA5350:Do Not Use Weak Cryptographic Algorithms",
+ Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "ApiDesign",
+ "RS0030:Do not use banned APIs",
+ Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
+ private static string ComputeHandshakeAccept(string key) =>
+ Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + HandshakeGuid)));
+
+ private static async Task WriteResponseAsync(
+ Stream stream,
+ string status,
+ string contentType,
+ byte[] body,
+ CancellationToken cancellationToken)
+ {
+ var header =
+ "HTTP/1.1 " + status + "\r\n"
+ + "Content-Type: " + contentType + "\r\n"
+ + "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n"
+
+ // The page carries a connection token, so it must never be cached anywhere.
+ + "Cache-Control: no-store\r\n"
+ + "Connection: close\r\n\r\n";
+
+ await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false);
+ await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task ReadRequestAsync(
+ Stream stream,
+ CancellationToken cancellationToken)
+ {
+ var buffer = new byte[MaximumRequestBytes];
+ var count = 0;
+
+ while (count < buffer.Length)
+ {
+ var read = await stream
+ .ReadAsync(buffer.AsMemory(count), cancellationToken)
+ .ConfigureAwait(false);
+
+ if (read == 0)
+ {
+ break;
+ }
+
+ count += read;
+
+ if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal))
+ {
+ break;
+ }
+ }
+
+ var lines = Encoding.ASCII.GetString(buffer, 0, count).Split("\r\n");
+ var parts = lines[0].Split(' ');
+
+ if (parts.Length < 2 || !string.Equals(parts[0], "GET", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var line in lines.Skip(1))
+ {
+ if (line.Length == 0)
+ {
+ break;
+ }
+
+ var separator = line.IndexOf(':', StringComparison.Ordinal);
+ if (separator > 0)
+ {
+ headers[line[..separator].Trim()] = line[(separator + 1)..].Trim();
+ }
+ }
+
+ var path = parts[1];
+ var query = path.IndexOf('?', StringComparison.Ordinal);
+
+ return new HttpRequestLine(query < 0 ? path : path[..query], headers);
+ }
+
+ private sealed record HttpRequestLine(string Path, IReadOnlyDictionary Headers);
+}
diff --git a/src/DodoSSH.Client.Terminal/TerminalFrame.cs b/src/DodoSSH.Client.Terminal/TerminalFrame.cs
new file mode 100644
index 0000000..5606f51
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/TerminalFrame.cs
@@ -0,0 +1,181 @@
+using System.Buffers.Binary;
+
+namespace DodoSSH.Client.Terminal;
+
+/// Frames the host sends to the renderer.
+public enum TerminalServerOpcode : byte
+{
+ /// Not a legal value.
+ Unspecified = 0,
+
+ /// Terminal output. Payload is raw bytes for term.write.
+ Output = 1,
+
+ /// A session has been created; the renderer should attach a terminal to it.
+ SessionOpened = 2,
+
+ /// A session has ended. Payload is a UTF-8 reason for the user.
+ SessionClosed = 3,
+}
+
+/// Frames the renderer sends to the host.
+public enum TerminalClientOpcode : byte
+{
+ /// Not a legal value.
+ Unspecified = 0,
+
+ /// Keystrokes. Payload is raw bytes for the remote.
+ Input = 1,
+
+ ///
+ /// Bytes actually rendered. Payload is a big-endian , returning credit.
+ ///
+ Acknowledge = 2,
+
+ /// The terminal was resized. Payload is four big-endian values.
+ Resize = 3,
+}
+
+///
+/// The wire format between the host process and the renderer page.
+///
+///
+///
+/// Binary frames over a loopback WebSocket, not the WebView's JavaScript bridge. The official bridge
+/// is UI-thread-bound string evaluation: at 10 MB/s in 4 KiB chunks that is roughly 2,500 script
+/// evaluations per second on the thread that also has to paint, with base64's 33% overhead on top and
+/// — decisively — no backpressure signal at all. A socket gives flow control for free.
+///
+///
+/// Every frame carries a session id because one WebView hosts every terminal. A WebView2 instance is
+/// a separate browser process, so one per tab would mean twenty renderer processes and hundreds of
+/// megabytes for a normal working set of tabs.
+///
+///
+/// Fixed 5-byte header, big-endian, no length prefix: WebSocket already delimits messages, so adding
+/// our own length would be a second source of truth about where a frame ends.
+///
+///
+public static class TerminalFrame
+{
+ /// Opcode plus session id.
+ public const int HeaderLength = 1 + sizeof(uint);
+
+ /// Writes a frame into and returns its length.
+ public static int Write(
+ Span destination,
+ byte opcode,
+ uint sessionId,
+ ReadOnlySpan payload)
+ {
+ var total = HeaderLength + payload.Length;
+
+ if (destination.Length < total)
+ {
+ throw new ArgumentException(
+ $"Need {total} bytes for the frame, got {destination.Length}.",
+ nameof(destination));
+ }
+
+ destination[0] = opcode;
+ BinaryPrimitives.WriteUInt32BigEndian(destination[1..], sessionId);
+ payload.CopyTo(destination[HeaderLength..]);
+
+ return total;
+ }
+
+ /// Allocates and writes a frame.
+ public static byte[] Create(byte opcode, uint sessionId, ReadOnlySpan payload)
+ {
+ var frame = new byte[HeaderLength + payload.Length];
+ Write(frame, opcode, sessionId, payload);
+
+ return frame;
+ }
+
+ ///
+ /// Reads a frame's header and payload.
+ ///
+ ///
+ /// Returns false rather than throwing on anything malformed. These frames arrive from a WebView
+ /// page — a different process running code we shipped but do not control at runtime — so a bad
+ /// frame is untrusted input to be dropped, not an exceptional condition.
+ ///
+ public static bool TryRead(
+ ReadOnlySpan frame,
+ out byte opcode,
+ out uint sessionId,
+ out ReadOnlySpan payload)
+ {
+ opcode = 0;
+ sessionId = 0;
+ payload = default;
+
+ if (frame.Length < HeaderLength)
+ {
+ return false;
+ }
+
+ opcode = frame[0];
+ sessionId = BinaryPrimitives.ReadUInt32BigEndian(frame[1..]);
+ payload = frame[HeaderLength..];
+
+ return true;
+ }
+
+ /// Reads an payload.
+ public static bool TryReadAcknowledgement(ReadOnlySpan payload, out uint rendered)
+ {
+ rendered = 0;
+
+ if (payload.Length != sizeof(uint))
+ {
+ return false;
+ }
+
+ rendered = BinaryPrimitives.ReadUInt32BigEndian(payload);
+
+ return true;
+ }
+
+ /// Reads a payload.
+ public static bool TryReadResize(ReadOnlySpan payload, out DodoSSH.Client.Ssh.TerminalSize size)
+ {
+ size = default;
+
+ if (payload.Length != sizeof(ushort) * 4)
+ {
+ return false;
+ }
+
+ size = new DodoSSH.Client.Ssh.TerminalSize(
+ BinaryPrimitives.ReadUInt16BigEndian(payload),
+ BinaryPrimitives.ReadUInt16BigEndian(payload[2..]),
+ BinaryPrimitives.ReadUInt16BigEndian(payload[4..]),
+ BinaryPrimitives.ReadUInt16BigEndian(payload[6..]));
+
+ return true;
+ }
+
+ /// Writes a payload. Used by tests and tooling.
+ public static byte[] CreateResizePayload(DodoSSH.Client.Ssh.TerminalSize size)
+ {
+ var payload = new byte[sizeof(ushort) * 4];
+
+ BinaryPrimitives.WriteUInt16BigEndian(payload, size.Columns);
+ BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(2), size.Rows);
+ BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(4), size.PixelWidth);
+ BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(6), size.PixelHeight);
+
+ return payload;
+ }
+
+ /// Writes an payload.
+ public static byte[] CreateAcknowledgementPayload(uint rendered)
+ {
+ var payload = new byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32BigEndian(payload, rendered);
+
+ return payload;
+ }
+}
diff --git a/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs b/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs
new file mode 100644
index 0000000..4b4db8e
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs
@@ -0,0 +1,308 @@
+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);
+ }
+}
diff --git a/src/DodoSSH.Client.Terminal/packages.lock.json b/src/DodoSSH.Client.Terminal/packages.lock.json
new file mode 100644
index 0000000..716d82c
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/packages.lock.json
@@ -0,0 +1,54 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Meziantou.Analyzer": {
+ "type": "Direct",
+ "requested": "[3.0.134, )",
+ "resolved": "3.0.134",
+ "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
+ },
+ "Microsoft.CodeAnalysis.BannedApiAnalyzers": {
+ "type": "Direct",
+ "requested": "[5.6.0, )",
+ "resolved": "5.6.0",
+ "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.2",
+ "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.3",
+ "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
+ }
+ },
+ "dodossh.client.ssh": {
+ "type": "Project",
+ "dependencies": {
+ "SSH.NET": "[2025.1.0, )"
+ }
+ },
+ "BouncyCastle.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[2.6.2, )",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "SSH.NET": {
+ "type": "CentralTransitive",
+ "requested": "[2025.1.0, )",
+ "resolved": "2025.1.0",
+ "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
+ "dependencies": {
+ "BouncyCastle.Cryptography": "2.6.2",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/DodoSSH.Client.Terminal.Tests/CreditWindowTests.cs b/tests/DodoSSH.Client.Terminal.Tests/CreditWindowTests.cs
new file mode 100644
index 0000000..aff5685
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/CreditWindowTests.cs
@@ -0,0 +1,181 @@
+namespace DodoSSH.Client.Terminal.Tests;
+
+///
+/// The flow-control accounting.
+///
+///
+/// Worth testing on its own, ahead of the pump, because everything else about the terminal depends on
+/// this arithmetic being right. An off-by-one that leaks credit shows up as a session that stalls
+/// after several minutes of heavy output — a symptom nobody would trace back to here.
+///
+public sealed class CreditWindowTests
+{
+ [Fact]
+ public void AFreshWindow_OffersItsWholeSize()
+ {
+ var window = new CreditWindow(1024);
+
+ window.Available.ShouldBe(1024);
+ window.Outstanding.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Reserving_ReducesWhatIsAvailable()
+ {
+ var window = new CreditWindow(1024);
+
+ window.TryReserve(400).ShouldBe(400);
+
+ window.Outstanding.ShouldBe(400);
+ window.Available.ShouldBe(624);
+ }
+
+ [Fact]
+ public void ReservingMoreThanRemains_GrantsAPartialAmount()
+ {
+ // All-or-nothing would stall a session that could have made progress with what was left, and
+ // the caller has to cope with a short read regardless.
+ var window = new CreditWindow(1024);
+ window.TryReserve(900);
+
+ window.TryReserve(400).ShouldBe(124);
+ window.Available.ShouldBe(0);
+ }
+
+ [Fact]
+ public void AFullWindow_GrantsNothing()
+ {
+ var window = new CreditWindow(1024);
+ window.TryReserve(1024);
+
+ window.TryReserve(1).ShouldBe(0);
+ }
+
+ [Fact]
+ public void Returning_RestoresCredit()
+ {
+ var window = new CreditWindow(1024);
+ window.TryReserve(1024);
+
+ window.Return(512);
+
+ window.Available.ShouldBe(512);
+ window.Outstanding.ShouldBe(512);
+ }
+
+ [Fact]
+ public void ReturningMoreThanWasReserved_IsClamped()
+ {
+ // The acknowledgement crosses into JavaScript, so a buggy or tampered page can claim to have
+ // rendered more than it was sent. Letting that drive outstanding negative would hand it an
+ // unbounded window, which is precisely what this class exists to prevent.
+ var window = new CreditWindow(1024);
+ window.TryReserve(100);
+
+ window.Return(100_000);
+
+ window.Outstanding.ShouldBe(0);
+ window.Available.ShouldBe(1024);
+ }
+
+ [Fact]
+ public void ReturningWithNothingOutstanding_ChangesNothing()
+ {
+ var window = new CreditWindow(1024);
+
+ window.Return(500);
+
+ window.Available.ShouldBe(1024);
+ }
+
+ [Fact]
+ public async Task WaitingWithCreditAvailable_ReturnsImmediately()
+ {
+ var window = new CreditWindow(1024);
+
+ await window.WaitForCreditAsync(TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task WaitingOnAFullWindow_BlocksUntilCreditIsReturned()
+ {
+ var window = new CreditWindow(1024);
+ window.TryReserve(1024);
+
+ var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask();
+
+ wait.IsCompleted.ShouldBeFalse("A full window must not let a reader proceed.");
+
+ window.Return(1);
+
+ await wait;
+ }
+
+ [Fact]
+ public async Task Reset_UnblocksAWaiter()
+ {
+ // A session being torn down must not leave its reader parked forever on credit that will
+ // never be acknowledged.
+ var window = new CreditWindow(1024);
+ window.TryReserve(1024);
+
+ var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask();
+ wait.IsCompleted.ShouldBeFalse();
+
+ window.Reset();
+
+ await wait;
+ window.Outstanding.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Waiting_ObservesCancellation()
+ {
+ var window = new CreditWindow(1024);
+ window.TryReserve(1024);
+
+ using var cts = new CancellationTokenSource();
+ var wait = window.WaitForCreditAsync(cts.Token).AsTask();
+
+ await cts.CancelAsync();
+
+ await Should.ThrowAsync(async () => await wait);
+ }
+
+ [Fact]
+ public async Task ConcurrentReservesAndReturns_KeepTheAccountingConsistent()
+ {
+ // The pump reserves on one task and returns on another, so the arithmetic has to hold under
+ // real contention rather than only in a single-threaded walkthrough.
+ const int Window = 64 * 1024;
+ var window = new CreditWindow(Window);
+
+ var reserved = 0;
+ var returned = 0;
+
+ var workers = Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
+ {
+ for (var i = 0; i < 2_000; i++)
+ {
+ var granted = window.TryReserve(97);
+ Interlocked.Add(ref reserved, granted);
+
+ window.Return(granted);
+ Interlocked.Add(ref returned, granted);
+ }
+ }, TestContext.Current.CancellationToken));
+
+ await Task.WhenAll(workers);
+
+ reserved.ShouldBe(returned);
+ window.Outstanding.ShouldBe(0);
+ window.Available.ShouldBe(Window);
+ }
+
+ [Fact]
+ public void AWindowOfZeroOrLess_IsRejected()
+ {
+ Should.Throw(() => new CreditWindow(0));
+ Should.Throw(() => new CreditWindow(-1));
+ }
+}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj b/tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj
new file mode 100644
index 0000000..44b9525
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
new file mode 100644
index 0000000..14589fc
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
@@ -0,0 +1,167 @@
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.Terminal.Tests;
+
+/// A shell session that produces output on demand, for exercising the pump.
+internal sealed class FakeShellSession : ISshShellSession
+{
+ private readonly List written = [];
+ private readonly Lock gate = new();
+
+ private long remaining;
+ private byte pattern;
+
+ ///
+ /// How many bytes to emit before reporting end of stream. for an
+ /// endless producer, which is what a runaway remote process looks like — those sessions are ended
+ /// by disposing the pump rather than by running out of data.
+ ///
+ internal FakeShellSession(long bytesToProduce = 0) => remaining = bytesToProduce;
+
+ ///
+ public bool IsOpen { get; private set; } = true;
+
+ /// Reads issued against this session, to detect a pump that kept reading.
+ public int ReadCount { get; private set; }
+
+ /// Bytes the pump wrote toward the remote.
+ public byte[] Written
+ {
+ get
+ {
+ lock (gate)
+ {
+ return [.. written];
+ }
+ }
+ }
+
+ /// The last size the pump forwarded, or null if it forwarded none.
+ public TerminalSize? LastResize { get; private set; }
+
+ /// How many resizes were forwarded, so a dropped one is observable.
+ public int ResizeCount { get; private set; }
+
+ ///
+ ///
+ /// Returns 0 once the configured budget is spent, which is what a remote closing the channel looks
+ /// like. Not synchronous: a fake that never yields would let the pump's read loop monopolise the
+ /// thread and hide any ordering problem between reading and flushing.
+ ///
+ public async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken)
+ {
+ ReadCount++;
+
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (remaining <= 0)
+ {
+ return 0;
+ }
+
+ var count = (int)Math.Min(buffer.Length, remaining);
+ buffer.Span[..count].Fill(unchecked(pattern++));
+ remaining -= count;
+
+ return count;
+ }
+
+ ///
+ public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ written.AddRange(data.ToArray());
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public void Resize(TerminalSize size)
+ {
+ // Mirrors the real session: an unusable size is dropped rather than forwarded.
+ if (!size.IsUsable)
+ {
+ return;
+ }
+
+ ResizeCount++;
+ LastResize = size;
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ IsOpen = false;
+ remaining = 0;
+
+ return ValueTask.CompletedTask;
+ }
+}
+
+/// Records frames, and can acknowledge them to keep credit flowing.
+internal sealed class RecordingTransport : ITerminalTransport
+{
+ private readonly List frames = [];
+ private readonly Lock gate = new();
+
+ /// Set to acknowledge every output frame immediately, as a keeping-up renderer would.
+ internal TerminalSessionPump? AutoAcknowledge { get; set; }
+
+ /// Frames sent so far.
+ internal IReadOnlyList Frames
+ {
+ get
+ {
+ lock (gate)
+ {
+ return [.. frames];
+ }
+ }
+ }
+
+ ///
+ public ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken)
+ {
+ var copy = frame.ToArray();
+
+ lock (gate)
+ {
+ frames.Add(copy);
+ }
+
+ if (AutoAcknowledge is { } pump
+ && TerminalFrame.TryRead(copy, out var opcode, out _, out var payload)
+ && opcode == (byte)TerminalServerOpcode.Output)
+ {
+ pump.Acknowledge((uint)payload.Length);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ /// Concatenated payloads of every output frame.
+ internal byte[] OutputBytes()
+ {
+ var output = new List();
+
+ foreach (var frame in Frames)
+ {
+ if (TerminalFrame.TryRead(frame, out var opcode, out _, out var payload)
+ && opcode == (byte)TerminalServerOpcode.Output)
+ {
+ output.AddRange(payload);
+ }
+ }
+
+ return [.. output];
+ }
+
+ /// Counts frames of one opcode.
+ internal int CountOf(TerminalServerOpcode opcode) =>
+ Frames.Count(frame =>
+ TerminalFrame.TryRead(frame, out var actual, out _, out _)
+ && actual == (byte)opcode);
+}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs
new file mode 100644
index 0000000..8a0dc92
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs
@@ -0,0 +1,389 @@
+using System.Globalization;
+using System.Net;
+using System.Net.WebSockets;
+using System.Text;
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.Terminal.Tests;
+
+///
+/// The loopback transport end to end, with a real WebSocket client.
+///
+///
+/// Uses against the real listener rather than a stubbed transport,
+/// because the hand-rolled HTTP upgrade is the part that could be subtly wrong — and a handshake that
+/// no real client accepts would pass any test that skipped it.
+///
+public sealed class TerminalDataPlaneTests : IAsyncDisposable
+{
+ private const uint SessionId = 3;
+
+ private static readonly byte[] PageTemplate = Encoding.UTF8.GetBytes(
+ $"");
+
+ private readonly TerminalDataPlane plane = new(new InMemoryTerminalAssetProvider(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", PageTemplate),
+ ["/xterm.js"] = new("text/javascript; charset=utf-8", "console.log(1)"u8.ToArray()),
+ }));
+
+ private readonly CancellationTokenSource lifetime = new();
+ private Task? server;
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await lifetime.CancelAsync();
+ await plane.DisposeAsync();
+
+ if (server is not null)
+ {
+ try
+ {
+ await server;
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected: the accept loop is stopped by cancelling it.
+ }
+ }
+
+ lifetime.Dispose();
+ }
+
+ // ---- Serving the page ----
+
+ [Fact]
+ public async Task ThePage_IsServedWithTheTokenAndSocketUrlSubstituted()
+ {
+ // Substituted at serve time rather than written into the file, so the token never touches disk
+ // and never appears in a URL that a log or a browser history could keep.
+ Start();
+
+ using var client = new HttpClient();
+ var body = await client.GetStringAsync(plane.PageUrl, TestContext.Current.CancellationToken);
+
+ body.ShouldContain(plane.Token);
+ body.ShouldContain(
+ string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{plane.Port}/socket"));
+
+ body.ShouldNotContain(TerminalDataPlane.TokenPlaceholder);
+ body.ShouldNotContain(TerminalDataPlane.SocketUrlPlaceholder);
+ }
+
+ [Fact]
+ public async Task ThePage_IsNotCached()
+ {
+ // It carries a connection token.
+ Start();
+
+ using var client = new HttpClient();
+ using var response = await client.GetAsync(plane.PageUrl, TestContext.Current.CancellationToken);
+
+ response.Headers.CacheControl!.NoStore.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task AnUnknownPath_Is404()
+ {
+ Start();
+
+ using var client = new HttpClient();
+ using var response = await client.GetAsync(
+ new Uri($"http://127.0.0.1:{plane.Port}/nope"),
+ TestContext.Current.CancellationToken);
+
+ response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ }
+
+ // ---- Attaching ----
+
+ [Fact]
+ public async Task TheRenderer_Attaches()
+ {
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ socket.State.ShouldBe(WebSocketState.Open);
+ socket.SubProtocol.ShouldBe(TerminalDataPlane.SubProtocol);
+
+ await plane.RendererAttached;
+ }
+
+ [Fact]
+ public async Task AConnectionWithoutTheToken_IsRejected()
+ {
+ Start();
+
+ await Should.ThrowAsync(async () =>
+ await ConnectAsync(token: null));
+ }
+
+ [Fact]
+ public async Task AConnectionWithTheWrongToken_IsRejected()
+ {
+ Start();
+
+ await Should.ThrowAsync(async () =>
+ await ConnectAsync(token: "not-the-token"));
+ }
+
+ [Fact]
+ public async Task AConnectionFromAnotherOrigin_IsRejected()
+ {
+ // The threat this actually addresses: a page open in the user's browser can attempt WebSocket
+ // connections to loopback ports, and would otherwise reach a terminal.
+ Start();
+
+ await Should.ThrowAsync(async () =>
+ await ConnectAsync(origin: "https://evil.example"));
+ }
+
+ [Fact]
+ public async Task ASecondRenderer_IsRejected()
+ {
+ Start();
+
+ using var first = await ConnectAsync();
+ first.State.ShouldBe(WebSocketState.Open);
+
+ await Should.ThrowAsync(async () => await ConnectAsync());
+ }
+
+ // ---- Frames ----
+
+ [Fact]
+ public async Task Output_ReachesTheRenderer()
+ {
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ await using var session = new FakeShellSession(bytesToProduce: 128);
+ await using var pump = CreatePump(session);
+ plane.Register(SessionId, pump);
+
+ var run = pump.RunAsync(TestContext.Current.CancellationToken);
+
+ // SessionOpened first, then the output.
+ var opened = await ReceiveAsync(socket);
+ opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
+ opened.SessionId.ShouldBe(SessionId);
+
+ var output = await ReceiveAsync(socket);
+ output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
+ output.Payload.Length.ShouldBe(128);
+
+ // Acknowledge it, exactly as the page does from term.write's callback.
+ await SendAsync(
+ socket,
+ (byte)TerminalClientOpcode.Acknowledge,
+ TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
+
+ await run;
+ }
+
+ [Fact]
+ public async Task Input_ReachesTheRemote()
+ {
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ await using var session = new FakeShellSession();
+ await using var pump = CreatePump(session);
+ plane.Register(SessionId, pump);
+
+ await SendAsync(socket, (byte)TerminalClientOpcode.Input, "whoami\r"u8.ToArray());
+
+ await WaitUntilAsync(() => session.Written.Length > 0);
+
+ Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r");
+ }
+
+ [Fact]
+ public async Task AResize_ReachesTheRemote()
+ {
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ await using var session = new FakeShellSession();
+ await using var pump = CreatePump(session);
+ plane.Register(SessionId, pump);
+
+ await SendAsync(
+ socket,
+ (byte)TerminalClientOpcode.Resize,
+ TerminalFrame.CreateResizePayload(new TerminalSize(132, 43, 1320, 1075)));
+
+ await WaitUntilAsync(() => session.ResizeCount > 0);
+
+ session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075));
+ }
+
+ [Fact]
+ public async Task AnAcknowledgement_ReturnsCredit()
+ {
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ await using var session = new FakeShellSession();
+ await using var pump = CreatePump(session);
+ plane.Register(SessionId, pump);
+
+ pump.Credits.TryReserve(1024);
+ pump.Credits.Outstanding.ShouldBe(1024);
+
+ await SendAsync(
+ socket,
+ (byte)TerminalClientOpcode.Acknowledge,
+ TerminalFrame.CreateAcknowledgementPayload(1024));
+
+ await WaitUntilAsync(() => pump.Credits.Outstanding == 0);
+ }
+
+ [Fact]
+ public async Task AFrameForAnUnregisteredSession_IsIgnored()
+ {
+ // Ordinary during teardown: the page can still have frames in flight for a session that just
+ // ended. Dropping them must not disturb anything else.
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ await using var session = new FakeShellSession();
+ await using var pump = CreatePump(session);
+ plane.Register(SessionId, pump);
+
+ // A session id that is never registered. Using the registered one and relying on ordering
+ // would be a race: frames are dispatched on the receive loop, so the orphan can land after
+ // registration and legitimately be delivered.
+ await SendAsync(socket, (byte)TerminalClientOpcode.Input, "orphan"u8.ToArray(), sessionId: 999);
+
+ await SendAsync(socket, (byte)TerminalClientOpcode.Input, "real"u8.ToArray());
+ await WaitUntilAsync(() => session.Written.Length > 0);
+
+ Encoding.UTF8.GetString(session.Written).ShouldBe("real");
+ }
+
+ [Fact]
+ public async Task AMalformedFrame_IsIgnored()
+ {
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ // Shorter than the header.
+ await socket.SendAsync(
+ new byte[] { 1, 2 },
+ WebSocketMessageType.Binary,
+ endOfMessage: true,
+ TestContext.Current.CancellationToken);
+
+ await using var session = new FakeShellSession();
+ await using var pump = CreatePump(session);
+ plane.Register(SessionId, pump);
+
+ await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray());
+ await WaitUntilAsync(() => session.Written.Length > 0);
+
+ Encoding.UTF8.GetString(session.Written).ShouldBe("still here");
+ }
+
+ // ---- Helpers ----
+
+ private void Start() => server = plane.RunAsync(lifetime.Token);
+
+ private TerminalSessionPump CreatePump(FakeShellSession session) =>
+ new(
+ SessionId,
+ session,
+ plane,
+ TimeProvider.System,
+ new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
+
+ private async Task ConnectAsync(
+ string? token = "",
+ string? origin = null)
+ {
+ var socket = new ClientWebSocket();
+
+ socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
+
+ if (token is not null)
+ {
+ socket.Options.AddSubProtocol($"token.{(token.Length == 0 ? plane.Token : token)}");
+ }
+
+ socket.Options.SetRequestHeader(
+ "Origin",
+ origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));
+
+ try
+ {
+ await socket.ConnectAsync(
+ new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
+ TestContext.Current.CancellationToken);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+
+ return socket;
+ }
+
+ private static async Task SendAsync(
+ ClientWebSocket socket,
+ byte opcode,
+ byte[] payload,
+ uint sessionId = SessionId) =>
+ await socket.SendAsync(
+ TerminalFrame.Create(opcode, sessionId, payload),
+ WebSocketMessageType.Binary,
+ endOfMessage: true,
+ TestContext.Current.CancellationToken);
+
+ private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
+ ClientWebSocket socket)
+ {
+ var buffer = new byte[64 * 1024];
+
+ var result = await socket.ReceiveAsync(
+ buffer.AsMemory(),
+ TestContext.Current.CancellationToken);
+
+ TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
+ .ShouldBeTrue();
+
+ return (opcode, sessionId, payload.ToArray());
+ }
+
+ ///
+ /// Polls rather than awaiting a signal: inbound frames are dispatched on the transport's receive
+ /// loop, which has no completion for the caller to await. The bound keeps a genuine failure a
+ /// failure rather than a hang.
+ ///
+ private static async Task WaitUntilAsync(Func condition)
+ {
+ var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(5);
+
+ while (TimeProvider.System.GetUtcNow() < deadline)
+ {
+ if (condition())
+ {
+ return;
+ }
+
+ await Task.Delay(10, TestContext.Current.CancellationToken);
+ }
+
+ throw new TimeoutException("The expected state was not reached within 5 seconds.");
+ }
+}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs
new file mode 100644
index 0000000..a68e90f
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs
@@ -0,0 +1,117 @@
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.Terminal.Tests;
+
+///
+/// The wire format between the host and the renderer page.
+///
+///
+/// Byte order is asserted explicitly rather than only round-tripped. Both ends of this protocol are
+/// ours today, but the JavaScript side reads the header with a DataView, whose default is
+/// big-endian — a round-trip-only test would pass just as happily with a little-endian header that the
+/// page then misreads.
+///
+public sealed class TerminalFrameTests
+{
+ [Fact]
+ public void AFrame_RoundTrips()
+ {
+ var frame = TerminalFrame.Create((byte)TerminalServerOpcode.Output, 42, "hello"u8);
+
+ TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload).ShouldBeTrue();
+
+ opcode.ShouldBe((byte)TerminalServerOpcode.Output);
+ sessionId.ShouldBe(42u);
+ payload.ToArray().ShouldBe("hello"u8.ToArray());
+ }
+
+ [Fact]
+ public void TheSessionId_IsBigEndian()
+ {
+ var frame = TerminalFrame.Create((byte)TerminalServerOpcode.Output, 0x01020304, []);
+
+ frame[1].ShouldBe((byte)0x01);
+ frame[2].ShouldBe((byte)0x02);
+ frame[3].ShouldBe((byte)0x03);
+ frame[4].ShouldBe((byte)0x04);
+ }
+
+ [Fact]
+ public void AnEmptyPayload_IsLegal()
+ {
+ // SessionOpened carries nothing.
+ var frame = TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, 1, []);
+
+ frame.Length.ShouldBe(TerminalFrame.HeaderLength);
+ TerminalFrame.TryRead(frame, out _, out _, out var payload).ShouldBeTrue();
+ payload.Length.ShouldBe(0);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(4)]
+ public void AFrameShorterThanTheHeader_IsRejected(int length)
+ {
+ // These arrive from a WebView page, so a malformed frame is untrusted input to drop rather
+ // than an exceptional condition.
+ TerminalFrame.TryRead(new byte[length], out _, out _, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Write_RejectsATooSmallDestination()
+ {
+ Should.Throw(() =>
+ TerminalFrame.Write(new byte[4], 1, 1, "payload"u8));
+ }
+
+ [Fact]
+ public void AnAcknowledgement_RoundTrips()
+ {
+ var payload = TerminalFrame.CreateAcknowledgementPayload(123_456);
+
+ TerminalFrame.TryReadAcknowledgement(payload, out var rendered).ShouldBeTrue();
+ rendered.ShouldBe(123_456u);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(3)]
+ [InlineData(5)]
+ public void AnAcknowledgementOfTheWrongLength_IsRejected(int length)
+ {
+ TerminalFrame.TryReadAcknowledgement(new byte[length], out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AResize_RoundTrips()
+ {
+ var size = new TerminalSize(132, 43, 1320, 1075);
+ var payload = TerminalFrame.CreateResizePayload(size);
+
+ TerminalFrame.TryReadResize(payload, out var decoded).ShouldBeTrue();
+ decoded.ShouldBe(size);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(7)]
+ [InlineData(9)]
+ public void AResizeOfTheWrongLength_IsRejected(int length)
+ {
+ TerminalFrame.TryReadResize(new byte[length], out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AResizePayload_OrdersColumnsBeforeRows()
+ {
+ // The page builds this by hand, and columns-before-rows is the SSH convention. Swapping them
+ // produces a terminal that is 24 columns by 80 rows, which looks like a rendering bug.
+ var payload = TerminalFrame.CreateResizePayload(new TerminalSize(80, 24));
+
+ payload[0].ShouldBe((byte)0);
+ payload[1].ShouldBe((byte)80);
+ payload[2].ShouldBe((byte)0);
+ payload[3].ShouldBe((byte)24);
+ }
+}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalSessionPumpTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalSessionPumpTests.cs
new file mode 100644
index 0000000..9d24b8f
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalSessionPumpTests.cs
@@ -0,0 +1,350 @@
+using System.Diagnostics;
+using System.Globalization;
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.Terminal.Tests;
+
+///
+/// The output path under load: coalescing, backpressure, and shutdown.
+///
+///
+/// The plan calls for a throughput harness built before any UI, and this is it. Terminal throughput is
+/// where a client like this usually fails — cat on a large file either freezes the UI or grows
+/// memory until the process dies — and neither symptom is diagnosable once a WebView is in the picture.
+///
+public sealed class TerminalSessionPumpTests
+{
+ private const uint SessionId = 7;
+
+ [Fact]
+ public async Task Output_ReachesTheTransportTaggedWithItsSession()
+ {
+ await using var session = new FakeShellSession(bytesToProduce: 64);
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(session, transport);
+ transport.AutoAcknowledge = pump;
+
+ await pump.RunAsync(TestContext.Current.CancellationToken);
+
+ transport.OutputBytes().Length.ShouldBe(64);
+
+ foreach (var frame in transport.Frames)
+ {
+ TerminalFrame.TryRead(frame, out _, out var sessionId, out _).ShouldBeTrue();
+ sessionId.ShouldBe(SessionId);
+ }
+ }
+
+ [Fact]
+ public async Task ASessionOpenedFrame_PrecedesAnyOutput()
+ {
+ // The renderer has to attach a terminal before bytes arrive for it, or the first screenful is
+ // written into nothing.
+ await using var session = new FakeShellSession(bytesToProduce: 16);
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(session, transport);
+ transport.AutoAcknowledge = pump;
+
+ await pump.RunAsync(TestContext.Current.CancellationToken);
+
+ TerminalFrame.TryRead(transport.Frames[0], out var opcode, out _, out _).ShouldBeTrue();
+ opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
+ }
+
+ [Fact]
+ public async Task SeveralReadsWithinOneInterval_BecomeASingleFrame()
+ {
+ // A terminal cannot render faster than the display refreshes, so sending each read separately
+ // is work whose result is overwritten before anyone sees it.
+ await using var session = new FakeShellSession(bytesToProduce: 8 * 1024);
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(
+ session,
+ transport,
+ new TerminalPumpOptions
+ {
+ ReadBufferBytes = 512,
+ FlushInterval = TimeSpan.FromMilliseconds(400),
+ });
+
+ transport.AutoAcknowledge = pump;
+
+ var run = pump.RunAsync(TestContext.Current.CancellationToken);
+
+ // Long enough for one flush, short enough that a second cannot have happened.
+ await Task.Delay(TimeSpan.FromMilliseconds(600), TestContext.Current.CancellationToken);
+
+ // 8 KiB arrived as sixteen 512-byte reads and left as one frame.
+ transport.CountOf(TerminalServerOpcode.Output).ShouldBe(1);
+ transport.OutputBytes().Length.ShouldBe(8 * 1024);
+
+ await run;
+ }
+
+ [Fact]
+ public async Task WhenCreditRunsOut_ThePumpStopsReading()
+ {
+ // The property the whole design rests on. With no acknowledgement the pump must read exactly
+ // the window and then stop, so nothing accumulates between the socket and the screen. Left
+ // unbounded, a remote running `yes` grows the client's memory until it dies.
+ const int Window = 4 * 1024;
+
+ await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
+ var transport = new RecordingTransport();
+
+ // Deliberately no AutoAcknowledge: this models a renderer that has stopped keeping up.
+ await using var pump = CreatePump(
+ session,
+ transport,
+ new TerminalPumpOptions
+ {
+ ReadBufferBytes = 1024,
+ FlushInterval = TimeSpan.FromMilliseconds(5),
+ WindowBytes = Window,
+ });
+
+ var run = pump.RunAsync(TestContext.Current.CancellationToken);
+
+ await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
+
+ pump.BytesRead.ShouldBe(
+ Window,
+ "The pump read past its credit window, so output accumulates without bound.");
+
+ // Still stalled a moment later, rather than merely slow.
+ await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
+ pump.BytesRead.ShouldBe(Window);
+
+ pump.Credits.Available.ShouldBe(0);
+
+ await pump.DisposeAsync();
+ await run;
+ }
+
+ [Fact]
+ public async Task AcknowledgingCredit_LetsThePumpResume()
+ {
+ const int Window = 4 * 1024;
+
+ await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(
+ session,
+ transport,
+ new TerminalPumpOptions
+ {
+ ReadBufferBytes = 1024,
+ FlushInterval = TimeSpan.FromMilliseconds(5),
+ WindowBytes = Window,
+ });
+
+ var run = pump.RunAsync(TestContext.Current.CancellationToken);
+
+ await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
+ pump.BytesRead.ShouldBe(Window);
+
+ pump.Acknowledge(2048);
+
+ await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
+
+ pump.BytesRead.ShouldBe(Window + 2048);
+
+ await pump.DisposeAsync();
+ await run;
+ }
+
+ [Fact]
+ public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
+ {
+ // The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must
+ // not be able to talk the host into an unbounded read.
+ const int Window = 4 * 1024;
+
+ await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(
+ session,
+ transport,
+ new TerminalPumpOptions
+ {
+ ReadBufferBytes = 1024,
+ FlushInterval = TimeSpan.FromMilliseconds(5),
+ WindowBytes = Window,
+ });
+
+ var run = pump.RunAsync(TestContext.Current.CancellationToken);
+ await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
+
+ pump.Acknowledge(uint.MaxValue);
+
+ await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
+
+ // One further window's worth at most, never everything the endless producer could offer.
+ pump.BytesRead.ShouldBeLessThanOrEqualTo(Window * 2);
+
+ await pump.DisposeAsync();
+ await run;
+ }
+
+ [Fact]
+ public async Task WhenTheRemoteCloses_ASessionClosedFrameFollowsTheLastOutput()
+ {
+ // The last thing a remote writes is often the most important — an error, an exit code — and
+ // dropping it makes a clean exit look like a crash.
+ await using var session = new FakeShellSession(bytesToProduce: 4096);
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(
+ session,
+ transport,
+ new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
+
+ transport.AutoAcknowledge = pump;
+
+ await pump.RunAsync(TestContext.Current.CancellationToken);
+
+ transport.OutputBytes().Length.ShouldBe(4096);
+
+ TerminalFrame.TryRead(transport.Frames[^1], out var opcode, out _, out var payload)
+ .ShouldBeTrue();
+
+ opcode.ShouldBe((byte)TerminalServerOpcode.SessionClosed);
+ System.Text.Encoding.UTF8.GetString(payload).ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task Input_ReachesTheRemote()
+ {
+ await using var session = new FakeShellSession();
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(session, transport);
+
+ await pump.WriteInputAsync("ls -la\r"u8.ToArray(), TestContext.Current.CancellationToken);
+
+ System.Text.Encoding.UTF8.GetString(session.Written).ShouldBe("ls -la\r");
+ }
+
+ [Fact]
+ public async Task AResize_ReachesTheRemote()
+ {
+ await using var session = new FakeShellSession();
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(session, transport);
+
+ pump.Resize(new TerminalSize(132, 43, 1320, 1075));
+
+ session.ResizeCount.ShouldBe(1);
+ session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075));
+ }
+
+ [Fact]
+ public async Task AZeroSizedResize_IsDropped()
+ {
+ // Arrives naturally when a pane collapses or a window minimises. Forwarding it leaves the
+ // remote's idea of the terminal nonsensical until the next resize.
+ await using var session = new FakeShellSession();
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(session, transport);
+
+ pump.Resize(new TerminalSize(0, 0));
+
+ session.ResizeCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task ThroughputHarness_SustainsTenMegabytesPerSecondWithBoundedMemory()
+ {
+ // The plan's target: `yes` at full tilt must hold a fixed memory ceiling. Throughput is
+ // asserted well below what the machine manages, because a CI runner under load is slower than
+ // a desktop and a flaky performance test gets deleted rather than fixed. The bounded-memory
+ // assertion is the one that carries the real meaning.
+ const long Target = 32L * 1024 * 1024;
+ const int Window = CreditWindow.DefaultWindowBytes;
+
+ await using var session = new FakeShellSession(bytesToProduce: Target);
+ var transport = new RecordingTransport();
+
+ await using var pump = CreatePump(
+ session,
+ transport,
+ new TerminalPumpOptions
+ {
+ ReadBufferBytes = 32 * 1024,
+ FlushInterval = TimeSpan.FromMilliseconds(1),
+ WindowBytes = Window,
+ });
+
+ transport.AutoAcknowledge = pump;
+
+ using var monitor = new CancellationTokenSource();
+ var sampler = SamplePeakOutstandingAsync(pump, monitor.Token);
+
+ var stopwatch = Stopwatch.StartNew();
+ await pump.RunAsync(TestContext.Current.CancellationToken);
+ stopwatch.Stop();
+
+ await monitor.CancelAsync();
+ var peakOutstanding = await sampler;
+
+ pump.BytesRead.ShouldBe(Target);
+ transport.OutputBytes().Length.ShouldBe((int)Target);
+
+ var megabytesPerSecond = Target / 1024d / 1024d / stopwatch.Elapsed.TotalSeconds;
+
+ megabytesPerSecond.ShouldBeGreaterThan(
+ 10,
+ string.Create(
+ CultureInfo.InvariantCulture,
+ $"Sustained {megabytesPerSecond:F1} MB/s, below the 10 MB/s target."));
+
+ // The real assertion: in-flight bytes never exceeded the window, however fast the producer ran.
+ peakOutstanding.ShouldBeLessThanOrEqualTo(
+ Window,
+ string.Create(
+ CultureInfo.InvariantCulture,
+ $"Peak in-flight was {peakOutstanding} bytes against a {Window}-byte window."));
+ }
+
+ /// Watches in-flight bytes for the duration of a run, returning the highest seen.
+ private static async Task SamplePeakOutstandingAsync(
+ TerminalSessionPump pump,
+ CancellationToken cancellationToken)
+ {
+ var peak = 0;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ peak = Math.Max(peak, pump.Credits.Outstanding);
+
+ try
+ {
+ await Task.Delay(1, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+
+ return peak;
+ }
+
+ private static TerminalSessionPump CreatePump(
+ FakeShellSession session,
+ ITerminalTransport transport,
+ TerminalPumpOptions? options = null) =>
+ new(
+ SessionId,
+ session,
+ transport,
+ TimeProvider.System,
+ options ?? new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
+}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/packages.lock.json b/tests/DodoSSH.Client.Terminal.Tests/packages.lock.json
new file mode 100644
index 0000000..897f5d5
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/packages.lock.json
@@ -0,0 +1,240 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Meziantou.Analyzer": {
+ "type": "Direct",
+ "requested": "[3.0.134, )",
+ "resolved": "3.0.134",
+ "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
+ },
+ "Microsoft.CodeAnalysis.BannedApiAnalyzers": {
+ "type": "Direct",
+ "requested": "[5.6.0, )",
+ "resolved": "5.6.0",
+ "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
+ },
+ "NSubstitute": {
+ "type": "Direct",
+ "requested": "[6.0.0, )",
+ "resolved": "6.0.0",
+ "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
+ "dependencies": {
+ "Castle.Core": "5.1.1"
+ }
+ },
+ "Shouldly": {
+ "type": "Direct",
+ "requested": "[4.3.0, )",
+ "resolved": "4.3.0",
+ "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
+ "dependencies": {
+ "DiffEngine": "11.3.0",
+ "EmptyFiles": "4.4.0"
+ }
+ },
+ "xunit.v3": {
+ "type": "Direct",
+ "requested": "[3.2.2, )",
+ "resolved": "3.2.2",
+ "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
+ "dependencies": {
+ "xunit.v3.mtp-v1": "[3.2.2]"
+ }
+ },
+ "Castle.Core": {
+ "type": "Transitive",
+ "resolved": "5.1.1",
+ "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
+ "dependencies": {
+ "System.Diagnostics.EventLog": "6.0.0"
+ }
+ },
+ "DiffEngine": {
+ "type": "Transitive",
+ "resolved": "11.3.0",
+ "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
+ "dependencies": {
+ "EmptyFiles": "4.4.0",
+ "System.Management": "6.0.1"
+ }
+ },
+ "EmptyFiles": {
+ "type": "Transitive",
+ "resolved": "4.4.0",
+ "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
+ },
+ "Microsoft.ApplicationInsights": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.2",
+ "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.3",
+ "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
+ }
+ },
+ "Microsoft.Testing.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
+ "dependencies": {
+ "Microsoft.ApplicationInsights": "2.23.0",
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Platform": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
+ },
+ "Microsoft.Testing.Platform.MSBuild": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Win32.Registry": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
+ },
+ "System.CodeDom": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
+ },
+ "System.Management": {
+ "type": "Transitive",
+ "resolved": "6.0.1",
+ "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
+ "dependencies": {
+ "System.CodeDom": "6.0.0"
+ }
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.27.0",
+ "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
+ },
+ "xunit.v3.assert": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
+ },
+ "xunit.v3.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0"
+ }
+ },
+ "xunit.v3.core.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
+ "dependencies": {
+ "Microsoft.Testing.Extensions.Telemetry": "1.9.1",
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
+ "Microsoft.Testing.Platform": "1.9.1",
+ "Microsoft.Testing.Platform.MSBuild": "1.9.1",
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.inproc.console": "[3.2.2]"
+ }
+ },
+ "xunit.v3.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
+ "dependencies": {
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
+ "dependencies": {
+ "xunit.analyzers": "1.27.0",
+ "xunit.v3.assert": "[3.2.2]",
+ "xunit.v3.core.mtp-v1": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
+ "dependencies": {
+ "Microsoft.Win32.Registry": "[5.0.0]",
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.inproc.console": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.common": "[3.2.2]"
+ }
+ },
+ "dodossh.client.ssh": {
+ "type": "Project",
+ "dependencies": {
+ "SSH.NET": "[2025.1.0, )"
+ }
+ },
+ "dodossh.client.terminal": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Ssh": "[1.0.0, )"
+ }
+ },
+ "BouncyCastle.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[2.6.2, )",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "SSH.NET": {
+ "type": "CentralTransitive",
+ "requested": "[2025.1.0, )",
+ "resolved": "2025.1.0",
+ "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
+ "dependencies": {
+ "BouncyCastle.Cryptography": "2.6.2",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file