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
@@ -450,6 +450,17 @@
|
|||||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Red for a shell whose connection broke, splitting the grey that used to cover every dead tab. A shell
|
||||||
|
somebody exited and a shell that was cut off are the two dead states with the least in common, and on a
|
||||||
|
phone the difference matters more than it does on a desk: a mobile link drops on its own, and the tab
|
||||||
|
that went quiet while the screen was off is the one worth dialling again. See
|
||||||
|
TerminalTabViewModel.IsDropped.
|
||||||
|
-->
|
||||||
|
<Style Selector="Ellipse.dot.dropped">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||||
<Style Selector="TextBlock.mono">
|
<Style Selector="TextBlock.mono">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
|||||||
@@ -114,7 +114,13 @@
|
|||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<!--
|
||||||
|
Four rows since the disconnect strip arrived. It is Auto and empty except while the selected tab's shell
|
||||||
|
has stopped, so an ordinary session pays nothing for it — and when it is up it takes its height off the
|
||||||
|
renderer rather than drawing over it, which on this head is not optional either: the renderer is a native
|
||||||
|
child view composited above everything Avalonia draws, so a banner over its rectangle would be invisible.
|
||||||
|
-->
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!-- ============ the bar ============ -->
|
<!-- ============ the bar ============ -->
|
||||||
<!--
|
<!--
|
||||||
@@ -177,11 +183,13 @@
|
|||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
<!--
|
<!--
|
||||||
Green only while there is a shell behind it, amber while one is being made; see
|
Green only while there is a shell behind it, amber while one is being made, and
|
||||||
the same dot in PhoneShell, and Phone.axaml for why amber is not a rule broken.
|
red where a connection broke rather than a shell exiting; see the same dot in
|
||||||
|
PhoneShell, and Phone.axaml for why neither of the last two is a rule broken.
|
||||||
-->
|
-->
|
||||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
Classes.connecting="{Binding IsConnecting}"
|
||||||
|
Classes.dropped="{Binding IsDropped}" Width="6" Height="6"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||||
Text="{Binding Label}" />
|
Text="{Binding Label}" />
|
||||||
@@ -421,6 +429,18 @@
|
|||||||
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
||||||
-->
|
-->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
||||||
|
<!--
|
||||||
|
Only for a refusal, and it runs the same command the disconnect strip below the renderer does —
|
||||||
|
the vault's own connect path, back into this same tab. Worth more on a phone than on a desk: the
|
||||||
|
commonest refusal here is a mobile link that was not up yet, and the alternative is going back
|
||||||
|
to the hosts screen to find a machine whose name is already on this card.
|
||||||
|
-->
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||||
|
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsFailed}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="TRY AGAIN" />
|
||||||
|
</Button>
|
||||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||||
@@ -470,7 +490,56 @@
|
|||||||
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
||||||
that has gone wrong rather than as two that are different things.
|
that has gone wrong rather than as two that are different things.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||||
|
<!--
|
||||||
|
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||||
|
its own, the screen goes off, and the tab that went quiet is discovered later with no idea whether the
|
||||||
|
shell was exited or cut off. So it names which, prints the sentence that came with it, and offers the
|
||||||
|
one useful answer.
|
||||||
|
|
||||||
|
Two lines rather than the desktop's one, because 360dp cannot hold a word, a sentence and two buttons
|
||||||
|
across. The word and the buttons share the top line — the buttons are where a thumb already is — and
|
||||||
|
the sentence wraps underneath at two lines maximum, which is enough for anything sshd or a socket
|
||||||
|
error actually says and short of a strip that eats the terminal.
|
||||||
|
|
||||||
|
RECONNECT is disabled rather than hidden when there is nothing to dial: a machine typed in by hand has
|
||||||
|
no keychain entry to redial, and a control visibly refusing answers "why not" better than an absent one.
|
||||||
|
See MainWindowViewModel.CanReconnectSelectedTab.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="2" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="16,8"
|
||||||
|
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
|
<Ellipse Classes="dot" Classes.dropped="{Binding IsSessionDropped}" Width="7" Height="7"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="{Binding SessionEndedText}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||||
|
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||||
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||||
|
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding SessionEndedDetail}"
|
||||||
|
IsVisible="{Binding SessionEndedDetail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
|
||||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||||
|
|||||||
@@ -1282,6 +1282,17 @@
|
|||||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Red, and it is the fourth state this dot has rather than a recolouring of the grey one. Grey is a
|
||||||
|
shell that ended — somebody typed `exit`, and nothing is wrong with the machine. This is a connection
|
||||||
|
that went out from under a session that had not finished, which is the one worth looking at: the two
|
||||||
|
used to be the same dot, so a tab that had been cut off and a tab somebody had closed down properly
|
||||||
|
were indistinguishable in the strip. See TerminalTabViewModel.EndedSummary.
|
||||||
|
-->
|
||||||
|
<Style Selector="Ellipse.dot.dropped">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
|
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
|
||||||
item's presenter is the thing the theme keeps repainting.
|
item's presenter is the thing the theme keeps repainting.
|
||||||
|
|||||||
@@ -181,6 +181,19 @@
|
|||||||
in the strip and the card is still here on the way back.
|
in the strip and the card is still here on the way back.
|
||||||
-->
|
-->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||||
|
<!--
|
||||||
|
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
|
||||||
|
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
|
||||||
|
connect path, into this same tab — and it is worth having here for the reason the commonest
|
||||||
|
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
|
||||||
|
|
||||||
|
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
|
||||||
|
MainWindowViewModel.CanReconnectSelectedTab.
|
||||||
|
-->
|
||||||
|
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||||
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
||||||
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
||||||
|
|||||||
@@ -279,7 +279,14 @@
|
|||||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
answers for itself in the tab row's own "+" and in the connecting card below.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
<!--
|
||||||
|
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
||||||
|
and empty except while the selected tab's shell has stopped, so it costs the terminal
|
||||||
|
nothing in the ordinary case — and when it is up it takes its 34 pixels off the pane rather
|
||||||
|
than drawing over it, which is the only way Avalonia content can sit near the renderer at
|
||||||
|
all. See SessionEndedBar.axaml.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
|
||||||
|
|
||||||
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
||||||
|
|
||||||
@@ -306,7 +313,8 @@
|
|||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
|
<views:SessionEndedBar Grid.Row="1" />
|
||||||
|
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<!--
|
<!--
|
||||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SessionEndedBar"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── What a terminal says once its shell has stopped ──────────────────────────────────────────────────
|
||||||
|
A strip between the pane and the status bar, shown only while the selected tab has a session that has
|
||||||
|
ended. It names which of the two endings it was, prints the sentence that came with it, and offers the
|
||||||
|
one thing worth offering: dialling the machine again.
|
||||||
|
|
||||||
|
◆ IT IS A ROW OF ITS OWN RATHER THAN SOMETHING DRAWN OVER THE PANE, and that is not a style choice.
|
||||||
|
The renderer is a native child view that composites above everything Avalonia draws, so a banner
|
||||||
|
overlapping its rectangle would either be invisible or would have to collapse the terminal — and
|
||||||
|
collapsing it is exactly wrong here, because the scrollback is what the user is reading. A dead shell's
|
||||||
|
last words are usually why it died. So this takes its own Auto row in the grid the WebView's Panel sits
|
||||||
|
in, and the pane simply gets 34 fewer pixels while it is up. See MainWindow.axaml.
|
||||||
|
|
||||||
|
◆ THE SENTENCE IS HERE AS WELL AS IN THE PANE ON PURPOSE. terminal.js writes the same string into the
|
||||||
|
scrollback as a grey rule, which is right and is not enough: a machine that printed a page of shutdown
|
||||||
|
messages on its way down has pushed that line off the screen, and a session that ended while the user
|
||||||
|
was on another tab was never on screen at all. This copy does not scroll.
|
||||||
|
|
||||||
|
── the two endings, and why they are not one colour ─────────────────────────────────────────────────
|
||||||
|
SHELL EXITED is grey. Somebody typed `exit`, or a login shell ended; nothing is wrong and an alarm
|
||||||
|
would be noise. DISCONNECTED is the palette's red, because the connection went out from under a session
|
||||||
|
that had not finished — a reboot, a network drop, an sshd that was killed — and it is the one of the two
|
||||||
|
that means the machine may still be there and worth dialling. The dot beside the word says the same
|
||||||
|
thing a second way, for anybody who cannot tell this design's red from its grey.
|
||||||
|
|
||||||
|
── the buttons ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
RECONNECT is disabled rather than hidden when there is nothing to dial — a tab typed in by hand, or one
|
||||||
|
whose host has left the keychain — because the answer to "why can I not reconnect this" is better given
|
||||||
|
by a control that is visibly refusing than by one that is not there. See
|
||||||
|
MainWindowViewModel.CanReconnectSelectedTab, which is what draws that line, and ReconnectTabAsync for
|
||||||
|
what the button actually does: the vault's own connect path, into this same tab.
|
||||||
|
|
||||||
|
CLOSE TAB is the same command the tab's cross runs. It is here because this is the moment the other
|
||||||
|
reasonable answer is "I have read it, take it away", and hunting for a 16-pixel cross in the strip above
|
||||||
|
is not the shape of that answer.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.endword">
|
||||||
|
<Setter Property="FontFamily" Value="Montserrat" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="FontSize" Value="9.5" />
|
||||||
|
<Setter Property="LetterSpacing" Value="1.2" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.endword.dropped">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<Border Height="34" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||||
|
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0">
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
|
<Ellipse Classes="dot" Width="7" Height="7" Classes.dropped="{Binding IsSessionDropped}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="endword" Classes.dropped="{Binding IsSessionDropped}"
|
||||||
|
Text="{Binding SessionEndedText}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Trimmed rather than wrapped: this row is one line tall and the sentences it draws are short, but a
|
||||||
|
transport error can hand over whatever the operating system called it, and a bar that grew to fit
|
||||||
|
one would take the height out of the terminal above it.
|
||||||
|
-->
|
||||||
|
<SelectableTextBlock Grid.Column="1" Classes="mono" FontSize="11.5" Margin="16,0"
|
||||||
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding SessionEndedDetail}"
|
||||||
|
ToolTip.Tip="{Binding SessionEndedDetail}" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
|
<Button Classes="ghost" Content="RECONNECT"
|
||||||
|
Command="{Binding ReconnectTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||||
|
ToolTip.Tip="Opens a new shell on this machine, in this tab. The scrollback above it goes." />
|
||||||
|
<Button Classes="ghost" Content="CLOSE TAB"
|
||||||
|
Command="{Binding CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>What the terminal surface says once the selected tab's shell has stopped.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// No code of its own: the word, the sentence and both buttons are the shell's. In its own file for the
|
||||||
|
/// reason every other card here is — nothing inside <c>MainWindow</c> can be laid out by a test, because
|
||||||
|
/// WebView2's adapter refuses the headless session's thread. See the markup.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class SessionEndedBar : UserControl
|
||||||
|
{
|
||||||
|
public SessionEndedBar() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -42,14 +42,29 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||||
|
Classes.dropped="{Binding IsSessionDropped}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||||
IsVisible="{Binding IsSessionConnected}" />
|
IsVisible="{Binding IsSessionConnected}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Three words where there were two, and the third replaces rather than joins the second — see
|
||||||
|
MainWindowViewModel.IsSessionIdle, which is what keeps them from ever being drawn together.
|
||||||
|
NOT CONNECTED is honest about a tab still dialling and useless about one whose shell has
|
||||||
|
stopped: it says nothing about which of the two endings it was, and the difference between them
|
||||||
|
is the difference between work that is finished and a machine that stopped answering. The strip
|
||||||
|
below carries the sentence and the way back; this is the same fact in the one line that is
|
||||||
|
always on screen.
|
||||||
|
-->
|
||||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||||
IsVisible="{Binding !IsSessionConnected}" />
|
IsVisible="{Binding IsSessionIdle}" />
|
||||||
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
|
VerticalAlignment="Center" Text="{Binding SessionEndedText}"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
|||||||
@@ -74,9 +74,17 @@
|
|||||||
Worth the third colour because the two it replaces were the same one: a tab still
|
Worth the third colour because the two it replaces were the same one: a tab still
|
||||||
dialling and a tab whose shell has exited both drew grey, which are the two states in
|
dialling and a tab whose shell has exited both drew grey, which are the two states in
|
||||||
this strip with the least in common — one is worth waiting for and the other is over.
|
this strip with the least in common — one is worth waiting for and the other is over.
|
||||||
|
|
||||||
|
── and a fourth since, splitting that grey ────────────────────────────────────────────
|
||||||
|
A shell that ended and a connection that broke were still one dot, and they are the two
|
||||||
|
dead states with the least in common: one is work finished, the other is a machine that
|
||||||
|
stopped answering and may well still be there. Red for the second, matching the strip
|
||||||
|
under the terminal — see TerminalTabViewModel.IsDropped and SessionEndedBar.axaml, which
|
||||||
|
is where the sentence and the way back live.
|
||||||
-->
|
-->
|
||||||
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
||||||
Classes.connecting="{Binding IsConnecting}"
|
Classes.connecting="{Binding IsConnecting}"
|
||||||
|
Classes.dropped="{Binding IsDropped}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
|||||||
@@ -370,6 +370,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
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>
|
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
|
/// 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>
|
/// </remarks>
|
||||||
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
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);
|
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
||||||
attempts[e.AttemptId] = tab;
|
attempts[e.AttemptId] = tab;
|
||||||
|
|
||||||
@@ -3745,6 +3782,222 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Surface = ShellSurface.Terminal;
|
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>
|
/// <summary>
|
||||||
/// Marks a tab dead when its shell ends on its own.
|
/// Marks a tab dead when its shell ends on its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3764,17 +4017,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// gap rather than worth blocking this handler on the recorder's queue.
|
/// gap rather than worth blocking this handler on the recorder's queue.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => post(() =>
|
||||||
Dispatcher.UIThread.Post(() =>
|
{
|
||||||
|
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||||
{
|
{
|
||||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
tab.Ended(e.Outcome.End, e.Outcome.Description);
|
||||||
{
|
|
||||||
tab.IsLive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
RefreshConnectedHosts();
|
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
|
||||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
// 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>
|
/// <summary>
|
||||||
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
|
/// 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(SessionAddress));
|
||||||
OnPropertyChanged(nameof(IsSessionConnected));
|
OnPropertyChanged(nameof(IsSessionConnected));
|
||||||
|
OnPropertyChanged(nameof(SessionEndedText));
|
||||||
|
OnPropertyChanged(nameof(SessionEndedDetail));
|
||||||
|
OnPropertyChanged(nameof(IsSessionDropped));
|
||||||
|
OnPropertyChanged(nameof(IsSessionIdle));
|
||||||
|
OnPropertyChanged(nameof(CanReconnectSelectedTab));
|
||||||
OnPropertyChanged(nameof(SessionElapsedText));
|
OnPropertyChanged(nameof(SessionElapsedText));
|
||||||
OnPropertyChanged(nameof(SessionCipher));
|
OnPropertyChanged(nameof(SessionCipher));
|
||||||
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||||
|
|||||||
@@ -351,6 +351,95 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isShowing;
|
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>
|
/// <summary>Whether there is a pane behind this tab.</summary>
|
||||||
internal bool HasSession => State is TerminalTabState.Open;
|
internal bool HasSession => State is TerminalTabState.Open;
|
||||||
|
|
||||||
@@ -413,6 +502,13 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
SessionId = sessionId;
|
SessionId = sessionId;
|
||||||
Status = string.Empty;
|
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;
|
IsLive = true;
|
||||||
State = TerminalTabState.Open;
|
State = TerminalTabState.Open;
|
||||||
|
|
||||||
@@ -432,6 +528,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
Status = reason;
|
Status = reason;
|
||||||
IsLive = false;
|
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
|
// 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
|
// 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
|
// 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;
|
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>
|
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
|
||||||
private void CompleteSteps()
|
private void CompleteSteps()
|
||||||
{
|
{
|
||||||
@@ -463,5 +655,24 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HasSession));
|
OnPropertyChanged(nameof(HasSession));
|
||||||
OnPropertyChanged(nameof(IsConnecting));
|
OnPropertyChanged(nameof(IsConnecting));
|
||||||
OnPropertyChanged(nameof(IsFailed));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,12 +66,76 @@ public sealed record SshConnectionRequest(
|
|||||||
TimeSpan? ConnectTimeout = null,
|
TimeSpan? ConnectTimeout = null,
|
||||||
SshLoopbackProxy? Proxy = null);
|
SshLoopbackProxy? Proxy = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a shell session came to an end.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Three, because they are the three a user would answer differently. A shell that exited is over and there
|
||||||
|
/// is nothing wrong; a connection that was lost is a machine that may well still be there, and is the one
|
||||||
|
/// worth offering to dial again; a session this client ended is a tab being closed, which nobody needs told.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Deliberately not a fourth for "the remote rebooted".</b> Nothing on the wire distinguishes a reboot
|
||||||
|
/// from a cable being pulled — both are a transport that stops answering — and a name this client cannot
|
||||||
|
/// check would be a guess dressed as a fact. <see cref="ConnectionLost"/> is what is actually known.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public enum SshSessionEnd
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The remote ended it in an orderly way: the shell exited, or the server hung up cleanly.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What <c>exit</c> and <c>logout</c> produce, and what a login shell being killed produces. The channel
|
||||||
|
/// closed while the transport under it was still healthy.
|
||||||
|
/// </remarks>
|
||||||
|
RemoteClosed = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The transport failed underneath it, with no channel close first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A reboot, a network that went away, an <c>sshd</c> that was killed, a laptop lid. See
|
||||||
|
/// <see cref="ISshShellSession.TransportFailure"/> for how this is told apart from
|
||||||
|
/// <see cref="RemoteClosed"/> without a race.
|
||||||
|
/// </remarks>
|
||||||
|
ConnectionLost = 1,
|
||||||
|
|
||||||
|
/// <summary>This client ended it: a tab was closed, a reconnect began, or the process is shutting down.</summary>
|
||||||
|
ClosedLocally = 2,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>An interactive shell over a pseudo-terminal.</summary>
|
/// <summary>An interactive shell over a pseudo-terminal.</summary>
|
||||||
public interface ISshShellSession : IAsyncDisposable
|
public interface ISshShellSession : IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <summary>Whether the channel is still usable.</summary>
|
/// <summary>Whether the channel is still usable.</summary>
|
||||||
bool IsOpen { get; }
|
bool IsOpen { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why the transport under this shell went away, or null if it did not.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>A latched value rather than a question asked afterwards, and that is the whole point of it.</b>
|
||||||
|
/// The obvious way to tell <c>exit</c> from a dropped connection is to wait for the read to report the end
|
||||||
|
/// of the shell and then ask whether the transport is still up — and it is wrong about half the time,
|
||||||
|
/// because a server whose only channel just closed hangs the connection up a millisecond later. The
|
||||||
|
/// answer to that question races the teardown; this does not.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// What makes it safe is an ordering SSH.NET guarantees: a transport failure is raised on its
|
||||||
|
/// message-listener thread <em>before</em> that thread goes on to disconnect the session, and it is the
|
||||||
|
/// disconnect that wakes a blocked read. So a read that has just returned zero can consult this and get a
|
||||||
|
/// settled answer — set if the transport failed, still null if the remote merely closed the channel.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The message rather than the exception: this is read to be shown to somebody, and nothing above this
|
||||||
|
/// interface branches on a type.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
string? TransportFailure { get; }
|
||||||
|
|
||||||
/// <summary>Reads whatever output is available, blocking until at least one byte arrives.</summary>
|
/// <summary>Reads whatever output is available, blocking until at least one byte arrives.</summary>
|
||||||
/// <returns>Bytes read, or 0 once the remote closes the channel.</returns>
|
/// <returns>Bytes read, or 0 once the remote closes the channel.</returns>
|
||||||
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
|
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -18,6 +18,34 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
{
|
{
|
||||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
|
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often to put a byte on the wire for a connection that is otherwise idle.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Without this a rebooted machine is never noticed, and the tab claims to be connected for ever.</b>
|
||||||
|
/// An idle SSH session is a socket nobody is writing to, and a TCP connection that is not written to has
|
||||||
|
/// no way to discover that the far end has gone: the read simply never returns. That is the whole of the
|
||||||
|
/// "reboot leaves it hanging" failure — nothing was broken, nothing had been asked, so nothing could
|
||||||
|
/// fail. This is what makes the question get asked.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It is the write that matters, not the reply.</b> SSH.NET's keep-alive is <c>SSH_MSG_IGNORE</c>,
|
||||||
|
/// which a server is not obliged to answer, so this is not a heartbeat with a deadline — it is traffic.
|
||||||
|
/// A machine that has rebooted answers the first probe with a RST, and the session is torn down at once;
|
||||||
|
/// a network that has vanished is discovered when the OS gives up retransmitting. Neither of those is a
|
||||||
|
/// timeout this client sets, which is why there is no "no reply within N seconds" rule here to be wrong
|
||||||
|
/// about a slow link.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Thirty seconds, which is what OpenSSH's own <c>ServerAliveInterval</c> examples use. It costs a packet
|
||||||
|
/// a minute per idle session in each direction and it bounds how long a dead session can go on looking
|
||||||
|
/// alive; shorter would not make the RST arrive sooner, because what is being waited for is the first
|
||||||
|
/// probe after the machine came back.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How much of a file SSH.NET reads or writes per SFTP request.
|
/// How much of a file SSH.NET reads or writes per SFTP request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -47,7 +75,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
var client = new SshClient(BuildConnectionInfo(request));
|
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
|
||||||
|
|
||||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
@@ -67,7 +95,14 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
||||||
|
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
||||||
|
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
||||||
|
var client = new SftpClient(BuildConnectionInfo(request))
|
||||||
|
{
|
||||||
|
BufferSize = SftpBufferSize,
|
||||||
|
KeepAliveInterval = KeepAliveInterval,
|
||||||
|
};
|
||||||
|
|
||||||
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
// 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
|
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
||||||
@@ -327,7 +362,7 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
|||||||
effective.PixelHeight,
|
effective.PixelHeight,
|
||||||
4096);
|
4096);
|
||||||
|
|
||||||
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell));
|
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -339,10 +374,96 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An SSH.NET-backed shell session.</summary>
|
/// <summary>An SSH.NET-backed shell session.</summary>
|
||||||
internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
|
internal sealed class SshNetShellSession : ISshShellSession
|
||||||
{
|
{
|
||||||
|
private readonly ShellStream shell;
|
||||||
|
private readonly SshClient client;
|
||||||
|
|
||||||
|
private string? transportFailure;
|
||||||
|
|
||||||
|
/// <param name="shell">The channel this shell reads and writes.</param>
|
||||||
|
/// <param name="client">
|
||||||
|
/// The connection underneath it, subscribed to for the one thing the stream cannot report on its own —
|
||||||
|
/// see <see cref="TransportFailure"/>.
|
||||||
|
/// </param>
|
||||||
|
internal SshNetShellSession(ShellStream shell, SshClient client)
|
||||||
|
{
|
||||||
|
this.shell = shell;
|
||||||
|
this.client = client;
|
||||||
|
|
||||||
|
client.ErrorOccurred += OnTransportError;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsOpen => shell.CanRead;
|
/// <remarks>
|
||||||
|
/// <c>CanWrite</c> rather than <c>CanRead</c>, which is what this used to read and which is a constant:
|
||||||
|
/// <c>ShellStream.CanRead</c> is documented as always true — it stays readable after disposal so that
|
||||||
|
/// buffered output can still be drained — so this property answered "yes" for every shell that had ever
|
||||||
|
/// existed. <c>CanWrite</c> is the one that goes false when the stream is disposed, which is what closing
|
||||||
|
/// the channel and losing the session both do.
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsOpen => shell.CanWrite;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Written on SSH.NET's message-listener thread and read on the pump's, which is why it is a
|
||||||
|
/// <see cref="Volatile"/> pair rather than a plain field: the two threads are ordered by the wake-up the
|
||||||
|
/// interface's own remark describes, but nothing in the language says so, and a torn or cached read here
|
||||||
|
/// would report a clean exit for a connection that had just died.
|
||||||
|
/// </remarks>
|
||||||
|
public string? TransportFailure => Volatile.Read(ref transportFailure);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Latches the first transport failure and ends the read that is waiting on a connection which is
|
||||||
|
/// no longer there.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>THE SECOND HALF OF THIS IS WHY A REBOOTED MACHINE USED TO HANG FOR EVER, AND IT IS A DEFECT IN
|
||||||
|
/// SSH.NET RATHER THAN IN ANYTHING ABOVE IT.</b> <c>ShellStream</c> ends a blocked read from exactly two
|
||||||
|
/// places: its channel being closed, and the session raising <c>Disconnected</c>. A shell that exits
|
||||||
|
/// gives the first. But <c>Session</c> raises <c>Disconnected</c> from one method only —
|
||||||
|
/// <c>OnDisconnectReceived</c>, for a server that politely says goodbye — and a socket that simply fails
|
||||||
|
/// goes down a different path: it raises the error event, sets its disconnecting flag and disposes the
|
||||||
|
/// socket, without ever raising <c>Disconnected</c>. So the stream was never disposed, its
|
||||||
|
/// <c>Monitor.Wait</c> was never pulsed, and the read blocked until the process ended. The transport was
|
||||||
|
/// gone, the client knew the transport was gone, and the terminal went on looking connected.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Keep-alive is what makes the failure <em>happen</em> on an idle connection; this is what makes it
|
||||||
|
/// <em>arrive</em>. Neither is sufficient alone, which is why both are in this file.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Queued rather than disposed inline, and that is not tidiness.</b> This runs on SSH.NET's own
|
||||||
|
/// message-listener thread. Closing a channel waits for the server's close to come back — which is
|
||||||
|
/// processed by that same thread — so disposing here would be the listener thread waiting on itself, and
|
||||||
|
/// the best case is that it unwinds when the wait times out.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The latch is set before the queue, so <see cref="TransportFailure"/> is already true by the time the
|
||||||
|
/// read it releases can look at it. That ordering is the whole of the promise this property makes.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnTransportError(object? sender, ExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
|
||||||
|
|
||||||
|
ThreadPool.UnsafeQueueUserWorkItem(
|
||||||
|
static state =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
state.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// Best-effort. The connection is already gone; what matters is that the read was
|
||||||
|
// released, and a fault tearing down a dead channel has no caller to report it to.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shell,
|
||||||
|
preferLocal: false);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -388,6 +509,10 @@ internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
// Unsubscribed before the stream goes, so a connection that outlives this shell — the workspace
|
||||||
|
// disposes the two in that order — is not still holding a reference to it through its own event.
|
||||||
|
client.ErrorOccurred -= OnTransportError;
|
||||||
|
|
||||||
await shell.DisposeAsync().ConfigureAwait(false);
|
await shell.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ public interface ITerminalTransport
|
|||||||
ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken);
|
ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How one session ended, and the sentence that says so.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="End">Which of the three ways it was.</param>
|
||||||
|
/// <param name="Description">
|
||||||
|
/// What to show a person. Written into the pane by the renderer, and available to the shell beside the
|
||||||
|
/// <paramref name="End"/> it belongs to.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The pair travels together because either alone is not enough. The enum is what code branches on — whether
|
||||||
|
/// to offer a reconnect, whether a tab is worth keeping — and the sentence is the detail underneath it, which
|
||||||
|
/// for a lost connection is the only place the actual cause appears.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record TerminalSessionOutcome(SshSessionEnd End, string Description);
|
||||||
|
|
||||||
/// <summary>Tuning for one session's output path.</summary>
|
/// <summary>Tuning for one session's output path.</summary>
|
||||||
public sealed class TerminalPumpOptions
|
public sealed class TerminalPumpOptions
|
||||||
{
|
{
|
||||||
@@ -100,6 +115,24 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||||
public long FramesSent { get; private set; }
|
public long FramesSent { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How this session ended and what to say about it, once <see cref="RunAsync"/> has returned.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Meaningless before the run completes, and deliberately not nullable for it: a session that has not
|
||||||
|
/// ended is not a session that ended in an unknown way, and every caller here already knows whether the
|
||||||
|
/// run is over — the workspace awaits it before reading this.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The same description that goes to the renderer in the <see cref="TerminalServerOpcode.SessionClosed"/>
|
||||||
|
/// frame, so the line written into the pane and whatever the shell draws around the tab are the one
|
||||||
|
/// sentence rather than two that can disagree.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public TerminalSessionOutcome Outcome { get; private set; } =
|
||||||
|
new(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs until the remote closes the channel or the token is cancelled.
|
/// Runs until the remote closes the channel or the token is cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -119,21 +152,36 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
var reader = ReadLoopAsync(linked.Token);
|
var reader = ReadLoopAsync(linked.Token);
|
||||||
var flusher = FlushLoopAsync(linked.Token);
|
var flusher = FlushLoopAsync(linked.Token);
|
||||||
|
|
||||||
string reason;
|
TerminalSessionOutcome outcome;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await reader.ConfigureAwait(false);
|
await reader.ConfigureAwait(false);
|
||||||
reason = "The remote closed the session.";
|
|
||||||
|
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
||||||
|
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
||||||
|
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
||||||
|
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
||||||
|
outcome = session.TransportFailure is { Length: > 0 } failure
|
||||||
|
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
||||||
|
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
reason = "The session was closed.";
|
outcome = new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
reason = exception.Message;
|
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
||||||
|
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
||||||
|
outcome = new TerminalSessionOutcome(
|
||||||
|
SshSessionEnd.ConnectionLost,
|
||||||
|
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before the drain and the last frame, so that anything reading this off a completed run — the
|
||||||
|
// workspace's own announcement — cannot see the default.
|
||||||
|
Outcome = outcome;
|
||||||
|
|
||||||
// Stop the flusher, but only after draining what the reader already produced — the last thing
|
// Stop the flusher, but only after draining what the reader already produced — the last thing
|
||||||
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
||||||
// crash.
|
// crash.
|
||||||
@@ -150,7 +198,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
|
|
||||||
await SendAsync(
|
await SendAsync(
|
||||||
TerminalServerOpcode.SessionClosed,
|
TerminalServerOpcode.SessionClosed,
|
||||||
Encoding.UTF8.GetBytes(reason),
|
Encoding.UTF8.GetBytes(outcome.Description),
|
||||||
CancellationToken.None)
|
CancellationToken.None)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,21 @@ public sealed class TerminalWorkspaceOptions
|
|||||||
|
|
||||||
/// <summary>A session whose shell has ended.</summary>
|
/// <summary>A session whose shell has ended.</summary>
|
||||||
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
|
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
|
||||||
public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
|
/// <param name="outcome">How it ended, and the sentence that says so.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>The outcome is here because a subscriber cannot work it out afterwards.</b> By the time this event
|
||||||
|
/// is raised the session is over: there is no transport left to interrogate, and the difference between a
|
||||||
|
/// shell that exited and a connection that broke is exactly the difference between a tab that is finished
|
||||||
|
/// and a tab worth offering to dial again. It used to carry the id alone, so the only place that difference
|
||||||
|
/// existed was a line of grey text inside the WebView.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TerminalSessionEndedEventArgs(uint sessionId, TerminalSessionOutcome outcome) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>The session that ended.</summary>
|
/// <summary>The session that ended.</summary>
|
||||||
public uint SessionId { get; } = sessionId;
|
public uint SessionId { get; } = sessionId;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="TerminalSessionOutcome" />
|
||||||
|
public TerminalSessionOutcome Outcome { get; } = outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -108,6 +119,10 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly byte[] ReplayMarker = [1];
|
private static readonly byte[] ReplayMarker = [1];
|
||||||
|
|
||||||
|
/// <summary>What <see cref="CloseSessionAsync"/> announces, whatever the pump made of the same end.</summary>
|
||||||
|
private static readonly TerminalSessionOutcome ClosedByThisClient =
|
||||||
|
new(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||||
|
|
||||||
private uint nextSessionId = 1;
|
private uint nextSessionId = 1;
|
||||||
private Task? server;
|
private Task? server;
|
||||||
private int disposed;
|
private int disposed;
|
||||||
@@ -434,7 +449,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
|
|
||||||
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
|
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
|
||||||
// so the containment check inside it can never run against a dictionary the session had not reached.
|
// so the containment check inside it can never run against a dictionary the session had not reached.
|
||||||
_ = AnnounceEndedAsync(sessionId, run);
|
_ = AnnounceEndedAsync(sessionId, pump, run);
|
||||||
|
|
||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
@@ -611,9 +626,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
|
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
|
||||||
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
|
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
|
||||||
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
|
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
|
||||||
|
//
|
||||||
|
// Reported as this client's own doing whatever the pump made of it. A shell that had already ended on
|
||||||
|
// its own before the tab was closed still carries that older reason, and re-announcing it here as
|
||||||
|
// though it had just happened is how closing a tab comes to look like a dropped connection.
|
||||||
if (Volatile.Read(ref disposed) == 0)
|
if (Volatile.Read(ref disposed) == 0)
|
||||||
{
|
{
|
||||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, ClosedByThisClient));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,6 +762,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||||
|
/// <param name="sessionId">The session, as the renderer knows it.</param>
|
||||||
|
/// <param name="pump">
|
||||||
|
/// The pump, for its <see cref="TerminalSessionPump.Outcome"/> — which is only settled once
|
||||||
|
/// <paramref name="run"/> has completed, and is therefore read after the await below rather than captured
|
||||||
|
/// alongside it.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="run">The session's run task.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
|
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
|
||||||
@@ -758,7 +784,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
|
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task AnnounceEndedAsync(uint sessionId, Task run)
|
private async Task AnnounceEndedAsync(uint sessionId, TerminalSessionPump pump, Task run)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -779,7 +805,9 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
|
|
||||||
if (announce)
|
if (announce)
|
||||||
{
|
{
|
||||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
// Read after the await, which is the only point at which the pump has settled it — see
|
||||||
|
// TerminalSessionPump.Outcome.
|
||||||
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Every shell this factory has opened, in order.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Exists so a test can reach one and drop it — see <see cref="FakeSshShellSession.Drop"/>. There was no
|
||||||
|
/// reason to hold them while the only ending a session had was the tab being closed, which the shell can
|
||||||
|
/// ask for directly.
|
||||||
|
/// </remarks>
|
||||||
|
internal List<FakeSshShellSession> Shells { get; } = [];
|
||||||
|
|
||||||
/// <summary>Requests for a file-transfer session, in order.</summary>
|
/// <summary>Requests for a file-transfer session, in order.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
|
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
|
||||||
@@ -65,7 +73,7 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
||||||
progress?.Report(SshConnectionPhase.Authenticating);
|
progress?.Report(SshConnectionPhase.Authenticating);
|
||||||
|
|
||||||
return new FakeSshConnection(request);
|
return new FakeSshConnection(request, Shells);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The file-transfer session this factory last handed out, or null when it never has.</summary>
|
/// <summary>The file-transfer session this factory last handed out, or null when it never has.</summary>
|
||||||
@@ -188,7 +196,8 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
|
internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeSshShellSession> shells)
|
||||||
|
: ISshConnection
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsConnected { get; private set; } = true;
|
public bool IsConnected { get; private set; } = true;
|
||||||
@@ -203,8 +212,13 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(
|
public Task<ISshShellSession> OpenShellAsync(
|
||||||
TerminalSize size,
|
TerminalSize size,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken)
|
||||||
Task.FromResult<ISshShellSession>(new FakeSshShellSession());
|
{
|
||||||
|
var shell = new FakeSshShellSession();
|
||||||
|
shells.Add(shell);
|
||||||
|
|
||||||
|
return Task.FromResult<ISshShellSession>(shell);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
@@ -214,11 +228,11 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A shell that is open, silent and never closes on its own.</summary>
|
/// <summary>A shell that is open, silent and closes only when it is told to.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
|
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
|
||||||
/// channel, which would end the session the moment it was opened and make the test assert against a
|
/// channel, which would end the session the moment it was opened and make the test assert against a
|
||||||
/// connection that had already gone.
|
/// connection that had already gone. <see cref="Drop"/> and <see cref="Exit"/> are the two ways out.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class FakeSshShellSession : ISshShellSession
|
internal sealed class FakeSshShellSession : ISshShellSession
|
||||||
{
|
{
|
||||||
@@ -227,13 +241,43 @@ internal sealed class FakeSshShellSession : ISshShellSession
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsOpen { get; private set; } = true;
|
public bool IsOpen { get; private set; } = true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? TransportFailure { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends this shell the way a rebooted machine does: the read stops, with a transport failure latched.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Latched before the read is released, which is the ordering the real session promises and the whole
|
||||||
|
/// reason the distinction is race-free — see <see cref="ISshShellSession.TransportFailure"/>. A fake
|
||||||
|
/// that set it afterwards would be testing a guarantee the product does not make.
|
||||||
|
/// </remarks>
|
||||||
|
internal void Drop(string reason)
|
||||||
|
{
|
||||||
|
TransportFailure = reason;
|
||||||
|
closed.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ends this shell the way <c>exit</c> does: the read stops, with nothing wrong underneath.</summary>
|
||||||
|
internal void Exit() => closed.Cancel();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
try
|
||||||
cancellationToken, closed.Token);
|
{
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, closed.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// This shell ending, not the pump being asked to stop. Reported as end of stream rather than
|
||||||
|
// rethrown, because that is what a real read does for both of the endings above: the channel
|
||||||
|
// stops producing bytes, and what happened to the transport is told by the latch, not by an
|
||||||
|
// exception coming out of here.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1126,6 +1126,270 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
||||||
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
||||||
|
|
||||||
|
// ---- A session that stops: the status it gets, and the way back ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THE REBOOT CASE, as far as this shell can see it. A machine going away is a transport failure
|
||||||
|
/// latched under a read that then reports the end of the shell — see <c>ISshShellSession.TransportFailure</c>
|
||||||
|
/// — and what has to come of it here is that the tab stops claiming to be connected and says which of the
|
||||||
|
/// two endings it was.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The words are asserted rather than only the flags. DISCONNECTED against SHELL EXITED is the whole
|
||||||
|
/// difference a user reads, and a status bar that had a bool but printed the same thing for both would
|
||||||
|
/// pass a test written against <c>IsLive</c> alone.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASessionWhoseConnectionBreaks_SaysSoAndOffersTheWayBack()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
tab.IsLive.ShouldBeTrue();
|
||||||
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
|
|
||||||
|
ssh.Shells.ShouldHaveSingleItem().Drop("An established connection was aborted by the server.");
|
||||||
|
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
tab.EndReason.ShouldBe(SshSessionEnd.ConnectionLost);
|
||||||
|
tab.IsDropped.ShouldBeTrue("the strip draws a dropped connection differently from a clean exit");
|
||||||
|
|
||||||
|
shell.IsSessionConnected.ShouldBeFalse();
|
||||||
|
shell.SessionEndedText.ShouldBe("DISCONNECTED");
|
||||||
|
shell.SessionEndedDetail.ShouldBe("An established connection was aborted by the server.");
|
||||||
|
shell.IsSessionDropped.ShouldBeTrue();
|
||||||
|
shell.IsSessionIdle.ShouldBeFalse("NOT CONNECTED must not be drawn beside a named ending");
|
||||||
|
|
||||||
|
shell.CanReconnectSelectedTab.ShouldBeTrue("the host is still in the keychain to dial again");
|
||||||
|
|
||||||
|
// The pane stays, which is the whole reason a dead tab is kept: the last thing the remote said is
|
||||||
|
// usually why it went.
|
||||||
|
tab.HasSession.ShouldBeTrue();
|
||||||
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ THE <c>exit</c> CASE, and the reason it is a separate test from the one above rather than an extra
|
||||||
|
/// assertion inside it: the two differ by a latched transport failure and nothing else, all the way down
|
||||||
|
/// to the read that ends them, and the point is that they do not report the same thing. A shell somebody
|
||||||
|
/// exited is work finished — grey, no alarm — and calling it a dropped connection would be crying wolf on
|
||||||
|
/// the ordinary end of every session.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AShellThatExits_SaysThatRatherThanClaimingTheConnectionBroke()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
ssh.Shells.ShouldHaveSingleItem().Exit();
|
||||||
|
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the exiting shell should have ended the session");
|
||||||
|
|
||||||
|
tab.EndReason.ShouldBe(SshSessionEnd.RemoteClosed);
|
||||||
|
tab.IsDropped.ShouldBeFalse();
|
||||||
|
|
||||||
|
shell.SessionEndedText.ShouldBe("SHELL EXITED");
|
||||||
|
shell.IsSessionDropped.ShouldBeFalse();
|
||||||
|
|
||||||
|
// Still worth offering, because the tab and the machine are both still there — what it is not is an
|
||||||
|
// alarm.
|
||||||
|
shell.CanReconnectSelectedTab.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The tab is reused rather than a second one appearing beside it. That is what reconnecting means to
|
||||||
|
/// somebody who pressed the button: the terminal they were working in comes back, in the place it was.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The old session is released as part of it, which the assertion on the connection's disposal stands
|
||||||
|
/// for. 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. Leaking one of each per reconnect would be invisible until a working day's worth of them ran
|
||||||
|
/// the renderer out.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconnectingADroppedTab_DialsAgainInTheSameTab()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
// Bound to a key, which is what makes this host redialable without anybody present: a host that
|
||||||
|
// wants a password typed is diverted to the keychain instead, and has a test of its own below.
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
var firstSession = tab.SessionId;
|
||||||
|
|
||||||
|
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||||
|
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(tab, "a reconnect reuses the tab it was asked for");
|
||||||
|
|
||||||
|
tab.IsLive.ShouldBeTrue();
|
||||||
|
tab.SessionId.ShouldNotBe(firstSession, "a reconnect is a new session, not the old one resumed");
|
||||||
|
tab.EndReason.ShouldBeNull("the ending belonged to the session that has been replaced");
|
||||||
|
|
||||||
|
shell.SessionEndedText.ShouldBeNull();
|
||||||
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
|
shell.SelectedTab.ShouldBe(tab);
|
||||||
|
|
||||||
|
ssh.Requests.Count.ShouldBe(2, "the vault's own connect path was used, not a second one");
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => !workspace.IsSessionLive(firstSession),
|
||||||
|
"the session that was replaced should have been released with its pane");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
||||||
|
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
|
||||||
|
/// The card's button is the same command, which is what makes this worth asserting here rather than
|
||||||
|
/// trusting to two code paths that happen to agree today.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARefusedConnection_CanBeRetriedInItsOwnTab()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
ssh.Failure = new IOException("The host did not answer.");
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
tab.IsFailed.ShouldBeTrue(tab.Status);
|
||||||
|
shell.CanReconnectSelectedTab.ShouldBeTrue();
|
||||||
|
|
||||||
|
ssh.Failure = null;
|
||||||
|
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(tab);
|
||||||
|
tab.IsLive.ShouldBeTrue();
|
||||||
|
tab.HasSession.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A host that wants a password typed cannot be redialled on its own: this client does not keep the
|
||||||
|
/// password, so dialling with an empty box would be an authentication failure dressed up as a reconnect.
|
||||||
|
/// It goes to the keychain with the password sheet raised instead, which is the one screen that sheet is
|
||||||
|
/// drawn on.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The tab is deliberately left as it was rather than put back to connecting. Nothing has been dialled
|
||||||
|
/// yet — it waits on somebody typing — and a tab that had thrown away its ending and its pane to sit on a
|
||||||
|
/// step list nothing was advancing would be the worse of the two wrongs.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconnectingAHostThatWantsAPassword_AsksForOneRatherThanDiallingWithout()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
vault.IsAskingForConnectPassword.ShouldBeTrue();
|
||||||
|
shell.Screen.ShouldBe(ShellScreen.Hosts, "the password sheet is drawn there and nowhere else");
|
||||||
|
|
||||||
|
ssh.Requests.ShouldHaveSingleItem("nothing should have been dialled without the password");
|
||||||
|
tab.HasSession.ShouldBeTrue("the tab keeps its pane while it waits on somebody typing");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A machine reached by typing an address has no keychain entry, so there is nothing to dial again with —
|
||||||
|
/// this client would need the password, which it does not keep. The button is drawn disabled rather than
|
||||||
|
/// hidden, so the refusal has to be a fact the shell states rather than an absence.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ATabWhoseHostIsNotInTheKeychain_IsNotOfferedAReconnect()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
// The host leaves, as deleting it or locking the vault would take it. The tab outlives the vault
|
||||||
|
// that opened it, which is why this state is reachable at all.
|
||||||
|
vault.Hosts.Clear();
|
||||||
|
|
||||||
|
shell.CanReconnectSelectedTab.ShouldBeFalse();
|
||||||
|
|
||||||
|
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
ssh.Requests.ShouldHaveSingleItem("nothing should have been dialled");
|
||||||
|
|
||||||
|
// And it says so where the button that was pressed is, rather than only in the vault's own status on
|
||||||
|
// a screen this surface is not.
|
||||||
|
shell.SessionEndedDetail.ShouldNotBeNull().ShouldContain("not in this keychain");
|
||||||
|
shell.SessionEndedText.ShouldBe("DISCONNECTED", "the ending it is still in is unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other way a reconnect can come to nothing: the host is still there, but what it authenticates
|
||||||
|
/// with is not. The vault refuses before it dials — which is the whole reason a reconnect goes through
|
||||||
|
/// the vault's own connect path rather than reaching past it — and the refusal has to arrive where the
|
||||||
|
/// button was pressed, because the vault draws its own status on the keychain screen.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AReconnectTheVaultRefusesBeforeDialling_SaysWhyOnTheTab()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
// The key the host is bound to leaves, which is a binding that no longer resolves.
|
||||||
|
vault.Keys.Clear();
|
||||||
|
|
||||||
|
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
ssh.Requests.ShouldHaveSingleItem("a binding that cannot be resolved must not be dialled with");
|
||||||
|
|
||||||
|
shell.SessionEndedDetail.ShouldNotBeNull().ShouldContain("SSH key");
|
||||||
|
tab.HasSession.ShouldBeTrue("the tab keeps the pane it had; there was nothing to replace it with");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
||||||
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using DodoSSH.Client.Terminal;
|
using DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
@@ -34,6 +36,253 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
|||||||
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
|
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>exit</c> against a real sshd is reported as the remote closing the session, not as a lost
|
||||||
|
/// connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THIS IS THE TEST THE RACE WOULD FAIL. The obvious way to tell an ordinary exit from a dropped
|
||||||
|
/// connection is to wait for the read to end and then ask whether the transport is still up — and against
|
||||||
|
/// a real server it is wrong most of the time, because this was the only channel on the connection and
|
||||||
|
/// sshd hangs up within a millisecond of closing it. By the time anything here could ask, the transport
|
||||||
|
/// is gone. A fake shell cannot show that: it has no server behind it deciding to disconnect.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// What makes the answer settled instead is the failure being latched when it happens rather than
|
||||||
|
/// inferred afterwards — see <see cref="ISshShellSession.TransportFailure"/>. Nothing latches one here,
|
||||||
|
/// because nothing failed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AShellThatExits_IsReportedAsTheRemoteClosingItRatherThanAsALostConnection()
|
||||||
|
{
|
||||||
|
await using var connection = await ConnectTrustedAsync();
|
||||||
|
|
||||||
|
var shell = await connection.OpenShellAsync(
|
||||||
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var transport = new CountingTransport();
|
||||||
|
|
||||||
|
await using var pump = new TerminalSessionPump(
|
||||||
|
1,
|
||||||
|
shell,
|
||||||
|
transport,
|
||||||
|
TimeProvider.System,
|
||||||
|
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
|
||||||
|
|
||||||
|
transport.Pump = pump;
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await pump.WriteInputAsync("exit\n"u8.ToArray(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// No disposal and no cancellation: the run has to end because the shell did, which is the whole
|
||||||
|
// point. A timeout here would mean the session hangs on `exit`.
|
||||||
|
await run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
pump.Outcome.End.ShouldBe(
|
||||||
|
SshSessionEnd.RemoteClosed,
|
||||||
|
$"exit is not a dropped connection; got <{pump.Outcome.Description}>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A transport that goes away under a live shell is reported as a lost connection, and the read does not
|
||||||
|
/// hang.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THE REBOOT CASE, produced without rebooting anything. The connection is dialled through a plain TCP
|
||||||
|
/// relay this test owns and then the relay is torn down, which takes the socket out from under a live
|
||||||
|
/// shell exactly as a machine going down does. The container is shared by every test in this collection,
|
||||||
|
/// so killing <c>sshd</c> itself was never an option; killing something this test owns is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What is not reproduced here is the waiting.</b> A relay that is closed sends a FIN, so the socket
|
||||||
|
/// reports itself at once; a machine that has been switched off sends nothing at all, and finding that
|
||||||
|
/// out requires writing to it — which is what the keep-alive in <see cref="SshNetConnectionFactory"/>
|
||||||
|
/// exists to do, and which no test can hurry along without a clock this code does not own. So this covers
|
||||||
|
/// the half that can be checked, and it is the half everything above the socket depends on: a transport
|
||||||
|
/// that has failed is <em>reported as one</em> rather than being called an ordinary exit or leaving the
|
||||||
|
/// read blocked for ever.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A relay rather than the SOCKS forward <c>LoopbackProxyTests</c> uses, which was the first attempt:
|
||||||
|
/// disconnecting the carrier of a <c>ForwardedPortDynamic</c> does not close the loopback socket it
|
||||||
|
/// accepted, so the inner connection was left holding a socket with nothing behind it and the read did
|
||||||
|
/// not return within thirty seconds. That is a property of SSH.NET's forwarder rather than of anything
|
||||||
|
/// this repository ships, and it is not what a dying machine looks like.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AShellWhoseTransportDies_IsReportedAsALostConnection()
|
||||||
|
{
|
||||||
|
using var relay = new TcpRelay(fixture.Host, fixture.Port);
|
||||||
|
|
||||||
|
await using var connection = await ConnectThroughAsync(relay);
|
||||||
|
|
||||||
|
var shell = await connection.OpenShellAsync(
|
||||||
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var transport = new CountingTransport();
|
||||||
|
|
||||||
|
await using var pump = new TerminalSessionPump(
|
||||||
|
1,
|
||||||
|
shell,
|
||||||
|
transport,
|
||||||
|
TimeProvider.System,
|
||||||
|
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
|
||||||
|
|
||||||
|
transport.Pump = pump;
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// The shell is up and talking before anything is broken, so what follows is a live session losing its
|
||||||
|
// transport rather than one that never started.
|
||||||
|
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
|
while (TimeProvider.System.GetUtcNow() < deadline && pump.BytesRead == 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
pump.BytesRead.ShouldBeGreaterThan(0, "the shell should have produced a prompt before the relay goes");
|
||||||
|
|
||||||
|
// And the ground goes out from under it.
|
||||||
|
relay.Kill();
|
||||||
|
|
||||||
|
// No disposal and no cancellation: the run has to end because the transport did.
|
||||||
|
await run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
pump.Outcome.End.ShouldBe(
|
||||||
|
SshSessionEnd.ConnectionLost,
|
||||||
|
$"a transport that died is not a shell that exited; got <{pump.Outcome.Description}>");
|
||||||
|
|
||||||
|
pump.Outcome.Description.ShouldNotBeNullOrWhiteSpace(
|
||||||
|
"the strip under the terminal prints this sentence, so an empty one is a blank status line");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Trusts the relay's identity, then connects through it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
|
||||||
|
/// transport dying, and this store has one entry in it either way.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<ISshConnection> ConnectThroughAsync(TcpRelay relay)
|
||||||
|
{
|
||||||
|
var knownHosts = new InMemoryKnownHostStore();
|
||||||
|
var factory = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
|
var request = new SshConnectionRequest(
|
||||||
|
"127.0.0.1",
|
||||||
|
relay.Port,
|
||||||
|
SshServerFixture.Username,
|
||||||
|
new SshPasswordCredential(SshServerFixture.Password));
|
||||||
|
|
||||||
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
|
await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A loopback TCP forwarder that can be cut, standing in for a machine that goes away.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately dumb: it accepts one connection, opens one to the far end and copies bytes both ways
|
||||||
|
/// until something stops. SSH is opaque to it, which is the point — what is being simulated is the wire
|
||||||
|
/// disappearing, and a relay that understood the protocol could not simulate that without also deciding
|
||||||
|
/// how to say goodbye.
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class TcpRelay : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TcpListener listener;
|
||||||
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
private readonly List<TcpClient> sockets = [];
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
internal TcpRelay(string host, int port)
|
||||||
|
{
|
||||||
|
listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
|
||||||
|
_ = AcceptAsync(host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The loopback port to dial instead of the far end's.</summary>
|
||||||
|
internal int Port { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops every socket, in both directions, without warning either end first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Disposing the sockets is what closes them, and closing is what puts a FIN on the wire. Nothing
|
||||||
|
/// here speaks to the SSH session running over it, which is the whole idea: this is the network
|
||||||
|
/// going away, not a disconnect being negotiated.
|
||||||
|
/// </remarks>
|
||||||
|
internal void Kill()
|
||||||
|
{
|
||||||
|
lifetime.Cancel();
|
||||||
|
listener.Stop();
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
foreach (var socket in sockets)
|
||||||
|
{
|
||||||
|
socket.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
sockets.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Kill();
|
||||||
|
lifetime.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptAsync(string host, int port)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!lifetime.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var inbound = await listener.AcceptTcpClientAsync(lifetime.Token).ConfigureAwait(false);
|
||||||
|
var outbound = new TcpClient();
|
||||||
|
|
||||||
|
await outbound.ConnectAsync(host, port, lifetime.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
sockets.Add(inbound);
|
||||||
|
sockets.Add(outbound);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = CopyAsync(inbound, outbound);
|
||||||
|
_ = CopyAsync(outbound, inbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// Killed, or the listener stopped. There is no caller on this thread to hand anything to.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyAsync(TcpClient from, TcpClient to)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await from.GetStream().CopyToAsync(to.GetStream(), lifetime.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// Either end going is the ordinary way this ends, and is what the test is arranging.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ThePump_ForwardsRealShellOutput()
|
public async Task ThePump_ForwardsRealShellOutput()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsOpen { get; private set; } = true;
|
public bool IsOpen { get; private set; } = true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Settable, because it is the whole of what tells a shell that exited apart from one whose connection
|
||||||
|
/// broke — a test sets it for the second case and leaves it null for the first, which is exactly what the
|
||||||
|
/// real session's latch does off SSH.NET's error event.
|
||||||
|
/// </remarks>
|
||||||
|
public string? TransportFailure { get; set; }
|
||||||
|
|
||||||
/// <summary>Reads issued against this session, to detect a pump that kept reading.</summary>
|
/// <summary>Reads issued against this session, to detect a pump that kept reading.</summary>
|
||||||
public int ReadCount { get; private set; }
|
public int ReadCount { get; private set; }
|
||||||
|
|
||||||
@@ -133,6 +141,9 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
|
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
|
||||||
: ISshConnectionFactory
|
: ISshConnectionFactory
|
||||||
{
|
{
|
||||||
|
/// <summary>Armed on every shell handed out, so a workspace-level test can produce a dropped connection.</summary>
|
||||||
|
internal string? TransportFailure { get; set; }
|
||||||
|
|
||||||
/// <summary>Connections handed out, in order.</summary>
|
/// <summary>Connections handed out, in order.</summary>
|
||||||
internal List<FakeConnection> Connections { get; } = [];
|
internal List<FakeConnection> Connections { get; } = [];
|
||||||
|
|
||||||
@@ -148,7 +159,11 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue,
|
|||||||
progress?.Report(SshConnectionPhase.Reaching);
|
progress?.Report(SshConnectionPhase.Reaching);
|
||||||
progress?.Report(SshConnectionPhase.Authenticating);
|
progress?.Report(SshConnectionPhase.Authenticating);
|
||||||
|
|
||||||
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
|
var connection = new FakeConnection(request, bytesPerShell, blockShellReads)
|
||||||
|
{
|
||||||
|
TransportFailure = TransportFailure,
|
||||||
|
};
|
||||||
|
|
||||||
Connections.Add(connection);
|
Connections.Add(connection);
|
||||||
|
|
||||||
return Task.FromResult<ISshConnection>(connection);
|
return Task.FromResult<ISshConnection>(connection);
|
||||||
@@ -172,13 +187,17 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
|||||||
/// <summary>The shell this connection opened, if it opened one.</summary>
|
/// <summary>The shell this connection opened, if it opened one.</summary>
|
||||||
internal FakeShellSession? Shell { get; private set; }
|
internal FakeShellSession? Shell { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ISshShellSession.TransportFailure" />
|
||||||
|
/// <remarks>Handed to whatever shell this connection opens, so the arming happens before the pump runs.</remarks>
|
||||||
|
internal string? TransportFailure { get; init; }
|
||||||
|
|
||||||
/// <summary>Whether the connection was disposed, which is what closing a session must do.</summary>
|
/// <summary>Whether the connection was disposed, which is what closing a session must do.</summary>
|
||||||
internal bool IsDisposed { get; private set; }
|
internal bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Shell = new FakeShellSession(bytesPerShell, blockShellReads);
|
Shell = new FakeShellSession(bytesPerShell, blockShellReads) { TransportFailure = TransportFailure };
|
||||||
|
|
||||||
return Task.FromResult<ISshShellSession>(Shell);
|
return Task.FromResult<ISshShellSession>(Shell);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,80 @@ public sealed class TerminalSessionPumpTests
|
|||||||
System.Text.Encoding.UTF8.GetString(payload).ShouldNotBeNullOrWhiteSpace();
|
System.Text.Encoding.UTF8.GetString(payload).ShouldNotBeNullOrWhiteSpace();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>exit</c>, as the pump sees it: the read loop runs out and nothing latched a transport failure on
|
||||||
|
/// the way. This is the ending that must not be reported as a dropped connection, because it is the
|
||||||
|
/// ordinary end of a session and there is nothing wrong with the machine.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task WhenTheShellExits_TheOutcomeIsTheRemoteClosingIt()
|
||||||
|
{
|
||||||
|
await using var session = new FakeShellSession(bytesToProduce: 32);
|
||||||
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
|
await using var pump = CreatePump(session, transport);
|
||||||
|
transport.AutoAcknowledge = pump;
|
||||||
|
|
||||||
|
await pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
pump.Outcome.End.ShouldBe(SshSessionEnd.RemoteClosed);
|
||||||
|
pump.Outcome.Description.ShouldBe("The remote closed the session.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ A reboot, as the pump sees it. The shell's read ends exactly as it does for <c>exit</c> — there is
|
||||||
|
/// no separate signal on that path — so the only thing telling the two apart is the failure the session
|
||||||
|
/// latched off the transport before the read was woken. Without it, a machine that went away is reported
|
||||||
|
/// as somebody having typed <c>exit</c>, which is the state this whole wave exists to stop drawing.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task WhenTheTransportFailed_TheOutcomeIsALostConnectionCarryingWhatFailed()
|
||||||
|
{
|
||||||
|
await using var session = new FakeShellSession(bytesToProduce: 32)
|
||||||
|
{
|
||||||
|
TransportFailure = "An established connection was aborted by the server.",
|
||||||
|
};
|
||||||
|
|
||||||
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
|
await using var pump = CreatePump(session, transport);
|
||||||
|
transport.AutoAcknowledge = pump;
|
||||||
|
|
||||||
|
await pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
pump.Outcome.End.ShouldBe(SshSessionEnd.ConnectionLost);
|
||||||
|
pump.Outcome.Description.ShouldBe("An established connection was aborted by the server.");
|
||||||
|
|
||||||
|
// And the renderer is told the same sentence, so the line in the scrollback and whatever the shell
|
||||||
|
// draws around the tab cannot disagree.
|
||||||
|
TerminalFrame.TryRead(transport.Frames[^1], out var opcode, out _, out var payload).ShouldBeTrue();
|
||||||
|
|
||||||
|
opcode.ShouldBe((byte)TerminalServerOpcode.SessionClosed);
|
||||||
|
System.Text.Encoding.UTF8.GetString(payload)
|
||||||
|
.ShouldBe("An established connection was aborted by the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The third ending, and the one nothing draws a status for: this client asked. A tab being closed and a
|
||||||
|
/// reconnect starting both arrive here, and neither is news to whoever caused it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task WhenThisClientEndsIt_TheOutcomeSaysSoRatherThanBlamingTheRemote()
|
||||||
|
{
|
||||||
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
|
var pump = CreatePump(session, transport);
|
||||||
|
transport.AutoAcknowledge = pump;
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await pump.DisposeAsync();
|
||||||
|
await run;
|
||||||
|
|
||||||
|
pump.Outcome.End.ShouldBe(SshSessionEnd.ClosedLocally);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Input_ReachesTheRemote()
|
public async Task Input_ReachesTheRemote()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -344,6 +344,84 @@ public sealed class TerminalWorkspaceTests
|
|||||||
liveAtAnnouncement.ShouldBe(0, "announced after the drain, so the count already excludes it");
|
liveAtAnnouncement.ShouldBe(0, "announced after the drain, so the count already excludes it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ The announcement carries how it ended, not just that it did. A subscriber cannot work that out for
|
||||||
|
/// itself afterwards — by the time it hears, there is no transport left to interrogate — and the
|
||||||
|
/// difference between a shell that exited and a connection that broke is the difference between a tab
|
||||||
|
/// that is finished and one worth offering to dial again.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASessionWhoseConnectionBroke_IsAnnouncedAsALostConnection()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory(bytesPerShell: 0)
|
||||||
|
{
|
||||||
|
TransportFailure = "An established connection was aborted by the server.",
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
TerminalSessionOutcome? outcome = null;
|
||||||
|
workspace.SessionEnded += (_, e) => Volatile.Write(ref outcome, e.Outcome);
|
||||||
|
|
||||||
|
await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => Volatile.Read(ref outcome) is not null);
|
||||||
|
|
||||||
|
var announced = Volatile.Read(ref outcome)!;
|
||||||
|
|
||||||
|
announced.End.ShouldBe(SshSessionEnd.ConnectionLost);
|
||||||
|
announced.Description.ShouldBe("An established connection was aborted by the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The same shell ending with nothing wrong underneath it, which is what <c>exit</c> looks like from
|
||||||
|
/// here. Its own test rather than an assertion tacked onto the one above, because the pair is the point:
|
||||||
|
/// one fake differs from the other by a latched failure and nothing else, and the two must not report
|
||||||
|
/// the same thing.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASessionWhoseShellExited_IsAnnouncedAsTheRemoteClosingIt()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
TerminalSessionOutcome? outcome = null;
|
||||||
|
workspace.SessionEnded += (_, e) => Volatile.Write(ref outcome, e.Outcome);
|
||||||
|
|
||||||
|
await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => Volatile.Read(ref outcome) is not null);
|
||||||
|
|
||||||
|
Volatile.Read(ref outcome)!.End.ShouldBe(SshSessionEnd.RemoteClosed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A tab being closed, announced as this client's own doing however the pump unwound. It matters for the
|
||||||
|
/// shell reusing a tab: a reconnect closes the session it is replacing, and an announcement blaming the
|
||||||
|
/// remote for that would draw a dropped connection over an attempt that is already under way.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingASession_IsAnnouncedAsThisClientsDoing()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory();
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
TerminalSessionOutcome? outcome = null;
|
||||||
|
workspace.SessionEnded += (_, e) => outcome = e.Outcome;
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
|
outcome.ShouldNotBeNull();
|
||||||
|
outcome.End.ShouldBe(SshSessionEnd.ClosedLocally);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
|
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
|
||||||
/// that the SSH connections are known to be released rather than assumed to be — and that these closes,
|
/// that the SSH connections are known to be released rather than assumed to be — and that these closes,
|
||||||
|
|||||||
Reference in new issue
Block a user