From 0ed4fdf67718e2d1e67c94e7110a1ad462363d6b Mon Sep 17 00:00:00 2001 From: William Candillon Date: Tue, 20 Oct 2026 15:39:27 +0200 Subject: [PATCH 1/4] :wrench: --- .../docs/getting-started/native-api.mdx | 8 + packages/webgpu/apple/WebGPUModule.mm | 38 ++++ packages/webgpu/cpp/rnwgpu/GpuWorkGate.h | 190 ++++++++++++++++++ packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 21 +- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 7 +- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 118 +++++++++-- .../cpp/rnwgpu/async/RuntimeContext.cpp | 38 +++- .../webgpu/cpp/rnwgpu/async/RuntimeContext.h | 4 +- 8 files changed, 387 insertions(+), 37 deletions(-) create mode 100644 packages/webgpu/cpp/rnwgpu/GpuWorkGate.h diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index e7be17692..b0e082287 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -37,6 +37,14 @@ Dawn's implicit device synchronization, enabled by default, lets a device be use See [Worklets](/docs/integrations/worklets) for the full integration guide. +## Background and foreground + +iOS refuses GPU work from an app in the background, including work still queued when the app got there, and Dawn loses the device over a refused command buffer. +React Native WebGPU follows Apple's guidance for Metal apps so that this never happens: when the app goes to the background, every device is drained (what was submitted completes before the app is suspended), and from then on `queue.submit()`, `writeBuffer()`, `writeTexture()`, `copyExternalImageToTexture()` and `context.present()` are held, in order, and run when the app comes back to the foreground. +Nothing changes for your code: a held `onSubmittedWorkDone()` or `mapAsync()` promise settles once the app is back. +A render loop should still pause while the app is in the background (React Native's `AppState`), if only to spare the battery; what it submits meanwhile is kept in memory until the app returns. +Android and macOS do not refuse GPU work from a background app, so nothing is held there. + ## Loading images This module provides a `createImageBitmap` function you can use with `copyExternalImageToTexture`. diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index 62f58dfb5..c7aa994f0 100644 --- a/packages/webgpu/apple/WebGPUModule.mm +++ b/packages/webgpu/apple/WebGPUModule.mm @@ -2,6 +2,7 @@ #include "ApplePlatformContext.h" #import "GPUCanvasContext.h" #import "RNWGUIKit.h" +#include "rnwgpu/GpuWorkGate.h" #import #import @@ -97,9 +98,46 @@ - (void)invalidate { std::make_shared(std::move(viewLookup)); webgpuManager = std::make_shared(runtime, jsInvoker, platformContext); + [WebGPUModule observeApplicationState]; return @true; } +// iOS refuses GPU work from an app in the background, and Dawn loses the +// device over it: GPU work is drained when the app goes to the background and +// held until it comes back (see GpuWorkGate). Observed once per process, the +// gate being process-wide. The blocks run on the thread the notification is +// posted on (the main thread), synchronously with it, so that the drain is +// done before the app is suspended. macOS does not refuse GPU work from a +// hidden app. ++ (void)observeApplicationState { +#if !TARGET_OS_OSX + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; + [center addObserverForName:UIApplicationDidEnterBackgroundNotification + object:nil + queue:nil + usingBlock:^(NSNotification *) { + rnwgpu::GpuWorkGate::getInstance().suspend(); + }]; + [center addObserverForName:UIApplicationWillEnterForegroundNotification + object:nil + queue:nil + usingBlock:^(NSNotification *) { + rnwgpu::GpuWorkGate::getInstance().resume(); + }]; + // Installed while already in the background (a headless launch): hold + // from the start. The application state is main-thread only. + dispatch_async(dispatch_get_main_queue(), ^{ + if (UIApplication.sharedApplication.applicationState == + UIApplicationStateBackground) { + rnwgpu::GpuWorkGate::getInstance().suspend(); + } + }); + }); +#endif +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { return std::make_shared(params); diff --git a/packages/webgpu/cpp/rnwgpu/GpuWorkGate.h b/packages/webgpu/cpp/rnwgpu/GpuWorkGate.h new file mode 100644 index 000000000..3c7ce0ec9 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/GpuWorkGate.h @@ -0,0 +1,190 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "webgpu/webgpu_cpp.h" + +#include "WGPULogger.h" + +namespace rnwgpu { + +/** + * Whether GPU work may reach the device right now, and the work held back + * while it may not. + * + * iOS refuses GPU work from an app in the background, including work still + * queued when the app got there, and Dawn (since 157) loses the device over + * a refused Metal command buffer. So the gate follows Apple's guidance for + * Metal apps: when the app goes to the background (WebGPUModule.mm, on the + * notification itself so that it is done before the app is suspended) every + * device is drained, that is what Dawn staged is committed and the GPU is + * waited for, and from then on everything that would commit a command buffer + * is held, in order, and run when the app comes back. That is queue.submit, + * writeBuffer and writeTexture (Dawn commits their staging past 16 MB), the + * internal blits, and presenting a canvas (Dawn commits the pending command + * buffer along with the drawable). Dawn's ProcessEvents ticks every device, + * which commits pending commands too, so the async pump (RuntimeContext) + * idles meanwhile. + * + * Only a platform that refuses GPU work suspends the gate; elsewhere it stays + * open and run() costs a lock. Any thread; the work itself runs outside of + * the gate's lock, so it may take other locks (a surface's, Dawn's). + */ +class GpuWorkGate { +public: + static GpuWorkGate &getInstance() { + // Never destroyed: work may be run during process teardown. + static auto *instance = new GpuWorkGate(); + return *instance; + } + + GpuWorkGate(const GpuWorkGate &) = delete; + GpuWorkGate &operator=(const GpuWorkGate &) = delete; + + /** Whether the app is in the background and GPU work is held. */ + bool isSuspended() const { + return _suspended.load(std::memory_order_acquire); + } + + /** + Runs `work` now, or holds it for resume() while suspended. The work must + own what it uses by then (the held copy is the same closure). + */ + void run(std::function work) { + runOrHold(work, [&work]() { return std::move(work); }); + } + + /** + Runs `work` now, or holds the closure `hold` makes while suspended. For + work pointing at memory it does not own (the bytes of a JS ArrayBuffer): + `hold` copies them into a closure of its own, and is only called while + suspended, so the common path copies nothing. + */ + template + void runOrHold(Work &&work, Hold &&hold) { + { + std::unique_lock lock(_mutex); + if (_suspended.load(std::memory_order_relaxed)) { + _held.push_back(hold()); + return; + } + _active++; + } + // Counted while it runs, so that suspend() waits for it before draining + // (the count is released on an exception too). + struct Running { + GpuWorkGate &gate; + ~Running() { + std::lock_guard lock(gate._mutex); + if (--gate._active == 0) { + gate._idle.notify_all(); + } + } + } running{*this}; + work(); + } + + /** + A device whose queue suspend() drains: every device the API wraps + (GPUDevice tracks itself), imported ones included. Untracked when the + wrapper goes. + */ + void trackDevice(const wgpu::Device &device) { + std::lock_guard lock(_mutex); + _devices[device.Get()] = device; + } + + void untrackDevice(WGPUDevice handle) { + std::lock_guard lock(_mutex); + _devices.erase(handle); + } + + /** + The app is going to the background: holds the work from here on, lets the + work being run go through, then drains every device (see drain). Called + on the thread the app state notification arrives on, and returns before + the app is suspended: a drain is a frame's worth of GPU work. + */ + void suspend() { + std::vector devices; + { + std::unique_lock lock(_mutex); + if (_suspended.exchange(true, std::memory_order_acq_rel)) { + return; + } + _idle.wait(lock, [this]() { return _active == 0; }); + devices.reserve(_devices.size()); + for (const auto &entry : _devices) { + devices.push_back(entry.second); + } + } + for (const auto &device : devices) { + drain(device); + } + } + + /** The app is back in the foreground: runs the work held meanwhile, in order. + */ + void resume() { + std::vector> held; + { + std::lock_guard lock(_mutex); + if (!_suspended.exchange(false, std::memory_order_acq_rel)) { + return; + } + held.swap(_held); + } + for (auto &work : held) { + // Through run(), so that a suspend() racing with the replay waits for + // the work or holds it again. + run(std::move(work)); + } + } + +private: + GpuWorkGate() = default; + + /** + Commits what Dawn staged on the device (writeBuffer and writeTexture data, + lazy clears) with an empty submit and waits for the GPU to finish all of + it, within kDrainTimeoutNs. A device that does not answer in time is left + to its fate: with work in flight it is lost when the app is suspended, and + its owner handles that (device.lost, Skia's recovery). A lost device + answers right away. + */ + static void drain(const wgpu::Device &device) { + auto queue = device.GetQueue(); + queue.Submit(0, nullptr); + auto future = queue.OnSubmittedWorkDone( + wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::QueueWorkDoneStatus, wgpu::StringView) {}); + wgpu::FutureWaitInfo wait{future}; + auto status = + device.GetAdapter().GetInstance().WaitAny(1, &wait, kDrainTimeoutNs); + if (status != wgpu::WaitStatus::Success) { + Logger::logToConsole( + "[WebGPU] GPU work was still running when the app went to the " + "background; the device may be lost."); + } + } + + static constexpr uint64_t kDrainTimeoutNs = 500ull * 1000 * 1000; + + std::mutex _mutex; + std::condition_variable _idle; + std::atomic _suspended{false}; + // Work being run right now, which suspend() waits for. + int _active = 0; + // The work held while suspended, in order. + std::vector> _held; + std::unordered_map _devices; +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index e734e65f6..316acf9e1 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -19,6 +19,8 @@ #include "webgpu/webgpu_cpp.h" +#include "rnwgpu/GpuWorkGate.h" + #if defined(__ANDROID__) #include #include @@ -307,7 +309,10 @@ class SurfaceInfo { // (their newer content stands; presenting here would be a Dawn // present-without-acquire error). if (_surface && !_frameInFlight && _frameEpoch == blitEpoch) { - _surface.Present(); + // Presenting commits Dawn's pending command buffer: held while the + // app is in the background, after the blit (see GpuWorkGate). + GpuWorkGate::getInstance().run( + [surface = _surface]() { surface.Present(); }); } } } @@ -555,7 +560,13 @@ class SurfaceInfo { { std::unique_lock lock(_mutex); if (_surface && _acquiredFromSurface) { - _surface.Present(); + // Presenting commits Dawn's pending command buffer: held while the + // app is in the background, in order after the frame's submit (see + // GpuWorkGate). A held present runs on the thread the app comes back + // on, which Dawn's surface does not mind; the frame state below is + // settled now either way. + GpuWorkGate::getInstance().run( + [surface = _surface]() { surface.Present(); }); #if defined(__ANDROID__) framePresented = _framePresented; #endif @@ -867,7 +878,11 @@ class SurfaceInfo { _config.device.CreateCommandEncoder(&encoderDescriptor); encoder.CopyTextureToTexture(&source, &destination, &size); wgpu::CommandBuffer commands = encoder.Finish(); - _config.device.GetQueue().Submit(1, &commands); + // Held while the app is in the background (see GpuWorkGate). + GpuWorkGate::getInstance().run( + [queue = _config.device.GetQueue(), commands]() { + queue.Submit(1, &commands); + }); return true; } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 4507c6459..f889b69ae 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -15,6 +15,7 @@ #include "NativeObject.h" +#include "rnwgpu/GpuWorkGate.h" #include "rnwgpu/async/AsyncTaskHandle.h" #include "rnwgpu/async/RuntimeContext.h" @@ -72,9 +73,13 @@ class GPUDevice : public NativeObject { std::shared_ptr async, std::string label) : NativeObject(CLASS_NAME), _instance(instance), _async(async), - _label(label) {} + _label(label) { + // Drained when the app goes to the background (see GpuWorkGate). + GpuWorkGate::getInstance().trackDevice(_instance); + } ~GPUDevice() override { + GpuWorkGate::getInstance().untrackDevice(_instance.Get()); // Unregister from the static registry unregisterDevice(_instance.Get()); } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index 1cf179b25..d0cb7a273 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -12,6 +12,7 @@ #include "EnforceRange.h" #include "ImageBitmap.h" #include "PlatformContext.h" +#include "rnwgpu/GpuWorkGate.h" namespace rnwgpu { @@ -32,7 +33,11 @@ void GPUQueue::submit( if (!conv(bufs_size, bufs.size())) { return; } - _instance.Submit(bufs_size, bufs.data()); + // Held while the app is in the background (see GpuWorkGate). + GpuWorkGate::getInstance().run( + [queue = _instance, bufs = std::move(bufs), bufs_size]() { + queue.Submit(bufs_size, bufs.data()); + }); } void GPUQueue::writeBuffer(std::shared_ptr buffer, @@ -81,8 +86,19 @@ void GPUQueue::writeBuffer(std::shared_ptr buffer, } assert(size64 <= std::numeric_limits::max()); - _instance.WriteBuffer(buf, bufferOffset, src.data, - static_cast(size64)); + const auto size = static_cast(size64); + const auto *bytes = static_cast(src.data); + // Dawn commits its staging past 16 MB of writes: held while the app is in + // the background (see GpuWorkGate). WebGPU copies the bytes at the call, + // so the held write takes a copy of them, the ArrayBuffer may change. + GpuWorkGate::getInstance().runOrHold( + [&]() { _instance.WriteBuffer(buf, bufferOffset, bytes, size); }, + [queue = _instance, buf, bufferOffset, bytes, size]() { + std::vector copy(bytes, bytes + size); + return [queue, buf, bufferOffset, copy = std::move(copy)]() { + queue.WriteBuffer(buf, bufferOffset, copy.data(), copy.size()); + }; + }); } async::AsyncTaskHandle GPUQueue::onSubmittedWorkDone(jsi::Runtime &runtime) { @@ -94,19 +110,25 @@ async::AsyncTaskHandle GPUQueue::onSubmittedWorkDone(jsi::Runtime &runtime) { return context->postTask( [queue](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { - queue.OnSubmittedWorkDone( - wgpu::CallbackMode::AllowProcessEvents, - [resolve, reject](wgpu::QueueWorkDoneStatus status, - wgpu::StringView message) { - if (status == wgpu::QueueWorkDoneStatus::Success) { - resolve(nullptr); - } else { - std::string error = - message.length ? std::string(message.data, message.length) - : "Queue work did not complete successfully"; - reject(std::move(error)); - } - }); + // Registered in order with the submits: one held while the app is in + // the background (see GpuWorkGate) waits for the held submits before + // it rather than settling on the idle queue. + GpuWorkGate::getInstance().run([queue, resolve, reject]() { + queue.OnSubmittedWorkDone( + wgpu::CallbackMode::AllowProcessEvents, + [resolve, reject](wgpu::QueueWorkDoneStatus status, + wgpu::StringView message) { + if (status == wgpu::QueueWorkDoneStatus::Success) { + resolve(nullptr); + } else { + std::string error = + message.length + ? std::string(message.data, message.length) + : "Queue work did not complete successfully"; + reject(std::move(error)); + } + }); + }); }); } @@ -163,7 +185,12 @@ void GPUQueue::copyVideoFrameToTexture( } request.dstX = dstOrigin.x; request.dstY = dstOrigin.y; - _videoFrameBlit->copy(_device, _instance, request); + // The blit submits: held while the app is in the background (see + // GpuWorkGate), with the frame it copies. + GpuWorkGate::getInstance().run( + [blit = _videoFrameBlit, device = _device, queue = _instance, request]() { + blit->copy(device, queue, request); + }); } void GPUQueue::copyExternalImageToTexture( @@ -249,10 +276,33 @@ void GPUQueue::copyExternalImageToTexture( } layout.bytesPerRow = rowSize; layout.rowsPerImage = sz.height; - _instance.WriteTexture(&dst, staged.data(), totalSize, &layout, &sz); + // Held while the app is in the background (see GpuWorkGate), with the + // staged pixels. + GpuWorkGate::getInstance().runOrHold( + [&]() { + _instance.WriteTexture(&dst, staged.data(), totalSize, &layout, &sz); + }, + [&]() { + return [queue = _instance, dst, layout, sz, + staged = std::move(staged)]() { + queue.WriteTexture(&dst, staged.data(), staged.size(), &layout, + &sz); + }; + }); } else { - _instance.WriteTexture(&dst, source->source->getData(), - source->source->getSize(), &layout, &sz); + // Same, keeping the ImageBitmap alive for its pixels. + GpuWorkGate::getInstance().runOrHold( + [&]() { + _instance.WriteTexture(&dst, source->source->getData(), + source->source->getSize(), &layout, &sz); + }, + [&]() { + return + [queue = _instance, dst, layout, sz, bitmap = source->source]() { + queue.WriteTexture(&dst, bitmap->getData(), bitmap->getSize(), + &layout, &sz); + }; + }); } } @@ -465,7 +515,20 @@ async::AsyncTaskHandle GPUQueue::drawElementImageToTexture( layout.rowsPerImage = static_cast(image.height); wgpu::Extent3D extent{static_cast(image.width), static_cast(image.height), 1}; - queue.WriteTexture(&dst, pixels, byteLength, &layout, &extent); + // Held while the app is in the background (see GpuWorkGate), + // with the pixels. + GpuWorkGate::getInstance().runOrHold( + [&]() { + queue.WriteTexture(&dst, pixels, byteLength, &layout, + &extent); + }, + [&]() { + return [queue, dst, layout, extent, byteLength, + image = std::move(image)]() { + queue.WriteTexture(&dst, image.data.data(), byteLength, + &layout, &extent); + }; + }); return jsi::Value::undefined(); }); }, @@ -548,7 +611,18 @@ void GPUQueue::writeTexture(std::shared_ptr destination, throw std::runtime_error("Invalid input for GPUQueue::writeTexture()"); } - _instance.WriteTexture(&dst, data->_data, data->_size, &layout, &sz); + // Held while the app is in the background (see GpuWorkGate), with a copy + // of the bytes: WebGPU copies them at the call, the ArrayBuffer may change. + const auto *bytes = static_cast(data->_data); + const size_t byteLength = data->_size; + GpuWorkGate::getInstance().runOrHold( + [&]() { _instance.WriteTexture(&dst, bytes, byteLength, &layout, &sz); }, + [queue = _instance, dst, layout, sz, bytes, byteLength]() { + std::vector copy(bytes, bytes + byteLength); + return [queue, dst, layout, sz, copy = std::move(copy)]() { + queue.WriteTexture(&dst, copy.data(), copy.size(), &layout, &sz); + }; + }); } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp index db4aab4ff..f1b8c83e7 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp @@ -9,6 +9,7 @@ #include "AsyncTaskHandle.h" #include "WGPULogger.h" +#include "rnwgpu/GpuWorkGate.h" namespace rnwgpu::async { @@ -17,6 +18,8 @@ struct RuntimeData { std::shared_ptr context; }; constexpr const char *TAG = "RuntimeContext"; +// How often the pump looks again while GPU work is held (see tick). +constexpr double kSuspendedTickDelayMs = 50; // The main JS runtime and its CallInvoker, registered once on install. The // context created for sMainRuntime gets sMainInvoker; spontaneous events @@ -26,8 +29,9 @@ jsi::Runtime *sMainRuntime = nullptr; std::shared_ptr sMainInvoker; // Serializes ProcessEvents() across all runtimes that share a wgpu::Instance. -// Held only across the ProcessEvents call itself, never while running JS / mailbox -// settle-actions, so it cannot deadlock against the per-context mailbox mutex. +// Held only across the ProcessEvents call itself, never while running JS / +// mailbox settle-actions, so it cannot deadlock against the per-context mailbox +// mutex. std::mutex &processEventsMutex() { static std::mutex mutex; return mutex; @@ -80,7 +84,8 @@ AsyncTaskHandle RuntimeContext::postTask(const TaskCallback &callback, // Only pumping tasks (request/response ops) drive the ProcessEvents pump. // Spontaneous tasks (keepPumping == false, e.g. device.lost) never touch the - // pump: they settle via the CallInvoker (see AsyncTaskHandle::State::schedule). + // pump: they settle via the CallInvoker (see + // AsyncTaskHandle::State::schedule). if (keepPumping) { _pumpTasks.fetch_add(1, std::memory_order_acq_rel); requestTick(); @@ -125,7 +130,7 @@ void RuntimeContext::drainMailbox() { } } -void RuntimeContext::requestTick() { +void RuntimeContext::requestTick(double delayMs) { bool expected = false; if (!_tickScheduled.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { @@ -135,8 +140,8 @@ void RuntimeContext::requestTick() { // The pump only ever runs while a request/response op is outstanding, so it // always schedules as soon as possible (delay 0). postTask and tick both run // on the owning runtime's thread, so we schedule the next tick directly via - // that runtime's own timer. setTimeout is available on the main RN runtime and - // on worklet runtimes (backed by the worklets EventLoop); setImmediate / + // that runtime's own timer. setTimeout is available on the main RN runtime + // and on worklet runtimes (backed by the worklets EventLoop); setImmediate / // queueMicrotask are fallbacks. We do NOT use queueMicrotask as the primary // mechanism: a self-rescheduling microtask never yields the microtask // checkpoint, starving the runtime's task loop. @@ -155,7 +160,7 @@ void RuntimeContext::requestTick() { if (setTimeoutValue.isObject() && setTimeoutValue.asObject(rt).isFunction(rt)) { setTimeoutValue.asObject(rt).asFunction(rt).call( - rt, jsi::Value(rt, tickCallback), jsi::Value(0.0)); + rt, jsi::Value(rt, tickCallback), jsi::Value(delayMs)); return; } auto setImmediateValue = global.getProperty(rt, "setImmediate"); @@ -170,6 +175,18 @@ void RuntimeContext::requestTick() { void RuntimeContext::tick() { _tickScheduled.store(false, std::memory_order_release); + if (GpuWorkGate::getInstance().isSuspended()) { + // ProcessEvents ticks every device on the instance, which commits the + // commands Dawn staged: nothing may reach the GPU while the app is in the + // background (see GpuWorkGate). What already settled is still delivered; + // the rest waits for the app to come back, looked at again at a slow pace + // in case the runtime's timers keep running meanwhile. + drainMailbox(); + if (_pumpTasks.load(std::memory_order_acquire) > 0) { + requestTick(kSuspendedTickDelayMs); + } + return; + } { // Serialize ProcessEvents across runtimes sharing this instance. Callbacks // fired here only deposit into mailboxes (postSettle), they do not run JS. @@ -178,9 +195,10 @@ void RuntimeContext::tick() { } // Settle this runtime's ready promises on this thread, outside the pump lock. drainMailbox(); - // Keep pumping only while a "pumping" task (active async work) is outstanding. - // Non-pumping tasks (e.g. device.lost) intentionally do NOT keep the pump - // alive: we prioritise battery over catching a device.lost fired while idle. + // Keep pumping only while a "pumping" task (active async work) is + // outstanding. Non-pumping tasks (e.g. device.lost) intentionally do NOT keep + // the pump alive: we prioritise battery over catching a device.lost fired + // while idle. if (_pumpTasks.load(std::memory_order_acquire) > 0) { requestTick(); } diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h index 6787a60db..0fffda95a 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h @@ -120,7 +120,9 @@ class RuntimeContext : public std::enable_shared_from_this { private: static jsi::UUID runtimeDataUUID(); - void requestTick(); + // Schedules tick() on this runtime, once. `delayMs` is 0 but while GPU + // work is held (see tick). + void requestTick(double delayMs = 0); void tick(); void drainMailbox(); From 0d2883407526e2be00cf02cfd0d27f7caf9cb430 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 09:28:17 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix(=F0=9F=8D=8F):=20hold=20GPU=20work=20in?= =?UTF-8?q?=20the=20background=20through=20Dawn's=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS refuses GPU work from an app in the background, and Dawn 157 turns the refused Metal command buffer into a device loss. The gate this branch put in front of Dawn (GpuWorkGate) could not see every commit: Dawn commits from inside WaitAny and ProcessEvents too, and a drain from outside races with the hold the same notification sets inside Dawn. react-native-skia now ships a Dawn with a device toggle for exactly this, metal_suspend_submissions_in_background (react-native-webgpu-dawn 157.1.0, scripts/dawn-patches in react-native-skia): while the app is in the background Dawn encodes the submissions but commits nothing, drops the presents, and commits the held work when the app comes back; on entering the background the pending work is committed and waited on to be scheduled, as Apple asks. Nothing is lost, reordered or refused, and a promise on held work settles once the app is back, from the waiter thread that sleeps on it. Every device requestDevice makes on Apple platforms asks for the toggle (Dawn applies it on iOS and tvOS only, a Dawn without it drops the name), unless the caller disables it through dawnToggles. A device imported from Skia already has it. The gate, its drain, the held call sites and the pump hold are gone; the docs describe the hold and the Dawn version it needs. --- .../content/api/gpu-device-extensions.mdx | 2 + .../docs/getting-started/native-api.mdx | 7 +- packages/webgpu/apple/WebGPUModule.mm | 38 ---- packages/webgpu/cpp/rnwgpu/GpuWorkGate.h | 190 ------------------ packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h | 20 +- packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 26 +++ packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 7 +- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 86 +------- 8 files changed, 46 insertions(+), 330 deletions(-) delete mode 100644 packages/webgpu/cpp/rnwgpu/GpuWorkGate.h diff --git a/apps/docs/content/api/gpu-device-extensions.mdx b/apps/docs/content/api/gpu-device-extensions.mdx index 71f1783da..a788e1425 100644 --- a/apps/docs/content/api/gpu-device-extensions.mdx +++ b/apps/docs/content/api/gpu-device-extensions.mdx @@ -161,6 +161,8 @@ const device = await adapter.requestDevice({ On Android, the `ignore_imported_ahardwarebuffer_vulkan_image_size` toggle is enabled by default, so native buffers import on every driver. Pass it in `disabledToggles` to turn it off. +On iOS and tvOS, the `metal_suspend_submissions_in_background` toggle is enabled by default, so GPU work submitted while the app is in the background is held rather than refused (see [Background and foreground](/docs/getting-started/native-api#background-and-foreground)). Pass it in `disabledToggles` to turn it off. + ## implicitDeviceSynchronization By default, `requestDevice()` enables Dawn's [`implicit-device-synchronization`](https://dawn.googlesource.com/dawn/+/refs/heads/main/docs/dawn/features/implicit_device_synchronization.md) feature, which lets a device created on the JS thread be used from the UI thread or a dedicated worklet runtime (see [Thread safety](/docs/integrations/worklets#thread-safety)). diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index cc31cbe62..61054cf5d 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -40,10 +40,13 @@ See [Worklets](/docs/integrations/worklets) for the full integration guide. ## Background and foreground iOS refuses GPU work from an app in the background, including work still queued when the app got there, and Dawn loses the device over a refused command buffer. -React Native WebGPU follows Apple's guidance for Metal apps so that this never happens: when the app goes to the background, every device is drained (what was submitted completes before the app is suspended), and from then on `queue.submit()`, `writeBuffer()`, `writeTexture()`, `copyExternalImageToTexture()` and `context.present()` are held, in order, and run when the app comes back to the foreground. +React Native WebGPU holds that work instead, with Dawn's `metal_suspend_submissions_in_background` toggle, enabled on every device it creates on iOS and tvOS: when the app goes to the background, what was submitted is committed before the app is suspended, and from then on every submission is encoded but not committed, in order, until the app comes back to the foreground. +Presenting a canvas does nothing meanwhile; the canvas catches up on return. Nothing changes for your code: a held `onSubmittedWorkDone()` or `mapAsync()` promise settles once the app is back. -A render loop should still pause while the app is in the background (React Native's `AppState`), if only to spare the battery; what it submits meanwhile is kept in memory until the app returns. +A render loop should still pause while the app is in the background (React Native's `AppState`), if only to spare the battery: what it submits meanwhile is kept in memory until the app returns. Android and macOS do not refuse GPU work from a background app, so nothing is held there. +The hold needs `react-native-webgpu-dawn` 157.1.0 or later; an older Dawn drops the toggle, and the device is lost over the first refused command buffer (its `lost` promise then resolves with the reason `unknown`). +Pass the toggle in `disabledToggles` (see [dawnToggles](/docs/api/gpu-device-extensions#dawntoggles)) to opt out. ## Loading images diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index 1a573e33e..d0334a0c8 100644 --- a/packages/webgpu/apple/WebGPUModule.mm +++ b/packages/webgpu/apple/WebGPUModule.mm @@ -2,7 +2,6 @@ #include "ApplePlatformContext.h" #import "GPUCanvasContext.h" #import "RNWGUIKit.h" -#include "rnwgpu/GpuWorkGate.h" #import #import @@ -112,46 +111,9 @@ - (void)invalidate { std::move(viewLookup), std::move(blobManagerLookup)); webgpuManager = std::make_shared(runtime, jsInvoker, platformContext); - [WebGPUModule observeApplicationState]; return @true; } -// iOS refuses GPU work from an app in the background, and Dawn loses the -// device over it: GPU work is drained when the app goes to the background and -// held until it comes back (see GpuWorkGate). Observed once per process, the -// gate being process-wide. The blocks run on the thread the notification is -// posted on (the main thread), synchronously with it, so that the drain is -// done before the app is suspended. macOS does not refuse GPU work from a -// hidden app. -+ (void)observeApplicationState { -#if !TARGET_OS_OSX - static dispatch_once_t once; - dispatch_once(&once, ^{ - NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; - [center addObserverForName:UIApplicationDidEnterBackgroundNotification - object:nil - queue:nil - usingBlock:^(NSNotification *) { - rnwgpu::GpuWorkGate::getInstance().suspend(); - }]; - [center addObserverForName:UIApplicationWillEnterForegroundNotification - object:nil - queue:nil - usingBlock:^(NSNotification *) { - rnwgpu::GpuWorkGate::getInstance().resume(); - }]; - // Installed while already in the background (a headless launch): hold - // from the start. The application state is main-thread only. - dispatch_async(dispatch_get_main_queue(), ^{ - if (UIApplication.sharedApplication.applicationState == - UIApplicationStateBackground) { - rnwgpu::GpuWorkGate::getInstance().suspend(); - } - }); - }); -#endif -} - - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { return std::make_shared(params); diff --git a/packages/webgpu/cpp/rnwgpu/GpuWorkGate.h b/packages/webgpu/cpp/rnwgpu/GpuWorkGate.h deleted file mode 100644 index 3c7ce0ec9..000000000 --- a/packages/webgpu/cpp/rnwgpu/GpuWorkGate.h +++ /dev/null @@ -1,190 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "webgpu/webgpu_cpp.h" - -#include "WGPULogger.h" - -namespace rnwgpu { - -/** - * Whether GPU work may reach the device right now, and the work held back - * while it may not. - * - * iOS refuses GPU work from an app in the background, including work still - * queued when the app got there, and Dawn (since 157) loses the device over - * a refused Metal command buffer. So the gate follows Apple's guidance for - * Metal apps: when the app goes to the background (WebGPUModule.mm, on the - * notification itself so that it is done before the app is suspended) every - * device is drained, that is what Dawn staged is committed and the GPU is - * waited for, and from then on everything that would commit a command buffer - * is held, in order, and run when the app comes back. That is queue.submit, - * writeBuffer and writeTexture (Dawn commits their staging past 16 MB), the - * internal blits, and presenting a canvas (Dawn commits the pending command - * buffer along with the drawable). Dawn's ProcessEvents ticks every device, - * which commits pending commands too, so the async pump (RuntimeContext) - * idles meanwhile. - * - * Only a platform that refuses GPU work suspends the gate; elsewhere it stays - * open and run() costs a lock. Any thread; the work itself runs outside of - * the gate's lock, so it may take other locks (a surface's, Dawn's). - */ -class GpuWorkGate { -public: - static GpuWorkGate &getInstance() { - // Never destroyed: work may be run during process teardown. - static auto *instance = new GpuWorkGate(); - return *instance; - } - - GpuWorkGate(const GpuWorkGate &) = delete; - GpuWorkGate &operator=(const GpuWorkGate &) = delete; - - /** Whether the app is in the background and GPU work is held. */ - bool isSuspended() const { - return _suspended.load(std::memory_order_acquire); - } - - /** - Runs `work` now, or holds it for resume() while suspended. The work must - own what it uses by then (the held copy is the same closure). - */ - void run(std::function work) { - runOrHold(work, [&work]() { return std::move(work); }); - } - - /** - Runs `work` now, or holds the closure `hold` makes while suspended. For - work pointing at memory it does not own (the bytes of a JS ArrayBuffer): - `hold` copies them into a closure of its own, and is only called while - suspended, so the common path copies nothing. - */ - template - void runOrHold(Work &&work, Hold &&hold) { - { - std::unique_lock lock(_mutex); - if (_suspended.load(std::memory_order_relaxed)) { - _held.push_back(hold()); - return; - } - _active++; - } - // Counted while it runs, so that suspend() waits for it before draining - // (the count is released on an exception too). - struct Running { - GpuWorkGate &gate; - ~Running() { - std::lock_guard lock(gate._mutex); - if (--gate._active == 0) { - gate._idle.notify_all(); - } - } - } running{*this}; - work(); - } - - /** - A device whose queue suspend() drains: every device the API wraps - (GPUDevice tracks itself), imported ones included. Untracked when the - wrapper goes. - */ - void trackDevice(const wgpu::Device &device) { - std::lock_guard lock(_mutex); - _devices[device.Get()] = device; - } - - void untrackDevice(WGPUDevice handle) { - std::lock_guard lock(_mutex); - _devices.erase(handle); - } - - /** - The app is going to the background: holds the work from here on, lets the - work being run go through, then drains every device (see drain). Called - on the thread the app state notification arrives on, and returns before - the app is suspended: a drain is a frame's worth of GPU work. - */ - void suspend() { - std::vector devices; - { - std::unique_lock lock(_mutex); - if (_suspended.exchange(true, std::memory_order_acq_rel)) { - return; - } - _idle.wait(lock, [this]() { return _active == 0; }); - devices.reserve(_devices.size()); - for (const auto &entry : _devices) { - devices.push_back(entry.second); - } - } - for (const auto &device : devices) { - drain(device); - } - } - - /** The app is back in the foreground: runs the work held meanwhile, in order. - */ - void resume() { - std::vector> held; - { - std::lock_guard lock(_mutex); - if (!_suspended.exchange(false, std::memory_order_acq_rel)) { - return; - } - held.swap(_held); - } - for (auto &work : held) { - // Through run(), so that a suspend() racing with the replay waits for - // the work or holds it again. - run(std::move(work)); - } - } - -private: - GpuWorkGate() = default; - - /** - Commits what Dawn staged on the device (writeBuffer and writeTexture data, - lazy clears) with an empty submit and waits for the GPU to finish all of - it, within kDrainTimeoutNs. A device that does not answer in time is left - to its fate: with work in flight it is lost when the app is suspended, and - its owner handles that (device.lost, Skia's recovery). A lost device - answers right away. - */ - static void drain(const wgpu::Device &device) { - auto queue = device.GetQueue(); - queue.Submit(0, nullptr); - auto future = queue.OnSubmittedWorkDone( - wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::QueueWorkDoneStatus, wgpu::StringView) {}); - wgpu::FutureWaitInfo wait{future}; - auto status = - device.GetAdapter().GetInstance().WaitAny(1, &wait, kDrainTimeoutNs); - if (status != wgpu::WaitStatus::Success) { - Logger::logToConsole( - "[WebGPU] GPU work was still running when the app went to the " - "background; the device may be lost."); - } - } - - static constexpr uint64_t kDrainTimeoutNs = 500ull * 1000 * 1000; - - std::mutex _mutex; - std::condition_variable _idle; - std::atomic _suspended{false}; - // Work being run right now, which suspend() waits for. - int _active = 0; - // The work held while suspended, in order. - std::vector> _held; - std::unordered_map _devices; -}; - -} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index a1cb1d9b0..70d0fe976 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -21,7 +21,6 @@ #include "CanvasColorSpace.h" #include "WebGPURuntime.h" -#include "rnwgpu/GpuWorkGate.h" #if defined(__ANDROID__) #include @@ -329,10 +328,7 @@ class SurfaceInfo { // (their newer content stands; presenting here would be a Dawn // present-without-acquire error). if (_surface && !_frameInFlight && _frameEpoch == blitEpoch) { - // Presenting commits Dawn's pending command buffer: held while the - // app is in the background, after the blit (see GpuWorkGate). - GpuWorkGate::getInstance().run( - [surface = _surface]() { surface.Present(); }); + _surface.Present(); } } } @@ -602,13 +598,7 @@ class SurfaceInfo { { std::unique_lock lock(_mutex); if (_surface && _acquiredFromSurface) { - // Presenting commits Dawn's pending command buffer: held while the - // app is in the background, in order after the frame's submit (see - // GpuWorkGate). A held present runs on the thread the app comes back - // on, which Dawn's surface does not mind; the frame state below is - // settled now either way. - GpuWorkGate::getInstance().run( - [surface = _surface]() { surface.Present(); }); + _surface.Present(); #if defined(__ANDROID__) framePresented = _framePresented; #endif @@ -920,11 +910,7 @@ class SurfaceInfo { _config.device.CreateCommandEncoder(&encoderDescriptor); encoder.CopyTextureToTexture(&source, &destination, &size); wgpu::CommandBuffer commands = encoder.Finish(); - // Held while the app is in the background (see GpuWorkGate). - GpuWorkGate::getInstance().run( - [queue = _config.device.GetQueue(), commands]() { - queue.Submit(1, &commands); - }); + _config.device.GetQueue().Submit(1, &commands); return true; } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index f2df9398d..97154b24a 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -271,6 +271,32 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( enabledToggles.push_back(name); } } +#endif +#if defined(__APPLE__) + // iOS refuses GPU work from an app in the background, and Dawn loses + // the device over a refused command buffer. With this toggle Dawn + // holds the commits while the app is in the background, from + // UIApplication's own notifications, and commits them when it comes + // back: nothing is lost, nothing is refused, and a promise on the + // held work settles once the app is back (the same toggle Skia + // requests on the device it shares, see react-native-skia's + // DawnUtils::requestDevice). Dawn applies it on iOS and tvOS only, and + // a Dawn without it (react-native-webgpu-dawn before 157.1.0) drops + // the name. The caller opts out through disabledToggles. + { + static const char *const kName = + "metal_suspend_submissions_in_background"; + const auto named = [](const char *t) { + return std::strcmp(t, kName) == 0; + }; + const bool explicitlyDisabled = std::any_of( + disabledToggles.begin(), disabledToggles.end(), named); + const bool alreadyEnabled = + std::any_of(enabledToggles.begin(), enabledToggles.end(), named); + if (!explicitlyDisabled && !alreadyEnabled) { + enabledToggles.push_back(kName); + } + } #endif if (!enabledToggles.empty() || !disabledToggles.empty()) { toggles.enabledToggleCount = enabledToggles.size(); diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 4df68e533..f2deb045d 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -15,7 +15,6 @@ #include "NativeObject.h" -#include "rnwgpu/GpuWorkGate.h" #include "rnwgpu/async/AsyncTaskHandle.h" #include "rnwgpu/async/RuntimeContext.h" @@ -73,13 +72,9 @@ class GPUDevice : public NativeObject { std::shared_ptr async, std::string label) : NativeObject(CLASS_NAME), _instance(instance), _async(async), - _label(label) { - // Drained when the app goes to the background (see GpuWorkGate). - GpuWorkGate::getInstance().trackDevice(_instance); - } + _label(label) {} ~GPUDevice() override { - GpuWorkGate::getInstance().untrackDevice(_instance.Get()); // Unregister from the static registry unregisterDevice(_instance.Get()); } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index 78228f383..f109fe86d 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -12,7 +12,6 @@ #include "EnforceRange.h" #include "ImageBitmap.h" #include "PlatformContext.h" -#include "rnwgpu/GpuWorkGate.h" namespace rnwgpu { @@ -33,11 +32,7 @@ void GPUQueue::submit( if (!conv(bufs_size, bufs.size())) { return; } - // Held while the app is in the background (see GpuWorkGate). - GpuWorkGate::getInstance().run( - [queue = _instance, bufs = std::move(bufs), bufs_size]() { - queue.Submit(bufs_size, bufs.data()); - }); + _instance.Submit(bufs_size, bufs.data()); } void GPUQueue::writeBuffer(std::shared_ptr buffer, @@ -86,19 +81,8 @@ void GPUQueue::writeBuffer(std::shared_ptr buffer, } assert(size64 <= std::numeric_limits::max()); - const auto size = static_cast(size64); - const auto *bytes = static_cast(src.data); - // Dawn commits its staging past 16 MB of writes: held while the app is in - // the background (see GpuWorkGate). WebGPU copies the bytes at the call, - // so the held write takes a copy of them, the ArrayBuffer may change. - GpuWorkGate::getInstance().runOrHold( - [&]() { _instance.WriteBuffer(buf, bufferOffset, bytes, size); }, - [queue = _instance, buf, bufferOffset, bytes, size]() { - std::vector copy(bytes, bytes + size); - return [queue, buf, bufferOffset, copy = std::move(copy)]() { - queue.WriteBuffer(buf, bufferOffset, copy.data(), copy.size()); - }; - }); + _instance.WriteBuffer(buf, bufferOffset, src.data, + static_cast(size64)); } async::AsyncTaskHandle GPUQueue::onSubmittedWorkDone(jsi::Runtime &runtime) { @@ -179,12 +163,7 @@ void GPUQueue::copyVideoFrameToTexture( } request.dstX = dstOrigin.x; request.dstY = dstOrigin.y; - // The blit submits: held while the app is in the background (see - // GpuWorkGate), with the frame it copies. - GpuWorkGate::getInstance().run( - [blit = _videoFrameBlit, device = _device, queue = _instance, request]() { - blit->copy(device, queue, request); - }); + _videoFrameBlit->copy(_device, _instance, request); } void GPUQueue::copyExternalImageToTexture( @@ -270,33 +249,10 @@ void GPUQueue::copyExternalImageToTexture( } layout.bytesPerRow = rowSize; layout.rowsPerImage = sz.height; - // Held while the app is in the background (see GpuWorkGate), with the - // staged pixels. - GpuWorkGate::getInstance().runOrHold( - [&]() { - _instance.WriteTexture(&dst, staged.data(), totalSize, &layout, &sz); - }, - [&]() { - return [queue = _instance, dst, layout, sz, - staged = std::move(staged)]() { - queue.WriteTexture(&dst, staged.data(), staged.size(), &layout, - &sz); - }; - }); + _instance.WriteTexture(&dst, staged.data(), totalSize, &layout, &sz); } else { - // Same, keeping the ImageBitmap alive for its pixels. - GpuWorkGate::getInstance().runOrHold( - [&]() { - _instance.WriteTexture(&dst, source->source->getData(), - source->source->getSize(), &layout, &sz); - }, - [&]() { - return - [queue = _instance, dst, layout, sz, bitmap = source->source]() { - queue.WriteTexture(&dst, bitmap->getData(), bitmap->getSize(), - &layout, &sz); - }; - }); + _instance.WriteTexture(&dst, source->source->getData(), + source->source->getSize(), &layout, &sz); } } @@ -509,20 +465,7 @@ async::AsyncTaskHandle GPUQueue::drawElementImageToTexture( layout.rowsPerImage = static_cast(image.height); wgpu::Extent3D extent{static_cast(image.width), static_cast(image.height), 1}; - // Held while the app is in the background (see GpuWorkGate), - // with the pixels. - GpuWorkGate::getInstance().runOrHold( - [&]() { - queue.WriteTexture(&dst, pixels, byteLength, &layout, - &extent); - }, - [&]() { - return [queue, dst, layout, extent, byteLength, - image = std::move(image)]() { - queue.WriteTexture(&dst, image.data.data(), byteLength, - &layout, &extent); - }; - }); + queue.WriteTexture(&dst, pixels, byteLength, &layout, &extent); return jsi::Value::undefined(); }); }, @@ -607,18 +550,7 @@ void GPUQueue::writeTexture(std::shared_ptr destination, throw std::runtime_error("Invalid input for GPUQueue::writeTexture()"); } - // Held while the app is in the background (see GpuWorkGate), with a copy - // of the bytes: WebGPU copies them at the call, the ArrayBuffer may change. - const auto *bytes = static_cast(data->_data); - const size_t byteLength = data->_size; - GpuWorkGate::getInstance().runOrHold( - [&]() { _instance.WriteTexture(&dst, bytes, byteLength, &layout, &sz); }, - [queue = _instance, dst, layout, sz, bytes, byteLength]() { - std::vector copy(bytes, bytes + byteLength); - return [queue, dst, layout, sz, copy = std::move(copy)]() { - queue.WriteTexture(&dst, copy.data(), copy.size(), &layout, &sz); - }; - }); + _instance.WriteTexture(&dst, data->_data, data->_size, &layout, &sz); } } // namespace rnwgpu From 3b4e5575a2cd144c4b828b74cb0e94774884d5ff Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 12:33:24 +0100 Subject: [PATCH 3/4] =?UTF-8?q?chore(=E2=AC=86=EF=B8=8F):=20upgrade=20to?= =?UTF-8?q?=20Dawn=20157.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-native-webgpu-dawn 157.1.0 is the same Dawn commit (ada567700e) with react-native-skia's hold of command buffer commits while the app is in the background, behind the metal_suspend_submissions_in_background toggle requestDevice now asks for (published by react-native-skia's binaries workflow, run 41422364356). The example installs react-native-skia 3.7.0, which pins react-native-webgpu-dawn 157.0.0 exactly; its podspec and Gradle refuse an app whose two packages resolve different versions. Until a react-native-skia release depends on 157.1.0 (its binaries link unchanged against it), the workspace resolves the package to 157.1.0 for both. Drop the resolution when the example moves to that release. --- package.json | 1 + packages/webgpu/package.json | 2 +- yarn.lock | 10 +++++----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e973c4835..883e5ea05 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "resolutions": { "@types/react": "^18.2.44", "@webgpu/types": "^0.1.42", + "react-native-webgpu-dawn": "157.1.0", "@react-three/fiber@^9.4.0": "patch:@react-three/fiber@npm%3A9.4.0#./.yarn/patches/@react-three-fiber-npm-9.4.0-77fe8ee9bd.patch" }, "packageManager": "yarn@3.6.1", diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json index 9d0e47cd3..7a0a3c3f4 100644 --- a/packages/webgpu/package.json +++ b/packages/webgpu/package.json @@ -111,7 +111,7 @@ "yargs": "^17.7.2" }, "dependencies": { - "react-native-webgpu-dawn": "157.0.0" + "react-native-webgpu-dawn": "157.1.0" }, "peerDependencies": { "react": "*", diff --git a/yarn.lock b/yarn.lock index 4e03cd522..1da273877 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20880,10 +20880,10 @@ __metadata: languageName: node linkType: hard -"react-native-webgpu-dawn@npm:157.0.0": - version: 157.0.0 - resolution: "react-native-webgpu-dawn@npm:157.0.0" - checksum: 2fbebac05e083a4d0ecd258e459ddb6a41c1755f1720b9361a43af31c37d8e67aac60904f2731535b52e9fb19dfb0ced05d3011861635786a449e8a3835cbb20 +"react-native-webgpu-dawn@npm:157.1.0": + version: 157.1.0 + resolution: "react-native-webgpu-dawn@npm:157.1.0" + checksum: c938c4d0c1f1b5bc9eb8b8f2f3580df1babb7e02151262a3ff1cd4dfe4954c7ee6e56408403e715730eed5b00c33189095639f990befe9ad759632c82605ed1a languageName: node linkType: hard @@ -20948,7 +20948,7 @@ __metadata: react-native-builder-bob: ^0.23.2 react-native-reanimated: 4.6.0 react-native-web: ^0.21.2 - react-native-webgpu-dawn: 157.0.0 + react-native-webgpu-dawn: 157.1.0 react-native-worklets: 0.12.2 rimraf: ^5.0.7 seedrandom: ^3.0.5 From 205ca3f469de2ffc59e04d8214199e155f1288a3 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 13:38:22 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(=F0=9F=8D=8F):=20compare=20the=20Dawn?= =?UTF-8?q?=20react-native-skia=20resolves=20in=20the=20Swift=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest refused a react-native-skia whose package.json pins another react-native-webgpu-dawn than this package, which fails the SwiftPM example on every Dawn bump until a react-native-skia release pins the new version: here 157.1.0 against react-native-skia 3.7.0's 157.0.0, although the workspace resolves the package to 157.1.0 for both and the app links one Dawn. The podspec compares the versions the two packages resolve, not the ones they declare; the manifest now does the same: the copy nested under react-native-skia when the package manager could not hoist it, else the one next to it. A nested copy of another version still fails the evaluation, with both the resolved and the pinned version in the message. Verified with `swift package --manifest-cache none dump-package`: the old manifest fails on this tree, the new one passes, and a nested 157.0.0 under react-native-skia fails it; the SwiftPM example builds for the simulator. --- packages/webgpu/CONTRIBUTING.md | 5 ++++- packages/webgpu/Package.swift | 26 +++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/webgpu/CONTRIBUTING.md b/packages/webgpu/CONTRIBUTING.md index b0ba79b77..aa9a21588 100644 --- a/packages/webgpu/CONTRIBUTING.md +++ b/packages/webgpu/CONTRIBUTING.md @@ -93,7 +93,10 @@ sibling in `node_modules`, or this monorepo's root) and links its headers, so no header search path is needed for them. A Graphite build of react-native-skia installed alongside must link the same Dawn, which the manifest checks at evaluation time: a react-native-skia that depends on -`react-native-webgpu-dawn` must pin the same version, and the 3.0.x line, which +`react-native-webgpu-dawn` must resolve the same version (the copy nested under +it, else the one next to it, as its podspec resolves it; a workspace that +resolves the package to one version for both passes while react-native-skia's +own pin lags behind a Dawn bump), and the 3.0.x line, which vendors its own Dawn, must record this package's `dawn` tag at `react-native-skia-graphite-apple-ios/libs/.dawn-version` (hoisted or nested under `react-native-skia/node_modules`, also checked for macOS and for the v2 diff --git a/packages/webgpu/Package.swift b/packages/webgpu/Package.swift index c3506c0ef..1793b97bb 100644 --- a/packages/webgpu/Package.swift +++ b/packages/webgpu/Package.swift @@ -56,21 +56,33 @@ let dawnVersion = // changing either package's Dawn reset the package caches (File > Packages > // Reset Package Caches) if this does not re-run. // -// A react-native-skia that depends on react-native-webgpu-dawn itself must pin -// the same version as this package. Older Graphite builds (react-native-skia -// 3.0.x) vendor their own Dawn and record its release tag at -// libs/.dawn-version in their binary packages; this package's `dawn` field +// A react-native-skia that depends on react-native-webgpu-dawn itself must +// resolve the same version as this package: the copy nested under it when the +// package manager could not hoist it, else the one next to it (Node's +// resolution, which react-native-skia's podspec compares too). A workspace +// that resolves the package to one version for both (yarn `resolutions`, npm +// `overrides`) passes while react-native-skia's own pin lags behind a Dawn +// bump: the app then contains that one Dawn. Older Graphite builds +// (react-native-skia 3.0.x) vendor their own Dawn and record its release tag +// at libs/.dawn-version in their binary packages; this package's `dawn` field // names the release that Dawn is interchangeable with. if let skiaPath = nodePackage("react-native-skia"), let skiaPackage = readJSON("\(skiaPath)/package.json"), - let skiaDawn = (skiaPackage["dependencies"] as? [String: String])?["react-native-webgpu-dawn"] + let skiaDawnPin = (skiaPackage["dependencies"] as? [String: String])?["react-native-webgpu-dawn"] { + let skiaDawnPath = [ + "\(skiaPath)/node_modules/react-native-webgpu-dawn", + "\(skiaPath)/../react-native-webgpu-dawn", + ].first { FileManager.default.fileExists(atPath: "\($0)/package.json") } + let skiaDawn = + skiaDawnPath.flatMap { readJSON("\($0)/package.json")?["version"] as? String } ?? skiaDawnPin if skiaDawn != dawnVersion { fatalError( """ react-native-webgpu: Dawn version mismatch. This package links \ - react-native-webgpu-dawn \(dawnVersion) but react-native-skia depends on \ - \(skiaDawn). Align the two packages so the app contains exactly one Dawn. + react-native-webgpu-dawn \(dawnVersion) but react-native-skia resolves \ + \(skiaDawn) (it depends on \(skiaDawnPin)). Align the two packages so the \ + app contains exactly one Dawn. """) } } else {