[BUG] Copying is broken on Wayland: half of all copies never reach the clipboard — two racing wl-copy calls

Status Fixed / completed
Reported on v2.1.221
Maintainer reply None cached
Activity 1 comment · opened Aug 4, 2026 · closed Aug 20, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

On Wayland, copying from the fullscreen TUI writes CLIPBOARD and PRIMARY by spawning two wl-copy processes concurrently. Only one of them acquires its selection; the other exits silently without taking ownership. Which one wins is a race, so roughly half of all copies never reach CLIPBOARD — the text is only in PRIMARY (middle-click paste), while the toast still reports Copied N characters to clipboard.

In practice: select text in the TUI, and about half the time Ctrl+V afterwards pastes the previous clipboard contents while middle-click pastes what you just selected. Because it is a race it looks intermittent and unrelated to what was selected, and the toast claims success either way, so there is no signal that anything failed.

What Should Happen?

Every copy should set both CLIPBOARD and PRIMARY, as documented in Fullscreen rendering: "Claude Code writes both the clipboard and the PRIMARY selection, so middle-click paste works."

Failing that, the toast should not report success when the clipboard write did not take effect.

Error Messages/Logs

No errors are produced — both child processes are spawned and the failure is silent. Intercepting the calls with a shim placed ahead of /usr/bin on PATH shows both invocations, 1–2 ms apart:

12:36:18.732 wl-copy argv=[]          bytes=19 head=CONTROL-ALPHA-11111
12:36:18.733 wl-copy argv=[--primary] bytes=19 head=CONTROL-ALPHA-11111

At that moment CLIPBOARD still held the value from a copy 18 seconds earlier, and that older wl-copy process was still alive and owning the selection — so the new one exited without ever acquiring it.

Steps to Reproduce

In the TUI:

  1. Run claude with the fullscreen renderer, mouse capture and copy-on-select on
  2. Put a known value in the clipboard: printf SENTINEL | wl-copy
  3. Click and drag to select any short run of text in the conversation
  4. Run wl-paste -n and wl-paste -p -n
  5. About half the time wl-paste -n still prints SENTINEL while wl-paste -p -n prints the selected text

Four consecutive copies of the same short selection failed all four times here, alternating which of the two selections was missed.

Minimal reproducer, no Claude Code involved — requires a Wayland session and wl-clipboard:

// repro.mjs — node repro.mjs
import { spawn, execFileSync } from "node:child_process";

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const read = (primary) =>
  execFileSync("/usr/bin/wl-paste", primary ? ["-p", "-n"] : ["-n"], { encoding: "utf8" });

function write(text, args) {
  const child = spawn("/usr/bin/wl-copy", args, { stdio: ["pipe", "ignore", "ignore"] });
  child.on("error", () => {});
  child.stdin.on("error", () => {});
  child.stdin.end(text);
  return new Promise((res) => child.on("exit", res));
}

for (const mode of ["concurrent", "awaited"]) {
  let ok = 0;
  for (let i = 1; i <= 5; i++) {
    const c = `C-${mode}-${i}`, p = `P-${mode}-${i}`;
    if (mode === "concurrent") { write(c, []); write(p, ["--primary"]); }
    else { await write(c, []); await write(p, ["--primary"]); }
    await sleep(500);
    if (read(false) === c && read(true) === p) ok++;
  }
  console.log(`${mode}: ${ok}/5 both selections set`);
}

| Mode | Both selections set |
|---|---|
| concurrent (current behavior) | 0/5, then 2/5 on a rerun — flaky |
| awaited | 5/5 |

Failures alternate cleanly between the two selections. The same pair of invocations run sequentially from bash passes 10/10 — the shell waits for each to fork before starting the next — so this is not a wl-copy or Mutter bug.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

Not established — I did not test earlier versions.

Claude Code Version

2.1.221 (Claude Code)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Other — GNOME Terminal (VTE 0.76), bash. Ubuntu 24.04.4 LTS, GNOME Shell 46.0 (Mutter), Wayland, wl-clipboard 2.2.1. Local session, no SSH/tmux/screen.

Additional Information

Root cause. From the shipped binary (~/.local/share/claude/versions/2.1.221), reformatted. Minified identifiers are meaningless outside Anthropic, but the shape of the statement is unambiguous:

function pqu(e) {                              // not async
  let t = { input: e, useCwd: false, timeout: 2000 };
  switch ($t()) {
    case "linux":
      ...
      else if (IRe === "wl-copy")
        dn("wl-copy", [], t), dn("wl-copy", ["--primary"], t);
      else if (IRe === "xclip")
        dn("xclip", ["-selection","clipboard"], t), dn("xclip", ["-selection","primary"], t);
      else if (IRe === "xsel")
        dn("xsel", ["--clipboard","--input"], t), dn("xsel", ["--primary","--input"], t);
      return;

dn is async — elsewhere in the same scope it is used as let {code:n} = await dn("tmux", ["load-buffer","-w","-"], t). Here both calls are started in one comma-operator statement with no await, from a non-async function that cannot await, so both children start in the same tick.

wl-copy cannot set a selection without a valid Wayland input serial, so each instance creates a surface and waits for focus to obtain one. Two instances launched concurrently take focus from each other and only one ends up with a usable serial. The loser exits without ever owning its selection, leaving that selection at its previous value.

Suggested fix. Make the enclosing function async and await the first write before starting the second:

await dn("wl-copy", [], t);
await dn("wl-copy", ["--primary"], t);

A fixed delay also works but is a weaker guarantee. The xclip and xsel branches have the same unserialized shape; whether they misbehave is untested, and X11 selection ownership does not require an input serial, so they may be fine.

Terminals that honor OSC 52 may not see this. On a local session the caller runs the native tool and still emits an OSC 52 sequence targeting CLIPBOARD:

if (!Son()) pqu(e);                  // Son() = isSSH → native tool locally
return ZT(jv.CLIPBOARD, "c", t);     // …and OSC 52 as well

On a terminal that honors OSC 52, that second write would set CLIPBOARD independently of the race and hide this bug. VTE does not implement OSC 52 clipboard writes — verified here, the sequence is silently ignored — so VTE terminals have no such safety net. I have not tested kitty, WezTerm, foot or Ghostty, so the masking is expected rather than measured.

Workaround. CLAUDE_CODE_DISABLE_MOUSE=1 returns selection handling to the terminal and avoids this code path entirely.

Possibly related. #69704, #62462, #57654, #66957, #74214 — several describe this symptom without identifying a mechanism.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