Files
DodoSSH/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
T
jaap-jan 367cebae9b
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s
Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
2026-10-03 14:29:22 +02:00

1074 lines
51 KiB
C#

using System.Globalization;
using System.Text;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal;
/// <summary>Tuning for the workspace.</summary>
public sealed class TerminalWorkspaceOptions
{
/// <summary>
/// How long <see cref="TerminalWorkspace.WaitForRendererAsync"/> waits for the renderer's socket
/// before giving up.
/// </summary>
/// <remarks>
/// <para>
/// The value has to separate two cases. Attaching is normally near-instant: WebView2 starts with the
/// window and the page has usually attached while the user was still typing a passphrase. But a first
/// run on a cold profile creates a user-data directory and starts a process tree of some thirty-five
/// processes, and on a slow or loaded machine that is seconds rather than milliseconds. A renderer
/// that will never attach — no Evergreen runtime, an install blocked by policy, an AppContainer that
/// cannot reach loopback — will not attach however long the wait is.
/// </para>
/// <para>
/// So being generous costs only how long a genuinely broken WebView2 takes to say so, while being
/// tight costs telling someone their runtime is broken when it was merely slow. Fifteen seconds is
/// well clear of any cold start observed here and is still an answer rather than a hang.
/// </para>
/// </remarks>
public TimeSpan RendererTimeout { get; init; } = TimeSpan.FromSeconds(15);
}
/// <summary>A session whose shell has ended.</summary>
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
/// <param name="outcome">How it ended, and the sentence that says so.</param>
/// <remarks>
/// ◆ <b>The outcome is here because a subscriber cannot work it out afterwards.</b> By the time this event
/// is raised the session is over: there is no transport left to interrogate, and the difference between a
/// shell that exited and a connection that broke is exactly the difference between a tab that is finished
/// and a tab worth offering to dial again. It used to carry the id alone, so the only place that difference
/// existed was a line of grey text inside the WebView.
/// </remarks>
public sealed class TerminalSessionEndedEventArgs(uint sessionId, TerminalSessionOutcome outcome) : EventArgs
{
/// <summary>The session that ended.</summary>
public uint SessionId { get; } = sessionId;
/// <inheritdoc cref="TerminalSessionOutcome" />
public TerminalSessionOutcome Outcome { get; } = outcome;
}
/// <summary>
/// The negotiated cipher and host-key algorithm for one live session.
/// </summary>
/// <param name="Cipher">The server-to-client encryption algorithm; see <see cref="ISshConnection.Cipher"/>.</param>
/// <param name="HostKeyAlgorithm">The host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
/// <remarks>
/// Two facts rather than the whole <see cref="ISshConnection"/>, because that is all a caller outside this
/// assembly has any business reading off a session it does not own — everything else on the connection
/// (disposal, the shell) belongs to the workspace alone. See <see cref="TerminalWorkspace.GetSessionFacts"/>.
/// </remarks>
public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm);
/// <summary>The renderer asking for a different font size.</summary>
/// <param name="step">
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
/// size because the page does not hold the current one — the host does, and clamping a step is what stops
/// two chords in flight from disagreeing about where they started.
/// </param>
public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
{
/// <summary>The requested move, or zero for "back to the default".</summary>
public int Step { get; } = step;
}
/// <summary>The renderer handing over text for the system clipboard.</summary>
/// <param name="text">
/// The selection, exactly as the page had it. Never empty — the transport drops an empty copy rather than
/// letting a stray gesture wipe the machine's clipboard.
/// </param>
public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs
{
/// <summary>What to put on the clipboard.</summary>
public string Text { get; } = text;
}
/// <summary>The renderer asking for the system clipboard's text.</summary>
/// <param name="sessionId">
/// The pane the pointer was over, which is where the text is to land. The page sends it rather than leaving
/// the host to infer it from whichever tab it believes is selected.
/// </param>
public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs
{
/// <summary>The terminal to paste into.</summary>
public uint SessionId { get; } = sessionId;
}
/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
/// <remarks>
/// <para>
/// One data plane and one renderer page for the whole application, with a session id per terminal.
/// Not one WebView per tab: each WebView2 is a separate browser process, so twenty tabs would mean
/// twenty renderer processes and several hundred megabytes for a working set a user would call
/// ordinary. Splits and tabs are layout inside the single page.
/// </para>
/// <para>
/// <b>A session's lifetime is the application's, not the vault's.</b> This object is composed once at
/// startup and outlives every lock, deliberately: locking the vault zeroes keys, and a shell needs no
/// vault key to keep running, so a job started before the lock keeps running through it. That is a
/// policy rather than an oversight — <c>MainWindowViewModel.LockAsync</c> says why, and the shell shows
/// <see cref="LiveSessionCount"/> on the unlock screen so it is not a hidden state.
/// </para>
/// </remarks>
public sealed class TerminalWorkspace : IAsyncDisposable
{
private readonly TerminalDataPlane dataPlane;
private readonly ISshConnectionFactory connections;
private readonly TimeProvider clock;
private readonly TerminalWorkspaceOptions options;
private readonly Dictionary<uint, LiveSession> sessions = [];
/// <summary>
/// Guards <see cref="sessions"/> and <see cref="nextSessionId"/>.
/// </summary>
/// <remarks>
/// This dictionary is genuinely touched from more than one thread, which the tab strip made true rather
/// than merely arguable: sessions are opened and closed from the UI thread, and
/// <see cref="RunSessionAsync"/>'s finally now asks whether the session is still known — on whichever
/// thread-pool thread the pump happened to unwind on. An unsynchronised <see cref="Dictionary{TKey,
/// TValue}"/> read concurrent with a write does not merely return a stale answer; it can throw or spin.
/// The data plane guards its own registration table the same way and for the same reason.
/// </remarks>
private readonly Lock sessionGate = new();
private readonly CancellationTokenSource lifetime = new();
/// <summary>
/// The payload that marks a <see cref="TerminalServerOpcode.SessionOpened"/> frame as a replay rather
/// than a fresh open. A one-byte non-empty payload, so terminal.js's existing length check (empty
/// payload for a real open) tells the two apart without a second opcode.
/// </summary>
private static readonly byte[] ReplayMarker = [1];
/// <summary>What <see cref="CloseSessionAsync"/> announces, whatever the pump made of the same end.</summary>
private static readonly TerminalSessionOutcome ClosedByThisClient =
new(SshSessionEnd.ClosedLocally, "The session was closed.");
private uint nextSessionId = 1;
private Task? server;
private int disposed;
/// <param name="assets">Where the renderer's files come from.</param>
/// <param name="connections">How SSH connections are made.</param>
/// <param name="clock">Time source, so the pumps' flush interval is testable.</param>
/// <param name="options">Tuning, or null for the defaults.</param>
public TerminalWorkspace(
ITerminalAssetProvider assets,
ISshConnectionFactory connections,
TimeProvider clock,
TerminalWorkspaceOptions? options = null)
{
this.connections = connections;
this.clock = clock;
this.options = options ?? new TerminalWorkspaceOptions();
dataPlane = new TerminalDataPlane(assets);
// Forwarded rather than re-raised with the workspace as the sender, so a handler can tell where it
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is
// what remembers it between launches.
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
// Forwarded for the same reason and decided no more than the size is. What a clipboard is and how it
// is reached is a property of the platform the shell is running on, and this object has never seen a
// visual.
dataPlane.ClipboardCopyRequested += (_, e) => ClipboardCopyRequested?.Invoke(this, e);
dataPlane.ClipboardPasteRequested += (_, e) => ClipboardPasteRequested?.Invoke(this, e);
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
// handshake handling, and has no business making that wait on however long a replay takes. See
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
// is harmless.
dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync();
}
/// <summary>
/// Where connections are recorded, or null to record none.
/// </summary>
/// <remarks>
/// <para>
/// Settable rather than a constructor parameter, because the two objects have different lifetimes and
/// the workspace's is the longer one: it is composed at startup and outlives every lock, while anything
/// that can write to a vault exists only while one is open. Locking sets this back to null, and the
/// sessions that were already running go on running with nothing recording them.
/// </para>
/// <para>
/// Which means an entry can be missed — a shell open across a lock and closed after it. The recorder
/// holds its store for close-out past <c>Close()</c> precisely so that the common case does not, and the
/// residue is stated here rather than papered over.
/// </para>
/// </remarks>
public IConnectionLogSink? ConnectionLog { get; set; }
/// <summary>Where the WebView should navigate.</summary>
public Uri PageUrl => dataPlane.PageUrl;
/// <summary>
/// How many terminals still have a live shell behind them.
/// </summary>
/// <remarks>
/// <para>
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes the
/// channel on its own — <see cref="ReleaseAsync"/> gives back what that session was holding but leaves
/// the entry, so the tab can go on saying it ended — and a session whose shell exited half an hour ago
/// is therefore still in the dictionary. A completed <c>Run</c> task is what "the shell is gone"
/// actually looks like: the pump's loops have finished and it has already sent <c>SessionClosed</c> to
/// the renderer.
/// </para>
/// <para>
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
/// claims a shell is still running when it is not would be the same class of dishonesty the number
/// is there to prevent.
/// </para>
/// </remarks>
public int LiveSessionCount
{
get
{
lock (sessionGate)
{
return sessions.Values.Count(session => !session.Run.IsCompleted);
}
}
}
/// <summary>
/// Whether one session's shell is still running.
/// </summary>
/// <remarks>
/// The same question <see cref="LiveSessionCount"/> answers in aggregate, and answered the same way: a
/// completed <c>Run</c> is what "the shell is gone" looks like, because nothing removes the entry when
/// the remote closes the channel on its own. A session id this workspace never issued is not live.
/// </remarks>
public bool IsSessionLive(uint sessionId)
{
lock (sessionGate)
{
return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted;
}
}
/// <summary>
/// The negotiated cipher and host-key algorithm for one live session, or null when the id names no
/// session this workspace still has open.
/// </summary>
/// <remarks>
/// <para>
/// Additive, and deliberately narrow: the caller this exists for — the shell's own connect path, which
/// wants these two facts for its status bar — has no other business with a session it does not own, and a
/// method that handed back the <see cref="ISshConnection"/> itself would have handed over the shell,
/// disposal and all, to code that already goes through <see cref="CloseSessionAsync"/> for that.
/// </para>
/// <para>
/// "No session this workspace still has open" covers two different absences the same way
/// <see cref="IsSessionLive"/> already does: an id this workspace never issued, and one whose shell has
/// already ended but whose entry has not been removed yet. Both are "nothing to report" to a caller
/// asking what a session's transport looks like right now.
/// </para>
/// </remarks>
public SessionFacts? GetSessionFacts(uint sessionId)
{
lock (sessionGate)
{
if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted)
{
return null;
}
return new SessionFacts(session.Connection.Cipher, session.Connection.HostKey.Algorithm);
}
}
/// <summary>
/// What one live session's remote has most recently written, or null when the id names no session this
/// workspace still has open.
/// </summary>
/// <remarks>
/// For the phone's command history, which keeps a typed line only once it has seen the remote echo it —
/// see <see cref="OutputTail"/>. A copy, so the caller can take its time over it.
/// </remarks>
public RecentOutput? GetRecentOutput(uint sessionId)
{
lock (sessionGate)
{
return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
? session.Pump.Recent.Snapshot()
: null;
}
}
/// <summary>
/// A live session's flow-control window, or null when the id names no session this workspace still has
/// open.
/// </summary>
/// <remarks>
/// A test seam rather than something the shell has ever needed: nothing outside this assembly has a
/// reason to see a pump's credit window rather than what the transport does with it, but
/// <see cref="ReplayAfterAttachAsync"/>'s reset of that window on reattach is exactly the kind of thing
/// that is easy to get backwards, and worth asserting directly rather than only through its side
/// effects. Internal rather than public, reachable from the test assembly through the
/// <c>InternalsVisibleTo</c> this project already declares for it.
/// </remarks>
internal CreditWindow? CreditsFor(uint sessionId)
{
lock (sessionGate)
{
return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null;
}
}
/// <summary>
/// Raised with the session id once a session is over — its shell having ended on its own, or a
/// deliberate close having fully drained.
/// </summary>
/// <remarks>
/// <para>
/// A tab has to be able to stop claiming it is connected, and polling would be the alternative: a timer
/// asking <see cref="IsSessionLive"/> often enough to look immediate, for a thing that happens a handful
/// of times a day. The renderer already learns about this instantly — the pump sends
/// <c>SessionClosed</c> and the page writes the reason into the pane — so this is the same fact reaching
/// the half of the interface Avalonia draws.
/// </para>
/// <para>
/// <b>Raised only after the session's run task has completed, and that ordering is load-bearing.</b>
/// It used to fire from inside the run's own finally block, where the task is by definition not yet
/// complete — so a handler reading <see cref="LiveSessionCount"/> still counted the session that had
/// just ended, which is how the phone's foreground notification went on saying "1 shell connected"
/// over nothing. See <see cref="AnnounceEndedAsync"/>. Raised on a thread-pool continuation, or on the
/// closer's own thread; a handler that touches an observable collection has to marshal either way.
/// </para>
/// <para>
/// <b>Raised by <see cref="CloseSessionAsync"/> too, which reverses a recorded decision.</b> The old
/// reasoning — the caller asked, so telling it is an echo — assumed every subscriber was the caller.
/// The phone's keep-alive is not: it hears this event to reconcile a notification with reality, and a
/// close that announced nothing left that notification claiming a shell that was gone. Every subscriber
/// treats the event as "reconcile" rather than "act" — a tab is marked dead if it is still there and
/// skipped if it is not — so a second announcement for a session that already announced its own end
/// (closing the tab of a shell that exited earlier) is deliberate and harmless. Shutdown is the one
/// close that stays silent: <see cref="DisposeAsync"/> is tearing the subscribers down with the
/// sessions, and news nobody is left to hear is not news.
/// </para>
/// </remarks>
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
/// <summary>Raised when the renderer's own keyboard asks for a different font size.</summary>
/// <remarks>
/// The chords can only be heard by the page — once a terminal has focus the host's window sees no key
/// events at all — so this is how Ctrl+plus reaches the thing that owns the setting. Raised on the
/// socket's receive loop; marshal before touching a view model.
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Raised when a right-click over a selection asks for it to be copied.</summary>
/// <remarks>
/// The gesture can only be heard by the page, for the reason <see cref="FontSizeStepRequested"/> gives,
/// and the selection can only be <em>read</em> there: xterm draws it into a canvas. So the text arrives
/// here, and what a clipboard is on this platform is the shell's to know. Raised on the data plane's
/// receive loop; marshal before touching one.
/// </remarks>
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
/// <summary>Raised when a right-click with nothing selected asks for the clipboard to be pasted.</summary>
/// <remarks>
/// Answered with <see cref="SendClipboardAsync"/>, which is the other half: this event carries no text
/// because the page has none to give — reading the clipboard is exactly what it cannot do. Raised on the
/// data plane's receive loop; marshal before touching a view model, and before touching a clipboard,
/// which is a UI-thread object on more than one platform.
/// </remarks>
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
/// <summary>
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
/// </summary>
/// <remarks>
/// <para>
/// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets
/// its <c>SessionOpened</c> frame again, done by the time this fires. What is left is what the workspace
/// has no business owning: the font size and which tab is selected are both remembered by the shell, not
/// by a terminal, so this is the seam the shell uses to re-push them. See
/// <c>MainWindowViewModel</c>'s subscription for the other half.
/// </para>
/// <para>
/// Raised on the socket-accept thread, same as <see cref="TerminalDataPlane.SocketAttached"/> that
/// triggers it — a handler that touches a view model has to marshal.
/// </para>
/// </remarks>
public event EventHandler? RendererReattached;
/// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
/// <summary>
/// Tells every pane what size to draw at.
/// </summary>
/// <remarks>
/// <para>
/// Sent to the page rather than applied per session, and unconditionally rather than only when a
/// session is live: the page keeps the size for panes opened later, so this is also how the first
/// terminal of a launch comes up at the size the user last chose.
/// </para>
/// <para>
/// Every live pane refits as a result and reports its new geometry, so the remotes are told they have
/// fewer columns. That round trip is the feature rather than a side effect — see
/// <see cref="TerminalServerOpcode.FontSize"/>.
/// </para>
/// </remarks>
/// <param name="pixels">The size in CSS pixels. Clamped by the caller; sent as one byte.</param>
/// <param name="cancellationToken">Cancellation.</param>
public ValueTask SetFontSizeAsync(int pixels, CancellationToken cancellationToken) =>
dataPlane.SendAsync(
TerminalFrame.Create(
(byte)TerminalServerOpcode.FontSize,
sessionId: 0,
TerminalFrame.CreateFontSizePayload(pixels)),
cancellationToken);
/// <summary>
/// Hands the system clipboard's text to one pane, to be pasted into it.
/// </summary>
/// <remarks>
/// <para>
/// The answer to <see cref="ClipboardPasteRequested"/>, and the only thing that sends this frame. Always
/// bracketed and never followed by an Enter — see <see cref="TerminalServerOpcode.Clipboard"/> for why a
/// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode.
/// </para>
/// <para>
/// Refused for a dead session, exactly as <see cref="PasteAsync"/> is refused and for the weaker of its
/// two reasons: nothing visible would happen either way, because a paste is keystrokes rather than
/// output and there is no shell left to echo them. Checked anyway so that the answer is honest, and so
/// that a caller which grows a message one day has something to hang it on.
/// </para>
/// <para>
/// An empty clipboard is the caller's to notice. Sending nothing is not the same as sending an empty
/// string — the page would bracket it and the remote would see an empty paste — and "there was nothing
/// to paste" is a fact about the machine rather than about this terminal.
/// </para>
/// </remarks>
/// <param name="sessionId">The terminal to paste into.</param>
/// <param name="text">The clipboard's text. Sent verbatim.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns><see langword="false"/> when that session's shell is not running.</returns>
public async Task<bool> SendClipboardAsync(
uint sessionId,
string text,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(text);
if (!IsSessionLive(sessionId))
{
return false;
}
await dataPlane
.SendAsync(
TerminalFrame.Create(
(byte)TerminalServerOpcode.Clipboard,
sessionId,
Encoding.UTF8.GetBytes(text)),
cancellationToken)
.ConfigureAwait(false);
return true;
}
/// <summary>
/// Waits until the renderer page has attached its socket.
/// </summary>
/// <remarks>
/// <para>
/// A session opened before the renderer attaches would have its <c>SessionOpened</c> frame dropped —
/// the transport discards frames when nothing is connected — leaving output arriving for a terminal
/// that was never created. The gate is the invariant and stays.
/// </para>
/// <para>
/// Bounded, because whether the renderer attaches at all depends on a WebView2 runtime this process
/// does not install. An unbounded wait turned a missing runtime into a Connect that never returned,
/// with the caller's busy state never cleared and nothing on screen to explain it. Callers are
/// expected to translate the timeout into something that names the runtime, because
/// <see cref="TimeoutException"/>'s own message names nothing.
/// </para>
/// </remarks>
/// <param name="cancellationToken">Abandons the wait.</param>
/// <exception cref="TimeoutException">
/// No renderer attached within <see cref="TerminalWorkspaceOptions.RendererTimeout"/>.
/// </exception>
public Task WaitForRendererAsync(CancellationToken cancellationToken) =>
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
/// <summary>Connects to a host and starts a terminal for it.</summary>
/// <param name="request">What to connect to, as whom, and with what.</param>
/// <param name="size">The pseudo-terminal's initial size.</param>
/// <param name="progress">
/// Told each phase as it begins, or null to report nothing. Reported from the handshake's own thread;
/// see <see cref="SshConnectionPhase"/>. Optional because a session opened by anything other than the
/// connecting card has nobody watching a step list for it, which is every caller but one.
/// </param>
/// <param name="cancellationToken">Abandons the attempt.</param>
/// <returns>The session id, which identifies this terminal in the renderer.</returns>
/// <remarks>
/// <see cref="SshConnectionPhase.OpeningShell"/> is reported here rather than by the factory because
/// this is where it happens: the factory's work ends with an authenticated connection, and asking for a
/// pseudo-terminal on it is a separate round trip this method makes.
/// </remarks>
public async Task<uint> OpenSessionAsync(
SshConnectionRequest request,
TerminalSize size,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
var connection = await connections
.ConnectAsync(request, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report(SshConnectionPhase.OpeningShell);
ISshShellSession shell;
try
{
shell = await connection.OpenShellAsync(size, cancellationToken).ConfigureAwait(false);
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
uint sessionId;
TerminalSessionPump pump;
lock (sessionGate)
{
sessionId = nextSessionId++;
pump = new TerminalSessionPump(sessionId, shell, dataPlane, clock);
}
dataPlane.Register(sessionId, pump);
// After the connection succeeded and before the run begins. Ordered that way for two reasons: a
// host-key refusal throws out of ConnectAsync above and must never be recorded as a session that
// started, and a session whose shell ends immediately must already have a ticket open for the
// finally below to close.
ConnectionLog?.Opened(sessionId, Describe(request), clock.GetUtcNow());
// Registered before running, so an acknowledgement that arrives with the very first output
// frame has somewhere to go.
var run = RunSessionAsync(sessionId, pump);
lock (sessionGate)
{
sessions[sessionId] = new LiveSession(connection, pump, run);
}
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
// so the containment check inside it can never run against a dictionary the session had not reached.
_ = AnnounceEndedAsync(sessionId, pump, run);
return sessionId;
}
/// <summary>
/// Starts a port forward over one terminal's connection.
/// </summary>
/// <param name="sessionId">The terminal whose connection the forward runs over.</param>
/// <param name="spec">What to forward.</param>
/// <param name="cancellationToken">Abandons the start.</param>
/// <returns>The forward, which the caller may dispose to stop it sooner.</returns>
/// <remarks>
/// <para>
/// <b>The forward lives as long as the session, and this object does not keep a list of them.</b> SSH.NET
/// stops every forward on a connection when the connection is disposed, which is what both
/// <see cref="CloseSessionAsync"/> and the release of a session that ended on its own already do — so a
/// second record here would be a copy of a fact the library already enforces, and the one that could
/// disagree with it. Disposing a handle after its session has gone is safe.
/// </para>
/// <para>
/// Refused for a session that is not running, for the reason <see cref="PasteAsync"/> refuses one: a tab
/// whose shell ended is still on screen, and a forward started on its connection would be a listener
/// over a transport that is already being torn down.
/// </para>
/// </remarks>
/// <exception cref="InvalidOperationException">The session is not running.</exception>
/// <exception cref="SshPortForwardException">The forward could not be started.</exception>
public Task<ISshPortForward> StartForwardAsync(
uint sessionId,
SshPortForwardSpec spec,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(spec);
ISshConnection connection;
lock (sessionGate)
{
if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted)
{
throw new InvalidOperationException("That terminal is no longer connected.");
}
connection = session.Connection;
}
return connection.StartForwardAsync(spec, cancellationToken);
}
/// <summary>
/// Shows one terminal's pane and hides the others.
/// </summary>
/// <remarks>
/// <para>
/// The page keeps a pane per session and displays one of them, so switching tabs is this frame and
/// nothing else — no terminal is destroyed, no scrollback is lost, and the shell behind a hidden pane
/// goes on running and goes on being read. That is the whole reason tabs cost so little here: the
/// expensive object is the WebView, and there is one of those however many tabs are open.
/// </para>
/// <para>
/// Sent for a session id this workspace does not know as readily as for one it does. The page ignores
/// a pane it has never created, and refusing here would mean holding a second copy of the tab strip's
/// idea of what exists — which is the sort of duplicated truth that ends up disagreeing.
/// </para>
/// </remarks>
public ValueTask ActivateSessionAsync(uint sessionId, CancellationToken cancellationToken) =>
dataPlane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionActivated, sessionId, []),
cancellationToken);
/// <summary>
/// Inserts text into one terminal, as if it had been pasted there.
/// </summary>
/// <param name="sessionId">The terminal to insert into.</param>
/// <param name="text">What to insert. Sent verbatim.</param>
/// <param name="execute">Whether to press Enter afterwards.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>
/// <see langword="false"/> when that session's shell is not running, which is an ordinary answer rather
/// than an error: a tab whose remote hung up an hour ago is still on screen and still selectable, and
/// somebody clicking a snippet at it has made a mistake worth a sentence, not an exception.
/// </returns>
/// <remarks>
/// <para>
/// <b>Refused for a dead session rather than sent and dropped.</b> The transport discards frames for a
/// pane the page no longer has, so sending regardless would look exactly like success — and the one thing
/// somebody inserting a command needs to know is whether it arrived.
/// </para>
/// <para>
/// The liveness check and the send are deliberately not atomic. A shell that ends between the two is a
/// race no lock can close — the remote could hang up while the frame is in the socket — so the check is
/// there to catch the ordinary case honestly, not to make a guarantee it cannot keep.
/// </para>
/// </remarks>
public async Task<bool> PasteAsync(
uint sessionId,
string text,
bool execute,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(text);
if (!IsSessionLive(sessionId))
{
return false;
}
var utf8 = Encoding.UTF8.GetBytes(text);
var payload = new byte[1 + utf8.Length];
payload[0] = execute ? (byte)1 : (byte)0;
utf8.CopyTo(payload, 1);
await dataPlane
.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.Paste, sessionId, payload),
cancellationToken)
.ConfigureAwait(false);
return true;
}
/// <summary>
/// Sends keystrokes to one terminal as though they had been typed into it.
/// </summary>
/// <remarks>
/// <para>
/// Ordinary typing does not come through here — it goes from the renderer's own keyboard handling
/// straight down the socket, which is one hop shorter and is what keeps a fast <c>cat</c> responsive.
/// This is for input that has no key on the keyboard to produce it.
/// </para>
/// <para>
/// <b>Which on a phone is most of the useful input.</b> A software keyboard has no Ctrl, Esc, Tab or
/// arrows, so the Android head draws an accessory row and sends the bytes itself; the desktop head
/// will want the same seam the day it grows a snippet that types into a terminal. Bytes rather than a
/// key name deliberately: what a terminal wants is a control sequence, and translating one at this
/// layer would mean owning a keymap that the renderer already owns.
/// </para>
/// <para>
/// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
/// </para>
/// <para>
/// A session whose shell has <em>ended</em> is ignored the same way, and has to be: the tab is still on
/// screen and still selectable, so an accessory key pressed at one is as ordinary as a key pressed at a
/// tab that has closed. It is also the one of the two that would throw rather than fail quietly, since
/// <see cref="ReleaseAsync"/> disposes the shell channel the moment the remote hangs up. Liveness
/// rather than mere presence, which is the same question <see cref="IsSessionLive"/> answers.
/// </para>
/// </remarks>
/// <param name="sessionId">The terminal to type into.</param>
/// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public ValueTask SendInputAsync(
uint sessionId,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken)
{
TerminalSessionPump? pump;
lock (sessionGate)
{
pump = sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
? session.Pump
: null;
}
return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
}
/// <summary>
/// Closes one terminal.
/// </summary>
/// <remarks>
/// Closing one terminal without taking the process down is what a tab close needs, and what it is now
/// reached for. It is also what <see cref="DisposeAsync"/> uses on the way out, which was its only
/// caller while the interface had no tabs.
/// </remarks>
public async Task CloseSessionAsync(uint sessionId)
{
LiveSession? session;
lock (sessionGate)
{
if (!sessions.Remove(sessionId, out session))
{
return;
}
}
dataPlane.Unregister(sessionId);
// Before the teardown, while the socket is still up. The page holds an xterm instance, its whole
// scrollback and a WebGL context per pane and never reclaimed one on its own; a frame dropped here
// is a leak nothing else would notice, because it is inside the WebView.
await dataPlane
.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionRemoved, sessionId, []),
CancellationToken.None)
.ConfigureAwait(false);
// The connection goes even if the pump's disposal throws. The entry is already out of the dictionary,
// so nothing will come back for it — not DisposeAsync's loop, which snapshots the keys — and an
// undisposed ISshConnection is a live authenticated channel to a remote host that this process has
// forgotten about. Which of the two is more important is not close.
try
{
await session.Pump.DisposeAsync().ConfigureAwait(false);
}
finally
{
await session.Connection.DisposeAsync().ConfigureAwait(false);
}
// Draining, not doing work. This await exists so the pump's loops have finished before the caller
// moves on; the session is already dead either way, and a fault here is nothing the caller can act
// on — the pump's last act is a best-effort SessionClosed frame on a socket that may have gone with
// the window. Letting that escape would fault a tab-close command over a session that closed fine.
try
{
await session.Run.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Expected on the ordinary path: disposing the pump cancels its run.
}
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
//
// Reported as this client's own doing whatever the pump made of it. A shell that had already ended on
// its own before the tab was closed still carries that older reason, and re-announcing it here as
// though it had just happened is how closing a tab comes to look like a dropped connection.
if (Volatile.Read(ref disposed) == 0)
{
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, ClosedByThisClient));
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
uint[] open;
lock (sessionGate)
{
open = [.. sessions.Keys];
}
foreach (var sessionId in open)
{
await CloseSessionAsync(sessionId).ConfigureAwait(false);
}
await lifetime.CancelAsync().ConfigureAwait(false);
await dataPlane.DisposeAsync().ConfigureAwait(false);
if (server is not null)
{
try
{
await server.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: the accept loop is stopped by cancelling it.
}
}
lifetime.Dispose();
}
/// <summary>
/// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its
/// own.
/// </summary>
/// <remarks>
/// <para>
/// Runs on the socket-accept thread that raised <see cref="TerminalDataPlane.SocketAttached"/> — the
/// constructor wires it up fire-and-forget for exactly that reason, so this method owns its own error
/// handling rather than leaving an unobserved exception for nobody to see.
/// </para>
/// <para>
/// Every live session — one whose <c>Run</c> has not completed — gets two things. Its credit window is
/// reset, because whatever was outstanding was reserved against bytes sent to a page that is now gone;
/// the acknowledgement that would return that credit died with it, and without this reset the session
/// would stall the moment 256 KiB of history had accumulated. And it gets its <c>SessionOpened</c> frame
/// again, marked with <see cref="ReplayMarker"/> so the page can tell a reattach from a session that is
/// genuinely new — the same frame a page that survived the socket drop already has a pane for, and one a
/// reloaded page does not.
/// </para>
/// <para>
/// A session whose shell has already ended gets nothing here. Its scrollback lived only in the page that
/// is gone, and sending a frame that implied otherwise would be exactly the kind of dishonesty this
/// fix is supposed to remove, not add. The tab strip still shows that session ended; nothing about this
/// method changes what <see cref="LiveSessionCount"/> or <see cref="IsSessionLive"/> report.
/// </para>
/// <para>
/// This can race the fresh page's own first frames — an early resize, an acknowledgement for output it
/// already had. That is harmless: every frame in both directions names its session, delivery order
/// within a session is preserved by both xterm and the socket, and a frame for a pane the page has not
/// created yet is simply dropped, the same as any frame for a session it does not know — see
/// <c>terminal.js</c>'s <c>handleFrame</c>.
/// </para>
/// </remarks>
private async Task ReplayAfterAttachAsync()
{
KeyValuePair<uint, LiveSession>[] live;
lock (sessionGate)
{
live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)];
}
try
{
foreach (var (sessionId, session) in live)
{
session.Pump.Credits.Reset();
await dataPlane
.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker),
CancellationToken.None)
.ConfigureAwait(false);
}
RendererReattached?.Invoke(this, EventArgs.Empty);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay
// leaves nothing worse than the problem this method exists to fix, and there is no caller on
// this thread left to hand a failure to.
}
}
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
{
try
{
await pump.RunAsync(lifetime.Token).ConfigureAwait(false);
}
finally
{
dataPlane.Unregister(sessionId);
// Unconditional, and this one hook covers all three ways a session ends: the user closing the
// tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
// the application is closing, and everything inside a finally is time the run task has not
// completed yet, which is time LiveSessionCount still counts this session as running.
//
// Neither SessionEnded nor the release of what the session held is done from here, and both
// used to be candidates for it — see AnnounceEndedAsync, which is where they ended up and why.
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
}
}
/// <summary>
/// Gives back everything a session that ended on its own was still holding.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Nothing used to do this, and a shell that exited went on costing what a running one costs.</b>
/// The entry stayed in <see cref="sessions"/> until the user closed the tab, and with it an undisposed
/// <see cref="ISshConnection"/> — an authenticated socket to the remote, its cipher state, and a
/// keep-alive still putting a packet on the wire every thirty seconds — plus the pump and, underneath
/// it, SSH.NET's read buffer at whatever size the session's heaviest moment had grown it to. A tab
/// somebody typed <c>exit</c> into an hour ago and left open was indistinguishable from a live one in
/// everything except what it drew, and a working day of those is the memory a user actually notices.
/// </para>
/// <para>
/// <b>The dictionary entry stays even so, and that is not an oversight.</b>
/// <see cref="LiveSessionCount"/>, <see cref="IsSessionLive"/> and
/// <see cref="AnnounceEndedAsync"/>'s containment check are all written against it — a session that
/// ended still has to be able to say so, and a tab that shows ENDED and offers a reconnect is reading
/// exactly that. What goes is everything the entry was holding that costs anything; what is left is
/// three references and a completed task.
/// </para>
/// <para>
/// Skipped entirely when the entry has already been removed, which is precisely the deliberate path:
/// <see cref="CloseSessionAsync"/> takes the entry out before it disposes anything, so the two can
/// never both be tearing the same session down. That is also what makes this a no-op during
/// <see cref="DisposeAsync"/>, which closes every session by that route before anything here could run.
/// </para>
/// <para>
/// Every failure is swallowed. The caller is a fire-and-forget continuation with nobody to hand a fault
/// to, and the shell it would be about is already gone.
/// </para>
/// </remarks>
private async Task ReleaseAsync(uint sessionId)
{
LiveSession? session;
lock (sessionGate)
{
if (!sessions.TryGetValue(sessionId, out session))
{
return;
}
}
try
{
// The connection goes even if the pump's disposal throws, for the reason CloseSessionAsync
// gives at the same pair of calls: an undisposed ISshConnection is a live authenticated
// channel this process has forgotten about.
try
{
await session.Pump.DisposeAsync().ConfigureAwait(false);
}
finally
{
await session.Connection.DisposeAsync().ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort. The shell is already gone; nothing a caller could do with this.
}
}
/// <summary>Announces a session's end once its run task has actually completed.</summary>
/// <param name="sessionId">The session, as the renderer knows it.</param>
/// <param name="pump">
/// The pump, for its <see cref="TerminalSessionPump.Outcome"/> — which is only settled once
/// <paramref name="run"/> has completed, and is therefore read after the await below rather than captured
/// alongside it.
/// </param>
/// <param name="run">The session's run task.</param>
/// <remarks>
/// <para>
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
/// what a handler sees. Inside that finally the run task is not yet complete — a finally is part of the
/// task — so <see cref="LiveSessionCount"/>, which counts incomplete runs, still included the session
/// that had just ended. The phone's keep-alive answers this event by reading exactly that count, and
/// reconciled its foreground notification to "1 shell connected" over a shell that was gone, with
/// nothing left to fire afterwards and correct it. By the time an await on the run resumes, the task is
/// complete and the count is honest.
/// </para>
/// <para>
/// The containment check keeps the deliberate paths out of this route: <see cref="CloseSessionAsync"/>
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
/// </para>
/// </remarks>
private async Task AnnounceEndedAsync(uint sessionId, TerminalSessionPump pump, Task run)
{
try
{
await run.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The run's faults belong to whoever drains it — CloseSessionAsync, on the deliberate path.
// This continuation cares only that the run is over, however it got there.
}
bool announce;
lock (sessionGate)
{
announce = sessions.ContainsKey(sessionId);
}
if (!announce)
{
return;
}
try
{
// Read after the await, which is the only point at which the pump has settled it — see
// TerminalSessionPump.Outcome.
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
}
finally
{
// After the announcement rather than before it, and in a finally so a handler that throws
// cannot leave a connection open. Tearing down an SSH connection is a socket closing and a
// library shutting down its listener; doing that first would put it between the shell ending
// and the tab being told, which is latency on the one thing the user is watching for.
await ReleaseAsync(sessionId).ConfigureAwait(false);
}
}
/// <summary>The address as dialled, for the log.</summary>
/// <remarks>
/// The username is in here because it is part of the address that was dialled, and an address without one
/// does not identify the connection — two people reaching one machine as different accounts is the
/// ordinary case. This is <em>not</em> the same as recording which account authenticated: nothing here
/// reads the credential, and the payload has no field for one.
/// </remarks>
private static string Describe(SshConnectionRequest request) =>
string.Create(
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
private sealed record LiveSession(ISshConnection Connection, TerminalSessionPump Pump, Task Run);
}