Skip to content

fix(🤖): release the Java Surface wrapper on the non-opaque path - #4046

Open
kushal-matiks wants to merge 1 commit into
Shopify:mainfrom
kushal-matiks:fix/android-surface-leak
Open

fix(🤖): release the Java Surface wrapper on the non-opaque path#4046
kushal-matiks wants to merge 1 commit into
Shopify:mainfrom
kushal-matiks:fix/android-surface-leak

Conversation

@kushal-matiks

Copy link
Copy Markdown

Fixes #4045

Problem

On Android, every non-opaque <Canvas> leaks one android.view.Surface.

surfaceAvailable builds a Surface to wrap the SurfaceTexture, hands it to ANativeWindow_fromSurface, then drops it with DeleteLocalRef. That only releases the JNI local referenceSurface.release() is never called, so the underlying buffer producer is left to the finalizer and CloseGuard logs:

W System: A resource failed to call Surface.release.

once per Canvas.

surfaceDestroyed() releases _jSurfaceTexture and OpenGLWindowContext releases the ANativeWindow, but nothing owns the intermediate Surface.

Fix

Release the Java wrapper once ANativeWindow_fromSurface has taken its own reference on the producer. The lookup is null-guarded and the call is exception-guarded, so it degrades to the current behaviour rather than throwing if anything is unexpected.

This mirrors the ownership rule the WebGPU path already documents in JniWebGPUView.cpp:

// ANativeWindow_fromSurface acquires a reference; SurfaceInfo releases it
// (via the releaser below) once it is done with the window.

Only the !opaque branch is touched; the opaque branch never creates the wrapper.

Verification

Measured on an app with 6 <Canvas> components (a tab bar of Skia icons), counting Surface.release warnings per launch:

environment before after
Emulator API 37, debug 6 0
Samsung SM-M366B (Mali), API 36, debug 6 0
Samsung SM-M366B, minified release (R8) 6 0

The unpatched release build was measured first as an instrument check — CloseGuard is normally gated on the debuggable flag, so a patched 0 would be meaningless if the unpatched release were also silent. It reported 6, so the 0 is a real measurement.

No regressions across 20 navigations plus 3 background/foreground cycles on the release build:

  • SIGSEGV / SIGABRT / Fatal signal / tombstone / ANR — 0
  • BufferQueue has been abandoned — 0
  • EGL_BAD_SURFACE, EGL_BAD_NATIVE_WINDOW — 0
  • rendering unchanged; canvases still transparent, no visual diff

R8 does not affect the change: GetMethodID(surfaceClass, "release", "()V") resolves android.view.Surface.release from the platform framework at runtime, and minification never rewrites android.*.

Not addressed here

surfaceSizeChanged re-enters surfaceAvailable, which assigns _jSurfaceTexture = env->NewGlobalRef(...) without DeleteGlobalRef on the previous value — a separate JNI global-ref leak on the resize/rotation path. Left alone deliberately: it is a distinct defect, and I could not exercise that path to verify a fix (the app I reproduced on is portrait-locked). Noted in #4045.

Testing done

Verified by rebuilding the Android native library from source and measuring on device via logcat, in both debug and minified release builds, on a physical Mali device and an emulator. I did not run the repo's own example app or test suite locally — happy to do so, or to adjust the approach, if you'd prefer.

surfaceAvailable creates an android.view.Surface to wrap the
SurfaceTexture and passes it to ANativeWindow_fromSurface, but only
drops the JNI local reference afterwards. DeleteLocalRef does not call
Surface.release(), so the underlying buffer producer is left to the
finalizer and CloseGuard logs 'A resource failed to call
Surface.release.' once per Canvas.

ANativeWindow_fromSurface acquires its own reference, so the wrapper can
be released immediately. The WebGPU path already documents this
ownership rule in JniWebGPUView.cpp.
@kushal-matiks

Copy link
Copy Markdown
Author

I have signed the CLA!

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.

Android: every non-opaque Canvas leaks one android.view.Surface

1 participant