Public Access
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user