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));
}
}