Public Access
The connecting card set its status string once, when the tab was created, and never touched it again. Every connection therefore looked identical from the outside: one three seconds into a key exchange, one waiting out a fifteen-second timeout against a machine that is asleep, and one that had hung all drew the same "connecting…". The card now draws the five steps of getting there, each lit at the moment the handshake reports reaching it, over an amber track that fills as they finish. ◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below it said so, at the instant the thing it names actually began. That is the whole reason it is worth showing, and it is why most of this commit is plumbing rather than XAML: there was no progress reporting anywhere in the stack to hook a step list onto, and a card animating plausible progress would have been indistinguishable from one that had stopped receiving any. SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the entire handshake inside one ConnectAsync and raises exactly one event from the middle of it — HostKeyReceived, once the key exchange has produced a key to show — so that event is the only interior moment there is to report. Everything before it is Reaching and everything after it is Authenticating. A fifth phase in that assembly would have to be a timer, so there is not one. OpeningShell is reported by TerminalWorkspace instead, because that is where it happens: the factory's work ends with an authenticated connection, and asking for a pseudo-terminal on one is a separate round trip. The SFTP path passes null — a second connection opened behind an already-open shell has nobody watching a step list for it. The card's fifth step, "Starting the terminal", is the renderer wait and lives in the shell rather than in the SSH assembly, which has never heard of a renderer. On the first connection after a cold start it is a real wait with a real failure mode of its own — a missing WebView2 runtime — so a list that began at "reaching the host" would leave the one wait most likely to hang unnamed. Amber for the step in flight, and that follows the palette's rule rather than bending it. Green is what is true and purple is what you can press; a step still happening is neither, and it is exactly the caveat-worth-reading that amber exists for. Steps behind it go green as they become true. Nothing animates, which is the argument TransfersScreen.axaml already makes for its own track, reaching a screen with far more reason to want a spinner: a spinner is furniture invented to fill a state nobody measured, and these states are measured, so the track fills to what has finished and then waits there. A refusal keeps the step it stopped on, in red, with the ones behind it still green. That is the half a progress bar could not do, and it is the difference between "that host is not there" and "that host is there and would not have me" — a question the reason sentence alone frequently does not settle. The strip's dot goes amber while a tab is connecting, on both heads. It was grey, and so is a tab whose shell has exited: the two states in that strip with the least in common, one worth waiting for and one over. PhoneShell's own comment already recorded half of this — the dot stopped being green before anything had answered — and this is the other half. Progress is raised inline rather than through System.Progress<T>, which captures whatever synchronisation context it was constructed on and posts to it. That reads like a convenience and is really a second place the marshalling decision gets made: silently, differently under a test with no context, and out of order with respect to the failure that follows a phase. The shell marshals once, in one handler, through a new optional post parameter on MainWindowViewModel — the same seam TransfersViewModel already uses, and for the reason its own remark gives. The three Dispatcher.UIThread.Post calls that predate it are the ones this suite's comments record as out of reach; they are left alone rather than swept in here. Both heads draw the list. They differ in one place: Phone.axaml's mono class sets a colour and a size along with the family, so the caption rule names its own family instead of composing the two and asking two rules for one Foreground. The desktop's mono sets the family alone, which is why ConnectingCard does compose them. Each head also gains SHOW LOGS beside the button that gives up — the step list is this attempt and the log is every other one, which is what a connection taking too long actually raises. Seven tests, and the two that matter most run against the container rather than a fake: a real handshake reports its phases in order, and a host-key refusal never claims to have authenticated. A fake asserting what it was written to assert would have established nothing about either. The rest cover the tab advancing while the connection is gated, the step a refusal stops on, and a phase reported after the user has given up on the tab. 1,861 tests, none failing. The Android head's layout is not verified by anything. It compiles, and compiled bindings mean every new binding path resolves, but that project is not in DodoSSH.slnx, there is no test project for it and no device here — so unlike the desktop card, whose shapes the layout harness measures, these rows have not been drawn. Vertical fit is reasoned, not observed.
800 lines
37 KiB
C#
800 lines
37 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>
|
|
public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
|
|
{
|
|
/// <summary>The session that ended.</summary>
|
|
public uint SessionId { get; } = sessionId;
|
|
}
|
|
|
|
/// <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>
|
|
/// 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];
|
|
|
|
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);
|
|
|
|
// 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="RunSessionAsync"/> only drops the renderer registration — so
|
|
/// a session whose shell exited half an hour ago is 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>
|
|
/// 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 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>
|
|
/// 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, run);
|
|
|
|
return sessionId;
|
|
}
|
|
|
|
/// <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>
|
|
/// </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.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.
|
|
if (Volatile.Read(ref disposed) == 0)
|
|
{
|
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
|
}
|
|
}
|
|
|
|
/// <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.
|
|
//
|
|
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
|
// AnnounceEndedAsync for what was wrong with that.
|
|
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
|
}
|
|
}
|
|
|
|
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
|
/// <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, 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)
|
|
{
|
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
}
|