using System.Net;
using System.Net.Sockets;
using System.Text;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Ssh.Tests;
///
/// The pump against a real shell, with the transport replaced by a recorder.
///
///
/// Sits between the pump's unit tests, which use a fake shell, and the full end-to-end test, which
/// adds the loopback socket. Its value is diagnostic: when output does not reach a renderer, this
/// says whether the pump and SSH.NET are producing anything at all.
///
[Collection(SshCollection.Name)]
public sealed class PumpOverRealSshTests(SshServerFixture fixture)
{
/// Trusts the container's host key, then connects. First contact is refused by design.
private async Task ConnectTrustedAsync()
{
var knownHosts = new InMemoryKnownHostStore();
var factory = new SshNetConnectionFactory(knownHosts);
var request = new SshConnectionRequest(
fixture.Host,
fixture.Port,
SshServerFixture.Username,
new SshPasswordCredential(SshServerFixture.Password));
var unknown = await Should.ThrowAsync(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);
}
///
/// exit against a real sshd is reported as the remote closing the session, not as a lost
/// connection.
///
///
///
/// ◆ 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.
///
///
/// What makes the answer settled instead is the failure being latched when it happens rather than
/// inferred afterwards — see . Nothing latches one here,
/// because nothing failed.
///
///
[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}>");
}
///
/// A transport that goes away under a live shell is reported as a lost connection, and the read does not
/// hang.
///
///
///
/// ◆ 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 sshd itself was never an option; killing something this test owns is.
///
///
/// What is not reproduced here is the waiting. 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
/// 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 reported as one rather than being called an ordinary exit or leaving the
/// read blocked for ever.
///
///
/// A relay rather than the SOCKS forward LoopbackProxyTests uses, which was the first attempt:
/// disconnecting the carrier of a ForwardedPortDynamic 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.
///
///
[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");
}
/// Trusts the relay's identity, then connects through it.
///
/// 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.
///
private static async Task 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(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);
}
/// A loopback TCP forwarder that can be cut, standing in for a machine that goes away.
///
/// 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.
///
private sealed class TcpRelay : IDisposable
{
private readonly TcpListener listener;
private readonly CancellationTokenSource lifetime = new();
private readonly List 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);
}
/// The loopback port to dial instead of the far end's.
internal int Port { get; }
///
/// Drops every socket, in both directions, without warning either end first.
///
///
/// 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.
///
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]
public async Task ThePump_ForwardsRealShellOutput()
{
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);
// Marker split so the PTY's echo of the command line does not satisfy the match.
await pump.WriteInputAsync(
Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"\n"),
TestContext.Current.CancellationToken);
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20);
while (TimeProvider.System.GetUtcNow() < deadline
&& !transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal))
{
await Task.Delay(50, TestContext.Current.CancellationToken);
}
transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal).ShouldBeTrue(
$"BytesRead={pump.BytesRead}, FramesSent={pump.FramesSent}, "
+ $"OutputFrames={transport.OutputFrames}, Text=<{transport.Text}>");
await pump.DisposeAsync();
try
{
await run;
}
catch (OperationCanceledException)
{
// Expected: disposing the pump cancels its run.
}
}
/// Accumulates output and acknowledges it, as a keeping-up renderer would.
private sealed class CountingTransport : ITerminalTransport
{
private readonly StringBuilder text = new();
private readonly Lock gate = new();
internal TerminalSessionPump? Pump { get; set; }
internal int OutputFrames { get; private set; }
internal string Text
{
get
{
lock (gate)
{
return text.ToString();
}
}
}
public ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken)
{
if (!TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload)
|| opcode != (byte)TerminalServerOpcode.Output)
{
return ValueTask.CompletedTask;
}
lock (gate)
{
OutputFrames++;
text.Append(Encoding.UTF8.GetString(payload));
}
Pump?.Acknowledge((uint)payload.Length);
return ValueTask.CompletedTask;
}
}
}