Replay the live sessions to a renderer that just attached

Each live session gets its credit window reset — the unacknowledged
bytes died with the old page, and their acknowledgement is never
coming — and its SessionOpened frame again, flagged as a replay so the
page can tell a reattach from a genuinely new session. A session whose
shell already ended gets nothing: its scrollback lived only in the page
that is gone, and a frame implying otherwise would lie.

RendererReattached is the seam for what the workspace has no business
owning: the font size and the selected tab live in the shell, which
re-pushes them from its own subscription.
This commit is contained in:
2026-08-09 10:54:29 +02:00
parent 095774c498
commit 4d1f07f253
3 changed files with 291 additions and 6 deletions
@@ -8,6 +8,8 @@ internal sealed class FakeShellSession : ISshShellSession
private readonly List<byte> written = [];
private readonly Lock gate = new();
private readonly bool blockReads;
private long remaining;
private byte pattern;
@@ -16,7 +18,19 @@ internal sealed class FakeShellSession : ISshShellSession
/// endless producer, which is what a runaway remote process looks like — those sessions are ended
/// by disposing the pump rather than by running out of data.
/// </param>
internal FakeShellSession(long bytesToProduce = 0) => remaining = bytesToProduce;
/// <param name="blockReads">
/// True for a shell that is open and live but has nothing to say — an idle prompt, rather than either
/// end of the "produces bytes" and "hit end of stream" spectrum <paramref name="bytesToProduce"/>
/// covers. <see cref="ReadAsync"/> then blocks until cancelled, which is what a real idle SSH channel's
/// read does. Exists for tests that need a session whose <c>Run</c> stays live without a background
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
/// <c>TerminalWorkspaceTests</c>.
/// </param>
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
{
remaining = bytesToProduce;
this.blockReads = blockReads;
}
/// <inheritdoc />
public bool IsOpen { get; private set; } = true;
@@ -52,6 +66,13 @@ internal sealed class FakeShellSession : ISshShellSession
{
ReadCount++;
if (blockReads)
{
// Never completes on its own. The only way out is the same way a real blocked read ends: the
// token being cancelled, which is what disposing the pump does.
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
}
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
@@ -109,7 +130,8 @@ internal sealed class FakeShellSession : ISshShellSession
/// workspace is the layer that decides when a session is over, and that decision is what needs a
/// connection whose shell can be made to end on cue.
/// </remarks>
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue) : ISshConnectionFactory
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
: ISshConnectionFactory
{
/// <summary>Connections handed out, in order.</summary>
internal List<FakeConnection> Connections { get; } = [];
@@ -119,7 +141,7 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
SshConnectionRequest request,
CancellationToken cancellationToken)
{
var connection = new FakeConnection(request, bytesPerShell);
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
Connections.Add(connection);
return Task.FromResult<ISshConnection>(connection);
@@ -127,7 +149,8 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
}
/// <summary>A connection that opens fake shells and records its own disposal.</summary>
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell) : ISshConnection
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell, bool blockShellReads = false)
: ISshConnection
{
/// <inheritdoc />
public bool IsConnected { get; private set; } = true;
@@ -148,7 +171,7 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
Shell = new FakeShellSession(bytesPerShell);
Shell = new FakeShellSession(bytesPerShell, blockShellReads);
return Task.FromResult<ISshShellSession>(Shell);
}
@@ -1,3 +1,6 @@
using System.Globalization;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
@@ -291,6 +294,85 @@ public sealed class TerminalWorkspaceTests
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
}
// ---- Reattach ----
/// <remarks>
/// <para>
/// The scenario the whole fix exists for: a page that lost its socket — killed WebView renderer, or
/// simply a reload — reattaches, and the session that was already running has to come back rather than
/// sit there forever with its output going nowhere.
/// </para>
/// <para>
/// The session's shell blocks on every read rather than producing output, which is what an idle prompt
/// looks like and — for this test — is what keeps its <c>Run</c> live without a background read loop
/// competing with this test over the credit window's exact value.
/// </para>
/// <para>
/// Neither assertion below polls, deliberately. The "before" one does not need to: reserving credit is
/// a synchronous call, so it is true the instant it returns. The "after" one does not need to either,
/// for a subtler reason — <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> calls
/// <c>Credits.Reset()</c> and only then awaits sending the replay frame for that same session, with no
/// suspension between the two, so by the time this test has received that frame the reset has
/// necessarily already happened. A poll here would only have hidden a real ordering bug behind a
/// generous timeout instead of catching it.
/// </para>
/// </remarks>
[Fact]
public async Task ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits()
{
var connections = new FakeConnectionFactory(blockShellReads: true);
await using var workspace = CreateWorkspace(connections);
workspace.Start();
using var first = await ConnectRendererAsync(workspace);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
// The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not
// what this test is about — read and discarded so it cannot be confused for one below.
await ReceiveFrameAsync(first);
var credits = workspace.CreditsFor(sessionId).ShouldNotBeNull();
credits.TryReserve(4096);
credits.Outstanding.ShouldBeGreaterThanOrEqualTo(
4096, "the pump's own read loop may have reserved a buffer's worth on top of this");
using var second = await ConnectRendererAsync(workspace);
var replay = await ReceiveFrameAsync(second);
replay.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
replay.SessionId.ShouldBe(sessionId);
replay.Payload.ShouldBe(new byte[] { 1 }, "a replay is flagged so the page can tell it apart from a fresh open");
credits.Outstanding.ShouldBe(0, "the replay frame above cannot have been sent before the reset that precedes it");
}
/// <remarks>
/// The other half of a reattach: the workspace has replayed what it owns, and this is the seam the
/// shell uses to replay what it owns instead — the font size and the selected tab, neither of which a
/// terminal session knows anything about. <c>MainWindowViewModel</c>'s subscription is what actually
/// does that; this only asserts that the workspace hands it the chance to.
/// </remarks>
[Fact]
public async Task ANewRenderer_RaisesRendererReattached()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
workspace.Start();
using var first = await ConnectRendererAsync(workspace);
var reattachedCount = 0;
workspace.RendererReattached += (_, _) => Interlocked.Increment(ref reattachedCount);
using var second = await ConnectRendererAsync(workspace);
await WaitUntilAsync(() => Volatile.Read(ref reattachedCount) > 0);
}
// ---- Helpers ----
/// <remarks>
@@ -362,12 +444,76 @@ public sealed class TerminalWorkspaceTests
private static InMemoryTerminalAssetProvider StubAssets() =>
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
{
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
// The placeholders, not a token and URL already filled in — the reattach tests below have to
// connect a real renderer, and doing that by reading them back out of the served page is what
// proves the workspace serves a page a real renderer could actually attach with, rather than
// one that merely looks servable.
[TerminalDataPlane.PagePath] = new(
"text/html; charset=utf-8",
Encoding.UTF8.GetBytes(
$"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>")),
});
private static SshConnectionRequest Request() =>
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
/// <remarks>
/// 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.
/// </remarks>
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
{
using var http = new HttpClient();
var page = await http.GetStringAsync(workspace.PageUrl, TestContext.Current.CancellationToken);
var token = ExtractAttribute(page, "data-token");
var socketUrl = ExtractAttribute(page, "data-socket");
var client = new ClientWebSocket();
client.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
client.Options.AddSubProtocol($"token.{token}");
client.Options.SetRequestHeader(
"Origin",
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
try
{
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
}
catch
{
client.Dispose();
throw;
}
return client;
}
private static string ExtractAttribute(string html, string name)
{
var marker = $"{name}=\"";
var start = html.IndexOf(marker, StringComparison.Ordinal) + marker.Length;
var end = html.IndexOf('"', start);
return html[start..end];
}
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveFrameAsync(
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>
/// Polled rather than awaited on a task, because the point is what an observer of the property
/// sees: the pump ends on a thread of its own, and the count has to catch up without anyone