Refactor WebGPU surface lifecycle to support latched attach#426
Merged
Conversation
…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
added a commit
that referenced
this pull request
Jul 18, 2026
|
🎉 This PR is included in version 0.6.1 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
MetalView.dealloc/WebGPUViewManager.onDropViewInstance) when a surface is attached, and the newRNWebGPU.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.RNWebGPUManagerteardown so dev reloads cannot alias new canvases onto stale entries; all native-side registry lookups are null-checked.Latched attach, immediate detach
getCurrentTexture/ end ofpresent) 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.present().present()only presents frames whose texture was actually acquired from the attached surface — eliminating present-without-acquire validation errors around transitions. A_frameEpochcounter revalidates the deferred blit-present so it can never race a frame that completed while the lock was released.Blit & error-path correctness
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 withCopyDst.getCurrentTexture()checksSurfaceTexture.status(reconfigure + retry once, then offscreen fallback), throws a catchable JS error when called beforeconfigure()instead of dereferencing a null device, and clamps zero-sized canvases to 1×1.unconfigure()is now implemented.viewFormatsare deep-copied intoSurfaceInfo; the stored configuration previously kept a pointer into Convertor-owned memory that died with theconfigure()call.Native resource ownership
ANativeWindowreferences are released on every path (the acquire inonSurfaceCreatewas never paired); iOS retains theCAMetalLayerfor the lifetime of the attach (releasing on the main queue), fixing a use-after-free when a dev reload cleared the registry beforedealloccould cancel a pending attach. Both flow through a per-attachNativeSurfaceReleaser, balanced on every replace/cancel/adopt/destroy path.WebGPUBaseView(its JNI bindings never existed) and theonSurfaceDestroypath.Verification
chromium/7849); all changed C++ syntax-checked under g++ and clang++. TS typecheck (all workspaces), ESLint, cpplint clean.MetalView.mmand the Java/JNI changes (no Apple SDK/NDK in the environment) — platform smoke tests recommended before merge.