Files
DodoSSH/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs
jaap-jan 2ff9dff560 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.
2026-10-02 13:16:48 +02:00

699 lines
25 KiB
C#

using System.Globalization;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// The loopback transport end to end, with a real WebSocket client.
/// </summary>
/// <remarks>
/// Uses <see cref="ClientWebSocket"/> against the real listener rather than a stubbed transport,
/// because the hand-rolled HTTP upgrade is the part that could be subtly wrong — and a handshake that
/// no real client accepts would pass any test that skipped it.
/// </remarks>
public sealed class TerminalDataPlaneTests : IAsyncDisposable
{
private const uint SessionId = 3;
private static readonly byte[] PageTemplate = Encoding.UTF8.GetBytes(
$"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>");
private readonly TerminalDataPlane plane = new(new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
{
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", PageTemplate),
["/xterm.js"] = new("text/javascript; charset=utf-8", "console.log(1)"u8.ToArray()),
}));
private readonly CancellationTokenSource lifetime = new();
private Task? server;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await lifetime.CancelAsync();
await plane.DisposeAsync();
if (server is not null)
{
try
{
await server;
}
catch (OperationCanceledException)
{
// Expected: the accept loop is stopped by cancelling it.
}
}
lifetime.Dispose();
}
// ---- Serving the page ----
[Fact]
public async Task ThePage_IsServedWithTheTokenAndSocketUrlSubstituted()
{
// Substituted at serve time rather than written into the file, so the token never touches disk
// and never appears in a URL that a log or a browser history could keep.
Start();
using var client = new HttpClient();
var body = await client.GetStringAsync(plane.PageUrl, TestContext.Current.CancellationToken);
body.ShouldContain(plane.Token);
body.ShouldContain(
string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{plane.Port}/socket"));
body.ShouldNotContain(TerminalDataPlane.TokenPlaceholder);
body.ShouldNotContain(TerminalDataPlane.SocketUrlPlaceholder);
}
[Fact]
public async Task ThePage_IsNotCached()
{
// It carries a connection token.
Start();
using var client = new HttpClient();
using var response = await client.GetAsync(plane.PageUrl, TestContext.Current.CancellationToken);
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
}
[Fact]
public async Task AnUnknownPath_Is404()
{
Start();
using var client = new HttpClient();
using var response = await client.GetAsync(
new Uri($"http://127.0.0.1:{plane.Port}/nope"),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
// ---- Attaching ----
[Fact]
public async Task TheRenderer_Attaches()
{
Start();
using var socket = await ConnectAsync();
socket.State.ShouldBe(WebSocketState.Open);
socket.SubProtocol.ShouldBe(TerminalDataPlane.SubProtocol);
await plane.RendererAttached;
}
[Fact]
public async Task AConnectionWithoutTheToken_IsRejected()
{
Start();
await Should.ThrowAsync<WebSocketException>(async () =>
await ConnectAsync(token: null));
}
[Fact]
public async Task AConnectionWithTheWrongToken_IsRejected()
{
Start();
await Should.ThrowAsync<WebSocketException>(async () =>
await ConnectAsync(token: "not-the-token"));
}
[Fact]
public async Task AConnectionFromAnotherOrigin_IsRejected()
{
// The threat this actually addresses: a page open in the user's browser can attempt WebSocket
// connections to loopback ports, and would otherwise reach a terminal.
Start();
await Should.ThrowAsync<WebSocketException>(async () =>
await ConnectAsync(origin: "https://evil.example"));
}
/// <remarks>
/// The truth this replaced: a second valid attach used to be a 409, on the theory that one renderer
/// lives for the whole process. Android's WebView does not honour that theory — its renderer process is
/// routinely killed and the page reloads with a fresh socket — so a second valid attach is now a
/// takeover. This asserts both halves: the newcomer gets the connection, and the displaced socket
/// actually goes rather than lingering as a phantom nothing is reading from.
/// </remarks>
[Fact]
public async Task ASecondRenderer_TakesOver_AndTheFirstSocketIsDropped()
{
Start();
using var first = await ConnectAsync();
first.State.ShouldBe(WebSocketState.Open);
using var second = await ConnectAsync();
second.State.ShouldBe(WebSocketState.Open);
// The first socket was aborted rather than closed gracefully — Abort skips the close handshake
// entirely, so there is no Close frame for this side to see coming. What a receive on it sees
// instead is the connection simply gone, which the client surfaces as an exception rather than as
// a state that quietly flips on its own; nothing here reads from the socket otherwise, so the
// state alone would not move.
var firstBuffer = new byte[16];
await Should.ThrowAsync<Exception>(async () =>
await first.ReceiveAsync(firstBuffer.AsMemory(), TestContext.Current.CancellationToken));
await using var session = new FakeShellSession(bytesToProduce: 64);
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
var run = pump.RunAsync(TestContext.Current.CancellationToken);
var opened = await ReceiveAsync(second);
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
var output = await ReceiveAsync(second);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
output.Payload.Length.ShouldBe(64);
await SendAsync(
second,
(byte)TerminalClientOpcode.Acknowledge,
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
await run;
}
/// <remarks>
/// <para>
/// The other half of the takeover: a renderer process that dies without a close handshake — which is
/// what a killed Android WebView actually does, no FIN, nothing — must not fault the send path. A
/// faulted send would propagate into <see cref="TerminalSessionPump"/>'s flush loop and freeze a live
/// session; see <see cref="TerminalDataPlane.SendAsync"/>'s remark for why. Disposing the client socket
/// abruptly, with no close handshake sent, is the closest this harness gets to that: the server-side
/// socket is left believing itself open until it actually tries to write to it.
/// </para>
/// <para>
/// Driven straight through <see cref="TerminalDataPlane.SendAsync"/> rather than through a pump, because
/// a pump adds nothing here — the point is entirely about the transport's own contract, and a session
/// layered on top would only leave it unclear whether a passing test proved the transport never threw or
/// merely that the frames never happened to need a live socket.
/// </para>
/// </remarks>
[Fact]
public async Task SendAsync_DoesNotThrow_WhenTheAttachedRendererDiedWithoutClosing_AndAFreshAttachStillReceives()
{
Start();
var first = await ConnectAsync();
first.State.ShouldBe(WebSocketState.Open);
first.Dispose();
// Whether this particular send lands on the OS's send buffer before the peer's absence is noticed,
// or fails immediately, is not the point — either way it must not throw.
await Should.NotThrowAsync(async () =>
await plane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "before"u8.ToArray()),
TestContext.Current.CancellationToken));
using var second = await ConnectAsync();
await Should.NotThrowAsync(async () =>
await plane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "after"u8.ToArray()),
TestContext.Current.CancellationToken));
var output = await ReceiveAsync(second);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
Encoding.UTF8.GetString(output.Payload).ShouldBe("after");
}
// ---- Frames ----
[Fact]
public async Task Output_ReachesTheRenderer()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession(bytesToProduce: 128);
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
var run = pump.RunAsync(TestContext.Current.CancellationToken);
// SessionOpened first, then the output.
var opened = await ReceiveAsync(socket);
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
opened.SessionId.ShouldBe(SessionId);
var output = await ReceiveAsync(socket);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
output.Payload.Length.ShouldBe(128);
// Acknowledge it, exactly as the page does from term.write's callback.
await SendAsync(
socket,
(byte)TerminalClientOpcode.Acknowledge,
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
await run;
}
[Fact]
public async Task Input_ReachesTheRemote()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "whoami\r"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length > 0);
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()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(
socket,
(byte)TerminalClientOpcode.Resize,
TerminalFrame.CreateResizePayload(new TerminalSize(132, 43, 1320, 1075)));
await WaitUntilAsync(() => session.ResizeCount > 0);
session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075));
}
[Fact]
public async Task AnAcknowledgement_ReturnsCredit()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
pump.Credits.TryReserve(1024);
pump.Credits.Outstanding.ShouldBe(1024);
await SendAsync(
socket,
(byte)TerminalClientOpcode.Acknowledge,
TerminalFrame.CreateAcknowledgementPayload(1024));
await WaitUntilAsync(() => pump.Credits.Outstanding == 0);
}
/// <remarks>
/// The point of this one is the <em>absence</em> of a registered session. Every other client frame is
/// about a terminal and is dropped once its session has gone; a text-size chord is about the person
/// reading, and the page sends whichever id it had to hand — often one whose shell has just ended,
/// which is exactly when somebody may be squinting at the message it left behind. Dispatching this
/// opcode before the session lookup is what makes that work.
/// </remarks>
[Fact]
public async Task AFontSizeStep_IsHeardWithNoSessionRegistered()
{
Start();
using var socket = await ConnectAsync();
var steps = new List<int>();
plane.FontSizeStepRequested += (_, e) => steps.Add(e.Step);
await SendAsync(
socket,
(byte)TerminalClientOpcode.FontSizeStep,
TerminalFrame.CreateFontSizeStepPayload(-1),
sessionId: 0);
await WaitUntilAsync(() => steps.Count > 0);
steps[0].ShouldBe(-1);
}
/// <remarks>
/// Dispatched before the session lookup for the reason the text-size chord is, and one of its own: a
/// clipboard is not a property of any terminal. The absence of a registered session is the point of the
/// test — copying the last thing a remote said before it hung up is an ordinary thing to want, and a
/// dead tab must not be the one place the gesture silently does nothing.
/// </remarks>
[Fact]
public async Task ACopy_IsHeardWithNoSessionRegistered()
{
Start();
using var socket = await ConnectAsync();
var copied = new List<string>();
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
await SendAsync(
socket,
(byte)TerminalClientOpcode.ClipboardCopy,
Encoding.UTF8.GetBytes("error: nothing was written"));
await WaitUntilAsync(() => copied.Count > 0);
copied[0].ShouldBe("error: nothing was written");
}
/// <remarks>
/// An empty copy is dropped rather than forwarded. The clipboard is shared with everything else on the
/// machine, so "copy nothing" would be a gesture that quietly destroys whatever was on it — and the page
/// does not send one, which makes this a guard against a frame from something that is not the page.
/// </remarks>
[Fact]
public async Task AnEmptyCopy_IsDropped()
{
Start();
using var socket = await ConnectAsync();
var copies = 0;
plane.ClipboardCopyRequested += (_, _) => Interlocked.Increment(ref copies);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, []);
// Followed by one that must be heard, so the assertion below is about ordering rather than about
// having waited long enough: the receive loop is sequential, so a copy arriving after this proves
// the empty one was seen and passed over.
var copied = new List<string>();
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, "uptime"u8.ToArray());
await WaitUntilAsync(() => copied.Count > 0);
copies.ShouldBe(1, "only the second frame carried anything");
}
/// <remarks>
/// The session id is the whole payload of a paste request — it says which pane the pointer was over, and
/// therefore where the answer has to land. Heard with nothing registered for the reason a copy is.
/// </remarks>
[Fact]
public async Task APasteRequest_CarriesTheSessionItWasMadeIn()
{
Start();
using var socket = await ConnectAsync();
var asked = new List<uint>();
plane.ClipboardPasteRequested += (_, e) => asked.Add(e.SessionId);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardPaste, [], sessionId: 4242);
await WaitUntilAsync(() => asked.Count > 0);
asked[0].ShouldBe(4242u);
}
[Fact]
public async Task AFrameForAnUnregisteredSession_IsIgnored()
{
// Ordinary during teardown: the page can still have frames in flight for a session that just
// ended. Dropping them must not disturb anything else.
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
// A session id that is never registered. Using the registered one and relying on ordering
// would be a race: frames are dispatched on the receive loop, so the orphan can land after
// registration and legitimately be delivered.
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "orphan"u8.ToArray(), sessionId: 999);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "real"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length > 0);
Encoding.UTF8.GetString(session.Written).ShouldBe("real");
}
[Fact]
public async Task AMalformedFrame_IsIgnored()
{
Start();
using var socket = await ConnectAsync();
// Shorter than the header.
await socket.SendAsync(
new byte[] { 1, 2 },
WebSocketMessageType.Binary,
endOfMessage: true,
TestContext.Current.CancellationToken);
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length > 0);
Encoding.UTF8.GetString(session.Written).ShouldBe("still here");
}
// ---- Helpers ----
private void Start() => server = plane.RunAsync(lifetime.Token);
private TerminalSessionPump CreatePump(FakeShellSession session) =>
new(
SessionId,
session,
plane,
TimeProvider.System,
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
/// <summary>
/// Opens a renderer's socket and returns once the plane is actually holding it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Connected and attached are two different moments, and the gap between them is where this used to
/// flake.</b> <c>ClientWebSocket.ConnectAsync</c> completes on the 101, which
/// <see cref="TerminalDataPlane.UpgradeAsync"/> writes before it has a <see cref="WebSocket"/> to
/// attach — it builds one from the stream and swaps it in a few instructions later, on the accept
/// thread. A frame sent in between is dropped, by design rather than by accident: the transport has
/// nowhere to put a frame for a renderer that is not there, and queueing it is the unbounded growth the
/// credit window exists to prevent.
/// </para>
/// <para>
/// So a test that connected and immediately expected a frame was racing that window on every run. It
/// lost one on CI — <c>Output_ReachesTheRenderer</c> read the output frame first and asked why it was
/// not the session's opening one, the opening one having been dropped a moment earlier — which is a
/// scheduling accident on a loaded machine and says nothing whatever about the transport.
/// </para>
/// <para>
/// Production does not race it and needs no change: everything that opens a session waits on
/// <c>TerminalWorkspace.WaitForRendererAsync</c> first, and that resolves from the same few lines this
/// event is raised from.
/// </para>
/// <para>
/// Subscribed before the connection rather than after it, because the event is raised on the accept
/// thread and can be over before <c>ConnectAsync</c> has returned here. Bounded, so that a socket that
/// never attaches fails this helper rather than hanging the suite in a later receive.
/// </para>
/// </remarks>
private async Task<ClientWebSocket> ConnectAsync(
string? token = "",
string? origin = null)
{
var socket = new ClientWebSocket();
socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
if (token is not null)
{
socket.Options.AddSubProtocol($"token.{(token.Length == 0 ? plane.Token : token)}");
}
socket.Options.SetRequestHeader(
"Origin",
origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));
var attached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnAttached(object? sender, EventArgs e) => attached.TrySetResult();
plane.SocketAttached += OnAttached;
try
{
await socket.ConnectAsync(
new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
TestContext.Current.CancellationToken);
await attached.Task.WaitAsync(
TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
}
catch
{
socket.Dispose();
throw;
}
finally
{
plane.SocketAttached -= OnAttached;
}
return socket;
}
private static async Task SendAsync(
ClientWebSocket socket,
byte opcode,
byte[] payload,
uint sessionId = SessionId) =>
await socket.SendAsync(
TerminalFrame.Create(opcode, sessionId, payload),
WebSocketMessageType.Binary,
endOfMessage: true,
TestContext.Current.CancellationToken);
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
ClientWebSocket socket)
{
var buffer = new byte[64 * 1024];
var result = await socket.ReceiveAsync(
buffer.AsMemory(),
TestContext.Current.CancellationToken);
TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
.ShouldBeTrue();
return (opcode, sessionId, payload.ToArray());
}
/// <remarks>
/// Polls rather than awaiting a signal: inbound frames are dispatched on the transport's receive
/// loop, which has no completion for the caller to await. The bound keeps a genuine failure a
/// failure rather than a hang.
/// </remarks>
private static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(5);
while (TimeProvider.System.GetUtcNow() < deadline)
{
if (condition())
{
return;
}
await Task.Delay(10, TestContext.Current.CancellationToken);
}
throw new TimeoutException("The expected state was not reached within 5 seconds.");
}
}