Skip to content

Refactor WebGPU surface lifecycle to support latched attach#426

Merged
wcandillon merged 5 commits into
mainfrom
claude/react-native-webgpu-arch-21mes3
Jul 18, 2026
Merged

Refactor WebGPU surface lifecycle to support latched attach#426
wcandillon merged 5 commits into
mainfrom
claude/react-native-webgpu-arch-21mes3

Conversation

@wcandillon

@wcandillon wcandillon commented Jul 17, 2026

Copy link
Copy Markdown
Owner

fixes #414

Summary

Stabilizes the surface lifecycle behind the synchronous getContext("webgpu") contract. The offscreen-fallback model stays (it is what gives fast time-to-first-frame on Android), but the reconciliation points between the asynchronous native surface lifecycle and the synchronous WebGPU API are reworked around explicit invariants instead of ad-hoc state swaps.

Key changes

Ownership: registry entries live exactly as long as their JS Canvas

  • Surface destruction alone (backgrounding, TextureView teardown) now only detaches — it never removes the registry entry. This fixes the orphaned-context black screen on the transparent/TextureView path and the SurfaceView/TextureView asymmetry.
  • Entry removal happens in exactly two places: native view teardown (MetalView.dealloc / WebGPUViewManager.onDropViewInstance) when a surface is attached, and the new RNWebGPU.destroyContext (Canvas unmount cleanup) when none ever was. The split keeps React StrictMode's simulated unmount — which re-runs effects without unmounting native views — from orphaning a live surface.
  • destroyContext's check-and-erase and the platform attach both run atomically under the registry lock (SurfaceRegistry::attachSurface / removeSurfaceInfoIfDetached), so they can never orphan each other. Lock order is strictly registry → SurfaceInfo.
  • The registry is cleared on RNWebGPUManager teardown so dev reloads cannot alias new canvases onto stale entries; all native-side registry lookups are null-checked.

Latched attach, immediate detach

  • Surface attaches are stored as pending by the UI thread and adopted at frame boundaries (start of getCurrentTexture / end of present) on whichever thread renders (main JS, Reanimated UI, worklet runtimes) — preserving Dawn surface thread-affinity and guaranteeing a surface is never swapped mid-frame. A CallInvoker flush covers contexts that are not actively rendering, so static content still appears.
  • Surface detaches stay immediate (the platform destroys the surface synchronously); a configured context falls back to offscreen rendering and the in-flight frame is dropped at present().
  • present() only presents frames whose texture was actually acquired from the attached surface — eliminating present-without-acquire validation errors around transitions. A _frameEpoch counter revalidates the deferred blit-present so it can never race a frame that completed while the lock was released.

Blit & error-path correctness

  • The offscreen→onscreen blit checks SurfaceTexture.status, clamps the copy extent to the shared region (rotation while detached), presents from the rendering thread, and no longer permanently widens the configured usage with CopyDst.
  • getCurrentTexture() checks SurfaceTexture.status (reconfigure + retry once, then offscreen fallback), throws a catchable JS error when called before configure() instead of dereferencing a null device, and clamps zero-sized canvases to 1×1. unconfigure() is now implemented.
  • viewFormats are deep-copied into SurfaceInfo; the stored configuration previously kept a pointer into Convertor-owned memory that died with the configure() call.

Native resource ownership

  • ANativeWindow references are released on every path (the acquire in onSurfaceCreate was never paired); iOS retains the CAMetalLayer for the lifetime of the attach (releasing on the main queue), fixing a use-after-free when a dev reload cleared the registry before dealloc could cancel a pending attach. Both flow through a per-attach NativeSurfaceReleaser, balanced on every replace/cancel/adopt/destroy path.
  • Removes the dead WebGPUBaseView (its JNI bindings never existed) and the onSurfaceDestroy path.

Verification

  • Real Dawn headers regenerated from the pinned submodule commit (chromium/7849); all changed C++ syntax-checked under g++ and clang++. TS typecheck (all workspaces), ESLint, cpplint clean.
  • A sanitizer stress test exercises the transition state machine (4 threads: attach/detach churn, flush, frame loop, registry probes, plus registry-level attach vs remove-if-detached arbitration) — clean under ThreadSanitizer and ASan/UBSan, 26k native-window cycles balanced exactly.
  • Adversarial multi-agent review (5 lenses, 3-vote verification per finding) confirmed 3 defects — all fixed in follow-up commits and re-verified against their exact failure interleavings.
  • Not covered here: on-device builds of MetalView.mm and the Java/JNI changes (no Apple SDK/NDK in the environment) — platform smoke tests recommended before merge.

