Public Access
The terminal's memory was bounded by an argument that is not true of the SSH library underneath it, and the whole of CreditWindow's remark was that argument: the pump stops reading when the renderer falls behind, SSH's own receive window closes, the remote sshd blocks on write, and the backpressure reaches the process producing the output. The middle step does not happen. Renci.SshNet's Channels.Channel.OnData calls its window adjustment before it raises DataReceived and re-opens the channel window as soon as it drops below one packet, and ShellStream's own handler copies the bytes into a System.Net.ArrayBuffer without blocking and without a ceiling — a buffer that doubles on demand and is not given back until the stream is disposed. So a reader that stopped reading slowed the remote down by nothing at all. It only chose whose buffer the flood landed in, and it chose the one nothing bounds. Measured against the container this repository already runs sshd in: a reader that reads nothing while `yes` runs leaves ShellStream holding 8.9 MB after one second, 26.8 MB after three and 44.3 MB after five, its capacity stepping 16 → 32 → 64 MB. That is the report of "a massive memory leak on Windows"; it is not a Windows fault, it is simply where long sessions with heavy output live. The fix is to read continuously, which is what keeps SSH.NET's buffer empty, and to put the bound where it can actually be enforced. OutputBacklog is that bound: a byte-capped queue of unsent output, 4 MiB by default, which drops its oldest bytes when full and counts what it discarded. Oldest rather than newest, because the tail of a flood is the prompt somebody is waiting for. The two bounds are now separate and each is enforced where it means something. The backlog bounds what is waiting to be sent. The credit window goes on bounding what has been sent and not yet drawn — the queue inside the WebView that nothing on this side can measure — and is reserved against in the flush loop rather than the read loop. Reading is part of neither, because reading was never what the remote was waiting on. Dropping is announced rather than silent. A terminal quietly showing a different transcript from the one the remote sent is worse than one that says it could not keep up, because the second can be believed. The line goes out under its own opcode, TerminalServerOpcode.OutputDropped, rather than spliced into the output stream: what this application says about the transcript is not part of the transcript, and keeping them apart is also what makes the arithmetic hold — every byte read is either in an Output payload or counted in BytesDropped. It opens with an SGR reset, because the cut lands at an arbitrary offset and whatever escape sequence the remote was in the middle of has been left half-said. Frames are now capped at 64 KiB and taken straight into the frame buffer. The cap is the large-object-heap threshold rather than a guess — a quarter-megabyte array allocated sixty times a second is collected only by a generation 2 pass — and the direct take removes an allocation and a copy of every byte the terminal shows. The second half is the one that made the first half's cost persist. Nothing gave a session back when the remote hung up: TerminalWorkspace kept the entry, and with it an undisposed ISshConnection — an authenticated socket, its cipher state, and a keep-alive putting a packet on the wire every thirty seconds — plus the pump and SSH.NET's read buffer at whatever size the session's busiest moment had grown it to, for as long as the tab stayed open. A tab somebody typed `exit` into an hour ago was indistinguishable from a live one in everything except what it drew. ReleaseAsync disposes both, from AnnounceEndedAsync and after the announcement: the tab is told first, because tearing down an SSH connection in front of that would be latency on the one thing the user is watching for. The dictionary entry stays, because LiveSessionCount, IsSessionLive and the tab's own ENDED and RECONNECT all read it. SendInputAsync therefore asks about liveness rather than presence, since the shell channel is gone the moment the remote is. Two faults introduced on the way here and fixed, both worth naming because neither would have been visible from the outside: The read loop left through a condition on its cancellation token, and RunAsync reads a read loop that returns as the remote having closed the channel. A tab the user closed would have reported itself as a shell that exited on its own. It throws instead. With the reader finished and a renderer that had stopped acknowledging, the flush loop span: output to send, no credit to send it with, and a wait for more output that returned at once because there was some. RunAsync never completed, so the tab was never told and the connection was never released — a session invisible and permanent. The loop now leaves for the drain as soon as the backlog is complete, and RunAsync resets the credit window alongside completing it so a flusher already parked on an acknowledgement that will never come is released. Four tests asserted the old contract and now assert its reverse, which is the point rather than a relaxation — WhenCreditRunsOut_ThePumpStopsReading is now WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine, and has to prove both halves at once. The throughput harness gained a counting transport, because a consumer that copies every frame into a 32 MB list was the slowest thing in the pipeline and a harness whose consumer cannot keep up measures itself. The fake shell gained a pace, so "a renderer that keeps up loses nothing" can be asked of a producer a network could actually deliver rather than of one running at memory bandwidth. The test that would have caught this in the first place is ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog, and it needs the real SSH.NET and the real sshd to say anything: a fake shell hands over bytes when it is asked, and a real channel hands them over when they arrive. Under the old code its BytesRead would have stopped at exactly WindowBytes. 121 terminal tests pass over three consecutive runs with no flakes, 105 against the container, and the app, session, layout and remaining suites are unchanged and green. Manual checks 1.10a and 1.10b cover the two symptoms a person can see: a working set that plateaus under `yes`, and a connection that goes when the shell does rather than when the tab does.
1011 lines
48 KiB
C#
1011 lines
48 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>
|
|
/// 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);
|
|
}
|