Skip to content

[iOS] Crash: AVPlayer.replaceCurrentItem(with:) called from background threads in HybridVideoPlayer #4844

@RaiN3772

Description

@RaiN3772

Summary

HybridVideoPlayer.swift calls AVPlayer.replaceCurrentItem(with:) from Task.detached / Promise.async background contexts in 5 locations. This violates Apple's requirement that AVPlayer API access be serialized on the main queue, causing crashes under concurrent access (e.g., rapid scrolling through a video feed).

Environment

  • react-native-video: v7.0.0-alpha.12 (also confirmed on latest master as of 2026-02-20)
  • React Native: 0.81.5
  • iOS: 18.6.2
  • Device: iPhone 11 (iPhone12,1)
  • Architecture: New Architecture (Fabric + NitroModules)

Crash Report

Thread 13 (crashed):

Thread 13 Crashed:
0   libswiftCore.dylib  swift_unexpectedError
1   ReactNativeVideo    HybridVideoPlayer.swift:316
    closure #1 in HybridVideoPlayer.replaceSourceAsync(source:)
2   ReactNativeVideo    HybridVideoPlayer.swift:0

Exception: Swift runtime failure: unhandled C++ / Objective-C exception
(EXC_BREAKPOINT / SIGTRAP)

Thread 0 (main) at the time of crash:

Thread 0:
0   libsystem_kernel.dylib  mach_msg2_trap
...
12  UIKitCore  -[UIScrollView(UIScrollViewInternal) _notifyDidScroll]
13  UIKitCore  -[UIScrollView setContentOffset:animated:]

The crash occurs because replaceSourceAsync calls AVPlayer.replaceCurrentItem(with:) on Thread 13 (a Task.detached worker thread) while the main thread is processing UIScrollView scroll events that read from the same AVPlayer instance via KVO observers.

Reproduction

  1. Use VideoPlayer with initializeOnCreation: false in a pool of 3 players
  2. Bind players to a vertical paging FlatList (TikTok-style feed)
  3. Call player.replaceSourceAsync(...) when the active index changes
  4. Scroll rapidly back and forth through the feed
  5. Crash — typically within 15-30 seconds of aggressive scrolling

Example Implementation (JS-side)

Below is a minimal reproduction of the player pool pattern that triggers this crash. This is a standard approach for short-form video feeds (TikTok, Reels, Shorts) — 3 players are rotated via modulo arithmetic so that the current, previous, and next videos each get a dedicated player:

import { useCallback, useEffect, useRef } from "react";
import { InteractionManager } from "react-native";
import { type VideoConfig, VideoPlayer, VideoView } from "react-native-video";

// ── Pool setup ────────────────────────────────────────────────────────
// Deferred-init: creates a native player without buffering anything.
const POOL_INIT_CONFIG = { uri: "about:blank", initializeOnCreation: false };

const BUFFER_CONFIG: VideoConfig["bufferConfig"] = {
  bufferForPlaybackMs: 500,
  backBufferDurationMs: 10_000,
  preferredForwardBufferDurationMs: 10_000,
};

type PoolSlot = 0 | 1 | 2;
type PlayerTuple = [VideoPlayer, VideoPlayer, VideoPlayer];

/** Positive modulo — maps any feed index to one of the 3 pool slots. */
const mod3 = (n: number): PoolSlot => (((n % 3) + 3) % 3) as PoolSlot;

// ── Hook ──────────────────────────────────────────────────────────────

