diff --git a/README.md b/README.md
index 84f8108..fa09f1b 100644
--- a/README.md
+++ b/README.md
@@ -25,13 +25,21 @@ zero-knowledge.
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
-Two consequences worth knowing before you read further:
+Three consequences worth knowing before you read further:
- **Revocation is not retroactive.** A removed member keeps what they already downloaded. The
real remediation is rotating the SSH credential, so offboarding is built around a rotation
checklist rather than a button that implies more than it delivers.
- **No session recording in relay mode.** The relay forwards SSH ciphertext, so it cannot see
commands. That is the cost of the relay not being able to read your traffic.
+- **Locking the vault does not close your shells.** Lock closes the vault and zeroes every key it
+ held; a session that authenticated before it keeps running, because the remote host never
+ consulted the vault and the credential was already spent. That is deliberate — you lock when you
+ walk away from the machine, which is exactly when a long upgrade or transfer is most likely to be
+ in flight, and an idle auto-lock that killed it would be worse than the exposure it removed. The
+ honest reading is that *locked* describes the vault and not this machine's access to your hosts.
+ The unlock screen therefore shows how many shells are still connected, and quitting DodoSSH is
+ what ends them.
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
diff --git a/docs/crypto.md b/docs/crypto.md
index e5af0da..b70d454 100644
--- a/docs/crypto.md
+++ b/docs/crypto.md
@@ -499,5 +499,13 @@ server, its operators, its backups and the network. It does **not** address:
- metadata — item counts, sizes, timestamps, access patterns and the sharing graph are
visible, as are host addresses for relay-enabled hosts;
- a weak passphrase — §2 parameters and passphrase entropy are the whole defence;
+- **a locked vault on a machine with open sessions** — locking zeroes the identity keys, the vault
+ keys and the cache key, so nothing on disk can be read again without the passphrase. It does not
+ touch an SSH channel that is already open: that channel was authorised at connect time by a
+ credential the remote host verified itself, and no vault key participates in keeping it alive.
+ Sessions therefore survive lock **by design** (the client says so on its unlock screen, and the
+ README explains why), which means a locked client can still hold authenticated access to remote
+ hosts. Ending that is quitting the client, or rotating the credential — the same non-retroactive
+ limit as revocation, one layer down;
- supply chain — a server can serve a backdoored client. Sign releases with a key the server
does not hold. In a self-hosted E2EE product this is the largest practical hole.
diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index 2921b9f..e9dd98c 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -158,6 +158,53 @@ headless test renders and focuses correctly and would confirm the wrong belief.
the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does
not, and that locking stops the forwarding.
+**The lock/unlock cycle does not resize the pane at all, and the 40 px guard is not what makes it safe.**
+Measured on Windows with a live shell, against a real `sshd` in a container, in a harness mirroring
+`MainWindow.axaml`'s `340,*` grid: with the `NativeWebView` collapsed by `IsVisible=false`, the page still
+reports `paneWidth: 840, paneHeight: 760`, unchanged `cols`/`rows`, and `visibilityState: "visible"`.
+Hiding is `SetWindowPos(holder, …, SWP_HIDEWINDOW)`, which does not resize the holder, so no
+`ResizeObserver` callback fires, no fit runs, and **no `window-change` reaches the remote** — before,
+during or after the cycle. `stty size` on the remote answered `50 118` both before locking and after
+unlocking, and the renderer's own buffer came back byte for byte, wrapped lines included.
+
+The guard's irrelevance here was established rather than assumed: the same run with
+`MINIMUM_FITTABLE_PIXELS` patched to `0` — the guard fully disabled — produced an identical clean result.
+So the guard is still worth keeping for the paths it was written for, minimising and a splitter dragged to
+the edge, but it is **not** on the lock path and must not be cited as the reason locking is safe. It was
+described that way when it landed.
+
+Two further results from the same harness, both about the deliberate decision that shells outlive a lock
+(README, `MainWindowViewModel.LockAsync`):
+
+- **A collapsed WebView is not typed into.** With the harness confirmed as the foreground window and all
+ twelve injected `SendInput` events accepted, not one character of the probe reached the remote pty, and a
+ `Ctrl-U` afterwards answered `BEL` — nothing was sitting in the remote's line editor either. So the lock
+ screen is a real input barrier even though the session behind it is live, and that is what makes
+ surviving the lock defensible rather than merely convenient. The *mechanism* is not what this run
+ concluded: it read the result as a hidden `WS_CHILD` window being ineligible for keyboard focus, but the
+ focus entry above measured Win32 focus still held by the hidden holder window, and the lock path now
+ moves the keyboard off it deliberately. Take the barrier as measured here and the reason from there —
+ which also means the barrier is something the lock path maintains, not something the platform guarantees.
+- **The session survives the cycle in the real control, not only in tests.** `LiveSessionCount` was 1
+ before, during and after, and the shell accepted a command again immediately on unlock.
+
+*Suspected, seen once, not reproduced:* on the first run — before the harness learned to wait for the
+window's scale to settle — the window opened at 2558x1367 px and the page reported a 2202x1328 pane
+(312x88 characters) for a window 1180 logical units wide, which looks like physical pixels arriving where
+CSS pixels were expected. A later re-push to 1177x672 then reflowed the wrapped line and split it in two.
+Both events straddled a DPI settle rather than the lock, and three later runs at `RenderScaling 1.00`
+never showed it. If a user reports mangled scrollback after moving the window between displays of
+different scale, start here.
+
+**WebView2 will not initialise when the host executable sits under a very long path.**
+`CreateCoreWebView2Environment` fails with `COMException 0x80080005 CO_E_SERVER_EXEC_FAILURE` ("Server
+execution failed") and the terminal never appears. Hit while building the harness above: the same binary
+that failed from a ~230-character directory ran first time from `%TEMP%\h`. The exact threshold was not
+established and the mechanism is unconfirmed — the user data folder is created beside the executable by
+default and the browser process is launched with paths derived from it, so `MAX_PATH` is the obvious
+suspect. Relevant to packaging: an installer that lands under a deep per-user path would break the
+terminal with an error that names nothing.
+
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
window for native control host"* — so the WebView, and therefore the terminal, does not start at all.
diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
index 53a19df..f1af6f4 100644
--- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
@@ -139,6 +139,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private VaultViewModel? vault;
+ ///
+ /// Shells that were left running when the vault was locked.
+ ///
+ ///
+ /// Refreshed by , which is where the policy this reports is explained.
+ ///
+ [ObservableProperty]
+ private int liveSessionCount;
+
+ internal bool HasLiveSessions => LiveSessionCount > 0;
+
+ /// The count as a sentence, because a bare number on a lock screen explains nothing.
+ internal string LiveSessionSummary => LiveSessionCount == 1
+ ? "1 shell is still connected and still running."
+ : $"{LiveSessionCount} shells are still connected and still running.";
+
/// Where the embedded browser should navigate.
internal Uri TerminalPageUrl => workspace.PageUrl;
@@ -351,7 +367,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}).ConfigureAwait(true);
}
- /// Closes the vault and forgets every key it held.
+ ///
+ /// Closes the vault and forgets every key it held. Open shells keep running.
+ ///
+ ///
+ ///
+ /// Lock is a vault operation, and deliberately not a disconnect. The reason a person locks is
+ /// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
+ /// transfer is most likely to be in flight — so killing every shell would make Lock a button that
+ /// destroys work, and the predictable response is to stop using it and leave the vault open instead.
+ /// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
+ /// terminated a running job would be worse than the exposure it removes.
+ ///
+ ///
+ /// What "locked" therefore describes. Disposing the vault zeroes the identity keys, the vault
+ /// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
+ /// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
+ /// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
+ /// retroactively un-authorise a session any more than revocation can — the same honest limit the
+ /// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
+ /// channels, and is shown on the unlock screen rather than left to be
+ /// inferred from a terminal that the lock screen hides.
+ ///
+ ///
+ /// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
+ /// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
+ /// warning of this kind.
+ ///
+ ///
[RelayCommand]
private async Task LockAsync()
{
@@ -361,6 +404,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await open.DisposeAsync().ConfigureAwait(true);
}
+ LiveSessionCount = workspace.LiveSessionCount;
+
State = ShellState.Locked;
StatusMessage = "Locked.";
}
@@ -500,6 +545,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private void OnVaultSessionOpened(object? sender, EventArgs e) =>
TerminalSessionOpened?.Invoke(this, e);
+ partial void OnLiveSessionCountChanged(int value)
+ {
+ OnPropertyChanged(nameof(HasLiveSessions));
+ OnPropertyChanged(nameof(LiveSessionSummary));
+ }
+
partial void OnStateChanged(ShellState value)
{
OnPropertyChanged(nameof(IsStarting));
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 8664cc4..2e6712e 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -68,7 +68,12 @@
-
+
+
@@ -319,6 +324,23 @@
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/WebAssets/terminal.js b/src/DodoSSH.Client.App/WebAssets/terminal.js
index ef28a1b..107dd73 100644
--- a/src/DodoSSH.Client.App/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.App/WebAssets/terminal.js
@@ -175,12 +175,17 @@ function activate(sessionId) {
}
}
-// Below this, a pane is not being looked at — it is minimised, dragged to nothing, or the host has
-// hidden its window. Fitting anyway would be actively harmful rather than merely useless: the fit addon
-// floors its proposal at 2 columns by 1 row, so a degenerate viewport reflows the *remote* pty to 2x1
-// through window-change, and the wrapped scrollback that produces cannot be recovered when the pane comes
-// back. A guard rather than a fix for one caller, because several paths reach here — a minimised window, a
-// splitter dragged to the edge, and a host that hides the WebView while the vault is locked.
+// Below this, a pane is not being looked at — it is minimised or dragged to nothing. Fitting anyway would
+// be actively harmful rather than merely useless: the fit addon floors its proposal at 2 columns by 1 row,
+// so a degenerate viewport reflows the *remote* pty to 2x1 through window-change, and the wrapped
+// scrollback that produces cannot be recovered when the pane comes back. A guard rather than a fix for one
+// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
+// once splits land.
+//
+// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
+// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
+// does not change, no observer fires and this function is never called — measured with a live shell, and
+// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
const MINIMUM_FITTABLE_PIXELS = 40;
function resize(session, sessionId) {
diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
index 22f8f51..80ffe5e 100644
--- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
@@ -31,10 +31,19 @@ public sealed class TerminalWorkspaceOptions
/// 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
{
@@ -69,6 +78,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// 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 => sessions.Values.Count(session => !session.Run.IsCompleted);
+
/// Starts the loopback listener.
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
@@ -130,7 +158,15 @@ public sealed class TerminalWorkspace : IAsyncDisposable
return sessionId;
}
- /// Closes one terminal.
+ ///
+ /// Closes one terminal.
+ ///
+ ///
+ /// Reached only from today, which is a consequence of the lifetime policy
+ /// above rather than an accident: nothing else in the application ends a session, because locking
+ /// deliberately does not and there is no per-tab close in the interface yet. It is here, and tested,
+ /// because closing one terminal without taking the process down is what a tab close needs.
+ ///
public async Task CloseSessionAsync(uint sessionId)
{
if (!sessions.Remove(sessionId, out var session))
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index c2daf69..0053017 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -644,6 +644,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.State.ShouldBe(ShellState.Unlocked);
}
+ ///
+ /// The deliberate half of what Lock does: the vault closes, the shells do not.
+ ///
+ ///
+ ///
+ /// A policy rather than an implementation detail, which is why it is asserted here. Locking is what a
+ /// person does when they walk away from the machine, and that is exactly when a long job is most
+ /// likely to be running — so ending every shell would make Lock destroy work, and an idle auto-lock
+ /// would do it unattended. MainWindowViewModel.LockAsync carries the full argument.
+ ///
+ ///
+ /// The disclosure is asserted along with the behaviour, because the two are the same decision. A
+ /// lock screen that hides the terminal — which it does, the WebView is collapsed while locked — while
+ /// authenticated SSH channels stay open is only defensible if it says so.
+ ///
+ ///
+ [Fact]
+ public async Task LockingKeepsOpenShellsRunning_AndSaysSoOnTheUnlockScreen()
+ {
+ await UnlockedAsync();
+
+ // Opened on the workspace rather than through Connect. Connect would work here — FakeRenderer can
+ // satisfy the renderer gate — but what is under test is what Lock does to a session that exists,
+ // not how it came to exist, and going through the gate would only add a way for this to fail.
+ await workspace.OpenSessionAsync(
+ new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
+ TerminalSize.Default,
+ Token);
+
+ workspace.LiveSessionCount.ShouldBe(1);
+
+ await shell.LockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Locked);
+ shell.Vault.ShouldBeNull("the vault's keys are gone");
+
+ workspace.LiveSessionCount.ShouldBe(1, "the shell was still running, so it kept running");
+
+ shell.HasLiveSessions.ShouldBeTrue();
+ shell.LiveSessionCount.ShouldBe(1);
+ shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
+
+ // And it survives the unlock too, so the session outlives the whole cycle rather than merely
+ // outliving the disposal.
+ shell.Passphrase = Passphrase;
+ await shell.UnlockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Unlocked);
+ workspace.LiveSessionCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task LockingWithNoOpenShells_DisclosesNothing()
+ {
+ await UnlockedAsync();
+
+ await shell.LockCommand.ExecuteAsync(null);
+
+ shell.LiveSessionCount.ShouldBe(0);
+ shell.HasLiveSessions.ShouldBeFalse("an ordinary lock must not warn about nothing");
+ }
+
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
diff --git a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
index 14589fc..003f655 100644
--- a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
+++ b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
@@ -101,6 +101,65 @@ internal sealed class FakeShellSession : ISshShellSession
}
}
+///
+/// Hands out s, so a workspace can be driven with no network.
+///
+///
+/// Exists for the session-lifetime tests. Everything else in this suite works on a pump directly; the
+/// workspace is the layer that decides when a session is over, and that decision is what needs a
+/// connection whose shell can be made to end on cue.
+///
+internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue) : ISshConnectionFactory
+{
+ /// Connections handed out, in order.
+ internal List Connections { get; } = [];
+
+ ///
+ public Task ConnectAsync(
+ SshConnectionRequest request,
+ CancellationToken cancellationToken)
+ {
+ var connection = new FakeConnection(request, bytesPerShell);
+ Connections.Add(connection);
+
+ return Task.FromResult(connection);
+ }
+}
+
+/// A connection that opens fake shells and records its own disposal.
+internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell) : ISshConnection
+{
+ ///
+ public bool IsConnected { get; private set; } = true;
+
+ ///
+ public HostKeyPresentation HostKey { get; } =
+ new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
+
+ /// The shell this connection opened, if it opened one.
+ internal FakeShellSession? Shell { get; private set; }
+
+ /// Whether the connection was disposed, which is what closing a session must do.
+ internal bool IsDisposed { get; private set; }
+
+ ///
+ public Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
+ {
+ Shell = new FakeShellSession(bytesPerShell);
+
+ return Task.FromResult(Shell);
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ IsDisposed = true;
+ IsConnected = false;
+
+ return ValueTask.CompletedTask;
+ }
+}
+
/// Records frames, and can acknowledge them to keep credit flowing.
internal sealed class RecordingTransport : ITerminalTransport
{
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
index 8205e82..6fd0dda 100644
--- a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
@@ -1,21 +1,29 @@
-using System.Text;
using DodoSSH.Client.Ssh;
-using NSubstitute;
namespace DodoSSH.Client.Terminal.Tests;
///
-/// The renderer gate: the one place the workspace waits on something outside the process.
+/// What the workspace waits for outside the process, and how long a session lives once it has one.
///
///
-/// The gate itself is not in question. drops frames when nothing
-/// is attached, so a session opened before the renderer arrives loses its SessionOpened frame and
-/// streams output at a terminal that was never created — and that it opens when a renderer does attach is
-/// covered by . What is worth a test here is the
+///
+/// The renderer gate itself is not in question. drops frames when
+/// nothing is attached, so a session opened before the renderer arrives loses its SessionOpened frame
+/// and streams output at a terminal that was never created — and that it opens when a renderer does attach
+/// is covered by . What is worth a test here is the
/// half that used to be missing: waiting for a renderer that never arrives has to end.
+///
+///
+/// The session count is here for a different reason. It is shown to a user on the unlock screen, as the
+/// disclosure that locking the vault leaves shells running, and a wrong number there is not a cosmetic bug:
+/// it either hides a live connection or invents one.
+///
///
public sealed class TerminalWorkspaceTests
{
+ /// How long polls before calling it a failure.
+ private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
+
[Fact]
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
{
@@ -23,7 +31,9 @@ public sealed class TerminalWorkspaceTests
// runtime, an install blocked by policy, an AppContainer that cannot reach loopback. From this
// side they are identical and all look like the listener being up with nothing ever connecting
// to it. Before the wait was bounded this test would have hung instead of failing.
- await using var workspace = CreateWorkspace(TimeSpan.FromMilliseconds(250));
+ await using var workspace = CreateWorkspace(
+ new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMilliseconds(250));
+
workspace.Start();
await Should.ThrowAsync(async () =>
@@ -36,7 +46,9 @@ public sealed class TerminalWorkspaceTests
// The timeout is the backstop; the caller's token is what makes a Connect the user gave up on
// return at once rather than sitting out the rest of the wait. The timeout here is long enough
// that only cancellation can end this.
- await using var workspace = CreateWorkspace(TimeSpan.FromMinutes(5));
+ await using var workspace = CreateWorkspace(
+ new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMinutes(5));
+
workspace.Start();
using var cancellation = new CancellationTokenSource();
@@ -47,20 +59,132 @@ public sealed class TerminalWorkspaceTests
await Should.ThrowAsync(async () => await wait);
}
+ [Fact]
+ public async Task AnOpenSessionIsReportedAsLive()
+ {
+ var connections = new FakeConnectionFactory();
+
+ await using var workspace = CreateWorkspace(connections);
+
+ workspace.LiveSessionCount.ShouldBe(0);
+
+ await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+
+ workspace.LiveSessionCount.ShouldBe(1);
+
+ await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+
+ workspace.LiveSessionCount.ShouldBe(2);
+ }
+
///
- /// The connection factory is never reached: every test here stops at the gate, and reaching a real
- /// host would make this a network test.
+ /// The case a naive sessions.Count gets wrong. Nothing removes the entry when the remote
+ /// closes the channel by itself — the session is still in the dictionary, holding a connection that
+ /// is finished — so counting entries would report a shell that exited as still running. A user
+ /// deciding whether it is safe to walk away is the person that lie is told to.
///
- private static TerminalWorkspace CreateWorkspace(TimeSpan rendererTimeout) =>
+ [Fact]
+ public async Task ASessionWhoseRemoteHasExitedIsNotReportedAsLive()
+ {
+ // A shell with no output to give: its first read returns 0, which is a remote closing the
+ // channel, so the pump finishes on its own with nobody asking it to.
+ var connections = new FakeConnectionFactory(bytesPerShell: 0);
+
+ await using var workspace = CreateWorkspace(connections);
+
+ await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+
+ await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
+ }
+
+ [Fact]
+ public async Task ClosingASessionEndsItAndDisposesItsConnection()
+ {
+ var connections = new FakeConnectionFactory();
+
+ await using var workspace = CreateWorkspace(connections);
+
+ var sessionId = await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+
+ await workspace.CloseSessionAsync(sessionId);
+
+ workspace.LiveSessionCount.ShouldBe(0);
+ connections.Connections.ShouldHaveSingleItem().IsDisposed.ShouldBeTrue();
+ }
+
+ ///
+ /// Disposal is the one path that does close sessions, because it is process shutdown. Asserted so
+ /// that the SSH connections are known to be released rather than assumed to be.
+ ///
+ [Fact]
+ public async Task DisposingTheWorkspaceClosesEverySession()
+ {
+ var connections = new FakeConnectionFactory();
+
+ var workspace = CreateWorkspace(connections);
+
+ await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+
+ await workspace.DisposeAsync();
+
+ workspace.LiveSessionCount.ShouldBe(0);
+ connections.Connections.Count.ShouldBe(2);
+ connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
+ }
+
+ // ---- Helpers ----
+
+ ///
+ /// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
+ /// take a fake anyway, because reaching a real host from here would make this a network test.
+ ///
+ /// The gate's bound, or null for the shipped default.
+ private static TerminalWorkspace CreateWorkspace(
+ ISshConnectionFactory connections,
+ TimeSpan? rendererTimeout = null) =>
new(
- new InMemoryTerminalAssetProvider(
- new Dictionary(StringComparer.Ordinal)
- {
- [TerminalDataPlane.PagePath] = new(
- "text/html; charset=utf-8",
- Encoding.UTF8.GetBytes("")),
- }),
- Substitute.For(),
+ StubAssets(),
+ connections,
TimeProvider.System,
- new TerminalWorkspaceOptions { RendererTimeout = rendererTimeout });
+ rendererTimeout is { } timeout
+ ? new TerminalWorkspaceOptions { RendererTimeout = timeout }
+ : null);
+
+ private static InMemoryTerminalAssetProvider StubAssets() =>
+ new(new Dictionary(StringComparer.Ordinal)
+ {
+ [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", ""u8.ToArray()),
+ });
+
+ private static SshConnectionRequest Request() =>
+ new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
+
+ ///
+ /// Polled rather than awaited on a task, because the point is what an observer of the property
+ /// sees: the pump ends on a thread of its own, and the count has to catch up without anyone
+ /// telling it to.
+ ///
+ private static async Task WaitUntilAsync(Func condition)
+ {
+ var deadline = TimeProvider.System.GetUtcNow() + PollTimeout;
+
+ while (TimeProvider.System.GetUtcNow() < deadline)
+ {
+ if (condition())
+ {
+ return;
+ }
+
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ throw new TimeoutException($"The condition was still false after {PollTimeout}.");
+ }
}