tui: fix Wayland clipboard paste flakiness

OpenTUI can settle empty before Wayland MIME callbacks drain, and
clipboard provider pipes can raise SIGPIPE. Retry transient empty
reads and omit SIGPIPE from interactive exit signals so paste
survives those races without killing the session.
This commit is contained in:
Simon Klee 2026-07-08 14:12:16 +02:00
parent 080fb9f2e7
commit 4957f13337
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
4 changed files with 65 additions and 7 deletions

View file

@ -197,6 +197,17 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
exitSignals: [
"SIGINT",
"SIGTERM",
"SIGQUIT",
"SIGABRT",
"SIGHUP",
// Interactive stdout cannot break as a pipe, but Wayland clipboard provider pipes can.
...(process.stdout.isTTY ? [] : (["SIGPIPE"] as const)),
"SIGBREAK",
"SIGBUS",
],
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,

View file

@ -11,6 +11,8 @@ import type { ClipboardService, ClipboardWriteOutcome } from "./context/clipboar
const timeoutMs = 1_000
const maxReadBytes = 8 * 1024 * 1024
// The pinned OpenTUI can settle a read before its queued Wayland MIME callbacks are drained.
const waylandReadAttempts = 64
export type ClipboardNotification = Readonly<{
message: string
@ -55,16 +57,14 @@ export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedCl
}),
terminal: createRendererClipboardAdapter(renderer),
}),
process.platform === "linux" && Boolean(process.env.WAYLAND_DISPLAY) ? waylandReadAttempts : 1,
)
}
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
export function createClipboardAdapter(clipboard: CoreClipboardService, readAttempts = 1): OwnedClipboardService {
return {
async read() {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
const result = await readClipboard(clipboard, readAttempts)
if (result.status !== "read") {
if (result.status === "empty" || result.status === "unsupported" || result.status === "cancelled") return
if (result.status === "failed") throw result.error
@ -104,6 +104,18 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl
}
}
async function readClipboard(
clipboard: CoreClipboardService,
attempts: number,
): Promise<Awaited<ReturnType<CoreClipboardService["read"]>>> {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
if (result.status !== "empty" || attempts <= 1) return result
return readClipboard(clipboard, attempts - 1)
}
export function classifyClipboardWriteResult(result: ClipboardWriteResult): ClipboardWriteOutcome {
const partial =
result.host.status === "failed" ||

View file

@ -30,6 +30,7 @@ async function mockOpenTuiClipboard(
} = {},
) {
const calls = {
renderer: [] as Parameters<typeof openTui.createCliRenderer>[0][],
host: [] as (HostClipboardOptions | undefined)[],
adapter: [] as RendererClipboardBoundary[],
service: [] as ClipboardOptions[],
@ -57,7 +58,10 @@ async function mockOpenTuiClipboard(
mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => renderer,
createCliRenderer: async (input: Parameters<typeof openTui.createCliRenderer>[0]) => {
calls.renderer.push(input)
return renderer
},
createHostClipboard: (input?: HostClipboardOptions) => {
if (options.constructionError) throw options.constructionError
calls.host.push(input)
@ -161,6 +165,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
])
expect(clipboard.adapter).toEqual([setup.renderer])
expect(clipboard.service).toHaveLength(1)
expect(clipboard.renderer[0]?.exitSignals).toContain("SIGHUP")
expect(clipboard.renderer[0]?.exitSignals?.includes("SIGPIPE")).toBe(process.stdout.isTTY !== true)
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
expect(process.listeners("SIGHUP").every((listener) => listeners.includes(listener))).toBe(true)

View file

@ -17,6 +17,7 @@ import {
type OpenTuiFixture = {
read?: ClipboardReadResult
reads?: ClipboardReadResult[]
remote?: boolean
onCoreRead?: (options: ClipboardReadOptions) => void
onCoreWrite?: (text: string, options: ClipboardWriteOptions) => void
@ -28,7 +29,7 @@ function openTuiClipboard(options: OpenTuiFixture = {}) {
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return options.read ?? { status: "empty" }
return options.reads?.shift() ?? options.read ?? { status: "empty" }
},
async writeText() {
options.onHostWrite?.()
@ -125,6 +126,34 @@ test("maps empty host results and zero-byte text to no content", async () => {
).toBeUndefined()
})
test("retries transient empty Wayland reads within the configured bound", async () => {
const requests: ClipboardReadOptions[] = []
const bytes = new TextEncoder().encode("eventually available")
const clipboard = createClipboardAdapter(
openTuiClipboard({
reads: [
{ status: "empty" },
{ status: "empty" },
{ status: "read", representation: { mimeType: "text/plain", bytes } },
],
onCoreRead: (input) => requests.push(input),
}),
3,
)
expect(await clipboard.read()).toEqual({ data: "eventually available", mime: "text/plain" })
expect(requests).toHaveLength(3)
const exhausted: ClipboardReadOptions[] = []
expect(
await createClipboardAdapter(
openTuiClipboard({ onCoreRead: (input) => exhausted.push(input) }),
3,
).read(),
).toBeUndefined()
expect(exhausted).toHaveLength(3)
})
test("preserves backend read failures and synthesizes operational errors", async () => {
const failure = new Error("read failed")
const failed = createClipboardAdapter(openTuiClipboard({ read: { status: "failed", error: failure } }))