e30c010e
fix(web): the badge stops being the state machine's memory
a73x 2026-08-13 14:18
Commit message
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 | } |