a73x

4c1110f1

fix: serialize web clipboard writes

a73x   2026-08-18 12:27

Commit message
fix: serialize web clipboard writes

web/mux.js
Old New
@@ -129,6 +129,7 @@ class Tile {
129 this.statusText = 'connecting'; 129 this.statusText = 'connecting';
130 this.pendingClipboard = null; 130 this.pendingClipboard = null;
131 this.clipboardVersion = 0; 131 this.clipboardVersion = 0;
132 this.clipboardWriteActive = false;
132 133
133 this.el = document.createElement('div'); 134 this.el = document.createElement('div');
134 this.el.className = 'tile'; 135 this.el.className = 'tile';
@@ -139,6 +140,7 @@ class Tile {
139 this.copyButton.addEventListener('click', (ev) => { 140 this.copyButton.addEventListener('click', (ev) => {
140 ev.stopPropagation(); 141 ev.stopPropagation();
141 this.copyPendingClipboard(); 142 this.copyPendingClipboard();
143 ime.focus();
142 }); 144 });
143 this.canvas = document.createElement('canvas'); 145 this.canvas = document.createElement('canvas');
144 this.el.appendChild(this.canvas); 146 this.el.appendChild(this.canvas);
@@ -471,14 +473,31 @@ class Tile {
471 } 473 }
472 474
473 async tryClipboardWrite(version, automatic) { 475 async tryClipboardWrite(version, automatic) {
476 if (this.clipboardWriteActive) return;
474 if (version !== this.clipboardVersion || this.pendingClipboard === null) return; 477 if (version !== this.clipboardVersion || this.pendingClipboard === null) return;
475 const text = this.pendingClipboard; 478 const text = this.pendingClipboard;
479 this.clipboardWriteActive = true;
480 let succeeded = false;
476 try { 481 try {
477 const writeText = navigator.clipboard?.writeText; 482 const writeText = navigator.clipboard?.writeText;
478 if (typeof writeText !== 'function') throw new Error('clipboard unavailable'); 483 if (typeof writeText !== 'function') throw new Error('clipboard unavailable');
479 await writeText.call(navigator.clipboard, text); 484 await writeText.call(navigator.clipboard, text);
485 succeeded = true;
480 } catch (_) { 486 } catch (_) {
481 if (version !== this.clipboardVersion || this.pendingClipboard !== text) return; 487 // Expected platform failures are rendered below, never allowed to
488 // reject the promise launched by onMessage or the button handler.
489 }
490 this.clipboardWriteActive = false;
491
492 // A newer event owns the one pending slot. Serialize its write after
493 // this one and skip every intermediate value it already replaced.
494 if (version !== this.clipboardVersion || this.pendingClipboard !== text) {
495 if (this.zoomed && this.pendingClipboard !== null)
496 return this.tryClipboardWrite(this.clipboardVersion, true);
497 return;
498 }
499
500 if (!succeeded) {
482 this.copyButton.className = automatic 501 this.copyButton.className = automatic
483 ? 'copy-request on' 502 ? 'copy-request on'
484 : 'copy-request on error'; 503 : 'copy-request on error';
@@ -486,7 +505,6 @@ class Tile {
486 return; 505 return;
487 } 506 }
488 507
489 if (version !== this.clipboardVersion || this.pendingClipboard !== text) return;
490 this.pendingClipboard = null; 508 this.pendingClipboard = null;
491 this.copyButton.className = 'copy-request on'; 509 this.copyButton.className = 'copy-request on';
492 this.copyButton.textContent = 'Copied'; 510 this.copyButton.textContent = 'Copied';
@@ -745,6 +763,21 @@ document.addEventListener('keydown', (ev) => {
745 const t = zoomedTile; 763 const t = zoomedTile;
746 if (!t) return; 764 if (!t) return;
747 if (ev.isComposing) return; // IME owns it; compositionend delivers 765 if (ev.isComposing) return; // IME owns it; compositionend delivers
766 // A visible clipboard retry is real browser chrome inside the tile.
767 // Let its native Enter/Space activation and Tab navigation work rather
768 // than translating those keys into terminal input.
769 if (ev.target === t.copyButton &&
770 (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar' || ev.key === 'Tab')) return;
771 // The hidden IME normally owns focus, so plain Tab would otherwise be
772 // encoded for the terminal. When a retry is visible, use that one key
773 // to make the recovery control keyboard reachable.
774 if (ev.target === ime && ev.key === 'Tab' &&
775 !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey &&
776 t.pendingClipboard !== null && t.copyButton.classList.contains('on')) {
777 ev.preventDefault();
778 t.copyButton.focus();
779 return;
780 }
748 const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0); 781 const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0);
749 // Leave genuine browser chords alone (copy/paste arrive as events): 782 // Leave genuine browser chords alone (copy/paste arrive as events):
750 // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which 783 // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which
web/verify.js
Old New
@@ -301,7 +301,13 @@ function deferred() {
301 return { promise, resolve, reject }; 301 return { promise, resolve, reject };
302 } 302 }
303 303
304 function executableCss(source) {
305 return source.replace(/\/\*[\s\S]*?\*\//g, (comment) =>
306 comment.replace(/[^\n\r]/g, ' '));
307 }
308
304 function browserShell(source) { 309 function browserShell(source) {
310 let activeElement = null;
305 class FakeClassList { 311 class FakeClassList {
306 constructor(owner) { this.owner = owner; } 312 constructor(owner) { this.owner = owner; }
307 add(...names) { 313 add(...names) {
@@ -314,6 +320,7 @@ function browserShell(source) {
314 this.owner.className = this.owner.className.split(/\s+/) 320 this.owner.className = this.owner.className.split(/\s+/)
315 .filter((name) => name && !gone.has(name)).join(' '); 321 .filter((name) => name && !gone.has(name)).join(' ');
316 } 322 }
323 contains(name) { return this.owner.className.split(/\s+/).includes(name); }
317 } 324 }
318 325
319 class FakeElement { 326 class FakeElement {
@@ -377,8 +384,8 @@ function browserShell(source) {
377 setTransform() {}, fillRect() {}, fillText() {}, 384 setTransform() {}, fillRect() {}, fillText() {},
378 }; 385 };
379 } 386 }
380 focus() { this.focused = true; } 387 focus() { activeElement = this; }
381 blur() { this.focused = false; } 388 blur() { if (activeElement === this) activeElement = null; }
382 } 389 }
383 390
384 const elements = { 391 const elements = {
@@ -386,10 +393,13 @@ function browserShell(source) {
386 ime: new FakeElement('input'), 393 ime: new FakeElement('input'),
387 wall: new FakeElement('div'), 394 wall: new FakeElement('div'),
388 }; 395 };
396 const documentListeners = new Map();
389 const document = { 397 const document = {
390 createElement: (tag) => new FakeElement(tag), 398 createElement: (tag) => new FakeElement(tag),
391 getElementById: (id) => elements[id], 399 getElementById: (id) => elements[id],
392 addEventListener() {}, 400 get activeElement() { return activeElement; },
401 addEventListener(type, fn) { documentListeners.set(type, fn); },
402 dispatchEvent(type, event) { return documentListeners.get(type)?.(event); },
393 }; 403 };
394 const timers = []; 404 const timers = [];
395 const navigator = { clipboard: undefined }; 405 const navigator = { clipboard: undefined };
@@ -411,8 +421,10 @@ function browserShell(source) {
411 } 421 }
412 422
413 async function flushPromises() { 423 async function flushPromises() {
414 await Promise.resolve(); 424 // Some clipboard fakes deliberately use then/finally to model the
415 await Promise.resolve(); 425 // external side effect and active-call accounting. Drain enough turns
426 // for the Tile's await continuation and a serialized follow-up to run.
427 for (let i = 0; i < 8; i++) await Promise.resolve();
416 } 428 }
417 429
418 async function verifyClipboardShell(shell, html) { 430 async function verifyClipboardShell(shell, html) {
@@ -449,7 +461,8 @@ async function verifyClipboardShell(shell, html) {
449 461
450 const architecture = tileFor('architecture'); 462 const architecture = tileFor('architecture');
451 const hasClipboardState = architecture.tile.pendingClipboard === null 463 const hasClipboardState = architecture.tile.pendingClipboard === null
452 && architecture.tile.clipboardVersion === 0; 464 && architecture.tile.clipboardVersion === 0
465 && architecture.tile.clipboardWriteActive === false;
453 const hasCopyButton = architecture.tile.copyButton?.tagName === 'BUTTON'; 466 const hasCopyButton = architecture.tile.copyButton?.tagName === 'BUTTON';
454 check('tile initializes clipboard request state', hasClipboardState, true); 467 check('tile initializes clipboard request state', hasClipboardState, true);
455 check('tile constructor stores a real copy button', hasCopyButton, true); 468 check('tile constructor stores a real copy button', hasCopyButton, true);
@@ -518,38 +531,61 @@ async function verifyClipboardShell(shell, html) {
518 check('automatic rejection shows retry without error', rejected.tile.copyButton.className, 'copy-request on'); 531 check('automatic rejection shows retry without error', rejected.tile.copyButton.className, 'copy-request on');
519 532
520 const latest = tileFor('old'); 533 const latest = tileFor('old');
521 const first = deferred(), second = deferred(); 534 const serial = {
522 const writes = [first, second]; 535 active: 0, maxActive: 0, system: null, calls: [],
523 h.navigator.clipboard = { writeText: () => writes.shift().promise }; 536 writeText(text) {
524 const firstRun = latest.tile.onClipboardEffect(); 537 const done = deferred();
525 const newest = Buffer.from('new', 'utf8').toString('base64'); 538 this.calls.push({ text, done });
526 new Uint8Array(latest.memory.buffer).fill(0); 539 this.active++;
527 new Uint8Array(latest.memory.buffer).set(Buffer.from(newest, 'ascii'), 17); 540 this.maxActive = Math.max(this.maxActive, this.active);
528 latest.tile.core.mux_clipboard_len = () => newest.length; 541 return done.promise.then(() => { this.system = text; }).finally(() => { this.active--; });
529 const secondRun = latest.tile.onClipboardEffect(); 542 },
530 second.reject(new Error('new denied')); 543 };
531 await secondRun; 544 h.navigator.clipboard = { writeText: serial.writeText.bind(serial) };
532 first.resolve(); 545 const latestRun = latest.tile.onClipboardEffect();
533 await firstRun; 546 for (const value of ['middle one', 'middle two', 'newest']) {
534 check('late old success cannot clear newer pending text', latest.tile.pendingClipboard, 'new'); 547 const encoded = Buffer.from(value, 'utf8').toString('base64');
535 check('late old success cannot overwrite newer UI', latest.tile.copyButton.textContent, 'Copy'); 548 new Uint8Array(latest.memory.buffer).fill(0);
536 549 new Uint8Array(latest.memory.buffer).set(Buffer.from(encoded, 'ascii'), 17);
537 const lateFailure = tileFor('old failure'); 550 latest.tile.core.mux_clipboard_len = () => encoded.length;
538 const oldFailure = deferred(), newFailure = deferred(); 551 latest.tile.onClipboardEffect();
539 const failureWrites = [oldFailure, newFailure]; 552 }
540 h.navigator.clipboard = { writeText: () => failureWrites.shift().promise }; 553 check('unresolved clipboard writes have one external call', serial.calls.length, 1);
541 const oldFailureRun = lateFailure.tile.onClipboardEffect(); 554 check('unresolved clipboard writes retain only latest pending text', latest.tile.pendingClipboard, 'newest');
542 const later = Buffer.from('new failure', 'utf8').toString('base64'); 555 check('clipboard writes never overlap before settlement', serial.maxActive, 1);
543 new Uint8Array(lateFailure.memory.buffer).fill(0); 556 serial.calls[0].done.resolve();
544 new Uint8Array(lateFailure.memory.buffer).set(Buffer.from(later, 'ascii'), 17); 557 await flushPromises();
545 lateFailure.tile.core.mux_clipboard_len = () => later.length; 558 check('settled old write starts exactly one coalesced follow-up', serial.calls.length, 2);
546 const newFailureRun = lateFailure.tile.onClipboardEffect(); 559 check('coalesced follow-up skips intermediate clipboard values', serial.calls[1]?.text, 'newest');
547 newFailure.reject(new Error('new denied')); 560 check('old success cannot overwrite newer pending UI', `${latest.tile.copyButton.className}|${latest.tile.copyButton.textContent}`, 'copy-request|Copy');
548 await newFailureRun; 561 serial.calls[1].done.resolve();
549 oldFailure.reject(new Error('old denied')); 562 await latestRun;
550 await oldFailureRun; 563 check('serialized clipboard writes have maximum concurrency one', serial.maxActive, 1);
551 check('late old failure cannot replace newer pending text', lateFailure.tile.pendingClipboard, 'new failure'); 564 check('serialized external clipboard finishes with newest value', serial.system, 'newest');
552 check('late old failure cannot overwrite newer UI', lateFailure.tile.copyButton.textContent, 'Copy'); 565 check('newest serialized success clears pending text', latest.tile.pendingClipboard, null);
566
567 const afterFailure = tileFor('old failure');
568 const failedSerial = { calls: [] };
569 h.navigator.clipboard = { writeText: (text) => {
570 const done = deferred();
571 failedSerial.calls.push({ text, done });
572 return done.promise;
573 } };
574 const afterFailureRun = afterFailure.tile.onClipboardEffect();
575 const afterFailureText = Buffer.from('latest after failure', 'utf8').toString('base64');
576 new Uint8Array(afterFailure.memory.buffer).fill(0);
577 new Uint8Array(afterFailure.memory.buffer).set(Buffer.from(afterFailureText, 'ascii'), 17);
578 afterFailure.tile.core.mux_clipboard_len = () => afterFailureText.length;
579 afterFailure.tile.onClipboardEffect();
580 check('new value queues behind unresolved old write', failedSerial.calls.length, 1);
581 failedSerial.calls[0].done.reject(new Error('old denied'));
582 await flushPromises();
583 check('old failure starts only latest queued value', failedSerial.calls[1]?.text, 'latest after failure');
584 check('stale old failure does not show fallback UI', `${afterFailure.tile.copyButton.className}|${afterFailure.tile.copyButton.textContent}`, 'copy-request|Copy');
585 failedSerial.calls[1].done.reject(new Error('latest denied'));
586 await afterFailureRun;
587 check('latest failure retains latest pending request', afterFailure.tile.pendingClipboard, 'latest after failure');
588 check('latest automatic failure shows fallback UI', `${afterFailure.tile.copyButton.className}|${afterFailure.tile.copyButton.textContent}`, 'copy-request on|Copy');
553 589
554 const timerRace = tileFor('copied first'); 590 const timerRace = tileFor('copied first');
555 h.navigator.clipboard = { writeText: () => Promise.resolve() }; 591 h.navigator.clipboard = { writeText: () => Promise.resolve() };
@@ -568,32 +604,78 @@ async function verifyClipboardShell(shell, html) {
568 604
569 const leaving = tileFor('leave'); 605 const leaving = tileFor('leave');
570 const inFlight = deferred(); 606 const inFlight = deferred();
571 h.navigator.clipboard = { writeText: () => inFlight.promise }; 607 const leavingCalls = [];
608 h.navigator.clipboard = { writeText: (text) => { leavingCalls.push(text); return inFlight.promise; } };
572 const leavingRun = leaving.tile.onClipboardEffect(); 609 const leavingRun = leaving.tile.onClipboardEffect();
610 const queuedAfterLeave = Buffer.from('queued then left', 'utf8').toString('base64');
611 new Uint8Array(leaving.memory.buffer).fill(0);
612 new Uint8Array(leaving.memory.buffer).set(Buffer.from(queuedAfterLeave, 'ascii'), 17);
613 leaving.tile.core.mux_clipboard_len = () => queuedAfterLeave.length;
614 leaving.tile.onClipboardEffect();
615 check('unresolved write keeps queued latest out of clipboard API', leavingCalls.length, 1);
573 let reflows = 0; 616 let reflows = 0;
574 leaving.tile.exitScroll = () => {}; 617 leaving.tile.exitScroll = () => {};
575 leaving.tile.reflow = () => { reflows++; }; 618 leaving.tile.reflow = () => { reflows++; };
576 h.setZoomedTile(leaving.tile); 619 h.setZoomedTile(leaving.tile);
577 h.unzoom(); 620 h.unzoom();
578 check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 2); 621 check('unzoom invalidates clipboard work before reflow', leaving.tile.clipboardVersion, 3);
579 check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null); 622 check('unzoom clears pending clipboard text', leaving.tile.pendingClipboard, null);
580 check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); 623 check('unzoom hides and resets copy button', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy');
581 check('unzoom still reflows once', reflows, 1); 624 check('unzoom still reflows once', reflows, 1);
582 inFlight.resolve(); 625 inFlight.resolve();
583 await leavingRun; 626 await leavingRun;
627 check('unzoom prevents queued clipboard follow-up', leavingCalls.length, 1);
584 check('in-flight success after unzoom remains invisible', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy'); 628 check('in-flight success after unzoom remains invisible', `${leaving.tile.copyButton.className}|${leaving.tile.copyButton.textContent}`, 'copy-request|Copy');
585 629
586 const click = tileFor('click retry'); 630 const click = tileFor('click retry');
587 h.navigator.clipboard = undefined; 631 h.navigator.clipboard = undefined;
588 await click.tile.onClipboardEffect(); 632 await click.tile.onClipboardEffect();
633 h.setZoomedTile(click.tile);
634 const keyCalls = [];
635 click.tile.sendKey = (...args) => { keyCalls.push(args.join(',')); };
636 let tabPrevented = false;
637 h.elements.ime.focus();
638 h.document.dispatchEvent('keydown', {
639 key: 'Tab', target: h.elements.ime,
640 preventDefault: () => { tabPrevented = true; },
641 });
642 check('visible retry Tab is captured for button focus', tabPrevented, true);
643 check('visible retry Tab focuses copy button', h.document.activeElement, click.tile.copyButton);
644 check('visible retry Tab emits no terminal key', keyCalls.length, 0);
645
646 for (const key of ['Enter', ' ', 'Tab']) {
647 let prevented = false;
648 h.document.dispatchEvent('keydown', {
649 key, target: click.tile.copyButton,
650 preventDefault: () => { prevented = true; },
651 });
652 check(`copy button ${JSON.stringify(key)} keeps native behavior`, prevented, false);
653 }
654 check('copy button native keys emit no terminal bytes', keyCalls.length, 0);
655
589 const clickWrites = []; 656 const clickWrites = [];
590 h.navigator.clipboard = { writeText: (text) => { clickWrites.push(text); return Promise.resolve(); } }; 657 h.navigator.clipboard = { writeText: (text) => { clickWrites.push(text); return Promise.resolve(); } };
591 let stopped = false; 658 let stopped = false;
659 click.tile.copyButton.focus();
592 click.tile.copyButton.dispatchEvent('click', { stopPropagation: () => { stopped = true; } }); 660 click.tile.copyButton.dispatchEvent('click', { stopPropagation: () => { stopped = true; } });
593 await flushPromises(); 661 await flushPromises();
594 check('copy button click stops tile click propagation', stopped, true); 662 check('copy button click stops tile click propagation', stopped, true);
595 check('copy button click takes manual clipboard path', clickWrites.join('|'), 'click retry'); 663 check('copy button click takes manual clipboard path', clickWrites.join('|'), 'click retry');
596 check('copy button manual path clears pending on success', click.tile.pendingClipboard, null); 664 check('copy button manual path clears pending on success', click.tile.pendingClipboard, null);
665 check('copy button interaction restores IME focus', h.document.activeElement, h.elements.ime);
666
667 const terminalTab = tileFor('terminal tab');
668 h.setZoomedTile(terminalTab.tile);
669 const terminalKeys = [];
670 terminalTab.tile.sendKey = (...args) => { terminalKeys.push(args.join(',')); };
671 let terminalTabPrevented = false;
672 h.elements.ime.focus();
673 h.document.dispatchEvent('keydown', {
674 key: 'Tab', target: h.elements.ime,
675 preventDefault: () => { terminalTabPrevented = true; },
676 });
677 check('hidden fallback preserves terminal Tab prevention', terminalTabPrevented, true);
678 check('hidden fallback preserves terminal Tab encoding', terminalKeys.join('|'), '2,0,0');
597 679
598 const header = click.tile.el.querySelector('header'); 680 const header = click.tile.el.querySelector('header');
599 check('tile header places copy button between label and badge', header.children.map((el) => el.className).join('|'), 'label|copy-request on|badge connecting'); 681 check('tile header places copy button between label and badge', header.children.map((el) => el.className).join('|'), 'label|copy-request on|badge connecting');
@@ -602,9 +684,21 @@ async function verifyClipboardShell(shell, html) {
602 check('tile header does not interpolate label into innerHTML', click.tile.el.innerHTML.includes('<unsafe-label>'), false); 684 check('tile header does not interpolate label into innerHTML', click.tile.el.innerHTML.includes('<unsafe-label>'), false);
603 check('tile header does not interpolate session into innerHTML', click.tile.el.innerHTML.includes('<unsafe-session>'), false); 685 check('tile header does not interpolate session into innerHTML', click.tile.el.innerHTML.includes('<unsafe-session>'), false);
604 686
605 check('page styles copy control hidden by default', /\.copy-request\s*\{[^}]*display\s*:\s*none\s*;[^}]*\}/s.test(html), true); 687 const hiddenRule = /\.copy-request\s*\{[^}]*display\s*:\s*none\s*;[^}]*\}/s;
606 check('page styles copy control visible on request', /\.copy-request\.on\s*\{[^}]*display\s*:\s*(?!none)[^;}]+\s*;[^}]*\}/s.test(html), true); 688 const visibleRule = /\.copy-request\.on\s*\{[^}]*display\s*:\s*(?!none)[^;}]+\s*;[^}]*\}/s;
607 check('page gives failed copy a distinct style', /\.copy-request\.error\s*\{[^}]+\}/s.test(html), true); 689 const errorRule = /\.copy-request\.error\s*\{[^}]+\}/s;
690 const commentedRules = executableCss(`/*
691 .copy-request { display: none; }
692 .copy-request.on { display: inline-block; }
693 .copy-request.error { color: red; }
694 */`);
695 check('CSS masker rejects commented hidden rule decoy', hiddenRule.test(commentedRules), false);
696 check('CSS masker rejects commented visible rule decoy', visibleRule.test(commentedRules), false);
697 check('CSS masker rejects commented error rule decoy', errorRule.test(commentedRules), false);
698 const executableHtmlCss = executableCss(html);
699 check('page styles copy control hidden by default', hiddenRule.test(executableHtmlCss), true);
700 check('page styles copy control visible on request', visibleRule.test(executableHtmlCss), true);
701 check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true);
608 } 702 }
609 703
610 // --- wire builders (layouts golden-pinned in protocol.zig) --- 704 // --- wire builders (layouts golden-pinned in protocol.zig) ---