Skip to content

fix(a11y/macos): avoid main-queue deadlock in headless clipboard reads#3877

Open
guillaumedeshayes wants to merge 1 commit into
screenpipe:mainfrom
guillaumedeshayes:fix/headless-clipboard-deadlock
Open

fix(a11y/macos): avoid main-queue deadlock in headless clipboard reads#3877
guillaumedeshayes wants to merge 1 commit into
screenpipe:mainfrom
guillaumedeshayes:fix/headless-clipboard-deadlock

Conversation

@guillaumedeshayes

@guillaumedeshayes guillaumedeshayes commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

description

In headless CLI mode (screenpipe record), the first Cmd+C permanently breaks clipboard capture.

get_clipboard() hops onto the libdispatch main queue via dispatch_sync (introduced in c3598dc to fix the off-main NSPasteboard SIGSEGV). That's correct in the desktop app, where the main thread runs the runloop — but the CLI's main thread is #[tokio::main]'s block_on, so the main queue is never drained and the sync hop blocks forever. The clipboard worker hangs, no clipboard row is written, and clipboard-read-inflight is left behind — so on the next startup the dead-man switch promotes the stale marker to clipboard-disabled-after-crash and clipboard capture is silently disabled until the user manually deletes the file.

Fix: probe the main queue once per process (async no-op, 500ms timeout). Alive (desktop app) → keep the main-thread sync_once read, unchanged. Dead (headless CLI) → read the pasteboard on the worker thread. This re-exposes the off-main race from 1Password/arboard#218, but it's the only option without a main runloop, the window is microseconds per read, and the existing crash-marker dead-man switch backstops a crash loop. If the queue is dead, the leaked probe block is harmless (it could only run at process exit).

related issue: none filed — happy to open one if preferred.

before

$ screenpipe record --disable-audio --disable-vision --data-dir /tmp/sp-test
# press Cmd+C with any text selected …

$ ls /tmp/sp-test/clipboard*
/tmp/sp-test/clipboard-read-inflight          # never cleaned up — worker hung
$ sqlite3 /tmp/sp-test/db.sqlite "SELECT count(*) FROM ui_events WHERE event_type='clipboard'"
0                                              # no clipboard row ever written

Sampled stack of the hung worker (100% of 1589 samples over 2s):

Thread: clipboard-capture
  _dispatch_sync_f_slow
    __DISPATCH_WAIT_FOR_QUEUE__
      _dispatch_thread_main_event_wait_slow   ← waiting for main thread to drain main queue
        __ulock_wait

Main thread at the same time: pthread_cond_wait inside tokio's block_on — never touches the dispatch main queue. On the next launch, the stale inflight marker is promoted to clipboard-disabled-after-crash → clipboard permanently off.

after

$ screenpipe record --disable-audio --disable-vision --data-dir /tmp/sp-test
# log on first Cmd+C:
INFO screenpipe_a11y::platform::macos: main dispatch queue not serviced (headless CLI) — clipboard reads will run on the capture worker thread, guarded by the crash marker

$ sqlite3 /tmp/sp-test/db.sqlite "SELECT text_content FROM ui_events WHERE event_type='clipboard'"
CLIP-PATCH-VERIFY-67890                        # payload captured
$ ls /tmp/sp-test/clipboard*
zsh: no matches found                          # inflight marker cleaned up

Desktop app path unaffected: the probe succeeds there, so reads stay on the main thread exactly as today.

how to test

  1. cargo build --release --bin screenpipe, then ./target/release/screenpipe record --disable-audio --disable-vision --data-dir /tmp/sp-test --port 3042
  2. Copy any text (Cmd+C), wait ~2s
  3. sqlite3 /tmp/sp-test/db.sqlite "SELECT text_content FROM ui_events WHERE event_type='clipboard'" → shows the copied text (before this patch: zero rows, and /tmp/sp-test/clipboard-read-inflight is left behind)
  4. Desktop app regression check: copy/paste with the app running — behavior unchanged (probe succeeds → main-thread read)

test status

