a73x

c1c27e1f

fix: harden retained web selection state

a73x   2026-08-18 12:27

Commit message
fix: harden retained web selection state

web/mux.js
Old New
@@ -131,7 +131,9 @@ class Tile {
131 this.pendingClipboard = null; 131 this.pendingClipboard = null;
132 this.clipboardVersion = 0; 132 this.clipboardVersion = 0;
133 this.clipboardWriteActive = false; 133 this.clipboardWriteActive = false;
134 this.copyUiOwner = null; // null, 'clipboard', or 'selection'
134 this.viewStartRow = 0; 135 this.viewStartRow = 0;
136 this.requestedViewStartRow = 0;
135 this.selection = null; // {anchor, active, requestId, text} 137 this.selection = null; // {anchor, active, requestId, text}
136 this.nextSelectionId = 0; 138 this.nextSelectionId = 0;
137 this.drag = null; 139 this.drag = null;
@@ -146,7 +148,7 @@ class Tile {
146 this.copyButton = this.el.querySelector('.copy-request'); 148 this.copyButton = this.el.querySelector('.copy-request');
147 this.copyButton.addEventListener('click', (ev) => { 149 this.copyButton.addEventListener('click', (ev) => {
148 ev.stopPropagation(); 150 ev.stopPropagation();
149 this.copyPendingClipboard(); 151 if (this.copyUiOwner === 'clipboard') this.copyPendingClipboard();
150 ime.focus(); 152 ime.focus();
151 }); 153 });
152 this.canvas = document.createElement('canvas'); 154 this.canvas = document.createElement('canvas');
@@ -167,6 +169,9 @@ class Tile {
167 this.canvas.addEventListener('pointercancel', (ev) => { 169 this.canvas.addEventListener('pointercancel', (ev) => {
168 if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection(); 170 if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
169 }); 171 });
172 this.canvas.addEventListener('lostpointercapture', (ev) => {
173 if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
174 });
170 } 175 }
171 176
172 async start() { 177 async start() {
@@ -195,6 +200,7 @@ class Tile {
195 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); 200 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`);
196 this.ws.binaryType = 'arraybuffer'; 201 this.ws.binaryType = 'arraybuffer';
197 this.ws.onopen = () => { 202 this.ws.onopen = () => {
203 this.clearSelection(false);
198 this.wsOpened = true; 204 this.wsOpened = true;
199 this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule 205 this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule
200 this.wsFailures = 0; 206 this.wsFailures = 0;
@@ -253,6 +259,7 @@ class Tile {
253 // it held is gone, so the attach quotes (0,0) and the daemon answers 259 // it held is gone, so the attach quotes (0,0) and the daemon answers
254 // with a snapshot. 260 // with a snapshot.
255 resetCore(why) { 261 resetCore(why) {
262 this.clearSelection(false);
256 if (this.core.mux_init(80, 24) !== 0) { 263 if (this.core.mux_init(80, 24) !== 0) {
257 this.replayDead = true; 264 this.replayDead = true;
258 this.setStatus('stuck', 'core failed'); 265 this.setStatus('stuck', 'core failed');
@@ -297,6 +304,7 @@ class Tile {
297 // control message included. A tile that gave up on replaying must 304 // control message included. A tile that gave up on replaying must
298 // not be talked back into asking for the same frame again. 305 // not be talked back into asking for the same frame again.
299 if (this.replayDead) return; 306 if (this.replayDead) return;
307 this.clearSelection(false);
300 const cols = this.zoomed ? this.zoomCols() : 1; 308 const cols = this.zoomed ? this.zoomCols() : 1;
301 const rows = this.zoomed ? this.zoomRows() : 1; 309 const rows = this.zoomed ? this.zoomRows() : 1;
302 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); 310 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
@@ -448,12 +456,15 @@ class Tile {
448 } 456 }
449 case MSG.scrollback_chunk: { 457 case MSG.scrollback_chunk: {
450 if (this.scrollPages === 0 || payload.length < 6) return; 458 if (this.scrollPages === 0 || payload.length < 6) return;
451 this.viewStartRow = new DataView( 459 const start = new DataView(
452 payload.buffer, payload.byteOffset, 6, 460 payload.buffer, payload.byteOffset, 6,
453 ).getUint32(0, true); 461 ).getUint32(0, true);
454 const rows = payload.subarray(6); // echoed start+count stripped 462 const rows = payload.subarray(6); // echoed start+count stripped
455 if (!this.stage(rows)) return; 463 if (!this.stage(rows)) return;
456 if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll(); 464 if (this.core.mux_scroll_feed(rows.length) === 0) {
465 this.viewStartRow = start;
466 this.paintScroll();
467 }
457 return; 468 return;
458 } 469 }
459 case MSG.term_modes: 470 case MSG.term_modes:
@@ -485,11 +496,29 @@ class Tile {
485 496
486 const version = ++this.clipboardVersion; 497 const version = ++this.clipboardVersion;
487 this.pendingClipboard = text; 498 this.pendingClipboard = text;
488 this.copyButton.className = 'copy-request'; 499 this.renderCopyUi('clipboard', 'copy-request', 'Copy');
489 this.copyButton.textContent = 'Copy';
490 return this.tryClipboardWrite(version, true); 500 return this.tryClipboardWrite(version, true);
491 } 501 }
492 502
503 renderCopyUi(owner, className, text) {
504 this.copyUiOwner = owner;
505 this.copyButton.className = className;
506 this.copyButton.textContent = text;
507 }
508
509 releaseSelectionCopyUi() {
510 if (this.copyUiOwner !== 'selection') return;
511 if (this.pendingClipboard !== null) {
512 this.renderCopyUi(
513 'clipboard',
514 this.clipboardWriteActive ? 'copy-request' : 'copy-request on',
515 'Copy',
516 );
517 } else {
518 this.renderCopyUi(null, 'copy-request', 'Copy');
519 }
520 }
521
493 async tryClipboardWrite(version, automatic) { 522 async tryClipboardWrite(version, automatic) {
494 if (this.clipboardWriteActive) return; 523 if (this.clipboardWriteActive) return;
495 if (version !== this.clipboardVersion || this.pendingClipboard === null) return; 524 if (version !== this.clipboardVersion || this.pendingClipboard === null) return;
@@ -516,25 +545,28 @@ class Tile {
516 } 545 }
517 546
518 if (!succeeded) { 547 if (!succeeded) {
519 this.copyButton.className = automatic 548 if (this.copyUiOwner === 'clipboard') {
520 ? 'copy-request on' 549 this.renderCopyUi(
521 : 'copy-request on error'; 550 'clipboard',
522 this.copyButton.textContent = automatic ? 'Copy' : 'Copy failed'; 551 automatic ? 'copy-request on' : 'copy-request on error',
552 automatic ? 'Copy' : 'Copy failed',
553 );
554 }
523 return; 555 return;
524 } 556 }
525 557
526 this.pendingClipboard = null; 558 this.pendingClipboard = null;
527 this.copyButton.className = 'copy-request on'; 559 if (this.copyUiOwner !== 'clipboard') return;
528 this.copyButton.textContent = 'Copied'; 560 this.renderCopyUi('clipboard', 'copy-request on', 'Copied');
529 setTimeout(() => { 561 setTimeout(() => {
530 if (version !== this.clipboardVersion || this.pendingClipboard !== null) return; 562 if (version !== this.clipboardVersion || this.pendingClipboard !== null ||
531 this.copyButton.className = 'copy-request'; 563 this.copyUiOwner !== 'clipboard') return;
532 this.copyButton.textContent = 'Copy'; 564 this.renderCopyUi(null, 'copy-request', 'Copy');
533 }, 1200); 565 }, 1200);
534 } 566 }
535 567
536 copyPendingClipboard() { 568 copyPendingClipboard() {
537 if (!this.zoomed || this.pendingClipboard === null) return; 569 if (!this.zoomed || this.copyUiOwner !== 'clipboard' || this.pendingClipboard === null) return;
538 return this.tryClipboardWrite(this.clipboardVersion, false); 570 return this.tryClipboardWrite(this.clipboardVersion, false);
539 } 571 }
540 572
@@ -544,8 +576,7 @@ class Tile {
544 if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return; 576 if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return;
545 if (this.core.mux_selection_status() !== 0) { 577 if (this.core.mux_selection_status() !== 0) {
546 this.selection.text = null; 578 this.selection.text = null;
547 this.copyButton.className = 'copy-request on error'; 579 this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable');
548 this.copyButton.textContent = 'Selection unavailable';
549 return; 580 return;
550 } 581 }
551 const ptr = this.core.mux_selection_ptr(); 582 const ptr = this.core.mux_selection_ptr();
@@ -555,8 +586,10 @@ class Tile {
555 const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len); 586 const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
556 try { 587 try {
557 this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); 588 this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
589 this.releaseSelectionCopyUi();
558 } catch (_) { 590 } catch (_) {
559 this.selection.text = null; 591 this.selection.text = null;
592 this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable');
560 } 593 }
561 } 594 }
562 595
@@ -580,10 +613,21 @@ class Tile {
580 613
581 clearSelection(repaint = true) { 614 clearSelection(repaint = true) {
582 this.selection = null; 615 this.selection = null;
616 this.stopSelectionDrag();
617 this.releaseSelectionCopyUi();
618 if (repaint && this.core) this.reflow();
619 }
620
621 stopSelectionDrag() {
622 const pointerId = this.drag?.pointerId;
583 this.drag = null; 623 this.drag = null;
624 this.lastPointerY = null;
584 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); 625 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
585 this.selectionScrollTimer = null; 626 this.selectionScrollTimer = null;
586 if (repaint && this.core) this.reflow(); 627 // Clear drag state before release: releasePointerCapture queues a
628 // lostpointercapture event, which must not erase a completed selection.
629 if (pointerId !== undefined && this.canvas.hasPointerCapture(pointerId))
630 this.canvas.releasePointerCapture(pointerId);
587 } 631 }
588 632
589 beginSelection(ev) { 633 beginSelection(ev) {
@@ -614,14 +658,10 @@ class Tile {
614 if (!this.drag || ev.pointerId !== this.drag.pointerId) return; 658 if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
615 ev.preventDefault(); 659 ev.preventDefault();
616 const moved = this.drag.moved; 660 const moved = this.drag.moved;
617 if (this.canvas.hasPointerCapture(ev.pointerId)) this.canvas.releasePointerCapture(ev.pointerId); 661 this.stopSelectionDrag();
618 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
619 this.selectionScrollTimer = null;
620 this.drag = null;
621 if (!moved) { this.clearSelection(); return; } 662 if (!moved) { this.clearSelection(); return; }
622 663
623 this.nextSelectionId = (this.nextSelectionId + 1) >>> 0; 664 this.nextSelectionId = (this.nextSelectionId + 1) >>> 0;
624 if (this.nextSelectionId === 0) this.nextSelectionId = 1;
625 this.selection.requestId = this.nextSelectionId; 665 this.selection.requestId = this.nextSelectionId;
626 const a = this.selection.anchor, b = this.selection.active; 666 const a = this.selection.anchor, b = this.selection.active;
627 const n = this.core.mux_selection_request( 667 const n = this.core.mux_selection_request(
@@ -683,9 +723,27 @@ class Tile {
683 } 723 }
684 paintLive() { 724 paintLive() {
685 this.viewStartRow = this.core.mux_history_rows(); 725 this.viewStartRow = this.core.mux_history_rows();
726 this.requestedViewStartRow = this.viewStartRow;
686 this.sizeCanvas(); 727 this.sizeCanvas();
687 const n = this.core.mux_read_viewport(); 728 const n = this.core.mux_read_viewport();
688 for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i)); 729 const paintedRows = new Set();
730 for (let i = 0; i < n; i++) {
731 const row = this.core.mux_dirty_row(i);
732 this.paintRow(row);
733 paintedRows.add(row);
734 }
735 // The overlay is translucent. Restore every selected row from the
736 // terminal cells before compositing it again, even when a delta dirtied
737 // some other row, or repeated live paints would darken the selection.
738 const ordered = this.orderedSelection();
739 if (ordered) {
740 const rows = this.core.mux_rows();
741 const first = Math.max(0, ordered[0].row - this.viewStartRow);
742 const last = Math.min(rows - 1, ordered[1].row - this.viewStartRow);
743 for (let row = first; row <= last; row++) {
744 if (!paintedRows.has(row)) this.paintRow(row);
745 }
746 }
689 this.paintCursor(); 747 this.paintCursor();
690 this.paintSelection(); 748 this.paintSelection();
691 } 749 }
@@ -782,7 +840,7 @@ class Tile {
782 } 840 }
783 this.renderBadge(); 841 this.renderBadge();
784 const start = this.core.mux_scroll_start(this.scrollPages, rows); 842 const start = this.core.mux_scroll_start(this.scrollPages, rows);
785 this.viewStartRow = start; 843 this.requestedViewStartRow = start;
786 const p = new Uint8Array(6); 844 const p = new Uint8Array(6);
787 const dv = new DataView(p.buffer); 845 const dv = new DataView(p.buffer);
788 dv.setUint32(0, start, true); 846 dv.setUint32(0, start, true);
@@ -880,8 +938,7 @@ function unzoom() {
880 // exit-scroll or wall reflow work can run. 938 // exit-scroll or wall reflow work can run.
881 was.clipboardVersion++; 939 was.clipboardVersion++;
882 was.pendingClipboard = null; 940 was.pendingClipboard = null;
883 was.copyButton.className = 'copy-request'; 941 was.renderCopyUi(null, 'copy-request', 'Copy');
884 was.copyButton.textContent = 'Copy';
885 was.exitScroll(); 942 was.exitScroll();
886 was.zoomed = false; 943 was.zoomed = false;
887 was.el.classList.remove('zoomed'); 944 was.el.classList.remove('zoomed');
web/verify.js
Old New
@@ -383,7 +383,10 @@ function browserShell(source) {
383 getBoundingClientRect() { return this.rect; } 383 getBoundingClientRect() { return this.rect; }
384 setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); } 384 setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); }
385 hasPointerCapture(pointerId) { return this.capturedPointers.has(pointerId); } 385 hasPointerCapture(pointerId) { return this.capturedPointers.has(pointerId); }
386 releasePointerCapture(pointerId) { this.capturedPointers.delete(pointerId); } 386 releasePointerCapture(pointerId) {
387 this.capturedPointers.delete(pointerId);
388 this.dispatchEvent('lostpointercapture', { pointerId });
389 }
387 getContext() { 390 getContext() {
388 return { 391 return {
389 measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), 392 measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }),
@@ -629,6 +632,7 @@ async function verifyClipboardShell(shell, html) {
629 h.unzoom(); 632 h.unzoom();
630 check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 3); 633 check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 3);
631 check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null); 634 check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null);
635 check('unzoom releases shared copy-control ownership', leaving.tile.copyUiOwner, null);
632 check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); 636 check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy');
633 check('unzoom still reflows once', reflows, 1); 637 check('unzoom still reflows once', reflows, 1);
634 inFlight.resolve(); 638 inFlight.resolve();
@@ -710,7 +714,7 @@ async function verifyClipboardShell(shell, html) {
710 check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true); 714 check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true);
711 } 715 }
712 716
713 function verifySelectionShell(shell, html) { 717 async function verifySelectionShell(shell, html) {
714 const h = browserShell(shell); 718 const h = browserShell(shell);
715 const wall = h.document.createElement('div'); 719 const wall = h.document.createElement('div');
716 const tile = new h.Tile(3, 'selection', wall, ''); 720 const tile = new h.Tile(3, 'selection', wall, '');
@@ -721,9 +725,11 @@ function verifySelectionShell(shell, html) {
721 && tile.lastPointerY === null 725 && tile.lastPointerY === null
722 && tile.selectionScrollTimer === null; 726 && tile.selectionScrollTimer === null;
723 check('tile initializes retained selection state', initialState, true); 727 check('tile initializes retained selection state', initialState, true);
728 check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0);
729 check('tile initializes shared copy-control ownership', tile.copyUiOwner, null);
724 check( 730 check(
725 'tile binds all pointer selection events', 731 'tile binds all pointer selection events',
726 ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'] 732 ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'lostpointercapture']
727 .every((name) => tile.canvas.listeners.has(name)), 733 .every((name) => tile.canvas.listeners.has(name)),
728 true, 734 true,
729 ); 735 );
@@ -751,6 +757,7 @@ function verifySelectionShell(shell, html) {
751 const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); 757 const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), '');
752 const memory = { buffer: new ArrayBuffer(1024) }; 758 const memory = { buffer: new ArrayBuffer(1024) };
753 let requestResult = 16; 759 let requestResult = 16;
760 let scrollFeedResult = 0;
754 let result = { id: 0, status: 3, ptr: 96, len: 0 }; 761 let result = { id: 0, status: 3, ptr: 96, len: 0 };
755 const requestCalls = []; 762 const requestCalls = [];
756 selected.core = { 763 selected.core = {
@@ -770,7 +777,9 @@ function verifySelectionShell(shell, html) {
770 mux_output_ptr: () => 256, 777 mux_output_ptr: () => 256,
771 mux_output_len: () => requestResult === 16 ? 16 : 0, 778 mux_output_len: () => requestResult === 16 ? 16 : 0,
772 mux_scroll_start: (pages, rows) => 30 - pages * rows, 779 mux_scroll_start: (pages, rows) => 30 - pages * rows,
773 mux_scroll_feed: () => 0, 780 mux_scroll_feed: () => scrollFeedResult,
781 mux_init: () => 0,
782 mux_attach_payload: () => 20,
774 mux_client_frame: (type) => type === 0x90 ? 4 : 0, 783 mux_client_frame: (type) => type === 0x90 ? 4 : 0,
775 mux_selection_id: () => result.id | 0, 784 mux_selection_id: () => result.id | 0,
776 mux_selection_status: () => result.status, 785 mux_selection_status: () => result.status,
@@ -798,6 +807,7 @@ function verifySelectionShell(shell, html) {
798 return { 807 return {
799 tile: selected, memory, requestCalls, sent, 808 tile: selected, memory, requestCalls, sent,
800 setRequestResult(value) { requestResult = value; }, 809 setRequestResult(value) { requestResult = value; },
810 setScrollFeedResult(value) { scrollFeedResult = value; },
801 setResult(id, status, textBytes) { 811 setResult(id, status, textBytes) {
802 const bytes = Uint8Array.from(textBytes); 812 const bytes = Uint8Array.from(textBytes);
803 new Uint8Array(memory.buffer).set(bytes, 96); 813 new Uint8Array(memory.buffer).set(bytes, 96);
@@ -857,6 +867,7 @@ function verifySelectionShell(shell, html) {
857 forward.tile.canvas.dispatchEvent('pointerup', up); 867 forward.tile.canvas.dispatchEvent('pointerup', up);
858 check('drag release prevents native pointer behavior', up.wasPrevented(), true); 868 check('drag release prevents native pointer behavior', up.wasPrevented(), true);
859 check('drag release gives up pointer capture', forward.tile.canvas.hasPointerCapture(7), false); 869 check('drag release gives up pointer capture', forward.tile.canvas.hasPointerCapture(7), false);
870 check('normal capture release preserves completed selection', forward.tile.selection !== null, true);
860 check('drag release clears the scroll timer', forward.tile.selectionScrollTimer, null); 871 check('drag release clears the scroll timer', forward.tile.selectionScrollTimer, null);
861 check('drag release sends exactly one frame', forward.sent.length, 1); 872 check('drag release sends exactly one frame', forward.sent.length, 1);
862 check('drag release uses selection-request wire type', forward.sent[0]?.type, 0x0b); 873 check('drag release uses selection-request wire type', forward.sent[0]?.type, 0x0b);
@@ -903,6 +914,17 @@ function verifySelectionShell(shell, html) {
903 check('pointer cancel stops the scroll timer', h.intervals[cancelTimer - 1]?.cleared, true); 914 check('pointer cancel stops the scroll timer', h.intervals[cancelTimer - 1]?.cleared, true);
904 check('pointer cancel sends no request', cancelled.sent.length, 0); 915 check('pointer cancel sends no request', cancelled.sent.length, 0);
905 916
917 const lost = makeTile();
918 lost.tile.canvas.dispatchEvent('pointerdown', pointer(12, 2, 1));
919 const lostTimer = lost.tile.selectionScrollTimer;
920 lost.tile.canvas.capturedPointers.delete(12);
921 lost.tile.canvas.dispatchEvent('lostpointercapture', { pointerId: 12 });
922 check('unexpected lost pointer capture clears selection', lost.tile.selection, null);
923 check('unexpected lost pointer capture clears drag state', lost.tile.drag, null);
924 check('unexpected lost pointer capture clears pointer position', lost.tile.lastPointerY, null);
925 check('unexpected lost pointer capture stops the timer', h.intervals[lostTimer - 1]?.cleared, true);
926 check('unexpected lost pointer capture sends no request', lost.sent.length, 0);
927
906 const refused = makeTile(); 928 const refused = makeTile();
907 refused.setRequestResult(-3); 929 refused.setRequestResult(-3);
908 refused.tile.canvas.dispatchEvent('pointerdown', pointer(11, 1, 1)); 930 refused.tile.canvas.dispatchEvent('pointerdown', pointer(11, 1, 1));
@@ -913,9 +935,14 @@ function verifySelectionShell(shell, html) {
913 935
914 const scrolling = makeTile(); 936 const scrolling = makeTile();
915 scrolling.tile.scrollPages = 0; 937 scrolling.tile.scrollPages = 0;
938 scrolling.tile.viewStartRow = 30;
916 scrolling.tile.onWheel({ deltaY: -1 }); 939 scrolling.tile.onWheel({ deltaY: -1 });
917 check('wheel request immediately updates displayed start row', scrolling.tile.viewStartRow, 25); 940 check('wheel request preserves the currently painted start row', scrolling.tile.viewStartRow, 30);
941 check('wheel retains the separately requested start row', scrolling.tile.requestedViewStartRow, 25);
918 check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05); 942 check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05);
943 scrolling.tile.canvas.dispatchEvent('pointerdown', pointer(13, 2, 2));
944 check('quick drag before history reply maps against painted rows', scrolling.tile.selection.anchor.row, 32);
945 scrolling.tile.canvas.dispatchEvent('pointercancel', { pointerId: 13 });
919 let paintedScroll = 0; 946 let paintedScroll = 0;
920 scrolling.tile.paintScroll = () => { paintedScroll++; }; 947 scrolling.tile.paintScroll = () => { paintedScroll++; };
921 const chunk = new Uint8Array(6 + 3); 948 const chunk = new Uint8Array(6 + 3);
@@ -931,6 +958,20 @@ function verifySelectionShell(shell, html) {
931 check('scrollback echo is authoritative for displayed start row', scrolling.tile.viewStartRow, 17); 958 check('scrollback echo is authoritative for displayed start row', scrolling.tile.viewStartRow, 17);
932 check('scrollback echo paints the history viewport', paintedScroll, 1); 959 check('scrollback echo paints the history viewport', paintedScroll, 1);
933 960
961 const failedStage = makeTile();
962 failedStage.tile.scrollPages = 1;
963 failedStage.tile.viewStartRow = 30;
964 failedStage.tile.stage = () => false;
965 failedStage.tile.onMessage(envelope);
966 check('failed scrollback staging preserves painted start row', failedStage.tile.viewStartRow, 30);
967
968 const failedFeed = makeTile();
969 failedFeed.tile.scrollPages = 1;
970 failedFeed.tile.viewStartRow = 30;
971 failedFeed.setScrollFeedResult(-3);
972 failedFeed.tile.onMessage(envelope);
973 check('failed scrollback decode preserves painted start row', failedFeed.tile.viewStartRow, 30);
974
934 const painted = makeTile(); 975 const painted = makeTile();
935 const order = []; 976 const order = [];
936 painted.tile.sizeCanvas = () => {}; 977 painted.tile.sizeCanvas = () => {};
@@ -947,6 +988,33 @@ function verifySelectionShell(shell, html) {
947 check('history paint retains echoed start row', painted.tile.viewStartRow, 12); 988 check('history paint retains echoed start row', painted.tile.viewStartRow, 12);
948 check('history selection overlay paints after cells', order.join('|'), 'row0|row1|row2|row3|row4|selection'); 989 check('history selection overlay paints after cells', order.join('|'), 'row0|row1|row2|row3|row4|selection');
949 990
991 const partial = makeTile();
992 partial.tile.sizeCanvas = () => {};
993 partial.tile.selection = {
994 anchor: { row: 31, col: 2 }, active: { row: 33, col: 4 }, requestId: 1, text: null,
995 };
996 let dirtyRows = [0];
997 partial.tile.core.mux_read_viewport = () => dirtyRows.length;
998 partial.tile.core.mux_dirty_row = (i) => dirtyRows[i];
999 const partialOps = [];
1000 partial.tile.paintRow = (row) => { partialOps.push(`row${row}`); };
1001 partial.tile.paintCursor = () => { partialOps.push('cursor'); };
1002 partial.tile.paintSelection = () => { partialOps.push('overlay'); };
1003 partial.tile.paintLive();
1004 check(
1005 'partial live paint restores every selected row before translucent overlay',
1006 partialOps.join('|'),
1007 'row0|row1|row2|row3|cursor|overlay',
1008 );
1009 dirtyRows = [2, 4];
1010 partialOps.length = 0;
1011 partial.tile.paintLive();
1012 check(
1013 'successive partial live paint restores selected rows without duplicates',
1014 partialOps.join('|'),
1015 'row2|row4|row1|row3|cursor|overlay',
1016 );
1017
950 const overlay = makeTile(); 1018 const overlay = makeTile();
951 const fills = []; 1019 const fills = [];
952 overlay.tile.ctx = { 1020 overlay.tile.ctx = {
@@ -1007,6 +1075,134 @@ function verifySelectionShell(shell, html) {
1007 replyEnvelope.set(replyPayload, 6); 1075 replyEnvelope.set(replyPayload, 6);
1008 routed.tile.onMessage(replyEnvelope); 1076 routed.tile.onMessage(replyEnvelope);
1009 check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed'); 1077 check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed');
1078
1079 const blockedCopy = makeTile();
1080 blockedCopy.tile.pendingClipboard = 'unrelated OSC 52 text';
1081 blockedCopy.tile.clipboardVersion = 1;
1082 blockedCopy.tile.copyUiOwner = 'clipboard';
1083 blockedCopy.tile.selection = {
1084 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 71, text: null,
1085 };
1086 blockedCopy.setResult(71, 3, []);
1087 blockedCopy.tile.onSelectionReply();
1088 const blockedWrites = [];
1089 h.navigator.clipboard = { writeText: (text) => { blockedWrites.push(text); return Promise.resolve(); } };
1090 blockedCopy.tile.copyButton.dispatchEvent('click', { stopPropagation() {} });
1091 await flushPromises();
1092 check('selection failure owns the shared copy control', blockedCopy.tile.copyUiOwner, 'selection');
1093 check('selection failure button cannot copy unrelated OSC 52 text', blockedWrites.length, 0);
1094 check('selection failure leaves unrelated OSC 52 text pending', blockedCopy.tile.pendingClipboard, 'unrelated OSC 52 text');
1095
1096 const inFlightUi = makeTile();
1097 inFlightUi.tile.pendingClipboard = 'in flight';
1098 inFlightUi.tile.clipboardVersion = 1;
1099 inFlightUi.tile.copyUiOwner = 'clipboard';
1100 const inFlightWrite = deferred();
1101 h.navigator.clipboard = { writeText: () => inFlightWrite.promise };
1102 const inFlightRun = inFlightUi.tile.tryClipboardWrite(1, true);
1103 inFlightUi.tile.selection = {
1104 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 72, text: null,
1105 };
1106 inFlightUi.setResult(72, 2, []);
1107 inFlightUi.tile.onSelectionReply();
1108 inFlightWrite.resolve();
1109 await inFlightRun;
1110 check(
1111 'settled in-flight OSC 52 write cannot overwrite selection failure status',
1112 `${inFlightUi.tile.copyButton.className}|${inFlightUi.tile.copyButton.textContent}`,
1113 'copy-request on error|Selection unavailable',
1114 );
1115
1116 const timerUi = makeTile();
1117 timerUi.tile.pendingClipboard = 'copied before selection failure';
1118 timerUi.tile.clipboardVersion = 1;
1119 timerUi.tile.copyUiOwner = 'clipboard';
1120 h.navigator.clipboard = { writeText: () => Promise.resolve() };
1121 await timerUi.tile.tryClipboardWrite(1, true);
1122 const oldClipboardTimer = h.timers.at(-1);
1123 timerUi.tile.selection = {
1124 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 73, text: null,
1125 };
1126 timerUi.setResult(73, 1, []);
1127 timerUi.tile.onSelectionReply();
1128 oldClipboardTimer.fn();
1129 check(
1130 'old OSC 52 feedback timer cannot overwrite selection failure status',
1131 `${timerUi.tile.copyButton.className}|${timerUi.tile.copyButton.textContent}`,
1132 'copy-request on error|Selection unavailable',
1133 );
1134
1135 const clearedUi = makeTile();
1136 clearedUi.tile.selection = {
1137 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 74, text: null,
1138 };
1139 clearedUi.setResult(74, 3, []);
1140 clearedUi.tile.onSelectionReply();
1141 clearedUi.tile.clearSelection(false);
1142 check('clearing failed selection removes stale error UI', `${clearedUi.tile.copyButton.className}|${clearedUi.tile.copyButton.textContent}`, 'copy-request|Copy');
1143
1144 const successfulUi = makeTile();
1145 successfulUi.tile.selection = {
1146 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 75, text: null,
1147 };
1148 successfulUi.setResult(75, 3, []);
1149 successfulUi.tile.onSelectionReply();
1150 successfulUi.setResult(75, 0, Buffer.from('available'));
1151 successfulUi.tile.onSelectionReply();
1152 check('successful selection removes stale unavailable UI', `${successfulUi.tile.copyButton.className}|${successfulUi.tile.copyButton.textContent}`, 'copy-request|Copy');
1153
1154 const newSelectionUi = makeTile();
1155 newSelectionUi.tile.selection = {
1156 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null,
1157 };
1158 newSelectionUi.setResult(76, 3, []);
1159 newSelectionUi.tile.onSelectionReply();
1160 newSelectionUi.tile.canvas.dispatchEvent('pointerdown', pointer(17, 1, 1));
1161 check('starting a new selection removes stale unavailable UI', `${newSelectionUi.tile.copyButton.className}|${newSelectionUi.tile.copyButton.textContent}`, 'copy-request|Copy');
1162 newSelectionUi.tile.canvas.dispatchEvent('pointercancel', { pointerId: 17 });
1163
1164 const wrap = makeTile();
1165 wrap.tile.nextSelectionId = 0xffffffff;
1166 wrap.tile.canvas.dispatchEvent('pointerdown', pointer(14, 1, 1));
1167 wrap.tile.canvas.dispatchEvent('pointermove', pointer(14, 2, 1));
1168 wrap.tile.canvas.dispatchEvent('pointerup', pointer(14, 2, 1));
1169 check('selection request ID naturally wraps from max u32 to zero', wrap.requestCalls[0]?.[0], 0);
1170 check('wrapped zero remains the retained correlation ID', wrap.tile.selection.requestId, 0);
1171 wrap.setResult(0, 0, Buffer.from('wrapped'));
1172 wrap.tile.onSelectionReply();
1173 check('wrapped zero reply correlates normally', wrap.tile.selection.text, 'wrapped');
1174
1175 const reset = makeTile();
1176 reset.tile.selection = {
1177 anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session',
1178 };
1179 reset.tile.drag = { pointerId: 15, moved: true };
1180 reset.tile.canvas.setPointerCapture(15);
1181 reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120);
1182 const resetTimer = reset.tile.selectionScrollTimer;
1183 reset.tile.resetCore('selection test');
1184 check('destructive core reset clears resolved selection', reset.tile.selection, null);
1185 check('destructive core reset clears drag state', reset.tile.drag, null);
1186 check('destructive core reset clears pointer position', reset.tile.lastPointerY, null);
1187 check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true);
1188
1189 const reconnect = makeTile();
1190 reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1));
1191 const reconnectTimer = reconnect.tile.selectionScrollTimer;
1192 reconnect.tile.selection.requestId = 44;
1193 reconnect.tile.connect();
1194 reconnect.tile.ws.onopen();
1195 check('new socket clears pending selection from prior connection', reconnect.tile.selection, null);
1196 check('new socket clears prior drag state', reconnect.tile.drag, null);
1197 check('new socket clears prior pointer position', reconnect.tile.lastPointerY, null);
1198 check('new socket stops prior selection timer', h.intervals[reconnectTimer - 1]?.cleared, true);
1199
1200 const reattach = makeTile();
1201 reattach.tile.selection = {
1202 anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 45, text: 'old attach',
1203 };
1204 reattach.tile.sendAttach(false);
1205 check('reattach clears selection text from prior session', reattach.tile.selection, null);
1010 } 1206 }
1011 1207
1012 // --- wire builders (layouts golden-pinned in protocol.zig) --- 1208 // --- wire builders (layouts golden-pinned in protocol.zig) ---
@@ -1436,7 +1632,7 @@ async function main() {
1436 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); 1632 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8');
1437 const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); 1633 const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
1438 await verifyClipboardShell(shell, html); 1634 await verifyClipboardShell(shell, html);
1439 verifySelectionShell(shell, html); 1635 await verifySelectionShell(shell, html);
1440 1636
1441 // Mutation fixture for the source checks below. A correct-looking route 1637 // Mutation fixture for the source checks below. A correct-looking route
1442 // in either kind of comment must be invisible, while live literal 1638 // in either kind of comment must be invisible, while live literal