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

@@ -56,6 +56,16 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
private const int MaximumRequestBytes = 16 * 1024;
private const int ReceiveBufferBytes = 64 * 1024;
/// <summary>The largest message from the page this host will reassemble.</summary>
/// <remarks>
/// A paste is one input frame however long it is, and so is a copied selection, so the receive buffer
/// is the size of a read and not the size of a message. Sixteen megabytes is far beyond anything a
/// person pastes into a shell on purpose and still small enough that a page sending garbage cannot make
/// this process hold an unbounded amount of it. A message beyond it is dropped whole — never its tail,
/// which would be parsed as a frame whose first bytes are somebody's paste.
/// </remarks>
private const int MaximumMessageBytes = 16 * 1024 * 1024;
private readonly TcpListener listener;
private readonly ITerminalAssetProvider assets;
private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
@@ -451,6 +461,11 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
{
var buffer = new byte[ReceiveBufferBytes];
// Grown only when a message spans more than one read, and kept for the next one that does.
var message = Array.Empty<byte>();
var length = 0;
var oversized = false;
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var result = await webSocket
@@ -462,14 +477,73 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return;
}
if (!result.EndOfMessage)
// The common case — a keystroke, an acknowledgement — is one read, and goes straight through.
if (result.EndOfMessage && length == 0 && !oversized)
{
// A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
// so this is either a bug or something hostile; dropping is the safe response.
DispatchSafely(buffer.AsSpan(0, result.Count));
continue;
}
Dispatch(buffer.AsSpan(0, result.Count));
if (!oversized && length + result.Count <= MaximumMessageBytes)
{
if (length + result.Count > message.Length)
{
Array.Resize(ref message, Math.Min(MaximumMessageBytes, Math.Max(message.Length * 2, length + result.Count)));
}
buffer.AsSpan(0, result.Count).CopyTo(message.AsSpan(length));
length += result.Count;
}
else
{
oversized = true;
}
if (!result.EndOfMessage)
{
continue;
}
if (!oversized)
{
DispatchSafely(message.AsSpan(0, length));
}
length = 0;
oversized = false;
}
}
/// <summary>Dispatches one frame, so that what goes wrong with one session stays with that session.</summary>
/// <remarks>
/// Every session shares this socket, and an exception escaping the receive loop closes it — so a resize
/// that reached a shell SSH.NET had already disposed, which is an ordinary moment at the end of every
/// session, used to drop every other tab to "reconnecting" and lose the output sent meanwhile. A frame
/// that cannot be acted on is a frame not acted on; the session it was for reports its own ending.
/// </remarks>
private void DispatchSafely(ReadOnlySpan<byte> frame)
{
try
{
Dispatch(frame);
}
catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException)
{
// See the remarks.
}
}
/// <summary>Writes one input frame, without letting a session that is going away fault anything.</summary>
private async Task WriteInputAsync(TerminalSessionPump pump, byte[] input)
{
try
{
await pump.WriteInputAsync(input, lifetime.Token).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The shell ended or the connection dropped between the keystroke and the write. The session's
// own read loop reports that; a keystroke that had nowhere to go has nothing to add.
}
}
@@ -502,7 +576,7 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
case TerminalClientOpcode.Input:
// Fire and forget: a blocked write must not stall acknowledgements for other sessions
// sharing this socket.
_ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
_ = WriteInputAsync(pump, payload.ToArray());
break;
case TerminalClientOpcode.Acknowledge:
@@ -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);