wcandillon / wcandillon/react-native-webgpu

iOS: surface configuration mutates CAMetalLayer off the main thread

Open
#458 0 comments 0 reactions 0 assignees View on GitHub

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.

  1. Check out repro/cametal-main-thread.
  2. Build and run apps/example as Debug on a physical iPhone.
  3. The app opens Surface Churn and begins remounting and configuring the canvas every 400 ms.
  4. 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 CAMetalLayer mutation site.

Acceptance criteria

  • Every Apple operation that reads or mutates CAMetalLayer state 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.