export function usePlayerPool() {
  const playersRef = useRef<PlayerTuple | null>(null);
  const sourcesRef = useRef<(string | null)[]>([null, null, null]);
  const activeIndexRef = useRef(-1);

  // Lazily create 3 looping players
  const ensurePlayers = useCallback((): PlayerTuple => {
    if (playersRef.current) return playersRef.current;
    const tuple: PlayerTuple = [
      new VideoPlayer(POOL_INIT_CONFIG),
      new VideoPlayer(POOL_INIT_CONFIG),
      new VideoPlayer(POOL_INIT_CONFIG),
    ];
    tuple.forEach((p) => (p.loop = true));
    playersRef.current = tuple;
    return tuple;
  }, []);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      playersRef.current?.forEach((p) => {
        try { p.release(); } catch {}
      });
      playersRef.current = null;
    };
  }, []);

  // Preload an adjacent (prev/next) slot
  const preloadSlot = useCallback(
    (players: PlayerTuple, idx: number, getUrl: (i: number) => string | null) => {
      const slot = mod3(idx);
      const player = players[slot];
      const url = getUrl(idx);
      if (!url || url === sourcesRef.current[slot]) return;

      sourcesRef.current[slot] = url;
      player.pause();
      // This triggers the crash inside HybridVideoPlayer.replaceSourceAsync()
      // because the native side calls AVPlayer.replaceCurrentItem(with:) from
      // a Task.detached background thread.
      player
        .replaceSourceAsync({ uri: url, bufferConfig: BUFFER_CONFIG })
        .then(() => { try { player.preload(); } catch {} })
        .catch(() => {});
    },
    [],
  );

  // Activate a feed index — play current, preload prev/next
  const activateIndex = useCallback(
    (index: number, getUrl: (i: number) => string | null) => {
      const players = ensurePlayers();
      activeIndexRef.current = index;

      // Phase 1 — current slot (this frame)
      const slot = mod3(index);
      const player = players[slot];
      const url = getUrl(index);

      if (!url) { player.pause(); return; }

      if (url === sourcesRef.current[slot]) {
        // Already preloaded — instant playback
        player.currentTime = 0;
        player.play();
      } else {
        // New source — replaceSourceAsync then play
        sourcesRef.current[slot] = url;
        player.pause();
        const captured = index;
        player
          .replaceSourceAsync({ uri: url, bufferConfig: BUFFER_CONFIG })
          .then(() => {
            if (activeIndexRef.current !== captured) return;
            player.play();
          })
          .catch(() => {});
      }

      // Phase 2 — adjacent slots (deferred until scroll settles)
      InteractionManager.runAfterInteractions(() => {
        if (activeIndexRef.current !== index) return;
        preloadSlot(players, index - 1, getUrl);
        preloadSlot(players, index + 1, getUrl);
      });
    },
    [ensurePlayers, preloadSlot],
  );

  // Get player for a visible index (only current ± 1)
  const getPlayer = useCallback((index: number): VideoPlayer | null => {
    const players = playersRef.current;
    if (!players || activeIndexRef.current < 0) return null;
    if (Math.abs(index - activeIndexRef.current) > 1) return null;
    return players[mod3(index)];
  }, []);

  return { activateIndex, getPlayer };
}

// ── Usage in a FlatList ───────────────────────────────────────────────
//
//   const { activateIndex, getPlayer } = usePlayerPool();
//
//   // On viewable items change (debounced):
//   activateIndex(newIndex, (i) => feedData[i]?.videoUrl ?? null);
//
//   // In renderItem:
//   const player = getPlayer(index);
//   return player ? <VideoView player={player} style={{ flex: 1 }} /> : null;

Key points about this JS code:

  • Each replaceSourceAsync call targets a different pool slot (guaranteed by mod3) — there are no concurrent calls on the same player
  • Source deduplication (sourcesRef) avoids redundant replaceSourceAsync calls
  • Adjacent preloading is deferred via InteractionManager.runAfterInteractions
  • All promises are properly .catch()-ed
  • Players are properly release()-d on unmount

Despite this correct usage, the crash occurs because the native implementation dispatches AVPlayer.replaceCurrentItem(with:) from Task.detached background threads inside HybridVideoPlayer.swift.

Root Cause Analysis

Apple's Threading Requirement

From WWDC 2014 Session 503 — Mastering Modern Media Playback:

"AVFoundation serializes the access to AVPlayer and PlayerItem on the main queue. It's safe to access and register and unregister for observers for these objects on the main queue."

Additionally, iOS 18 introduced stricter runtime enforcement of this requirement — developers report "Incorrect actor executor assumption" crashes when calling replaceCurrentItem from non-main contexts, even when using @MainActor.

The Bug

HybridVideoPlayer is a plain class (not @MainActor-isolated, not an actor). Five methods call AVPlayer.replaceCurrentItem(with:) from background threads:

1. init (line ~52)

Task {
  if source.config.initializeOnCreation == true {
    // ...
    self.player.replaceCurrentItem(with: self.playerItem) // ← background thread
  }
}

2. initialize() (line ~194)

return Promise.async { [weak self] in
  // ...
  self.player.replaceCurrentItem(with: self.playerItem) // ← background thread
}

3. release() (line ~209)

func release() {
  // ...
  self.player.replaceCurrentItem(with: nil) // ← caller's thread (may be background)
}

4. preload() (line ~253)

