Reconnect the terminal view when somebody comes back to it #14

Merged
jaap-jan merged 2 commits from claude/terminal-reconnection-stuck-4e57ff into main 2026-08-14 14:13:29 +00:00
2 changed files with 62 additions and 0 deletions
Showing only changes of commit ef12e8cc99 - Show all commits
@@ -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
{