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 } }))