a73x

168495eb

fix: harden web selection interaction races

a73x   2026-08-18 12:27

Commit message
fix: harden web selection interaction races

web/mux.js
Old New
@@ -32,6 +32,10 @@ const KEY = {
32 // Pastes chunk at 32 KiB so no browser→hub message approaches the hub's 32 // Pastes chunk at 32 KiB so no browser→hub message approaches the hub's
33 // 64 KiB inbound bound (webhub.zig ws_buffer_len). 33 // 64 KiB inbound bound (webhub.zig ws_buffer_len).
34 const PASTE_CHUNK = 32 * 1024; 34 const PASTE_CHUNK = 32 * 1024;
35 // A missing history reply must not permanently lock wheel/drag movement.
36 // Two seconds tolerates an ordinary remote round trip without letting the
37 // 120ms drag interval flood retries while one request is still outstanding.
38 const SCROLL_REQUEST_TIMEOUT_MS = 2000;
35 39
36 // The reconnect schedule, and it is NOT a new one: this is client.zig's 40 // The reconnect schedule, and it is NOT a new one: this is client.zig's
37 // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers") 41 // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers")
@@ -135,10 +139,12 @@ class Tile {
135 this.selectionCopyVersion = 0; 139 this.selectionCopyVersion = 0;
136 this.viewStartRow = 0; 140 this.viewStartRow = 0;
137 this.requestedViewStartRow = 0; 141 this.requestedViewStartRow = 0;
138 this.scrollRequest = null; // {fromPages, toPages, start}; at most one in flight 142 this.scrollRequest = null; // {fromPages, start, count}; at most one in flight
143 this.scrollRequestTimer = null;
139 this.selection = null; // {anchor, active, requestId, text} 144 this.selection = null; // {anchor, active, requestId, text}
140 this.nextSelectionId = 0; 145 this.nextSelectionId = 0;
141 this.drag = null; 146 this.drag = null;
147 this.lastPointerX = null;
142 this.lastPointerY = null; 148 this.lastPointerY = null;
143 this.selectionScrollTimer = null; 149 this.selectionScrollTimer = null;
144 150
@@ -203,6 +209,7 @@ class Tile {
203 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); 209 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`);
204 this.ws.binaryType = 'arraybuffer'; 210 this.ws.binaryType = 'arraybuffer';
205 this.ws.onopen = () => { 211 this.ws.onopen = () => {
212 this.cancelScrollRequest();
206 this.clearSelection(); 213 this.clearSelection();
207 this.wsOpened = true; 214 this.wsOpened = true;
208 this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule 215 this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule
@@ -219,6 +226,7 @@ class Tile {
219 // onerror always precedes onclose; the badge is decided in one place. 226 // onerror always precedes onclose; the badge is decided in one place.
220 this.ws.onerror = () => {}; 227 this.ws.onerror = () => {};
221 this.ws.onclose = () => { 228 this.ws.onclose = () => {
229 this.cancelScrollRequest();
222 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; 230 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1;
223 const dead = this.wsFailures >= GONE_AFTER_FAILURES; 231 const dead = this.wsFailures >= GONE_AFTER_FAILURES;
224 this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); 232 this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting');
@@ -263,7 +271,7 @@ class Tile {
263 // with a snapshot. 271 // with a snapshot.
264 resetCore(why) { 272 resetCore(why) {
265 this.clearSelection(false); 273 this.clearSelection(false);
266 this.scrollRequest = null; 274 this.cancelScrollRequest();
267 this.scrollPages = 0; 275 this.scrollPages = 0;
268 if (this.core.mux_init(80, 24) !== 0) { 276 if (this.core.mux_init(80, 24) !== 0) {
269 this.clearBackingCanvas(); 277 this.clearBackingCanvas();
@@ -292,13 +300,18 @@ class Tile {
292 300
293 // --- wire out --- 301 // --- wire out ---
294 sendFrame(type, payload) { 302 sendFrame(type, payload) {
295 if (this.ws.readyState !== WebSocket.OPEN) return; 303 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
296 const msg = new Uint8Array(6 + payload.length); 304 const msg = new Uint8Array(6 + payload.length);
297 msg[0] = ENV_FRAME; 305 msg[0] = ENV_FRAME;
298 msg[1] = type; 306 msg[1] = type;
299 new DataView(msg.buffer).setUint32(2, payload.length, true); 307 new DataView(msg.buffer).setUint32(2, payload.length, true);
300 msg.set(payload, 6); 308 msg.set(payload, 6);
301 this.ws.send(msg); 309 try {
310 this.ws.send(msg);
311 return true;
312 } catch (_) {
313 return false;
314 }
302 } 315 }
303 sendAttach(fresh) { 316 sendAttach(fresh) {
304 // THE PASSIVITY CONTRACT (spec amendment 1): a wall tile attaches at 317 // THE PASSIVITY CONTRACT (spec amendment 1): a wall tile attaches at
@@ -309,6 +322,7 @@ class Tile {
309 // ONE gate for every attach, wherever it comes from — the `up` 322 // ONE gate for every attach, wherever it comes from — the `up`
310 // control message included. A tile that gave up on replaying must 323 // control message included. A tile that gave up on replaying must
311 // not be talked back into asking for the same frame again. 324 // not be talked back into asking for the same frame again.
325 this.cancelScrollRequest();
312 if (this.replayDead) return; 326 if (this.replayDead) return;
313 this.clearSelection(); 327 this.clearSelection();
314 const cols = this.zoomed ? this.zoomCols() : 1; 328 const cols = this.zoomed ? this.zoomCols() : 1;
@@ -382,6 +396,9 @@ class Tile {
382 if (!this.zoomed) return; 396 if (!this.zoomed) return;
383 const cols = this.zoomCols(), rows = this.zoomRows(); 397 const cols = this.zoomCols(), rows = this.zoomRows();
384 if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return; 398 if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return;
399 // A scrollback reply encodes the grid row count sampled by its request.
400 // Never apply those bytes after changing the grid they describe.
401 this.cancelScrollRequest();
385 const p = new Uint8Array(4); 402 const p = new Uint8Array(4);
386 const dv = new DataView(p.buffer); 403 const dv = new DataView(p.buffer);
387 dv.setUint16(0, cols, true); 404 dv.setUint16(0, cols, true);
@@ -411,6 +428,7 @@ class Tile {
411 // this tile: setStatus withholds the badge while the tile holds a 428 // this tile: setStatus withholds the badge while the tile holds a
412 // terminal state of its own. The bookkeeping below runs regardless 429 // terminal state of its own. The bookkeeping below runs regardless
413 // — sendAttach has its own gate (replayDead) and keeps it. 430 // — sendAttach has its own gate (replayDead) and keeps it.
431 if (state !== 'up') this.cancelScrollRequest();
414 this.setStatus(state, state); 432 this.setStatus(state, state);
415 if (state === 'up') this.sendAttach(false); // browser owns re-attach 433 if (state === 'up') this.sendAttach(false); // browser owns re-attach
416 return; 434 return;
@@ -424,6 +442,7 @@ class Tile {
424 switch (type) { 442 switch (type) {
425 case MSG.snapshot: 443 case MSG.snapshot:
426 case MSG.delta: { 444 case MSG.delta: {
445 if (type === MSG.snapshot) this.cancelScrollRequest();
427 // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone 446 // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone
428 // proves the attach was admitted — decodable or not — while a 447 // proves the attach was admitted — decodable or not — while a
429 // short snapshot proves nothing. Without this an undecodable 448 // short snapshot proves nothing. Without this an undecodable
@@ -456,6 +475,7 @@ class Tile {
456 return; 475 return;
457 } 476 }
458 case MSG.exit_status: { 477 case MSG.exit_status: {
478 this.cancelScrollRequest();
459 // Before any state this is the daemon refusing the attach 479 // Before any state this is the daemon refusing the attach
460 // (session full) — the CLI's own discriminator, mirrored. 480 // (session full) — the CLI's own discriminator, mirrored.
461 if (!this.gotState) this.setStatus('full', 'session full'); 481 if (!this.gotState) this.setStatus('full', 'session full');
@@ -468,21 +488,22 @@ class Tile {
468 if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest); 488 if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest);
469 return; 489 return;
470 } 490 }
471 const start = new DataView( 491 const echoed = new DataView(payload.buffer, payload.byteOffset, 6);
472 payload.buffer, payload.byteOffset, 6, 492 const start = echoed.getUint32(0, true);
473 ).getUint32(0, true); 493 const count = echoed.getUint16(4, true);
474 // A viewport becomes addressable by pointer coordinates only after 494 // A viewport becomes addressable by pointer coordinates only after
475 // the exact request for it decoded and painted. Delayed older wheel 495 // the exact request for it decoded and painted. Delayed older wheel
476 // replies must never relabel the cells currently on the canvas. 496 // replies must never relabel the cells currently on the canvas.
477 const request = this.scrollRequest; 497 const request = this.scrollRequest;
478 if (!request || start !== request.start) return; 498 if (!request || start !== request.start || count !== request.count) return;
479 const rows = payload.subarray(6); // echoed start+count stripped 499 const rows = payload.subarray(6); // echoed start+count stripped
480 if (!this.stage(rows)) { this.scrollRequestFailed(request); return; } 500 if (!this.stage(rows)) { this.scrollRequestFailed(request); return; }
481 if (this.core.mux_scroll_feed(rows.length) === 0) { 501 if (this.core.mux_scroll_feed(rows.length) === 0) {
502 this.clearScrollRequestTimer();
482 this.scrollRequest = null; 503 this.scrollRequest = null;
483 this.viewStartRow = start; 504 this.viewStartRow = start;
484 this.requestedViewStartRow = start; 505 this.requestedViewStartRow = start;
485 this.moveDragToScrollBoundary(); 506 this.moveDragToCurrentPointer();
486 this.paintScroll(); 507 this.paintScroll();
487 } else this.scrollRequestFailed(request); 508 } else this.scrollRequestFailed(request);
488 return; 509 return;
@@ -603,6 +624,9 @@ class Tile {
603 try { 624 try {
604 // This call occurs synchronously before the first await, preserving 625 // This call occurs synchronously before the first await, preserving
605 // the key/click user activation required by the Clipboard API. 626 // the key/click user activation required by the Clipboard API.
627 // It cannot cancel an OSC52 write already handed to the browser, so
628 // those two calls may overlap. Versions order only our UI; the browser
629 // owns external clipboard completion ordering for the active calls.
606 await this.writeClipboardText(selection.text); 630 await this.writeClipboardText(selection.text);
607 succeeded = true; 631 succeeded = true;
608 } catch (_) { 632 } catch (_) {
@@ -676,6 +700,7 @@ class Tile {
676 stopSelectionDrag() { 700 stopSelectionDrag() {
677 const pointerId = this.drag?.pointerId; 701 const pointerId = this.drag?.pointerId;
678 this.drag = null; 702 this.drag = null;
703 this.lastPointerX = null;
679 this.lastPointerY = null; 704 this.lastPointerY = null;
680 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer); 705 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
681 this.selectionScrollTimer = null; 706 this.selectionScrollTimer = null;
@@ -692,6 +717,7 @@ class Tile {
692 this.clearSelection(false); 717 this.clearSelection(false);
693 this.selection = { anchor: point, active: point, requestId: 0, text: null }; 718 this.selection = { anchor: point, active: point, requestId: 0, text: null };
694 this.drag = { pointerId: ev.pointerId, moved: false }; 719 this.drag = { pointerId: ev.pointerId, moved: false };
720 this.lastPointerX = ev.clientX;
695 this.lastPointerY = ev.clientY; 721 this.lastPointerY = ev.clientY;
696 this.canvas.setPointerCapture(ev.pointerId); 722 this.canvas.setPointerCapture(ev.pointerId);
697 this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120); 723 this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120);
@@ -701,10 +727,12 @@ class Tile {
701 moveSelection(ev) { 727 moveSelection(ev) {
702 if (!this.drag || ev.pointerId !== this.drag.pointerId) return; 728 if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
703 ev.preventDefault(); 729 ev.preventDefault();
730 this.lastPointerX = ev.clientX;
704 this.lastPointerY = ev.clientY; 731 this.lastPointerY = ev.clientY;
705 const point = this.cellAtPointer(ev); 732 const point = this.cellAtPointer(ev);
706 if (point.row !== this.selection.active.row || point.col !== this.selection.active.col) 733 const endpointChanged = point.row !== this.selection.active.row ||
707 this.drag.moved = true; 734 point.col !== this.selection.active.col;
735 if (endpointChanged) this.drag.moved = true;
708 this.selection.active = point; 736 this.selection.active = point;
709 this.reflow(); 737 this.reflow();
710 } 738 }
@@ -712,6 +740,13 @@ class Tile {
712 endSelection(ev) { 740 endSelection(ev) {
713 if (!this.drag || ev.pointerId !== this.drag.pointerId) return; 741 if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
714 ev.preventDefault(); 742 ev.preventDefault();
743 this.lastPointerX = ev.clientX;
744 this.lastPointerY = ev.clientY;
745 const point = this.cellAtPointer(ev);
746 const endpointChanged = point.row !== this.selection.active.row ||
747 point.col !== this.selection.active.col;
748 if (endpointChanged) this.drag.moved = true;
749 this.selection.active = point;
715 const moved = this.drag.moved; 750 const moved = this.drag.moved;
716 this.stopSelectionDrag(); 751 this.stopSelectionDrag();
717 if (!moved) { this.clearSelection(); return; } 752 if (!moved) { this.clearSelection(); return; }
@@ -722,7 +757,10 @@ class Tile {
722 const n = this.core.mux_selection_request( 757 const n = this.core.mux_selection_request(
723 this.nextSelectionId, a.row, a.col, b.row, b.col, 758 this.nextSelectionId, a.row, a.col, b.row, b.col,
724 ); 759 );
725 if (n === 16) this.sendFrame(MSG.selection_req, this.outBytes()); 760 if (n === 16) {
761 if (endpointChanged) this.reflow();
762 this.sendFrame(MSG.selection_req, this.outBytes());
763 }
726 else this.clearSelection(); 764 else this.clearSelection();
727 } 765 }
728 766
@@ -733,16 +771,16 @@ class Tile {
733 (this.lastPointerY >= rect.bottom ? -1 : 0); 771 (this.lastPointerY >= rect.bottom ? -1 : 0);
734 } 772 }
735 773
736 moveDragToScrollBoundary() { 774 moveDragToCurrentPointer() {
737 const direction = this.pointerOutsideDirection(); 775 if (!this.drag || !this.selection ||
738 if (direction === 0 || !this.selection) return false; 776 this.lastPointerX === null || this.lastPointerY === null) return false;
739 const viewRow = direction > 0 ? 0 : this.core.mux_rows() - 1; 777 const point = this.cellAtPointer({
740 this.selection.active = { 778 clientX: this.lastPointerX,
741 row: this.viewStartRow + viewRow, 779 clientY: this.lastPointerY,
742 col: this.selection.active.col, 780 });
743 viewRow, 781 if (point.row !== this.selection.active.row || point.col !== this.selection.active.col)
744 }; 782 this.drag.moved = true;
745 this.drag.moved = true; 783 this.selection.active = point;
746 return true; 784 return true;
747 } 785 }
748 786
@@ -755,7 +793,7 @@ class Tile {
755 // History requests update the endpoint only in scrollback_chunk after 793 // History requests update the endpoint only in scrollback_chunk after
756 // their matching bytes have decoded and become the painted viewport. 794 // their matching bytes have decoded and become the painted viewport.
757 if (!this.scrollRequest && this.viewStartRow !== paintedStart && 795 if (!this.scrollRequest && this.viewStartRow !== paintedStart &&
758 this.moveDragToScrollBoundary()) this.reflow(); 796 this.moveDragToCurrentPointer()) this.reflow();
759 } 797 }
760 798
761 // --- painting --- 799 // --- painting ---
@@ -924,17 +962,38 @@ class Tile {
924 this.renderBadge(); 962 this.renderBadge();
925 const start = this.core.mux_scroll_start(this.scrollPages, rows); 963 const start = this.core.mux_scroll_start(this.scrollPages, rows);
926 this.requestedViewStartRow = start; 964 this.requestedViewStartRow = start;
927 this.scrollRequest = { fromPages, start }; 965 const request = { fromPages, start, count: rows };
966 this.scrollRequest = request;
928 const p = new Uint8Array(6); 967 const p = new Uint8Array(6);
929 const dv = new DataView(p.buffer); 968 const dv = new DataView(p.buffer);
930 dv.setUint32(0, start, true); 969 dv.setUint32(0, start, true);
931 dv.setUint16(4, rows, true); 970 dv.setUint16(4, rows, true);
932 this.sendFrame(MSG.fetch_scrollback, p); 971 if (!this.sendFrame(MSG.fetch_scrollback, p)) {
972 this.scrollRequestFailed(request);
973 return false;
974 }
975 // WebSocket delivery is asynchronous in browsers, but keep the state
976 // transition correct for any host that can answer during send().
977 if (this.scrollRequest !== request) return true;
978 this.scrollRequestTimer = setTimeout(() => {
979 if (this.scrollRequest === request) this.scrollRequestFailed(request);
980 }, SCROLL_REQUEST_TIMEOUT_MS);
933 return true; 981 return true;
934 } 982 }
935 983
984 clearScrollRequestTimer() {
985 if (this.scrollRequestTimer !== null) clearTimeout(this.scrollRequestTimer);
986 this.scrollRequestTimer = null;
987 }
988
989 cancelScrollRequest() {
990 if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest);
991 else this.clearScrollRequestTimer();
992 }
993
936 scrollRequestFailed(request) { 994 scrollRequestFailed(request) {
937 if (this.scrollRequest !== request) return; 995 if (this.scrollRequest !== request) return;
996 this.clearScrollRequestTimer();
938 this.scrollRequest = null; 997 this.scrollRequest = null;
939 this.scrollPages = request.fromPages; 998 this.scrollPages = request.fromPages;
940 this.requestedViewStartRow = this.viewStartRow; 999 this.requestedViewStartRow = this.viewStartRow;
@@ -946,7 +1005,8 @@ class Tile {
946 } 1005 }
947 1006
948 exitScroll() { 1007 exitScroll() {
949 if (this.scrollPages === 0) return; 1008 this.clearScrollRequestTimer();
1009 if (this.scrollPages === 0) { this.scrollRequest = null; return; }
950 this.scrollRequest = null; 1010 this.scrollRequest = null;
951 this.scrollPages = 0; 1011 this.scrollPages = 0;
952 this.renderBadge(); 1012 this.renderBadge();
@@ -1068,7 +1128,11 @@ document.addEventListener('keydown', (ev) => {
1068 // to make the recovery control keyboard reachable. 1128 // to make the recovery control keyboard reachable.
1069 if (ev.target === ime && ev.key === 'Tab' && 1129 if (ev.target === ime && ev.key === 'Tab' &&
1070 !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey && 1130 !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey &&
1071 t.pendingClipboard !== null && t.copyButton.classList.contains('on')) { 1131 t.copyButton.classList.contains('on') && (
1132 (t.copyUiOwner === 'clipboard' && t.pendingClipboard !== null) ||
1133 (t.copyUiOwner === 'selection' && t.selection?.text !== null &&
1134 t.selection?.text !== undefined && t.copyButton.classList.contains('error'))
1135 )) {
1072 ev.preventDefault(); 1136 ev.preventDefault();
1073 t.copyButton.focus(); 1137 t.copyButton.focus();
1074 return; 1138 return;
web/verify.js
Old New
@@ -417,7 +417,8 @@ function browserShell(source) {
417 ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, 417 ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder,
418 Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, 418 Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document,
419 location: { host: 'verify.invalid' }, navigator, 419 location: { host: 'verify.invalid' }, navigator,
420 setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, 420 setTimeout: (fn, ms) => { timers.push({ fn, ms, cleared: false }); return timers.length; },
421 clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; },
421 setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; }, 422 setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; },
422 clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; }, 423 clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; },
423 window: { addEventListener() {}, devicePixelRatio: 1 }, 424 window: { addEventListener() {}, devicePixelRatio: 1 },
@@ -722,9 +723,11 @@ async function verifySelectionShell(shell, html) {
722 && tile.selection === null 723 && tile.selection === null
723 && tile.nextSelectionId === 0 724 && tile.nextSelectionId === 0
724 && tile.drag === null 725 && tile.drag === null
726 && tile.lastPointerX === null
725 && tile.lastPointerY === null 727 && tile.lastPointerY === null
726 && tile.selectionScrollTimer === null 728 && tile.selectionScrollTimer === null
727 && tile.scrollRequest === null 729 && tile.scrollRequest === null
730 && tile.scrollRequestTimer === null
728 && tile.selectionCopyVersion === 0; 731 && tile.selectionCopyVersion === 0;
729 check('tile initializes retained selection state', initialState, true); 732 check('tile initializes retained selection state', initialState, true);
730 check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0); 733 check('tile initializes requested viewport state separately', tile.requestedViewStartRow, 0);
@@ -804,7 +807,10 @@ async function verifySelectionShell(shell, html) {
804 selected.drawScale = 1; 807 selected.drawScale = 1;
805 selected.canvas.rect = { left: 10, top: 20, width: 80, height: 70 }; 808 selected.canvas.rect = { left: 10, top: 20, width: 80, height: 70 };
806 const sent = []; 809 const sent = [];
807 selected.sendFrame = (type, payload) => sent.push({ type, payload: Uint8Array.from(payload) }); 810 selected.sendFrame = (type, payload) => {
811 sent.push({ type, payload: Uint8Array.from(payload) });
812 return true;
813 };
808 let reflows = 0; 814 let reflows = 0;
809 selected.reflow = () => { reflows++; }; 815 selected.reflow = () => { reflows++; };
810 return { 816 return {
@@ -842,6 +848,128 @@ async function verifySelectionShell(shell, html) {
842 return envelope; 848 return envelope;
843 }; 849 };
844 850
851 const transport = makeTile();
852 transport.tile.sendFrame = h.Tile.prototype.sendFrame.bind(transport.tile);
853 const transportWrites = [];
854 transport.tile.ws = { readyState: 0, send: (bytes) => transportWrites.push(bytes) };
855 check('sendFrame reports a closed-socket drop', transport.tile.sendFrame(0x05, new Uint8Array()), false);
856 transport.tile.ws.readyState = 1;
857 check('sendFrame reports an open-socket send', transport.tile.sendFrame(0x05, new Uint8Array()), true);
858 check('open sendFrame writes exactly one envelope', transportWrites.length, 1);
859
860 const droppedScroll = makeTile();
861 droppedScroll.tile.sendFrame = h.Tile.prototype.sendFrame.bind(droppedScroll.tile);
862 droppedScroll.tile.ws = { readyState: 0, send() { throw new Error('closed socket must not send'); } };
863 droppedScroll.tile.viewStartRow = 30;
864 droppedScroll.tile.requestedViewStartRow = 30;
865 check('closed-socket page request reports no movement', droppedScroll.tile.changeScrollPages(1), false);
866 check('closed-socket page request immediately rolls page intent back', droppedScroll.tile.scrollPages, 0);
867 check('closed-socket page request retains no pending request', droppedScroll.tile.scrollRequest, null);
868 check('closed-socket page request starts no response timeout', droppedScroll.tile.scrollRequestTimer, null);
869
870 const timeoutScroll = makeTile();
871 timeoutScroll.tile.viewStartRow = 30;
872 timeoutScroll.tile.requestedViewStartRow = 30;
873 timeoutScroll.tile.canvas.dispatchEvent('pointerdown', pointer(25, 2, 2));
874 timeoutScroll.tile.canvas.dispatchEvent('pointermove', {
875 ...pointer(25, 4, 0), clientY: 10,
876 });
877 const timeoutAutoTimer = h.intervals[timeoutScroll.tile.selectionScrollTimer - 1];
878 timeoutAutoTimer.fn();
879 const responseTimeout = h.timers[timeoutScroll.tile.scrollRequestTimer - 1];
880 check('scroll request timeout is bounded to 2000ms', responseTimeout?.ms, 2000);
881 timeoutAutoTimer.fn();
882 check('120ms auto-scroll timer does not retry before response timeout', timeoutScroll.sent.length, 1);
883 responseTimeout.fn();
884 check('silent scroll server timeout rolls page intent back', timeoutScroll.tile.scrollPages, 0);
885 check('silent scroll server timeout releases pending request', timeoutScroll.tile.scrollRequest, null);
886 check('silent scroll server timeout clears timer identity', timeoutScroll.tile.scrollRequestTimer, null);
887 timeoutAutoTimer.fn();
888 check('auto-scroll retries once after bounded response timeout', timeoutScroll.sent.length, 2);
889 timeoutScroll.tile.canvas.dispatchEvent('pointercancel', { pointerId: 25 });
890
891 const staleTimeout = makeTile();
892 staleTimeout.tile.viewStartRow = 30;
893 staleTimeout.tile.requestedViewStartRow = 30;
894 staleTimeout.tile.paintScroll = () => {};
895 staleTimeout.tile.changeScrollPages(1);
896 const firstTimeout = h.timers[staleTimeout.tile.scrollRequestTimer - 1];
897 staleTimeout.tile.onMessage(scrollEnvelope(25));
898 check('successful scroll reply clears its response timeout', firstTimeout?.cleared, true);
899 staleTimeout.tile.changeScrollPages(1);
900 const secondRequest = staleTimeout.tile.scrollRequest;
901 firstTimeout.fn();
902 check('stale response timeout cannot roll back a later request', staleTimeout.tile.scrollRequest, secondRequest);
903 check('stale response timeout cannot change later requested page', staleTimeout.tile.scrollPages, 2);
904
905 const lifecycleScroll = makeTile();
906 lifecycleScroll.tile.viewStartRow = 30;
907 lifecycleScroll.tile.requestedViewStartRow = 30;
908 lifecycleScroll.tile.connect();
909 lifecycleScroll.tile.changeScrollPages(1);
910 const closeTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1];
911 lifecycleScroll.tile.ws.onclose();
912 check('socket close rolls pending scroll request back', lifecycleScroll.tile.scrollRequest, null);
913 check('socket close clears pending scroll timeout', closeTimeout?.cleared, true);
914 lifecycleScroll.tile.changeScrollPages(1);
915 const openTimeout = h.timers[lifecycleScroll.tile.scrollRequestTimer - 1];
916 lifecycleScroll.tile.ws.onopen();
917 check('socket open invalidates pre-open scroll request', lifecycleScroll.tile.scrollRequest, null);
918 check('socket open clears pre-open scroll timeout', openTimeout?.cleared, true);
919
920 const attachScroll = makeTile();
921 attachScroll.tile.viewStartRow = 30;
922 attachScroll.tile.requestedViewStartRow = 30;
923 attachScroll.tile.changeScrollPages(1);
924 const attachTimeout = h.timers[attachScroll.tile.scrollRequestTimer - 1];
925 attachScroll.tile.sendAttach(false);
926 check('reattach rolls pending scroll request back', attachScroll.tile.scrollRequest, null);
927 check('reattach clears pending scroll timeout', attachTimeout?.cleared, true);
928
929 const controlLifecycle = makeTile();
930 controlLifecycle.tile.viewStartRow = 30;
931 controlLifecycle.tile.requestedViewStartRow = 30;
932 controlLifecycle.tile.changeScrollPages(1);
933 const controlTimeout = h.timers[controlLifecycle.tile.scrollRequestTimer - 1];
934 const reconnectingControl = Uint8Array.from([
935 1, ...Buffer.from(JSON.stringify({ state: 'reconnecting' }), 'utf8'),
936 ]);
937 controlLifecycle.tile.onMessage(reconnectingControl);
938 check('daemon reconnect lifecycle rolls pending scroll request back', controlLifecycle.tile.scrollRequest, null);
939 check('daemon reconnect lifecycle clears pending scroll timeout', controlTimeout?.cleared, true);
940
941 const exitLifecycle = makeTile();
942 exitLifecycle.tile.viewStartRow = 30;
943 exitLifecycle.tile.requestedViewStartRow = 30;
944 exitLifecycle.tile.changeScrollPages(1);
945 const exitTimeout = h.timers[exitLifecycle.tile.scrollRequestTimer - 1];
946 exitLifecycle.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0]));
947 check('session exit rolls pending scroll request back', exitLifecycle.tile.scrollRequest, null);
948 check('session exit clears pending scroll timeout', exitTimeout?.cleared, true);
949
950 const countMismatch = makeTile();
951 countMismatch.tile.viewStartRow = 30;
952 countMismatch.tile.requestedViewStartRow = 30;
953 let countPaints = 0;
954 countMismatch.tile.paintScroll = () => { countPaints++; };
955 countMismatch.tile.changeScrollPages(1);
956 countMismatch.tile.onMessage(scrollEnvelope(25, 4));
957 check('matching start with wrong echoed count is not painted', countPaints, 0);
958 check('wrong echoed count leaves exact request pending', countMismatch.tile.scrollRequest !== null, true);
959 countMismatch.tile.onMessage(scrollEnvelope(25, 5));
960 check('matching start and count paints requested viewport', countPaints, 1);
961
962 const resizeMismatch = makeTile();
963 resizeMismatch.tile.viewStartRow = 30;
964 resizeMismatch.tile.requestedViewStartRow = 30;
965 resizeMismatch.tile.changeScrollPages(1);
966 const resizeTimeout = h.timers[resizeMismatch.tile.scrollRequestTimer - 1];
967 resizeMismatch.tile.zoomCols = () => 11;
968 resizeMismatch.tile.zoomRows = () => 6;
969 resizeMismatch.tile.sendResizeIfDiffers();
970 check('grid resize invalidates old-count scroll request', resizeMismatch.tile.scrollRequest, null);
971 check('grid resize clears old-count scroll timeout', resizeTimeout?.cleared, true);
972
845 const coordinate = makeTile(); 973 const coordinate = makeTile();
846 coordinate.tile.viewStartRow = 40; 974 coordinate.tile.viewStartRow = 40;
847 check( 975 check(
@@ -1046,6 +1174,7 @@ async function verifySelectionShell(shell, html) {
1046 auto.tile.onMessage(scrollEnvelope(25)); 1174 auto.tile.onMessage(scrollEnvelope(25));
1047 check('matching successful history reply advances painted viewport', auto.tile.viewStartRow, 25); 1175 check('matching successful history reply advances painted viewport', auto.tile.viewStartRow, 25);
1048 check('matching successful history reply moves drag to painted boundary', auto.tile.selection.active.row, 25); 1176 check('matching successful history reply moves drag to painted boundary', auto.tile.selection.active.row, 25);
1177 check('matching successful history reply remaps latest pointer column', auto.tile.selection.active.col, 4);
1049 check('matching successful history reply retains boundary view row', auto.tile.selection.active.viewRow, 0); 1178 check('matching successful history reply retains boundary view row', auto.tile.selection.active.viewRow, 0);
1050 check('matching successful history reply paints once', autoPaints, 1); 1179 check('matching successful history reply paints once', autoPaints, 1);
1051 autoTimer.fn(); 1180 autoTimer.fn();
@@ -1057,6 +1186,39 @@ async function verifySelectionShell(shell, html) {
1057 ); 1186 );
1058 auto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 18 }); 1187 auto.tile.canvas.dispatchEvent('pointercancel', { pointerId: 18 });
1059 1188
1189 const insideDrift = makeTile();
1190 insideDrift.tile.viewStartRow = 30;
1191 insideDrift.tile.requestedViewStartRow = 30;
1192 insideDrift.tile.paintScroll = () => {};
1193 insideDrift.tile.canvas.dispatchEvent('pointerdown', pointer(26, 2, 2));
1194 insideDrift.tile.canvas.dispatchEvent('pointermove', {
1195 ...pointer(26, 4, 0), clientY: 10,
1196 });
1197 h.intervals[insideDrift.tile.selectionScrollTimer - 1].fn();
1198 insideDrift.tile.canvas.dispatchEvent('pointermove', pointer(26, 7, 3));
1199 check('pointer returning inside pending page still names old painted row', insideDrift.tile.selection.active.row, 33);
1200 insideDrift.tile.onMessage(scrollEnvelope(25));
1201 check('matching paint remaps inside pointer to new painted row', insideDrift.tile.selection.active.row, 28);
1202 check('matching paint remaps latest inside pointer X', insideDrift.tile.selection.active.col, 7);
1203 insideDrift.tile.canvas.dispatchEvent('pointercancel', { pointerId: 26 });
1204
1205 const outsideXDrift = makeTile();
1206 outsideXDrift.tile.viewStartRow = 30;
1207 outsideXDrift.tile.requestedViewStartRow = 30;
1208 outsideXDrift.tile.paintScroll = () => {};
1209 outsideXDrift.tile.canvas.dispatchEvent('pointerdown', pointer(27, 2, 2));
1210 outsideXDrift.tile.canvas.dispatchEvent('pointermove', {
1211 ...pointer(27, 4, 0), clientY: 10,
1212 });
1213 h.intervals[outsideXDrift.tile.selectionScrollTimer - 1].fn();
1214 outsideXDrift.tile.canvas.dispatchEvent('pointermove', {
1215 ...pointer(27, 8, 0), clientY: 10,
1216 });
1217 outsideXDrift.tile.onMessage(scrollEnvelope(25));
1218 check('matching paint remaps changed outside pointer X', outsideXDrift.tile.selection.active.col, 8);
1219 check('matching paint clamps changed outside pointer to painted boundary', outsideXDrift.tile.selection.active.row, 25);
1220 outsideXDrift.tile.canvas.dispatchEvent('pointercancel', { pointerId: 27 });
1221
1060 const failedAuto = makeTile(); 1222 const failedAuto = makeTile();
1061 failedAuto.tile.viewStartRow = 30; 1223 failedAuto.tile.viewStartRow = 30;
1062 failedAuto.tile.requestedViewStartRow = 30; 1224 failedAuto.tile.requestedViewStartRow = 30;
@@ -1067,10 +1229,12 @@ async function verifySelectionShell(shell, html) {
1067 const failedAutoTimer = h.intervals[failedAuto.tile.selectionScrollTimer - 1]; 1229 const failedAutoTimer = h.intervals[failedAuto.tile.selectionScrollTimer - 1];
1068 failedAutoTimer.fn(); 1230 failedAutoTimer.fn();
1069 failedAuto.setScrollFeedResult(-3); 1231 failedAuto.setScrollFeedResult(-3);
1232 const failedDecodeTimeout = h.timers[failedAuto.tile.scrollRequestTimer - 1];
1070 failedAuto.tile.onMessage(scrollEnvelope(25)); 1233 failedAuto.tile.onMessage(scrollEnvelope(25));
1071 check('failed auto-scroll reply preserves painted viewport', failedAuto.tile.viewStartRow, 30); 1234 check('failed auto-scroll reply preserves painted viewport', failedAuto.tile.viewStartRow, 30);
1072 check('failed auto-scroll reply preserves displayed endpoint', failedAuto.tile.selection.active.row, 30); 1235 check('failed auto-scroll reply preserves displayed endpoint', failedAuto.tile.selection.active.row, 30);
1073 check('failed auto-scroll reply rolls requested page back', failedAuto.tile.scrollPages, 0); 1236 check('failed auto-scroll reply rolls requested page back', failedAuto.tile.scrollPages, 0);
1237 check('failed auto-scroll reply clears response timeout', failedDecodeTimeout?.cleared, true);
1074 failedAuto.setScrollFeedResult(0); 1238 failedAuto.setScrollFeedResult(0);
1075 failedAutoTimer.fn(); 1239 failedAutoTimer.fn();
1076 check('auto-scroll retries the same page after a failed decode', failedAuto.sent.length, 2); 1240 check('auto-scroll retries the same page after a failed decode', failedAuto.sent.length, 2);
@@ -1091,9 +1255,11 @@ async function verifySelectionShell(shell, html) {
1091 const failedStageTimer = h.intervals[failedAutoStage.tile.selectionScrollTimer - 1]; 1255 const failedStageTimer = h.intervals[failedAutoStage.tile.selectionScrollTimer - 1];
1092 failedStageTimer.fn(); 1256 failedStageTimer.fn();
1093 failedAutoStage.tile.stage = () => false; 1257 failedAutoStage.tile.stage = () => false;
1258 const failedStageTimeout = h.timers[failedAutoStage.tile.scrollRequestTimer - 1];
1094 failedAutoStage.tile.onMessage(scrollEnvelope(25)); 1259 failedAutoStage.tile.onMessage(scrollEnvelope(25));
1095 check('failed auto-scroll staging rolls requested page back', failedAutoStage.tile.scrollPages, 0); 1260 check('failed auto-scroll staging rolls requested page back', failedAutoStage.tile.scrollPages, 0);
1096 check('failed auto-scroll staging preserves painted endpoint', failedAutoStage.tile.selection.active.row, 30); 1261 check('failed auto-scroll staging preserves painted endpoint', failedAutoStage.tile.selection.active.row, 30);
1262 check('failed auto-scroll staging clears response timeout', failedStageTimeout?.cleared, true);
1097 failedAutoStage.tile.stage = h.Tile.prototype.stage.bind(failedAutoStage.tile); 1263 failedAutoStage.tile.stage = h.Tile.prototype.stage.bind(failedAutoStage.tile);
1098 failedStageTimer.fn(); 1264 failedStageTimer.fn();
1099 check('auto-scroll retries after failed staging', failedAutoStage.sent.length, 2); 1265 check('auto-scroll retries after failed staging', failedAutoStage.sent.length, 2);
@@ -1147,6 +1313,38 @@ async function verifySelectionShell(shell, html) {
1147 check('late scroll reply after release cannot change finalized selection', JSON.stringify(releaseRace.tile.selection.active), finalizedActive); 1313 check('late scroll reply after release cannot change finalized selection', JSON.stringify(releaseRace.tile.selection.active), finalizedActive);
1148 check('late scroll reply after release sends no second selection request', releaseRace.requestCalls.length, 1); 1314 check('late scroll reply after release sends no second selection request', releaseRace.requestCalls.length, 1);
1149 1315
1316 const finalUp = makeTile();
1317 finalUp.tile.viewStartRow = 30;
1318 finalUp.tile.requestedViewStartRow = 30;
1319 finalUp.tile.canvas.dispatchEvent('pointerdown', pointer(28, 2, 1));
1320 finalUp.tile.canvas.dispatchEvent('pointerup', pointer(28, 6, 4));
1321 check(
1322 'pointerup samples final coordinates even without a last pointermove',
1323 JSON.stringify(finalUp.requestCalls),
1324 JSON.stringify([[1, 31, 2, 34, 6]]),
1325 );
1326 check('pointerup-only endpoint movement repaints retained overlay once', finalUp.reflows(), 2);
1327
1328 const finalPendingUp = makeTile();
1329 finalPendingUp.tile.viewStartRow = 30;
1330 finalPendingUp.tile.requestedViewStartRow = 30;
1331 finalPendingUp.tile.paintScroll = () => {};
1332 finalPendingUp.tile.canvas.dispatchEvent('pointerdown', pointer(29, 2, 2));
1333 finalPendingUp.tile.canvas.dispatchEvent('pointermove', {
1334 ...pointer(29, 4, 0), clientY: 10,
1335 });
1336 h.intervals[finalPendingUp.tile.selectionScrollTimer - 1].fn();
1337 finalPendingUp.tile.canvas.dispatchEvent('pointerup', pointer(29, 7, 3));
1338 check(
1339 'pointerup before page reply samples final point on displayed viewport',
1340 JSON.stringify(finalPendingUp.requestCalls),
1341 JSON.stringify([[1, 32, 2, 33, 7]]),
1342 );
1343 check('changed pointerup after move adds one final overlay repaint', finalPendingUp.reflows(), 3);
1344 const finalPendingPoint = JSON.stringify(finalPendingUp.tile.selection.active);
1345 finalPendingUp.tile.onMessage(scrollEnvelope(25));
1346 check('late painted page cannot remap final pointerup point', JSON.stringify(finalPendingUp.tile.selection.active), finalPendingPoint);
1347
1150 const painted = makeTile(); 1348 const painted = makeTile();
1151 const order = []; 1349 const order = [];
1152 painted.tile.sizeCanvas = () => {}; 1350 painted.tile.sizeCanvas = () => {};
@@ -1432,6 +1630,31 @@ async function verifySelectionShell(shell, html) {
1432 check('composition handling precedes selection copy chords', composingWrites, 0); 1630 check('composition handling precedes selection copy chords', composingWrites, 0);
1433 check('composing copy chord remains unprevented', composingEvent.wasPrevented(), false); 1631 check('composing copy chord remains unprevented', composingEvent.wasPrevented(), false);
1434 1632
1633 const selectionRetryTab = makeTile();
1634 selectionRetryTab.tile.selection = authoritativeSelection('retry by keyboard');
1635 selectionRetryTab.tile.renderCopyUi('selection', 'copy-request on error', 'Copy failed');
1636 h.setZoomedTile(selectionRetryTab.tile);
1637 h.elements.ime.focus();
1638 const selectionTab = keyEvent({ key: 'Tab' });
1639 h.document.dispatchEvent('keydown', selectionTab);
1640 check('plain Tab reaches actionable selection-copy failure', selectionTab.wasPrevented(), true);
1641 check('selection-copy retry receives keyboard focus', h.document.activeElement, selectionRetryTab.tile.copyButton);
1642 check('selection-copy retry Tab sends no PTY bytes', selectionRetryTab.sent.length, 0);
1643
1644 const unavailableTab = makeTile();
1645 unavailableTab.tile.selection = authoritativeSelection(null);
1646 unavailableTab.tile.pendingClipboard = 'unrelated hidden OSC52';
1647 unavailableTab.tile.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable');
1648 unavailableTab.tile.core.mux_key_encode = () => 1;
1649 unavailableTab.tile.core.mux_output_len = () => 1;
1650 h.setZoomedTile(unavailableTab.tile);
1651 h.elements.ime.focus();
1652 const unavailableSelectionTab = keyEvent({ key: 'Tab' });
1653 h.document.dispatchEvent('keydown', unavailableSelectionTab);
1654 check('unavailable selection does not advertise an actionable retry', h.document.activeElement, h.elements.ime);
1655 check('unavailable selection Tab keeps terminal key handling', unavailableSelectionTab.wasPrevented(), true);
1656 check('unavailable selection Tab reaches PTY once', unavailableTab.sent.length, 1);
1657
1435 const scrollExitKey = makeTile(); 1658 const scrollExitKey = makeTile();
1436 scrollExitKey.tile.scrollPages = 1; 1659 scrollExitKey.tile.scrollPages = 1;
1437 scrollExitKey.tile.viewStartRow = 25; 1660 scrollExitKey.tile.viewStartRow = 25;
@@ -1517,7 +1740,7 @@ async function verifySelectionShell(shell, html) {
1517 check('explicit selection success reports Copied', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); 1740 check('explicit selection success reports Copied', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied');
1518 oldOsc.resolve(); 1741 oldOsc.resolve();
1519 await oldOscRun; 1742 await oldOscRun;
1520 check('older OSC52 settlement cannot overwrite selection-copy success', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); 1743 check('older OSC52 settlement cannot overwrite selection-copy success UI', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied');
1521 check('older OSC52 still completes its serialized pending state', clipboardRace.tile.pendingClipboard, null); 1744 check('older OSC52 still completes its serialized pending state', clipboardRace.tile.pendingClipboard, null);
1522 1745
1523 const oldTimerRace = makeTile(); 1746 const oldTimerRace = makeTile();
@@ -1550,6 +1773,7 @@ async function verifySelectionShell(shell, html) {
1550 copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} }); 1773 copyFailure.tile.copyButton.dispatchEvent('click', { stopPropagation() {} });
1551 await flushPromises(); 1774 await flushPromises();
1552 check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection'); 1775 check('selection failure button retries the retained selection only', retryWrites.join('|'), 'retry exact selection');
1776 check('selection retry click restores hidden IME focus', h.document.activeElement, h.elements.ime);
1553 1777
1554 const invalidatedCopy = makeTile(); 1778 const invalidatedCopy = makeTile();
1555 invalidatedCopy.tile.selection = authoritativeSelection('old selection'); 1779 invalidatedCopy.tile.selection = authoritativeSelection('old selection');
@@ -1588,6 +1812,16 @@ async function verifySelectionShell(shell, html) {
1588 await unzoomCopyRun; 1812 await unzoomCopyRun;
1589 check('late selection-copy failure after unzoom remains invisible', `${unzoomCopy.tile.copyButton.className}|${unzoomCopy.tile.copyButton.textContent}`, 'copy-request|Copy'); 1813 check('late selection-copy failure after unzoom remains invisible', `${unzoomCopy.tile.copyButton.className}|${unzoomCopy.tile.copyButton.textContent}`, 'copy-request|Copy');
1590 1814
1815 const unzoomScroll = makeTile();
1816 unzoomScroll.tile.viewStartRow = 30;
1817 unzoomScroll.tile.requestedViewStartRow = 30;
1818 unzoomScroll.tile.changeScrollPages(1);
1819 const unzoomScrollTimeout = h.timers[unzoomScroll.tile.scrollRequestTimer - 1];
1820 h.setZoomedTile(unzoomScroll.tile);
1821 h.unzoom();
1822 check('unzoom invalidates pending scroll request', unzoomScroll.tile.scrollRequest, null);
1823 check('unzoom clears pending scroll timeout', unzoomScrollTimeout?.cleared, true);
1824
1591 const newSelectionUi = makeTile(); 1825 const newSelectionUi = makeTile();
1592 newSelectionUi.tile.selection = { 1826 newSelectionUi.tile.selection = {
1593 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null, 1827 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 76, text: null,
@@ -1614,17 +1848,23 @@ async function verifySelectionShell(shell, html) {
1614 anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session', 1848 anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 7, text: 'resolved old session',
1615 }; 1849 };
1616 reset.tile.drag = { pointerId: 15, moved: true }; 1850 reset.tile.drag = { pointerId: 15, moved: true };
1617 reset.tile.scrollPages = 1; 1851 reset.tile.lastPointerX = 20;
1618 reset.tile.scrollRequest = { fromPages: 0, start: 25 }; 1852 reset.tile.lastPointerY = 30;
1853 reset.tile.viewStartRow = 30;
1854 reset.tile.requestedViewStartRow = 30;
1855 reset.tile.changeScrollPages(1);
1856 const resetScrollTimeout = h.timers[reset.tile.scrollRequestTimer - 1];
1619 reset.tile.canvas.setPointerCapture(15); 1857 reset.tile.canvas.setPointerCapture(15);
1620 reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120); 1858 reset.tile.selectionScrollTimer = h.context.setInterval(() => {}, 120);
1621 const resetTimer = reset.tile.selectionScrollTimer; 1859 const resetTimer = reset.tile.selectionScrollTimer;
1622 reset.tile.resetCore('selection test'); 1860 reset.tile.resetCore('selection test');
1623 check('destructive core reset clears resolved selection', reset.tile.selection, null); 1861 check('destructive core reset clears resolved selection', reset.tile.selection, null);
1624 check('destructive core reset clears drag state', reset.tile.drag, null); 1862 check('destructive core reset clears drag state', reset.tile.drag, null);
1625 check('destructive core reset clears pointer position', reset.tile.lastPointerY, null); 1863 check('destructive core reset clears pointer X', reset.tile.lastPointerX, null);
1864 check('destructive core reset clears pointer Y', reset.tile.lastPointerY, null);
1626 check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true); 1865 check('destructive core reset stops selection timer', h.intervals[resetTimer - 1]?.cleared, true);
1627 check('destructive core reset clears orphaned scroll request', reset.tile.scrollRequest, null); 1866 check('destructive core reset clears orphaned scroll request', reset.tile.scrollRequest, null);
1867 check('destructive core reset clears scroll timeout', resetScrollTimeout?.cleared, true);
1628 1868
1629 const reconnect = makeTile(); 1869 const reconnect = makeTile();
1630 reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1)); 1870 reconnect.tile.canvas.dispatchEvent('pointerdown', pointer(16, 1, 1));
@@ -1634,7 +1874,8 @@ async function verifySelectionShell(shell, html) {
1634 reconnect.tile.ws.onopen(); 1874 reconnect.tile.ws.onopen();
1635 check('new socket clears pending selection from prior connection', reconnect.tile.selection, null); 1875 check('new socket clears pending selection from prior connection', reconnect.tile.selection, null);
1636 check('new socket clears prior drag state', reconnect.tile.drag, null); 1876 check('new socket clears prior drag state', reconnect.tile.drag, null);
1637 check('new socket clears prior pointer position', reconnect.tile.lastPointerY, null); 1877 check('new socket clears prior pointer X', reconnect.tile.lastPointerX, null);
1878 check('new socket clears prior pointer Y', reconnect.tile.lastPointerY, null);
1638 check('new socket stops prior selection timer', h.intervals[reconnectTimer - 1]?.cleared, true); 1879 check('new socket stops prior selection timer', h.intervals[reconnectTimer - 1]?.cleared, true);
1639 1880
1640 const reattach = makeTile(); 1881 const reattach = makeTile();