cargo test -p screenpipe-a11y --lib: 166 passed, 3 failed — the 3 failures (test_get_clipboard_{set_and_read,unicode,large_content}) are pre-existing and fail identically on unpatched main (verified: 3 passed / 3 failed, same None assertion, baseline at f62ba66). They round-trip the real pasteboard from the test process, which doesn't work in a headless test environment on recent macOS — unrelated to this change.

🤖 Generated with Claude Code

`get_clipboard()` does dispatch_sync onto the libdispatch main queue, but
the headless CLI (`screenpipe record`) runs #[tokio::main] — nothing ever
drains the main queue, so the first Cmd+C parks the clipboard worker
forever in _dispatch_thread_main_event_wait_slow. The leftover inflight
marker then trips the dead-man switch on next launch, permanently
disabling clipboard capture.

Probe the main queue once (async no-op, 500ms): if alive (GUI app), keep
the main-thread read; if dead (headless), read on the worker thread —
re-exposing the off-main race (arboard#218), backstopped by the existing
crash marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@guillaumedeshayes

Copy link
Copy Markdown
Contributor Author

Closing while I investigate test failures — will reopen with a complete fix.

@guillaumedeshayes guillaumedeshayes deleted the fix/headless-clipboard-deadlock branch June 6, 2026 03:07
@guillaumedeshayes guillaumedeshayes restored the fix/headless-clipboard-deadlock branch June 6, 2026 03:12

@louis030195 louis030195 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nice fix. @guillaumedeshayes does this completely resolve the headless cli hang on mac?

generated by the screenpipe pr-review pipe (https://screenpi.pe), not written by a human — reply and tag @louis030195 if it got something wrong.

@guillaumedeshayes

Copy link
Copy Markdown
Contributor Author

Thank you. Yes, it does @louis030195 , I tested on mac.

@louis030195

Copy link
Copy Markdown
Collaborator

@guillaumedeshayes thanks for the detailed writeup. I pulled the branch and verified the fix end to end. It does resolve the headless deadlock.

I ran the screenpipe-a11y clipboard tests in isolation on main vs your branch (each test in its own process, so one hang does not mask the others):

test main (before) your branch (after)
test_get_clipboard_returns_option hangs (deadlock) pass
test_get_clipboard_set_and_read hangs pass
test_get_clipboard_unicode hangs pass
test_get_clipboard_large_content fail pass
test_get_clipboard_empty_returns_none pass pass
test_get_clipboard_no_subprocess pass fail (new)

So the three that deadlocked on main now complete. That is the hang, fixed. Nice work.

One thing to address before merge: test_get_clipboard_no_subprocess regresses. It does 10 reads and asserts they finish under 200ms, but the run now takes ~512ms because the first get_clipboard() pays the one-time main_queue_alive() probe (it waits the full 500ms timeout in any process without a runloop, e.g. the test binary or the CLI). In production this is a one-time cost on the very first read (the OnceLock caches it), so it is acceptable, but the test needs to account for it. Options: warm the probe before the timed loop, lower the probe timeout, or relax that assertion.

Heads-up on something that surprised me while testing: these macOS clipboard tests do not run in CI at all. The workspace test job runs on Ubuntu where platform/macos.rs is cfg'd out, and the macOS job only runs the screenpipe-screen OCR and screenpipe-core pii tests. So a green CI here will not catch either the pre-existing failures you mentioned or the new no_subprocess one. Worth knowing while you iterate.

Minor and optional: the probe result is latched for the whole process via OnceLock. If the app main runloop is ever stalled longer than 500ms at the exact moment of the first read, the probe would conclude "dead" and route reads off-main for the rest of that process, re-exposing the arboard#218 race in the desktop app too. Low odds given when the first read actually happens, but re-probing on a miss instead of latching would remove the footgun.

Net: the approach is right and the deadlock is gone. Fix up no_subprocess and I think this is good to merge.

@Anshgrover23 Anshgrover23 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@guillaumedeshayes Could you add the demonstration video/Screenshots?

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.

3 participants