wcandillon / wcandillon/react-native-webgpu
iOS: surface configuration mutates CAMetalLayer off the main thread
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 1.2k
- Forks
- 70
- Avg merge
- 1d 6h
- Merged PRs (30d)
- 23
Description
Summary
On iOS, canvas surface attach and configuration can reach Dawn and mutate the backing CAMetalLayer from a React JavaScript or worklet runtime instead of the main thread. Core Animation layer mutation is main-thread-only, so the current behavior is undefined during lifecycle churn.
Reproduction
Reproduction branch: AlirezaHadjar/react-native-webgpu repro/cametal-main-thread
This branch starts directly from upstream main. It changes only three files: it reuses the existing Diagnostics -> Surface Churn screen, opens and starts it automatically in iOS Debug, and adds a Debug-only assertion at the CAMetalLayer mutation site. It contains no fix.
- Check out
repro/cametal-main-thread. - Build and run
apps/exampleas Debug on a physical iPhone. - The app opens Surface Churn and begins remounting and configuring the canvas every 400 ms.
- Xcode prints:
[cametal-repro] CAMetalLayer main-thread check: FAIL
The Debug assertion then stops at:
CAMetalLayer mutations must run on the main thread
Why it happens
Pending surfaces are adopted at frame boundaries on whichever runtime renders. The inactive-surface flush also uses the React JavaScript CallInvoker:
_jsCallInvoker->invokeAsync(
[info = std::move(info)] { info->applyPendingAttach(); });
Both SurfaceInfo::configure() and applyPendingAttach() can call wgpu::Surface::Configure(). On Metal, that operation configures the backing layer. applyPendingAttach() also assigns CAMetalLayer.colorspace.
Physical iPhone reproduction data
This test targets issue #458 on hardware. No simulator measurements are included. I recorded five Xcode Activity Monitor runs on an iPhone 16 Pro Max running iOS 26.6.1, in this order: unfixed, unfixed, fixed, fixed, unfixed. Every run used the same Debug configuration, dependencies, signing, 15-second warmup, 60-second capture, fresh process, and 400 ms Surface Churn remount interval.
| Metric | Unfixed (upstream main) |
Fix (#459) |
|---|---|---|
| Runs | 3 | 2 |
| Total remounts | 450 | 300 |
| Mean app CPU | 31.78% | 16.64% |
| Average starting physical footprint | 378.52 MiB | 358.68 MiB |
| Average peak physical footprint | 388.97 MiB | 364.85 MiB |
| Average memory-growth slope | 7.06 MiB/min | 7.60 MiB/min |
| Crashes or WebGPU validation errors | 0 | 0 |
The Debug assertion on the unfixed reproduction branch reports CAMetalLayer main-thread check: FAIL, which reproduces the thread-confinement violation on a physical iPhone. During the same lifecycle workload, the fixed build used 47.7% less mean app CPU and about 20 to 24 MiB less physical footprint. The memory-growth slopes were effectively unchanged, so these runs do not establish a memory-leak fix.
Proposed fix
- Run Apple surface attach, configure, reconfigure, unconfigure, and stale-surface recovery on the main thread.
- Send the inactive-surface attach flush directly to the main queue on Apple platforms.
- Dispatch before taking
SurfaceInfo's mutex, and release the mutex before synchronous stale-surface recovery to avoid lock inversion. - Revalidate the frame epoch after dispatch so a lifecycle or frame change cannot be overwritten.
- Keep frame rendering, command encoding, queue submission, and presentation on their existing runtime.
- Add a Debug-only main-thread assertion at the
CAMetalLayermutation site.
Acceptance criteria
- Every Apple operation that reads or mutates
CAMetalLayerstate runs on the main thread. - Surface churn completes without deadlock or stale presentation.
- Rendering and Dawn queue work remain on their existing runtime.
- The physical-device Debug build passes, along with example lint and TypeScript checks.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with SurfaceInfo::configure(), applyPendingAttach(), and the _jsCallInvoker inactive-surface flush described in the issue; inspect the CAMetalLayer mutation site and the reproduction branch repro/cametal-main-thread. Done means Apple surface operations are main-thread confined without deadlock or stale presentation, while rendering and Dawn queue work stay on their existing runtime. Run the physical-device Debug build, example lint, and TypeScript checks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, ios, react-native
- Domain
- computer-graphics, mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100