Public Access
Reconnect the terminal view when somebody comes back to it #14
@@ -416,6 +416,36 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
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)
|
||||
@@ -433,17 +463,31 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -538,10 +538,26 @@ public sealed class TerminalWorkspaceTests
|
||||
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Attaches the way the real page does: by fetching the served page, reading the token and socket URL
|
||||
/// back out of it, and presenting them on the upgrade — rather than reaching into the workspace for a
|
||||
/// token it does not expose. A shortcut here would prove only that a socket can be opened, not that the
|
||||
/// workspace serves a page a renderer could actually attach with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And it waits for the attach rather than only for the handshake, for the reason
|
||||
/// <c>TerminalDataPlaneTests.ConnectAsync</c> sets out at length: the 101 is written before the socket
|
||||
/// is attachable, and a frame sent in between is dropped. A caller that opens a session on the socket
|
||||
/// this returns and then reads its opening frame is exactly the shape that loses that race.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
|
||||
/// returns immediately without proving anything about the second socket. That is enough here and is
|
||||
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the
|
||||
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent
|
||||
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own
|
||||
/// <c>SocketAttached</c>, which the workspace does not forward today.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||
{
|
||||
@@ -561,6 +577,8 @@ public sealed class TerminalWorkspaceTests
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user