Files
DodoSSH/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs
jaap-jan e2733e5cbc
ci / build and test (push) Successful in 3m28s
ci / android head (push) Successful in 4m48s
ci / desktop nightly (push) Successful in 1m17s
ci / api image (push) Successful in 28s
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.
2026-08-24 10:36:15 +02:00

291 lines
10 KiB
C#

using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>A shell session that produces output on demand, for exercising the pump.</summary>
internal sealed class FakeShellSession : ISshShellSession
{
private readonly List<byte> written = [];
private readonly Lock gate = new();
private readonly bool blockReads;
private long remaining;
private byte pattern;
/// <param name="bytesToProduce">
/// How many bytes to emit before reporting end of stream. <see cref="long.MaxValue"/> for an
/// endless producer, which is what a runaway remote process looks like — those sessions are ended
/// by disposing the pump rather than by running out of data.
/// </param>
/// <param name="blockReads">
/// True for a shell that is open and live but has nothing to say — an idle prompt, rather than either
/// end of the "produces bytes" and "hit end of stream" spectrum <paramref name="bytesToProduce"/>
/// covers. <see cref="ReadAsync"/> then blocks until cancelled, which is what a real idle SSH channel's
/// read does. Exists for tests that need a session whose <c>Run</c> stays live without a background
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
/// <c>TerminalWorkspaceTests</c>.
/// </param>
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
{
remaining = bytesToProduce;
this.blockReads = blockReads;
}
/// <inheritdoc />
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>
public int ReadCount { get; private set; }
/// <summary>Bytes the pump wrote toward the remote.</summary>
public byte[] Written
{
get
{
lock (gate)
{
return [.. written];
}
}
}
/// <summary>The last size the pump forwarded, or null if it forwarded none.</summary>
public TerminalSize? LastResize { get; private set; }
/// <summary>How many resizes were forwarded, so a dropped one is observable.</summary>
public int ResizeCount { get; private set; }
/// <inheritdoc />
/// <remarks>
/// Returns 0 once the configured budget is spent, which is what a remote closing the channel looks
/// like. Not synchronous: a fake that never yields would let the pump's read loop monopolise the
/// thread and hide any ordering problem between reading and flushing.
/// </remarks>
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
ReadCount++;
if (blockReads)
{
// Never completes on its own. The only way out is the same way a real blocked read ends: the
// token being cancelled, which is what disposing the pump does.
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
}
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
if (remaining <= 0)
{
return 0;
}
var count = (int)Math.Min(buffer.Length, remaining);
buffer.Span[..count].Fill(unchecked(pattern++));
remaining -= count;
return count;
}
/// <inheritdoc />
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
lock (gate)
{
written.AddRange(data.ToArray());
}
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public void Resize(TerminalSize size)
{
// Mirrors the real session: an unusable size is dropped rather than forwarded.
if (!size.IsUsable)
{
return;
}
ResizeCount++;
LastResize = size;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
IsOpen = false;
remaining = 0;
return ValueTask.CompletedTask;
}
}
/// <summary>
/// Hands out <see cref="FakeShellSession"/>s, so a workspace can be driven with no network.
/// </summary>
/// <remarks>
/// Exists for the session-lifetime tests. Everything else in this suite works on a pump directly; the
/// workspace is the layer that decides when a session is over, and that decision is what needs a
/// connection whose shell can be made to end on cue.
/// </remarks>
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
: 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>
internal List<FakeConnection> Connections { get; } = [];
/// <inheritdoc />
public Task<ISshConnection> ConnectAsync(
SshConnectionRequest request,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
// The real factory's own order, so that a test watching this fake is watching the same sequence a
// real handshake produces. It cannot report CheckingHostKey — there is no key exchange here to
// produce a key — and inventing one would make this the only place that phase came from.
progress?.Report(SshConnectionPhase.Reaching);
progress?.Report(SshConnectionPhase.Authenticating);
var connection = new FakeConnection(request, bytesPerShell, blockShellReads)
{
TransportFailure = TransportFailure,
};
Connections.Add(connection);
return Task.FromResult<ISshConnection>(connection);
}
}
/// <summary>A connection that opens fake shells and records its own disposal.</summary>
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell, bool blockShellReads = false)
: ISshConnection
{
/// <inheritdoc />
public bool IsConnected { get; private set; } = true;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } =
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
/// <inheritdoc />
public string Cipher { get; } = "aes256-gcm@openssh.com";
/// <summary>The shell this connection opened, if it opened one.</summary>
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>
internal bool IsDisposed { get; private set; }
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
Shell = new FakeShellSession(bytesPerShell, blockShellReads) { TransportFailure = TransportFailure };
return Task.FromResult<ISshShellSession>(Shell);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
IsDisposed = true;
IsConnected = false;
return ValueTask.CompletedTask;
}
}
/// <summary>Records frames, and can acknowledge them to keep credit flowing.</summary>
internal sealed class RecordingTransport : ITerminalTransport
{
private readonly List<byte[]> frames = [];
private readonly Lock gate = new();
/// <summary>Set to acknowledge every output frame immediately, as a keeping-up renderer would.</summary>
internal TerminalSessionPump? AutoAcknowledge { get; set; }
/// <summary>Frames sent so far.</summary>
internal IReadOnlyList<byte[]> Frames
{
get
{
lock (gate)
{
return [.. frames];
}
}
}
/// <inheritdoc />
public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
{
var copy = frame.ToArray();
lock (gate)
{
frames.Add(copy);
}
if (AutoAcknowledge is { } pump
&& TerminalFrame.TryRead(copy, out var opcode, out _, out var payload)
&& opcode == (byte)TerminalServerOpcode.Output)
{
pump.Acknowledge((uint)payload.Length);
}
return ValueTask.CompletedTask;
}
/// <summary>Concatenated payloads of every output frame.</summary>
internal byte[] OutputBytes()
{
var output = new List<byte>();
foreach (var frame in Frames)
{
if (TerminalFrame.TryRead(frame, out var opcode, out _, out var payload)
&& opcode == (byte)TerminalServerOpcode.Output)
{
output.AddRange(payload);
}
}
return [.. output];
}
/// <summary>Counts frames of one opcode.</summary>
internal int CountOf(TerminalServerOpcode opcode) =>
Frames.Count(frame =>
TerminalFrame.TryRead(frame, out var actual, out _, out _)
&& actual == (byte)opcode);
}
/// <summary>An <see cref="IProgress{T}"/> that runs its callback on the thread that reported.</summary>
/// <remarks>
/// <c>System.Progress&lt;T&gt;</c> would post to a captured synchronisation context, or to the thread pool
/// when there is none — which is what a test has here — so a list it appended to would be asserted on before
/// it had been written. This is the same reason the shell does not use it either; see
/// <c>VaultViewModel.ReporterFor</c>.
/// </remarks>
internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
{
public void Report(T value) => report(value);
}