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