Skip to content

[iOS] GfxDeviceWorker crash in Plugin.playerSetup(playerRef:) (poisoned playerRef, use-after-free) #2486

Description

@tmeng0911

Unity version

2022.3.3.62f2

Unity editor platform

macOS

AVPro Video edition

Core

AVPro Video version

3.3.4

Device hardware

iphone all series. (11,12,13,14,15,16)

Which iOS version are you using?

26.5.0

Texture format

BGRA

Audio output

Unity

Any other Media Player component configuration required to reproduce the issue.

No response

Which output component(s) are you using?

No response

Any other component configuration required to reproduce the issue.

No response

The issue

AVPro Video 3.3.4 — iOS GfxDeviceWorker crash in Plugin.playerSetup(playerRef:) (poisoned playerRef, use-after-free)

1. Summary

We are seeing a recurring, hard-to-reproduce iOS crash on the Unity render thread (UnityGfxDeviceWorker) inside AVPro's own native render-event dispatch. A symbolicated stack (matching dSYM available) shows the crash in:

@objc Plugin.playerSetup(playerRef:) + 60
  <- _AVPPluginRenderEventFunction + 88
  <- GfxDevice::InsertCustomMarkerCallbackAndDataWithFlags(...)   // Unity IssuePluginEvent mechanism
  <- GfxDeviceWorker::RunCommand(...)

The playerRef passed into playerSetup is a poisoned/invalid pointer (0x8000000000000000); the fault is a byte read of playerRef + 0x10. This is a classic use-after-free / dangling-handle dereference on the render thread.

We use multiple MediaPlayer instances concurrently and create/destroy/cross-fade them frequently. We have already implemented extensive per-player render-thread synchronization (GPU-fence-gated Close and Release, drain-before-Open), and the crash still occurs. Our analysis indicates the remaining cause is a cross-player race on AVPro's shared native state that per-player app-side synchronization cannot cover. We would like RenderHeads' help to confirm the mechanism and fix it at the source.

2. Environment

AVPro Video 3.3.4 (Apple native plugin version 3.3.4)
Unity 2022.3.62f2, IL2CPP
Platform iOS 18.7.8, iPhone 11 (iPhone12,1, A13), arm64, Metal
Graphics fence support SystemInfo.supportsGraphicsFence == true on this device
Build type Release, UnityFramework linked as .framework; AVPro static lib linked into UnityFramework
Occurrence Intermittent in production; ~4 minutes into a session (not cold start); during normal gameplay with video activity

We have the exact matching dSYM (UnityFramework UUID 7B219BD0-3113-3D53-8ECB-9ABC47C9946C) and can provide it or any further symbolication on request.

3. Crash detail (symbolicated)

Exception: EXC_BAD_ACCESS (SIGSEGV), KERN_INVALID_ADDRESS at 0x8000000000000010, ESR 0x92000006 (Data Abort, byte read, translation fault).

Note: Apple's report annotates "possible pointer authentication failure", but UnityFramework is arm64 (not arm64e) — there is no PAC here. This is a plain wild-pointer dereference; the annotation is a red herring.

Crashed thread — UnityGfxDeviceWorker:

0  @objc Plugin.playerSetup(playerRef:) + 60
1  _AVPPluginRenderEventFunction + 88
2  GfxDevice::InsertCustomMarkerCallbackAndDataWithFlags(void (*)(int, void*), int, CustomMarkerCallbackFlags, void*, unsigned long)   GfxDevice.cpp:2592
3  GfxDeviceWorker::RunCommand(ThreadedStreamBuffer&)                                                                                  GfxDeviceWorker.cpp
4  GfxDeviceWorkerAutoreleasePoolProxy                                                                                                 GfxDeviceMetal.mm:5886
5  GfxDeviceWorker::RunExt(ThreadedStreamBuffer&)                                                                                      GfxDeviceWorker.cpp:375
6  GfxDeviceWorker::RunGfxDeviceWorker(void*)                                                                                          GfxDeviceWorker.cpp:333
7  Thread::RunThreadWrapper(void*)                                                                                                     Thread.cpp:112

Register analysis (crash frame):

