Record the renderer-reattach correction and its phone checks

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.
This commit is contained in:
2026-08-09 10:54:45 +02:00
parent aaff81272a
commit 3f5979d639
2 changed files with 64 additions and 0 deletions
+31
View File
@@ -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
+33
View File
@@ -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