104de050
fix: bound web selection lifecycle
a73x 2026-08-18 12:27
Commit message
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(); |