From e2733e5cbc0da93ddc95665ce6e937b6f5200acf Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Mon, 24 Aug 2026 10:36:15 +0200 Subject: [PATCH] Notice when a shell ends, say which way it ended, and offer the way back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/DodoSSH.Client.Android/Theme/Phone.axaml | 11 + .../Views/TerminalScreen.axaml | 79 ++++- src/DodoSSH.Client.App/App.axaml | 11 + .../Views/ConnectingCard.axaml | 13 + src/DodoSSH.Client.App/Views/MainWindow.axaml | 12 +- .../Views/SessionEndedBar.axaml | 97 ++++++ .../Views/SessionEndedBar.axaml.cs | 14 + .../Views/SessionStatusBar.axaml | 17 +- .../Views/SessionTabRow.axaml | 8 + .../ViewModels/MainWindowViewModel.cs | 280 +++++++++++++++++- .../ViewModels/TerminalTabViewModel.cs | 211 +++++++++++++ src/DodoSSH.Client.Ssh/SshConnection.cs | 64 ++++ .../SshNetConnectionFactory.cs | 135 ++++++++- .../TerminalSessionPump.cs | 58 +++- .../TerminalWorkspace.cs | 38 ++- tests/DodoSSH.Client.App.Tests/FakeSsh.cs | 62 +++- .../ShellFlowTests.cs | 264 +++++++++++++++++ .../PumpOverRealSshTests.cs | 249 ++++++++++++++++ .../FakeShellSession.cs | 23 +- .../TerminalSessionPumpTests.cs | 74 +++++ .../TerminalWorkspaceTests.cs | 78 +++++ 21 files changed, 1755 insertions(+), 43 deletions(-) create mode 100644 src/DodoSSH.Client.App/Views/SessionEndedBar.axaml create mode 100644 src/DodoSSH.Client.App/Views/SessionEndedBar.axaml.cs diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml index c2c1e26..ee4fcf5 100644 --- a/src/DodoSSH.Client.Android/Theme/Phone.axaml +++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml @@ -450,6 +450,17 @@ + + + + + + + +