Public Access
Notice when a shell ends, say which way it ended, and offer the way back
A terminal whose machine rebooted stayed green for ever. Not "went stale and
recovered late" — for ever: the tab claimed CONNECTED, the elapsed timer went
on counting, and the only thing that ever corrected it was closing the
application. That is the defect this fixes, and it turned out to be two
separate failures wearing one symptom.
◆ THE HANG IS IN SSH.NET, AND KEEP-ALIVE ALONE WOULD NOT HAVE FIXED IT.
ShellStream ends a blocked read from exactly two places: its channel being
closed, and the session raising Disconnected. A shell that exits gives the
first. But Session raises Disconnected from one method only — OnDisconnectReceived,
for a server that politely says goodbye — and a socket that simply fails goes
down a different path entirely: RaiseError fires the error event, sets the
disconnecting flag and disposes the socket, without ever raising Disconnected.
So the stream was never disposed, its Monitor.Wait was never pulsed, and the
read blocked until the process ended. The transport was gone, the client knew
the transport was gone, and nothing woke the one thread that was waiting.
That is why the first attempt at a test for this sat at thirty seconds and
failed. Both halves are needed and neither is sufficient:
- Keep-alive makes the failure HAPPEN. An idle SSH session is a socket
nobody writes to, and TCP has no way to discover that the far end has gone
without writing to it — nothing was asked, so nothing could fail. Thirty
seconds, matching OpenSSH's own ServerAliveInterval examples. It is the
write that matters and not a reply: SSH_MSG_IGNORE need not be answered,
so this is traffic rather than a heartbeat with a deadline, and there is
no timeout invented here to be wrong about a slow link.
- Disposing the stream from the error handler makes it ARRIVE. Queued to the
thread pool and not done inline, which is not tidiness: this runs on
SSH.NET's message-listener thread, and closing a channel waits for the
server's close — processed by that same thread — so an inline dispose is
the listener thread waiting on itself.
SFTP gets the keep-alive too. Its operations are request/response and bounded
by ConnectionInfo.Timeout already, so it needs no equivalent wake-up.
◆ exit AND reboot ARE TOLD APART BY A LATCH, BECAUSE THE OBVIOUS WAY IS A RACE.
Wait for the read to end and then ask whether the transport is still up, and
against a real server the answer is wrong about half the time: this was the
only channel on the connection, so sshd hangs up a millisecond after closing
it. By the time anything could ask, the transport is gone either way — and a
poll would report every ordinary `exit` as a dropped connection.
So ISshShellSession.TransportFailure is set at the moment SSH.NET raises the
failure, which its own ordering puts strictly before the disconnect that wakes
the read. A read that has just returned zero consults a settled value: set if
the transport failed, still null if the remote merely closed the channel. The
pump turns that into a TerminalSessionOutcome — RemoteClosed, ConnectionLost,
ClosedLocally — carried on SessionEnded, which used to hand over a session id
and nothing else. A subscriber cannot work this out afterwards; by the time it
hears, there is no transport left to interrogate.
Deliberately no fourth value for "the remote rebooted". Nothing on the wire
distinguishes a reboot from a cable being pulled, and a name this client
cannot check would be a guess dressed as a fact.
◆ WHAT IS DRAWN, AND WHY IT IS NOT DRAWN OVER THE PANE.
The status bar says DISCONNECTED or SHELL EXITED where it used to say NOT
CONNECTED for both — a word that is honest about a tab still dialling and
useless about one whose shell has stopped. Red for the first and grey for the
second: one is a machine that stopped answering and the other is work
finished, and crying wolf on the ordinary end of every session is its own bug.
The tab strip's dot gains the same fourth state, on both heads.
Under the terminal, a strip carrying the reason and two buttons. It takes its
own Auto grid row rather than overlaying the renderer, and that is forced
rather than chosen: the WebView is a native child view composited above
everything Avalonia draws, so the alternative is collapsing the terminal — and
the scrollback is precisely what the user wants to read, because the last
thing the remote said is usually why it went. terminal.js already writes the
same sentence into the pane; this is the copy that does not scroll away.
RECONNECT reuses the tab in place, which is what the word means to somebody
who pressed it: the terminal they were working in comes back, where it was.
It dials through the vault's ordinary connect path, so every refusal — a key
that has left the keychain, an unknown host key, a group default that has
changed since — is decided in the one place that already decides them; what
this adds is which tab the attempt belongs to. The old session is closed as
part of it, because a dead shell still holds an authenticated connection this
process has stopped reading, and its pane still holds an xterm, a scrollback
and a WebGL context, of which a browser hands out about sixteen.
Two refusals it makes rather than pretending: a host reached by typing an
address has no keychain entry to redial, and one that wants a password typed
goes to the keychain with the sheet raised rather than being dialled with an
empty box. Both say so on the strip where the button was pressed, not only in
the vault's status on a screen that surface is not.
TRY AGAIN appears on a refused connection for the same reason, and it is worth
as much: the commonest refusal is a host that was not up yet.
◆ WHAT IS ACTUALLY VERIFIED.
Two tests against a real sshd in a container. `exit` is reported as the remote
closing the session — the one a polling implementation fails, and a fake shell
cannot show it because a fake has no server behind it deciding to hang up. And
a live shell losing its transport is reported as a lost connection, produced
by dialling through a plain TCP relay the test owns and then cutting it; the
container is shared by the collection, so killing sshd was never an option.
The first draft of that second test used LoopbackProxyTests' SOCKS forward and
timed out: disconnecting the carrier of a ForwardedPortDynamic does not close
the loopback socket it accepted. Recorded in the test, because it is a
property of SSH.NET's forwarder rather than of anything here, and it is not
what a dying machine looks like.
What no test reproduces is the waiting. A relay that closes sends a FIN, so
the socket reports itself at once; a machine switched off sends nothing, and
finding that out is the keep-alive's business and the OS's retransmit budget's
— neither of which a test can hurry along without a clock this code does not
own. A reboot is prompt in practice because the machine comes back and RSTs.
Beside those: three pump outcomes, three workspace announcements, and seven
shell-flow tests covering the drop, the clean exit, reuse of the tab, retry
after a refusal, and both ways a reconnect can honestly come to nothing.
Also fixed in passing: SshNetShellSession.IsOpen read ShellStream.CanRead,
which is documented as always true — the stream stays readable after disposal
so buffered output can be drained — so it had answered "yes" for every shell
that ever existed. It reads CanWrite now. And MainWindowViewModel's session-
ended handler moved from Dispatcher.UIThread.Post to the injected post
delegate, which is what makes any of the shell half testable at all; the
remark listing the three unreachable posts is one shorter.
This commit is contained in:
1 parent
c2c860b4f2
commit
e2733e5cbc
21 files changed
+1755
-43
No files matched your search
@@ -370,6 +370,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
||||
|
||||
/// <summary>
|
||||
/// The tab the next connection attempt belongs to, while a reconnect is being started.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A field rather than a parameter because the two ends of this are not the same call.</b> Dialling
|
||||
/// goes through the vault's own connect command — which is the whole point, since that is where
|
||||
/// credentials, host keys and every refusal live — and what comes back is a <c>ConnectionStarting</c>
|
||||
/// event that has never heard of tabs. This is how the attempt that event announces is recognised as
|
||||
/// belonging to a tab that already exists instead of getting a new one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It cannot be stale by the time it is read. <c>ConnectionStarting</c> is raised before the vault's
|
||||
/// first await, on this thread, so the set and the read are the same turn of the dispatcher — and
|
||||
/// <see cref="ReconnectTabAsync"/> clears it afterwards for the paths that never got that far, which is
|
||||
/// every refusal the vault makes before it dials.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private TerminalTabViewModel? reconnecting;
|
||||
|
||||
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
|
||||
/// <remarks>
|
||||
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
|
||||
@@ -3471,6 +3491,23 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
||||
{
|
||||
if (reconnecting is { } existing)
|
||||
{
|
||||
// A reconnect. The tab is already in the strip, already selected, and the user is looking at it —
|
||||
// so it goes back to connecting in place rather than a second tab for the same machine appearing
|
||||
// beside the one they pressed the button on. See ReconnectTabAsync.
|
||||
reconnecting = null;
|
||||
|
||||
existing.Reconnecting();
|
||||
attempts[e.AttemptId] = existing;
|
||||
|
||||
// The card replaces the pane the moment the session id is dropped, and both flags turn on that.
|
||||
RaiseTerminalState();
|
||||
RaiseSessionState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
||||
attempts[e.AttemptId] = tab;
|
||||
|
||||
@@ -3745,6 +3782,222 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Surface = ShellSurface.Terminal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host this tab was opened against, or null when the keychain has nothing under that name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
|
||||
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
||||
/// able to say no: a machine reached by typing an address rather than from the keychain, which has no row
|
||||
/// to dial again, and a tab whose host was deleted or whose vault is locked.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
||||
tab is null || Vault is not { } vault
|
||||
? null
|
||||
: vault.Hosts.FirstOrDefault(
|
||||
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected tab has stopped and there is something to dial again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both halves are needed and neither implies the other. The tab has to be finished — offering a
|
||||
/// reconnect beside a live shell is offering to throw it away — and the machine has to still be in the
|
||||
/// keychain, because that is the only place the credentials to redial it exist. A tab typed in by hand
|
||||
/// fails the second half, which is honest: this client cannot dial it again without the password, and a
|
||||
/// button that raised a password box under the word RECONNECT would be a different promise.
|
||||
/// </remarks>
|
||||
internal bool CanReconnectSelectedTab =>
|
||||
IsTerminalSurface
|
||||
&& SelectedTab is { CanOfferReconnect: true }
|
||||
&& HostFor(SelectedTab) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// The word the status bar shows in place of CONNECTED once the selected tab's shell has stopped, or
|
||||
/// null while it has not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Terminal-only, because it is a fact about a shell. The SFTP surface reports its own connection
|
||||
/// through <see cref="TransfersViewModel.IsConnected"/> and has no ending to word: a transfer session is
|
||||
/// opened per browse and closed when the screen is left, so there is no state in which one has stopped
|
||||
/// while somebody is still looking at it.
|
||||
/// </remarks>
|
||||
internal string? SessionEndedText =>
|
||||
IsTerminalSurface ? SelectedTab?.EndedSummary : null;
|
||||
|
||||
/// <summary>Whether the selected tab's session ended because the connection broke.</summary>
|
||||
/// <remarks>
|
||||
/// Drawn in the palette's red where a shell that merely exited is drawn in grey, which is the whole
|
||||
/// reason this is a second question rather than "did it end". One of the two is somebody having finished
|
||||
/// what they were doing and the other is a machine that stopped answering.
|
||||
/// </remarks>
|
||||
internal bool IsSessionDropped =>
|
||||
IsTerminalSurface && SelectedTab?.EndReason is SshSessionEnd.ConnectionLost;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the status bar should say NOT CONNECTED rather than name an ending.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The third of three, and it exists so that no two of them are ever drawn at once. A tab still dialling
|
||||
/// is this; a tab whose shell has stopped has a word of its own, and printing NOT CONNECTED beside
|
||||
/// DISCONNECTED would answer the same question twice in two vocabularies.
|
||||
/// </remarks>
|
||||
internal bool IsSessionIdle => !IsSessionConnected && SessionEndedText is null;
|
||||
|
||||
/// <summary>
|
||||
/// The sentence under that word — what the remote or the transport actually said, or why a reconnect was
|
||||
/// just refused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usually the same string the renderer has already written into the pane. Drawn outside it as well
|
||||
/// because the pane scrolls: a shell that printed a page of shutdown messages before it went has pushed
|
||||
/// the line explaining itself off the screen, and this is the copy that stays put. See
|
||||
/// <see cref="TerminalTabViewModel.EndedDetail"/> for the case where it is not that string.
|
||||
/// </remarks>
|
||||
internal string? SessionEndedDetail =>
|
||||
IsTerminalSurface ? SelectedTab?.EndedDetail : null;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new shell on the machine a stopped tab was pointed at, in that tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>It dials through the vault's ordinary connect path, and that is the whole design.</b> Every
|
||||
/// refusal a connection can meet — a key that will not resolve, an unknown host key, a mismatch, a group
|
||||
/// default that has changed since the first attempt — is decided in one place, and a reconnect that
|
||||
/// reached past it into <c>TerminalWorkspace</c> would be a second connect path that got all of those
|
||||
/// subtly wrong. What this method adds to that path is one thing: which tab the attempt belongs to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The old session is closed before the new one is asked for.</b> A dead shell still holds an
|
||||
/// authenticated connection this process has stopped reading, and its pane still holds an xterm, a
|
||||
/// scrollback and a WebGL context — of which a browser hands out about sixteen. Reconnecting without
|
||||
/// closing it would leak one of each per reconnect, invisibly, inside the WebView.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A host that asks for a typed password is sent to the keychain rather than refused.</b> The vault
|
||||
/// raises its password sheet for one, and that sheet is drawn on the hosts screen — so this navigates
|
||||
/// there instead of arming the tab, and the session that results is a new tab. That is a smaller wrong
|
||||
/// than a button that appears to do nothing because the thing it raised is on a screen nobody is looking
|
||||
/// at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ReconnectTabAsync(TerminalTabViewModel? tab)
|
||||
{
|
||||
if (tab is null || Vault is not { } vault || !tab.CanOfferReconnect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (HostFor(tab) is not { } host)
|
||||
{
|
||||
Refuse(tab, $"{tab.Label} is not in this keychain any more, so there is nothing here to dial again.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Selected first, so that a reconnect started from the tab strip while another tab was showing puts
|
||||
// the user in front of the attempt they just asked for.
|
||||
SelectTab(tab);
|
||||
|
||||
// Before the question below, because the question is about the selection: SelectedHostAsksForAPassword
|
||||
// reads whichever row is lit, and asking it about a host it has not been pointed at yet would answer
|
||||
// for the previous one. The connect command assigns the same row again, so this is not a second
|
||||
// selection — it is the same one, made early enough to be asked about.
|
||||
vault.SelectedHost = host;
|
||||
|
||||
// The same pair of conditions ConnectToRowAsync itself branches on: a machine that needs a secret and
|
||||
// has not been given one is a sheet being raised rather than a connection being made, and a sheet is
|
||||
// drawn on a screen this surface is not.
|
||||
if (vault.SelectedHostAsksForAPassword && vault.ConnectPassword.Length == 0)
|
||||
{
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
|
||||
// The command's own second half raises the sheet and says which machine it is for; running it
|
||||
// rather than setting the flag here keeps that wording in one place.
|
||||
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await RedialIntoAsync(vault, host, tab).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Dials <paramref name="host"/> with the attempt bound to <paramref name="tab"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Split from <see cref="ReconnectTabAsync"/> so that the arming, the dial and the old session's release
|
||||
/// read as the one sequence they are — the refusals above it are a separate concern and each of them
|
||||
/// returns before reaching here.
|
||||
/// </remarks>
|
||||
private async Task RedialIntoAsync(VaultViewModel vault, HostRowViewModel host, TerminalTabViewModel tab)
|
||||
{
|
||||
var previous = tab.SessionId;
|
||||
|
||||
reconnecting = tab;
|
||||
|
||||
bool dialled;
|
||||
|
||||
try
|
||||
{
|
||||
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The field being gone means OnVaultConnectionStarting took it, which is the one and only proof
|
||||
// that an attempt actually began. Asking the attempts map instead does not work: a handshake that
|
||||
// has already finished — succeeded or been refused — has been taken out of it again, and in this
|
||||
// suite's fakes that happens before this line is reached.
|
||||
dialled = reconnecting is null;
|
||||
|
||||
// Cleared however it went, so a refusal the vault made before dialling cannot leave the field
|
||||
// armed to capture somebody else's next connection into this tab.
|
||||
reconnecting = null;
|
||||
}
|
||||
|
||||
if (!dialled)
|
||||
{
|
||||
// The vault turned it down before starting one — a key that has left the keychain, a group
|
||||
// binding that no longer resolves. Its own Status carries the reason and is drawn on a screen
|
||||
// this surface is not, so it is repeated where the button was pressed. The old session is left
|
||||
// alone: there is nothing to replace it with.
|
||||
Refuse(
|
||||
tab,
|
||||
vault.Status is { Length: > 0 } refusal
|
||||
? refusal
|
||||
: $"{tab.Label} could not be dialled again.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous != 0)
|
||||
{
|
||||
// After the attempt has started, not before: the pane the user was reading stays up for as long
|
||||
// as there is nothing to replace it with, and closing it first would blank the rectangle for the
|
||||
// length of a handshake.
|
||||
await workspace.CloseSessionAsync(previous).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Says why a reconnect is not happening, where the strip that offered it is looking.</summary>
|
||||
/// <remarks>
|
||||
/// On the tab rather than in a field of this shell's own, because it is a fact about that tab: switching
|
||||
/// to another terminal and back should not lose it, and switching to a different one should not show it.
|
||||
/// See <see cref="TerminalTabViewModel.EndedDetail"/>.
|
||||
/// </remarks>
|
||||
private void Refuse(TerminalTabViewModel tab, string reason)
|
||||
{
|
||||
tab.Status = reason;
|
||||
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
/// </summary>
|
||||
@@ -3764,17 +4017,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// gap rather than worth blocking this handler on the recorder's queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => post(() =>
|
||||
{
|
||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||
{
|
||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||
{
|
||||
tab.IsLive = false;
|
||||
}
|
||||
tab.Ended(e.Outcome.End, e.Outcome.Description);
|
||||
|
||||
RefreshConnectedHosts();
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
});
|
||||
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
|
||||
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
|
||||
// the reconnect beside CONNECTED appears from here.
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
RefreshConnectedHosts();
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
|
||||
@@ -4258,6 +4515,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
OnPropertyChanged(nameof(SessionAddress));
|
||||
OnPropertyChanged(nameof(IsSessionConnected));
|
||||
OnPropertyChanged(nameof(SessionEndedText));
|
||||
OnPropertyChanged(nameof(SessionEndedDetail));
|
||||
OnPropertyChanged(nameof(IsSessionDropped));
|
||||
OnPropertyChanged(nameof(IsSessionIdle));
|
||||
OnPropertyChanged(nameof(CanReconnectSelectedTab));
|
||||
OnPropertyChanged(nameof(SessionElapsedText));
|
||||
OnPropertyChanged(nameof(SessionCipher));
|
||||
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||
|
||||
@@ -351,6 +351,95 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private bool isShowing;
|
||||
|
||||
/// <summary>
|
||||
/// How the shell behind this tab ended, or null while it has not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null covers three states that need no telling apart here — still connecting, still running, and a
|
||||
/// connection that never happened — because the only thing anything asks this is which ending to word,
|
||||
/// and none of the three has one.
|
||||
/// </remarks>
|
||||
internal SshSessionEnd? EndReason { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The sentence the shell's end came with, or null while there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same string the renderer wrote into the pane, so the detail under a status line and the line in
|
||||
/// the scrollback are one sentence rather than two accounts that can drift.
|
||||
/// </remarks>
|
||||
internal string? EndDescription { get; private set; }
|
||||
|
||||
/// <summary>Whether this tab has a session that has stopped, and something to say about it.</summary>
|
||||
internal bool HasEnded => State is TerminalTabState.Open && !IsLive;
|
||||
|
||||
/// <summary>
|
||||
/// The sentence the strip under a stopped terminal prints: why a reconnect was refused if one just was,
|
||||
/// and otherwise how the session ended.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The refusal wins because it is the newer answer and it is about something the user just did. A tab
|
||||
/// whose reconnect was turned down — the machine has left the keychain, the key it was bound to is gone —
|
||||
/// would otherwise go on printing how the shell ended, which is true and is not what was just asked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Status"/> is where the refusal lands because that is already what this property means:
|
||||
/// what the tab has to say for itself. It is empty for the whole of a session's life —
|
||||
/// <see cref="Opened"/> clears it — so a non-empty one on a stopped tab can only be something that
|
||||
/// happened after the shell did.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? EndedDetail => Status is { Length: > 0 } spoken ? spoken : EndDescription;
|
||||
|
||||
/// <summary>Whether this tab's session ended because the connection broke rather than because the shell did.</summary>
|
||||
/// <remarks>
|
||||
/// The tab strip's dot binds this, so the two endings are told apart at a glance in the one place every
|
||||
/// terminal is listed. They used to draw the same grey, which made a tab that had been cut off
|
||||
/// indistinguishable from one somebody had closed down properly.
|
||||
/// </remarks>
|
||||
internal bool IsDropped => EndReason is SshSessionEnd.ConnectionLost;
|
||||
|
||||
/// <summary>
|
||||
/// What the status bar says instead of CONNECTED, or null while nothing has ended.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two words rather than one, because the two endings are read completely differently. A shell that
|
||||
/// exited is the ordinary end of a day's work and wants no alarm; a connection that broke is a machine
|
||||
/// that may well still be there, and is the whole reason the reconnect beside it exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="SshSessionEnd.ClosedLocally"/> has no word here on purpose. This client closed the session,
|
||||
/// which means a tab is being removed or reconnected — there is no moment at which somebody reads a
|
||||
/// status line about it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? EndedSummary => EndReason switch
|
||||
{
|
||||
SshSessionEnd.ConnectionLost => "DISCONNECTED",
|
||||
SshSessionEnd.RemoteClosed => "SHELL EXITED",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether this tab is in a state where dialling again would mean something.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// True for a shell that ended however it ended, and for a connection that was refused. All three are a
|
||||
/// tab with no live session and a machine it was pointed at — and offering the retry on a refusal is
|
||||
/// worth as much as offering it on a drop, because the commonest refusal is a host that was not up yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It says the tab is willing, not that the shell can do it.</b> Whether there is anything to dial —
|
||||
/// a keychain entry under this tab's name, and one that does not need a password typed first — is the
|
||||
/// shell's question, because this view model has never heard of a vault. See
|
||||
/// <c>MainWindowViewModel.CanReconnectSelectedTab</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool CanOfferReconnect => HasEnded || IsFailed;
|
||||
|
||||
/// <summary>Whether there is a pane behind this tab.</summary>
|
||||
internal bool HasSession => State is TerminalTabState.Open;
|
||||
|
||||
@@ -413,6 +502,13 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
{
|
||||
SessionId = sessionId;
|
||||
Status = string.Empty;
|
||||
|
||||
// Cleared before the flags below raise off them. A tab reconnecting carries one of these from the
|
||||
// session that just ended, and a live terminal that still says how the last one stopped is the
|
||||
// stale-fact failure this pair exists to remove.
|
||||
EndReason = null;
|
||||
EndDescription = null;
|
||||
|
||||
IsLive = true;
|
||||
State = TerminalTabState.Open;
|
||||
|
||||
@@ -432,6 +528,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
Status = reason;
|
||||
IsLive = false;
|
||||
|
||||
// A refusal is not an ending of the kind Ended records — there was never a session to end — so
|
||||
// anything a previous attempt on this tab left behind goes with it. Without this, a reconnect that
|
||||
// is itself refused would draw the refusal and the old shell's ending at once.
|
||||
EndReason = null;
|
||||
EndDescription = null;
|
||||
|
||||
// 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
|
||||
@@ -447,6 +549,96 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
State = TerminalTabState.Failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the shell behind this tab has stopped, and how.
|
||||
/// </summary>
|
||||
/// <param name="end">Which of the three ways it ended.</param>
|
||||
/// <param name="description">The sentence the pump wrote into the pane, for the detail line.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tab stays and its pane stays, exactly as before: the last thing the remote said is usually why the
|
||||
/// shell ended, and the renderer has already written this same sentence into the scrollback. What is new
|
||||
/// is that the tab now knows <em>which</em> ending it was, which is the difference between a shell that is
|
||||
/// finished and a machine that is probably still there — see <see cref="CanOfferReconnect"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ignored for a tab that is connecting or failed. A session end can only be about a session, and a late
|
||||
/// announcement for one this tab has already been reconnected past would otherwise reach back and mark a
|
||||
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Ended(SshSessionEnd end, string description)
|
||||
{
|
||||
if (State is not TerminalTabState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EndReason = end;
|
||||
EndDescription = description;
|
||||
IsLive = false;
|
||||
|
||||
OnPropertyChanged(nameof(EndedDetail));
|
||||
OnPropertyChanged(nameof(EndedSummary));
|
||||
OnPropertyChanged(nameof(EndDescription));
|
||||
OnPropertyChanged(nameof(IsDropped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts this tab back to connecting, for an attempt that is about to be made on the same machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tab is reused rather than a second one opened beside it, because reconnecting is what a person
|
||||
/// means when they say it: the tab they were working in comes back, in the place it was, still selected.
|
||||
/// A new tab would leave the strip holding two entries for one machine, one of them a headstone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The session id is dropped here, and the pane it named is the caller's to reclaim.</b> Nothing in
|
||||
/// this view model can send a frame; what it can do is stop claiming a session that is about to stop
|
||||
/// being this tab's. See <c>MainWindowViewModel.ReconnectTabAsync</c>, which closes the old session for
|
||||
/// its pane's sake — a pane holds an xterm, its scrollback and a WebGL context, and browsers hand out
|
||||
/// about sixteen of the last.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The step list is rebuilt to how it looked at the start of the first attempt rather than left as it
|
||||
/// was. A list still showing where the last attempt got to would read as progress this one had already
|
||||
/// made, which is the one thing the card promises never to do.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Reconnecting()
|
||||
{
|
||||
SessionId = 0;
|
||||
EndReason = null;
|
||||
EndDescription = null;
|
||||
IsLive = false;
|
||||
|
||||
// Cleared with the session they were facts about. Keeping them would put the previous connection's
|
||||
// cipher in the status bar beside a handshake that has not negotiated one yet.
|
||||
StartedAt = null;
|
||||
Cipher = null;
|
||||
HostKeyAlgorithm = null;
|
||||
IdentityLabel = null;
|
||||
|
||||
foreach (var row in Steps)
|
||||
{
|
||||
row.State = ConnectionStepState.Pending;
|
||||
}
|
||||
|
||||
// The same opening position the constructor sets, and for the same reason: the renderer wait starts
|
||||
// in this turn, so a list of five pending rows would show an attempt that had not begun.
|
||||
Steps[0].State = ConnectionStepState.Running;
|
||||
Status = Steps[0].Caption;
|
||||
|
||||
State = TerminalTabState.Connecting;
|
||||
|
||||
OnPropertyChanged(nameof(StepsDone));
|
||||
OnPropertyChanged(nameof(EndedDetail));
|
||||
OnPropertyChanged(nameof(EndedSummary));
|
||||
OnPropertyChanged(nameof(EndDescription));
|
||||
OnPropertyChanged(nameof(IsDropped));
|
||||
}
|
||||
|
||||
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
|
||||
private void CompleteSteps()
|
||||
{
|
||||
@@ -463,5 +655,24 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasSession));
|
||||
OnPropertyChanged(nameof(IsConnecting));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(HasEnded));
|
||||
OnPropertyChanged(nameof(CanOfferReconnect));
|
||||
|
||||
// Opened and Failed both clear the previous ending and then land here, so this is where a dot that
|
||||
// was red for the last session goes back to whatever the new state deserves.
|
||||
OnPropertyChanged(nameof(IsDropped));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="HasEnded"/> and <see cref="CanOfferReconnect"/> are both partly this flag, and the strip
|
||||
/// binds them — so a shell that stops has to move them in the same notification that greys its dot.
|
||||
/// </remarks>
|
||||
/// <remarks>The strip under a stopped terminal reads this as well as the ending; see <see cref="EndedDetail"/>.</remarks>
|
||||
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(EndedDetail));
|
||||
|
||||
partial void OnIsLiveChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasEnded));
|
||||
OnPropertyChanged(nameof(CanOfferReconnect));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user