diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml
index 725e1dd..c2c1e26 100644
--- a/src/DodoSSH.Client.Android/Theme/Phone.axaml
+++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml
@@ -436,6 +436,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -97,8 +176,12 @@
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
-
-
+
@@ -284,11 +367,70 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -38,15 +121,43 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ Beside each, the logs. The step list is this attempt and the log is every other one, which is the
+ question both a connection taking too long and a connection just refused actually raise — has this
+ machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
+ second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
+ in the strip and the card is still here on the way back.
+ -->
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SessionTabRow.axaml b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml
index 27268e1..aacdf6e 100644
--- a/src/DodoSSH.Client.App/Views/SessionTabRow.axaml
+++ b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml
@@ -60,12 +60,23 @@
ToolTip.Tip="{Binding Address}">
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index e81699f..26d879c 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -328,6 +328,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
private readonly Func? copyToClipboard;
+ ///
+ private readonly Action post;
+
///
/// Created once and kept for the life of the process, like and for the same
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
@@ -470,6 +473,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
/// the Android head's distribution separately, so it must never acquire one by accident.
///
+ ///
+ /// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
+ /// dispatcher, which is the answer in every real head.
+ ///
+ /// A delegate rather than Dispatcher.UIThread reached directly, for exactly the reason
+ /// TransfersViewModel's is one — see the remark there. It is process-wide and belongs to whichever
+ /// thread touched it first, so a suite that runs with no window has no way to drain it and no way to
+ /// know whose it is. This one exists because connection phases are reported from the handshake's own
+ /// thread, which is the first thing in this class that has to cross onto the UI thread and also has to
+ /// be assertable: the three Dispatcher.UIThread.Post calls that predate it are the ones this
+ /// suite's own comments record as out of reach, and they are left alone rather than swept in here.
+ ///
+ ///
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
@@ -483,8 +499,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ResumeHandler? resume = null,
Func? copyToClipboard = null,
string? deviceName = null,
- IUpdateChannel? updates = null)
+ IUpdateChannel? updates = null,
+ Action? post = null)
{
+ this.post = post ?? (action => Dispatcher.UIThread.Post(action));
+
this.paths = paths;
this.caches = caches;
this.workspace = workspace;
@@ -3243,7 +3262,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
oldValue.PropertyChanged -= OnVaultPropertyChanged;
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
- // The three connection events are kept while an attempt is still in flight, and that is not an
+ // The four connection events are kept while an attempt is still in flight, and that is not an
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
// what holds both, and it outlives every vault — so a connection started just before a lock still
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
@@ -3257,6 +3276,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (attempts.Count == 0)
{
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
+ oldValue.ConnectionProgress -= OnVaultConnectionProgress;
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
oldValue.SessionOpened -= OnVaultSessionOpened;
}
@@ -3265,6 +3285,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (newValue is not null)
{
newValue.ConnectionStarting += OnVaultConnectionStarting;
+ newValue.ConnectionProgress += OnVaultConnectionProgress;
newValue.ConnectionFailed += OnVaultConnectionFailed;
newValue.SessionOpened += OnVaultSessionOpened;
newValue.PropertyChanged += OnVaultPropertyChanged;
@@ -3406,6 +3427,41 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
AdoptTab(tab);
}
+ ///
+ /// Moves a connecting tab's step list on, from the handshake's own report.
+ ///
+ ///
+ ///
+ /// The one place the phases raised by VaultViewModel.ConnectionProgress are marshalled, and the
+ /// reason that event does not marshal for itself: doing it here means it happens once, visibly, at the
+ /// only boundary that cares — everything this touches is a view model an Avalonia binding is attached to.
+ ///
+ ///
+ /// Posted unconditionally rather than applied inline when it looks safe. Some phases really do arrive
+ /// on this thread — the first is reported before the handshake has yielded at all — and a
+ /// CheckAccess fast path for them would buy one dispatcher turn on a card that is up for seconds,
+ /// at the price of the two orderings existing at once and only one of them being the one a test runs.
+ ///
+ ///
+ /// A step that arrives after the attempt has settled is harmless and needs no guard here:
+ /// ignores anything reported to a tab that is no longer
+ /// connecting, which is what a posted phase landing behind its own
+ /// looks like.
+ ///
+ ///
+ /// A report for an attempt with no tab is dropped, exactly as the other two handlers drop one: the user
+ /// closed the connecting tab and there is nothing left to draw a step on. The handshake is not affected
+ /// and its session is still adopted if it opens.
+ ///
+ ///
+ private void OnVaultConnectionProgress(object? sender, ConnectionProgressEventArgs e) => post(() =>
+ {
+ if (attempts.TryGetValue(e.AttemptId, out var tab))
+ {
+ tab.Advance(e.Step);
+ }
+ });
+
///
/// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
///
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs
index 4b6d07a..662c5e8 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs
@@ -1,7 +1,129 @@
using CommunityToolkit.Mvvm.ComponentModel;
+using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Shell.ViewModels;
+///
+/// One named part of making a connection, in the order they happen.
+///
+///
+///
+/// with one more at the front. The SSH assembly reports four phases and
+/// knows about no others, which is correct for it — it has never heard of a renderer. But the first thing a
+/// connection here waits on is the terminal page attaching its socket, and on the first connection after a
+/// cold start that is a real wait with a real failure mode of its own: a missing WebView2 runtime. A step
+/// list that began at "reaching the host" would leave the one wait most likely to hang unnamed.
+///
+///
+/// Declared here rather than shared with the SSH layer for that reason, and the mapping between the two is
+/// one switch in VaultViewModel. The numbering is the order and the order is load-bearing:
+/// compares these values to decide what is already behind it.
+///
+///
+internal enum ConnectionStep
+{
+ /// Waiting for the renderer to attach, before anything is dialled.
+ PreparingTerminal = 0,
+
+ ///
+ Reaching = 1,
+
+ ///
+ CheckingHostKey = 2,
+
+ ///
+ Authenticating = 3,
+
+ ///
+ OpeningShell = 4,
+}
+
+/// How one step of a connection is getting on.
+///
+/// Four states rather than a bool per row, because a step list is read as a sequence and the reader's
+/// question at each row is which of the four this is: behind us, happening, not yet, or where it stopped.
+/// exists only for the row a failure landed on — see
+/// — and is what turns the list from a progress bar into an
+/// account of how far the attempt got.
+///
+internal enum ConnectionStepState
+{
+ /// Not started. Nothing is known about it yet.
+ Pending = 0,
+
+ /// Happening now.
+ Running = 1,
+
+ /// Finished, because something after it started.
+ Done = 2,
+
+ /// Where the attempt stopped. There is no step after this one.
+ Stopped = 3,
+}
+
+/// One row of the connecting card's step list.
+///
+/// A view model per step rather than an index the view compares against, because each row draws its own
+/// state and an ItemsControl has no way to ask "am I before the current one?" — the alternative was a
+/// converter taking two bindings, which is the same comparison written somewhere it cannot be tested.
+///
+internal sealed partial class ConnectionStepViewModel : ObservableObject
+{
+ internal ConnectionStepViewModel(ConnectionStep step, string caption)
+ {
+ Step = step;
+ Caption = caption;
+ }
+
+ /// Which step this is.
+ internal ConnectionStep Step { get; }
+
+ /// What the row says, in the present tense of the thing being waited on.
+ internal string Caption { get; }
+
+ ///
+ [ObservableProperty]
+ private ConnectionStepState state;
+
+ /// Whether this step is the one happening now.
+ internal bool IsRunning => State is ConnectionStepState.Running;
+
+ /// Whether this step finished.
+ internal bool IsDone => State is ConnectionStepState.Done;
+
+ /// Whether the attempt stopped on this step.
+ internal bool IsStopped => State is ConnectionStepState.Stopped;
+
+ ///
+ /// The character drawn beside the caption for whichever state this is in.
+ ///
+ ///
+ /// Here rather than in a converter for the reason TransferRowViewModel.StatusWord is: the mapping
+ /// is four cases with no arithmetic, and a converter would put it in a file the shell's tests cannot
+ /// reach. The colours stay in the view, where the palette is.
+ ///
+ /// Four distinguishable shapes rather than one recoloured, because the difference between a step that
+ /// finished and a step still running has to survive somebody who cannot tell this design's green from
+ /// its amber.
+ ///
+ ///
+ internal string Mark => State switch
+ {
+ ConnectionStepState.Done => "✓",
+ ConnectionStepState.Running => "●",
+ ConnectionStepState.Stopped => "✕",
+ _ => "○",
+ };
+
+ partial void OnStateChanged(ConnectionStepState value)
+ {
+ OnPropertyChanged(nameof(IsRunning));
+ OnPropertyChanged(nameof(IsDone));
+ OnPropertyChanged(nameof(IsStopped));
+ OnPropertyChanged(nameof(Mark));
+ }
+}
+
///
/// How far along a tab's connection is.
///
@@ -57,8 +179,23 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
{
Label = label;
Address = address;
- status = "connecting…";
isLive = false;
+
+ Steps =
+ [
+ new ConnectionStepViewModel(ConnectionStep.PreparingTerminal, "Starting the terminal"),
+ new ConnectionStepViewModel(ConnectionStep.Reaching, "Reaching the host"),
+ new ConnectionStepViewModel(ConnectionStep.CheckingHostKey, "Checking the host key"),
+ new ConnectionStepViewModel(ConnectionStep.Authenticating, "Signing in"),
+ new ConnectionStepViewModel(ConnectionStep.OpeningShell, "Opening the shell"),
+ ];
+
+ // The first step is running before anything is awaited, because it is: the tab is created in the
+ // same turn as the click and the renderer wait starts immediately after. A list that opened with
+ // every row pending would show a connection that had not begun, which is one turn of the dispatcher
+ // away from being untrue and is the turn the card is first drawn in.
+ status = Steps[0].Caption;
+ Steps[0].State = ConnectionStepState.Running;
}
/// A tab for a session that is already open.
@@ -72,6 +209,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
state = TerminalTabState.Open;
status = string.Empty;
isLive = true;
+
+ // A session that already exists got through every step by definition, even though this tab watched
+ // none of them happen — an adopted session is one whose connecting tab the user closed. The list is
+ // never drawn for a tab in this state; it is filled in so that nothing downstream has to treat "open"
+ // as a fourth answer to "how far did it get".
+ CompleteSteps();
}
///
@@ -128,6 +271,36 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
///
internal string? IdentityLabel { get; set; }
+ ///
+ /// How far this connection got, step by step, for the card that stands in for the pane.
+ ///
+ ///
+ ///
+ /// Fixed at construction and never added to or removed from — the steps of a connection are known before
+ /// it starts, and only their state changes — so a plain array is enough and the view needs no collection
+ /// change notification for it.
+ ///
+ ///
+ /// Every row here is reported, not guessed. The states come from
+ /// , raised by the handshake itself at the moment each part of it begins.
+ /// Nothing on this list is a timer, a fraction, or a step this view model decided had probably finished
+ /// by now. That is the whole reason it is worth showing: a card that invented plausible progress would be
+ /// indistinguishable from one that had stopped receiving any.
+ ///
+ ///
+ internal IReadOnlyList Steps { get; }
+
+ /// How many steps are behind the attempt, for the card's track.
+ ///
+ /// Counted rather than stored, and it counts alone: the running
+ /// step is deliberately not half a step. The track fills to where the attempt has actually got to and
+ /// stops there, which is the same promise the list itself makes.
+ ///
+ internal int StepsDone => Steps.Count(step => step.IsDone);
+
+ /// How many steps there are, for the card's track.
+ internal int StepCount => Steps.Count;
+
///
[ObservableProperty]
private TerminalTabState state;
@@ -187,6 +360,54 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// Whether this tab is a connection that never happened.
internal bool IsFailed => State is TerminalTabState.Failed;
+ ///
+ /// Records that the connection has reached a named step.
+ ///
+ ///
+ ///
+ /// Everything before is marked done, because a phase that has begun is proof the
+ /// ones before it ended — the handshake is a sequence and there is no way to be at one point in it
+ /// without having passed the earlier ones. That is also what covers a step too fast to observe: it is
+ /// closed by its successor rather than needing a report of its own.
+ ///
+ ///
+ /// Monotonic, and silently so. A report that has already been passed is ignored rather than rewinding
+ /// the list, because the one thing that can produce one is a retry after the host-key question, and a
+ /// card that jumped backwards would read as the connection having come undone.
+ ///
+ ///
+ internal void Advance(ConnectionStep step)
+ {
+ if (State is not TerminalTabState.Connecting)
+ {
+ // Nothing to draw and nothing to correct. A late report from a handshake that has since
+ // finished or been given up on is not worth reopening a settled tab for.
+ return;
+ }
+
+ var reached = Steps.FirstOrDefault(row => row.Step == step);
+
+ if (reached is null || reached.IsDone)
+ {
+ return;
+ }
+
+ foreach (var row in Steps)
+ {
+ if (row.Step < step)
+ {
+ row.State = ConnectionStepState.Done;
+ }
+ else if (row.Step == step)
+ {
+ row.State = ConnectionStepState.Running;
+ }
+ }
+
+ Status = reached.Caption;
+ OnPropertyChanged(nameof(StepsDone));
+ }
+
/// Takes ownership of the session that has just opened for this tab.
internal void Opened(uint sessionId)
{
@@ -194,6 +415,8 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
Status = string.Empty;
IsLive = true;
State = TerminalTabState.Open;
+
+ CompleteSteps();
}
///
@@ -208,9 +431,33 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
{
Status = reason;
IsLive = false;
+
+ // Before the state change, so the list is already correct the first time a view asks. The step that
+ // was running is where it stopped, and the ones behind it stay done: how far a refused connection
+ // got is the most useful thing the card still knows, and it is the difference between "that host is
+ // not there" and "that host is there and would not have me".
+ foreach (var row in Steps)
+ {
+ if (row.IsRunning)
+ {
+ row.State = ConnectionStepState.Stopped;
+ }
+ }
+
State = TerminalTabState.Failed;
}
+ /// Marks every step done, for a connection that is no longer being waited on.
+ private void CompleteSteps()
+ {
+ foreach (var row in Steps)
+ {
+ row.State = ConnectionStepState.Done;
+ }
+
+ OnPropertyChanged(nameof(StepsDone));
+ }
+
partial void OnStateChanged(TerminalTabState value)
{
OnPropertyChanged(nameof(HasSession));
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index bbf61f8..e239a80 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -943,6 +943,31 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
internal string Address { get; } = address;
}
+/// A connection that has got as far as a named step.
+/// The attempt this is about.
+/// The step that has just begun.
+///
+///
+/// The fourth of the attempt events, and the only one that can be raised more than once for an attempt. It
+/// exists because the other three say a connection started and then, seconds later, whether it worked — and
+/// the seconds in between are the whole of what a user staring at a connecting card is trying to find out.
+///
+///
+/// Raised on whichever thread the handshake is on. SSH.NET reports the interior of a connection from
+/// its own thread, and this event is that report forwarded rather than a copy made on a timer, so a
+/// subscriber that touches a view model must marshal for itself. MainWindowViewModel does; see the
+/// handler.
+///
+///
+internal sealed class ConnectionProgressEventArgs(Guid attemptId, ConnectionStep step) : EventArgs
+{
+ ///
+ internal Guid AttemptId { get; } = attemptId;
+
+ ///
+ internal ConnectionStep Step { get; } = step;
+}
+
/// A connection that was asked for and did not happen.
/// The attempt that has just ended.
/// What to say about it, in the tab.
@@ -4127,6 +4152,15 @@ internal sealed partial class VaultViewModel(
///
internal event EventHandler? ConnectionStarting;
+ /// Raised as a connection this vault announced gets from one step to the next.
+ ///
+ /// Between and whichever of the other two ends the attempt, any number
+ /// of times including none — a handshake fast enough to finish inside one turn reports nothing, which is
+ /// the honest account of it. See for the threading, which is
+ /// the one way this event differs from its three neighbours.
+ ///
+ internal event EventHandler? ConnectionProgress;
+
/// Raised when a connection this vault announced does not become a session.
///
internal event EventHandler? ConnectionFailed;
@@ -10889,6 +10923,53 @@ internal sealed partial class VaultViewModel(
return true;
}
+ /// Turns the handshake's phases into this attempt's progress events.
+ ///
+ ///
+ /// Forwarded rather than accumulated, because the tab is the thing that knows what has already happened
+ /// and this object deliberately does not: a connection here is one straight line from renderer to
+ /// session, and a running total of where it had got to would be a second copy of the state the card
+ /// already draws from the first.
+ ///
+ ///
+ /// Deliberately not System.Progress<T>, which captures whatever synchronisation
+ /// context it happens to be constructed on and posts to it. That reads like a convenience and is really
+ /// a second place the marshalling decision gets made: silently, differently under a test with no
+ /// context, and — because a post is a later turn — out of order with respect to the failure or the
+ /// session that follows the phase. Raised inline instead, and the shell marshals once where it can be
+ /// seen. See MainWindowViewModel.OnVaultConnectionProgress.
+ ///
+ ///
+ private PhaseReporter ReporterFor(ConnectionAttemptEventArgs attempt) => new(phase =>
+ ConnectionProgress?.Invoke(this, new ConnectionProgressEventArgs(attempt.AttemptId, StepFor(phase))));
+
+ /// The step a handshake phase is reported to the shell as.
+ ///
+ /// The whole of the mapping between the SSH assembly's four phases and the card's five steps, in one
+ /// place. is not here because nothing reports it: the tab
+ /// starts on it, and the first phase to arrive is what closes it.
+ ///
+ private static ConnectionStep StepFor(SshConnectionPhase phase) => phase switch
+ {
+ SshConnectionPhase.Reaching => ConnectionStep.Reaching,
+ SshConnectionPhase.CheckingHostKey => ConnectionStep.CheckingHostKey,
+ SshConnectionPhase.Authenticating => ConnectionStep.Authenticating,
+ SshConnectionPhase.OpeningShell => ConnectionStep.OpeningShell,
+ _ => ConnectionStep.Reaching,
+ };
+
+ /// Hands each phase straight to a delegate, on the thread that reported it.
+ ///
+ /// The whole type, and it exists to be the thing System.Progress<T> is not — see the remark
+ /// at its one use. A lambda cannot implement an interface, and the alternative was widening the
+ /// workspace's parameter to Action<T>, which would have put a non-standard progress
+ /// contract into three assemblies to save one class here.
+ ///
+ private sealed class PhaseReporter(Action report) : IProgress
+ {
+ public void Report(SshConnectionPhase value) => report(value);
+ }
+
/// What a manual target reads as, once it has been taken apart.
///
/// Separate from because a keychain host has no username of its own at
@@ -11238,6 +11319,8 @@ internal sealed partial class VaultViewModel(
HostAuthentication authentication,
CancellationToken cancellationToken)
{
+ // Not reported before the await: the tab is constructed with this step already running — see
+ // TerminalTabViewModel — because there is no moment between the two worth telling anybody about.
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
var request = new SshConnectionRequest(
@@ -11247,7 +11330,7 @@ internal sealed partial class VaultViewModel(
authentication.Credential);
var sessionId = await workspace
- .OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
+ .OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
.ConfigureAwait(true);
// The workspace has already opened a ticket for this session, with the address and the moment it
diff --git a/src/DodoSSH.Client.Ssh/SshConnection.cs b/src/DodoSSH.Client.Ssh/SshConnection.cs
index 3939d2e..e358d69 100644
--- a/src/DodoSSH.Client.Ssh/SshConnection.cs
+++ b/src/DodoSSH.Client.Ssh/SshConnection.cs
@@ -121,12 +121,53 @@ public interface ISshConnection : IAsyncDisposable
Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
}
+///
+/// How far a connection being made has got.
+///
+///
+///
+/// These are the boundaries a client can actually observe, and there are deliberately no others. SSH.NET
+/// runs the whole handshake inside one ConnectAsync and raises exactly one event from the middle of
+/// it — HostKeyReceived, once the key exchange has produced a key to show. That event is the only
+/// interior moment there is, so it is the only interior phase named here: everything before it is
+/// and everything after it is .
+///
+///
+/// ◆ Nothing here is a guess about elapsed time or a fraction of the way through. Each value is
+/// reported at the instant the thing it names actually starts, which is what makes it safe for a screen to
+/// draw as fact. A phase that took no measurable time is reported anyway and simply passes at once — that
+/// is a true account of a fast handshake, not a step that was skipped. See the transfer strip's own remark
+/// in TransfersScreen.axaml for why this design does not invent furniture for states it cannot measure.
+///
+///
+public enum SshConnectionPhase
+{
+ /// Resolving the name, opening the socket, and exchanging keys. Before any key is known.
+ Reaching = 0,
+
+ /// The server has offered a host key, and its trust is being decided.
+ CheckingHostKey = 1,
+
+ /// The key was accepted. The credential is being offered.
+ Authenticating = 2,
+
+ /// Authenticated. A pseudo-terminal and a shell channel are being opened.
+ OpeningShell = 3,
+}
+
/// Opens connections, enforcing host key trust before authenticating.
public interface ISshConnectionFactory
{
///
/// Connects and authenticates.
///
+ /// What to connect to, as whom, and with what.
+ ///
+ /// Told each phase as it begins, or null to report nothing. Called from whichever thread the handshake
+ /// is on — SSH.NET raises host key verification on its own — so an implementation that touches a UI must
+ /// marshal for itself.
+ ///
+ /// Abandons the attempt.
///
/// The host has no pinned key. The caller must show the fingerprint, and only on explicit
/// confirmation record it via and retry.
@@ -134,5 +175,8 @@ public interface ISshConnectionFactory
///
/// The presented key differs from the pin. There is no retry path: this is a hard block.
///
- Task ConnectAsync(SshConnectionRequest request, CancellationToken cancellationToken);
+ Task ConnectAsync(
+ SshConnectionRequest request,
+ IProgress? progress,
+ CancellationToken cancellationToken);
}
diff --git a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
index ed768d9..b33a5d7 100644
--- a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
+++ b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
@@ -42,13 +42,14 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
///
public async Task ConnectAsync(
SshConnectionRequest request,
+ IProgress? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var client = new SshClient(BuildConnectionInfo(request));
- var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
+ var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
.ConfigureAwait(false);
return new SshNetConnection(client, gate.Presented!);
@@ -68,7 +69,10 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
- var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
+ // No progress for the file-transfer path. The screen that waits on one is the file browser, which
+ // reports itself, and a second connection opened behind an already-open shell has nothing the user
+ // is watching a step list for.
+ var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
.ConfigureAwait(false);
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
@@ -101,12 +105,18 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
private async Task ConnectThroughHostKeyGateAsync(
BaseClient client,
SshConnectionRequest request,
+ IProgress? progress,
CancellationToken cancellationToken)
{
- var gate = new HostKeyGate(knownHosts, request, cancellationToken);
+ var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
client.HostKeyReceived += gate.OnHostKeyReceived;
+ // Before the await rather than inside the gate, because this phase is the part of the handshake
+ // that happens before there is anything to raise an event about: the lookup, the socket and the key
+ // exchange. Nothing else can report the start of it.
+ progress?.Report(SshConnectionPhase.Reaching);
+
try
{
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
@@ -139,6 +149,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
private sealed class HostKeyGate(
IKnownHostStore knownHosts,
SshConnectionRequest request,
+ IProgress? progress,
CancellationToken cancellationToken)
{
/// What the server offered, once the handshake has reached that point.
@@ -157,6 +168,11 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
Presented = presentation;
+ // Reported before the lookup rather than after it, because the lookup is the wait: this is a
+ // vault-backed store on the handshake thread, and on a locked or cold vault it is the part of
+ // "checking the host key" long enough to be worth naming.
+ progress?.Report(SshConnectionPhase.CheckingHostKey);
+
// Looked up here rather than before connecting, because the negotiated algorithm is only
// known now and a server may choose a different one than it did last time.
//
@@ -179,6 +195,15 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
mismatch = !matches;
e.CanTrust = matches;
+
+ // Only on acceptance, and here rather than after the await above, because this is the last
+ // moment SSH.NET gives anyone: returning true from this handler is what lets the handshake go on
+ // to offer the credential, and it does not come back until it has an answer either way. A
+ // refusal reports nothing — there is no authentication about to happen for it to be true of.
+ if (matches)
+ {
+ progress?.Report(SshConnectionPhase.Authenticating);
+ }
}
/// The specific exception for a refusal this gate caused, or null if it did not.
diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
index ef97e06..70dff2e 100644
--- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
@@ -369,13 +369,31 @@ public sealed class TerminalWorkspace : IAsyncDisposable
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, cancellationToken).ConfigureAwait(false);
+ var connection = await connections
+ .ConnectAsync(request, progress, cancellationToken)
+ .ConfigureAwait(false);
+
+ progress?.Report(SshConnectionPhase.OpeningShell);
ISshShellSession shell;
try
diff --git a/tests/DodoSSH.Client.App.Tests/FakeSsh.cs b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
index 36f9d12..3349689 100644
--- a/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
+++ b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
@@ -40,18 +40,32 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
///
public async Task ConnectAsync(
SshConnectionRequest request,
+ IProgress? progress,
CancellationToken cancellationToken)
{
Requests.Add(request);
+ // Before the gate rather than after it, which is what makes this fake useful for the connecting
+ // card: a test that holds Gate open is a connection stuck partway through, and the step list has to
+ // show it stuck on a named step rather than on none.
+ progress?.Report(SshConnectionPhase.Reaching);
+
if (Gate is { } gate)
{
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
- return Failure is { } failure
- ? throw failure
- : new FakeSshConnection(request);
+ if (Failure is { } failure)
+ {
+ throw failure;
+ }
+
+ // Only on the way to succeeding. A failure reported as having authenticated would let a test pass
+ // while the card showed a refused connection getting one step further than it did.
+ progress?.Report(SshConnectionPhase.CheckingHostKey);
+ progress?.Report(SshConnectionPhase.Authenticating);
+
+ return new FakeSshConnection(request);
}
///
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 1522a3e..b0f1c9e 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -143,7 +143,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
{
clipboard.Add(text);
return Task.CompletedTask;
- });
+ },
+
+ // Inline, because this suite has no window and therefore no dispatcher to drain — the same
+ // answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
+ // Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
+ // it first. Running the action where it was raised takes the thread out of the question, and
+ // every phase this suite reports is raised on the thread doing the asserting anyway.
+ post: action => action());
return ValueTask.CompletedTask;
}
@@ -1303,6 +1310,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsConnectingShowing.ShouldBeFalse();
}
+ ///
+ ///
+ /// What the card draws while the stretch above is going on. The tab used to carry one line of prose
+ /// fixed at the moment it was created, which made a handshake stuck on a key exchange look exactly like
+ /// one stuck on a dead socket — and made a connection that was progressing look exactly like one that
+ /// was not.
+ ///
+ ///
+ /// The gate is held open on the step the fake reports before it, so this asserts the state the card is
+ /// actually drawn in rather than one it passes through: one step behind, one step lit, three not
+ /// reached. Nothing here waits or polls, which is the other half of the claim — the report arrives on
+ /// the thread that raised it and the tab is up to date in the same turn.
+ ///
+ ///
+ [Fact]
+ public async Task Connecting_LightsTheStepTheHandshakeHasActuallyReached()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+
+ ssh.Gate = new TaskCompletionSource();
+ var connecting = vault.ConnectCommand.ExecuteAsync(null);
+
+ var tab = shell.Tabs.ShouldHaveSingleItem();
+
+ tab.Steps.Select(step => step.State).ShouldBe(
+ [
+ ConnectionStepState.Done,
+ ConnectionStepState.Running,
+ ConnectionStepState.Pending,
+ ConnectionStepState.Pending,
+ ConnectionStepState.Pending,
+ ],
+ "the renderer attached, the host is being reached, and nothing after that has happened");
+
+ tab.StepsDone.ShouldBe(1, "the track fills to what finished, and the running step is not half a step");
+ tab.Status.ShouldBe("Reaching the host");
+
+ ssh.Gate.SetResult();
+ await connecting;
+
+ tab.Steps.ShouldAllBe(step => step.IsDone, "a session that opened got through all of them");
+ tab.StepsDone.ShouldBe(tab.StepCount);
+ }
+
+ ///
+ /// The half of the step list a progress bar could not do: where it stopped is kept, and the steps behind
+ /// it stay done. That is the difference between "that host is not there" and "that host is there and
+ /// would not have me", and it is the question the reason sentence alone often does not settle.
+ ///
+ [Fact]
+ public async Task ARefusedConnection_KeepsTheStepItStoppedOn()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+
+ ssh.Failure = new InvalidOperationException("No route to host.");
+
+ await vault.ConnectCommand.ExecuteAsync(null);
+
+ var tab = shell.Tabs.ShouldHaveSingleItem();
+
+ tab.Steps.Select(step => step.State).ShouldBe(
+ [
+ ConnectionStepState.Done,
+ ConnectionStepState.Stopped,
+ ConnectionStepState.Pending,
+ ConnectionStepState.Pending,
+ ConnectionStepState.Pending,
+ ],
+ "it got as far as reaching the host and no further");
+
+ tab.Steps[1].Mark.ShouldBe("✕", "and says so without relying on the colour");
+
+ // The reason still goes where it always went. The list says how far, and this says what happened.
+ tab.Status.ShouldBe("No route to host.");
+ }
+
+ ///
+ /// A report that arrives for an attempt the shell has forgotten. Giving up on a connecting tab removes
+ /// it while the handshake is still running — see CloseTabAsync — so every phase reported after
+ /// that has no tab to land on. Dropped rather than resurrecting the tab, and above all not thrown: the
+ /// handshake is still going, and its session is still adopted if it opens.
+ ///
+ [Fact]
+ public async Task GivingUpOnATab_LeavesLaterPhasesWithNothingToDo()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+
+ ssh.Gate = new TaskCompletionSource();
+ var connecting = vault.ConnectCommand.ExecuteAsync(null);
+
+ var tab = shell.Tabs.ShouldHaveSingleItem();
+ await shell.CloseTabCommand.ExecuteAsync(tab);
+
+ // Everything after the gate — the host key, the credential, the shell — is reported to a shell that
+ // no longer has a tab for this attempt.
+ ssh.Gate.SetResult();
+ await connecting;
+
+ // The session opened anyway and was adopted, which is the behaviour giving up already promised.
+ var adopted = shell.Tabs.ShouldHaveSingleItem();
+ adopted.HasSession.ShouldBeTrue();
+ adopted.ShouldNotBe(tab);
+
+ // And the forgotten tab was left where it was rather than being advanced from the sidelines.
+ tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
+ }
+
///
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
@@ -2341,6 +2461,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await workspace.OpenSessionAsync(
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
TerminalSize.Default,
+ progress: null,
Token);
workspace.LiveSessionCount.ShouldBe(1);
@@ -8112,6 +8233,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await workspace.OpenSessionAsync(
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
TerminalSize.Default,
+ progress: null,
Token);
shell.SignOutCommand.Execute(null);
diff --git a/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs b/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs
index 8ed48ef..02750e2 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs
@@ -73,7 +73,8 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
var factory = new SshNetConnectionFactory(knownHosts);
await Should.ThrowAsync(async () =>
- await factory.ConnectAsync(Request(new SshPrivateKeyCredential(Pkcs1(stranger), null)), Token));
+ await factory.ConnectAsync(
+ Request(new SshPrivateKeyCredential(Pkcs1(stranger), null)), progress: null, Token));
}
[Fact]
@@ -125,6 +126,86 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
shell.IsOpen.ShouldBeTrue();
}
+ ///
+ ///
+ /// The claim the connecting card is built on, checked where it can actually be checked: against a real
+ /// handshake rather than a fake that reports whatever it was written to report. Every other test of the
+ /// step list asserts that the shell draws what it is told; this one asserts that what it is told is
+ /// true.
+ ///
+ ///
+ /// The order is the assertion. A step list is only readable if the reports arrive in the order it draws
+ /// them, and the middle one is the load-bearing part —
+ /// comes out of SSH.NET's HostKeyReceived, which is the single interior moment the library gives
+ /// anybody, and it has to land between the other two rather than beside them.
+ ///
+ ///
+ /// is deliberately absent: this factory's work ends with
+ /// an authenticated connection, and the phase for opening a channel on one belongs to the layer that
+ /// opens it. TerminalWorkspaceTests covers that half.
+ ///
+ ///
+ [Fact]
+ public async Task AHandshake_ReportsItsPhasesInTheOrderTheyHappen()
+ {
+ var knownHosts = await TrustedStoreAsync();
+ var reported = new List();
+
+ await using var connection = await new SshNetConnectionFactory(knownHosts).ConnectAsync(
+ Request(new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null)),
+ new DelegateProgress(phase =>
+ {
+ lock (reported)
+ {
+ // Locked because the last two are reported from SSH.NET's own handshake thread rather
+ // than from the awaiting one, which is the whole reason the shell marshals them.
+ reported.Add(phase);
+ }
+ }),
+ Token);
+
+ connection.IsConnected.ShouldBeTrue();
+
+ lock (reported)
+ {
+ reported.ShouldBe(
+ [
+ SshConnectionPhase.Reaching,
+ SshConnectionPhase.CheckingHostKey,
+ SshConnectionPhase.Authenticating,
+ ]);
+ }
+ }
+
+ ///
+ /// The other half of the phase contract, and the one that would be easy to get wrong by reporting
+ /// optimistically: a refused key stops at the check. Nothing may claim the credential was offered, and
+ /// against an unknown host nothing ever is — the gate returns false and SSH.NET abandons the handshake
+ /// before authentication.
+ ///
+ [Fact]
+ public async Task AHostKeyRefusal_NeverClaimsToHaveAuthenticated()
+ {
+ var reported = new List();
+
+ await Should.ThrowAsync(async () =>
+ await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync(
+ Request(new SshPasswordCredential(SshServerFixture.Password)),
+ new DelegateProgress(phase =>
+ {
+ lock (reported)
+ {
+ reported.Add(phase);
+ }
+ }),
+ Token));
+
+ lock (reported)
+ {
+ reported.ShouldBe([SshConnectionPhase.Reaching, SshConnectionPhase.CheckingHostKey]);
+ }
+ }
+
private static byte[] Pkcs1(RSA key) => Encoding.UTF8.GetBytes(key.ExportRSAPrivateKeyPem());
private static byte[] Pkcs8(RSA key) => Encoding.UTF8.GetBytes(key.ExportPkcs8PrivateKeyPem());
@@ -141,7 +222,7 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
// Learned by being refused, which is the only way this client learns a host key.
var unknown = await Should.ThrowAsync(async () =>
await factory.ConnectAsync(
- Request(new SshPasswordCredential(SshServerFixture.Password)), Token));
+ Request(new SshPasswordCredential(SshServerFixture.Password)), progress: null, Token));
await knownHosts.TrustAsync(unknown.Presentation, Token);
@@ -153,6 +234,6 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
var knownHosts = await TrustedStoreAsync();
return await new SshNetConnectionFactory(knownHosts)
- .ConnectAsync(Request(credential), Token);
+ .ConnectAsync(Request(credential), progress: null, Token);
}
}
diff --git a/tests/DodoSSH.Client.Ssh.Tests/KnownHostStoreTests.cs b/tests/DodoSSH.Client.Ssh.Tests/KnownHostStoreTests.cs
index 719000b..ebf5530 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/KnownHostStoreTests.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/KnownHostStoreTests.cs
@@ -137,3 +137,14 @@ public sealed class KnownHostStoreTests
string fingerprint = "SHA256:approved") =>
new(host, port, algorithm, fingerprint);
}
+
+/// An that runs its callback on the thread that reported.
+///
+/// System.Progress<T> posts to a captured synchronisation context, or to the thread pool when
+/// there is none — which is what a test has here — so a list it appended to would be asserted on before it
+/// had been written. The same reason the shell does not use it either; see VaultViewModel.ReporterFor.
+///
+internal sealed class DelegateProgress(Action report) : IProgress
+{
+ public void Report(T value) => report(value);
+}
diff --git a/tests/DodoSSH.Client.Ssh.Tests/LoopbackProxyTests.cs b/tests/DodoSSH.Client.Ssh.Tests/LoopbackProxyTests.cs
index 317c47e..f35ca25 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/LoopbackProxyTests.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/LoopbackProxyTests.cs
@@ -66,7 +66,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
var factory = new SshNetConnectionFactory(knownHosts);
var unknown = await Should.ThrowAsync(async () =>
- await factory.ConnectAsync(request, Token));
+ await factory.ConnectAsync(request, progress: null, Token));
unknown.Presentation.Host.ShouldBe(InternalHost, "the target's name, not the proxy's");
unknown.Presentation.Port.ShouldBe(SshServerFixture.InternalPort);
@@ -75,7 +75,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
await knownHosts.TrustAsync(unknown.Presentation, Token);
- await using var connection = await factory.ConnectAsync(request, Token);
+ await using var connection = await factory.ConnectAsync(request, progress: null, Token);
connection.IsConnected.ShouldBeTrue();
connection.HostKey.Host.ShouldBe(InternalHost);
@@ -121,7 +121,8 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
var request = Request(Credential(), new SshLoopbackProxy(DeadPort()));
var failure = await Should.ThrowAsync(async () =>
- await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync(request, Token));
+ await new SshNetConnectionFactory(new InMemoryKnownHostStore())
+ .ConnectAsync(request, progress: null, Token));
failure.ShouldNotBeOfType();
failure.ShouldNotBeOfType();
@@ -137,7 +138,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
var knownHosts = await TrustedStoreAsync();
await using var connection = await new SshNetConnectionFactory(knownHosts)
- .ConnectAsync(Request(Credential(), proxy: null), Token);
+ .ConnectAsync(Request(Credential(), proxy: null), progress: null, Token);
connection.IsConnected.ShouldBeTrue();
}
@@ -216,7 +217,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
var unknown = await Should.ThrowAsync(async () =>
await new SshNetConnectionFactory(knownHosts)
- .ConnectAsync(Request(Credential(), proxy: null), Token));
+ .ConnectAsync(Request(Credential(), proxy: null), progress: null, Token));
await knownHosts.TrustAsync(unknown.Presentation, Token);
diff --git a/tests/DodoSSH.Client.Ssh.Tests/PumpOverRealSshTests.cs b/tests/DodoSSH.Client.Ssh.Tests/PumpOverRealSshTests.cs
index e8115aa..23841dc 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/PumpOverRealSshTests.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/PumpOverRealSshTests.cs
@@ -27,11 +27,11 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
new SshPasswordCredential(SshServerFixture.Password));
var unknown = await Should.ThrowAsync(async () =>
- await factory.ConnectAsync(request, TestContext.Current.CancellationToken));
+ await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken));
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
- return await factory.ConnectAsync(request, TestContext.Current.CancellationToken);
+ return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
}
[Fact]
diff --git a/tests/DodoSSH.Client.Ssh.Tests/TerminalEndToEndTests.cs b/tests/DodoSSH.Client.Ssh.Tests/TerminalEndToEndTests.cs
index a7ec0e0..0f833bc 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/TerminalEndToEndTests.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/TerminalEndToEndTests.cs
@@ -110,7 +110,7 @@ public sealed class TerminalEndToEndTests(SshServerFixture fixture)
var unknown = await Should.ThrowAsync(async () =>
await workspace.OpenSessionAsync(
- request, TerminalSize.Default, TestContext.Current.CancellationToken));
+ request, TerminalSize.Default, progress: null, TestContext.Current.CancellationToken));
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
@@ -119,6 +119,7 @@ public sealed class TerminalEndToEndTests(SshServerFixture fixture)
return await workspace.OpenSessionAsync(
request,
new TerminalSize(100, 30, 1000, 750),
+ progress: null,
TestContext.Current.CancellationToken);
}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
index ee33752..f12c253 100644
--- a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
+++ b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
@@ -139,8 +139,15 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue,
///
public Task ConnectAsync(
SshConnectionRequest request,
+ IProgress? progress,
CancellationToken cancellationToken)
{
+ // The real factory's own order, so that a test watching this fake is watching the same sequence a
+ // real handshake produces. It cannot report CheckingHostKey — there is no key exchange here to
+ // produce a key — and inventing one would make this the only place that phase came from.
+ progress?.Report(SshConnectionPhase.Reaching);
+ progress?.Report(SshConnectionPhase.Authenticating);
+
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
Connections.Add(connection);
@@ -250,3 +257,15 @@ internal sealed class RecordingTransport : ITerminalTransport
TerminalFrame.TryRead(frame, out var actual, out _, out _)
&& actual == (byte)opcode);
}
+
+/// An that runs its callback on the thread that reported.
+///
+/// System.Progress<T> would post to a captured synchronisation context, or to the thread pool
+/// when there is none — which is what a test has here — so a list it appended to would be asserted on before
+/// it had been written. This is the same reason the shell does not use it either; see
+/// VaultViewModel.ReporterFor.
+///
+internal sealed class DelegateProgress(Action report) : IProgress
+{
+ public void Report(T value) => report(value);
+}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
index bed2e44..8c5736a 100644
--- a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
@@ -72,12 +72,12 @@ public sealed class TerminalWorkspaceTests
workspace.LiveSessionCount.ShouldBe(0);
await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
workspace.LiveSessionCount.ShouldBe(1);
await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
workspace.LiveSessionCount.ShouldBe(2);
}
@@ -98,11 +98,63 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(connections);
await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
}
+ ///
+ ///
+ /// is the one phase no connection factory can report,
+ /// because by the time it happens the factory has handed back a connection and gone. If this layer did
+ /// not report it the card's last step would light only when the whole session opened, which is the one
+ /// moment the card is already being taken down — a step nobody would ever see lit.
+ ///
+ ///
+ /// Asserted as the whole sequence rather than as "contains OpeningShell", because the order is the part
+ /// that matters: a step list is only readable if what it is told arrives in the order it draws.
+ ///
+ ///
+ [Fact]
+ public async Task OpeningASession_ReportsTheShellPhaseTheFactoryCannot()
+ {
+ var connections = new FakeConnectionFactory();
+ var reported = new List();
+
+ await using var workspace = CreateWorkspace(connections);
+
+ await workspace.OpenSessionAsync(
+ Request(),
+ TerminalSize.Default,
+ new DelegateProgress(reported.Add),
+ TestContext.Current.CancellationToken);
+
+ reported.ShouldBe(
+ [
+ SshConnectionPhase.Reaching,
+ SshConnectionPhase.Authenticating,
+ SshConnectionPhase.OpeningShell,
+ ],
+ "the factory's own phases, then the one this layer performs itself");
+ }
+
+ ///
+ /// Nobody watching is the ordinary case — every caller but the connecting card passes null — so it is
+ /// worth one test that the null is a null and not a null reference.
+ ///
+ [Fact]
+ public async Task OpeningASession_WorksWithNobodyWatchingItsPhases()
+ {
+ var connections = new FakeConnectionFactory();
+
+ await using var workspace = CreateWorkspace(connections);
+
+ var sessionId = await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
+
+ workspace.IsSessionLive(sessionId).ShouldBeTrue();
+ }
+
[Fact]
public async Task ClosingASessionEndsItAndDisposesItsConnection()
{
@@ -111,7 +163,7 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
@@ -138,9 +190,9 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(connections);
var first = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
var second = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
workspace.IsSessionLive(first).ShouldBeTrue();
workspace.IsSessionLive(second).ShouldBeTrue();
@@ -166,7 +218,7 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull();
var connection = connections.Connections.ShouldHaveSingleItem();
@@ -199,7 +251,7 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
(await workspace.PasteAsync(
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
@@ -245,7 +297,7 @@ public sealed class TerminalWorkspaceTests
};
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await WaitUntilAsync(() =>
{
@@ -283,7 +335,7 @@ public sealed class TerminalWorkspaceTests
};
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
@@ -309,9 +361,9 @@ public sealed class TerminalWorkspaceTests
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await workspace.DisposeAsync();
@@ -355,7 +407,7 @@ public sealed class TerminalWorkspaceTests
using var first = await ConnectRendererAsync(workspace);
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
// The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not
// what this test is about — read and discarded so it cannot be confused for one below.
@@ -422,7 +474,7 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
// The up-arrow, as a PTY expects it. Three bytes, and all three matter.
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
@@ -444,7 +496,7 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
var sessionId = await workspace.OpenSessionAsync(
- Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+ Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
diff --git a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs
index 3fe48b8..bc2fcda 100644
--- a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs
+++ b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs
@@ -391,7 +391,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture