Repository navigation
Description
Describe the bug
On Chrome and Edge, the web client can throw an unhandled promise rejection out of the seamless clipboard path and show the fatal error overlay over a working session:
KasmVNC encountered an error:
Failed to execute 'getType' on 'ClipboardItem': Clipboard data has changed
with InvalidStateError and a stack ending in clipboardPasteDataFrom.
The rejection comes from ClipboardItem.getType(). Chrome's "Selective Clipboard Format Read" makes navigator.clipboard.read() return lazy ClipboardItems, and getType() now rejects with an InvalidStateError if the system clipboard changed between the read() and the getType() call. The client awaits getType() without a try/catch, and the promise that the await belongs to is never given a rejection handler, so the rejection reaches window.onunhandledrejection and app/error-handler.js turns it into the fatal overlay.
It is not an error condition for the client: the clipboard simply changed under it, and the correct response is to abandon the read, not to tear the UI down.
Environment
- KasmVNC 1.4.0 and 1.5.0 (client code is identical between the two, see below)
- Client: Google Chrome 153.0.8010.52 on macOS 26.6.2. Also reported on Chrome and Edge on Windows in other projects using the same API.
- Not reproducible in Firefox or Safari —
supportsBinaryClipboard()incore/util/browser.jsreturnsfalsefor both, so they never take the binary clipboard path. - Server side is irrelevant: the whole code path is in the web client (
kasmwebsubmodule, kasmtech/noVNC), so it reproduces against any KasmVNC server.
Seamless clipboard is on by default for this path. In app/ui.js, outside the Kasm Workspaces embed, clipboard_seamless is initialised to true on anything that is not Firefox or Safari, and UI.rfb.clipboardBinary = supportsBinaryClipboard() && UI.rfb.clipboardSeamless.
To Reproduce
Natural trigger (intermittent, needs the clipboard to change during the read):
- Open a KasmVNC session in Chrome with seamless clipboard enabled (the default) and grant clipboard permission.
- Copy something in a local application, ideally rich content that offers several MIME types (for example a formatted selection from a word processor, which offers
text/plain,text/htmlandimage/png). - Click into the session canvas. This calls
checkLocalClipboard(), which reads the clipboard and awaitsgetType()plusarrayBuffer()for each offered type. - Have anything write the clipboard while that loop is still running — a clipboard manager, another copy, or the client's own server-to-client clipboard write.
- The overlay appears.
Deterministic reproduction with DevTools:
- Open a session in Chrome, open DevTools, and set a breakpoint on the
await clipdata[i].getType(mime)line inclipboardPasteDataFrom(core/rfb.js). - Copy some rich text locally.
- Click the session canvas. Execution pauses on the breakpoint.
- While paused, copy something else in another application.
- Resume.
getType()rejects withInvalidStateErrorand the overlay appears.
Expected behaviour
A clipboard read that is invalidated mid-flight should be logged and dropped. The next user-driven event re-arms the read anyway (_resendClipboardNextUserDrivenEvent), so skipping one pass costs nothing.
Actual behaviour
The rejection escapes as an unhandledrejection and app/error-handler.js renders the fatal error overlay on top of the session. The same handler also blurs the active element and sets tabindex="-1" on every focusable element in the page, so the control bar becomes keyboard-inaccessible. The handler only ever renders the first error, so no later error is surfaced in the overlay either.
Root cause
All line numbers are for core/rfb.js at 475ecfa5356579ef222983c7ce4619a7576a3bce, the kasmweb submodule commit pinned by KasmVNC v1.5.0. The same code is present on kasmtech/noVNC master today (at checkLocalClipboard() line 1105 and clipboardPasteDataFrom line 1153) and at ae1e012fe34e5dd29478575d2a1253dfa916ed24, the commit pinned by v1.4.0 — I diffed checkLocalClipboard, clipboardPasteDataFrom and _write_binary_clipboard across the three and the only differences are trailing whitespace.
-
_focusCanvas(line 1604), bound to the canvasmousedownandtouchstart, callscheckLocalClipboard()(line 1622) whenever_resendClipboardNextUserDrivenEventis set. That flag is set at construction and again in_handleFocusChange(line 1571), so it is armed on every focus change. -
checkLocalClipboard()(line 1085):
navigator.clipboard.read().then((data) => {
this.clipboardPasteDataFrom(data);
}, (err) => {
Log.Debug("No data in clipboard: " + err);
});clipboardPasteDataFrom is async, but its promise is discarded — the arrow body does not return it. The onRejected argument only covers read(), so nothing handles a rejection from inside clipboardPasteDataFrom.
clipboardPasteDataFrom(line 1133) loops over items and MIME types and does, with notry/catch(line 1147):
let blob = await clipdata[i].getType(mime);
if (!blob) {
continue;
}
let buff = await blob.arrayBuffer();Each getType() is a separate round trip to the system clipboard, so each one is an opportunity for the sequence number check to fail. The window is widest right after a fresh copy: when the content hash differs from _clipHash, the loop does not bail early and awaits getType() plus arrayBuffer() for every accepted type (image/png, text/plain, text/html).
- On the Chrome side,
ClipboardItem::getTypeinthird_party/blink/renderer/modules/clipboard/clipboard_item.cccallsHasClipboardChangedSinceClipboardRead(), which compares the captured sequence number againstSystemClipboard::SequenceNumber(), and throwsDOMExceptionCode::kInvalidStateErrorwith the message"Clipboard data has changed".ResolveFormatDatarejects the same way for an already-issued promise.
Worth noting: the client can trigger this against itself. _write_binary_clipboard (line 3651) calls navigator.clipboard.write() when the server sends clipboard data down, which necessarily bumps the system clipboard sequence number. If that lands while a clipboardPasteDataFrom pass is in flight, the pass is invalidated.
This is the same class of bug other projects using the async clipboard API have hit since the Chrome change — see the tldraw issue in the references, which describes an identical "text/html reads are wrapped, the blob read is not" shape.
Suggested fix
Handle the rejection in both places. Sketch against core/rfb.js:
checkLocalClipboard() {
if (this.clipboardUp && this.clipboardSeamless && this._resendClipboardNextUserDrivenEvent) {
this._resendClipboardNextUserDrivenEvent = false;
if (this.clipboardBinary) {
navigator.clipboard.read().then((data) => {
- this.clipboardPasteDataFrom(data);
+ this.clipboardPasteDataFrom(data).catch((err) => {
+ Log.Debug("Could not process clipboard contents: " + err);
+ });
}, (err) => {
Log.Debug("No data in clipboard: " + err);
}); case 'image/png':
case 'text/plain':
case 'text/html':
- let blob = await clipdata[i].getType(mime);
- if (!blob) {
- continue;
- }
- let buff = await blob.arrayBuffer();
- let data = new Uint8Array(buff);
+ let data;
+ try {
+ let blob = await clipdata[i].getType(mime);
+ if (!blob) {
+ continue;
+ }
+ data = new Uint8Array(await blob.arrayBuffer());
+ } catch (err) {
+ // The system clipboard changed between read() and
+ // getType(), so this pass is stale. Abandon it; the
+ // next user driven event reads the clipboard again.
+ Log.Debug("Could not read clipboard format " + mime + ": " + err);
+ this._clipHash = 0;
+ return;
+ }Abandoning the whole pass rather than continueing seems right: once the clipboard has moved on, every remaining getType() in the pass fails for the same reason, and a partially read set would not be coherent. The this._clipHash = 0 resets the dedupe hash, which may already have been set from an earlier format in the same pass, so the next pass is not skipped as a duplicate.
The non-binary branch is already safe — clipboardPasteFrom is synchronous and readText() has a .catch().
If it would help, I am happy to open a PR against kasmtech/noVNC with this change. Just say the word and I will put it up rather than leaving it as a sketch here.
References
- Chrome, Selective Format Read: https://developer.chrome.com/blog/selective-format-read — "If the contents of the clipboard change between the call to
read()and a later call togetType(), that call will be rejected with anInvalidStateError." - Chrome Platform Status, Selective Clipboard Format Read: https://chromestatus.com/feature/5203433409871872 (Chrome 149)
- Chromium source:
third_party/blink/renderer/modules/clipboard/clipboard_item.cc,ClipboardItem::getTypeandClipboardItem::HasClipboardChangedSinceClipboardRead - Same failure mode in another project: Paste throws an unhandled rejection when the clipboard changes during read tldraw/tldraw#10126
- Affected code: https://github.com/kasmtech/noVNC/blob/475ecfa5356579ef222983c7ce4619a7576a3bce/core/rfb.js#L1085-L1150
Activity
gnawhleinad commentedon Oct 14, 2026
kasmtech/noVNC#219 is a proposed fix for this issue.