From 71904b5a8ac226c558600c536acf1ae76d88167e Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sat, 18 Jul 2026 16:56:53 +0200 Subject: [PATCH 1/2] repro(example): add surface lifecycle crash repros Five diagnostic screens that reproduce, on top of main, the surface lifecycle holes fixed by #426: - Context Edge Cases: getCurrentTexture() before configure() (null device deref), configure() on a 0x0 canvas (null texture createView), and the unconfigure() no-op stub. - viewFormats Use-After-Free: configure() with viewFormats, then resize; the stored configuration keeps a pointer into Convertor-owned memory that was freed when configure() returned. - Render After Unmount: the frame loop keeps rendering after the Canvas unmounts; native teardown frees the CAMetalLayer / TextureView window while the context still holds the wgpu::Surface wrapping it. - Background Detach: backgrounding on the Android TextureView path removes the registry entry, orphaning the context (permanent black screen); the SurfaceView path logs a present-without-acquire error on resume. - Surface Churn: automated remount/transparent-flip stress racing native view teardown against the JS frame loop. Co-Authored-By: Claude Fable 5 --- apps/example/src/App.tsx | 16 +++ .../src/Diagnostics/BackgroundDetach.tsx | 111 +++++++++++++++++ .../src/Diagnostics/ContextEdgeCases.tsx | 113 +++++++++++++++++ .../src/Diagnostics/RenderAfterUnmount.tsx | 91 ++++++++++++++ apps/example/src/Diagnostics/SurfaceChurn.tsx | 116 ++++++++++++++++++ .../Diagnostics/ViewFormatsUseAfterFree.tsx | 114 +++++++++++++++++ .../src/Diagnostics/surfaceLifecycle.tsx | 96 +++++++++++++++ apps/example/src/Home.tsx | 20 +++ apps/example/src/Route.ts | 5 + 9 files changed, 682 insertions(+) create mode 100644 apps/example/src/Diagnostics/BackgroundDetach.tsx create mode 100644 apps/example/src/Diagnostics/ContextEdgeCases.tsx create mode 100644 apps/example/src/Diagnostics/RenderAfterUnmount.tsx create mode 100644 apps/example/src/Diagnostics/SurfaceChurn.tsx create mode 100644 apps/example/src/Diagnostics/ViewFormatsUseAfterFree.tsx create mode 100644 apps/example/src/Diagnostics/surfaceLifecycle.tsx diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index 887eb0d66..1eeb3b232 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -36,6 +36,11 @@ import { Reanimated } from "./Reanimated"; import { AsyncStarvation } from "./Diagnostics/AsyncStarvation"; import { DeviceLostHang } from "./Diagnostics/DeviceLostHang"; 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"; @@ -102,6 +107,17 @@ function App() { name="WorkletRequestAdapter" component={WorkletRequestAdapter} /> + + + + + { + 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. + +