fix(native): serialize startup frame capture to stop heap corruption on macOS 26#120
Conversation
…on macOS 26
FrameCapture.wireUpFramebuffer() registered the SimulatorKit screen
callbacks (which dispatch captureFrame() onto captureQueue) and then called
captureFrame() directly on the caller thread. The two ran concurrently
during startup, so handleFrame() set up the encoders twice in parallel and
double-freed VideoEncoder.onEncodedFrame's closure context. On macOS 26 the
hardened "xzone" allocator detects the corrupted freelist and aborts with
SIGTRAP ("BUG IN CLIENT OF LIBMALLOC: memory corruption of free block").
The AVCC/H.264 path made it far more likely by adding concurrent native
allocations in the same window. The observable tell was "JPEG encoder
ready" being logged twice at startup.
- FrameCapture: dispatch the initial captureFrame() onto captureQueue so
every capture is mutually exclusive with the callback-driven ones.
- VideoEncoder: guard onEncodedFrame with a lock so the resolution-change
swap on the capture queue cannot race the read on the encode queue.
Verified on macOS 26.5.1 (arm64, Xcode 26, Node 22): "JPEG encoder ready"
now logs once, 0 crashes across 60 startup-race trials (baseline ~1/25),
and H.264/AVCC renders without aborting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughTwo concurrency fixes in the SimNative layer: ChangesConcurrency Fixes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
This is likely resolved by #117 |
Summary
On macOS 26 (Tahoe),
serve-simreliably crashes the host process with a heap-corruption abort shortly after a browser connects:The crash is intermittent (≈1 in 25 cold starts, but often on the very first browser connect) and is far more likely on the default
--codec auto(H.264/AVCC) path. MJPEG-only sessions usually survive. It does not reproduce on macOS 15.Root cause
A startup data race in
FrameCapture.wireUpFramebuffer()registers the SimulatorKit screen callbacks — which dispatchcaptureFrame()ontocaptureQueue— and then callscaptureFrame()directly on the caller thread:So two
captureFrame()→handleFrame()runs execute concurrently during startup.handleFrame()does the first-frame encoder setup (videoEncoder.stop()thenvideoEncoder.setup(...), which swaps theonEncodedFrameclosure), so the two concurrent runs double-free the old closure's context. On macOS 26 the hardened "xzone" allocator detects the corrupted freelist and aborts; on older allocators the same race was benign. The AVCC path amplifies it by addingVTCompressionSession/ pixel-buffer-pool /Dataallocations in the same tiny window.The observable tell is in the logs: "JPEG encoder ready" is printed twice at startup — once per concurrent
handleFrame().(Consistent with
Package.swift'sswiftLanguageModes: [.v5], chosen so these cross-queue captures stay warnings rather than Swift 6 errors.)Fix
FrameCapture.swift— dispatch the initialcaptureFrame()ontocaptureQueue(like the callback- and idle-timer-driven captures already do), so everycaptureFrame()is mutually exclusive.asyncis safe in both thestart()(caller thread) and the self-heal re-wire (already oncaptureQueue) paths.VideoEncoder.swift— guardonEncodedFramewith anNSLockso the resolution-change swap (capture queue) can't race the read inencode()(encode queue). Defensive; same closure-double-free class on a different trigger.2 files, +24/−2. No behavior change other than the initial frame being captured on the proper queue.
Verification (macOS 26.5.1, arm64, Xcode 26, Node 22)
"JPEG encoder ready"at startupNotes
This is macOS-26-specific in manifestation (the new allocator turns a latent race into a hard abort), but the race itself exists on all versions. No allocator-specific workaround is used — the fix removes the race.
🤖 Generated with Claude Code
Summary by CodeRabbit