namespace DodoSSH.Client.Terminal; /// /// Credit-based flow control over one terminal session's output. /// /// /// /// This is what makes yes survivable. A terminal emulator renders at 60 Hz at best, while a /// remote process can produce output as fast as the network allows. Without a limit the difference /// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the /// application's memory grows until it dies. /// /// /// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte /// sent to it consumes credit; each byte it reports having actually rendered returns credit. When /// credit reaches zero the pump stops reading the SSH channel. That closes SSH's own receive /// window, which makes the remote sshd block on write, which propagates the backpressure all /// the way to the process producing the output. Nothing buffers without bound because nothing is read /// that cannot be delivered. /// /// /// The window has to be large enough that a normal burst never stalls and small enough to bound /// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's /// in-flight cost at a quarter of a megabyte. /// /// public sealed class CreditWindow { /// Default window size: 256 KiB. public const int DefaultWindowBytes = 256 * 1024; private readonly Lock gate = new(); private readonly int windowBytes; /// Signalled whenever credit becomes available. private TaskCompletionSource available = CreateSignal(); private int outstanding; /// How many unrendered bytes the renderer may be behind by. public CreditWindow(int windowBytes = DefaultWindowBytes) { ArgumentOutOfRangeException.ThrowIfLessThan(windowBytes, 1); this.windowBytes = windowBytes; } /// Bytes sent but not yet reported as rendered. public int Outstanding { get { lock (gate) { return outstanding; } } } /// Bytes that may be sent right now. public int Available { get { lock (gate) { return windowBytes - outstanding; } } } /// /// Reserves up to bytes of credit, returning how many were granted. /// /// /// A partial grant rather than all-or-nothing. Refusing to send 40 KiB because only 30 KiB of /// credit remains would stall a session that could have made progress, and the caller has to /// handle short writes regardless. /// /// Bytes reserved, which is zero when the window is full. public int TryReserve(int wanted) { ArgumentOutOfRangeException.ThrowIfLessThan(wanted, 0); lock (gate) { var granted = Math.Min(wanted, windowBytes - outstanding); outstanding += granted; return granted; } } /// /// Returns credit for bytes the renderer has reported rendering. /// /// /// Clamped rather than trusted. The acknowledgement crosses a process boundary into JavaScript, so /// a buggy or tampered page could acknowledge more than it was ever sent; letting that drive /// outstanding negative would hand it an unbounded window and reintroduce exactly the /// failure this class exists to prevent. /// public void Return(int rendered) { ArgumentOutOfRangeException.ThrowIfLessThan(rendered, 0); lock (gate) { outstanding -= Math.Min(rendered, outstanding); // Released inside the lock so a waiter cannot miss the transition, and completed // asynchronously so a continuation cannot run while the lock is held. available.TrySetResult(); available = CreateSignal(); } } /// Waits until at least one byte of credit is available. public async ValueTask WaitForCreditAsync(CancellationToken cancellationToken) { while (true) { Task signal; lock (gate) { if (windowBytes - outstanding > 0) { return; } signal = available.Task; } await signal.WaitAsync(cancellationToken).ConfigureAwait(false); } } /// Discards all outstanding credit, for a session being torn down. public void Reset() { lock (gate) { outstanding = 0; available.TrySetResult(); available = CreateSignal(); } } private static TaskCompletionSource CreateSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); }