Skip to content

fix: [Apple] notify the texture registry on the main thread - #1769

Closed
luutruong wants to merge 2 commits into
juliansteenbakker:developfrom
luutruong:fix/texture-frame-available-main-thread
Closed

fix: [Apple] notify the texture registry on the main thread#1769
luutruong wants to merge 2 commits into
juliansteenbakker:developfrom
luutruong:fix/texture-frame-available-main-thread

Conversation

@luutruong

@luutruong luutruong commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

MobileScannerPlugin.captureOutput(_:didOutput:from:) runs on the plugin's own
sampleBufferQueue (com.juliansteenbakker.mobile_scanner.captureOutputQueue) and calls
registry.textureFrameAvailable(textureId) directly from that queue.

The Flutter engine, however, is destroyed synchronously on the main thread
(-[FlutterViewController appOrSceneWillTerminate]-[FlutterEngine destroyContext]
Shell::~Shell). If a frame callback is in flight on the capture queue at that moment, it calls
-[FlutterEngine textureFrameAvailable:] on an engine that is already mid-dealloc, with a
partially destroyed ShellEXC_BAD_ACCESS.

FlutterTextureRegistryRelay forwards to a weak parent, but a weak reference is only cleared
after dealloc completes, so it does not protect against a call that arrives during teardown.

We see this in production on physical iPhones, release builds, on the order of hundreds of crashes
per day for a screen that keeps a scanner running.

Crash stack (redacted)

Crashed: com.juliansteenbakker.mobile_scanner.captureOutputQueue
0  Flutter    -[FlutterEngine textureFrameAvailable:] + 84 (ref_ptr.h:84)
1  Flutter    -[FlutterTextureRegistryRelay textureFrameAvailable:] + 34
2  Runner     specialized MobileScannerPlugin.captureOutput(_:didOutput:from:) + 166 (MobileScannerPlugin.swift:166)
3  Runner     @objc MobileScannerPlugin.captureOutput(_:didOutput:from:)
4  AVFCapture -[AVCaptureVideoDataOutput _processSampleBuffer:] + 300
5  AVFCapture __47-[AVCaptureVideoDataOutput _updateRemoteQueue:]_block_invoke + 88
...

com.apple.main-thread
7  Flutter    std::shared_ptr<impeller::Texture>::~shared_ptr()
8  Flutter    impeller::DlImageImpeller::~DlImageImpeller()
13 Flutter    dart::Isolate::Shutdown() + 2650
14 Flutter    Dart_ShutdownIsolate + 1167
17 Flutter    flutter::Engine::~Engine() + 284
20 Flutter    flutter::Shell::~Shell() + 403
22 Flutter    -[FlutterEngine destroyContext] + 44
23 Flutter    -[FlutterViewController appOrSceneWillTerminate] + 1089 (FlutterViewController.mm:1089)
29 UIKitCore  -[UIApplication _terminateWithStatus:] + 236

Steps to reproduce

  1. Release or profile build on a physical iPhone.
  2. Show a MobileScanner widget with the camera running.
  3. Swipe-kill the app from the app switcher while the preview is live.
  4. Repeat ~20×. It is a race, so it does not fire on every attempt.

Why a Dart-side workaround cannot fix this

Stopping the camera from didChangeAppLifecycleState(inactive/paused/detached) does not help: it
needs a platform-channel round trip, while termination is synchronous inside the notification
callout and the Dart isolate is shut down in that same call chain (Dart_ShutdownIsolate is visible
in the stack above). The stop message is never delivered.

Stopping the session natively is not sufficient on its own either — captureSession.stopRunning()
does not drain a callback that is already executing on the capture queue.

Fix

Hop the texture notification to the main queue. Since engine teardown also runs on the main thread,
the queue itself serializes the two, so correctness no longer depends on notification or
plugin-detach ordering. A block enqueued after the engine is gone finds the relay's parent already
nil and becomes a no-op, and the textureId != nil re-check drops frames whose texture was released
while the block was queued.

Barcode analysis stays off the main thread exactly as before — only the registry notification moves,
which just marks the texture dirty and schedules a frame.

Additionally, detachFromEngineForRegistrar: is now implemented to release the camera and texture
deterministically when the engine detaches the plugin (defense in depth; the main-queue hop is what
actually closes the race).

Not touched: the separate latestBuffer / copyPixelBuffer() interaction with the raster thread —
out of scope for this fix.

Notes

@luutruong

Copy link
Copy Markdown
Contributor Author

Bug reported: #1768

@luutruong
luutruong force-pushed the fix/texture-frame-available-main-thread branch from 2f9d9a4 to 228e45b Compare August 3, 2026 04:50

@navaronbracke navaronbracke left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, although this will need a patch version bump and changelog entry

@codecov-commenter

codecov-commenter commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.40%. Comparing base (3d1c1f9) to head (f8d849e).

Additional details and impacted files
@@           Coverage Diff            @@
##           develop    #1769   +/-   ##
========================================
  Coverage    60.40%   60.40%           
========================================
  Files           48       48           
  Lines         1225     1225           
========================================
  Hits           740      740           
  Misses         485      485           
Flag Coverage Δ
unittests 60.40% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@navaronbracke

Copy link
Copy Markdown
Collaborator

@luutruong Friendly ping for the patch version and changelog entry

@luutruong

Copy link
Copy Markdown
Contributor Author

@luutruong Friendly ping for the patch version and changelog entry

Thank you. I've run this patch on my app in production. So far, crash is gone.

luutruong and others added 2 commits August 12, 2026 14:33
`captureOutput(_:didOutput:from:)` runs on `sampleBufferQueue` and called
`registry.textureFrameAvailable` directly from that queue. The Flutter engine is
torn down synchronously on the main thread, so an in-flight frame callback could
call into `-[FlutterEngine textureFrameAvailable:]` while the engine was already
mid-`dealloc`, crashing with EXC_BAD_ACCESS.

`FlutterTextureRegistryRelay` holds its parent weakly, but a weak reference is
only cleared after `dealloc` completes, so it does not guard against a call that
arrives during teardown.

Hop the notification to the main queue so it is serialized against the teardown
by the queue itself, rather than relying on notification or plugin-detach
ordering, and drop frames whose texture was released while the block was queued.
Also implement `detachFromEngineForRegistrar:` to release the camera and texture
deterministically when the engine detaches the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@luutruong
luutruong force-pushed the fix/texture-frame-available-main-thread branch from 228e45b to f8d849e Compare August 12, 2026 07:34
@navaronbracke

Copy link
Copy Markdown
Collaborator

@juliansteenbakker do we merge this into 7.4.1? Since I see that release is still pending

@juliansteenbakker

Copy link
Copy Markdown
Owner

Thanks for the PR! I've landed the fix in #1797, which takes the same main queue approach but coalesces the notification so we don't queue a block per frame, and stops the camera on willTerminate rather than relying on the relay's weak parent, which doesn't help here since the engine object outlives its shell.

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.

[iOS/macOS] captureOutput calls textureFrameAvailable from a background queue → use-after-free crash on engine teardown

4 participants