#50294·expo

[expo-media-library][iOS] `getAssetInfoAsync` on a Live Photo settles its promise twice and traps with "Cannot settle a promise more than once"

Author: henriquegpbCreated Sep 17, 2026Updated Sep 17, 2026
Labels📦 expo-media-libraryneeds reviewsdk-56contributor: externalseverity: medium

Summary

MediaLibrary.getAssetInfoAsync() hard-crashes the app (EXC_BREAKPOINT / SIGTRAP) when the asset is a Live Photo.

handleLivePhoto calls PHImageManager.requestLivePhoto(…) with a PHLivePhotoRequestOptions whose deliveryMode is left at its default, .opportunistic. Apple documents that this mode invokes the result handler more than once — a degraded placeholder first, then the full-quality Live Photo. The handler resolves promise on every invocation, so the promise is settled twice and JavaScriptPromise.resolve trips its preconditionFailure("Cannot settle a promise more than once").

This is not recoverable from JS: it's a Swift runtime trap, so there is no redbox, no rejected promise, and no try/catch that can catch it — the process dies.

Source

https://github.com/expo/expo/blob/main/packages/expo-media-library/ios/MediaLibraryModule.swift#L383-L399

private func handleLivePhoto(asset: PHAsset, shouldDownloadFromNetwork: Bool, result: [String: Any?], promise: Promise) {
  let livePhotoOptions = PHLivePhotoRequestOptions()
  livePhotoOptions.isNetworkAccessAllowed = shouldDownloadFromNetwork
  // deliveryMode is never set --> defaults to .opportunistic
  var updatedResult = result
    updatedResult["pairedVideoAsset"] = nil

  PHImageManager.default()
    .requestLivePhoto(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: livePhotoOptions) { livePhoto, _ in
    //                                                                        the `info` dict is discarded ^^^
    guard let livePhoto = livePhoto,
      let videoResource = PHAssetResource.assetResources(for: livePhoto)
      .first(where: { $0.type == .pairedVideo }) else {
      promise.resolve(updatedResult)   // (a)
      return
    }
    self.writePairedVideoAsset(videoResource: videoResource, asset: asset, result: updatedResult, promise: promise)  // (b) also resolves
    }
}

Every pass through the handler reaches promise.resolve via (a) or (b). With .opportunistic there is more than one pass, so the promise settles more than once.

PHImageResultIsDegradedKey, which is what tells you a pass is the temporary low-quality one, is discarded by the _ in the closure signature.

Why this is .opportunistic

PHImageManager.h in the iOS SDK, on requestLivePhoto specifically:

Requests a live photo representation of the asset. With PHImageRequestOptionsDeliveryModeOpportunistic (or if no options are specified), the resultHandler block may be called more than once (the first call may occur before the method returns). The PHImageResultIsDegradedKey key in the result handler's info parameter indicates when a temporary low-quality live photo is provided.

and on deliveryMode:

delivery mode. Defaults to PHImageRequestOptionsDeliveryModeOpportunistic

PHLivePhotoRequestOptions() leaves deliveryMode at 0 == .opportunistic, so the default construction in handleLivePhoto is exactly the documented multi-callback case.

Crash report

Trapping thread is the JS thread (app binary name redacted):

Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Exception Codes: 0x0000000000000001, 0x000000010693f2d4
Termination Reason: SIGNAL 5 Trace/BPT trap: 5

Thread 15 Crashed:
0   ExpoModulesJSI   Swift runtime failure: precondition failure + 0
1   ExpoModulesJSI   JavaScriptPromise.resolve<A>(_:) + 540 (JavaScriptPromise.swift:81)
2   <App>            closure #1 in closure #1 in AsyncFunctionDefinition.build(appContext:) + 236 (AsyncFunctionDefinition.swift:195)
3   <App>            partial apply for closure #1 in AsyncFunctionDefinition.call(_:this:arguments:callback:) + 36
4   <App>            closure #1 in Promise.tryResolve<A>(_:dynamicType:) + 292 (Promise.swift:86)
5   ExpoModulesJSI   thunk for @callee_guaranteed @Sendable () -> (@error @owned Error) + 4
6   ExpoModulesJSI   partial apply for thunk for @callee_guaranteed @Sendable () -> (@error @owned Error) + 20
7   ExpoModulesJSI   closure #1 in JavaScriptRuntime.schedule(priority:_:) + 160
8   ExpoModulesJSI   thunk for @escaping @callee_guaranteed () -> () + 28
9   <App>            facebook::react::Task::execute(facebook::jsi::Runtime&, bool) + 240
...
33  <App>            +[RCTJSThreadManager runRunLoop] + 212 (RCTJSThreadManager.mm:102)

JavaScriptPromise.swift:81 is the preconditionFailure("Cannot settle a promise more than once") guard in expo-modules-jsi.

Device: iPhone 17 Pro (iPhone17,1), iOS 26.7, release build via TestFlight.

Minimal reproducible example

