From 4957f13337733eb4657fb5a27f2a5fa7c8cfc3af Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 8 Jul 2026 14:12:16 +0200 Subject: [PATCH] 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. --- packages/tui/src/app.tsx | 11 +++++++++ packages/tui/src/clipboard.ts | 22 +++++++++++++---- packages/tui/test/app-lifecycle.test.tsx | 8 +++++- packages/tui/test/clipboard.test.ts | 31 +++++++++++++++++++++++- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index e469f846000..43136e017ec 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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, diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index bbfe496868a..df56a7d00dc 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -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>> { + 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" || diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 5279815665e..98920dbdee6 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -30,6 +30,7 @@ async function mockOpenTuiClipboard( } = {}, ) { const calls = { + renderer: [] as Parameters[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[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) diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index c773647672e..4c1fc9cb7e6 100644 --- a/packages/tui/test/clipboard.test.ts +++ b/packages/tui/test/clipboard.test.ts @@ -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 } }))