x20 = 0x8000000000000000     <- this is the playerRef argument
far = 0x8000000000000010     <- fault address = playerRef + 0x10  (a field read)
pc  = playerSetup + 60

So playerSetup was invoked with playerRef = 0x8000000000000000 and faulted reading a field at offset +0x10. The playerRef itself is already invalid by the time the worker dispatches this custom render event — i.e. the player object the render command refers to has been freed/poisoned, or the reference resolved from the render-event data no longer maps to a live player.

Main thread (com.apple.main-thread) at crash time — provided for context; it is only a synchronization point, not the cause:

... ScriptableRenderContext::ExtractAndExecuteRenderPipeline
 -> UniversalRenderPipeline.Render -> RenderCameraStack -> RenderSingleCamera
 -> GfxDevice::ExecuteAsync -> ScriptableRenderLoopDraw -> BatchRenderer::ApplyShaderPass
 -> ShaderLab compile -> GfxDeviceClient::CreateGpuProgram   // blocked on the render-thread semaphore

The main thread is inside the normal URP render loop, blocked on the render-thread sync semaphore (creating a GPU shader program). The GfxDeviceWorker is independently draining its command queue and hits the AVPro custom render event with the bad playerRef. The main-thread work is unrelated to the fault; it merely establishes that a normal per-frame render submission is in flight.

4. How we use AVPro (so you can reason about the trigger)

Our title is a casual puzzle game with many short videos (cutscenes, level-complete clips, story/character videos, activity popups). Key facts about our usage:

  • Multiple native players are alive simultaneously. At steady state there are typically 8+ native AVPlayers: a manager pool pre-warms 4 "queue managers", each owning two MediaPlayers (A/B) for cross-fade = 8 players; plus embedded players in certain UI forms; plus on-demand growth.
  • Cross-fade keeps two players of a manager live at once. During a transition we open a second MediaPlayer while the first is still rendering, and blend them with Graphics.Blit into a RenderTexture. So concurrent Setup/Render of two players of the same logical unit is normal.
  • Frequent create/destroy. Our UI framework instantiates a form on open and Destroys it on close (no instance pool at the UI layer). Any form with an embedded MediaPlayer therefore triggers a real native AVPPluginMakePlayer on open and AVPPlayerRelease on close. The highest-frequency case is the level-complete video (every level). We also grow the manager pool by Instantiate on demand when concurrency exceeds the pre-warm count.
  • Net effect: native AVPPluginMakePlayer / AVPPlayerClose / AVPPlayerRelease are called often, from the main thread, while other players are actively being rendered by GfxDeviceWorker.

Native lifecycle entry points we observe in the plugin (3.3.4, PlatformMediaPlayer)

  • MakePlatformMediaPlayer ctor: AVPPluginMakePlayer(_playerSettings)CreateCommandBuffers()Graphics.ExecuteCommandBuffer(_setupCommandBuffer). This enqueues the Setup render command, which the worker later executes via _AVPPluginRenderEventFunction → Plugin.playerSetup(playerRef:) (this is exactly our crash site).
  • CloseCloseMedia(): AVPPlayerClose(_player) + Texture2D.Destroy(_texturePlanes[]).
  • ReleaseDispose(): Graphics.ExecuteCommandBuffer(_freeResourcesCommandBuffer)AVPPlayerRelease(_player).

All three appear to bake the raw _player handle into render commands (Setup/Render/FreeResources) that run on GfxDeviceWorker, with no visible handle validation on the native side when the event is dispatched.

5. Root-cause analysis

Observation: The crash is on the establish/dispatch path (playerSetup), not on a Free/teardown path. For a single player, the ordering Setup → Render → Free is well-defined and we can fence it. So a single player's own lifecycle does not explain a poisoned playerRef arriving at playerSetup.

Hypothesis (cross-player race on shared native state): Because many native players coexist and are made/released frequently from the main thread, we believe one player's main-thread AVPPluginMakePlayer / AVPPlayerRelease mutates AVPro's shared Plugin state (e.g. an internal registry/table that maps a render-event playerRef/id to a live player object) concurrently with GfxDeviceWorker dispatching a different player's playerSetup/playerRender event. The worker then resolves/dereferences a playerRef that has just been invalidated → the poisoned 0x8000000000000000 we see.

