a73x

e30c010e

fix(web): the badge stops being the state machine's memory

a73x   2026-08-13 14:18

Commit message
fix(web): the badge stops being the state machine's memory

M17 simplify round, core/shell batch. The tile's state lived in the DOM
— written by seven callers, read back with classList.contains — and the
hub's control messages clobbered it. A `stuck` tile (replay gave up,
sendAttach gated on replayDead) whose DAEMON leg re-dialled got
`{"state":"up"}` and painted itself green: silent forever, reading
healthy. That is the worst failure a wall can have, because the wall's
whole job is to be glanced at.

  - `status`/`statusText` are the truth and `renderBadge` is the only
    writer of the DOM. `setStatus` applies the precedence: link
    narration — this socket's own lifecycle AND the hub's control
    messages, which are the same kind of news — never overwrites a
    state the tile reached locally and terminally (stuck, exited,
    full). The bookkeeping still runs on an `up` it does not paint;
    only the badge is withheld, and sendAttach keeps its own gate.

  - `revive()` is the one place a terminal state is left, and it clears
    replayDead in the same breath, because the two are one fact: the
    badge says gave up, the flag gates every attach, and moving one
    without the other either lies or goes silent. Two callers, a frame
    that applied and a socket that opened. The two badge corrections
    that used to read the DOM back (full→up at the apply site,
    stuck→up in replaySucceeded) are that one rule now, and it covers
    the case neither spelled.

  - scroll is a VIEW, not a status: renderBadge shows `history -N`
    while paged back and returns to whatever the status became while
    the user was reading — including a terminal state that arrived
    meanwhile, which the old `setBadge('up')` on scroll exit erased.

  - macOS: `ev.metaKey` was never consulted, so Cmd+V preventDefaulted
    itself, typed a literal 'v' into the session and suppressed the
    paste event that is the only way a mac pastes at all. Cmd chords
    now return early like the Ctrl+Shift+C/V exemption beside them —
    no preventDefault, no bytes. Meta is deliberately not encoded: the
    keymap has no meta concept.

  - zoom refuses a tile whose core has not booted. Clicks are bound in
    the constructor, start() is async, and a click in that window threw
    mid-zoom and left a postage-stamp tile. Gated at zoom because zoom
    is what makes a tile reachable: no zoom, no keys, no wheel, no
    paste.

  - the scroll exit was written twice; onWheel's last page down routes
    through exitScroll, which routes its repaint through reflow.
    mark_all_dirty callers 4 → 2.

  - dropped the `ev.key !== 'char'` guard: no browser produces it.

