diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx
index 887eb0d66..cc85c03b5 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -35,7 +35,13 @@ import { ComputeToys } from "./ComputeToys";
import { Reanimated } from "./Reanimated";
import { AsyncStarvation } from "./Diagnostics/AsyncStarvation";
import { DeviceLostHang } from "./Diagnostics/DeviceLostHang";
+import { DiagnosticsList } from "./Diagnostics/DiagnosticsList";
import { WorkletRequestAdapter } from "./Diagnostics/WorkletRequestAdapter";
+import { ContextEdgeCases } from "./Diagnostics/ContextEdgeCases";
+import { ViewFormatsUseAfterFree } from "./Diagnostics/ViewFormatsUseAfterFree";
+import { RenderAfterUnmount } from "./Diagnostics/RenderAfterUnmount";
+import { BackgroundDetach } from "./Diagnostics/BackgroundDetach";
+import { SurfaceChurn } from "./Diagnostics/SurfaceChurn";
import { StorageBufferVertices } from "./StorageBufferVertices";
import { SharedTextureMemory } from "./SharedTextureMemory";
import { ImportExternalTexture } from "./ImportExternalTexture";
@@ -96,12 +102,28 @@ function App() {
+
+
+
+
+
+
{
+ const ref = useRef(null);
+ const { log, append } = useDiagnosticLog();
+ const [transparent, setTransparent] = useState(true);
+
+ useEffect(() => {
+ const sub = AppState.addEventListener("change", (state) => {
+ append(`AppState: ${state}`);
+ });
+ return () => sub.remove();
+ }, [append]);
+
+ useEffect(() => {
+ let running = true;
+ let frame = 0;
+ (async () => {
+ const { device, format } = await initGPU(append);
+ const ctx = ref.current!.getContext("webgpu")!;
+ ctx.configure({ device, format, alphaMode: "premultiplied" });
+ append(
+ "rendering, background the app and come back (or flip the switch)",
+ );
+ const loop = () => {
+ if (!running) {
+ return;
+ }
+ try {
+ drawClearFrame(device, ctx, frame++);
+ if (frame % 120 === 0) {
+ append(`frame ${frame} ok`);
+ }
+ } catch (e) {
+ append(`frame ${frame}: ${e}`);
+ }
+ requestAnimationFrame(loop);
+ };
+ loop();
+ })();
+ return () => {
+ running = false;
+ };
+ }, [append]);
+
+ return (
+
+
+
+ Background the app and return: the animated gradient must resume. On a
+ broken build the transparent canvas stays black forever (Android), and
+ the opaque one logs a validation error on resume.
+
+
+
+ transparent (TextureView on Android)
+
+
+
+
+
+
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+};
+
+const styles = {
+ row: {
+ flexDirection: "row" as const,
+ alignItems: "center" as const,
+ justifyContent: "space-between" as const,
+ },
+};
diff --git a/apps/example/src/Diagnostics/ContextEdgeCases.tsx b/apps/example/src/Diagnostics/ContextEdgeCases.tsx
new file mode 100644
index 000000000..01ef7f808
--- /dev/null
+++ b/apps/example/src/Diagnostics/ContextEdgeCases.tsx
@@ -0,0 +1,113 @@
+import React, { useRef } from "react";
+import { Button, ScrollView, Text, View } from "react-native";
+import type { CanvasRef } from "react-native-webgpu";
+import { Canvas } from "react-native-webgpu";
+
+import {
+ diagnosticStyles,
+ drawClearFrame,
+ initGPU,
+ useDiagnosticLog,
+} from "./surfaceLifecycle";
+
+// Repros for GPUCanvasContext entry points the WebGPU spec expects to fail
+// gracefully but that crash or silently misbehave in the native
+// implementation:
+//
+// 1. getCurrentTexture() before configure(): SurfaceInfo has no device yet.
+// The native size-changed path reconfigures with a null wgpu::Device
+// (CreateTexture on the offscreen path, Surface::Configure on-screen) and
+// crashes the app instead of throwing a catchable JS error.
+//
+// 2. configure() on a 0x0 canvas: Dawn refuses zero-sized textures, so
+// getCurrentTexture() wraps a null texture and createView() dereferences
+// it. A canvas can legitimately be measured at 0x0 for a frame (collapsed
+// layout, display: none equivalents), so this is reachable from ordinary
+// app code.
+//
+// 3. unconfigure(): the native method is an empty stub. After unconfigure()
+// the context keeps handing out textures as if still configured, where the
+// spec says the canvas should behave as if it was never configured.
+//
+// Each button is an independent repro; on a broken build the first two
+// terminate the app, so relaunch between attempts.
+export const ContextEdgeCases = () => {
+ const ref = useRef(null);
+ const { log, append } = useDiagnosticLog();
+
+ const getCurrentTextureUnconfigured = () => {
+ try {
+ const ctx = ref.current!.getContext("webgpu")!;
+ append("getCurrentTexture() before configure()...");
+ const texture = ctx.getCurrentTexture();
+ append(
+ `returned a ${texture.width}x${texture.height} texture (spec: should throw)`,
+ );
+ } catch (e) {
+ append(`threw (correct per spec): ${e}`);
+ }
+ };
+
+ const zeroSizedCanvas = async () => {
+ try {
+ const { device, format } = await initGPU(append);
+ const ctx = ref.current!.getContext("webgpu")!;
+ const canvas = ctx.canvas as HTMLCanvasElement;
+ canvas.width = 0;
+ canvas.height = 0;
+ append("configure() with canvas.width = canvas.height = 0...");
+ ctx.configure({ device, format, alphaMode: "opaque" });
+ const texture = ctx.getCurrentTexture();
+ append(`getCurrentTexture() -> ${texture.width}x${texture.height}`);
+ texture.createView();
+ append("createView() survived");
+ } catch (e) {
+ append(`threw: ${e}`);
+ }
+ };
+
+ const unconfigureStub = async () => {
+ try {
+ const { device, format } = await initGPU(append);
+ const ctx = ref.current!.getContext("webgpu")!;
+ ctx.configure({ device, format, alphaMode: "opaque" });
+ drawClearFrame(device, ctx, 0);
+ append("rendered one frame, calling unconfigure()...");
+ ctx.unconfigure();
+ const texture = ctx.getCurrentTexture();
+ append(
+ `getCurrentTexture() after unconfigure() -> ${texture.width}x${texture.height} texture (spec: context should be unconfigured)`,
+ );
+ } catch (e) {
+ append(`threw (correct per spec): ${e}`);
+ }
+ };
+
+ return (
+
+
+
+ Each button exercises a context entry point that must fail gracefully
+ per the WebGPU spec. On a broken build the first two crash the app.
+
+
+
+
+
+
+
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/example/src/Diagnostics/DiagnosticsList.tsx b/apps/example/src/Diagnostics/DiagnosticsList.tsx
new file mode 100644
index 000000000..64d0a2ca5
--- /dev/null
+++ b/apps/example/src/Diagnostics/DiagnosticsList.tsx
@@ -0,0 +1,75 @@
+import * as React from "react";
+import { ScrollView, StyleSheet, Text, View } from "react-native";
+import { useNavigation } from "@react-navigation/native";
+import { RectButton } from "react-native-gesture-handler";
+import type { StackNavigationProp } from "@react-navigation/stack";
+
+import type { Routes } from "../Route";
+
+// Sub navigation for the diagnostic screens: each entry reproduces a bug or
+// stresses an invariant of the native implementation.
+const tests = [
+ {
+ screen: "AsyncStarvation",
+ title: "⚠️ Async Runner Starvation",
+ },
+ {
+ screen: "DeviceLostHang",
+ title: "⚠️ Device Lost Hang",
+ },
+ {
+ screen: "WorkletRequestAdapter",
+ title: "⚠️ Worklet requestAdapter",
+ },
+ {
+ screen: "ContextEdgeCases",
+ title: "⚠️ Context Edge Cases",
+ },
+ {
+ screen: "ViewFormatsUseAfterFree",
+ title: "⚠️ viewFormats Use-After-Free",
+ },
+ {
+ screen: "RenderAfterUnmount",
+ title: "⚠️ Render After Unmount",
+ },
+ {
+ screen: "BackgroundDetach",
+ title: "⚠️ Background Detach",
+ },
+ {
+ screen: "SurfaceChurn",
+ title: "⚠️ Surface Churn",
+ },
+] as const;
+
+export const DiagnosticsList = () => {
+ const { navigate } =
+ useNavigation>();
+ return (
+
+ {tests.map((test) => (
+ navigate(test.screen)}>
+
+ {test.title}
+
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ marginBottom: 32,
+ },
+ thumbnail: {
+ backgroundColor: "white",
+ padding: 32,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ title: {},
+});
diff --git a/apps/example/src/Diagnostics/RenderAfterUnmount.tsx b/apps/example/src/Diagnostics/RenderAfterUnmount.tsx
new file mode 100644
index 000000000..e0a4f76f3
--- /dev/null
+++ b/apps/example/src/Diagnostics/RenderAfterUnmount.tsx
@@ -0,0 +1,91 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Button, ScrollView, Text, View } from "react-native";
+import type { CanvasRef } from "react-native-webgpu";
+import { Canvas } from "react-native-webgpu";
+
+import {
+ diagnosticStyles,
+ drawClearFrame,
+ initGPU,
+ useDiagnosticLog,
+} from "./surfaceLifecycle";
+
+// The frame loop deliberately keeps calling getCurrentTexture()/present()
+// after the Canvas is unmounted. This is legal from the JS side: the context
+// object is still alive, and the documented behavior for a detached surface
+// is to fall back to offscreen rendering.
+//
+// On a broken build, native view teardown frees the surface out from under
+// the context:
+// - iOS: MetalView dealloc removes the registry entry while the stored
+// wgpu::Surface still wraps the now-released CAMetalLayer. The next
+// getCurrentTexture() asks the dead layer for a drawable: EXC_BAD_ACCESS.
+// - Android (transparent canvas / TextureView): onSurfaceTextureDestroyed
+// removes the registry entry without detaching, so the context keeps a
+// wgpu::Surface on a destroyed window: dead-window errors or a crash.
+//
+// The transparent prop is set so Android takes the TextureView path, which is
+// the one that crashes rather than silently going black.
+export const RenderAfterUnmount = () => {
+ const ref = useRef(null);
+ const { log, append } = useDiagnosticLog();
+ const [mounted, setMounted] = useState(true);
+
+ useEffect(() => {
+ let running = true;
+ let frame = 0;
+ (async () => {
+ const { device, format } = await initGPU(append);
+ const ctx = ref.current!.getContext("webgpu")!;
+ ctx.configure({ device, format, alphaMode: "premultiplied" });
+ append("rendering, unmount the canvas while the loop keeps going");
+ const loop = () => {
+ if (!running) {
+ return;
+ }
+ try {
+ drawClearFrame(device, ctx, frame++);
+ if (frame % 120 === 0) {
+ append(`frame ${frame} ok`);
+ }
+ } catch (e) {
+ append(`frame ${frame}: ${e}`);
+ }
+ requestAnimationFrame(loop);
+ };
+ loop();
+ })();
+ return () => {
+ running = false;
+ };
+ }, [append]);
+
+ return (
+
+
+
+ A frame loop renders into the canvas and keeps running after the
+ canvas unmounts. Expected: the context detaches and falls back to
+ offscreen rendering. On a broken build the next frame uses the freed
+ native surface and crashes.
+
+
+ {mounted ? (
+
+ ) : (
+
+ )}
+
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/example/src/Diagnostics/SurfaceChurn.tsx b/apps/example/src/Diagnostics/SurfaceChurn.tsx
new file mode 100644
index 000000000..6603e4e44
--- /dev/null
+++ b/apps/example/src/Diagnostics/SurfaceChurn.tsx
@@ -0,0 +1,116 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Button, ScrollView, Text, View } from "react-native";
+import type { CanvasRef } from "react-native-webgpu";
+import { Canvas } from "react-native-webgpu";
+
+import {
+ diagnosticStyles,
+ drawClearFrame,
+ initGPU,
+ useDiagnosticLog,
+} from "./surfaceLifecycle";
+
+// Automated stress for the attach/detach transitions. While a frame loop
+// renders on the JS thread, the canvas is fully remounted every 400ms (every
+// third remount also flips the transparent prop, which swaps the Android view
+// flavor). Each epoch tears the previous native view down on the platform
+// thread while the previous frame may still be in flight, exercising:
+// - native teardown (MetalView dealloc / TextureView destroy) racing
+// getCurrentTexture()/present() on the JS thread,
+// - registry add/remove churn against context creation,
+// - repeated offscreen/onscreen transitions and reconfigures.
+//
+// Expected: the gradient flickers but the app survives indefinitely with no
+// validation errors. On a broken build this crashes within seconds
+// (use-after-free of the native layer/window) or degrades into a black canvas
+// and a stream of validation errors.
+export const SurfaceChurn = () => {
+ const { log, append } = useDiagnosticLog();
+ const [running, setRunning] = useState(false);
+ const [epoch, setEpoch] = useState(0);
+ const ref = useRef(null);
+ const deviceRef = useRef<{
+ device: GPUDevice;
+ format: GPUTextureFormat;
+ } | null>(null);
+
+ useEffect(() => {
+ if (!running) {
+ return undefined;
+ }
+ const interval = setInterval(() => {
+ setEpoch((e) => e + 1);
+ }, 400);
+ return () => clearInterval(interval);
+ }, [running]);
+
+ useEffect(() => {
+ if (!running) {
+ return undefined;
+ }
+ let live = true;
+ let frame = 0;
+ (async () => {
+ if (deviceRef.current === null) {
+ deviceRef.current = await initGPU(append);
+ }
+ const { device, format } = deviceRef.current;
+ if (!live || !ref.current) {
+ return;
+ }
+ const ctx = ref.current.getContext("webgpu")!;
+ ctx.configure({ device, format, alphaMode: "premultiplied" });
+ if (epoch % 10 === 0) {
+ append(`epoch ${epoch}`);
+ }
+ const loop = () => {
+ if (!live) {
+ return;
+ }
+ try {
+ drawClearFrame(device, ctx, epoch * 24 + frame++);
+ } catch (e) {
+ append(`epoch ${epoch} frame ${frame}: ${e}`);
+ }
+ requestAnimationFrame(loop);
+ };
+ loop();
+ })();
+ return () => {
+ live = false;
+ };
+ }, [running, epoch, append]);
+
+ return (
+
+
+
+ Remounts the canvas every 400ms while rendering every frame, flipping
+ the transparent flag every third remount. Expected: flicker, but no
+ crash and no validation errors. Leave it running for a minute.
+
+
+ {running ? (
+
+ ) : (
+
+ )}
+
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/example/src/Diagnostics/ViewFormatsUseAfterFree.tsx b/apps/example/src/Diagnostics/ViewFormatsUseAfterFree.tsx
new file mode 100644
index 000000000..24e0e1ca7
--- /dev/null
+++ b/apps/example/src/Diagnostics/ViewFormatsUseAfterFree.tsx
@@ -0,0 +1,114 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Button, PixelRatio, ScrollView, Text, View } from "react-native";
+import type { CanvasRef, RNCanvasContext } from "react-native-webgpu";
+import { Canvas } from "react-native-webgpu";
+
+import {
+ diagnosticStyles,
+ drawClearFrame,
+ initGPU,
+ useDiagnosticLog,
+} from "./surfaceLifecycle";
+
+// configure() copies the wgpu::SurfaceConfiguration into SurfaceInfo, but the
+// viewFormats array inside it is owned by the Convertor that only lives for
+// the duration of the configure() call. The stored configuration keeps a
+// pointer into that freed memory. Any later reconfigure re-reads it:
+// - a canvas resize (this screen's button),
+// - a device rotation,
+// - the offscreen/onscreen transitions on background/foreground.
+// The symptom is a use-after-free: typically a Dawn validation error about a
+// garbage TextureFormat in viewFormats, sometimes a clean-looking frame
+// (freed memory not yet reused), a crash under ASan.
+//
+// The button churns the native heap first (shader module allocations) to
+// poison the freed allocation, then shrinks canvas.width by one pixel so the
+// next getCurrentTexture() triggers the reconfigure path.
+export const ViewFormatsUseAfterFree = () => {
+ const ref = useRef(null);
+ const { log, append } = useDiagnosticLog();
+ const [gpu, setGpu] = useState<{
+ device: GPUDevice;
+ context: RNCanvasContext;
+ } | null>(null);
+
+ useEffect(() => {
+ let running = true;
+ let frame = 0;
+ (async () => {
+ const { device, format } = await initGPU(append);
+ const ctx = ref.current!.getContext("webgpu")!;
+ const canvas = ctx.canvas as HTMLCanvasElement;
+ canvas.width = canvas.clientWidth * PixelRatio.get();
+ canvas.height = canvas.clientHeight * PixelRatio.get();
+ const viewFormat: GPUTextureFormat = `${format}-srgb` as GPUTextureFormat;
+ ctx.configure({
+ device,
+ format,
+ alphaMode: "opaque",
+ viewFormats: [viewFormat],
+ });
+ append(`configured ${format} with viewFormats: [${viewFormat}]`);
+ setGpu({ device, context: ctx });
+ const loop = () => {
+ if (!running) {
+ return;
+ }
+ try {
+ drawClearFrame(device, ctx, frame++);
+ } catch (e) {
+ append(`frame ${frame}: ${e}`);
+ }
+ requestAnimationFrame(loop);
+ };
+ loop();
+ })();
+ return () => {
+ running = false;
+ };
+ }, [append]);
+
+ const resize = () => {
+ if (!gpu) {
+ return;
+ }
+ const { device, context } = gpu;
+ // Churn the native heap so the freed viewFormats allocation gets reused
+ // before the reconfigure reads it.
+ for (let i = 0; i < 32; i++) {
+ device.createShaderModule({
+ code: `/* ${"poison".repeat(256 + i)} */
+@vertex fn v() -> @builtin(position) vec4f { return vec4f(0); }`,
+ });
+ }
+ const canvas = context.canvas as HTMLCanvasElement;
+ canvas.width -= 1;
+ append(
+ `canvas.width -> ${canvas.width}, next frame reconfigures with the dangling viewFormats pointer`,
+ );
+ };
+
+ return (
+
+
+
+ The canvas is configured with viewFormats. Resizing forces a native
+ reconfigure that re-reads the viewFormats array configure() was given;
+ on a broken build that memory was freed when configure() returned.
+
+
+
+
+
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/example/src/Diagnostics/surfaceLifecycle.tsx b/apps/example/src/Diagnostics/surfaceLifecycle.tsx
new file mode 100644
index 000000000..ac07729c0
--- /dev/null
+++ b/apps/example/src/Diagnostics/surfaceLifecycle.tsx
@@ -0,0 +1,96 @@
+import { useCallback, useState } from "react";
+import { StyleSheet } from "react-native";
+import type { RNCanvasContext } from "react-native-webgpu";
+
+// Shared plumbing for the surface lifecycle repros. Each screen renders a
+// trivial animated clear so that getCurrentTexture()/submit()/present() run
+// every frame; validation errors are surfaced through the uncapturederror
+// event so lifecycle holes that do not hard-crash still show up in the log.
+
+export interface DiagnosticGPU {
+ device: GPUDevice;
+ format: GPUTextureFormat;
+}
+
+export const initGPU = async (
+ onLog: (message: string) => void,
+): Promise => {
+ const adapter = await navigator.gpu.requestAdapter();
+ if (!adapter) {
+ throw new Error("No adapter");
+ }
+ const device = await adapter.requestDevice();
+ device.addEventListener("uncapturederror", (event) => {
+ const { error } = event as GPUUncapturedErrorEvent;
+ onLog(`[uncaptured] ${error.message.split("\n")[0]}`);
+ });
+ device.lost.then((info) => {
+ onLog(`[device lost] ${info.reason}: ${info.message}`);
+ });
+ return { device, format: navigator.gpu.getPreferredCanvasFormat() };
+};
+
+export const drawClearFrame = (
+ device: GPUDevice,
+ context: RNCanvasContext,
+ frame: number,
+) => {
+ const texture = context.getCurrentTexture();
+ const view = texture.createView();
+ const encoder = device.createCommandEncoder();
+ const t = frame / 60;
+ const pass = encoder.beginRenderPass({
+ colorAttachments: [
+ {
+ view,
+ clearValue: [
+ 0.5 + 0.5 * Math.sin(t),
+ 0.5 + 0.5 * Math.sin(t + 2.1),
+ 0.5 + 0.5 * Math.sin(t + 4.2),
+ 1,
+ ],
+ loadOp: "clear",
+ storeOp: "store",
+ },
+ ],
+ });
+ pass.end();
+ device.queue.submit([encoder.finish()]);
+ context.present();
+};
+
+export const useDiagnosticLog = () => {
+ const [log, setLog] = useState([]);
+ const append = useCallback((line: string) => {
+ console.log(`[surface-lifecycle] ${line}`);
+ setLog((prev) => [...prev.slice(-24), line]);
+ }, []);
+ return { log, append };
+};
+
+export const diagnosticStyles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ controls: {
+ padding: 12,
+ gap: 8,
+ },
+ description: {
+ fontSize: 12,
+ color: "#333",
+ },
+ canvas: {
+ flex: 1,
+ },
+ log: {
+ maxHeight: 200,
+ padding: 12,
+ backgroundColor: "#111",
+ },
+ logLine: {
+ fontFamily: "Menlo",
+ fontSize: 10,
+ color: "#0f0",
+ },
+});
diff --git a/apps/example/src/Home.tsx b/apps/example/src/Home.tsx
index 51baf4699..fd72751ad 100644
--- a/apps/example/src/Home.tsx
+++ b/apps/example/src/Home.tsx
@@ -116,16 +116,8 @@ export const examples = [
title: "🌈 Gradient Tiles",
},
{
- screen: "AsyncStarvation",
- title: "⚠️ Async Runner Starvation",
- },
- {
- screen: "DeviceLostHang",
- title: "⚠️ Device Lost Hang",
- },
- {
- screen: "WorkletRequestAdapter",
- title: "⚠️ Worklet requestAdapter",
+ screen: "Diagnostics",
+ title: "⚠️ Tests",
},
{
screen: "StorageBufferVertices",
diff --git a/apps/example/src/Route.ts b/apps/example/src/Route.ts
index cd2d7a329..a3c4cea20 100644
--- a/apps/example/src/Route.ts
+++ b/apps/example/src/Route.ts
@@ -26,9 +26,15 @@ export type Routes = {
CanvasAPI: undefined;
ComputeToys: undefined;
Reanimated: undefined;
+ Diagnostics: undefined;
AsyncStarvation: undefined;
DeviceLostHang: undefined;
WorkletRequestAdapter: undefined;
+ ContextEdgeCases: undefined;
+ ViewFormatsUseAfterFree: undefined;
+ RenderAfterUnmount: undefined;
+ BackgroundDetach: undefined;
+ SurfaceChurn: undefined;
StorageBufferVertices: undefined;
SharedTextureMemory: undefined;
ImportExternalTexture: undefined;