a73x

b41f8a73

test: pin web selection lifecycle and interrupt behaviour

a73x   2026-08-18 12:27

Commit message
test: pin web selection lifecycle and interrupt behaviour

Four behaviours decisions.md already claims survived every check in
web/verify.js. Each pin was confirmed against its own mutation:

  - an existing-but-undecoded selection must not swallow Ctrl+C, or the
    shell gets no interrupt for up to the whole response timeout;
  - session exit and a non-`up` daemon leg each clear the retained
    selection, not just the pending scrollback request;
  - a wheel tick during an outstanding fetch is refused, so it cannot
    replace scrollRequest and its timer without clearing the old one.

Also renames the clipboard snapshot assertion to what it can actually
hold up. Swapping its `.slice` for `.subarray` leaves the whole file
green, because nothing runs between the copy and the decode; the check
pins the decode landing before the first await, and now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

web/verify.js
Old New
@@ -529,8 +529,16 @@ async function verifyClipboardShell(shell, html) {
529 const snapshotCalls = []; 529 const snapshotCalls = [];
530 h.navigator.clipboard = { writeText: (text) => { snapshotCalls.push(text); return snapshotWrite.promise; } }; 530 h.navigator.clipboard = { writeText: (text) => { snapshotCalls.push(text); return snapshotWrite.promise; } };
531 const snapshotRun = snap.tile.onClipboardEffect(); 531 const snapshotRun = snap.tile.onClipboardEffect();
532 // The staging bytes are reused the instant that call returns, and the
533 // pending text below is already the decoded value: the DECODE landed
534 // before the first await. That is the whole of what this can pin. It is
535 // deliberately NOT a claim about the `.slice` that takes the bytes —
536 // nothing runs between the copy and `clipboardText`, so swapping in
537 // `.subarray` is invisible to every check in this file (measured). The
538 // copy is still correct, because a view is only safe while nothing can
539 // grow linear memory; it is simply not a property a test can hold up.
532 new Uint8Array(snap.memory.buffer).fill('A'.charCodeAt(0), 17, 17 + snap.encoded.length); 540 new Uint8Array(snap.memory.buffer).fill('A'.charCodeAt(0), 17, 17 + snap.encoded.length);
533 check('zoomed effect snapshots clipboard bytes before await', snap.tile.pendingClipboard, 'snapshot ☃'); 541 check('zoomed effect decodes clipboard text before any await', snap.tile.pendingClipboard, 'snapshot ☃');
534 check('zoomed effect starts automatic write', snapshotCalls.join('|'), 'snapshot ☃'); 542 check('zoomed effect starts automatic write', snapshotCalls.join('|'), 'snapshot ☃');
535 check('zoomed effect advances version', snap.tile.clipboardVersion, 1); 543 check('zoomed effect advances version', snap.tile.clipboardVersion, 1);
536 snapshotWrite.resolve(); 544 snapshotWrite.resolve();
@@ -985,6 +993,20 @@ async function verifySelectionShell(shell, html) {
985 check('session exit rolls pending scroll request back', exitLifecycle.tile.scrollRequest, null); 993 check('session exit rolls pending scroll request back', exitLifecycle.tile.scrollRequest, null);
986 check('session exit clears pending scroll timeout', exitTimeout?.cleared, true); 994 check('session exit clears pending scroll timeout', exitTimeout?.cleared, true);
987 995
996 const controlClearsSelection = makeTile();
997 controlClearsSelection.tile.selection = {
998 anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 61, text: 'daemon leg dropped',
999 };
1000 controlClearsSelection.tile.onMessage(reconnectingControl);
1001 check('daemon reconnect lifecycle clears the retained selection', controlClearsSelection.tile.selection, null);
1002
1003 const exitClearsSelection = makeTile();
1004 exitClearsSelection.tile.selection = {
1005 anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 62, text: 'outlived the shell',
1006 };
1007 exitClearsSelection.tile.onMessage(Uint8Array.from([0, 0x82, 1, 0, 0, 0, 0]));
1008 check('session exit clears the retained selection', exitClearsSelection.tile.selection, null);
1009
988 const countMismatch = makeTile(); 1010 const countMismatch = makeTile();
989 countMismatch.tile.viewStartRow = 30; 1011 countMismatch.tile.viewStartRow = 30;
990 countMismatch.tile.requestedViewStartRow = 30; 1012 countMismatch.tile.requestedViewStartRow = 30;
@@ -1229,6 +1251,21 @@ async function verifySelectionShell(shell, html) {
1229 check('matching scrollback echo becomes the displayed start row', scrolling.tile.viewStartRow, 25); 1251 check('matching scrollback echo becomes the displayed start row', scrolling.tile.viewStartRow, 25);
1230 check('scrollback echo paints the history viewport', paintedScroll, 1); 1252 check('scrollback echo paints the history viewport', paintedScroll, 1);
1231 1253
1254 // A wheel tick arriving while a fetch is outstanding must not replace
1255 // scrollRequest and scrollRequestTimer without clearing the old timer:
1256 // that leaks one setTimeout per tick and leaves a rollback armed for a
1257 // request nobody is waiting on any more.
1258 const wheelDuringFetch = makeTile();
1259 wheelDuringFetch.tile.viewStartRow = 30;
1260 wheelDuringFetch.tile.changeScrollPages(1);
1261 const inFlightScroll = wheelDuringFetch.tile.scrollRequest;
1262 const inFlightScrollTimer = wheelDuringFetch.tile.scrollRequestTimer;
1263 wheelDuringFetch.tile.onWheel({ deltaY: -1 });
1264 check('a wheel tick during a pending fetch sends nothing', wheelDuringFetch.sent.length, 1);
1265 check('a wheel tick during a pending fetch keeps the one request', wheelDuringFetch.tile.scrollRequest, inFlightScroll);
1266 check('a wheel tick during a pending fetch keeps its one timer', wheelDuringFetch.tile.scrollRequestTimer, inFlightScrollTimer);
1267 check('a wheel tick during a pending fetch does not move page intent', wheelDuringFetch.tile.scrollPages, 1);
1268
1232 const failedStage = makeTile(); 1269 const failedStage = makeTile();
1233 failedStage.tile.scrollPages = 1; 1270 failedStage.tile.scrollPages = 1;
1234 failedStage.tile.viewStartRow = 30; 1271 failedStage.tile.viewStartRow = 30;
@@ -1940,6 +1977,24 @@ async function verifySelectionShell(shell, html) {
1940 check('Ctrl+C without selection sends one PTY frame', terminalCopy.sent[0]?.type, 0x02); 1977 check('Ctrl+C without selection sends one PTY frame', terminalCopy.sent[0]?.type, 0x02);
1941 check('Ctrl+C fake encoder emits ETX byte', terminalCopy.sent[0]?.payload[0], 3); 1978 check('Ctrl+C fake encoder emits ETX byte', terminalCopy.sent[0]?.payload[0], 3);
1942 1979
1980 // The retained selection exists but its reply has not decoded (or came
1981 // back non-ok), so copySelection would return at its own text === null
1982 // guard. Treating "a selection object exists" as "there is something to
1983 // copy" swallows the interrupt for up to the whole response timeout.
1984 const undecodedCopy = makeTile();
1985 undecodedCopy.tile.selection = authoritativeSelection(null);
1986 undecodedCopy.tile.core.mux_key_encode = () => 1;
1987 undecodedCopy.tile.core.mux_output_len = () => 1;
1988 h.setZoomedTile(undecodedCopy.tile);
1989 const undecodedWrites = [];
1990 h.navigator.clipboard = { writeText: (text) => { undecodedWrites.push(text); return Promise.resolve(); } };
1991 const undecodedCtrlC = keyEvent({ key: 'c', ctrlKey: true });
1992 h.document.dispatchEvent('keydown', undecodedCtrlC);
1993 await flushPromises();
1994 check('Ctrl+C with an undecoded selection still reaches the shell', undecodedCopy.sent[0]?.type, 0x02);
1995 check('Ctrl+C with an undecoded selection sends exactly one frame', undecodedCopy.sent.length, 1);
1996 check('Ctrl+C with an undecoded selection writes no clipboard', undecodedWrites.length, 0);
1997
1943 const altCopy = makeTile(); 1998 const altCopy = makeTile();
1944 const altKeys = []; 1999 const altKeys = [];
1945 altCopy.tile.selection = authoritativeSelection('must not copy'); 2000 altCopy.tile.selection = authoritativeSelection('must not copy');