Public Access
A host can now carry port forwards (-L, -R, -D), saved inside its encrypted payload as RelayTicketRequest.PortForwardId already assumed, so they sync and merge per forward like tags and pinned paths. The host schema moves to version 8 only for hosts that carry forwards. ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports. Every listener binds loopback and there is no bind-address field: a forward is something any member of a shared keychain can edit. A remote forward's target is resolved here, IPv4 first, rather than by SSH.NET's constructor, which takes the first DNS answer and can send ::1. Forwards start with every terminal opened to the host and stop with its connection; a reconnect starts them again. The session sidebar lists them with state, failure reason and an on/off switch, the status bar counts the listening ones, and the host editor and detail pane gain a PORT FORWARDS section. A forward that cannot start fails alone. The phone does not start forwards yet: it has no surface showing what is listening, and its More screen now says so. No byte counts, since SSH.NET keeps none.
1056 lines
50 KiB
C#
1056 lines
50 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>
|
|
/// 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);
|
|
}
|