namespace DodoSSH.Client.Terminal.Tests; /// /// The flow-control accounting. /// /// /// Worth testing on its own, ahead of the pump, because everything else about the terminal depends on /// this arithmetic being right. An off-by-one that leaks credit shows up as a session that stalls /// after several minutes of heavy output — a symptom nobody would trace back to here. /// public sealed class CreditWindowTests { [Fact] public void AFreshWindow_OffersItsWholeSize() { var window = new CreditWindow(1024); window.Available.ShouldBe(1024); window.Outstanding.ShouldBe(0); } [Fact] public void Reserving_ReducesWhatIsAvailable() { var window = new CreditWindow(1024); window.TryReserve(400).ShouldBe(400); window.Outstanding.ShouldBe(400); window.Available.ShouldBe(624); } [Fact] public void ReservingMoreThanRemains_GrantsAPartialAmount() { // All-or-nothing would stall a session that could have made progress with what was left, and // the caller has to cope with a short read regardless. var window = new CreditWindow(1024); window.TryReserve(900); window.TryReserve(400).ShouldBe(124); window.Available.ShouldBe(0); } [Fact] public void AFullWindow_GrantsNothing() { var window = new CreditWindow(1024); window.TryReserve(1024); window.TryReserve(1).ShouldBe(0); } [Fact] public void Returning_RestoresCredit() { var window = new CreditWindow(1024); window.TryReserve(1024); window.Return(512); window.Available.ShouldBe(512); window.Outstanding.ShouldBe(512); } [Fact] public void ReturningMoreThanWasReserved_IsClamped() { // The acknowledgement crosses into JavaScript, so a buggy or tampered page can claim to have // rendered more than it was sent. Letting that drive outstanding negative would hand it an // unbounded window, which is precisely what this class exists to prevent. var window = new CreditWindow(1024); window.TryReserve(100); window.Return(100_000); window.Outstanding.ShouldBe(0); window.Available.ShouldBe(1024); } [Fact] public void ReturningWithNothingOutstanding_ChangesNothing() { var window = new CreditWindow(1024); window.Return(500); window.Available.ShouldBe(1024); } [Fact] public async Task WaitingWithCreditAvailable_ReturnsImmediately() { var window = new CreditWindow(1024); await window.WaitForCreditAsync(TestContext.Current.CancellationToken); } [Fact] public async Task WaitingOnAFullWindow_BlocksUntilCreditIsReturned() { var window = new CreditWindow(1024); window.TryReserve(1024); var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask(); wait.IsCompleted.ShouldBeFalse("A full window must not let a reader proceed."); window.Return(1); await wait; } [Fact] public async Task Reset_UnblocksAWaiter() { // A session being torn down must not leave its reader parked forever on credit that will // never be acknowledged. var window = new CreditWindow(1024); window.TryReserve(1024); var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask(); wait.IsCompleted.ShouldBeFalse(); window.Reset(); await wait; window.Outstanding.ShouldBe(0); } [Fact] public async Task Waiting_ObservesCancellation() { var window = new CreditWindow(1024); window.TryReserve(1024); using var cts = new CancellationTokenSource(); var wait = window.WaitForCreditAsync(cts.Token).AsTask(); await cts.CancelAsync(); await Should.ThrowAsync(async () => await wait); } [Fact] public async Task ConcurrentReservesAndReturns_KeepTheAccountingConsistent() { // The pump reserves on one task and returns on another, so the arithmetic has to hold under // real contention rather than only in a single-threaded walkthrough. const int Window = 64 * 1024; var window = new CreditWindow(Window); var reserved = 0; var returned = 0; var workers = Enumerable.Range(0, 8).Select(_ => Task.Run(() => { for (var i = 0; i < 2_000; i++) { var granted = window.TryReserve(97); Interlocked.Add(ref reserved, granted); window.Return(granted); Interlocked.Add(ref returned, granted); } }, TestContext.Current.CancellationToken)); await Task.WhenAll(workers); reserved.ShouldBe(returned); window.Outstanding.ShouldBe(0); window.Available.ShouldBe(Window); } [Fact] public void AWindowOfZeroOrLess_IsRejected() { Should.Throw(() => new CreditWindow(0)); Should.Throw(() => new CreditWindow(-1)); } }