Skip to content

clipboard.write() never settles on X11 when xclip is the backend #43697

Description

@dottedt

Description

Description

On X11 with xclip installed, copying in the TUI puts the text on the clipboard but clipboard.write() never settles. The copy itself works. The promise just sits there. I watched one stay pending for over eight minutes after a single copy, and it only cleared when another program took the X clipboard selection.

The cause is that xclip has to stay alive to own the X selection, which is how X selections work. It forks a background process and the original exits. That background process inherits the stdout pipe opencode gave it and keeps it open. command() in packages/tui/src/clipboard.ts resolves on the child's close event, and close waits for both the process to exit and its stdio to close. The stdio never closes, so it never fires.

Here is the proof from my machine, well after the copy finished. xclip's stdout is still paired to the opencode process and still established.

u_str ESTAB * 1733272 * 1733273 users:(("opencode",pid=269746,fd=64))
u_str ESTAB * 1733273 * 1733272 users:(("xclip",pid=272586,fd=1))

This is the default path on X11. copyCommand checks xclip before xsel, so xclip wins whenever it's installed, and the troubleshooting page tells people to install it. xsel does not behave this way. clipboardy has a comment in its linux backend saying it uses xsel instead of xclip for exactly this reason.

Two places it shows up in the UI. dialog-message.tsx awaits clipboard.write?.(text) and then calls dialog.clear(), so the dialog can sit open. error-component.tsx runs .then(() => setCopied(true)), so you get no confirmation even though the copy worked.

Worth flagging for #41924. That fix doesn't reach this. A pending promise never rejects, so there's no error to surface.

The fix is to stop piping stdout on the write path. command() always passes stdio: [..., "pipe", "ignore"], and stdout is only needed for reads. Using "ignore" for writes made it resolve in 3ms for me. Adding -i to the xclip arguments does not help, which is worth saying because that was part of #35289.

Plugins

None

OpenCode version

1.18.19

Steps to reproduce

  1. On X11, install xclip. It doesn't matter whether xsel is installed, since copyCommand checks xclip first.
  2. Start opencode and copy a message.
  3. Paste somewhere. The text is there, so the copy worked.
  4. Run pgrep -ax xclip. The xclip process is still running.
  5. Run ss -xp | grep xclip. Its fd 1 is still paired to the opencode process.
  6. Copy something in any other program. xclip exits and the promise settles.

If driving the TUI is awkward, this reproduces it with command() copied straight out of clipboard.ts.

import { spawn } from "node:child_process"

function command(cmd, args = [], input, stdoutMode = "pipe") {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: [input === undefined ? "ignore" : "pipe", stdoutMode, "ignore"] })
const out = []
child.on("error", reject)
child.stdout?.on("data", (c) => out.push(c))
child.on("close", (code) => (code === 0 ? resolve(Buffer.concat(out)) : reject(new Error("code " + code))))
if (input !== undefined) child.stdin?.end(input)
})
}

const run = (mode) =>
Promise.race([
command("xclip", ["-selection", "clipboard"], "payload", mode).then(() => "resolved"),
new Promise((r) => setTimeout(() => r("HUNG"), 5000)),
])

console.log("stdout piped :", await run("pipe")) // HUNG
console.log("stdout ignored:", await run("ignore")) // resolved
process.exit(0)

Screenshot and/or share link

No response

Operating System

Kubuntu 24.04, X11, xclip 0.13-3

Terminal

Konsole 23.08.5

Plugins

No response

OpenCode version

No response

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions