using System.Diagnostics; using System.Globalization; using System.Text; 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; } /// /// A renderer that has stopped acknowledging does not stop the pump reading, and does not grow it. /// /// /// ◆ THIS TEST REPLACES ONE THAT ASSERTED THE OPPOSITE, and the reversal is the point rather than a /// relaxation. The old contract was that the pump reads exactly one credit window and then stops, /// which was believed to close SSH's receive window and block the remote. It does not — see /// — so all the old behaviour bought was an unbounded buffer inside /// SSH.NET instead of a bounded one here. What has to be true now is both halves at once: reading /// continues, so nothing piles up underneath, and what is held stays inside the backlog. /// [Fact] public async Task WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine() { const int Window = 4 * 1024; const int Backlog = 64 * 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, BacklogBytes = Backlog, }); var run = pump.RunAsync(TestContext.Current.CancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); var readByNow = pump.BytesRead; readByNow.ShouldBeGreaterThan( Window, "The pump stopped reading, which leaves the flood accumulating inside SSH.NET instead."); pump.BytesPending.ShouldBeLessThanOrEqualTo( Backlog, "The backlog grew past its capacity, which is the unbounded growth it exists to prevent."); pump.BytesDropped.ShouldBeGreaterThan( 0, "Nothing was dropped, so something is still holding what the renderer never took."); // Still reading a moment later, rather than having quietly stalled once the backlog filled. await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); pump.BytesRead.ShouldBeGreaterThan(readByNow); pump.BytesPending.ShouldBeLessThanOrEqualTo(Backlog); pump.Credits.Available.ShouldBe(0); await pump.DisposeAsync(); await run; } [Fact] public async Task AcknowledgingCredit_LetsMoreOutputThrough() { const int Window = 4 * 1024; // Endless, so the backlog never completes while this is being measured: once it does, the tail // goes out regardless of credit and there is nothing for the window to hold back. Drops along the // way change which bytes are sent, never how many — and the notice they raise is its own opcode. 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); // One window's worth has gone out and stopped there, because nothing has been rendered. var sentBeforeAcknowledgement = transport.OutputBytes().Length; sentBeforeAcknowledgement.ShouldBe(Window); pump.Acknowledge(2048); await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); transport.OutputBytes().Length.ShouldBe(sentBeforeAcknowledgement + 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 filling its own buffer without limit. const int Window = 4 * 1024; // Endless for the reason the test above gives: a backlog that completes stops the window mattering. 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. transport.OutputBytes().Length.ShouldBeLessThanOrEqualTo(Window * 2); pump.Credits.Outstanding.ShouldBeLessThanOrEqualTo(Window); 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(); } /// /// exit, 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. /// [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."); } /// /// ◆ A reboot, as the pump sees it. The shell's read ends exactly as it does for exit — 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 exit, which is the state this whole wave exists to stop drawing. /// [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."); } /// /// 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. /// [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] 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); // Counting rather than recording, for the reason CountingTransport carries: a consumer that // copies every frame into a 32 MB list is the slowest thing in this pipeline, and a harness // whose consumer cannot keep up measures the harness. var transport = new CountingTransport(); 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); // ◆ Every byte read either reached the renderer or was counted as dropped, and none of it went // anywhere else. That is the invariant worth asserting here rather than "nothing was dropped": // this producer emits at memory bandwidth with no network under it, which is faster than any // real remote and faster than the pump can frame and send, so drops are the honest outcome and // the harness would be lying to forbid them. What must never happen is a byte that is neither // delivered nor accounted for — see NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote for // the case where the producer is one a network could actually deliver. (transport.OutputBytes + pump.BytesDropped).ShouldBe(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.")); } /// /// A renderer that keeps up loses nothing, however much the remote sends. /// /// /// The other half of the trade the backlog makes, and the half a user actually lives in: dropping is /// for a terminal that cannot keep up, and a terminal that can must show every byte. The producer here /// is sized for a real link rather than for memory bandwidth — several times the backlog, delivered in /// pieces a network could carry — so a drop would mean the pump losing output it had room for. /// [Fact] public async Task NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote() { const int Backlog = 256 * 1024; const long Target = 512L * 1024; // 8 KiB a millisecond — 8 MB/s, a fast remote rather than an impossible one, and thirty-two // times the backlog's capacity per second so the pump has no room to be merely lucky. await using var session = new FakeShellSession( bytesToProduce: Target, pacePerRead: TimeSpan.FromMilliseconds(1)); var transport = new CountingTransport(); await using var pump = CreatePump( session, transport, new TerminalPumpOptions { ReadBufferBytes = 8 * 1024, FlushInterval = TimeSpan.FromMilliseconds(1), WindowBytes = CreditWindow.DefaultWindowBytes, BacklogBytes = Backlog, }); transport.AutoAcknowledge = pump; await pump.RunAsync(TestContext.Current.CancellationToken); pump.BytesDropped.ShouldBe(0); transport.OutputBytes.ShouldBe(Target); } /// /// A shell that ends against a renderer which stopped acknowledging still finishes. /// /// /// ◆ THE HANG THIS DESIGN MAKES POSSIBLE, AND THE TEST THAT SAYS IT DOES NOT HAPPEN. The flush loop /// waits on credit when the renderer is behind, and completing the backlog is a different signal from /// returning credit — so a run whose reader finished while the flusher was parked on an /// acknowledgement that will never come would wait for ever. Nothing above would notice: the shell has /// ended, but the tab is told by SessionEnded, which fires off the run task completing, and the /// connection is given back at the same moment. A session in that state is invisible and permanent. /// [Fact] public async Task WhenTheRemoteCloses_TheRunEndsEvenIfTheRendererNeverAcknowledged() { const int Window = 4 * 1024; // More than the window, so the flusher has certainly run out of credit and parked by the time the // producer reaches the end of what it has to give. await using var session = new FakeShellSession(bytesToProduce: Window * 4); // No AutoAcknowledge, ever: this renderer's process died holding the socket. 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 Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); pump.Outcome.End.ShouldBe(SshSessionEnd.RemoteClosed); // And the tail went out rather than being abandoned with the flusher: the last thing a remote // writes is usually why it ended. transport.OutputBytes().Length.ShouldBe(Window * 4); transport.CountOf(TerminalServerOpcode.SessionClosed).ShouldBe(1); } /// /// A drop is announced in the pane rather than leaving a silent gap. /// /// /// The thing that makes dropping defensible at all. A terminal quietly showing a different transcript /// from the one the remote sent is worse than one that says it could not keep up, because the second /// can be believed. Its own opcode, so the sentence is never counted as something the remote said — /// see . /// [Fact] public async Task WhenOutputIsDropped_TheUserIsToldInThePane() { const int Window = 4 * 1024; const int Backlog = 16 * 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, BacklogBytes = Backlog, }); var run = pump.RunAsync(TestContext.Current.CancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); // Enough credit for one more frame, which is what carries the notice out. pump.Acknowledge((uint)Window); await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); transport.CountOf(TerminalServerOpcode.OutputDropped).ShouldBeGreaterThan(0); var notice = Encoding.UTF8.GetString(transport.PayloadsOf(TerminalServerOpcode.OutputDropped)); notice.ShouldContain("of output was dropped"); notice.ShouldStartWith("\u001b[0m"); await pump.DisposeAsync(); await run; } /// 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) }); }