Task.detached(priority: .userInitiated) { [weak self] in
  // ...
  self.player.replaceCurrentItem(with: playerItem) // ← background thread
}

5. replaceSourceAsync() (line ~329) — crash site

Task.detached(priority: .userInitiated) { [weak self] in
  // ...
  self.player.replaceCurrentItem(with: self.playerItem) // ← background thread
}

Meanwhile, VideoPlayerObserver registers KVO observers on the main queue and receives callbacks on the main queue. When replaceCurrentItem mutates currentItem from a background thread while KVO callbacks fire on the main thread, AVFoundation hits a thread-safety violation.

Proposed Fix

Wrap all AVPlayer.replaceCurrentItem(with:) calls in await MainActor.run { } (for async contexts) or dispatch to the main thread (for synchronous contexts):

For async call sites (init, initialize, preload, replaceSourceAsync):

// Before (crashes):
self.player.replaceCurrentItem(with: self.playerItem)

// After (safe):
await MainActor.run {
  self.player.replaceCurrentItem(with: self.playerItem)
}

For synchronous call sites (release):

// Before (crashes if called from background):
self.player.replaceCurrentItem(with: nil)

// After (safe):
let playerRef = self.player
if Thread.isMainThread {
  playerRef.replaceCurrentItem(with: nil)
} else {
  DispatchQueue.main.async {
    playerRef.replaceCurrentItem(with: nil)
  }
}

Alternative (long-term)

Mark HybridVideoPlayer as @MainActor to enforce main-thread isolation at the type level. This would require all callers to dispatch appropriately but would prevent future threading regressions.

Relationship to PR #4828

PR #4828 (merged 2026-02-03, included in v7.0.0-beta.6) fixes a different crash in release() related to KVO observer removal ordering:

  • PR fix(ios): prevent KVO crash in HybridVideoPlayer.release() #4828 fixes: KVO crash Cannot remove an observer for the key path 'currentItem.status' caused by setting playerItem = nil and playerObserver = nil while observers were still registered.
  • This issue: replaceCurrentItem(with:) called from background threads across all 5 call sites, causing thread-safety violations in AVFoundation.

These are separate issues. PR #4828 addresses KVO lifecycle ordering; this issue addresses thread confinement of AVPlayer API calls.

Diff

--- a/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift
+++ b/ios/hybrids/VideoPlayer/HybridVideoPlayer.swift
@@ -49,7 +49,9 @@
           self.playerItem = try await self.sourceLoader.load {
             try await self.initializePlayerItem()
           }
-          self.player.replaceCurrentItem(with: self.playerItem)
+          await MainActor.run {
+            self.player.replaceCurrentItem(with: self.playerItem)
+          }
         } catch {
           // Ignore cancellation errors during initialization
         }
@@ -189,7 +191,9 @@
         self.playerItem = try await self.sourceLoader.load {
           try await self.initializePlayerItem()
         }
-        self.player.replaceCurrentItem(with: self.playerItem)
+        await MainActor.run {
+          self.player.replaceCurrentItem(with: self.playerItem)
+        }
       } catch {
         if error is CancellationError {
           throw PlayerError.cancelled.error()
@@ -202,7 +206,14 @@
   func release() {
     sourceLoader.cancelSync()
     NowPlayingInfoCenterManager.shared.removePlayer(player: player)
-    self.player.replaceCurrentItem(with: nil)
+    let playerRef = self.player
+    if Thread.isMainThread {
+      playerRef.replaceCurrentItem(with: nil)
+    } else {
+      DispatchQueue.main.async {
+        playerRef.replaceCurrentItem(with: nil)
+      }
+    }
     self.playerItem = nil

     if let source = self.source as? HybridVideoPlayerSource {
@@ -239,7 +250,9 @@
         }
         self.playerItem = playerItem

-        self.player.replaceCurrentItem(with: playerItem)
+        await MainActor.run {
+          self.player.replaceCurrentItem(with: playerItem)
+        }
         promise.resolve(withResult: ())
       } catch {
         if error is CancellationError {
@@ -313,7 +326,9 @@
         self.playerItem = try await self.sourceLoader.load {
           try await self.initializePlayerItem()
         }
-        self.player.replaceCurrentItem(with: self.playerItem)
+        await MainActor.run {
+          self.player.replaceCurrentItem(with: self.playerItem)
+        }
         NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
         promise.resolve(withResult: ())
       } catch {

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    To Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions