Describe the bug
With at least one viewer running, the Windows clipboard becomes unreliable in an intermittent way:
- Copying an image from a third-party tool (e.g. Flameshot) with Ctrl+C sometimes fails to place anything on the clipboard: the item also never appears in the Windows clipboard history (Win+V).
- Copying text in another Windows app (e.g. a browser) and then pasting it into the viewer sometimes does nothing.
Both happen only when one or more viewers are running, and the probability of failure increases with the number of viewers. With no viewer running, the Windows clipboard works reliably.
Client (please complete the following information):
- OS: Windows 11
- VNC TigerVNC 1.16.2
Server (please complete the following information):
- OS: Oracle linux 9
- VNC server: TigerVNC XVnc
- VNC server version: 1.15.0
- Server downloaded from: OL9 repo
- Server was started using: XVnc
Additional context
Warning : I asked Claude to help me with this analysis
The Windows clipboard is a single-owner resource: only one process may hold it open via OpenClipboard() at a time; concurrent OpenClipboard() calls fail with ERROR_ACCESS_DENIED.
In the constructor, each Viewport registers a clipboard listener:
https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L112
Fl::add_clipboard_notify(handleClipboardChange, this);
On Windows, FLTK implements this with AddClipboardFormatListener, so every viewer receives WM_CLIPBOARDUPDATE whenever any application changes the clipboard — regardless of focus. The notify callback then inspects the clipboard:
https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L598
void Viewport::handleClipboardChange(int source, void *data)
{
...
if (!Fl::clipboard_contains(Fl::clipboard_plain_text)) {
vlog.debug("Got non-plain text in local clipboard, ignoring.");
// Reset the state as if we don't have any clipboard data at all
self->pendingClientClipboard = false;
...
if (!self->hasFocus()) {
vlog.debug("Local clipboard changed whilst not focused, will notify server later");
self->pendingClientClipboard = true;
// Clear any older client clipboard from the server
try {
self->cc->announceClipboard(false);
} catch (std::exception& e) {
vlog.error("%s", e.what());
abort_connection_with_unexpected_error(e);
}
return;
}
FLTK opens the clipboard:
https://github.com/fltk/fltk/blob/release-1.4.5/src/Fl.cxx#L2201-L2204
int Fl::clipboard_contains(const char *type)
{
return Fl::screen_driver()->clipboard_contains(type);
}
https://github.com/fltk/fltk/blob/release-1.4.5/src/Fl_win32.cxx#L933-L944
int Fl_WinAPI_Screen_Driver::clipboard_contains(const char *type) {
int retval = 0;
if (!OpenClipboard(NULL))
return 0;
if (strcmp(type, Fl::clipboard_plain_text) == 0 || type[0] == 0) {
retval = IsClipboardFormatAvailable(CF_UNICODETEXT);
} else if (strcmp(type, Fl::clipboard_image) == 0) {
retval = IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_ENHMETAFILE);
}
CloseClipboard();
return retval;
}
Fl::clipboard_contains() calls OpenClipboard() under the hood. Crucially, this happens before the focus check:
https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L613
So on every clipboard change, every running viewer (focused or not, one per process) immediately tries to open the Windows clipboard. This races against:
- the application that just wrote the clipboard (which may still hold it, or use delayed rendering /
WM_RENDERFORMAT), and
- the Windows Clipboard History service (itself a listener that re-opens the clipboard to snapshot it into Win+V).
When the viewers win the race, the writer or the history service gets ERROR_ACCESS_DENIED, so the data is lost or never enters Win+V (image case). When a viewer loses the race, Fl::clipboard_contains() returns false, the callback takes the "non-plain text → ignoring" branch and announces no clipboard to the server, so a later paste into the viewer does nothing (text case). The intermittency is the timing race; the scaling with viewer count is because each viewer is an independent contender.
Proposition
Notably, announceClipboard(true) is already deferred to focus regain via pendingClientClipboard / flushPendingClipboard():
https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L636
https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L519
The only thing that currently happens at change-time while unfocused is the OpenClipboard() from Fl::clipboard_contains(). So the fix is to defer that inspection to focus regain too: when the viewer does not have focus, just set the pending flag and return without opening the clipboard; perform Fl::clipboard_contains() (and the corresponding announceClipboard()) inside flushPendingClipboard() when focus is regained — at which point the writing application has long released the clipboard.
Sketch (Windows-scoped, to avoid changing X11 primary-selection behavior):
handleClipboardChange():
#if defined(WIN32)
if (!self->hasFocus()) {
// Do NOT open the clipboard here — it races with whatever app
// just wrote it (and with the clipboard history service).
// Defer inspection until we regain focus.
self->pendingClientClipboard = true;
return;
}
#endif
if (!Fl::clipboard_contains(Fl::clipboard_plain_text)) {
... announce(false); return;
}
self->clipboardSource = source;
... // focused: inspect + announce as today
flushPendingClipboard():
void Viewport::flushPendingClipboard()
{
if (pendingClientClipboard) {
if (Fl::clipboard_contains(Fl::clipboard_plain_text)) {
try { cc->announceClipboard(true); } catch (...) { ... }
} else {
try { cc->announceClipboard(false); } catch (...) { ... }
}
}
pendingClientClipboard = false;
}
This is behavior-preserving from the server's perspective (it only ever learns about the client clipboard at focus time anyway) and removes the unfocused OpenClipboard() calls entirely. In the failing workflows above (screenshot tool, browser copy) no viewer is focused, so the viewer-induced contention drops to zero.
Describe the bug
With at least one viewer running, the Windows clipboard becomes unreliable in an intermittent way:
Both happen only when one or more viewers are running, and the probability of failure increases with the number of viewers. With no viewer running, the Windows clipboard works reliably.
Client (please complete the following information):
Server (please complete the following information):
Additional context
Warning : I asked Claude to help me with this analysis
The Windows clipboard is a single-owner resource: only one process may hold it open via
OpenClipboard()at a time; concurrentOpenClipboard()calls fail withERROR_ACCESS_DENIED.In the constructor, each
Viewportregisters a clipboard listener:https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L112
On Windows, FLTK implements this with
AddClipboardFormatListener, so every viewer receivesWM_CLIPBOARDUPDATEwhenever any application changes the clipboard — regardless of focus. The notify callback then inspects the clipboard:https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L598
FLTK opens the clipboard:
https://github.com/fltk/fltk/blob/release-1.4.5/src/Fl.cxx#L2201-L2204
https://github.com/fltk/fltk/blob/release-1.4.5/src/Fl_win32.cxx#L933-L944
Fl::clipboard_contains()callsOpenClipboard()under the hood. Crucially, this happens before the focus check:https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L613
So on every clipboard change, every running viewer (focused or not, one per process) immediately tries to open the Windows clipboard. This races against:
WM_RENDERFORMAT), andWhen the viewers win the race, the writer or the history service gets
ERROR_ACCESS_DENIED, so the data is lost or never enters Win+V (image case). When a viewer loses the race,Fl::clipboard_contains()returnsfalse, the callback takes the "non-plain text → ignoring" branch and announces no clipboard to the server, so a later paste into the viewer does nothing (text case). The intermittency is the timing race; the scaling with viewer count is because each viewer is an independent contender.Proposition
Notably,
announceClipboard(true)is already deferred to focus regain viapendingClientClipboard/flushPendingClipboard():https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L636
https://github.com/TigerVNC/tigervnc/blob/v1.16.2/vncviewer/Viewport.cxx#L519
The only thing that currently happens at change-time while unfocused is the
OpenClipboard()fromFl::clipboard_contains(). So the fix is to defer that inspection to focus regain too: when the viewer does not have focus, just set the pending flag and return without opening the clipboard; performFl::clipboard_contains()(and the correspondingannounceClipboard()) insideflushPendingClipboard()when focus is regained — at which point the writing application has long released the clipboard.Sketch (Windows-scoped, to avoid changing X11 primary-selection behavior):
handleClipboardChange():flushPendingClipboard():This is behavior-preserving from the server's perspective (it only ever learns about the client clipboard at focus time anyway) and removes the unfocused
OpenClipboard()calls entirely. In the failing workflows above (screenshot tool, browser copy) no viewer is focused, so the viewer-induced contention drops to zero.