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

@@ -285,6 +285,86 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r");
}
/// <summary>
/// A paste is one input frame however long it is, and one longer than a single read used to be dropped
/// — or worse, its tail parsed as a frame of its own.
/// </summary>
[Fact]
public async Task APasteLargerThanOneRead_ReachesTheRemoteWhole()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
var paste = new byte[300 * 1024];
Random.Shared.NextBytes(paste);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, paste);
await WaitUntilAsync(() => session.Written.Length >= paste.Length);
session.Written.ShouldBe(paste);
}
/// <summary>
/// Frames are handed over without waiting for the write before them, so a slow write must still be
/// followed rather than overtaken: a snippet's RUN is its text and then a carriage return.
/// </summary>
[Fact]
public async Task InputFrames_AreWrittenInTheOrderTheyArrived_EvenWhenAWriteIsSlow()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession { FirstWriteDelay = TimeSpan.FromMilliseconds(300) };
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "sudo systemctl restart api"u8.ToArray());
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "\r"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length >= 27);
Encoding.UTF8.GetString(session.Written).ShouldBe("sudo systemctl restart api\r");
}
/// <summary>
/// A resize that reaches a shell SSH.NET has already disposed is an ordinary moment at the end of a
/// session, and it must not close the socket every other session shares.
/// </summary>
[Fact]
public async Task AResizeThatThrows_LeavesTheSocketWorkingForEverythingElse()
{
Start();
using var socket = await ConnectAsync();
await using var ending = new FakeShellSession { ResizeThrows = true };
await using var endingPump = CreatePump(ending);
plane.Register(SessionId, endingPump);
await using var other = new FakeShellSession();
await using var otherPump = CreatePump(other);
plane.Register(SessionId + 1, otherPump);
await SendAsync(
socket,
(byte)TerminalClientOpcode.Resize,
TerminalFrame.CreateResizePayload(new TerminalSize(80, 24, 800, 600)));
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray(), SessionId + 1);
await WaitUntilAsync(() => other.Written.Length > 0);
Encoding.UTF8.GetString(other.Written).ShouldBe("still here");
socket.State.ShouldBe(WebSocketState.Open);
}
[Fact]
public async Task AResize_ReachesTheRemote()
{