claude and others added 5 commits July 17, 2026 08:19
…ttach model

The SurfaceRegistry kept the sync getContext("webgpu") contract by falling
back to an offscreen texture when the native surface is not available yet,
but the reconciliation points were racy and crash-prone. This reworks the
lifecycle around explicit invariants:

- Registry entries now live exactly as long as their JS Canvas (contextIds
  are never reused). Native view teardown (MetalView dealloc /
  onDropViewInstance) retires the entry when a surface is attached;
  RNWebGPU.destroyContext (Canvas unmount cleanup) covers entries that never
  had one, which keeps StrictMode's simulated unmount from orphaning a live
  surface. Surface destruction alone (backgrounding, TextureView teardown)
  only detaches — fixing the orphaned-context black screen on the
  transparent/TextureView path and the SurfaceView/TextureView asymmetry.
- Surface attaches are latched by the UI thread and adopted at frame
  boundaries on the rendering thread (start of getCurrentTexture / end of
  present), so a surface is never swapped mid-frame and Dawn surface
  thread-affinity is preserved. A CallInvoker flush covers contexts that are
  not actively rendering. Detaches stay immediate (the platform is
  destroying the surface) and drop the in-flight frame at present().
- present() only presents frames whose texture was actually acquired from
  the attached surface, eliminating present-without-acquire validation
  errors around transitions.
- The offscreen->onscreen blit now checks SurfaceTexture.status, clamps the
  copy extent to the shared region, presents from the rendering thread, and
  no longer permanently widens the configured usage with CopyDst.
- getCurrentTexture checks SurfaceTexture.status (reconfigure + retry once,
  then offscreen fallback), throws a catchable JS error when called before
  configure() instead of dereferencing a null device, and clamps zero-sized
  canvases to 1x1. unconfigure() is now implemented.
- viewFormats are deep-copied into SurfaceInfo; the stored configuration
  previously kept a pointer into Convertor-owned memory that died with the
  configure() call.
- ANativeWindow references are now released (acquire in onSurfaceCreate was
  never paired), the registry is cleared on RNWebGPUManager teardown so dev
  reloads cannot alias new canvases onto stale entries, and every registry
  lookup on the native side is null-checked. Removes the unused
  WebGPUBaseView (its JNI bindings never existed) and the dead
  onSurfaceDestroy path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLLf4VPzGb642Zp8EyNgpr
…ch model

Three defects confirmed by review of the latched-attach rework:

- Double-present race: between applyPendingAttach's blit submit and its
  deferred Present(), a frame could start and complete on another runtime;
  the !_frameInFlight guard missed frames that finished inside the gap and
  Present() fired with no acquired texture. A _frameEpoch counter (bumped on
  every acquire, present, configure/reconfigure/unconfigure, adoption, and
  detach) is now revalidated before the deferred present.

- destroyContext TOCTOU: hasNativeSurface() (SurfaceInfo mutex) followed by
  removeSurfaceInfo (registry mutex) was not atomic against the UI thread's
  find-or-create + attach, so a StrictMode cleanup racing a native mount
  could orphan a just-attached surface. SurfaceRegistry now provides
  attachSurface (find-or-create + attach) and removeSurfaceInfoIfDetached
  (lookup + check + erase), both atomic under the registry lock; lock order
  is registry -> SurfaceInfo on every path.

- iOS CAMetalLayer use-after-free: the latched attach stored an unretained
  layer pointer; on dev reload the registry is cleared before MetalView
  dealloc runs, so dealloc could not cancel the pending attach and the
  queued flush later touched a freed layer. The layer is now
  CFBridgingRetain'd for the lifetime of the attach and released on the main
  queue via the same releaser mechanism as Android's ANativeWindow ref.

Verified with a TSAN/ASan/UBSan stress test hammering attach/detach/flush/
frame loops and the registry-level attach vs remove-if-detached arbitration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLLf4VPzGb642Zp8EyNgpr
…tion

A pending attach can carry a null wgpu::Surface when makeSurface fails for a
valid native window; adoption then called Configure on the null handle. The
context now keeps rendering offscreen in that case, consistent with
_configureLocked and the window-ownership accounting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLLf4VPzGb642Zp8EyNgpr
@wcandillon
wcandillon merged commit 06dbef1 into main Jul 18, 2026
2 checks passed
wcandillon added a commit that referenced this pull request Jul 18, 2026
Empty commit: #426 (06dbef1) was merged without a fix() prefix, so release tooling did not pick it up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.6.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native crash (SIGSEGV) configuring a swapchain on a destroyed surface

2 participants