I don't have a standalone repo to link — this came out of a production crash report, and the defect is fully determined by the source above plus Apple's documented contract for .opportunistic, so a repro adds little beyond the three lines below. I'm happy to put one together if a maintainer wants it.

On any bare/dev-client project with expo-media-library and photo-library permission granted:

import * as MediaLibrary from 'expo-media-library';

await MediaLibrary.requestPermissionsAsync();
const { assets } = await MediaLibrary.getAssetsAsync({ first: 50, mediaType: ['photo'] });

// Any asset whose mediaSubtypes include `livePhoto` — i.e. a normal photo shot
// with Live Photos on, which is the camera default.
const live = assets.find((a) => a.mediaSubtypes?.includes('livePhoto'));

await MediaLibrary.getAssetInfoAsync(live); // <-- process dies here

Steps to reproduce

iOS, release or debug, dev client or standalone (not Expo Go — needs the native module). npm.

  1. Take a photo with Live Photos enabled (the camera default), or pick any existing Live Photo.
  2. Grant photo-library permission.
  3. Call MediaLibrary.getAssetInfoAsync(asset) on that Live Photo.

Expected: the promise resolves once with the asset info (including pairedVideoAsset). Actual: the app crashes with EXC_BREAKPOINT on the JS thread. Nothing is catchable from JS.

It is intermittent in the sense that it depends on whether Photos decides to deliver a degraded pass first — which in practice it does whenever the full-quality Live Photo is not already resident (iCloud assets, cold Photos cache, large libraries).

Suggested fix

Ask for a single delivery, and ignore any degraded pass defensively:

   private func handleLivePhoto(asset: PHAsset, shouldDownloadFromNetwork: Bool, result: [String: Any?], promise: Promise) {
     let livePhotoOptions = PHLivePhotoRequestOptions()
     livePhotoOptions.isNetworkAccessAllowed = shouldDownloadFromNetwork
+    // `.opportunistic` (the default) invokes the result handler more than once: a degraded
+    // placeholder first, then the full-quality Live Photo. That settles `promise` twice.
+    livePhotoOptions.deliveryMode = .highQualityFormat
+
     var updatedResult = result
-      updatedResult["pairedVideoAsset"] = nil
+    updatedResult["pairedVideoAsset"] = nil
 
     PHImageManager.default()
-      .requestLivePhoto(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: livePhotoOptions) { livePhoto, _ in
-      guard let livePhoto = livePhoto,
-        let videoResource = PHAssetResource.assetResources(for: livePhoto)
-        .first(where: { $0.type == .pairedVideo }) else {
-        promise.resolve(updatedResult)
-        return
-      }
-      self.writePairedVideoAsset(videoResource: videoResource, asset: asset, result: updatedResult, promise: promise)
+      .requestLivePhoto(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: livePhotoOptions) { livePhoto, info in
+        // Belt and braces: never settle on a degraded pass, in case Photos delivers one anyway.
+        if let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool, isDegraded {
+          return
+        }
+        guard let livePhoto = livePhoto,
+          let videoResource = PHAssetResource.assetResources(for: livePhoto)
+          .first(where: { $0.type == .pairedVideo }) else {
+          promise.resolve(updatedResult)
+          return
+        }
+        self.writePairedVideoAsset(videoResource: videoResource, asset: asset, result: updatedResult, promise: promise)
       }
   }

I'm running this as a patch-package patch and it resolves the crash. Happy to open a PR with it if that's useful.

Note on other call sites

The same "handler may fire more than once" contract applies to PHImageManager requests elsewhere in this module; resolveVideo's requestAVAsset uses PHVideoRequestOptions whose delivery mode is .automatic rather than opportunistic, so it looks fine, but it may be worth a sweep now that a double-settle is fatal rather than a no-op.

Environment

  expo-env-info 2.1.0 environment info:
    System:
      OS: macOS 26.5
      Shell: 5.9 - /bin/zsh
    Binaries:
      Node: 24.1.0 - /opt/homebrew/bin/node
      npm: 11.3.0 - /opt/homebrew/bin/npm
      Watchman: 2026.03.30.00 - /opt/homebrew/bin/watchman
    Managers:
      CocoaPods: 1.16.2 - /opt/homebrew/bin/pod
    SDKs:
      iOS SDK:
        Platforms: DriverKit 25.5, iOS 26.5, macOS 26.5, tvOS 26.5, visionOS 26.5, watchOS 26.5
    IDEs:
      Android Studio: 2025.1 AI-251.25410.109.2511.13665796
      Xcode: 26.6/17F113 - /usr/bin/xcodebuild
    npmPackages:
      expo: ~56.0.8 => 56.0.8
      expo-router: ~56.2.8 => 56.2.8
      expo-updates: ~56.0.26 => 56.0.26
      react: 19.2.3 => 19.2.3
      react-native: 0.85.3 => 0.85.3
    npmGlobalPackages:
      eas-cli: 20.5.1
      expo-cli: 6.3.12
    Expo Workflow: bare

[email protected]. The affected code is identical on main as of today.