From 74dea47c2da31ec86e91bbaee2847067747aee5a Mon Sep 17 00:00:00 2001 From: Eduardo Borges Date: Wed, 2 Sep 2026 15:29:09 -0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(=F0=9F=92=A5):=20detach=20swapchains=20?= =?UTF-8?q?before=20destroying=20the=20device=20(Android=20Canvas=20unmoun?= =?UTF-8?q?t=20crash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Canvas unmounts on Android, releasing the wgpu::Surface runs ~Surface -> SwapChain::DetachFromSurface, which reads the device's FencedDeleter. If the GPUDevice was already destroyed (e.g. three's WebGPURenderer.dispose() calls device.destroy() synchronously in the same teardown), that FencedDeleter is freed and Dawn dereferences it: SIGSEGV on the Vulkan backend (fault addr 0x20). Metal tolerates the order. Surface.Unconfigure() alone is not enough because the swapchain is detached by ~Surface, not by Unconfigure(). GPUDevice::destroy() now detaches and releases every surface bound to the device (running ~Surface while the device is still alive) before _instance.Destroy(). The native window pointer is kept so the later view teardown still returns it. --- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 33 ++++++++++++++++++++ packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 5 +++ 2 files changed, 38 insertions(+) diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 8dad9d36f..99a2d838e 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -270,6 +270,30 @@ class SurfaceInfo { _frameEpoch++; } + // Detach this surface's swapchain when it is bound to `device`, called from + // GPUDevice::destroy() before the device is torn down. Releasing the Dawn + // surface here runs ~Surface -> SwapChain::DetachFromSurface while the + // device still owns its FencedDeleter. Left to the native view teardown + // (SurfaceInfo::detach, one frame later) the device is already destroyed and + // Dawn dereferences a freed FencedDeleter -> SIGSEGV on the Vulkan backend. + // The native window pointer stays in _nativeSurface so detach() still + // returns it to the platform. + void unconfigureIfDevice(const wgpu::Device &device) { + std::unique_lock lock(_mutex); + if (_config.device == nullptr || _config.device.Get() != device.Get()) { + return; + } + if (_surface) { + _surface.Unconfigure(); + _surface = nullptr; + _acquiredFromSurface = false; + } + _texture = nullptr; + _config = {}; + _viewFormats.clear(); + _frameEpoch++; + } + bool isConfigured() { std::shared_lock lock(_mutex); return _config.device != nullptr; @@ -587,6 +611,15 @@ class SurfaceRegistry { _registry.clear(); } + // Detach every surface configured with `device` before it is destroyed. + // Lock order is registry -> SurfaceInfo, matching every other path. + void unconfigureDevice(const wgpu::Device &device) { + std::shared_lock lock(_mutex); + for (auto &entry : _registry) { + entry.second->unconfigureIfDevice(device); + } + } + private: SurfaceRegistry() = default; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index 13ce67eb5..790065716 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -16,6 +16,7 @@ #include "GPUOutOfMemoryError.h" #include "GPUValidationError.h" #include "RnFeatures.h" +#include "SurfaceRegistry.h" namespace rnwgpu { @@ -149,6 +150,10 @@ std::shared_ptr GPUDevice::createCommandEncoder( } void GPUDevice::destroy() { + // Detach every swapchain bound to this device before destroying it, so the + // native surface teardown does not dereference a freed FencedDeleter (a + // SIGSEGV on the Vulkan backend when a Canvas unmounts). + rnwgpu::SurfaceRegistry::getInstance().unconfigureDevice(_instance); _instance.Destroy(); notifyDeviceLost(wgpu::DeviceLostReason::Destroyed, "device was destroyed"); } From 6abfec43dee13d2653fbce675894db81e2e18f14 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Tue, 8 Sep 2026 20:34:20 +0200 Subject: [PATCH 2/3] :wrench: --- .../src/Diagnostics/ContextEdgeCases.tsx | 62 +++++++- packages/webgpu/android/cpp/cpp-adapter.cpp | 17 +- packages/webgpu/apple/MetalView.mm | 13 +- packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 149 +++++++++++++----- packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 5 +- packages/webgpu/src/__tests__/Device.spec.ts | 33 ++++ 6 files changed, 225 insertions(+), 54 deletions(-) diff --git a/apps/example/src/Diagnostics/ContextEdgeCases.tsx b/apps/example/src/Diagnostics/ContextEdgeCases.tsx index 01ef7f808..ebe060e36 100644 --- a/apps/example/src/Diagnostics/ContextEdgeCases.tsx +++ b/apps/example/src/Diagnostics/ContextEdgeCases.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React, { 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"; @@ -29,11 +29,57 @@ import { // the context keeps handing out textures as if still configured, where the // spec says the canvas should behave as if it was never configured. // +// 4. device.destroy() on the device a mounted Canvas is configured with. +// Dawn's Vulkan backend cannot detach a swapchain after its device was +// destroyed, so the Canvas unmount crashed on Android. Per spec the canvas +// stays configured (getCurrentTexture() must not throw) and configure() +// with a replacement device must render on screen again; the unmount +// toggle then checks the swapchain teardown. "unconfigure() first" covers +// the idiomatic cleanup order, where Dawn parks the old swapchain in the +// surface for reuse instead of detaching it. +// // 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 [mounted, setMounted] = useState(true); + + const destroyDevice = async (unconfigureFirst: boolean) => { + try { + const { device, format } = await initGPU(append); + const ctx = ref.current!.getContext("webgpu")!; + ctx.configure({ device, format, alphaMode: "opaque" }); + drawClearFrame(device, ctx, 0); + if (unconfigureFirst) { + append("rendered one frame, calling unconfigure()..."); + ctx.unconfigure(); + } else { + append("rendered one frame"); + } + append("calling device.destroy()..."); + device.destroy(); + if (!unconfigureFirst) { + const texture = ctx.getCurrentTexture(); + append( + `getCurrentTexture() after destroy() -> ${texture.width}x${texture.height} (spec: invalid texture, no throw)`, + ); + ctx.present(); + } + const replacement = await initGPU(append); + ctx.configure({ + device: replacement.device, + format: replacement.format, + alphaMode: "opaque", + }); + drawClearFrame(replacement.device, ctx, 30); + append( + "reconfigured with a new device and rendered: the canvas should show a new color. Now unmount the canvas.", + ); + } catch (e) { + append(`threw: ${e}`); + } + }; const getCurrentTextureUnconfigured = () => { try { @@ -99,8 +145,20 @@ export const ContextEdgeCases = () => { title="unconfigure() then getCurrentTexture()" onPress={unconfigureStub} /> +