Skip to content

fix(android): prevent CameraX executor starvation - #4218

Merged
mrousavy merged 1 commit into
margelo:mainfrom
ismaelsousa:fix/android-camera-executor-starvation
Oct 14, 2026
Merged

mrousavy merged 1 commit into
margelo:mainfrom
ismaelsousa:fix/android-camera-executor-starvation

Conversation

@ismaelsousa

@ismaelsousa ismaelsousa commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Prevent long-running Android frame worklets from starving CameraX's internal ImageAnalysis background work.

This removes the explicit setBackgroundExecutor(executor) configuration from both the regular frame output and the depth frame output. Analyzer callbacks and the HybridNativeThread continue to share their existing single-thread IdentifiableExecutor, preserving serialized frame processing, while CameraX can use its own performance-tuned background executor for internal pipeline work.

Problem

HybridFrameOutput previously used the same IdentifiableExecutor("com.margelo.camera.frame") for three different responsibilities:

  1. CameraX ImageAnalysis internal background work via ImageAnalysis.Builder.setBackgroundExecutor(...).
  2. Serialized analyzer callbacks via imageAnalysis.setAnalyzer(...).
  3. The HybridNativeThread used by the frame worklet runtime.

A synchronous frame worklet therefore blocked not only the next analyzer callback, which is expected, but also CameraX's own background tasks on the same single-thread queue.

In a Perfetto trace with an approximately two-second synchronous worklet, CameraX tasks queued behind the worklet for up to approximately 1.95 seconds. This stalled the image-analysis pipeline and caused the camera preview to visibly freeze. Separating CameraX's background work from the analyzer/worklet executor kept the preview smooth under the same workload.

User-visible impact

This executor starvation is the cause of the preview stall: while the synchronous worklet occupies the shared executor, CameraX cannot promptly run the internal tasks needed to receive and discard image-analysis frames. The resulting pipeline delay makes the preview freeze for roughly the duration of the blocking worklet, even though preview rendering itself is not running on the worklet thread.

Video

A before-and-after video demonstrating the preview stall and the behavior after this fix will be added here.

Before After
cut.mov
after.mov

How to reproduce on Android

Add a frame output that intentionally blocks its worklet thread for two seconds:

const frameOutput = useFrameOutput({
  pixelFormat: 'yuv',
  dropFramesWhileBusy: true,
  onFrame(frame) {
    'worklet'

    try {
      // Simulate an expensive synchronous frame processor.
      const blockUntil = Date.now() + 2_000
      while (Date.now() < blockUntil) {}
    } finally {
      frame.dispose()
    }
  },
})

return (
  <Camera
    device={device}
    isActive
    outputs={[frameOutput]}
    style={StyleSheet.absoluteFill}
  />
)

⚠️ This code intentionally blocks the frame processor and should only be used to reproduce the issue.

Root cause

CameraX documents that the KEEP_ONLY_LATEST image-analysis strategy uses an internal executor to receive and discard stale frames. It also notes that a caller-provided background executor must respond quickly for smooth operation.

The executor provided here cannot guarantee that: it intentionally runs analyzer and worklet tasks serially, and user worklets can execute synchronously for an arbitrary amount of time. Reusing it for CameraX's internal work lets application processing starve the camera pipeline.

Changes

  • Stop overriding the CameraX background executor in HybridFrameOutput.
  • Apply the same correction to HybridDepthFrameOutput, which had the identical executor coupling.
  • Keep setAnalyzer(executor) unchanged, so analyzer callbacks remain serialized with the worklet runtime as before.
  • Keep the existing analyzer cleanup unchanged (clearAnalyzer() during disposal).

No tracing or diagnostic instrumentation is included in this change.

Why use CameraX's default executor?

Leaving the background executor unset is the smallest and safest separation:

  • CameraX supplies its performance-tuned high-priority executor for internal image-analysis work.
  • The library does not need to allocate, own, or shut down an additional executor.
  • CameraX retains responsibility for that executor's lifecycle.
  • Existing analyzer/worklet ordering and serialization remain unchanged.

Before and after

Before:

CameraX background tasks ─┐
Analyzer callbacks ───────┼─> one single-thread executor
Frame worklet runtime ────┘

After:

CameraX background tasks ───> CameraX-managed executor

Analyzer callbacks ───────┐
Frame worklet runtime ────┴─> existing single-thread executor

Verification

  • ./gradlew :react-native-vision-camera:compileDebugKotlin --console=plain
    • Passed.
  • ./gradlew :react-native-vision-camera:testDebugUnitTest --console=plain
    • Passed; the module currently has no unit-test sources, so the test task reports NO-SOURCE.
  • git diff --check
    • Passed.
  • Audited the other Android ImageAnalysis output paths:
    • Depth output had the same coupling and is fixed here.
    • Barcode scanning does not override CameraX's background executor and needs no change.

@vercel

vercel Bot commented Oct 13, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@mrousavy

Copy link
Copy Markdown
Member

Wow nice catch! Yes i think this is correct thanks

@mrousavy
mrousavy merged commit 1a69f29 into margelo:main Oct 14, 2026
4 of 5 checks passed
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.

2 participants