fix(canvas): keep JS contexts safe after their canvas releases the native context - #156
Merged
Merged
Conversation
…tive context The Canvas view frees the native 2D/WebGL/WebGPU context in disposeNativeView while app code may still hold the JS wrapper (frame loops, or a view tree rebuilt under HMR). Calls through a stale wrapper read freed memory, and on Android a destroyed SurfaceView surface leaves a WebGPU swapchain that getCurrentTexture blocks on. - GPUCanvasContext: guard every native entry point once detached, refuse to configure a surface that reports no capabilities, pause acquire/present while the surface is gone (resuming once the native re-attach answers a capabilities probe), and stop the wrapper's per-frame RAF before the context can be released. - 2D and WebGL contexts: swap the native object for an inert stand-in on detach, keeping the original referenced so finalization timing is unchanged. - Canvas views detach their contexts before releasing, and forward surfaceDestroyed/Created/Resize to the WebGPU context. - Native: the wrapper adopting the view's context pointer now takes its own strong count (canvas_native_webgpu_context_reference), since both the view and the wrapper's finalizer release one.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
The view and the JS wrapper shared one raw context pointer with no ownership between them, so the view's release (Android releaseNativeContext, iOS deinit) left the wrapper on freed memory, and the WebGPU wrapper released an Arc count it never took. - 2D contexts and WebGLState are refcounted (WebGLState's live-set is gone); the C++ wrappers take their own reference when they wrap the view's pointer, and the view only drops its own. - Before letting go, the Android view moves the context off its surface: WebGPU renders into an offscreen texture (no acquire on a dead surface, so no hang) until a resize attaches a new one, 2D GL moves to a pbuffer, Vulkan 2D to an offscreen render target. The same happens on surfaceDestroyed. - iOS deinit also releases WebGPU contexts. A context that outlives its canvas keeps working offscreen, like a detached <canvas>.
The context wrappers no longer swap in inert stand-ins, track detach/surface-lost state or pause the native RAF: a context keeps a reference of its own and renders offscreen once its view lets go, so every call stays valid without JS bookkeeping (and without breaking code that keeps drawing across a Vite HMR rebuild).
disposeNativeView drops the host, whose finalizer released the context the 2D/WebGL/WebGPU wrappers were still borrowing. The wrappers now take a reference of their own, like the V8 bindings.
…he context refcounts
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.
Summary
The
Canvasview frees its native context indisposeNativeView()(Android:releaseNativeContext()), but the JS context wrappers borrow that native object and nothing tells them it is gone. App code that keeps calling a wrapper after its canvas was torn down (arequestAnimationFrameloop, or a component whose view tree was rebuilt by Angular template HMR) then reads freed memory. Two further problems sit underneath for WebGPU:GPUCanvasContextImplcreated bycreateWebGPUContextWithPointeradopts the view's pointer in anArcHandlethat releases a strong count when the JS object is finalized, but never takes its own. The JNI init produced one count, so the view's release plus the finalizer's release is a double release:Arcdrop through already-freed memory (SIGSEGV insidenativeReleaseWebGPU, or in the finalizer, depending on which runs second).GPUCanvasContextImpl::Flush) keeps presenting through the raw pointer after the view released the context (SIGSEGV inFlush→canvas_native_webgpu_context_has_surface_presented), and on a destroyed Android SurfaceView surface aconfigure()that silently failed (native reports empty capabilities) leaves a swapchain that the nextgetCurrentTexture()blocks on inside wgpu (UI thread futex wait, ANR).Seen on a Galaxy Z Fold3 (Android 15) as an ANR with the toast frozen, and as SIGSEGV/SIGBUS at
canvas_native_webgpu_context_get_current_texture/_get_capabilities; reproduced on the Pixel 9 Pro Fold emulator with a plain_tearDownUIof a WebGPU canvas followed by one wrapper call, and withcanvas_native_context_set_transformfor the 2D context of a rebuilt fretboard canvas.What changes
JS (
packages/canvas)WebGPU/GPUCanvasContext.ts:__detach()marks the wrapper released, stops the native RAF (__stopRaf) and drops swapchain wrappers; every native entry point (configure,unconfigure,getCurrentTexture,presentSurface,getCapabilities,__toDataURL) becomes a warned no-op afterwards (getCurrentTexture→null,getCapabilities→ empty lists). The native object itself is kept referenced so its finalizer runs when it would have anyway.__surfaceLost()/__surfaceRestored()(Android) stop the RAF and pause acquire/present while the SurfaceView surface is gone, and resume once the natively re-attached surface answers a capabilities probe or aconfigure()succeeds (continuousRenderMode === falsekeeps the RAF off).configure()refuses a surface that reports no capabilities instead of creating the swapchain that later hangs on acquire.detached-native.ts(new): builds an inert stand-in for a released native object from its property descriptors (methods → no-op, accessors →undefined), touching no native code.Canvas2D/CanvasRenderingContext2D/index.ts,WebGL/WebGLRenderingContext/index.ts(base class, so WebGL2 inherits it):__detach()swaps the native object for the stand-in, keeping the original referenced.Canvas/index.android.ts,Canvas/index.ios.ts:disposeNativeView()detaches the 2D/WebGL/WebGL2/WebGPU contexts before releasing. Android'sNSCCanvas.ListenerforwardssurfaceDestroyed→__surfaceLost()andsurfaceCreated/surfaceResize→__surfaceRestored()(Kotlin firessurfaceCreatedbeforeresize()re-attaches the swapchain andsurfaceResizeafter, so probing on both catches the usable surface).Native
crates/canvas-c/src/webgpu/gpu_canvas_context.rs: newcanvas_native_webgpu_context_reference()(Arc::increment_strong_count), the pair ofcanvas_native_webgpu_context_release().packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.cpp(shared with the Android build viaCMakeLists.txt):CreateWebGPUContextWithPointerretains before adopting the pointer, so the view's release and the wrapper's finalizer each drop a count they own.No public API changes. Apps that reconfigure on
surfaceCreatedkeep working: the earlyconfigure()against the still-dead surface is skipped with a warning and rendering resumes onsurfaceResize.Testing
canvas_native_webgpu_context_get_current_textureunderMessageQueue.nativePollOnce, and tombstones at_get_capabilities/_get_current_texture.canvas._tearDownUI(true)thengetCurrentTexture()/getCapabilities()on the stale wrapper: before, SIGSEGV within ~60 ms (first inFlush, then innativeReleaseWebGPUonce the JS handle was dropped early); after,null/ empty capabilities, one warning, app alive and JS thread responsive for the whole watch window.canvas_native_context_set_transformSIGSEGV on the second rebuild; after, both rebuilds apply and the app keeps running.Frameunder a new layout (SurfaceView destroy + re-create under a live GPU context): no hang, no crash.tscover the changed files against@nativescript/core9.1.2 typings: no new diagnostics. Prettier clean.cargo check --target aarch64-linux-androidstopped atring's cross build script in my environment, so the native side needs a normal CI/native build. Both lockfiles in the repo are out of sync withpackage.json, so I did not run a workspace install.Follow-ups worth considering
canvas_native_webgpu_context_get_current_texturecould treat a lost/destroyed surface as an error instead of blocking inSurface::get_current_texture; with that, the JS pause on surface loss becomes belt-and-braces.undefinedfrom methods likemeasureText/createLinearGradientafter a canvas is gone; a JSTypeErrorin the caller is the intended outcome there rather than a native fault, but it may be worth documenting.