make test green (verify.js 74 checks); make e2e green (23 scenarios /
35 convergence points, unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

web/mux.js
Old New
@@ -38,6 +38,12 @@ const PASTE_CHUNK = 32 * 1024;
38 // which half broke. Change both or neither. 38 // which half broke. Change both or neither.
39 const nextBackoffMs = (prev) => (prev === 0 ? 200 : Math.min(prev * 2, 2000)); 39 const nextBackoffMs = (prev) => (prev === 0 ? 200 : Math.min(prev * 2, 2000));
40 40
41 // The states a tile reaches ON ITS OWN and does not leave on link news
42 // alone (see setStatus). Everything else in the badge vocabulary —
43 // connecting, up, reconnecting, gone — is narration ABOUT the link,
44 // from this socket's own lifecycle or from the hub's control messages.
45 const TERMINAL = new Set(['stuck', 'exited', 'full']);
46
41 // How many straight failures to OPEN before a tile reads 'gone' rather 47 // How many straight failures to OPEN before a tile reads 'gone' rather
42 // than 'reconnecting'. Four is where the schedule hits its cap 48 // than 'reconnecting'. Four is where the schedule hits its cap
43 // (0+200+400+800 = 1.4s of trying), which is long enough that restarting 49 // (0+200+400+800 = 1.4s of trying), which is long enough that restarting
@@ -94,6 +100,10 @@ class Tile {
94 this.replayBackoffMs = 0; 100 this.replayBackoffMs = 0;
95 this.replayDead = false; // give up: send nothing further 101 this.replayDead = false; // give up: send nothing further
96 this.drawScale = 0; // logical→CSS factor the backing store is sized for 102 this.drawScale = 0; // logical→CSS factor the backing store is sized for
103 // The badge's source of truth. The DOM renders this; nothing reads
104 // it back (see renderBadge).
105 this.status = 'connecting';
106 this.statusText = 'connecting';
97 107
98 this.el = document.createElement('div'); 108 this.el = document.createElement('div');
99 this.el.className = 'tile'; 109 this.el.className = 'tile';
@@ -125,7 +135,7 @@ class Tile {
125 // the other shape and is right to destructure.) 135 // the other shape and is right to destructure.)
126 const instance = await WebAssembly.instantiate(compiledCore, {}); 136 const instance = await WebAssembly.instantiate(compiledCore, {});
127 this.core = instance.exports; 137 this.core = instance.exports;
128 if (this.core.mux_init(80, 24) !== 0) { this.setBadge('gone', 'init failed'); return; } 138 if (this.core.mux_init(80, 24) !== 0) { this.setStatus('gone', 'init failed'); return; }
129 this.sizeCanvas(); 139 this.sizeCanvas();
130 this.connect(); 140 this.connect();
131 } 141 }
@@ -145,13 +155,11 @@ class Tile {
145 this.wsFailures = 0; 155 this.wsFailures = 0;
146 // A genuinely new socket is a genuinely new chance: the hub may 156 // A genuinely new socket is a genuinely new chance: the hub may
147 // have been restarted onto a different session, so the frame this 157 // have been restarted onto a different session, so the frame this
148 // tile choked on may simply not exist any more. Without this a 158 // tile choked on may simply not exist any more, and the session
149 // tile that gave up once would stay dead until the page reloaded. 159 // that was full may have a slot. Without this a tile that gave up
150 this.replayDead = false; 160 // once would stay dead until the page reloaded.
151 this.replayFailures = 0;
152 this.replayBackoffMs = 0;
153 // The hub narrates from here on: connecting → up. 161 // The hub narrates from here on: connecting → up.
154 this.setBadge('connecting', 'connecting'); 162 this.revive('connecting', 'connecting');
155 }; 163 };
156 this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data)); 164 this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data));
157 // onerror always precedes onclose; the badge is decided in one place. 165 // onerror always precedes onclose; the badge is decided in one place.
@@ -159,7 +167,7 @@ class Tile {
159 this.ws.onclose = () => { 167 this.ws.onclose = () => {
160 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; 168 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1;
161 const dead = this.wsFailures >= GONE_AFTER_FAILURES; 169 const dead = this.wsFailures >= GONE_AFTER_FAILURES;
162 this.setBadge(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); 170 this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting');
163 const wait = this.wsBackoffMs; 171 const wait = this.wsBackoffMs;
164 this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs); 172 this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs);
165 setTimeout(() => this.connect(), wait); 173 setTimeout(() => this.connect(), wait);
@@ -182,23 +190,19 @@ class Tile {
182 console.warn(`mux tile ${this.idx}: ${why} (replay failure ${this.replayFailures})`); 190 console.warn(`mux tile ${this.idx}: ${why} (replay failure ${this.replayFailures})`);
183 if (this.replayFailures >= GONE_AFTER_FAILURES) { 191 if (this.replayFailures >= GONE_AFTER_FAILURES) {
184 this.replayDead = true; // sendAttach is gated on this 192 this.replayDead = true; // sendAttach is gated on this
185 this.setBadge('stuck', terminal); 193 this.setStatus('stuck', terminal);
186 return; 194 return;
187 } 195 }
188 const wait = this.replayBackoffMs; 196 const wait = this.replayBackoffMs;
189 this.replayBackoffMs = nextBackoffMs(this.replayBackoffMs); 197 this.replayBackoffMs = nextBackoffMs(this.replayBackoffMs);
190 setTimeout(() => this.sendAttach(true), wait); 198 setTimeout(() => this.sendAttach(true), wait);
191 } 199 }
192 replaySucceeded() { 200 // A frame that applies cleanly is the recovery every terminal state was
193 this.replayFailures = 0; 201 // waiting for — the session shrank and its snapshot fits again, the
194 this.replayBackoffMs = 0; 202 // full session had a slot by the time we re-attached — so it revives
195 // A frame that applies cleanly is the recovery the terminal state was 203 // unconditionally. This is the clearing the old code spelled twice
196 // waiting for (e.g. the session shrank and its snapshot fits again) — 204 // (stuck→up here, full→up at the call site) and still missed once.
197 // clear the give-up flag or the badge lies about a working tile and 205 replaySucceeded() { this.revive('up', 'up'); }
198 // sendAttach stays gated off a tile that no longer deserves it.
199 this.replayDead = false;
200 if (this.badgeIs('stuck')) this.setBadge('up', 'up');
201 }
202 206
203 // The core is unusable — re-init and re-attach from nothing. Everything 207 // The core is unusable — re-init and re-attach from nothing. Everything
204 // it held is gone, so the attach quotes (0,0) and the daemon answers 208 // it held is gone, so the attach quotes (0,0) and the daemon answers
@@ -206,7 +210,7 @@ class Tile {
206 resetCore(why) { 210 resetCore(why) {
207 if (this.core.mux_init(80, 24) !== 0) { 211 if (this.core.mux_init(80, 24) !== 0) {
208 this.replayDead = true; 212 this.replayDead = true;
209 this.setBadge('stuck', 'core failed'); 213 this.setStatus('stuck', 'core failed');
210 return; 214 return;
211 } 215 }
212 this.gotState = false; 216 this.gotState = false;
@@ -330,7 +334,11 @@ class Tile {
330 if (bytes.length < 1) return; 334 if (bytes.length < 1) return;
331 if (bytes[0] === ENV_CONTROL) { 335 if (bytes[0] === ENV_CONTROL) {
332 const { state } = JSON.parse(new TextDecoder().decode(bytes.subarray(1))); 336 const { state } = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
333 this.setBadge(state, state); 337 // The hub speaks about the DAEMON leg, which is not the whole of
338 // this tile: setStatus withholds the badge while the tile holds a
339 // terminal state of its own. The bookkeeping below runs regardless
340 // — sendAttach has its own gate (replayDead) and keeps it.
341 this.setStatus(state, state);
334 if (state === 'up') this.sendAttach(false); // browser owns re-attach 342 if (state === 'up') this.sendAttach(false); // browser owns re-attach
335 return; 343 return;
336 } 344 }
@@ -362,7 +370,6 @@ class Tile {
362 if (r === 0) { 370 if (r === 0) {
363 this.gotState = true; 371 this.gotState = true;
364 this.replaySucceeded(); 372 this.replaySucceeded();
365 if (this.badgeIs('full')) this.setBadge('up', 'up');
366 if (this.scrollPages === 0) this.paintLive(); 373 if (this.scrollPages === 0) this.paintLive();
367 return; 374 return;
368 } 375 }
@@ -378,8 +385,8 @@ class Tile {
378 case MSG.exit_status: { 385 case MSG.exit_status: {
379 // Before any state this is the daemon refusing the attach 386 // Before any state this is the daemon refusing the attach
380 // (session full) — the CLI's own discriminator, mirrored. 387 // (session full) — the CLI's own discriminator, mirrored.
381 if (!this.gotState) this.setBadge('full', 'session full'); 388 if (!this.gotState) this.setStatus('full', 'session full');
382 else this.setBadge('exited', `exited ${payload[0] ?? 0}`); 389 else this.setStatus('exited', `exited ${payload[0] ?? 0}`);
383 return; 390 return;
384 } 391 }
385 case MSG.scrollback_chunk: { 392 case MSG.scrollback_chunk: {
@@ -510,15 +517,14 @@ class Tile {
510 else return; 517 else return;
511 } else { 518 } else {
512 if (this.scrollPages === 0) return; 519 if (this.scrollPages === 0) return;
520 // The last page down IS exitScroll — badge, full repaint, live
521 // paint — so it goes there rather than spelling the exit a second
522 // time. exitScroll refuses a tile that is already live, hence the
523 // test on 1 rather than a decrement to 0.
524 if (this.scrollPages === 1) { this.exitScroll(); return; }
513 this.scrollPages--; 525 this.scrollPages--;
514 } 526 }
515 if (this.scrollPages === 0) { 527 this.renderBadge();
516 this.setBadge('up', 'up');
517 this.core.mux_mark_all_dirty();
518 this.paintLive();
519 return;
520 }
521 this.setBadge('scroll', `history -${this.scrollPages}`);
522 const start = this.core.mux_scroll_start(this.scrollPages, rows); 528 const start = this.core.mux_scroll_start(this.scrollPages, rows);
523 const p = new Uint8Array(6); 529 const p = new Uint8Array(6);
524 const dv = new DataView(p.buffer); 530 const dv = new DataView(p.buffer);
@@ -530,18 +536,54 @@ class Tile {
530 exitScroll() { 536 exitScroll() {
531 if (this.scrollPages === 0) return; 537 if (this.scrollPages === 0) return;
532 this.scrollPages = 0; 538 this.scrollPages = 0;
533 this.setBadge('up', 'up'); 539 this.renderBadge();
534 this.core.mux_mark_all_dirty(); 540 this.reflow(); // mark-all + paint live: the same repaint, one owner
535 this.paintLive();
536 } 541 }
537 542
538 // --- chrome --- 543 // --- chrome ---
539 setBadge(cls, text) { 544 // THE BADGE IS A RENDERING, NOT A STATE. Reading the class back — which
545 // this used to do — made the DOM the state machine's memory and every
546 // writer a peer of every other.
547 //
548 // Two independent facts share the one badge and scroll wins while it
549 // lasts: a tile paged into history says so, and says whatever it was
550 // saying again the moment it returns to live — including a state that
551 // arrived while the user was reading history.
552 renderBadge() {
553 const [cls, text] = this.scrollPages > 0
554 ? ['scroll', `history -${this.scrollPages}`]
555 : [this.status, this.statusText];
540 const b = this.el.querySelector('.badge'); 556 const b = this.el.querySelector('.badge');
541 b.className = `badge ${cls}`; 557 b.className = `badge ${cls}`;
542 b.textContent = text; 558 b.textContent = text;
543 } 559 }
544 badgeIs(cls) { return this.el.querySelector('.badge').classList.contains(cls); } 560 // Link narration — this socket's lifecycle and the hub's control
561 // messages alike — never overwrites a state the tile reached locally
562 // and terminally. A `stuck` tile whose DAEMON leg re-dials is told
563 // 'up': painting that green while sendAttach stays gated on replayDead
564 // is exactly a silent tile that reads healthy. Terminal states do
565 // replace each other — the newest fact about a dead session is still
566 // the true one.
567 setStatus(cls, text) {
568 if (TERMINAL.has(this.status) && !TERMINAL.has(cls)) return;
569 this.status = cls;
570 this.statusText = text;
571 this.renderBadge();
572 }
573 // The ONE place a terminal state is left, and it clears replayDead in
574 // the same breath because the two are one fact: the badge says gave
575 // up, the flag gates every attach. Moving one without the other either
576 // lies (badge stuck, tile sending fine) or goes silent (badge up,
577 // sendAttach gated). Two callers: a frame that applied, a socket that
578 // opened.
579 revive(cls, text) {
580 this.replayDead = false;
581 this.replayFailures = 0;
582 this.replayBackoffMs = 0;
583 this.status = cls; // deliberately past setStatus's precedence
584 this.statusText = text;
585 this.renderBadge();
586 }
545 } 587 }
546 588
547 // --- zoom / focus --- 589 // --- zoom / focus ---
@@ -550,6 +592,11 @@ const shade = document.getElementById('shade');
550 const ime = document.getElementById('ime'); 592 const ime = document.getElementById('ime');
551 593
552 function zoom(tile) { 594 function zoom(tile) {
595 // The click is bound in the constructor and start() is async: a click
596 // that lands in that window has no core to size, resize or key against.
597 // Gated HERE and not at each call because zoom is what makes the tile
598 // reachable at all — no zoom, no keys, no wheel, no paste (spec).
599 if (!tile.core) return;
553 if (zoomedTile) unzoom(); 600 if (zoomedTile) unzoom();
554 zoomedTile = tile; 601 zoomedTile = tile;
555 tile.zoomed = true; 602 tile.zoomed = true;
@@ -586,14 +633,20 @@ document.addEventListener('keydown', (ev) => {
586 if (!t) return; 633 if (!t) return;
587 if (ev.isComposing) return; // IME owns it; compositionend delivers 634 if (ev.isComposing) return; // IME owns it; compositionend delivers
588 const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0); 635 const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0);
589 // Leave genuine browser chords alone (copy/paste arrive as events). 636 // Leave genuine browser chords alone (copy/paste arrive as events):
637 // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which
638 // is how a mac copies and pastes at all. Consuming Cmd+V typed a
639 // literal 'v' into the session and suppressed the paste event with it.
640 // No preventDefault and no bytes — the keymap has no meta concept by
641 // design, so a Cmd chord the browser does not want is simply dropped.
642 if (ev.metaKey) return;
590 if (ev.ctrlKey && ev.shiftKey && (ev.key.toLowerCase() === 'c' || ev.key.toLowerCase() === 'v')) return; 643 if (ev.ctrlKey && ev.shiftKey && (ev.key.toLowerCase() === 'c' || ev.key.toLowerCase() === 'v')) return;
591 if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') { 644 if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') {
592 t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule) 645 t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule)
593 ev.preventDefault(); 646 ev.preventDefault();
594 return; 647 return;
595 } 648 }
596 if (KEY[ev.key] !== undefined && ev.key !== 'char') { 649 if (KEY[ev.key] !== undefined) {
597 ev.preventDefault(); 650 ev.preventDefault();
598 t.sendKey(KEY[ev.key], 0, mods); 651 t.sendKey(KEY[ev.key], 0, mods);
599 return; 652 return;
@@ -635,7 +688,7 @@ window.addEventListener('resize', () => {
635 // rejection left the tile stuck on 'connecting' with the reason only 688 // rejection left the tile stuck on 'connecting' with the reason only
636 // in the console's rejection noise. 689 // in the console's rejection noise.
637 tile.start().catch((err) => { 690 tile.start().catch((err) => {
638 tile.setBadge('gone', 'gone'); 691 tile.setStatus('gone', 'gone');
639 console.error(`mux tile ${i} (${labels[i]}): start failed`, err); 692 console.error(`mux tile ${i} (${labels[i]}): start failed`, err);
640 }); 693 });
641 } 694 }