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);
}