Supporting evidence from our side:

  • The faulting playerRef (0x8000…0000) does not belong to the command's own just-created player (a freshly made player's pointer would not be that sentinel), consistent with a stale/foreign entry being read.
  • Our entire C# orchestration and native-binding layer contains no locking whatsoever (no lock/Monitor/Interlocked/Mutex) around AVPPluginMakePlayer / AVPPlayerRelease / AVPPlayerClose. And even if it did, a C# lock cannot serialize against AVPro's native GfxDeviceWorker thread. So the safety of concurrent main-thread lifecycle calls vs. worker-thread render-event dispatch rests entirely inside the plugin.
  • Per-player GPU fences (which we have) serialize "player X's Free vs X's own render drain." They structurally cannot serialize "player X's Make/Release (main thread) vs the worker dispatching player Y's Setup."

We acknowledge this is an inference about closed-source Swift internals — hence the questions in §7. But the symbolicated crash landing squarely in playerSetup with a poisoned playerRef, combined with our concurrency pattern and lack of any cross-player serialization, points strongly here.

6. Mitigations we have already shipped (per-player)

We mention these so you know the "obvious" fixes are already in place and did not stop the crash — reinforcing that the remaining cause is cross-player / inside the plugin:

  1. Fence-gated native Release. In PlatformMediaPlayer.Dispose() we insert a GraphicsFence (CPUSynchronisation, PixelProcessing) right after ExecuteCommandBuffer(_freeResourcesCommandBuffer), and defer AVPPlayerRelease on a DontDestroyOnLoad runner until fence.passed == true (floor of 2 frames, 120-frame leak fallback; falls back to frames when fence unsupported).
  2. Fence-gated native Close. A helper (MediaPlayerSafeClose) sets enabled=false, waits one frame, inserts a GraphicsFence, waits for fence.passed, then calls CloseMedia() — so AVPPlayerClose + Texture2D.Destroy do not tear down textures/outputs still referenced by in-flight render commands.
  3. Drain-before-Open. We await a render drain before any OpenMedia (which internally calls CloseMedia), and we defer the fade-to player's OpenMedia by a frame after CloseMedia.
  4. Churn reduction. A persistent DontDestroyOnLoad host owns the level-complete players so they are made once and reused (Open/Close only, no Make/Release per level). The manager pool reuses instances via SetActive rather than destroying them.

None of these can gate the cross-player window, and the crash persists on builds containing all of the above.

7. What we would like from RenderHeads

  1. Thread safety of the Plugin registry. In 3.3.4 (Apple), is the internal mapping of render-event playerRef/player-id → player object safe against concurrent main-thread AVPPluginMakePlayer/AVPPlayerRelease and GfxDeviceWorker render-event dispatch? If not, that is very likely our root cause.
  2. Handle validation before dereference. Does _AVPPluginRenderEventFunction / Plugin.playerSetup(playerRef:) validate that playerRef still refers to a live player before dereferencing (e.g. read +0x10)? Could a freed/unknown ref be turned into a safe no-op instead of a UAF (generation counter / handle table / validity check)?
  3. Supported concurrent multi-player usage. What is the officially supported pattern for running many players concurrently with frequent create/destroy and cross-fade? Is there a required serialization, a maximum live count, or a mandated teardown ordering we are violating?
  4. Any known fix / regression. Is this a known issue in 3.3.4, and is there a fix in a later 3.x we should upgrade to?

8. Reproduction & assets we can provide

  • The full .ips crash report and the matching dSYM (UUID above).
  • Additional symbolicated threads on request.
  • We can attempt a minimal reproduction project that isolates the pattern (several MediaPlayers created/destroyed rapidly + cross-fade of two players while others render), without our game code. Please confirm this would help and whether you have a preferred repro template.

Thank you — we are happy to run instrumented builds or test candidate fixes.

Media information

No response

Log output

Metadata

Metadata

Assignees

Labels

FixedA fix will be in the next releaseiOSiOS platform

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions