using System.Diagnostics;
using System.Globalization;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
///
/// The output path under load: coalescing, backpressure, and shutdown.
///
///
/// The plan calls for a throughput harness built before any UI, and this is it. Terminal throughput is
/// where a client like this usually fails — cat on a large file either freezes the UI or grows
/// memory until the process dies — and neither symptom is diagnosable once a WebView is in the picture.
///
public sealed class TerminalSessionPumpTests
{
private const uint SessionId = 7;
[Fact]
public async Task Output_ReachesTheTransportTaggedWithItsSession()
{
await using var session = new FakeShellSession(bytesToProduce: 64);
var transport = new RecordingTransport();
await using var pump = CreatePump(session, transport);
transport.AutoAcknowledge = pump;
await pump.RunAsync(TestContext.Current.CancellationToken);
transport.OutputBytes().Length.ShouldBe(64);
foreach (var frame in transport.Frames)
{
TerminalFrame.TryRead(frame, out _, out var sessionId, out _).ShouldBeTrue();
sessionId.ShouldBe(SessionId);
}
}
[Fact]
public async Task ASessionOpenedFrame_PrecedesAnyOutput()
{
// The renderer has to attach a terminal before bytes arrive for it, or the first screenful is
// written into nothing.
await using var session = new FakeShellSession(bytesToProduce: 16);
var transport = new RecordingTransport();
await using var pump = CreatePump(session, transport);
transport.AutoAcknowledge = pump;
await pump.RunAsync(TestContext.Current.CancellationToken);
TerminalFrame.TryRead(transport.Frames[0], out var opcode, out _, out _).ShouldBeTrue();
opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
}
[Fact]
public async Task SeveralReadsWithinOneInterval_BecomeASingleFrame()
{
// A terminal cannot render faster than the display refreshes, so sending each read separately
// is work whose result is overwritten before anyone sees it.
await using var session = new FakeShellSession(bytesToProduce: 8 * 1024);
var transport = new RecordingTransport();
await using var pump = CreatePump(
session,
transport,
new TerminalPumpOptions
{
ReadBufferBytes = 512,
FlushInterval = TimeSpan.FromMilliseconds(400),
});
transport.AutoAcknowledge = pump;
var run = pump.RunAsync(TestContext.Current.CancellationToken);
// Long enough for one flush, short enough that a second cannot have happened.
await Task.Delay(TimeSpan.FromMilliseconds(600), TestContext.Current.CancellationToken);
// 8 KiB arrived as sixteen 512-byte reads and left as one frame.
transport.CountOf(TerminalServerOpcode.Output).ShouldBe(1);
transport.OutputBytes().Length.ShouldBe(8 * 1024);
await run;
}
[Fact]
public async Task WhenCreditRunsOut_ThePumpStopsReading()
{
// The property the whole design rests on. With no acknowledgement the pump must read exactly
// the window and then stop, so nothing accumulates between the socket and the screen. Left
// unbounded, a remote running `yes` grows the client's memory until it dies.
const int Window = 4 * 1024;
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
var transport = new RecordingTransport();
// Deliberately no AutoAcknowledge: this models a renderer that has stopped keeping up.
await using var pump = CreatePump(
session,
transport,
new TerminalPumpOptions
{
ReadBufferBytes = 1024,
FlushInterval = TimeSpan.FromMilliseconds(5),
WindowBytes = Window,
});
var run = pump.RunAsync(TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
pump.BytesRead.ShouldBe(
Window,
"The pump read past its credit window, so output accumulates without bound.");
// Still stalled a moment later, rather than merely slow.
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
pump.BytesRead.ShouldBe(Window);
pump.Credits.Available.ShouldBe(0);
await pump.DisposeAsync();
await run;
}
[Fact]
public async Task AcknowledgingCredit_LetsThePumpResume()
{
const int Window = 4 * 1024;
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
var transport = new RecordingTransport();
await using var pump = CreatePump(
session,
transport,
new TerminalPumpOptions
{
ReadBufferBytes = 1024,
FlushInterval = TimeSpan.FromMilliseconds(5),
WindowBytes = Window,
});
var run = pump.RunAsync(TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
pump.BytesRead.ShouldBe(Window);
pump.Acknowledge(2048);
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
pump.BytesRead.ShouldBe(Window + 2048);
await pump.DisposeAsync();
await run;
}
[Fact]
public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
{
// The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must
// not be able to talk the host into an unbounded read.
const int Window = 4 * 1024;
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
var transport = new RecordingTransport();
await using var pump = CreatePump(
session,
transport,
new TerminalPumpOptions
{
ReadBufferBytes = 1024,
FlushInterval = TimeSpan.FromMilliseconds(5),
WindowBytes = Window,
});
var run = pump.RunAsync(TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
pump.Acknowledge(uint.MaxValue);
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
// One further window's worth at most, never everything the endless producer could offer.
pump.BytesRead.ShouldBeLessThanOrEqualTo(Window * 2);
await pump.DisposeAsync();
await run;
}
[Fact]
public async Task WhenTheRemoteCloses_ASessionClosedFrameFollowsTheLastOutput()
{
// The last thing a remote writes is often the most important — an error, an exit code — and
// dropping it makes a clean exit look like a crash.
await using var session = new FakeShellSession(bytesToProduce: 4096);
var transport = new RecordingTransport();
await using var pump = CreatePump(
session,
transport,
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
transport.AutoAcknowledge = pump;
await pump.RunAsync(TestContext.Current.CancellationToken);
transport.OutputBytes().Length.ShouldBe(4096);
TerminalFrame.TryRead(transport.Frames[^1], out var opcode, out _, out var payload)
.ShouldBeTrue();
opcode.ShouldBe((byte)TerminalServerOpcode.SessionClosed);
System.Text.Encoding.UTF8.GetString(payload).ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task Input_ReachesTheRemote()
{
await using var session = new FakeShellSession();
var transport = new RecordingTransport();
await using var pump = CreatePump(session, transport);
await pump.WriteInputAsync("ls -la\r"u8.ToArray(), TestContext.Current.CancellationToken);
System.Text.Encoding.UTF8.GetString(session.Written).ShouldBe("ls -la\r");
}
[Fact]
public async Task AResize_ReachesTheRemote()
{
await using var session = new FakeShellSession();
var transport = new RecordingTransport();
await using var pump = CreatePump(session, transport);
pump.Resize(new TerminalSize(132, 43, 1320, 1075));
session.ResizeCount.ShouldBe(1);
session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075));
}
[Fact]
public async Task AZeroSizedResize_IsDropped()
{
// Arrives naturally when a pane collapses or a window minimises. Forwarding it leaves the
// remote's idea of the terminal nonsensical until the next resize.
await using var session = new FakeShellSession();
var transport = new RecordingTransport();
await using var pump = CreatePump(session, transport);
pump.Resize(new TerminalSize(0, 0));
session.ResizeCount.ShouldBe(0);
}
[Fact]
public async Task ThroughputHarness_SustainsTenMegabytesPerSecondWithBoundedMemory()
{
// The plan's target: `yes` at full tilt must hold a fixed memory ceiling. Throughput is
// asserted well below what the machine manages, because a CI runner under load is slower than
// a desktop and a flaky performance test gets deleted rather than fixed. The bounded-memory
// assertion is the one that carries the real meaning.
const long Target = 32L * 1024 * 1024;
const int Window = CreditWindow.DefaultWindowBytes;
await using var session = new FakeShellSession(bytesToProduce: Target);
var transport = new RecordingTransport();
await using var pump = CreatePump(
session,
transport,
new TerminalPumpOptions
{
ReadBufferBytes = 32 * 1024,
FlushInterval = TimeSpan.FromMilliseconds(1),
WindowBytes = Window,
});
transport.AutoAcknowledge = pump;
using var monitor = new CancellationTokenSource();
var sampler = SamplePeakOutstandingAsync(pump, monitor.Token);
var stopwatch = Stopwatch.StartNew();
await pump.RunAsync(TestContext.Current.CancellationToken);
stopwatch.Stop();
await monitor.CancelAsync();
var peakOutstanding = await sampler;
pump.BytesRead.ShouldBe(Target);
transport.OutputBytes().Length.ShouldBe((int)Target);
var megabytesPerSecond = Target / 1024d / 1024d / stopwatch.Elapsed.TotalSeconds;
megabytesPerSecond.ShouldBeGreaterThan(
10,
string.Create(
CultureInfo.InvariantCulture,
$"Sustained {megabytesPerSecond:F1} MB/s, below the 10 MB/s target."));
// The real assertion: in-flight bytes never exceeded the window, however fast the producer ran.
peakOutstanding.ShouldBeLessThanOrEqualTo(
Window,
string.Create(
CultureInfo.InvariantCulture,
$"Peak in-flight was {peakOutstanding} bytes against a {Window}-byte window."));
}
/// Watches in-flight bytes for the duration of a run, returning the highest seen.
private static async Task SamplePeakOutstandingAsync(
TerminalSessionPump pump,
CancellationToken cancellationToken)
{
var peak = 0;
while (!cancellationToken.IsCancellationRequested)
{
peak = Math.Max(peak, pump.Credits.Outstanding);
try
{
await Task.Delay(1, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
return peak;
}
private static TerminalSessionPump CreatePump(
FakeShellSession session,
ITerminalTransport transport,
TerminalPumpOptions? options = null) =>
new(
SessionId,
session,
transport,
TimeProvider.System,
options ?? new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
}