a73x

104de050

fix: bound web selection lifecycle

a73x   2026-08-18 12:27

Commit message
fix: bound web selection lifecycle

web/mux.js
Old New
@@ -36,6 +36,10 @@ const PASTE_CHUNK = 32 * 1024;
36 // Two seconds tolerates an ordinary remote round trip without letting the 36 // Two seconds tolerates an ordinary remote round trip without letting the
37 // 120ms drag interval flood retries while one request is still outstanding. 37 // 120ms drag interval flood retries while one request is still outstanding.
38 const SCROLL_REQUEST_TIMEOUT_MS = 2000; 38 const SCROLL_REQUEST_TIMEOUT_MS = 2000;
39 // Extraction may format substantially more data than a viewport fetch. Five
40 // seconds bounds old-daemon/no-reply behavior without treating normal remote
41 // extraction of a large selection as unavailable too eagerly.
42 const SELECTION_REQUEST_TIMEOUT_MS = 5000;
39 43
40 // The reconnect schedule, and it is NOT a new one: this is client.zig's 44 // The reconnect schedule, and it is NOT a new one: this is client.zig's
41 // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers") 45 // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers")
@@ -143,6 +147,10 @@ class Tile {
143 this.scrollRequestTimer = null; 147 this.scrollRequestTimer = null;
144 this.selection = null; // {anchor, active, requestId, text} 148 this.selection = null; // {anchor, active, requestId, text}
145 this.nextSelectionId = 0; 149 this.nextSelectionId = 0;
150 this.selectionRequestTimer = null;
151 this.selectionRequestVersion = 0;
152 this.selectionFeedbackTimer = null;
153 this.selectionFeedbackVersion = 0;
146 this.drag = null; 154 this.drag = null;
147 this.lastPointerX = null; 155 this.lastPointerX = null;
148 this.lastPointerY = null; 156 this.lastPointerY = null;
@@ -227,6 +235,7 @@ class Tile {
227 this.ws.onerror = () => {}; 235 this.ws.onerror = () => {};
228 this.ws.onclose = () => { 236 this.ws.onclose = () => {
229 this.cancelScrollRequest(); 237 this.cancelScrollRequest();
238 this.clearSelection();
230 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; 239 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1;
231 const dead = this.wsFailures >= GONE_AFTER_FAILURES; 240 const dead = this.wsFailures >= GONE_AFTER_FAILURES;
232 this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); 241 this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting');
@@ -323,8 +332,8 @@ class Tile {
323 // control message included. A tile that gave up on replaying must 332 // control message included. A tile that gave up on replaying must
324 // not be talked back into asking for the same frame again. 333 // not be talked back into asking for the same frame again.
325 this.cancelScrollRequest(); 334 this.cancelScrollRequest();
326 if (this.replayDead) return;
327 this.clearSelection(); 335 this.clearSelection();
336 if (this.replayDead) return;
328 const cols = this.zoomed ? this.zoomCols() : 1; 337 const cols = this.zoomed ? this.zoomCols() : 1;
329 const rows = this.zoomed ? this.zoomRows() : 1; 338 const rows = this.zoomed ? this.zoomRows() : 1;
330 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); 339 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
@@ -428,7 +437,10 @@ class Tile {
428 // this tile: setStatus withholds the badge while the tile holds a 437 // this tile: setStatus withholds the badge while the tile holds a
429 // terminal state of its own. The bookkeeping below runs regardless 438 // terminal state of its own. The bookkeeping below runs regardless
430 // — sendAttach has its own gate (replayDead) and keeps it. 439 // — sendAttach has its own gate (replayDead) and keeps it.
431 if (state !== 'up') this.cancelScrollRequest(); 440 if (state !== 'up') {
441 this.cancelScrollRequest();
442 this.clearSelection();
443 }
432 this.setStatus(state, state); 444 this.setStatus(state, state);
433 if (state === 'up') this.sendAttach(false); // browser owns re-attach 445 if (state === 'up') this.sendAttach(false); // browser owns re-attach
434 return; 446 return;
@@ -442,7 +454,16 @@ class Tile {
442 switch (type) { 454 switch (type) {
443 case MSG.snapshot: 455 case MSG.snapshot:
444 case MSG.delta: { 456 case MSG.delta: {
445 if (type === MSG.snapshot) this.cancelScrollRequest(); 457 if (type === MSG.snapshot) {
458 // A snapshot can replace the grid dimensions. History scratch and
459 // retained coordinates name the old geometry, so leave history
460 // without painting it; the successful apply below owns the one
461 // repaint from the new live snapshot.
462 this.clearSelection(false);
463 this.cancelScrollRequest();
464 this.scrollPages = 0;
465 this.renderBadge();
466 }
446 // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone 467 // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone
447 // proves the attach was admitted — decodable or not — while a 468 // proves the attach was admitted — decodable or not — while a
448 // short snapshot proves nothing. Without this an undecodable 469 // short snapshot proves nothing. Without this an undecodable
@@ -476,6 +497,7 @@ class Tile {
476 } 497 }
477 case MSG.exit_status: { 498 case MSG.exit_status: {
478 this.cancelScrollRequest(); 499 this.cancelScrollRequest();
500 this.clearSelection();
479 // Before any state this is the daemon refusing the attach 501 // Before any state this is the daemon refusing the attach
480 // (session full) — the CLI's own discriminator, mirrored. 502 // (session full) — the CLI's own discriminator, mirrored.
481 if (!this.gotState) this.setStatus('full', 'session full'); 503 if (!this.gotState) this.setStatus('full', 'session full');
@@ -535,6 +557,7 @@ class Tile {
535 const text = clipboardText(encoded); 557 const text = clipboardText(encoded);
536 if (text === null) return; 558 if (text === null) return;
537 559
560 this.clearSelectionFeedback();
538 const version = ++this.clipboardVersion; 561 const version = ++this.clipboardVersion;
539 this.pendingClipboard = text; 562 this.pendingClipboard = text;
540 this.renderCopyUi('clipboard', 'copy-request', 'Copy'); 563 this.renderCopyUi('clipboard', 'copy-request', 'Copy');
@@ -618,6 +641,7 @@ class Tile {
618 async copySelection() { 641 async copySelection() {
619 const selection = this.selection; 642 const selection = this.selection;
620 if (!this.zoomed || !selection || selection.text === null) return; 643 if (!this.zoomed || !selection || selection.text === null) return;
644 this.clearSelectionFeedback();
621 const version = ++this.selectionCopyVersion; 645 const version = ++this.selectionCopyVersion;
622 this.renderCopyUi('selection', 'copy-request', 'Copy'); 646 this.renderCopyUi('selection', 'copy-request', 'Copy');
623 let succeeded = false; 647 let succeeded = false;
@@ -651,9 +675,10 @@ class Tile {
651 if (!this.selection) return; 675 if (!this.selection) return;
652 // A Zig u32 result reaches JavaScript as a signed WASM i32. 676 // A Zig u32 result reaches JavaScript as a signed WASM i32.
653 if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return; 677 if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return;
678 this.invalidateSelectionRequest();
654 if (this.core.mux_selection_status() !== 0) { 679 if (this.core.mux_selection_status() !== 0) {
655 this.selection.text = null; 680 this.selection.text = null;
656 this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable'); 681 this.showSelectionUnavailable();
657 return; 682 return;
658 } 683 }
659 const ptr = this.core.mux_selection_ptr(); 684 const ptr = this.core.mux_selection_ptr();
@@ -663,10 +688,11 @@ class Tile {
663 const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len); 688 const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
664 try { 689 try {
665 this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); 690 this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
691 this.clearSelectionFeedback();
666 this.releaseSelectionCopyUi(); 692 this.releaseSelectionCopyUi();
667 } catch (_) { 693 } catch (_) {
668 this.selection.text = null; 694 this.selection.text = null;
669 this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable'); 695 this.showSelectionUnavailable();
670 } 696 }
671 } 697 }
672 698
@@ -690,6 +716,8 @@ class Tile {
690 716
691 clearSelection(repaint = true) { 717 clearSelection(repaint = true) {
692 const hadSelection = this.selection !== null; 718 const hadSelection = this.selection !== null;
719 this.invalidateSelectionRequest();
720 this.clearSelectionFeedback();
693 if (hadSelection) this.selectionCopyVersion++; 721 if (hadSelection) this.selectionCopyVersion++;
694 this.selection = null; 722 this.selection = null;
695 this.stopSelectionDrag(); 723 this.stopSelectionDrag();
@@ -697,6 +725,54 @@ class Tile {
697 if (repaint && hadSelection && this.core) this.reflow(); 725 if (repaint && hadSelection && this.core) this.reflow();
698 } 726 }
699 727
728 invalidateSelectionRequest() {
729 if (this.selectionRequestTimer !== null) clearTimeout(this.selectionRequestTimer);
730 this.selectionRequestTimer = null;
731 this.selectionRequestVersion++;
732 }
733
734 startSelectionRequestTimeout(selection, reservedVersion = null) {
735 if (reservedVersion === null) {
736 this.invalidateSelectionRequest();
737 reservedVersion = this.selectionRequestVersion;
738 }
739 const version = reservedVersion;
740 const requestId = selection.requestId;
741 const timer = setTimeout(() => {
742 if (this.selectionRequestTimer !== timer ||
743 this.selectionRequestVersion !== version ||
744 this.selection !== selection || selection.requestId !== requestId) return;
745 this.selectionRequestTimer = null;
746 this.selectionRequestVersion++;
747 selection.text = null;
748 this.showSelectionUnavailable();
749 }, SELECTION_REQUEST_TIMEOUT_MS);
750 this.selectionRequestTimer = timer;
751 }
752
753 clearSelectionFeedback() {
754 if (this.selectionFeedbackTimer !== null) clearTimeout(this.selectionFeedbackTimer);
755 this.selectionFeedbackTimer = null;
756 this.selectionFeedbackVersion++;
757 }
758
759 showSelectionUnavailable() {
760 const selection = this.selection;
761 if (!selection) return;
762 this.clearSelectionFeedback();
763 const version = this.selectionFeedbackVersion;
764 this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable');
765 const timer = setTimeout(() => {
766 if (this.selectionFeedbackTimer !== timer) return;
767 this.selectionFeedbackTimer = null;
768 if (this.selectionFeedbackVersion !== version ||
769 this.selection !== selection || this.copyUiOwner !== 'selection') return;
770 this.selectionFeedbackVersion++;
771 this.releaseSelectionCopyUi();
772 }, 1200);
773 this.selectionFeedbackTimer = timer;
774 }
775
700 stopSelectionDrag() { 776 stopSelectionDrag() {
701 const pointerId = this.drag?.pointerId; 777 const pointerId = this.drag?.pointerId;
702 this.drag = null; 778 this.drag = null;
@@ -758,8 +834,17 @@ class Tile {
758 this.nextSelectionId, a.row, a.col, b.row, b.col, 834 this.nextSelectionId, a.row, a.col, b.row, b.col,
759 ); 835 );
760 if (n === 16) { 836 if (n === 16) {
837 const selection = this.selection;
838 this.invalidateSelectionRequest();
839 const requestVersion = this.selectionRequestVersion;
840 if (!this.sendFrame(MSG.selection_req, this.outBytes())) {
841 this.clearSelection();
842 return;
843 }
761 if (endpointChanged) this.reflow(); 844 if (endpointChanged) this.reflow();
762 this.sendFrame(MSG.selection_req, this.outBytes()); 845 if (this.selection === selection &&
846 this.selectionRequestVersion === requestVersion)
847 this.startSelectionRequestTimeout(selection, requestVersion);
763 } 848 }
764 else this.clearSelection(); 849 else this.clearSelection();
765 } 850 }
web/verify.js
Old New
@@ -728,6 +728,10 @@ async function verifySelectionShell(shell, html) {
728 && tile.selectionScrollTimer === null 728 && tile.selectionScrollTimer === null
729 && tile.scrollRequest === null 729 && tile.scrollRequest === null
730 && tile.scrollRequestTimer === null 730 && tile.scrollRequestTimer === null
731 && tile.selectionRequestTimer === null
732 && tile.selectionRequestVersion === 0
733 && tile.selectionFeedbackTimer === null
734 && tile.selectionFeedbackVersion === 0
731 && tile.selectionCopyVersion === 0; 735 && tile.selectionCopyVersion === 0;
732 check('tile initializes retained selection state', initialState, true); 736 check('tile initializes retained selection state', initialState, true);
733 check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); 737 check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0);
@@ -847,6 +851,14 @@ async function verifySelectionShell(shell, html) {
847 envelope.set(chunk, 6); 851 envelope.set(chunk, 6);
848 return envelope; 852 return envelope;
849 }; 853 };
854 const frameEnvelope = (type, payload) => {
855 const envelope = new Uint8Array(6 + payload.length);
856 envelope[0] = 0;
857 envelope[1] = type;
858 new DataView(envelope.buffer).setUint32(2, payload.length, true);
859 envelope.set(payload, 6);
860 return envelope;
861 };
850 862
851 const transport = makeTile(); 863 const transport = makeTile();
852 transport.tile.sendFrame = h.Tile.prototype.sendFrame.bind(transport.tile); 864 transport.tile.sendFrame = h.Tile.prototype.sendFrame.bind(transport.tile);
@@ -970,6 +982,60 @@ async function verifySelectionShell(shell, html) {
970 check('grid resize invalidates old-count scroll request', resizeMismatch.tile.scrollRequest, null); 982 check('grid resize invalidates old-count scroll request', resizeMismatch.tile.scrollRequest, null);
971 check('grid resize clears old-count scroll timeout', resizeTimeout?.cleared, true); 983 check('grid resize clears old-count scroll timeout', resizeTimeout?.cleared, true);
972 984
985 const snapshotHistory = makeTile();
986 let snapshotHistoryRows = 30, snapshotRows = 5;
987 snapshotHistory.tile.core.mux_history_rows = () => snapshotHistoryRows;
988 snapshotHistory.tile.core.mux_rows = () => snapshotRows;
989 snapshotHistory.tile.core.mux_apply_frame = () => {
990 snapshotHistoryRows = 40;
991 snapshotRows = 6;
992 return 0;
993 };
994 snapshotHistory.tile.scrollPages = 2;
995 snapshotHistory.tile.viewStartRow = 20;
996 snapshotHistory.tile.requestedViewStartRow = 20;
997 snapshotHistory.tile.selection = {
998 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 90, text: 'old grid',
999 };
1000 snapshotHistory.tile.startSelectionRequestTimeout(snapshotHistory.tile.selection);
1001 const snapshotSelectionHandle = snapshotHistory.tile.selectionRequestTimer;
1002 const snapshotSelectionTimer = snapshotSelectionHandle === null
1003 ? undefined : h.timers[snapshotSelectionHandle - 1];
1004 snapshotHistory.tile.sizeCanvas = () => {};
1005 let snapshotLivePaints = 0, snapshotScrollPaints = 0;
1006 const realSnapshotLivePaint = h.Tile.prototype.paintLive.bind(snapshotHistory.tile);
1007 snapshotHistory.tile.paintLive = () => { snapshotLivePaints++; realSnapshotLivePaint(); };
1008 snapshotHistory.tile.paintScroll = () => { snapshotScrollPaints++; };
1009 snapshotHistory.tile.onMessage(frameEnvelope(0x81, Uint8Array.from([1, 2, 3])));
1010 check('authoritative snapshot exits settled history mode', snapshotHistory.tile.scrollPages, 0);
1011 check('authoritative snapshot clears old-grid selection', snapshotHistory.tile.selection, null);
1012 check('authoritative snapshot repaints new live geometry exactly once', snapshotLivePaints, 1);
1013 check('authoritative snapshot never repaints stale history scratch', snapshotScrollPaints, 0);
1014 check('snapshot live repaint adopts new history start', snapshotHistory.tile.viewStartRow, 40);
1015 check('snapshot live repaint synchronizes requested start', snapshotHistory.tile.requestedViewStartRow, 40);
1016 check('snapshot leaves no scroll request timer', snapshotHistory.tile.scrollRequestTimer, null);
1017 check('snapshot clears old-grid selection response timeout', snapshotSelectionTimer?.cleared, true);
1018 check('snapshot leaves no selection response timer identity', snapshotHistory.tile.selectionRequestTimer, null);
1019 check('snapshot lifts history badge after successful apply', snapshotHistory.tile.el.querySelector('.badge').classList.contains('scroll'), false);
1020 check(
1021 'post-snapshot pointer coordinates use new live geometry',
1022 snapshotHistory.tile.cellAtPointer(pointer(30, 4, 5)).row,
1023 45,
1024 );
1025
1026 const failedSnapshot = makeTile();
1027 failedSnapshot.tile.scrollPages = 2;
1028 failedSnapshot.tile.viewStartRow = 20;
1029 failedSnapshot.tile.requestedViewStartRow = 20;
1030 failedSnapshot.tile.selection = {
1031 anchor: { row: 21, col: 1 }, active: { row: 24, col: 3 }, requestId: 91, text: 'failed old grid',
1032 };
1033 failedSnapshot.tile.core.mux_apply_frame = () => -3;
1034 failedSnapshot.tile.onMessage(frameEnvelope(0x81, Uint8Array.from([9])));
1035 check('failed authoritative snapshot follows reset to live mode', failedSnapshot.tile.scrollPages, 0);
1036 check('failed authoritative snapshot clears stale selection', failedSnapshot.tile.selection, null);
1037 check('failed authoritative snapshot repaints through reset path once', failedSnapshot.reflows(), 1);
1038
973 const coordinate = makeTile(); 1039 const coordinate = makeTile();
974 coordinate.tile.viewStartRow = 40; 1040 coordinate.tile.viewStartRow = 40;
975 check( 1041 check(
@@ -1345,6 +1411,110 @@ async function verifySelectionShell(shell, html) {
1345 finalPendingUp.tile.onMessage(scrollEnvelope(25)); 1411 finalPendingUp.tile.onMessage(scrollEnvelope(25));
1346 check('late painted page cannot remap final pointerup point', JSON.stringify(finalPendingUp.tile.selection.active), finalPendingPoint); 1412 check('late painted page cannot remap final pointerup point', JSON.stringify(finalPendingUp.tile.selection.active), finalPendingPoint);
1347 1413
1414 const droppedSelection = makeTile();
1415 let droppedSelectionSends = 0;
1416 droppedSelection.tile.sendFrame = () => { droppedSelectionSends++; return false; };
1417 droppedSelection.tile.canvas.dispatchEvent('pointerdown', pointer(31, 2, 1));
1418 droppedSelection.tile.canvas.dispatchEvent('pointerup', pointer(31, 5, 3));
1419 check('dropped selection request attempts one transport send', droppedSelectionSends, 1);
1420 check('dropped selection request clears retained pending geometry', droppedSelection.tile.selection, null);
1421 check('dropped selection request starts no response timeout', droppedSelection.tile.selectionRequestTimer, null);
1422
1423 const selectionTimeout = makeTile();
1424 selectionTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(32, 1, 1));
1425 selectionTimeout.tile.canvas.dispatchEvent('pointerup', pointer(32, 4, 2));
1426 const selectionTimeoutHandle = selectionTimeout.tile.selectionRequestTimer;
1427 const selectionTimeoutTask = selectionTimeoutHandle === null
1428 ? undefined : h.timers[selectionTimeoutHandle - 1];
1429 check('selection response timeout is bounded to 5000ms', selectionTimeoutTask?.ms, 5000);
1430 selectionTimeoutTask?.fn();
1431 check('selection response timeout clears timer identity', selectionTimeout.tile.selectionRequestTimer, null);
1432 check('selection response timeout retains no authoritative text', selectionTimeout.tile.selection?.text, null);
1433 check('selection response timeout reports transient unavailability', `${selectionTimeout.tile.copyButton.className}|${selectionTimeout.tile.copyButton.textContent}`, 'copy-request on error|Selection unavailable');
1434
1435 const replyBeforeTimeout = makeTile();
1436 replyBeforeTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(33, 1, 1));
1437 replyBeforeTimeout.tile.canvas.dispatchEvent('pointerup', pointer(33, 4, 2));
1438 const replyTimeoutHandle = replyBeforeTimeout.tile.selectionRequestTimer;
1439 const replyTimeoutTask = replyTimeoutHandle === null
1440 ? undefined : h.timers[replyTimeoutHandle - 1];
1441 replyBeforeTimeout.setResult(1, 0, Buffer.from('reply before timeout'));
1442 replyBeforeTimeout.tile.onSelectionReply();
1443 check('matching selection reply clears response timeout', replyTimeoutTask?.cleared, true);
1444 check('matching selection reply clears timeout identity', replyBeforeTimeout.tile.selectionRequestTimer, null);
1445 check('matching selection reply retains authoritative text', replyBeforeTimeout.tile.selection?.text, 'reply before timeout');
1446
1447 const synchronousReply = makeTile();
1448 synchronousReply.tile.sendFrame = () => {
1449 synchronousReply.setResult(1, 0, Buffer.from('synchronous host reply'));
1450 synchronousReply.tile.onSelectionReply();
1451 return true;
1452 };
1453 synchronousReply.tile.canvas.dispatchEvent('pointerdown', pointer(40, 1, 1));
1454 synchronousReply.tile.canvas.dispatchEvent('pointerup', pointer(40, 4, 2));
1455 check('synchronous host reply retains authoritative selection', synchronousReply.tile.selection?.text, 'synchronous host reply');
1456 check('synchronous host reply cannot acquire a stale response timeout', synchronousReply.tile.selectionRequestTimer, null);
1457
1458 const failureBeforeTimeout = makeTile();
1459 failureBeforeTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(34, 1, 1));
1460 failureBeforeTimeout.tile.canvas.dispatchEvent('pointerup', pointer(34, 4, 2));
1461 const failureTimeoutHandle = failureBeforeTimeout.tile.selectionRequestTimer;
1462 const failureTimeoutTask = failureTimeoutHandle === null
1463 ? undefined : h.timers[failureTimeoutHandle - 1];
1464 failureBeforeTimeout.setResult(1, 2, []);
1465 failureBeforeTimeout.tile.onSelectionReply();
1466 check('matching failed selection reply clears response timeout', failureTimeoutTask?.cleared, true);
1467 check('matching failed selection reply clears timeout identity', failureBeforeTimeout.tile.selectionRequestTimer, null);
1468
1469 const staleSelectionTimeout = makeTile();
1470 staleSelectionTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(35, 1, 1));
1471 staleSelectionTimeout.tile.canvas.dispatchEvent('pointerup', pointer(35, 4, 2));
1472 const staleSelectionHandle = staleSelectionTimeout.tile.selectionRequestTimer;
1473 const staleSelectionTask = staleSelectionHandle === null
1474 ? undefined : h.timers[staleSelectionHandle - 1];
1475 staleSelectionTimeout.tile.canvas.dispatchEvent('pointerdown', pointer(36, 2, 2));
1476 staleSelectionTimeout.tile.canvas.dispatchEvent('pointerup', pointer(36, 5, 3));
1477 const newerSelection = staleSelectionTimeout.tile.selection;
1478 const newerSelectionTimer = staleSelectionTimeout.tile.selectionRequestTimer;
1479 staleSelectionTask?.fn();
1480 check('stale selection timeout cannot clear newer retained geometry', staleSelectionTimeout.tile.selection, newerSelection);
1481 check('stale selection timeout cannot clear newer response timer', staleSelectionTimeout.tile.selectionRequestTimer, newerSelectionTimer);
1482 check('stale selection timeout cannot show unavailable for newer request', staleSelectionTimeout.tile.copyUiOwner, null);
1483
1484 const selectionLifecycle = makeTile();
1485 selectionLifecycle.tile.canvas.dispatchEvent('pointerdown', pointer(37, 1, 1));
1486 selectionLifecycle.tile.canvas.dispatchEvent('pointerup', pointer(37, 4, 2));
1487 const lifecycleSelectionHandle = selectionLifecycle.tile.selectionRequestTimer;
1488 const lifecycleSelectionTask = lifecycleSelectionHandle === null
1489 ? undefined : h.timers[lifecycleSelectionHandle - 1];
1490 selectionLifecycle.tile.sendAttach(false);
1491 check('selection lifecycle reset clears pending response timer', lifecycleSelectionTask?.cleared, true);
1492 check('selection lifecycle reset clears response timer identity', selectionLifecycle.tile.selectionRequestTimer, null);
1493 check('selection lifecycle reset clears pending geometry', selectionLifecycle.tile.selection, null);
1494
1495 const selectionUnzoom = makeTile();
1496 selectionUnzoom.tile.canvas.dispatchEvent('pointerdown', pointer(38, 1, 1));
1497 selectionUnzoom.tile.canvas.dispatchEvent('pointerup', pointer(38, 4, 2));
1498 const unzoomSelectionHandle = selectionUnzoom.tile.selectionRequestTimer;
1499 const unzoomSelectionTask = unzoomSelectionHandle === null
1500 ? undefined : h.timers[unzoomSelectionHandle - 1];
1501 h.setZoomedTile(selectionUnzoom.tile);
1502 h.unzoom();
1503 check('unzoom clears pending selection response timeout', unzoomSelectionTask?.cleared, true);
1504 check('unzoom clears pending selection response identity', selectionUnzoom.tile.selectionRequestTimer, null);
1505 check('unzoom clears pending selection geometry', selectionUnzoom.tile.selection, null);
1506
1507 const selectionReset = makeTile();
1508 selectionReset.tile.canvas.dispatchEvent('pointerdown', pointer(39, 1, 1));
1509 selectionReset.tile.canvas.dispatchEvent('pointerup', pointer(39, 4, 2));
1510 const resetSelectionHandle = selectionReset.tile.selectionRequestTimer;
1511 const resetSelectionTask = resetSelectionHandle === null
1512 ? undefined : h.timers[resetSelectionHandle - 1];
1513 selectionReset.tile.resetCore('pending selection timeout');
1514 check('core reset clears pending selection response timeout', resetSelectionTask?.cleared, true);
1515 check('core reset clears pending selection response identity', selectionReset.tile.selectionRequestTimer, null);
1516 check('core reset clears pending selection geometry', selectionReset.tile.selection, null);
1517
1348 const painted = makeTile(); 1518 const painted = makeTile();
1349 const order = []; 1519 const order = [];
1350 painted.tile.sizeCanvas = () => {}; 1520 painted.tile.sizeCanvas = () => {};
@@ -1434,6 +1604,12 @@ async function verifySelectionShell(shell, html) {
1434 replies.setResult(highId, 0, [0xff]); 1604 replies.setResult(highId, 0, [0xff]);
1435 replies.tile.onSelectionReply(); 1605 replies.tile.onSelectionReply();
1436 check('browser rejects invalid UTF-8 selection text defensively', replies.tile.selection.text, null); 1606 check('browser rejects invalid UTF-8 selection text defensively', replies.tile.selection.text, null);
1607 const invalidUtf8FeedbackHandle = replies.tile.selectionFeedbackTimer;
1608 const invalidUtf8Feedback = invalidUtf8FeedbackHandle === null
1609 ? undefined : h.timers[invalidUtf8FeedbackHandle - 1];
1610 check('invalid UTF-8 selection status is bounded to 1200ms', invalidUtf8Feedback?.ms, 1200);
1611 invalidUtf8Feedback?.fn();
1612 check('invalid UTF-8 selection status releases shared copy UI', `${replies.tile.copyUiOwner}|${replies.tile.copyButton.className}|${replies.tile.copyButton.textContent}`, 'null|copy-request|Copy');
1437 1613
1438 const routed = makeTile(); 1614 const routed = makeTile();
1439 routed.tile.selection = { 1615 routed.tile.selection = {
@@ -1458,6 +1634,9 @@ async function verifySelectionShell(shell, html) {
1458 }; 1634 };
1459 blockedCopy.setResult(71, 3, []); 1635 blockedCopy.setResult(71, 3, []);
1460 blockedCopy.tile.onSelectionReply(); 1636 blockedCopy.tile.onSelectionReply();
1637 const unavailableFeedbackHandle = blockedCopy.tile.selectionFeedbackTimer;
1638 const unavailableFeedback = unavailableFeedbackHandle === null
1639 ? undefined : h.timers[unavailableFeedbackHandle - 1];
1461 const blockedWrites = []; 1640 const blockedWrites = [];
1462 h.navigator.clipboard = { writeText: (text) => { blockedWrites.push(text); return Promise.resolve(); } }; 1641 h.navigator.clipboard = { writeText: (text) => { blockedWrites.push(text); return Promise.resolve(); } };
1463 blockedCopy.tile.copyButton.dispatchEvent('click', { stopPropagation() {} }); 1642 blockedCopy.tile.copyButton.dispatchEvent('click', { stopPropagation() {} });
@@ -1465,6 +1644,61 @@ async function verifySelectionShell(shell, html) {
1465 check('selection failure owns the shared copy control', blockedCopy.tile.copyUiOwner, 'selection'); 1644 check('selection failure owns the shared copy control', blockedCopy.tile.copyUiOwner, 'selection');
1466 check('selection failure button cannot copy unrelated OSC 52 text', blockedWrites.length, 0); 1645 check('selection failure button cannot copy unrelated OSC 52 text', blockedWrites.length, 0);
1467 check('selection failure leaves unrelated OSC 52 text pending', blockedCopy.tile.pendingClipboard, 'unrelated OSC 52 text'); 1646 check('selection failure leaves unrelated OSC 52 text pending', blockedCopy.tile.pendingClipboard, 'unrelated OSC 52 text');
1647 check('selection unavailable feedback is bounded to 1200ms', unavailableFeedback?.ms, 1200);
1648 unavailableFeedback?.fn();
1649 check('expired selection unavailable restores pending OSC52 owner', blockedCopy.tile.copyUiOwner, 'clipboard');
1650 check('expired selection unavailable restores pending OSC52 action', `${blockedCopy.tile.copyButton.className}|${blockedCopy.tile.copyButton.textContent}`, 'copy-request on|Copy');
1651
1652 const ownerChangedFeedback = makeTile();
1653 ownerChangedFeedback.tile.selection = {
1654 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 78, text: null,
1655 };
1656 ownerChangedFeedback.setResult(78, 3, []);
1657 ownerChangedFeedback.tile.onSelectionReply();
1658 const supersededFeedbackHandle = ownerChangedFeedback.tile.selectionFeedbackTimer;
1659 const supersededFeedback = supersededFeedbackHandle === null
1660 ? undefined : h.timers[supersededFeedbackHandle - 1];
1661 const ownerOsc = Buffer.from('new OSC owner', 'utf8').toString('base64');
1662 new Uint8Array(ownerChangedFeedback.memory.buffer).set(Buffer.from(ownerOsc, 'ascii'), 96);
1663 ownerChangedFeedback.tile.core.mux_clipboard_ptr = () => 96;
1664 ownerChangedFeedback.tile.core.mux_clipboard_len = () => ownerOsc.length;
1665 h.navigator.clipboard = undefined;
1666 await ownerChangedFeedback.tile.onClipboardEffect();
1667 check('new OSC52 owner clears transient selection feedback timer', supersededFeedback?.cleared, true);
1668 supersededFeedback?.fn();
1669 check('stale unavailable feedback cannot overwrite new OSC52 owner', ownerChangedFeedback.tile.copyUiOwner, 'clipboard');
1670 check('stale unavailable feedback cannot hide new OSC52 retry', `${ownerChangedFeedback.tile.copyButton.className}|${ownerChangedFeedback.tile.copyButton.textContent}`, 'copy-request on|Copy');
1671
1672 const copySupersedesFeedback = makeTile();
1673 copySupersedesFeedback.tile.selection = {
1674 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 79, text: null,
1675 };
1676 copySupersedesFeedback.setResult(79, 3, []);
1677 copySupersedesFeedback.tile.onSelectionReply();
1678 const preCopyFeedbackHandle = copySupersedesFeedback.tile.selectionFeedbackTimer;
1679 const preCopyFeedback = preCopyFeedbackHandle === null
1680 ? undefined : h.timers[preCopyFeedbackHandle - 1];
1681 copySupersedesFeedback.tile.selection.text = 'late authoritative text';
1682 h.navigator.clipboard = { writeText: () => Promise.resolve() };
1683 await copySupersedesFeedback.tile.copySelection();
1684 check('authoritative copy clears prior unavailable feedback timer', preCopyFeedback?.cleared, true);
1685 preCopyFeedback?.fn();
1686 check('stale unavailable timer cannot overwrite copy success', `${copySupersedesFeedback.tile.copyButton.className}|${copySupersedesFeedback.tile.copyButton.textContent}`, 'copy-request on|Copied');
1687
1688 const unzoomFeedback = makeTile();
1689 unzoomFeedback.tile.selection = {
1690 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 81, text: null,
1691 };
1692 unzoomFeedback.setResult(81, 3, []);
1693 unzoomFeedback.tile.onSelectionReply();
1694 const unzoomFeedbackHandle = unzoomFeedback.tile.selectionFeedbackTimer;
1695 const unzoomFeedbackTask = unzoomFeedbackHandle === null
1696 ? undefined : h.timers[unzoomFeedbackHandle - 1];
1697 h.setZoomedTile(unzoomFeedback.tile);
1698 h.unzoom();
1699 check('unzoom clears transient unavailable feedback timer', unzoomFeedbackTask?.cleared, true);
1700 unzoomFeedbackTask?.fn();
1701 check('stale unavailable timer remains invisible after unzoom', `${unzoomFeedback.tile.copyUiOwner}|${unzoomFeedback.tile.copyButton.className}|${unzoomFeedback.tile.copyButton.textContent}`, 'null|copy-request|Copy');
1468 1702
1469 const inFlightUi = makeTile(); 1703 const inFlightUi = makeTile();
1470 inFlightUi.tile.pendingClipboard = 'in flight'; 1704 inFlightUi.tile.pendingClipboard = 'in flight';
@@ -1768,6 +2002,7 @@ async function verifySelectionShell(shell, html) {
1768 await flushPromises(); 2002 await flushPromises();
1769 check('selection clipboard rejection is handled as visible failure', `${copyFailure.tile.copyButton.className}|${copyFailure.tile.copyButton.textContent}`, 'copy-request on error|Copy failed'); 2003 check('selection clipboard rejection is handled as visible failure', `${copyFailure.tile.copyButton.className}|${copyFailure.tile.copyButton.textContent}`, 'copy-request on error|Copy failed');
1770 check('selection clipboard rejection retains exact text for retry', copyFailure.tile.selection?.text, 'retry exact selection'); 2004 check('selection clipboard rejection retains exact text for retry', copyFailure.tile.selection?.text, 'retry exact selection');
2005 check('copy failure with valid text is durable, not transient unavailable feedback', copyFailure.tile.selectionFeedbackTimer, null);
1771 const retryWrites = []; 2006 const retryWrites = [];
1772 h.navigator.clipboard = { writeText: (text) => { retryWrites.push(text); return Promise.resolve(); } }; 2007 h.navigator.clipboard = { writeText: (text) => { retryWrites.push(text); return Promise.resolve(); } };
1773 copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} }); 2008 copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} });
@@ -1828,8 +2063,14 @@ async function verifySelectionShell(shell, html) {
1828 }; 2063 };
1829 newSelectionUi.setResult(76, 3, []); 2064 newSelectionUi.setResult(76, 3, []);
1830 newSelectionUi.tile.onSelectionReply(); 2065 newSelectionUi.tile.onSelectionReply();
2066 const oldUnavailableHandle = newSelectionUi.tile.selectionFeedbackTimer;
2067 const oldUnavailableTimer = oldUnavailableHandle === null
2068 ? undefined : h.timers[oldUnavailableHandle - 1];
1831 newSelectionUi.tile.canvas.dispatchEvent('pointerdown', pointer(17, 1, 1)); 2069 newSelectionUi.tile.canvas.dispatchEvent('pointerdown', pointer(17, 1, 1));
1832 check('starting a new selection removes stale unavailable UI', `${newSelectionUi.tile.copyButton.className}|${newSelectionUi.tile.copyButton.textContent}`, 'copy-request|Copy'); 2070 check('starting a new selection removes stale unavailable UI', `${newSelectionUi.tile.copyButton.className}|${newSelectionUi.tile.copyButton.textContent}`, 'copy-request|Copy');
2071 check('starting a new selection clears unavailable feedback timer', oldUnavailableTimer?.cleared, true);
2072 oldUnavailableTimer?.fn();
2073 check('stale unavailable timer cannot alter new selection UI', `${newSelectionUi.tile.copyButton.className}|${newSelectionUi.tile.copyButton.textContent}`, 'copy-request|Copy');
1833 newSelectionUi.tile.canvas.dispatchEvent('pointercancel', { pointerId: 17 }); 2074 newSelectionUi.tile.canvas.dispatchEvent('pointercancel', { pointerId: 17 });
1834 2075
1835 const wrap = makeTile(); 2076 const wrap = makeTile();