Skip to content

fix(💣): guard the picture swap in RNSkPictureRenderer - #4032

Open
afonsojramos wants to merge 1 commit into
Shopify:mainfrom
afonsojramos:fix-picture-swap-race
Open

fix(💣): guard the picture swap in RNSkPictureRenderer#4032
afonsojramos wants to merge 1 commit into
Shopify:mainfrom
afonsojramos:fix-picture-swap-race

Conversation

@afonsojramos

Copy link
Copy Markdown

The crash

An app that replaces the drawn picture at a high rate (ours publishes ~30 pictures/second from an audio visualizer) intermittently dies with:

Abort message: 'Pure virtual function called!'
  __cxa_pure_virtual
  RNSkia::RNSkPictureRenderer::performDraw(...)::'lambda'(SkCanvas*)
  RNSkia::RNSkOpenGLCanvasProvider::renderToCanvas(...)
  RNSkia::RNSkPictureRenderer::performDraw(...)
  RNSkia::RNSkView::requestRedraw()::'lambda'()
  RNSkia::JniPlatformContext::notifyTaskReadyNative()

Reproduced on a Pixel 8 (RN 0.86, release build, v2.11.0): cycling between different shader-based scenes while pictures stream in kills the process within seconds.

The race

performDraw copies the picture to protect against concurrent replacement:

// Capture picture pointer to ensure thread safety - _picture can be
// modified from the JS thread while we're drawing on the render thread
sk_sp<SkPicture> picture = _picture;

The copy itself is the race: copying an sk_sp is not atomic. When setPicture on the JS thread replaces _picture at the same moment, the render thread can read the old pointer while the replacement drops the last ref, then ref() an already-destructed object. drawPicture on it fails in __cxa_pure_virtual (or corrupts memory silently). #3588 introduced this copy and narrowed the window without closing it.

The fix

A std::mutex over the three places that touch _picture (setPicture, getPicture, the copy in performDraw). The lock is held only for a pointer copy or swap, so there is no contention worth measuring at any realistic picture rate; drawing still happens outside the lock.

With this patch, the reproduction above survived 48 consecutive scene switches plus mixed fullscreen transitions with the crash buffer empty, where the unpatched build died within the first dozen.

@wcandillon

wcandillon commented Aug 28, 2026 via email

Copy link
Copy Markdown
Contributor

@afonsojramos

afonsojramos commented Aug 28, 2026

Copy link
Copy Markdown
Author

I tried to hand you a single-file repro and have to report failure so far - three escalating attempts, none of which crash (yet!!):

  1. two runtime effects, uniforms through React state at 60 Hz, subtree swapped every 300 ms;
  2. same plus a float[8] array uniform, a Path-based scene rebuilt per frame in the cycle, and a paused sibling canvas;
  3. four animated canvases, pictures replaced every 8 ms, subtrees swapped every 100 ms, one canvas mount-churning every 700 ms - survived 10 minutes on the same Pixel 8 that kills the real app in seconds.

So the trivial distillation doesn't carry it, and whatever the missing ingredient is I'd rather tell you that than hand you a repro that doesn't reproduce. My best guess is a much busier JS thread, or GC timing - the real app runs audio decode threads and a 30 Hz spectrum tap alongside.

What does reproduce, reliably, is the app (private repo for now): ~30 setPicture/s from state-driven uniforms (including a float[8]), cycling between two runtime-effect fills and path-heavy scenes inside a fullscreen Modal, with a second paused canvas mounted. Release build, RN 0.86.2, Hermes, v2.11.0, Pixel 8. Two independent occurrences with byte-identical stacks - full tombstone below.

The strongest evidence I can offer for the mechanism: with the two-hunk mutex from this PR applied locally, the exact cycling sequence that killed the process within four rounds survived eighteen plus mixed fullscreen stress, same pid throughout, crash buffer empty. Without it, dead in seconds.

Happy to run any instrumented or diagnostic build you'd like against the reproducing setup.

Full tombstone (v2.11.0, Pixel 8, Android 17)
signal 6 (SIGABRT), code -1 (SI_QUEUE)
Abort message: 'Pure virtual function called!'
  #00 abort (libc.so)
  #01 base.apk!libc++_shared.so
  #02 __cxa_pure_virtual (libc++_shared.so)
  #03 base.apk!librnskia.so
  #04 std::__invoke_void_return_wrapper<void, true>::__call<RNSkia::RNSkPictureRenderer::performDraw(std::shared_ptr<RNSkia::RNSkCanvasProvider>)::'lambda'(SkCanvas*)&, SkCanvas*> (librnskia.so)
  #05 RNSkia::RNSkOpenGLCanvasProvider::renderToCanvas(std::function<void (SkCanvas*)> const&) (librnskia.so)
  #06 RNSkia::RNSkPictureRenderer::performDraw(std::shared_ptr<RNSkia::RNSkCanvasProvider>) (librnskia.so)
  #07 RNSkia::RNSkPictureRenderer::renderImmediate(std::shared_ptr<RNSkia::RNSkCanvasProvider>) (librnskia.so)
  #08 RNSkia::RNSkView::requestRedraw()::'lambda'()::operator()() const (librnskia.so)
  #09 RNSkia::JniPlatformContext::notifyTaskReadyNative() (librnskia.so)
  #10 facebook::jni::detail::MethodWrapper<...JniPlatformContext...>::dispatch (librnskia.so)
  ... (Handler.dispatchMessage / Looper.loop / ActivityThread.main)

@oudingfan

oudingfan commented Sep 4, 2026

Copy link
Copy Markdown

We ran into this crash on real iOS devices and bisected it across versions.

In our app, we have an interactive audio player with a playback cursor and pitch points that update at 60fps over a declarative <Canvas>.

Reproduction pattern

A declarative canvas re-rendered at a high frequency via React state updates:

import React, { useEffect, useState } from "react";
import { Canvas, Line, vec } from "@shopify/react-native-skia";

export function PlaybackCanvas() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setProgress((p) => (p + 0.01) % 1);
    }, 16);
    return () => clearInterval(timer);
  }, []);

  return (
    <Canvas style={{ width: 300, height: 100 }}>
      <Line
        p1={vec(progress * 300, 0)}
        p2={vec(progress * 300, 100)}
        color="green"
        strokeWidth={4}
      />
    </Canvas>
  );
}

Observations

  • 2.7.0: stable, no crashes during prolonged playback.
  • 2.8.0 to 2.11.2: crashes within seconds or minutes on iOS (EXC_BAD_ACCESS in SkCanvas::drawPicture).

Looking at the crash in Xcode LLDB, at the point of failure:

  • Call stack: RNSkPictureRenderer::performDraw -> SkCanvas::drawPicture.
  • In the debugger, picture points to an invalidated/zeroed object (fUniqueID = 0, crashing at address 0x923ec1d99b7600b2).

This started with 2.8.0 when lastPicture.dispose() was added to StaticContainer.ts:redraw(). When React triggers a new frame at high frequency, the JS thread disposes the previous picture while the Metal render thread is still drawing it.

Guarding the picture swap behind a mutex as done in this PR addresses this race condition.

Below is a screenshot of the crash.

截屏2026-09-01 19 46 041

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants