Repository navigation
Description
Describe the bug
ImageReaderSurfaceProducer.PerImageReader.onImageAvailableListener calls scheduleEngineFrame() (via onImage()) without first checking flutterJNI.isAttached(). The legacy SurfaceTextureRegistryEntry path guards this same call at line 345:
// SurfaceTextureRegistryEntry.onFrameListener (line 343–354) — has the guard:
if (released || !flutterJNI.isAttached()) {
return;
}
scheduleEngineFrame();// ImageReaderSurfaceProducer.PerImageReader.onImageAvailableListener (line 529–545) — missing the guard:
if (released || closed) { // <-- no isAttached() check
image.close();
return;
}
onImage(r, image); // → scheduleEngineFrame() at line 660The result is a crash with "FlutterJNI is not attached to native" that is reproducible whenever:
- A
SurfaceProducer-backed texture is actively receiving frames from a video decoder (e.g. ExoPlayer / Media3). - The host Activity goes through
onStop()→onDestroy(). - JNI detaches in
onDestroy()before an asyncclearVideoSurface()posted to the ExoPlayer looper is processed — leaving a narrow window where in-flightonImageAvailablecallbacks reachscheduleEngineFrame()on a detached JNI.
Steps to reproduce
- Create a Flutter Android plugin that uses
TextureRegistry.createSurfaceProducer()to back aTexturewidget. - Bind an ExoPlayer (Media3) instance to
producer.surfaceand start playback of a video. - Navigate away from the Activity (Home button, or
finish()) while the video is playing. - The crash fires reliably under load (multiple concurrent players increases the hit rate); it is intermittent with a single player but consistent across sessions.
We observed 149 crash events across 142 users on a single app version before isolating the cause.
Expected results
scheduleEngineFrame() is not called after the JNI has detached, matching the behaviour of SurfaceTextureRegistryEntry.
Actual results
Fatal crash on the main thread:
java.lang.RuntimeException: FlutterJNI is not attached to native.
at io.flutter.embedding.engine.FlutterJNI.ensureAttachedToNative(FlutterJNI.java)
at io.flutter.embedding.engine.FlutterJNI.scheduleFrame(FlutterJNI.java)
at io.flutter.embedding.engine.renderer.FlutterRenderer.scheduleEngineFrame(FlutterRenderer.java:1364)
at io.flutter.embedding.engine.renderer.FlutterRenderer$ImageReaderSurfaceProducer.onImage(FlutterRenderer.java:660)
at io.flutter.embedding.engine.renderer.FlutterRenderer$ImageReaderSurfaceProducer$PerImageReader.lambda$new$0(FlutterRenderer.java:544)
Flutter version
Flutter 3.44.0 • channel stable
Engine • revision 4c525dac5e
Dart 3.12.0
Proposed fix
The safest fix is to add the guard at scheduleEngineFrame() itself (line 1363), which protects all four call sites inside ImageReaderSurfaceProducer (lines 660, 730, 892, 1063) without requiring changes to any inner class:
// FlutterRenderer.java, line 1363
@VisibleForTesting
/* package */ void scheduleEngineFrame() {
+ if (!flutterJNI.isAttached()) return;
flutterJNI.scheduleFrame();
}flutterJNI is already a field of FlutterRenderer at this site. This mirrors the existing guard at line 436 in the same file (the texture-release Runnable).
Alternatively, a narrower fix adds the check inline in PerImageReader.onImageAvailableListener (line 540), mirroring the comment and pattern already present in SurfaceTextureRegistryEntry for the same race:
// FlutterRenderer.java, line 540
- if (released || closed) {
+ if (released || closed || !flutterJNI.isAttached()) {flutterJNI is accessible here through the enclosing FlutterRenderer instance since ImageReaderSurfaceProducer is a non-static inner class.
Additional context
The SurfaceTexture path comment at line 339–342 explicitly acknowledges this class of race ("the callback may still be called by a stale reference after released==true and mNativeView==null") and defends against it with the isAttached() guard. The ImageReader path was introduced later and the same defence was not carried over.
The onImageAvailableListener is dispatched on new Handler(Looper.getMainLooper()) (line 547), so the race window is: Activity onStop() returns → Flutter JNI detaches in onDestroy() → a frame queued in the ImageReader before onStop fires its listener on the next main-looper tick → scheduleEngineFrame() on a detached JNI.
Plugin-level workarounds (calling producer.release() synchronously in onActivityStopped) do close the window via the existing released flag check but require recreating the producer and its texture id on every background/foreground cycle, which is unnecessary overhead. The engine-side fix eliminates the need for such workarounds entirely.
Activity
mbcorona commentedon Jun 22, 2026
Thanks for the detailed analysis and stack trace — this looks like the root cause of #174859, which has the same scheduleEngineFrame → ImageReaderSurfaceProducer.onImage crash chain but was stuck on needs-repro-info. Routing to team-android for investigatioon
shivambhavsar commentedon Jul 7, 2026
Following up, any update on the issue?
bachras commentedon Jul 31, 2026
We are also seeing this crash in production with the identical stack:
Our environment is Flutter
3.44.0(engine4c525dac5e),camera: 0.12.0+1,camera_android_camerax: 0.7.1+2, and CameraX1.5.3. We use a standardFlutterActivitywith no cached engine, add-to-app integration, or custom platform views. Crashlytics shows active CameraX threads while the engine is detaching, consistent with an in-flightImageReadercallback arriving after JNI teardown. Our camera controllers already perform lifecycle cleanup, but asynchronous disposal cannot completely prevent this engine-level race.A guard in
FlutterRenderer.scheduleEngineFrame()appears to be the safest fix because it protects everyImageReaderSurfaceProducercall site:Please consider this another confirmed production occurrence. We can provide device and Android-version distribution details if useful.
fix(android): dispose native player before activity finish to stop ex…
[Android] Do not schedule engine frames on a detached FlutterJNI
[Android] Do not schedule engine frames on a detached FlutterJNI (flu…
github-actions commentedon Sep 16, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of
flutter doctor -vand a minimal reproduction of the issue.