namespace DodoSSH.Client.Terminal;
///
/// One session's bounded queue of output waiting for the renderer, oldest first.
///
///
///
/// ◆ This is where a session's memory is actually bounded, and it exists because the obvious place
/// cannot do it. The design used to be that bounded everything: the pump
/// stopped reading the SSH channel when the renderer fell behind, SSH's own receive window closed, and the
/// remote blocked on write. The middle step is not true of the library this client uses.
/// Renci.SshNet.Channels.Channel.OnData calls its window adjustment before raising
/// DataReceived and re-opens the channel window as soon as it drops below one packet, and
/// ShellStream's own handler copies the bytes into a growable buffer without blocking and without a
/// ceiling. So a reader that stops reading does not slow the remote down by one byte; it only decides
/// whose buffer the flood accumulates in — and SSH.NET's grows by doubling and does not give the memory
/// back until the stream is disposed. A remote running yes took the process with it.
///
///
/// So the pump now reads continuously, which is what keeps SSH.NET's buffer empty, and the backlog it
/// reads into is this one, with a hard capacity. When the renderer cannot keep up the oldest
/// bytes go rather than the newest, because the newest are the ones somebody is waiting to see: the tail
/// of a flood is the prompt that comes back after it. Dropping is announced rather than silent — see
/// — and it is the honest end of the trade, since output arriving faster than the
/// terminal can draw it was going to scroll off a 5,000-line scrollback regardless.
///
///
/// The credit window is still here and still does its own job: it bounds what is in flight to the
/// renderer, which is the buffer inside the WebView that nothing on this side can see. What changed
/// is that it no longer pretends to bound what is in flight from the remote.
///
///
public sealed class OutputBacklog
{
///
/// Default capacity: 4 MiB.
///
///
/// Sixteen times the credit window, so nothing an ordinary burst produces is ever dropped — a
/// screenful of dense output is kilobytes, and a build that scrolls for a second is well under this.
/// It is a per-session cost, and it is the whole of what a runaway remote can take, which is the
/// number worth choosing against: twenty tabs all flooding at once is 80 MiB and survivable, where the
/// same twenty unbounded was whatever the machine had.
///
public const int DefaultCapacityBytes = 4 * 1024 * 1024;
private readonly Lock gate = new();
private readonly Queue segments = new();
private readonly int capacityBytes;
/// Signalled when output arrives at an empty backlog, and when it is completed.
private TaskCompletionSource available = CreateSignal();
/// How much of the head segment has already been taken.
private int head;
private int buffered;
private long droppedSinceTake;
private long droppedTotal;
private bool completed;
/// How many unsent bytes to hold before dropping the oldest.
public OutputBacklog(int capacityBytes = DefaultCapacityBytes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacityBytes, 1);
this.capacityBytes = capacityBytes;
}
/// How many unsent bytes are held.
public int Buffered
{
get
{
lock (gate)
{
return buffered;
}
}
}
/// How many bytes this backlog has discarded over its whole life.
public long DroppedTotal
{
get
{
lock (gate)
{
return droppedTotal;
}
}
}
/// Whether the producer has said there will be no more output.
public bool IsCompleted
{
get
{
lock (gate)
{
return completed;
}
}
}
///
/// Adds output, dropping the oldest already held if that puts the backlog over capacity.
///
///
/// Takes ownership of ; the caller must not write to it afterwards.
/// Appending to a completed backlog does nothing rather than throwing, because a producer finding out
/// it has been shut down is a race rather than a mistake.
///
/// Bytes read from the remote, in order.
public void Append(byte[] segment)
{
ArgumentNullException.ThrowIfNull(segment);
if (segment.Length == 0)
{
return;
}
lock (gate)
{
if (completed)
{
return;
}
var wasEmpty = buffered == 0;
segments.Enqueue(segment);
buffered += segment.Length;
// Never the segment just added, however large it is. A read bigger than the whole capacity is
// possible if somebody configures one, and a backlog that answered every take with nothing
// would stall the session rather than bound it.
while (buffered > capacityBytes && segments.Count > 1)
{
var oldest = segments.Dequeue();
var discarded = oldest.Length - head;
head = 0;
buffered -= discarded;
droppedSinceTake += discarded;
droppedTotal += discarded;
}
// Only on the transition, because a waiter that found bytes here never captured the signal in
// the first place — see WaitForOutputAsync, which checks under this same lock. Signalling on
// every append would allocate a TaskCompletionSource per read of a session doing megabytes a
// second, for a waiter that is not there.
if (wasEmpty)
{
Signal();
}
}
}
///
/// Moves the oldest output held into , as much of it as will fit.
///
///
///
/// Into a caller's span rather than into an array of its own, because this is the hot path: the pump
/// writes straight into the frame it is about to send, so a terminal showing a gigabyte costs one copy
/// of it here rather than two.
///
///
/// is what makes a drop visible instead of silent. It counts the bytes
/// discarded since the previous take, which is exactly the gap sitting immediately before the bytes
/// being handed over now. The caller writes a line saying so, and it has to — the alternative is a
/// terminal quietly showing a different transcript from the one the remote sent. It is reported even
/// when nothing is taken, so a drop at the very end of a session is still announced.
///
///
/// Where the bytes go, oldest first.
/// Bytes discarded since the previous take, and zero on the ordinary path.
/// How many bytes were written, which is zero when nothing is held.
public int TakeInto(Span destination, out long dropped)
{
lock (gate)
{
dropped = droppedSinceTake;
droppedSinceTake = 0;
var take = Math.Min(destination.Length, buffered);
var offset = 0;
while (offset < take)
{
var oldest = segments.Peek();
var fromThis = Math.Min(oldest.Length - head, take - offset);
oldest.AsSpan(head, fromThis).CopyTo(destination[offset..]);
offset += fromThis;
head += fromThis;
buffered -= fromThis;
if (head == oldest.Length)
{
_ = segments.Dequeue();
head = 0;
}
}
return take;
}
}
/// Says there will be no more output, releasing anything waiting for some.
public void Complete()
{
lock (gate)
{
completed = true;
Signal();
}
}
/// Waits until there is output to take, or until there will never be any.
/// Abandons the wait.
/// once the backlog is completed and empty.
public async ValueTask WaitForOutputAsync(CancellationToken cancellationToken)
{
while (true)
{
Task signal;
lock (gate)
{
if (buffered > 0)
{
return true;
}
if (completed)
{
return false;
}
signal = available.Task;
}
await signal.WaitAsync(cancellationToken).ConfigureAwait(false);
}
}
///
/// Completed inside the lock so a waiter cannot miss the transition, and replaced there too so the
/// next wait is on a signal that has not already fired. Continuations run asynchronously, so nothing
/// resumes while the lock is held.
///
private void Signal()
{
_ = available.TrySetResult();
available = CreateSignal();
}
private static TaskCompletionSource CreateSignal() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
}