Terminal: deliver large pastes whole, keep keystrokes in order, and keep one session's failure off the shared socket

- A message larger than the 64 KiB receive buffer was skipped and its tail dispatched as a frame of its
  own. Messages are now reassembled up to 16 MiB; one beyond that is dropped whole.
- Input frames are handed to the pump without waiting, and ShellStream takes no lock around its write
  buffer, so a snippet's text and its carriage return could interleave or lose bytes. Writes are
  serialised per pump in arrival order.
- A resize reaching a shell SSH.NET had already disposed threw out of the receive loop and closed the
  socket every tab shares. Each frame's failure now stays with its session.
This commit is contained in:
jaap-jan committed 2026-10-02 13:16:48 +02:00
1 parent 6846f67c20
commit 2ff9dff560
4 files changed
+205 -11

No files matched your search

@@ -107,6 +107,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
private readonly OutputBacklog backlog;
private readonly CancellationTokenSource lifetime = new();
/// <summary>One write to the remote at a time, in the order they were asked for.</summary>
/// <remarks>
/// The data plane hands each input frame over without waiting for the one before it — a blocked write
/// must not hold up the acknowledgements of every other session on the socket — so without this, two
/// frames' writes run at once. SSH.NET's <c>ShellStream</c> takes no lock around its write buffer, and its
/// flush sends what is there and then empties it by length: bytes added mid-flush are discarded unsent.
/// A snippet's RUN is its text and then a carriage return as two frames, which is exactly the shape that lost or
/// reordered keystrokes. Waiters on a <see cref="SemaphoreSlim"/> are released in the order they arrived,
/// and the data plane calls this from one receive loop, so arrival order is the order typed.
/// </remarks>
private readonly SemaphoreSlim inputGate = new(1, 1);
private int disposed;
/// <param name="sessionId">Identifies this session in every frame.</param>
@@ -267,9 +280,20 @@ public sealed class TerminalSessionPump : IAsyncDisposable
}
}
/// <summary>Forwards keystrokes to the remote.</summary>
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
session.WriteAsync(data, cancellationToken);
/// <summary>Forwards keystrokes to the remote, after any still being written.</summary>
public async ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
await inputGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await session.WriteAsync(data, cancellationToken).ConfigureAwait(false);
}
finally
{
inputGate.Release();
}
}
/// <summary>Tells the remote the terminal was resized.</summary>
public void Resize(TerminalSize size) => session.Resize(size);