diff --git a/apps/example/index.js b/apps/example/index.js index 35de48950b..034d8f1410 100644 --- a/apps/example/index.js +++ b/apps/example/index.js @@ -5,9 +5,17 @@ import { AppRegistry } from "react-native"; import App from "./src/App"; import { name as appName } from "./app.json"; +import { RELOAD_STRESS_TEST } from "./src/ReloadStress/config"; if (!Symbol.dispose) { Symbol.dispose = Symbol.for("Symbol.dispose"); } -AppRegistry.registerComponent(appName, () => App); +if (RELOAD_STRESS_TEST) { + // Reproduction for https://github.com/Shopify/react-native-skia/issues/4003 + // — see src/ReloadStress/README.md + const { ReloadStressApp } = require("./src/ReloadStress/ReloadStressApp"); + AppRegistry.registerComponent(appName, () => ReloadStressApp); +} else { + AppRegistry.registerComponent(appName, () => App); +} diff --git a/apps/example/src/ReloadStress/README.md b/apps/example/src/ReloadStress/README.md new file mode 100644 index 0000000000..4037253896 --- /dev/null +++ b/apps/example/src/ReloadStress/README.md @@ -0,0 +1,58 @@ +# Runtime-reload stress test (issue #4003) + +Reproduction for [Shopify/react-native-skia#4003](https://github.com/Shopify/react-native-skia/issues/4003): +Android SIGSEGV in `RNSkManager::installBindings` / `Skia` undefined after the +JS runtime is recreated (e.g. expo-updates `Updates.reloadAsync()` after an OTA +download). Regression window: 2.10.0+ (native states migration, #3964). + +## How it works + +- `index.js` registers `ReloadStressApp` instead of the normal example app when + the flag in `src/ReloadStress/config.js` is enabled. +- On every boot of a fresh JS runtime, the app renders an animated Skia canvas + and runs a per-frame churn loop (`Skia.Path.Make()`, `Skia.Paint()`, ...) so + native-object creation is in flight when the runtime goes down. +- After a randomized 300–1800 ms delay it calls `DevSettings.reload()`. On + bridgeless Android this goes through `ReactHost.reload()` — the same native + path as `Updates.reloadAsync()`, which overlaps teardown of the old runtime + with startup of the new one. +- The fresh runtime re-runs `index.js`, re-arming the loop. It runs until it + crashes (or you disable the flag). + +## Running it + +1. Set `RELOAD_STRESS_TEST = true` in `src/ReloadStress/config.js`. +2. `yarn android` (debug build is fine — `DevSettings.reload()` needs dev + support; the dev-menu reload exercises the same race as `reloadAsync`). +3. Watch the logs: + + ```sh + adb logcat | grep -E "ReloadStress|DEBUG|libc|SIGSEGV" + ``` + +The "process uptime" line proves reloads stay in the same process (a cold +start would reset it). + +## What to look for + +**Symptom 1 — native crash (fatal):** a tombstone in logcat with a backtrace +through `librnskia.so`: + +``` +signal 11 (SIGSEGV) ... + RNSkia::RNSkManager::installBindings + RNJsi::NativeObject<...>::create +``` + +**Symptom 2 — bindings missing on the fresh runtime (non-fatal here):** + +``` +[ReloadStress] SYMPTOM-2: bindings missing on fresh runtime — Skia (global.SkiaApi snapshot) is undefined +``` + +(In production this is the `TypeError: Cannot read property +'MakeFreeTypeFaceFromData' of undefined` from `Typeface.ts`.) + +A healthy run logs `[ReloadStress] runtime up, Skia bindings ok` on every +cycle indefinitely. The race is probabilistic — let the loop run for a few +hundred cycles before concluding anything. diff --git a/apps/example/src/ReloadStress/ReloadStressApp.tsx b/apps/example/src/ReloadStress/ReloadStressApp.tsx new file mode 100644 index 0000000000..fe0b3bfd74 --- /dev/null +++ b/apps/example/src/ReloadStress/ReloadStressApp.tsx @@ -0,0 +1,135 @@ +/** + * Reproduction for https://github.com/Shopify/react-native-skia/issues/4003 + * + * Repeatedly recreates the JS runtime (DevSettings.reload() -> ReactHost.reload() + * on bridgeless Android — the same native path expo-updates' reloadAsync() takes) + * while keeping Skia native-object creation hot on the JS thread. Two failure + * modes are expected on affected versions (2.10.0+): + * + * 1. Native SIGSEGV in RNSkia::RNSkManager::installBindings during the + * reload (visible as a tombstone in adb logcat), caused by a stale + * prototype cache entry from the destroyed runtime. + * 2. `Skia` (the module-scope snapshot of global.SkiaApi) undefined on the + * fresh runtime even though NativeSkiaModule.install() returned true — + * logged below as "SYMPTOM-2". + */ +import React, { useEffect, useMemo, useState } from "react"; +import { DevSettings, Platform, StyleSheet, Text, View } from "react-native"; +import { Canvas, Fill, Path, Skia } from "@shopify/react-native-skia"; + +const TAG = "[ReloadStress]"; + +// Module-scope binding check, mirroring what src/skia/core/Typeface.ts does +// at import time (`Skia.Typeface.MakeFreeTypeFaceFromData.bind(...)`), which +// is where the TypeError surfaces in production. +const bindingsError = (() => { + try { + if (Skia == null) { + return "Skia (global.SkiaApi snapshot) is undefined"; + } + if (Skia.Typeface == null) { + return "Skia.Typeface is undefined"; + } + if (typeof Skia.Typeface.MakeFreeTypeFaceFromData !== "function") { + return "Skia.Typeface.MakeFreeTypeFaceFromData is not a function"; + } + // Exercise the exact production code path. + Skia.Typeface.MakeFreeTypeFaceFromData.bind(Skia.Typeface); + return null; + } catch (e) { + return `threw: ${e}`; + } +})(); + +if (bindingsError != null) { + console.error( + `${TAG} SYMPTOM-2: bindings missing on fresh runtime — ${bindingsError}` + ); +} else { + console.log(`${TAG} runtime up, Skia bindings ok`); +} + +// Delay before triggering the next reload. Randomized so the reload lands at +// varied points of the startup/render pipeline — the race is timing-dependent. +const reloadDelay = 300 + Math.floor(Math.random() * 1500); + +const makeChurnPath = (tick: number) => { + const path = Skia.Path.Make(); + for (let i = 0; i < 40; i++) { + const r = 10 + ((tick + i * 7) % 60); + path.addCircle(60 + ((tick * 3 + i * 31) % 200), 80 + ((i * 53) % 300), r); + } + return path; +}; + +export const ReloadStressApp = () => { + const [tick, setTick] = useState(0); + + // Keep native object creation hot on the JS thread: every frame creates + // paths, paints, colors and matrices (NativeObject::create traffic) so a + // reload is likely to land while JSI objects are in flight. + useEffect(() => { + let running = true; + let raf = 0; + const loop = () => { + if (!running) { + return; + } + const p = Skia.Path.Make(); + for (let i = 0; i < 50; i++) { + p.addCircle(50 + i, 50, 20); + } + const paint = Skia.Paint(); + paint.setColor(Skia.Color("cyan")); + const m = Skia.Matrix(); + m.translate(1, 1); + setTick((t) => t + 1); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => { + running = false; + cancelAnimationFrame(raf); + }; + }, []); + + useEffect(() => { + console.log( + `${TAG} process uptime ${Math.round( + performance.now() / 1000 + )}s — scheduling runtime reload in ${reloadDelay}ms` + ); + const t = setTimeout(() => { + console.log(`${TAG} reloading JS runtime now`); + DevSettings.reload("Skia reload stress (#4003)"); + }, reloadDelay); + return () => clearTimeout(t); + }, []); + + const path = useMemo(() => makeChurnPath(tick), [tick]); + + return ( + + + + + + + + #4003 reload stress ({Platform.OS}){"\n"} + bindings: {bindingsError ?? "ok"} + {"\n"} + process uptime: {Math.round(performance.now() / 1000)}s{"\n"} + next reload in {reloadDelay}ms + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "black" }, + canvas: { flex: 1 }, + overlay: { position: "absolute", top: 60, left: 20 }, + text: { color: "white", fontSize: 14 }, +}); diff --git a/apps/example/src/ReloadStress/config.js b/apps/example/src/ReloadStress/config.js new file mode 100644 index 0000000000..1e60278a46 --- /dev/null +++ b/apps/example/src/ReloadStress/config.js @@ -0,0 +1,7 @@ +/** + * Flip to `true` to boot the app in the runtime-reload stress mode used to + * reproduce https://github.com/Shopify/react-native-skia/issues/4003 + * (Android SIGSEGV in RNSkManager::installBindings / `Skia` undefined after + * a JS runtime reload). See src/ReloadStress/README.md. + */ +export const RELOAD_STRESS_TEST = false;