Skip to content

macOS client segfaults in NSPasteboard: clipboard is polled from a background thread #173

Description

@trouni

Summary

On macOS the desktop client segfaults inside NSPasteboard, killing the whole process. Sync
stops silently: the app is gone, there is no notification, and the client's own log is truncated
on the next start, so nothing is left to explain it.

The clipboard monitor polls NSPasteboard from a background Python thread. AppKit's pasteboard
APIs are not safe to call off the main thread, and the crash lands in AppKit's own bookkeeping for
pasteboard ownership changes.

Version: ClipCascade Desktop v3.2.0 (macOS build), against a self-hosted server
OS: macOS 26.5.2 (25F84), Apple Silicon

What happened

The client had been connected for hours. At 19:54:54 its websocket session ended and the process
died. It never came back on its own, and nothing else on the machine was disturbed — DNS traffic
shows the Mac was awake the whole time. I only found the cause because macOS wrote a crash report.

Crash

~/Library/Logs/DiagnosticReports/ClipCascade-2026-09-06-195526.ips

exception:   EXC_BAD_ACCESS (SIGSEGV), KERN_INVALID_ADDRESS
termination: Segmentation fault: 11
captureTime: 2026-09-06 19:54:54.7743

Faulting thread:
  libobjc.A.dylib                objc_retain
  CoreFoundation                 -[__NSFastEnumerationEnumerator initWithObject:]
  Foundation                     -[NSConcreteMapTable keyEnumerator]
  AppKit                         -[_NSPasteboardOwnersCollection handleOwnershipChange]
  AppKit                         -[NSPasteboard changeCount]
  _native.cpython-311-darwin.so  pasteboard_get_file_urls
  Python                         _PyEval_EvalFrameDefault
  Python                         thread_run
  libsystem_pthread.dylib        _pthread_start

Where it comes from

ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py runs _runner on a daemon thread
started in _start, and that loop calls pb_files.get_file_urls(diff=True) every 0.3s. That is
the pasteboard_get_file_urls frame above. -[NSPasteboard changeCount] walks
_NSPasteboardOwnersCollection, which AppKit mutates from the main thread whenever pasteboard
ownership changes — another app taking the clipboard, for instance. Enumerating that collection
concurrently is what releases an object out from under objc_retain.

Two things make it hard to live with:

  • _pasteboard_lock serializes ClipCascade's own calls, but the race is against AppKit's main
    thread, which does not take that lock. The lock cannot help here.
  • The except Exception around the loop cannot catch a SIGSEGV, so a fault in the polling thread
    takes the process down rather than just the monitor.

It is timing-dependent, so it is rare — but at a 0.3s poll it eventually lands.

Reproducing

I have not found a deterministic trigger; it needs the poll to coincide with an ownership change.
Copying files repeatedly from Finder while another app also writes to the clipboard is the
likeliest way to provoke it. On my machine it has produced three crash reports in three days, all
with the same faulting frames.

Setting enable_file_sharing: false avoids the get_file_urls path, at the cost of the feature.
I have not run long enough that way to say whether get_contents faults the same way; the same
changeCount call sits underneath it, so I would expect it can.

Suggested fix

Call the pasteboard APIs on the main thread. Keeping the 0.3s cadence but hopping each read onto
the main run loop — via PyObjC's performSelectorOnMainThread: or an equivalent — would keep the
polling design and remove the race. Failing that, running the reads inside an autorelease pool on
a thread AppKit knows about would be closer to what AppKit expects than a bare Python thread.

Happy to test a build, or to collect the full .ips files if they would help.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions