mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 04:38:31 +00:00
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:
parent
080fb9f2e7
commit
4957f13337
4 changed files with 65 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 } }))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue