Sony CrSDK on macOS Apple Silicon
Five Bugs Between USB Plug-In and "Connected"
Sony's Camera Remote SDK ships native arm64 dylibs for Apple Silicon — no Rosetta, no PAC mess. So a Sony A7 IV ought to "just work" on macOS the way it does on Windows. It does not. Five distinct macOS-specific bugs sit between the camera attaching and a working SDK session. Here's all of them, in the order I hit them.
TL;DR
Five macOS-specific bugs hide behind SCRSDK::Connect on Apple Silicon. (1) libCr_Core hardcodes its plugin lookup at Contents/Frameworks/CrAdapter — an app-bundle layout even for a CLI binary. (2) ptpcamerad opens a PTP session faster than any one-shot kill, and SIP blocks launchctl bootout. (3) The SDK segfaults inside Init() when called after a matching Release() in the same process. (4) "Stop Live View" only flips an SDK feature flag — the camera body keeps streaming. (5) Even with daemons suppressed, the body refuses Connect with SessionAlreadyOpened because ptpcamerad's now-dead PTP session lingers in the body. Solutions span CMake, a forked killer subprocess, a clean-exit handshake, an additional SDK property write, and an IOKit USB re-enumerate.
Background
eclipseClick spawns a per-vendor camera bridge as a child process and talks to it over JSON-RPC stdio. The Sony bridge wraps Sony's Camera Remote SDK (CrSDK), which on macOS ships native arm64 + x86_64 dylibs (separate, not lipo-merged), uses a bundled libusb for USB transport, and follows a clean async API.
The Windows path was already shipping. After bumping the eclipseClick mac DMG to native arm64, the expectation was that the Sony bridge would behave the same on Sequoia 15.x. It didn't. Plugging in a Sony A7 IV reproducibly produced empty settings, mysterious timeouts, or a fast SIGSEGV — depending on which bug fired first.
What follows is the sequence of bugs hit in order, with the SDK error code that surfaced each one and the fix that landed in sony-camera-bridge#9.
Five Bugs, in Order
Bug 1: CrError_Adaptor_Create (0x8703) — Hardcoded plugin path
First connect: EnumCameraObjects returns 0x8703. The error-name lookup says Adaptor_Create — meaning the SDK couldn't load its USB transport plugin. The plugin libCr_PTP_USB.dylib is right there in build/CrAdapter/, where the build's CMake post-build step put it. So why can't the SDK find it?
strings libCr_Core.dylib | grep CrAdapter tells the story:
$ strings build/libCr_Core.dylib | grep CrAdapter Contents/Frameworks/CrAdapter
Sony's libCr_Core on macOS hardcodes its plugin lookup at Contents/Frameworks/CrAdapter — an .app-bundle path. There's no fallback to the binary's directory. Even for a plain CLI binary, the SDK insists on the bundle layout.
Sony's own RemoteCli sample CMakeLists.txt works around this with a dual copy (lines 162–167 of the SDK's sample). Mirror it:
if(APPLE) add_custom_command(TARGET SonyBridge POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory $<TARGET_FILE_DIR:SonyBridge>/CrAdapter $<TARGET_FILE_DIR:SonyBridge>/Contents/Frameworks/CrAdapter ) endif()
Bug 2: CrError_Connect_TimeOut (0x8208) — ptpcamerad races the SDK
With the plugin layout fixed, EnumCameraObjects finds the camera. SCRSDK::Connect returns success synchronously. Then a moment later OnError fires with 0x8208. Empty settings panel.
macOS ships com.apple.ptpcamerad — a system LaunchAgent that takes a PTP session on any imaging-class USB device that attaches. The bridge had a one-shot launchctl bootout + killall at startup. On Sequoia, both fail in interesting ways:
launchctl bootout gui/<uid>/com.apple.ptpcameradfails under SIP. The.plistlives in/System/Library/LaunchAgents/, which System Integrity Protection guards. Errors get piped to/dev/nulland the bridge unconditionally logs success.launchctl disable gui/<uid>/com.apple.ptpcameradis allowed (writes to a per-user override) — but it doesn't block Mach-on-demand activation. CMIO does a Mach service lookup oncom.apple.ptpcameradwhen it sees a USB camera, andlaunchdrespawns the daemon regardless of the disable flag.killallworks once but the daemon respawns within milliseconds.
The fix is to kill faster than launchd can respawn. A bare-shell while :; do killall ...; done in a tight loop fully suppresses the daemon. A 100 ms-cadence in-process std::thread calling system("killall ...") is too slow, and brings its own problem: forking from a process holding libCr_Core's libusb + CoreFoundation state is the classic macOS fork-safety landmine.
Resolution: fork once at bridge init into a dedicated subprocess that runs the kill loop. The shell's per-iteration forks happen in the child, which doesn't have libCr_Core loaded. The subprocess polls kill -0 $BRIDGE_PID (an sh builtin — no fork) on each iteration and self-terminates if the bridge dies — so a non-graceful bridge exit can't orphan the killer to launchd consuming a CPU core forever.
m_ptp_killer_pid = fork(); if (m_ptp_killer_pid == 0) { execl("/bin/sh", "sh", "-c", "while :; do " " kill -0 \\$1 2>/dev/null || exit 0; " " /usr/bin/killall -9 ptpcamerad PTPCamera 2>/dev/null; " "done", "ptpcamerad-killer", ppid_arg, (char*)nullptr); _exit(127); }
Bug 3: SIGSEGV after disconnect — SDK Init crashes post-Release
Connect now works. Disconnect logs [sony] Disconnected and then the bridge exits with code=null, signal=SIGSEGV.
The bridge's disconnect path runs an SCRSDK::Release() followed by SCRSDK::Init() to clear retained USB transport state — without it, the next Connect blocks ~15 seconds. On Windows the cycle works fine. On macOS, breadcrumb logging shows SCRSDK::Release() returning cleanly, then the very next SCRSDK::Init() reliably segfaults. Sony's libCr_Core on macOS doesn't survive a Release/Init cycle in the same process — almost certainly its bundled libusb context.
Skipping the in-process Re-init leaves stale USB transport state and the next Connect's GetDeviceProperties hangs. So neither "Re-init" nor "skip Re-init" alone works.
Resolution: on macOS, after the synchronous PTP CloseSession sends, just exit the bridge process. eclipseClick already handles bridge respawn cleanly. Fresh process = fresh SDK Init = no crash. A flag set by the disconnect handler lets main() finish writing the response to stdout before breaking the read loop. Bridge spawn cost is ~50 ms, paid only on user-initiated disconnect, never in steady-state operation. The Windows code path is preserved untouched.
Bug 4: "Stop Live View" doesn't quiet the camera body
Click "Stop Live View" — the eclipseClick preview pane goes blank. The camera body's LCD/EVF keeps showing the live preview unchanged.
Setting_Key_EnableLiveView = Disable is what every reference (Sony's RemoteCli sample, the SDK header) uses for stop. But the SDK header's own description for CrLiveView_Disable is the giveaway: "Live view is supported, but can't get LV image." It's an SDK-side feature flag. The camera body never gets told to stop.
CrDeviceProperty_LiveViewProtocol is the camera-body-facing knob. Setting it to CrLiveViewProtocol_None on stop (and back to _Main on start) actually quiets the body's display.
SCRSDK::SetDeviceSetting(device, SCRSDK::Setting_Key_EnableLiveView, enable ? SCRSDK::CrDeviceSetting_Enable : SCRSDK::CrDeviceSetting_Disable); SCRSDK::CrDeviceProperty prop; prop.SetCode(SCRSDK::CrDeviceProperty_LiveViewProtocol); prop.SetValueType(SCRSDK::CrDataType_UInt32); prop.SetCurrentValue(enable ? SCRSDK::CrLiveViewProtocol_Main : SCRSDK::CrLiveViewProtocol_None); SCRSDK::SetDeviceProperty(device, &prop);
Bug 5: CrError_Connect_SessionAlreadyOpened (0x8210) — Stale ptpcamerad PTP session
Killer subprocess running. Power-cycle the camera. Launch eclipseClick fresh. Click Connect. The SDK fires OnError with CrError_Connect_SessionAlreadyOpened (0x8210). pgrep ptpcamerad shows nothing — the killer is doing its job.
The smoking gun is timing. Between camera-attach and bridge-launch, ptpcamerad spawns (CMIO Mach lookup) and opens a PTP session with the camera body in milliseconds — well before eclipseClick can spawn the bridge. Killing ptpcamerad after the fact does not tell the body to release the session. The body only releases on USB-disconnect or session timeout, and the bridge has no handle to send PTP CloseSession against (we don't know the stale session ID). Physical unplug+replug clears it. Programmatic equivalent: IOKit USB re-enumerate.
USBDeviceReEnumerate(0) tells the kernel to issue a USB-level disconnect followed by a re-attach — exactly what unplugging the cable does. The body sees a real bus disconnect and clears any open session. With the killer subprocess already suppressing ptpcamerad by the time the camera comes back, no new stale session is created. Bridge does this once at initialize(), before SCRSDK::Init().
Two non-obvious things made the IOKit code take longer than expected:
- Class matching: on modern macOS the camera registers as
IOUSBHostDevice, notIOUSBDevice. They're siblings, not in an inheritance relationship — IOKit class matching against one does not catch the other. The first stab matched only one class; iterator returned 0 matches. Fix: try both class names in turn. - Vendor filter: putting
idVendor=0x054cin the matching dict returned an empty iterator even thoughioregplainly listed the device withidVendor=1356. Likely a CFNumber type or property-key subtlety in the new stack. Sidestep: match the class with no vendor filter, then readidVendorproperty from each iterator entry and skip non-Sony.
Diagnostic logging proved it: class=IOUSBHostDevice scanned 5 device(s), 1 Sony, 1 re-enumerated. OnConnected fires immediately on the next Connect. No more 0x8210.
Bug → Fix Map
| Symptom | Root cause | Fix |
|---|---|---|
| 0x8703 | libCr_Core hardcodes plugin lookup at Contents/Frameworks/CrAdapter | CMake dual-copy of CrAdapter/ into both bridge dir and bundle-style path |
| 0x8208 | ptpcamerad respawns under SIP+Mach activation; one-shot kill insufficient | Forked subprocess running while :; do killall -9 ...; done + parent-watch via kill -0 |
| SIGSEGV | SCRSDK::Init() crashes when called after a matching Release() in the same process | Bridge cleanly exits on disconnect (macOS-only); eclipseClick respawns on next Connect |
| LV stuck on body | Setting_Key_EnableLiveView is an SDK feature flag, doesn't reach the body | Also write CrDeviceProperty_LiveViewProtocol = None on stop |
| 0x8210 | Body holds a stale PTP session opened by ptpcamerad before bridge launched | IOKit USBDeviceReEnumerate(0) at bridge init — programmatic unplug+replug |
Keeping the Windows Path Safe
The Sony bridge already shipped on Windows. Every macOS fix is gated behind #ifdef __APPLE__ — Windows code paths are bit-identical to the previously-tested baseline:
- The CMake
if(APPLE)blocks add theContents/Frameworks/CrAdaptercopy and linkIOKit/CoreFoundationonly on macOS. - The
start_ptp_killer/stop_ptp_killermethods exist only under#ifdef __APPLE__. - The post-disconnect SDK
Release()+Init()dance still runs on Windows; only the macOS branch was rerouted to a clean exit. usb_reset.hexposes a no-opinline int reenumerate_sony_usb_devices() { return 0; }stub on non-Apple so the call site stays platform-agnostic.
Windows CI's standalone-tests workflow exercises the #ifndef __APPLE__ paths and confirms the new code doesn't break the Windows build.
What a Healthy Connect Looks Like Now
Bridge stderr from a clean macOS startup, with the camera attached:
[sony-bridge] Started
[sony] macOS PTP killer subprocess started (tight-loop killall ptpcamerad/PTPCamera; bridge-watched)
[sony] usb_reset: re-enumerated Sony USB device (simulated unplug+replug to clear stale PTP session)
[sony] usb_reset: class=IOUSBHostDevice scanned 5 device(s), 1 Sony, 1 re-enumerated
[sony] usb_reset: class=IOUSBDevice scanned 4 device(s), 0 Sony, 0 re-enumerated
[sony] SDK initialized
[sony] Enumerated 1 camera(s)
[sony] Connected to: ILCE-7M4
[sony] Camera connected callback ← OnConnected fires, no error One Bug Deferred
Hardware testing of an actual eclipse-bracket script surfaced a sixth issue, scoped out of this PR for a focused follow-up. apply_shooting_settings silently returns success-with-errors when SetDeviceProperty exhausts its retry budget on CrError_Api_InvalidCalled (0x8402). The slot executor in eclipseClick proceeds with the capture, producing a frame at the previous shutter speed.
The retry budget is 20 × 100 ms = 2 s. On the A7 IV the buffer-flush window after a long-shutter or RAW frame can routinely exceed that. Fix shape: scale the retry budget by the last applied shutter speed (we already track m_last_shutter_ms), and re-throw on retry exhaustion instead of swallowing into a cJSON errors array, and gate the slot executor's capture on the response shape. Documented in docs/known-issues/silent-shutter-retry-exhaustion.md with a pinning-test plan.
It's a real bug, but it's in the capture flow rather than the connectivity path, and crosses into eclipseClick — better as its own PR with its own review and test surface.
Lessons
- Sony's macOS SDK is half-ported. The arm64 dylibs link cleanly and load cleanly, but the SDK assumes an
.app-bundle layout, doesn't survive Release/Init in-process, and exposes a feature-flag for "stop live view" that never reaches the camera body. Each one of these is a fixable papercut, but they compound — the SDK has clearly not been driven through a full end-to-end CLI use case on macOS post-port. - macOS daemon eviction is a moving target. Pre-Sequoia,
launchctl bootoutfromgui/<uid>worked unprivileged. On 15.x with SIP, it doesn't. Any code that pipes errors to/dev/nulland unconditionally logs success will confidently lie to you. The killer subprocess design is the only thing that's robust against future macOS hardening, because it doesn't depend onlaunchdcooperating. - USB session state is camera-side, not host-side. Every fix that tries to clear a stuck session via host-side process management will eventually fail. The only thing the camera body honors is a USB-level disconnect — physical or via
USBDeviceReEnumerate. - Diagnostic logging earns its keep. The IOKit class match would have been a rabbit hole without the
scanned N device(s), M Sony, K re-enumeratedlog. Cheap; pays for itself the first time something doesn't behave as expected. - Trust hardware over assumptions. Two of these bugs (3 and 5) only reproduce against a real Sony body — not a USB stub, not an emulator, not the SDK's own RemoteCli sample running interactively. Without the hardware loop, the SIGSEGV would have shipped.