Skip to content

fix(android): Destroy the session lifecycle owner on reconfigure - #4186

Open
vgorte wants to merge 1 commit into
margelo:mainfrom
vgorte:fix/android-session-lifecycle-owner-leak
Open

fix(android): Destroy the session lifecycle owner on reconfigure#4186
vgorte wants to merge 1 commit into
margelo:mainfrom
vgorte:fix/android-session-lifecycle-owner-leak

Conversation

@vgorte

@vgorte vgorte commented Sep 7, 2026

Copy link
Copy Markdown

Follow-up to #4185. Found while profiling #4180.

Problem

Every <Camera> mount on Android leaks the session's lifecycle owner.

HybridCameraSession creates one CustomLifecycle in its constructor. It registers itself with the ReactApplicationContext and is the LifecycleOwner passed to bindToLifecycle. CameraX keeps the LifecycleCamera and CameraUseCaseAdapter for an owner until that owner reaches ON_DESTROY; unbindAll() only detaches use cases. The only path to ON_DESTROY is HybridCameraSession.dispose(), and the useCamera teardown calls stop() + configure([]), never dispose().

So every mount leaves one CustomLifecycle + LifecycleCamera + CameraUseCaseAdapter rooted in ReactContext.mLifecycleEventListeners until 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 so start() before configure() 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 Key per binding in LifecycleCameraProviderImpl until provider shutdown. It holds only identityHashCode(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 on am dumpheap after 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):

Class before, N=30 before, N=200 after, N=30 after, N=200
CustomLifecycle 31 201 0 0
LifecycleCamera 31 201 0 0
CameraUseCaseAdapter 31 201 0 0

Before 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 by configure([]); 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 for HybridCameraSession, HybridCameraController and the three classes above, draining to 0 with more GC rounds). Such a controller still points at its destroyed owner through HybridCameraController.camera and LifecycleCamera.mLifecycleOwner, which is the only path left:

Shortest path to GC root after the fix (shark, 200 cycles, before the last GC rounds)
GC Root: Global variable in native code
├─ com.margelo.nitro.camera.hybrids.HybridCameraController instance
│    ↓ HybridCameraController.camera
├─ androidx.camera.lifecycle.LifecycleCamera instance
│    ↓ LifecycleCamera.mLifecycleOwner
╰→ com.margelo.nitro.camera.utils.CustomLifecycle instance

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 removed in visioncamera.session.harness.ts: configure, start, configure([]), stop, configure the same connection again, start, and wait for the second onStarted. 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 because AVCaptureSession keeps running through a reconfigure and emits no second start event; the heap counts above cover it. The retention itself is not observable from JS.

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

@vgorte is attempting to deploy a commit to the Margelo Team on Vercel.

A member of the Team first needs to authorize it.

@vgorte
vgorte force-pushed the fix/android-session-lifecycle-owner-leak branch from 0c3ba76 to f4a87e9 Compare September 7, 2026 15:31
@vgorte vgorte changed the title fix: Destroy the session lifecycle owner on reconfigure on Android fix(android): Destroy the session lifecycle owner on reconfigure Sep 7, 2026
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.

1 participant