Skip to content

Cap pasteboard-hold to 200ms when paste verification has no AX target#292

Open
NathanDrake2406 wants to merge 1 commit intoaltic-dev:mainfrom
NathanDrake2406:fix/paste-nil-snapshot-timeout
Open

Cap pasteboard-hold to 200ms when paste verification has no AX target#292
NathanDrake2406 wants to merge 1 commit intoaltic-dev:mainfrom
NathanDrake2406:fix/paste-nil-snapshot-timeout

Conversation

@NathanDrake2406
Copy link
Copy Markdown

What this changes

Caps the pasteboard-hold after Cmd+V to 200ms when waitForFocusedTextVerification has no AX snapshot to verify against. The three clipboard entry points still pass 5_000_000 as the verified-path ceiling; the new cap only applies to the nil-snapshot branch.

Why

Every clipboard paste into an app that exposes no accessible text element (Electron apps, web-based editors, many GPU terminals, games) hits the nil-snapshot branch in TypingService.waitForFocusedTextVerification at Sources/Fluid/Services/TypingService.swift:942. That branch runs an unconditional usleep(timeoutMicros), and each of the three call sites (insertTextViaClipboardToPid, insertTextViaClipboard, insertTextViaMenuPaste) hardcodes 5_000_000. The session semaphore is transferred to pasteboardRestoreQueue and held for the full 5 seconds. If the user triggers a second dictation during that window, withTemporaryPasteboardString blocks on pasteboardSessionSemaphore.wait() for up to 5 seconds with no visible cause.

Approach

The degenerate branch was conflating two different budgets under one timeoutMicros parameter:

  • The verified path needs seconds of headroom because it polls AX at 50ms intervals waiting for one of four signals to confirm the paste landed.
  • The nil-snapshot path has no signal to wait for. The only reason to wait at all is to give the target app enough time to consume Cmd+V before pasteboard restore. That budget is in the low hundreds of milliseconds.

Introduce pasteboardReadWindowMicros = 200_000 as the ceiling for the non-verified wait, and use min(timeoutMicros, pasteboardReadWindowMicros) so any future call site that passes a smaller value still wins.

Non-goals: the verified path, the polling cadence, and the AppleScript snapshot cost for Xcode/Notes are untouched. Those are separate issues (see follow-ups).

Validation

Manual: paste into an Electron target (VS Code, Discord, Slack, Spotify), then immediately retrigger dictation. Before: second trigger blocks visibly for several seconds. After: second trigger proceeds without delay.

No unit test added. Any such test would either hard-code the new 200_000 literal (structure mirror) or mock captureFocusedTextSnapshot to force a nil return and assert on usleep duration (mocks an internal collaborator). The real signal lives at the AX + NSPasteboard + target-app boundary, which the test environment cannot simulate.

Risks / follow-ups

  • If any target consistently takes longer than 200ms to read the pasteboard after Cmd+V dispatch, restore could race the read and deliver stale or empty clipboard data. 200ms is comfortably above measured Electron/Chromium IPC latency after synthetic Cmd+V on macOS, but worth validating under heavy load.
  • Two adjacent issues are deliberately out of scope:
    1. The Xcode polling cycle runs captureXcodeScriptSnapshot on every 50ms iteration, which issues two blocking AppleScript round-trips. Effective poll interval for Xcode is 200 to 600ms, not 50ms. A cheap-AX-first / expensive-AppleScript-last split would fix this.
    2. The session semaphore currently guards both the pasteboard-read window and the verification polling. Splitting those would further reduce serialization between dictations.

When withTemporaryPasteboardString dispatches a paste into an app that exposes no accessible text element, waitForFocusedTextVerification falls through to an unconditional usleep(timeoutMicros). All three clipboard entry points pass 5_000_000, so the session semaphore is held for 5 seconds on Electron apps, web-based editors, many terminals, and games. A second dictation triggered during that window blocks for up to 5s waiting on pasteboardSessionSemaphore.

timeoutMicros is sized for AX verification polling, which cannot run when no snapshot exists. The degenerate branch reused the verification budget as a pasteboard-hold budget, which is the wrong invariant: verification needs seconds of headroom for polling, while the pasteboard hold only needs enough time for the target to consume Cmd+V before restore. Introduce pasteboardReadWindowMicros (200_000) and cap the nil-snapshot wait to min(timeoutMicros, pasteboardReadWindowMicros). The verified path and all four PasteVerificationResult values are unchanged.

A unit test would either mirror the patch constant or mock captureFocusedTextSnapshot, both of which restate the implementation. Verified manually by pasting into an Electron target and immediately retriggering dictation; the second trigger no longer blocks.
Copilot AI review requested due to automatic review settings April 21, 2026 07:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces how long TypingService holds the pasteboard session after dispatching Cmd+V when there’s no focused AX text snapshot to verify the paste against, preventing long (multi-second) blocking of subsequent dictation-triggered paste operations in non-AX-friendly targets (e.g., many Electron apps).

Changes:

  • Introduces a pasteboardReadWindowMicros ceiling (200ms) for the nil-snapshot (“unverifiable”) paste verification path.
  • Uses min(timeoutMicros, pasteboardReadWindowMicros) so call sites with smaller timeouts still apply.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants