Skip to content

fix(canvas): keep JS contexts safe after their canvas releases the native context - #156

Merged
triniwiz merged 7 commits into
v3-v8from
fix/context-lifecycle-after-release
Sep 28, 2026
Merged

triniwiz merged 7 commits into
v3-v8from
fix/context-lifecycle-after-release

Conversation

@NathanWalker

Copy link
Copy Markdown
Contributor

Summary

The Canvas view frees its native context in disposeNativeView() (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 (a requestAnimationFrame loop, or a component whose view tree was rebuilt by Angular template HMR) then reads freed memory. Two further problems sit underneath for WebGPU:

  • The C++ GPUCanvasContextImpl created by createWebGPUContextWithPointer adopts the view's pointer in an ArcHandle that 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: Arc drop through already-freed memory (SIGSEGV inside nativeReleaseWebGPU, or in the finalizer, depending on which runs second).
  • The wrapper's own per-vsync RAF (GPUCanvasContextImpl::Flush) keeps presenting through the raw pointer after the view released the context (SIGSEGV in Flush → canvas_native_webgpu_context_has_surface_presented), and on a destroyed Android SurfaceView surface a configure() that silently failed (native reports empty capabilities) leaves a swapchain that the next getCurrentTexture() 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 _tearDownUI of a WebGPU canvas followed by one wrapper call, and with canvas_native_context_set_transform for 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 a configure() succeeds (continuousRenderMode === false keeps 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's NSCCanvas.Listener forwards surfaceDestroyed → __surfaceLost() and surfaceCreated / surfaceResize → __surfaceRestored() (Kotlin fires surfaceCreated before resize() re-attaches the swapchain and surfaceResize after, so probing on both catches the usable surface).

Native

  • crates/canvas-c/src/webgpu/gpu_canvas_context.rs: new canvas_native_webgpu_context_reference() (Arc::increment_strong_count), the pair of canvas_native_webgpu_context_release().
  • packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.cpp (shared with the Android build via CMakeLists.txt): CreateWebGPUContextWithPointer retains 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 surfaceCreated keep working: the early configure() against the still-dead surface is skipped with a warning and rendering resumes on surfaceResize.

Testing

  • Root cause traces came from the phone: ANR trace with the UI thread in canvas_native_webgpu_context_get_current_texture under MessageQueue.nativePollOnce, and tombstones at _get_capabilities / _get_current_texture.
  • Pixel 9 Pro Fold emulator (API 36), canvas 3.0.0-alpha.14 native library with the JS from this branch transpiled over it, and the app with no defensive code of its own:
    • canvas._tearDownUI(true) then getCurrentTexture() / getCapabilities() on the stale wrapper: before, SIGSEGV within ~60 ms (first in Flush, then in nativeReleaseWebGPU once the JS handle was dropped early); after, null / empty capabilities, one warning, app alive and JS thread responsive for the whole watch window.
    • Angular template rebuild of the component hosting a WebGPU canvas and a 2D canvas, twice: before, canvas_native_context_set_transform SIGSEGV on the second rebuild; after, both rebuilds apply and the app keeps running.
    • Re-parenting the root's child Frame under a new layout (SurfaceView destroy + re-create under a live GPU context): no hang, no crash.
  • tsc over the changed files against @nativescript/core 9.1.2 typings: no new diagnostics. Prettier clean.
  • Not built here: the Rust addition and the C++ retain. cargo check --target aarch64-linux-android stopped at ring'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 with package.json, so I did not run a workspace install.

Follow-ups worth considering

  • Natively, canvas_native_webgpu_context_get_current_texture could treat a lost/destroyed surface as an error instead of blocking in Surface::get_current_texture; with that, the JS pause on surface loss becomes belt-and-braces.
  • The 2D/WebGL stand-in returns undefined from methods like measureText/createLinearGradient after a canvas is gone; a JS TypeError in the caller is the intended outcome there rather than a native fault, but it may be worth documenting.

…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.
@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 08ee3fa9-5f00-45ee-b02f-aae521f517d7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.
@triniwiz
triniwiz merged commit 18b0628 into v3-v8 Sep 28, 2026
10 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants