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
@@ -101,6 +101,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
private readonly Lock sessionGate = new();
private readonly CancellationTokenSource lifetime = new();
/// <summary>
/// The payload that marks a <see cref="TerminalServerOpcode.SessionOpened"/> frame as a replay rather
/// than a fresh open. A one-byte non-empty payload, so terminal.js's existing length check (empty
/// payload for a real open) tells the two apart without a second opcode.
/// </summary>
private static readonly byte[] ReplayMarker = [1];
private uint nextSessionId = 1;
private Task? server;
private int disposed;
@@ -125,6 +132,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is
// what remembers it between launches.
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
// handshake handling, and has no business making that wait on however long a replay takes. See
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
// is harmless.
dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync();
}
/// <summary>
@@ -223,6 +236,26 @@ public sealed class TerminalWorkspace : IAsyncDisposable
}
}
/// <summary>
/// A live session's flow-control window, or null when the id names no session this workspace still has
/// open.
/// </summary>
/// <remarks>
/// A test seam rather than something the shell has ever needed: nothing outside this assembly has a
/// reason to see a pump's credit window rather than what the transport does with it, but
/// <see cref="ReplayAfterAttachAsync"/>'s reset of that window on reattach is exactly the kind of thing
/// that is easy to get backwards, and worth asserting directly rather than only through its side
/// effects. Internal rather than public, reachable from the test assembly through the
/// <c>InternalsVisibleTo</c> this project already declares for it.
/// </remarks>
internal CreditWindow? CreditsFor(uint sessionId)
{
lock (sessionGate)
{
return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null;
}
}
/// <summary>
/// Raised with the session id when a shell ends on its own.
/// </summary>
@@ -254,6 +287,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
/// </summary>
/// <remarks>
/// <para>
/// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets
/// its <c>SessionOpened</c> frame again, done by the time this fires. What is left is what the workspace
/// has no business owning: the font size and which tab is selected are both remembered by the shell, not
/// by a terminal, so this is the seam the shell uses to re-push them. See
/// <c>MainWindowViewModel</c>'s subscription for the other half.
/// </para>
/// <para>
/// Raised on the socket-accept thread, same as <see cref="TerminalDataPlane.SocketAttached"/> that
/// triggers it — a handler that touches a view model has to marshal.
/// </para>
/// </remarks>
public event EventHandler? RendererReattached;
/// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
@@ -563,6 +614,71 @@ public sealed class TerminalWorkspace : IAsyncDisposable
lifetime.Dispose();
}
/// <summary>
/// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its
/// own.
/// </summary>
/// <remarks>
/// <para>
/// Runs on the socket-accept thread that raised <see cref="TerminalDataPlane.SocketAttached"/> — the
/// constructor wires it up fire-and-forget for exactly that reason, so this method owns its own error
/// handling rather than leaving an unobserved exception for nobody to see.
/// </para>
/// <para>
/// Every live session — one whose <c>Run</c> has not completed — gets two things. Its credit window is
/// reset, because whatever was outstanding was reserved against bytes sent to a page that is now gone;
/// the acknowledgement that would return that credit died with it, and without this reset the session
/// would stall the moment 256 KiB of history had accumulated. And it gets its <c>SessionOpened</c> frame
/// again, marked with <see cref="ReplayMarker"/> so the page can tell a reattach from a session that is
/// genuinely new — the same frame a page that survived the socket drop already has a pane for, and one a
/// reloaded page does not.
/// </para>
/// <para>
/// A session whose shell has already ended gets nothing here. Its scrollback lived only in the page that
/// is gone, and sending a frame that implied otherwise would be exactly the kind of dishonesty this
/// fix is supposed to remove, not add. The tab strip still shows that session ended; nothing about this
/// method changes what <see cref="LiveSessionCount"/> or <see cref="IsSessionLive"/> report.
/// </para>
/// <para>
/// This can race the fresh page's own first frames — an early resize, an acknowledgement for output it
/// already had. That is harmless: every frame in both directions names its session, delivery order
/// within a session is preserved by both xterm and the socket, and a frame for a pane the page has not
/// created yet is simply dropped, the same as any frame for a session it does not know — see
/// <c>terminal.js</c>'s <c>handleFrame</c>.
/// </para>
/// </remarks>
private async Task ReplayAfterAttachAsync()
{
KeyValuePair<uint, LiveSession>[] live;
lock (sessionGate)
{
live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)];
}
try
{
foreach (var (sessionId, session) in live)
{
session.Pump.Credits.Reset();
await dataPlane
.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker),
CancellationToken.None)
.ConfigureAwait(false);
}
RendererReattached?.Invoke(this, EventArgs.Empty);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay
// leaves nothing worse than the problem this method exists to fix, and there is no caller on
// this thread left to hand a failure to.
}
}
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
{
try