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; } /// /// 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(); 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); } /// 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; } } /// /// Raised with the session id when a shell ends on its own. /// /// /// /// 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 on whatever thread the pump finished on, which is a thread-pool thread. A handler that /// touches an observable collection has to marshal; this type has no toolkit to do it with, which is /// exactly why it does not try. /// /// /// Not raised by . That path already has a caller who knows the session is /// going, and telling it what it just asked for is how a tab close turns into a second tab close. /// /// public event EventHandler? SessionEnded; /// Starts the loopback listener. public void Start() => server = dataPlane.RunAsync(lifetime.Token); /// /// 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. /// The session id, which identifies this terminal in the renderer. public async Task OpenSessionAsync( SshConnectionRequest request, TerminalSize size, CancellationToken cancellationToken) { var connection = await connections.ConnectAsync(request, cancellationToken).ConfigureAwait(false); 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); // 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); } 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); /// /// 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. } } /// 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(); } private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump) { try { await pump.RunAsync(lifetime.Token).ConfigureAwait(false); } finally { dataPlane.Unregister(sessionId); // Only when the session is still one this workspace knows about. CloseSessionAsync removes the // entry before it disposes the pump, so a tab the user closed does not come back as news. bool announce; lock (sessionGate) { announce = sessions.ContainsKey(sessionId); } if (announce) { SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId)); } } } private sealed record LiveSession(ISshConnection Connection, TerminalSessionPump Pump, Task Run); }