Merge pull request #431 from rounakdatta/feat/mobile-terminal-resilience

fix(terminal): four silent-failure paths — renderer freeze, replay race, reconnect gap, unbounded fetches
This commit is contained in:
Codeman maintainer
2026-09-23 11:32:14 +02:00
33 changed files with 2221 additions and 172 deletions
+19
View File
@@ -0,0 +1,19 @@
---
"aicodeman": patch
---
fix(terminal): five ways the terminal silently stopped being correct
**The PTY and the browser terminal must never disagree about size (#464).** "Text gets muffled sometimes" was arithmetic, not a dropped frame. Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the rows it believes that frame took, so a browser terminal of a different width makes the erase come out short and each repaint paints over rows nothing cleared — the doubled lines and half-overwritten prose in the report. Four ways the two drifted apart, all silent: `fitAddon.fit()` sized xterm to the raw measurement while every server-facing path reported it floored at 40x10 (font size 44 on a 430px phone proposed 13 columns, the server was told 40, xterm stayed at 13); `throttledResize` and `sendResize` reflowed locally while deliberately withholding the SIGWINCH; the font setters moved the cell size and told the server nothing; and `Session.resize` declined small-viewport requests under an active desktop sizing claim without telling the asking client, because resize was write-only. `syncTerminalGeometry()` is now the one function that changes the terminal's size — it fits, floors and applies as a single step — and both transports answer a resize with the geometry the PTY actually holds, which the client adopts by **columns only**. A terminal left wider than the box that shows it now earns horizontal reach for as long as that lasts, whether the cause is another device's claim or the 40-column floor.
**A replay clear must be in-stream, never `reset()`/`clear()`.** xterm's `write()` is queued while `Terminal.reset()` is synchronous and does not reset the parser, so bytes queued just before a reset are parsed after it and fuse into the snapshot written next — `write('p8'); reset(); write('rmissions')` renders `p8rmissions`. All three replay paths now go through one queued `\x1bc`, and the gate pins that no module blanks the terminal with a `clear()`+`reset()` pair.
**A renderer that stops painting now heals itself.** iOS discards scheduled `requestAnimationFrame` callbacks when a PWA backgrounds, and xterm's `RenderDebouncer` only clears its handle from inside that callback, so one drop leaves every later refresh a no-op while the buffer keeps updating correctly. Codeman has exactly one xterm for the whole page load, so a single backgrounding wedged it until a reload. A watchdog cancels the stale handle and forces a repaint.
**Every terminal capture carries a deadline that covers the response body.** `await fetch()` settles on headers, so clearing the timer there left the multi-megabyte `?full=1` body unbounded — measured at 4026ms under a 1000ms deadline. A capture that outruns its deadline during a tab switch now falls back to the bounded tail rather than leaving a blank pane, a mute session and a tab stuck reporting `aria-busy`.
**Output lost to a half-open WebSocket is reconciled.** Terminal output frames carry no sequence number, so a socket that dies while SSE stays up leaves a hole nothing replays. The session is marked and the next successful open repaints it, with the marker cleared only once a repaint has actually happened.
**The service worker's precache is generated by the build, and its cache key rotates per build.** The list was hand-maintained with pre-hash names, so every entry 404'd in production and the failure was swallowed (15 of 23 verified failing against a running instance); `caches.match` now passes `ignoreSearch: true`, without which no precached entry was reachable behind the build's `?v=` cache-bust. The old constant cache name meant `activate` never deleted anything, so assets from every past release accumulated forever.
Also: the crash trail is flattened and length-capped, so a server-controlled WebSocket close reason can no longer forge entries.