using System.Globalization; using System.Text; using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Terminal; /// Tuning for the workspace. public sealed class TerminalWorkspaceOptions { /// /// How long waits for the renderer's socket /// before giving up. /// /// /// /// 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. /// /// /// 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. /// /// public TimeSpan RendererTimeout { get; init; } = TimeSpan.FromSeconds(15); } /// A session whose shell has ended. /// The session, as the renderer and the workspace know it. public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs { /// The session that ended. public uint SessionId { get; } = sessionId; } /// /// The negotiated cipher and host-key algorithm for one live session. /// /// The server-to-client encryption algorithm; see . /// The host key's algorithm, e.g. ssh-ed25519. /// /// Two facts rather than the whole , 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 . /// public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm); /// The renderer asking for a different font size. /// /// 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. /// public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs { /// The requested move, or zero for "back to the default". public int Step { get; } = step; } /// /// Owns the loopback data plane and every live terminal session. /// /// /// /// 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. /// /// /// A session's lifetime is the application's, not the vault's. 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 — MainWindowViewModel.LockAsync says why, and the shell shows /// on the unlock screen so it is not a hidden state. /// /// public sealed class TerminalWorkspace : IAsyncDisposable { private readonly TerminalDataPlane dataPlane; private readonly ISshConnectionFactory connections; private readonly TimeProvider clock; private readonly TerminalWorkspaceOptions options; private readonly Dictionary sessions = []; /// /// Guards and . /// /// /// 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 /// 's finally now asks whether the session is still known — on whichever /// thread-pool thread the pump happened to unwind on. An unsynchronised 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. /// private readonly Lock sessionGate = new(); private readonly CancellationTokenSource lifetime = new(); /// /// The payload that marks a 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. /// private static readonly byte[] ReplayMarker = [1]; private uint nextSessionId = 1; private Task? server; private int disposed; /// Where the renderer's files come from. /// How SSH connections are made. /// Time source, so the pumps' flush interval is testable. /// Tuning, or null for the defaults. public TerminalWorkspace( ITerminalAssetProvider assets, ISshConnectionFactory connections, TimeProvider clock, TerminalWorkspaceOptions? options = null) { this.connections = connections; this.clock = clock; this.options = options ?? new TerminalWorkspaceOptions(); dataPlane = new TerminalDataPlane(assets); // Forwarded rather than re-raised with the workspace as the sender, so a handler can tell where it // came from. Nothing here decides anything about the size: the shell owns it, because the shell is // what remembers it between launches. dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e); // Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own // handshake handling, and has no business making that wait on however long a replay takes. See // ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames // is harmless. dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync(); } /// /// Where connections are recorded, or null to record none. /// /// /// /// 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. /// /// /// 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 Close() precisely so that the common case does not, and the /// residue is stated here rather than papered over. /// /// public IConnectionLogSink? ConnectionLog { get; set; } /// Where the WebView should navigate. public Uri PageUrl => dataPlane.PageUrl; /// /// How many terminals still have a live shell behind them. /// /// /// /// Not sessions.Count, which over-reports. Nothing removes an entry when the remote closes /// the channel on its own — only drops the renderer registration — so /// a session whose shell exited half an hour ago is still in the dictionary. A completed /// Run task is what "the shell is gone" actually looks like: the pump's loops have finished /// and it has already sent SessionClosed to the renderer. /// /// /// 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. /// /// public int LiveSessionCount { get { lock (sessionGate) { return sessions.Values.Count(session => !session.Run.IsCompleted); } } } /// /// Whether one session's shell is still running. /// /// /// The same question answers in aggregate, and answered the same way: a /// completed Run 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. /// public bool IsSessionLive(uint sessionId) { lock (sessionGate) { return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted; } } /// /// The negotiated cipher and host-key algorithm for one live session, or null when the id names no /// session this workspace still has open. /// /// /// /// 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 itself would have handed over the shell, /// disposal and all, to code that already goes through for that. /// /// /// "No session this workspace still has open" covers two different absences the same way /// 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. /// /// 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); } } /// /// A live session's flow-control window, or null when the id names no session this workspace still has /// open. /// /// /// 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 /// '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 /// InternalsVisibleTo this project already declares for it. /// internal CreditWindow? CreditsFor(uint sessionId) { lock (sessionGate) { return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null; } } /// /// Raised with the session id once a session is over — its shell having ended on its own, or a /// deliberate close having fully drained. /// /// /// /// A tab has to be able to stop claiming it is connected, and polling would be the alternative: a timer /// asking 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 /// SessionClosed and the page writes the reason into the pane — so this is the same fact reaching /// the half of the interface Avalonia draws. /// /// /// Raised only after the session's run task has completed, and that ordering is load-bearing. /// 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 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 . 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. /// /// /// Raised by too, which reverses a recorded decision. 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: is tearing the subscribers down with the /// sessions, and news nobody is left to hear is not news. /// /// public event EventHandler? SessionEnded; /// Raised when the renderer's own keyboard asks for a different font size. /// /// 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. /// public event EventHandler? FontSizeStepRequested; /// /// Raised once a (re)attached renderer has been sent everything this workspace owns for it. /// /// /// /// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets /// its SessionOpened 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 /// MainWindowViewModel's subscription for the other half. /// /// /// Raised on the socket-accept thread, same as that /// triggers it — a handler that touches a view model has to marshal. /// /// public event EventHandler? RendererReattached; /// Starts the loopback listener. public void Start() => server = dataPlane.RunAsync(lifetime.Token); /// /// Tells every pane what size to draw at. /// /// /// /// 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. /// /// /// 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 /// . /// /// /// The size in CSS pixels. Clamped by the caller; sent as one byte. /// Cancellation. public ValueTask SetFontSizeAsync(int pixels, CancellationToken cancellationToken) => dataPlane.SendAsync( TerminalFrame.Create( (byte)TerminalServerOpcode.FontSize, sessionId: 0, TerminalFrame.CreateFontSizePayload(pixels)), cancellationToken); /// /// Waits until the renderer page has attached its socket. /// /// /// /// A session opened before the renderer attaches would have its SessionOpened 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. /// /// /// 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 /// 's own message names nothing. /// /// /// Abandons the wait. /// /// No renderer attached within . /// public Task WaitForRendererAsync(CancellationToken cancellationToken) => dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken); /// Connects to a host and starts a terminal for it. /// What to connect to, as whom, and with what. /// The pseudo-terminal's initial size. /// /// Told each phase as it begins, or null to report nothing. Reported from the handshake's own thread; /// see . 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. /// /// Abandons the attempt. /// The session id, which identifies this terminal in the renderer. /// /// 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. /// public async Task OpenSessionAsync( SshConnectionRequest request, TerminalSize size, IProgress? progress, CancellationToken cancellationToken) { var connection = await connections .ConnectAsync(request, progress, cancellationToken) .ConfigureAwait(false); progress?.Report(SshConnectionPhase.OpeningShell); ISshShellSession shell; try { shell = await connection.OpenShellAsync(size, cancellationToken).ConfigureAwait(false); } catch { await connection.DisposeAsync().ConfigureAwait(false); throw; } uint sessionId; TerminalSessionPump pump; lock (sessionGate) { sessionId = nextSessionId++; pump = new TerminalSessionPump(sessionId, shell, dataPlane, clock); } dataPlane.Register(sessionId, pump); // After the connection succeeded and before the run begins. Ordered that way for two reasons: a // host-key refusal throws out of ConnectAsync above and must never be recorded as a session that // started, and a session whose shell ends immediately must already have a ticket open for the // finally below to close. ConnectionLog?.Opened(sessionId, Describe(request), clock.GetUtcNow()); // Registered before running, so an acknowledgement that arrives with the very first output // frame has somewhere to go. var run = RunSessionAsync(sessionId, pump); lock (sessionGate) { sessions[sessionId] = new LiveSession(connection, pump, run); } // The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored, // so the containment check inside it can never run against a dictionary the session had not reached. _ = AnnounceEndedAsync(sessionId, run); return sessionId; } /// /// Shows one terminal's pane and hides the others. /// /// /// /// 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. /// /// /// 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. /// /// public ValueTask ActivateSessionAsync(uint sessionId, CancellationToken cancellationToken) => dataPlane.SendAsync( TerminalFrame.Create((byte)TerminalServerOpcode.SessionActivated, sessionId, []), cancellationToken); /// /// Inserts text into one terminal, as if it had been pasted there. /// /// The terminal to insert into. /// What to insert. Sent verbatim. /// Whether to press Enter afterwards. /// Cancellation. /// /// 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. /// /// /// /// Refused for a dead session rather than sent and dropped. 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. /// /// /// 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. /// /// public async Task 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; } /// /// Sends keystrokes to one terminal as though they had been typed into it. /// /// /// /// 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 cat responsive. /// This is for input that has no key on the keyboard to produce it. /// /// /// Which on a phone is most of the useful input. 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. /// /// /// 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. /// /// /// The terminal to type into. /// Raw bytes, already encoded as the remote expects them. /// Cancellation token. public ValueTask SendInputAsync( uint sessionId, ReadOnlyMemory data, CancellationToken cancellationToken) { TerminalSessionPump? pump; lock (sessionGate) { pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null; } return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken); } /// /// Closes one terminal. /// /// /// 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 uses on the way out, which was its only /// caller while the interface had no tabs. /// public async Task CloseSessionAsync(uint sessionId) { LiveSession? session; lock (sessionGate) { if (!sessions.Remove(sessionId, out session)) { return; } } dataPlane.Unregister(sessionId); // Before the teardown, while the socket is still up. The page holds an xterm instance, its whole // scrollback and a WebGL context per pane and never reclaimed one on its own; a frame dropped here // is a leak nothing else would notice, because it is inside the WebView. await dataPlane .SendAsync( TerminalFrame.Create((byte)TerminalServerOpcode.SessionRemoved, sessionId, []), CancellationToken.None) .ConfigureAwait(false); // The connection goes even if the pump's disposal throws. The entry is already out of the dictionary, // so nothing will come back for it — not DisposeAsync's loop, which snapshots the keys — and an // undisposed ISshConnection is a live authenticated channel to a remote host that this process has // forgotten about. Which of the two is more important is not close. try { await session.Pump.DisposeAsync().ConfigureAwait(false); } finally { await session.Connection.DisposeAsync().ConfigureAwait(false); } // Draining, not doing work. This await exists so the pump's loops have finished before the caller // moves on; the session is already dead either way, and a fault here is nothing the caller can act // on — the pump's last act is a best-effort SessionClosed frame on a socket that may have gone with // the window. Letting that escape would fault a tab-close command over a session that closed fine. try { await session.Run.ConfigureAwait(false); } catch (Exception exception) when (exception is not OutOfMemoryException) { // Expected on the ordinary path: disposing the pump cancels its run. } // After the drain, so a handler reading LiveSessionCount sees this session already gone — the // event's own remark carries why a deliberate close is announced at all, and why shutdown is not: // DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway. if (Volatile.Read(ref disposed) == 0) { SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId)); } } /// 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(); } /// /// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its /// own. /// /// /// /// Runs on the socket-accept thread that raised — 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. /// /// /// Every live session — one whose Run 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 SessionOpened frame /// again, marked with 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. /// /// /// 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 or report. /// /// /// 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 /// terminal.js's handleFrame. /// /// private async Task ReplayAfterAttachAsync() { KeyValuePair[] live; lock (sessionGate) { live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)]; } try { foreach (var (sessionId, session) in live) { session.Pump.Credits.Reset(); await dataPlane .SendAsync( TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker), CancellationToken.None) .ConfigureAwait(false); } RendererReattached?.Invoke(this, EventArgs.Empty); } catch (Exception exception) when (exception is not OutOfMemoryException) { // Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay // leaves nothing worse than the problem this method exists to fix, and there is no caller on // this thread left to hand a failure to. } } private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump) { try { await pump.RunAsync(lifetime.Token).ConfigureAwait(false); } finally { dataPlane.Unregister(sessionId); // Unconditional, and this one hook covers all three ways a session ends: the user closing the // tab, the remote hanging up, and the process shutting down. Every one of them arrives here as // the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this // must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when // the application is closing. // // SessionEnded is deliberately NOT raised from here, and it used to be — see // AnnounceEndedAsync for what was wrong with that. ConnectionLog?.Closed(sessionId, clock.GetUtcNow()); } } /// Announces a session's end once its run task has actually completed. /// /// /// A continuation rather than a line in '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 , 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. /// /// /// The containment check keeps the deliberate paths out of this route: /// removes the entry before it disposes the pump, and makes its own announcement after its own drain. /// /// private async Task AnnounceEndedAsync(uint sessionId, Task run) { try { await run.ConfigureAwait(false); } catch (Exception exception) when (exception is not OutOfMemoryException) { // The run's faults belong to whoever drains it — CloseSessionAsync, on the deliberate path. // This continuation cares only that the run is over, however it got there. } bool announce; lock (sessionGate) { announce = sessions.ContainsKey(sessionId); } if (announce) { SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId)); } } /// The address as dialled, for the log. /// /// 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 not the same as recording which account authenticated: nothing here /// reads the credential, and the payload has no field for one. /// 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); }