Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/example/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
58 changes: 58 additions & 0 deletions apps/example/src/ReloadStress/README.md
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 135 additions & 0 deletions apps/example/src/ReloadStress/ReloadStressApp.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<Canvas style={styles.canvas}>
<Fill color="black" />
<Path path={path} color="cyan" style="stroke" strokeWidth={2} />
</Canvas>
<View style={styles.overlay} pointerEvents="none">
<Text style={styles.text}>
#4003 reload stress ({Platform.OS}){"\n"}
bindings: {bindingsError ?? "ok"}
{"\n"}
process uptime: {Math.round(performance.now() / 1000)}s{"\n"}
next reload in {reloadDelay}ms
</Text>
</View>
</View>
);
};

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "black" },
canvas: { flex: 1 },
overlay: { position: "absolute", top: 60, left: 20 },
text: { color: "white", fontSize: 14 },
});
7 changes: 7 additions & 0 deletions apps/example/src/ReloadStress/config.js
Original file line number Diff line number Diff line change
@@ -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;
Loading