From 3f5979d6399682fd88364935bc9019a6c48917bf Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sun, 9 Aug 2026 10:54:45 +0200 Subject: [PATCH] Record the renderer-reattach correction and its phone checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port notes carry the third correction of this round: the data plane assumed a renderer that attaches once and lives forever, which no foreground service can make true of Android's separate WebView renderer process. Phase 11 gains the two checks a phone can run — close and reopen a connection, and a backgrounded shell surviving its renderer being killed, banner and all. --- docs/android-port.md | 31 +++++++++++++++++++++++++++++++ docs/manual-checks.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/docs/android-port.md b/docs/android-port.md index de71e79..7724b45 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -238,6 +238,37 @@ The parts that are definitely different are the on-screen keyboard, and the fact needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an accessory key row for this. That is UI work, not porting. +> **⚠️ Corrected by the build. The data plane assumed a renderer that attaches once and lives forever, and +> that assumption is WebView2's truth, not Android's.** Desktop's WebView2 process starts with the window and +> dies with it; `TerminalDataPlane` was written to that reality — one socket, attached once, +> `Interlocked.Exchange`-guarded against a second attach ever happening at all. On a phone the WebView's own +> renderer process is a separate thing from the app process the foreground service above is keeping alive, +> and Android kills *that* independently — under memory pressure, or simply for being backgrounded — with no +> foreground service able to save it. The page then reloads with a fresh socket, and three things broke on +> that reload before this was found: the second attach was refused outright (`409 Conflict`), because a +> second valid upgrade could only mean a bug or a hostile second process, never our own page coming back; a +> send into the dead first socket threw, and that exception unwound `TerminalSessionPump`'s flush loop, +> freezing the still-live shell behind it — `LiveSessionCount` kept counting a session nothing would ever +> drain again; and every byte sent while no page was attached had already spent flow-control credit that no +> acknowledgement could ever return, so a session outliving 256 KiB of output into a dead page stalled for +> good regardless of the other two. Waiting for the old socket to notice it was dead and close on its own +> was never going to be enough either — a killed renderer sends no TCP FIN, so the old receive loop could sit +> unaware for the whole 30-second keepalive. +> +> Fixed as a takeover rather than a guard: a second valid upgrade — origin, token and subprotocol all +> checked exactly as before — now displaces whatever socket was attached instead of being refused, since +> only this app's own page ever knows the token, so a second valid attach *is* that page, back again. +> `TerminalDataPlane.SendAsync` no longer lets a dead-socket send escape as a fault; it reads as "nobody +> listening," same as no socket being attached at all. `TerminalWorkspace` resets each live session's credit +> window on every attach and resends its `SessionOpened` frame, flagged as a replay, so the fresh page +> rebuilds the pane and the pump stops waiting on an acknowledgement that was never coming. And +> `terminal.js`'s socket now retries itself, forever, with backoff, instead of reporting the connection +> failed and stopping — the page dies with the app anyway, so there is no case where retrying is the wrong +> call. What is **not** recovered, and says so rather than pretending otherwise: scrollback across a page +> reload. It lived in the page's own DOM, and a reloaded page is a new DOM. The replay banner — *"the view +> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the +> terminal will actually read it, not buried in a log. + --- ## Decisions taken diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 49d6bcf..df9f537 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -1745,6 +1745,39 @@ is a terminal that answers the buttons and ignores the keyboard: it reads as the Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first tap of an arrow key. +### 11.11 Closing a connection and opening a new one both take you somewhere real + +Open a shell, close its tab, then open a different one from HOSTS. + +**Pass:** the new terminal renders and takes input straight away — no stuck "Connecting…" status, no blank +pane that never receives the prompt. + +**Failure means:** `TerminalDataPlane` refused the page's reattach. The renderer's `WebSocket` does not +survive a tab going from one to zero and back to one on every device, and a host that answers a second valid +upgrade with `409 Conflict` instead of taking the socket over leaves every terminal after the first +permanently unreachable — see the correction in `docs/android-port.md`'s terminal section. + +### 11.12 A backgrounded shell survives its renderer being killed · **needs several minutes, or developer tooling** + +With a shell open and something worth reading in its scrollback, background the app (home button, not back) +for several minutes — long enough for Android to consider reclaiming it — then return. If the device exposes +it, forcing a stop of the WebView renderer process from Developer Options while backgrounded is the more +reliable way to trigger the same thing on demand rather than waiting on the OS's own judgement. Either way, +type something once you are back. + +**Pass:** one of two honest outcomes, both good. Either the pane is exactly as it was — the renderer process +survived, so nothing needed to happen — or the pane is empty but for a dim line reading `── the view +reconnected; earlier output stayed on the host ──`, meaning the page reloaded and reattached. In both cases +what is typed now reaches the shell, and the shell is still the same one — not a new tab, not a reconnect +sheet, no "Connecting…" status stuck on screen. + +**Failure means:** if the status stays stuck or nothing typed arrives, the renderer's socket did not retry +itself — see `terminal.js`'s `connect()` and its backoff. If the pane came back empty with **no** banner, a +session that survived a reload is being shown as though its scrollback had too, which is not true and is +worse than saying nothing: the banner exists so this is never silently wrong. If typing does nothing but the +banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it — +see `TerminalWorkspace.ReplayAfterAttachAsync`. + --- ## Phase 12 — Shared vaults: the operations that span two accounts