From aaff81272a44a80961571ac8b7675c8aa5855b39 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sun, 9 Aug 2026 10:54:38 +0200 Subject: [PATCH] Teach the page and the shell to put a reattached view back together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ViewModels/MainWindowViewModel.cs | 27 +++++ .../WebAssets/terminal.js | 105 ++++++++++++++++-- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index d3a6371..3eaae25 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -525,6 +525,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp // list. Detached in DisposeAsync, which is the only point either of them ends. this.workspace.SessionEnded += OnWorkspaceSessionEnded; this.workspace.FontSizeStepRequested += OnFontSizeStepRequested; + this.workspace.RendererReattached += OnRendererReattached; settings = new ClientSettingsStore(paths); @@ -635,6 +636,31 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) => Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step)); + /// + /// + /// Marshalled for the same reason as : this arrives on the data + /// plane's socket-accept thread, and both properties it reads here — and + /// — are bound to by the interface. + /// + /// + /// 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 sends it at startup, because nothing + /// has changed — the page has merely forgotten, and this is only a reminder. + /// + /// + 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] private ShellState state = ShellState.Starting; @@ -3051,6 +3077,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp workspace.SessionEnded -= OnWorkspaceSessionEnded; workspace.FontSizeStepRequested -= OnFontSizeStepRequested; + workspace.RendererReattached -= OnRendererReattached; transfers.PropertyChanged -= OnTransfersPropertyChanged; // Stopped here rather than left to the process exiting with it: the loop holds no vault key and diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js index 9d41115..c2d4664 100644 --- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js +++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js @@ -3,7 +3,7 @@ /* 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 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 happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the 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; @@ -33,6 +41,25 @@ const CLIENT_FONT_SIZE_STEP = 4; const HEADER_LENGTH = 5; 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 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; } +/** + * 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) { + const existing = sessions.get(sessionId); + if (existing) { + return existing; + } + const pane = document.createElement('div'); pane.className = 'pane'; pane.dataset.sessionId = String(sessionId); @@ -290,10 +329,23 @@ function handleFrame(buffer) { const payload = new Uint8Array(buffer, HEADER_LENGTH); switch (opcode) { - case SERVER_SESSION_OPENED: - createSession(sessionId); + case SERVER_SESSION_OPENED: { + // 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(''); break; + } case SERVER_OUTPUT: { 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() { const token = root.dataset.token; const url = root.dataset.socket; @@ -423,16 +500,22 @@ function connect() { socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]); 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('close', () => { - setStatus('Disconnected from DodoSSH.'); - }); - - socket.addEventListener('error', () => { - setStatus('The terminal connection failed.'); - }); + // Both close and error retry. They are not the same event on every failure — a socket that never + // 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('close', scheduleReconnect); + socket.addEventListener('error', scheduleReconnect); } // One observer for the whole root rather than one per pane: resizes arrive in bursts while a