fix(android): Destroy the session lifecycle owner on reconfigure - #4186
Open
vgorte wants to merge 1 commit into
Open
fix(android): Destroy the session lifecycle owner on reconfigure#4186vgorte wants to merge 1 commit into
vgorte wants to merge 1 commit into
Conversation
|
@vgorte is attempting to deploy a commit to the Margelo Team on Vercel. A member of the Team first needs to authorize it. |
vgorte
force-pushed
the
fix/android-session-lifecycle-owner-leak
branch
from
September 7, 2026 15:31
0c3ba76 to
f4a87e9
Compare
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.
Follow-up to #4185. Found while profiling #4180.
Problem
Every
<Camera>mount on Android leaks the session's lifecycle owner.HybridCameraSessioncreates oneCustomLifecyclein its constructor. It registers itself with theReactApplicationContextand is theLifecycleOwnerpassed tobindToLifecycle. CameraX keeps theLifecycleCameraandCameraUseCaseAdapterfor an owner until that owner reachesON_DESTROY;unbindAll()only detaches use cases. The only path toON_DESTROYisHybridCameraSession.dispose(), and theuseCamerateardown callsstop()+configure([]), neverdispose().So every mount leaves one
CustomLifecycle+LifecycleCamera+CameraUseCaseAdapterrooted inReactContext.mLifecycleEventListenersuntil the React context is destroyed, independent of Java or JS garbage collection.Change
One lifecycle owner per binding.
configure()destroys the current owner before it unbinds and creates a fresh one only when it binds a camera;dispose()destroys the current one. The active flag moves to the session sostart()beforeconfigure()keeps working.No change to the JS hooks or iOS. Calling
session.dispose()from the hook instead was considered and rejected: the listener subscriptions are layout effects that re-run against the same session on a StrictMode or Fast Refresh double invoke, and it would leave imperative API users leaking.CameraX still keeps one small
Keyper binding inLifecycleCameraProviderImpluntil provider shutdown. It holds onlyidentityHashCode(owner)plus the camera id, no reference to the owner, so it retains nothing from the chain above.Evidence
Instances retained after N mount/unmount cycles of
<Camera>in the example app, counted with shark onam dumpheapafter repeated rounds of forced JS and Java garbage collection until the counts stopped changing (before the fix the counts are the same with Java GC alone):CustomLifecycleLifecycleCameraCameraUseCaseAdapterBefore the fix the count grows by one per mount and the shortest path to a GC root goes through
ReactContext.mLifecycleEventListeners. After the fix that root is gone. The camera is unmounted at dump time, so every owner has already been destroyed byconfigure([]); what remains between GC rounds is a handful of session/controller pairs whose JS wrappers Hermes has not finalized yet (the same single-digit count forHybridCameraSession,HybridCameraControllerand the three classes above, draining to 0 with more GC rounds). Such a controller still points at its destroyed owner throughHybridCameraController.cameraandLifecycleCamera.mLifecycleOwner, which is the only path left:Shortest path to GC root after the fix (shark, 200 cycles, before the last GC rounds)
The native global is the Nitro JNI reference that keeps the Kotlin controller alive until Hermes finalizes its JS wrapper. Nothing in this chain accumulates per mount.
Test
starts again after all connections were removedinvisioncamera.session.harness.ts: configure, start,configure([]), stop, configure the same connection again, start, and wait for the secondonStarted. That is the hook's teardown followed by a fresh binding on the same session. The other new path, a reconfigure while running that must reopen the camera through an owner created active, has no cross-platform assertion becauseAVCaptureSessionkeeps running through a reconfigure and emits no second start event; the heap counts above cover it. The retention itself is not observable from JS.