Teach the page and the shell to put a reattached view back together

The page's socket now retries itself forever with backoff — a dropped
socket is an ordinary event on a phone, not the end of the terminal's
life — and createSession is idempotent, so a replay landing on a pane
that survived changes nothing. A replay creating a pane that did not
survive writes one dim line saying the earlier output stayed on the
host, because that is the truth about a reloaded page's scrollback.

The shell answers RendererReattached with the two things only it owns:
the font size, and which tab is active.
This commit is contained in:
2026-08-09 10:54:38 +02:00
parent 4d1f07f253
commit aaff81272a
2 changed files with 121 additions and 11 deletions
@@ -525,6 +525,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// list. Detached in DisposeAsync, which is the only point either of them ends. // list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded; this.workspace.SessionEnded += OnWorkspaceSessionEnded;
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested; this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths); settings = new ClientSettingsStore(paths);
@@ -635,6 +636,31 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) => private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step)); Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
/// <remarks>
/// <para>
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
/// plane's socket-accept thread, and both properties it reads here — <see cref="TerminalFontSize"/> and
/// <see cref="SelectedTab"/> — are bound to by the interface.
/// </para>
/// <para>
/// <see cref="TerminalWorkspace.RendererReattached"/> fires once the workspace has replayed what it
/// owns — the live sessions. Font size and the choice of active tab are not the workspace's to know;
/// they live here, so this is the other half of putting a reattached page back the way it was. The size
/// is sent exactly as <see cref="TellRendererTheFontSizeAsync"/> sends it at startup, because nothing
/// has changed — the page has merely forgotten, and this is only a reminder.
/// </para>
/// </remarks>
private void OnRendererReattached(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
_ = workspace.SetFontSizeAsync(TerminalFontSize, CancellationToken.None).AsTask();
if (SelectedTab is { } tab)
{
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
}
});
[ObservableProperty] [ObservableProperty]
private ShellState state = ShellState.Starting; private ShellState state = ShellState.Starting;
@@ -3051,6 +3077,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded; workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested; workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
workspace.RendererReattached -= OnRendererReattached;
transfers.PropertyChanged -= OnTransfersPropertyChanged; transfers.PropertyChanged -= OnTransfersPropertyChanged;
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and // Stopped here rather than left to the process exiting with it: the loop holds no vault key and
+94 -11
View File
@@ -3,7 +3,7 @@
/* /*
The renderer half of the terminal data plane. The renderer half of the terminal data plane.
Two things here are load-bearing and easy to get wrong: Three things here are load-bearing and easy to get wrong:
1. Output is acknowledged from term.write's completion callback, never on receipt. The 1. Output is acknowledged from term.write's completion callback, never on receipt. The
acknowledgement returns flow-control credit to the host, so acknowledging early would tell acknowledgement returns flow-control credit to the host, so acknowledging early would tell
@@ -15,6 +15,14 @@
partial sequences across writes. Decoding here would corrupt any multi-byte character that partial sequences across writes. Decoding here would corrupt any multi-byte character that
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
conditions that are hardest to reproduce. conditions that are hardest to reproduce.
3. The socket reconnects itself, forever, with backoff. This page's WebView is routinely killed
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
socket closed" is an ordinary event here, not the end of the terminal's life — see connect().
A reloaded page starts with an empty session map, so createSession is idempotent (a session
that already has a pane is left alone) and a SESSION_OPENED frame carries a flag telling this
page whether it is a replay: nothing to do for a pane that is still here, and a short banner
for one that is not, because that pane's scrollback genuinely did not survive.
*/ */
const SERVER_OUTPUT = 1; const SERVER_OUTPUT = 1;
@@ -33,6 +41,25 @@ const CLIENT_FONT_SIZE_STEP = 4;
const HEADER_LENGTH = 5; const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000; const SCROLLBACK_LINES = 5000;
/*
How long to wait before trying the socket again, and how that wait grows. Starting quick matters
because the ordinary case is a page that just finished loading after its WebView came back — the
host's listener has been sitting there the whole time — and capping it matters because there is no
point spacing attempts further apart than a person notices. Forever rather than giving up, because
giving up would need a way to try again and there is none better than the one already here: the page
dies with the app.
*/
const RECONNECT_INITIAL_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 5000;
/*
Styled like the SESSION_CLOSED banner (matching \x1b[38;5;244, the same dim grey), but written by
createSession's caller rather than by createSession itself: only a *replay* landing on a pane that
does not exist yet means the page reloaded and lost it, and createSession has no way to know which
of its callers that is.
*/
const REPLAY_BANNER = '\x1b[38;5;244m── the view reconnected; earlier output stayed on the host ──\x1b[0m\r\n';
/* /*
The size panes are created at, until the host says otherwise — which it does as soon as it has read The size panes are created at, until the host says otherwise — which it does as soon as it has read
the stored preference, usually before the first session exists. Kept here as well so a pane opened the stored preference, usually before the first session exists. Kept here as well so a pane opened
@@ -192,7 +219,19 @@ function handleKey(event) {
return true; return true;
} }
/**
* Builds a pane for a session, or returns the one already there.
*
* Idempotent because a replay can land on a page that never lost its pane — the socket dropped and
* came back, but this page's own process survived — and asking for a session that already has a pane
* must not build a second one on top of it, orphaning the first one's WebGL context and scrollback.
*/
function createSession(sessionId) { function createSession(sessionId) {
const existing = sessions.get(sessionId);
if (existing) {
return existing;
}
const pane = document.createElement('div'); const pane = document.createElement('div');
pane.className = 'pane'; pane.className = 'pane';
pane.dataset.sessionId = String(sessionId); pane.dataset.sessionId = String(sessionId);
@@ -290,10 +329,23 @@ function handleFrame(buffer) {
const payload = new Uint8Array(buffer, HEADER_LENGTH); const payload = new Uint8Array(buffer, HEADER_LENGTH);
switch (opcode) { switch (opcode) {
case SERVER_SESSION_OPENED: case SERVER_SESSION_OPENED: {
createSession(sessionId); // Checked before createSession, which would otherwise erase the answer by creating the pane
// this check is asking about.
const hadPaneAlready = sessions.has(sessionId);
const session = createSession(sessionId);
// Byte 1 means the host is replaying a session that existed before this socket attached — see
// TerminalWorkspace.ReplayAfterAttachAsync. A replay landing on a pane that is still here has
// nothing left to do beyond the idempotent create above; one landing on a pane that is not means
// this page reloaded and that pane's scrollback went with it, which is worth a line saying so.
if (payload.length > 0 && payload[0] === 1 && !hadPaneAlready) {
session.term.write(REPLAY_BANNER);
}
setStatus(''); setStatus('');
break; break;
}
case SERVER_OUTPUT: { case SERVER_OUTPUT: {
const session = sessions.get(sessionId) ?? createSession(sessionId); const session = sessions.get(sessionId) ?? createSession(sessionId);
@@ -414,6 +466,31 @@ function handleFrame(buffer) {
} }
} }
/** @type {number | null} */
let reconnectTimer = null;
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
/**
* Tries the socket again after a wait, unless a try is already pending.
*
* The guard is what keeps 'close' and 'error' from stacking two timers for one failure — a socket
* that fails to open typically fires both, and each would otherwise schedule its own reconnect.
*/
function scheduleReconnect() {
if (reconnectTimer !== null) {
return;
}
setStatus('Reconnecting the terminal view…');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
}
function connect() { function connect() {
const token = root.dataset.token; const token = root.dataset.token;
const url = root.dataset.socket; const url = root.dataset.socket;
@@ -423,16 +500,22 @@ function connect() {
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]); socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
socket.binaryType = 'arraybuffer'; socket.binaryType = 'arraybuffer';
socket.addEventListener('open', () => setStatus('')); socket.addEventListener('open', () => {
setStatus('');
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
// attempts within one outage, reset once the outage is actually over.
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
});
socket.addEventListener('message', (event) => handleFrame(event.data)); socket.addEventListener('message', (event) => handleFrame(event.data));
socket.addEventListener('close', () => { // Both close and error retry. They are not the same event on every failure — a socket that never
setStatus('Disconnected from DodoSSH.'); // opens can fire only 'error', one that opens and later drops fires only 'close' — and the host
}); // side of this same problem (TerminalDataPlane.UpgradeAsync's takeover) is exactly why retrying is
// safe: whichever attempt eventually reaches the host, a fresh valid upgrade always wins the socket.
socket.addEventListener('error', () => { socket.addEventListener('close', scheduleReconnect);
setStatus('The terminal connection failed.'); socket.addEventListener('error', scheduleReconnect);
});
} }
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a // One observer for the whole root rather than one per pane: resizes arrive in bursts while a