diff --git a/packages/webgpu/android/CMakeLists.txt b/packages/webgpu/android/CMakeLists.txt index 8f7321b7f0..2afae5fc14 100644 --- a/packages/webgpu/android/CMakeLists.txt +++ b/packages/webgpu/android/CMakeLists.txt @@ -49,8 +49,6 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/rnwgpu/api/GPUCanvasContext.cpp ../cpp/rnwgpu/RNWebGPUManager.cpp ../cpp/jsi/Promise.cpp - ../cpp/jsi/RuntimeLifecycleMonitor.cpp - ../cpp/jsi/RuntimeAwareCache.cpp ../cpp/rnwgpu/async/RuntimeContext.cpp ../cpp/rnwgpu/async/AsyncTaskHandle.cpp ) diff --git a/packages/webgpu/android/cpp/AndroidPlatformContext.h b/packages/webgpu/android/cpp/AndroidPlatformContext.h index b20078669f..d319ca3a82 100644 --- a/packages/webgpu/android/cpp/AndroidPlatformContext.h +++ b/packages/webgpu/android/cpp/AndroidPlatformContext.h @@ -5,71 +5,402 @@ #include #include +#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include #include #include +#include #include +#include + #include "webgpu/webgpu_cpp.h" #include "PlatformContext.h" -#include "RNWebGPUManager.h" namespace rnwgpu { -namespace jsi = facebook::jsi; namespace jni = facebook::jni; -class AndroidPlatformContext : public PlatformContext { +class AndroidWorkerPool final { +public: + AndroidWorkerPool(std::size_t workerCount, std::size_t maxPendingTasks) + : _maxPendingTasks(maxPendingTasks) { + if (workerCount == 0 || maxPendingTasks == 0) { + throw std::invalid_argument( + "AndroidWorkerPool requires workers and queue capacity"); + } + + try { + _workers.reserve(workerCount); + for (std::size_t index = 0; index < workerCount; ++index) { + _workers.emplace_back([this]() { workerLoop(); }); + } + } catch (...) { + shutdown(); + throw; + } + } + + ~AndroidWorkerPool() { shutdown(); } + + AndroidWorkerPool(const AndroidWorkerPool &) = delete; + AndroidWorkerPool &operator=(const AndroidWorkerPool &) = delete; + AndroidWorkerPool(AndroidWorkerPool &&) = delete; + AndroidWorkerPool &operator=(AndroidWorkerPool &&) = delete; + + bool submit(std::function task, std::function cancel) { + if (!task || !cancel) { + return false; + } + + { + std::lock_guard lock(_mutex); + if (_stopping || _tasks.size() >= _maxPendingTasks) { + return false; + } + _tasks.push_back( + PendingTask{.run = std::move(task), .cancel = std::move(cancel)}); + } + _condition.notify_one(); + return true; + } + + void shutdown() noexcept { + std::deque cancelledTasks; + { + std::lock_guard lock(_mutex); + _stopping = true; + // Pool shutdown happens only at native-library/process teardown. Session + // reloads invalidate their ref-counted decode state without stopping the + // shared workers. + cancelledTasks.swap(_tasks); + } + _condition.notify_all(); + cancelTasks(cancelledTasks); + + for (auto &worker : _workers) { + if (worker.joinable()) { + worker.join(); + } + } + _workers.clear(); + } + private: - jobject _blobModule; + struct PendingTask final { + std::function run; + std::function cancel; + }; + + static void cancelTasks(std::deque &tasks) noexcept { + for (auto &task : tasks) { + if (!task.cancel) { + continue; + } + try { + task.cancel(); + } catch (...) { + // Cancellation runs during reload/worker failure and must not escape. + } + } + tasks.clear(); + } + + void workerLoop() noexcept { + try { + // Pool threads are native-owned. Keep the JVM attachment scoped to the + // complete worker lifetime so fbjni detaches before std::thread exits. + jni::ThreadScope threadScope; + runWorkerLoop(); + } catch (...) { + // ThreadScope construction can fail when fbjni/JVM initialization is no + // longer available. Stop accepting work without allowing an exception + // to escape the noexcept std::thread entry point. + stopAfterWorkerFailure(); + } + } - std::vector resolveBlob(JNIEnv *env, const std::string &blobId, - double offset, double size) { + void runWorkerLoop() noexcept { + for (;;) { + PendingTask task; + { + std::unique_lock lock(_mutex); + _condition.wait(lock, + [this]() { return _stopping || !_tasks.empty(); }); + if (_stopping) { + return; + } + task = std::move(_tasks.front()); + _tasks.pop_front(); + } + + try { + task.run(); + } catch (...) { + // Individual tasks report their own failures. Never terminate a + // long-lived worker because an error callback threw. + } + } + } + + void stopAfterWorkerFailure() noexcept { + std::deque cancelledTasks; + try { + std::lock_guard lock(_mutex); + _stopping = true; + cancelledTasks.swap(_tasks); + } catch (...) { + // There is no recovery path if mutex acquisition itself fails. Preserve + // the noexcept worker boundary and let pool destruction reclaim tasks. + } + _condition.notify_all(); + cancelTasks(cancelledTasks); + } + + const std::size_t _maxPendingTasks; + std::mutex _mutex; + std::condition_variable _condition; + std::deque _tasks; + std::vector _workers; + bool _stopping{false}; +}; + +class AndroidImageDecodeState final { +public: + explicit AndroidImageDecodeState(jni::global_ref blobModule) + : _blobModule(std::move(blobModule)) { if (!_blobModule) { + throw std::invalid_argument( + "AndroidImageDecodeState requires a BlobModule"); + } + } + + AndroidImageDecodeState(const AndroidImageDecodeState &) = delete; + AndroidImageDecodeState &operator=(const AndroidImageDecodeState &) = delete; + AndroidImageDecodeState(AndroidImageDecodeState &&) = delete; + AndroidImageDecodeState &operator=(AndroidImageDecodeState &&) = delete; + + [[nodiscard]] bool isActive() const noexcept { + return _active.load(std::memory_order_acquire); + } + + void invalidate() noexcept { + _active.store(false, std::memory_order_release); + } + + [[nodiscard]] jobject blobModule() const noexcept { + return _blobModule.get(); + } + +private: + jni::global_ref _blobModule; + std::atomic _active{true}; +}; + +inline AndroidWorkerPool &androidImageWorkerPool() { + // Shared workers outlive individual React Native runtime generations. This + // lets module invalidation detach immediately while an already-running + // BitmapFactory decode safely finishes with its own ref-counted JNI state. + static AndroidWorkerPool pool{2, 32}; + return pool; +} + +class AndroidPlatformContext : public PlatformContext { +private: + template class LocalRef final { + public: + LocalRef(JNIEnv *env, T value) noexcept : _env(env), _value(value) {} + ~LocalRef() { + if (_value) { + _env->DeleteLocalRef(_value); + } + } + + LocalRef(const LocalRef &) = delete; + LocalRef &operator=(const LocalRef &) = delete; + LocalRef(LocalRef &&) = delete; + LocalRef &operator=(LocalRef &&) = delete; + + [[nodiscard]] T get() const noexcept { return _value; } + [[nodiscard]] explicit operator bool() const noexcept { + return _value != nullptr; + } + + private: + JNIEnv *_env; + T _value; + }; + + class LockedBitmapPixels final { + public: + LockedBitmapPixels(JNIEnv *env, jobject bitmap) + : _env(env), _bitmap(bitmap) { + const int status = AndroidBitmap_lockPixels(_env, _bitmap, &_pixels); + _locked = status == ANDROID_BITMAP_RESULT_SUCCESS && _pixels != nullptr; + if (_env->ExceptionCheck() == JNI_TRUE) { + _env->ExceptionClear(); + if (_locked) { + AndroidBitmap_unlockPixels(_env, _bitmap); + _locked = false; + if (_env->ExceptionCheck() == JNI_TRUE) { + _env->ExceptionClear(); + } + } + throw std::runtime_error( + "AndroidBitmap_lockPixels failed with a Java exception"); + } + if (!_locked) { + throw std::runtime_error("Couldn't lock bitmap pixels"); + } + } + + ~LockedBitmapPixels() { + if (_locked) { + AndroidBitmap_unlockPixels(_env, _bitmap); + if (_env->ExceptionCheck() == JNI_TRUE) { + _env->ExceptionClear(); + } + } + } + + LockedBitmapPixels(const LockedBitmapPixels &) = delete; + LockedBitmapPixels &operator=(const LockedBitmapPixels &) = delete; + LockedBitmapPixels(LockedBitmapPixels &&) = delete; + LockedBitmapPixels &operator=(LockedBitmapPixels &&) = delete; + + [[nodiscard]] const uint8_t *data() const noexcept { + return static_cast(_pixels); + } + + void unlock() { + if (!_locked) { + return; + } + _locked = false; + const int status = AndroidBitmap_unlockPixels(_env, _bitmap); + if (_env->ExceptionCheck() == JNI_TRUE) { + _env->ExceptionClear(); + throw std::runtime_error( + "AndroidBitmap_unlockPixels failed with a Java exception"); + } + if (status != ANDROID_BITMAP_RESULT_SUCCESS) { + throw std::runtime_error("Couldn't unlock bitmap pixels"); + } + } + + private: + JNIEnv *_env; + jobject _bitmap; + void *_pixels{nullptr}; + bool _locked{false}; + }; + + static void throwIfJavaException(JNIEnv *env, const char *operation) { + if (env->ExceptionCheck() != JNI_TRUE) { + return; + } + env->ExceptionClear(); + throw std::runtime_error(std::string(operation) + + " failed with a Java exception"); + } + + static jint checkedBlobRangeValue(double value, const char *name) { + constexpr double maxJint = + static_cast(std::numeric_limits::max()); + if (!std::isfinite(value) || value < 0.0 || std::trunc(value) != value || + value > maxJint) { + throw std::invalid_argument(std::string("Blob ") + name + + " must be a non-negative 32-bit integer"); + } + return static_cast(value); + } + + static std::size_t checkedMultiply(std::size_t left, std::size_t right, + const char *message) { + if (left != 0 && right > std::numeric_limits::max() / left) { + throw std::overflow_error(message); + } + return left * right; + } + + static constexpr const char *kDecodeCancelledError = + "Android image decode was cancelled during session teardown"; + + std::shared_ptr _decodeState; + + static std::vector + resolveBlob(const std::shared_ptr &decodeState, + JNIEnv *env, const std::string &blobId, double offset, + double size) { + const auto blobModule = decodeState ? decodeState->blobModule() : nullptr; + if (blobModule == nullptr) { throw std::runtime_error("BlobModule instance is null"); } - jclass blobModuleClass = env->GetObjectClass(_blobModule); + const jint checkedOffset = checkedBlobRangeValue(offset, "offset"); + const jint checkedSize = checkedBlobRangeValue(size, "size"); + if (checkedOffset > std::numeric_limits::max() - checkedSize) { + throw std::invalid_argument("Blob offset plus size exceeds 32-bit range"); + } + + LocalRef blobModuleClass(env, env->GetObjectClass(blobModule)); + throwIfJavaException(env, "GetObjectClass(BlobModule)"); if (!blobModuleClass) { throw std::runtime_error("Couldn't find BlobModule class"); } - jmethodID resolveMethod = env->GetMethodID(blobModuleClass, "resolve", + jmethodID resolveMethod = env->GetMethodID(blobModuleClass.get(), "resolve", "(Ljava/lang/String;II)[B"); - env->DeleteLocalRef(blobModuleClass); - + throwIfJavaException(env, "GetMethodID(BlobModule.resolve)"); if (!resolveMethod) { throw std::runtime_error("Couldn't find resolve method in BlobModule"); } - jstring jBlobId = env->NewStringUTF(blobId.c_str()); - jbyteArray blobData = (jbyteArray)env->CallObjectMethod( - _blobModule, resolveMethod, jBlobId, static_cast(offset), - static_cast(size)); - env->DeleteLocalRef(jBlobId); + LocalRef jBlobId(env, env->NewStringUTF(blobId.c_str())); + throwIfJavaException(env, "NewStringUTF(blobId)"); + if (!jBlobId) { + throw std::runtime_error("Couldn't allocate Blob identifier"); + } + LocalRef blobData( + env, static_cast(env->CallObjectMethod( + blobModule, resolveMethod, jBlobId.get(), checkedOffset, + checkedSize))); + throwIfJavaException(env, "BlobModule.resolve"); if (!blobData) { throw std::runtime_error("Couldn't retrieve blob data"); } - jsize len = env->GetArrayLength(blobData); - std::vector data(len); - env->GetByteArrayRegion(blobData, 0, len, - reinterpret_cast(data.data())); - env->DeleteLocalRef(blobData); + const jsize len = env->GetArrayLength(blobData.get()); + throwIfJavaException(env, "GetArrayLength(Blob data)"); + std::vector data(static_cast(len)); + if (len > 0) { + env->GetByteArrayRegion(blobData.get(), 0, len, + reinterpret_cast(data.data())); + throwIfJavaException(env, "GetByteArrayRegion(Blob data)"); + } return data; } public: - explicit AndroidPlatformContext(jobject blobModule) - : _blobModule(blobModule) {} - ~AndroidPlatformContext() { - if (_blobModule) { - JNIEnv *env = facebook::jni::Environment::current(); - env->DeleteGlobalRef(_blobModule); - _blobModule = nullptr; + explicit AndroidPlatformContext(jni::global_ref blobModule) + : _decodeState(std::make_shared( + std::move(blobModule))) {} + ~AndroidPlatformContext() override { + // Running workers retain AndroidImageDecodeState, including BlobModule. + // Mark their results stale without waiting for BitmapFactory during reload. + if (_decodeState) { + _decodeState->invalidate(); } } @@ -84,6 +415,10 @@ class AndroidPlatformContext : public PlatformContext { ImageData createImageBitmap(std::string blobId, double offset, double size) override { + const auto decodeState = _decodeState; + if (!decodeState || !decodeState->isActive()) { + throw std::runtime_error(kDecodeCancelledError); + } jni::Environment::ensureCurrentThreadIsAttached(); JNIEnv *env = facebook::jni::Environment::current(); @@ -91,33 +426,54 @@ class AndroidPlatformContext : public PlatformContext { throw std::runtime_error("Couldn't get JNI environment"); } - auto data = resolveBlob(env, blobId, offset, size); - return createImageBitmapFromData(data); + auto data = resolveBlob(decodeState, env, blobId, offset, size); + return decodeImageBitmapFromData(data); } void createImageBitmapAsync(std::string blobId, double offset, double size, std::function onSuccess, std::function onError) override { - std::thread([this, blobId = std::move(blobId), offset, size, - onSuccess = std::move(onSuccess), - onError = std::move(onError)]() { - jni::Environment::ensureCurrentThreadIsAttached(); + const auto decodeState = _decodeState; + auto task = [decodeState, blobId = std::move(blobId), offset, size, + onSuccess = std::move(onSuccess), onError]() mutable { + if (!decodeState || !decodeState->isActive()) { + onError(kDecodeCancelledError); + return; + } try { JNIEnv *env = facebook::jni::Environment::current(); if (!env) { throw std::runtime_error("Couldn't get JNI environment"); } - auto data = resolveBlob(env, blobId, offset, size); - auto result = createImageBitmapFromData(data); + auto data = resolveBlob(decodeState, env, blobId, offset, size); + if (!decodeState->isActive()) { + onError(kDecodeCancelledError); + return; + } + auto result = decodeImageBitmapFromData(data); + if (!decodeState->isActive()) { + onError(kDecodeCancelledError); + return; + } onSuccess(std::move(result)); - } catch (const std::exception &e) { - onError(e.what()); + } catch (const std::exception &error) { + onError(error.what()); + } catch (...) { + onError("Unknown error while decoding a Blob image"); } - }).detach(); + }; + + auto cancel = [onError]() { + onError("Android image worker pool is shutting down"); + }; + if (!androidImageWorkerPool().submit(std::move(task), std::move(cancel))) { + onError("Android image worker queue is full or shutting down"); + } } - ImageData createImageBitmapFromData(std::span data) override { + static ImageData + decodeImageBitmapFromData(std::span data) { jni::Environment::ensureCurrentThreadIsAttached(); JNIEnv *env = facebook::jni::Environment::current(); @@ -125,84 +481,127 @@ class AndroidPlatformContext : public PlatformContext { throw std::runtime_error("Couldn't get JNI environment"); } - // Create jbyteArray from the raw bytes - jbyteArray byteArray = env->NewByteArray(static_cast(data.size())); + if (data.size() > + static_cast(std::numeric_limits::max())) { + throw std::invalid_argument("Encoded image is too large for JNI"); + } + const auto encodedSize = static_cast(data.size()); + + LocalRef byteArray(env, env->NewByteArray(encodedSize)); + throwIfJavaException(env, "NewByteArray(encoded image)"); if (!byteArray) { throw std::runtime_error("Couldn't allocate byte array"); } - env->SetByteArrayRegion(byteArray, 0, static_cast(data.size()), - reinterpret_cast(data.data())); + if (encodedSize > 0) { + env->SetByteArrayRegion(byteArray.get(), 0, encodedSize, + reinterpret_cast(data.data())); + throwIfJavaException(env, "SetByteArrayRegion(encoded image)"); + } - // Decode via BitmapFactory - jclass bitmapFactoryClass = - env->FindClass("android/graphics/BitmapFactory"); + LocalRef bitmapFactoryClass( + env, env->FindClass("android/graphics/BitmapFactory")); + throwIfJavaException(env, "FindClass(BitmapFactory)"); if (!bitmapFactoryClass) { - env->DeleteLocalRef(byteArray); throw std::runtime_error("Couldn't find BitmapFactory class"); } jmethodID decodeByteArrayMethod = - env->GetStaticMethodID(bitmapFactoryClass, "decodeByteArray", + env->GetStaticMethodID(bitmapFactoryClass.get(), "decodeByteArray", "([BII)Landroid/graphics/Bitmap;"); + throwIfJavaException(env, + "GetStaticMethodID(BitmapFactory.decodeByteArray)"); if (!decodeByteArrayMethod) { - env->DeleteLocalRef(byteArray); - env->DeleteLocalRef(bitmapFactoryClass); throw std::runtime_error("Couldn't find decodeByteArray method"); } - jint length = static_cast(data.size()); - jobject bitmap = env->CallStaticObjectMethod( - bitmapFactoryClass, decodeByteArrayMethod, byteArray, 0, length); - env->DeleteLocalRef(bitmapFactoryClass); + LocalRef bitmap( + env, env->CallStaticObjectMethod(bitmapFactoryClass.get(), + decodeByteArrayMethod, byteArray.get(), + 0, encodedSize)); + throwIfJavaException(env, "BitmapFactory.decodeByteArray"); if (!bitmap) { - env->DeleteLocalRef(byteArray); throw std::runtime_error("Couldn't decode image"); } - AndroidBitmapInfo bitmapInfo; - if (AndroidBitmap_getInfo(env, bitmap, &bitmapInfo) != - ANDROID_BITMAP_RESULT_SUCCESS) { - env->DeleteLocalRef(byteArray); - env->DeleteLocalRef(bitmap); + AndroidBitmapInfo bitmapInfo{}; + const int bitmapInfoStatus = + AndroidBitmap_getInfo(env, bitmap.get(), &bitmapInfo); + throwIfJavaException(env, "AndroidBitmap_getInfo"); + if (bitmapInfoStatus != ANDROID_BITMAP_RESULT_SUCCESS) { throw std::runtime_error("Couldn't get bitmap info"); } - void *bitmapPixels; - if (AndroidBitmap_lockPixels(env, bitmap, &bitmapPixels) != - ANDROID_BITMAP_RESULT_SUCCESS) { - env->DeleteLocalRef(byteArray); - env->DeleteLocalRef(bitmap); - throw std::runtime_error("Couldn't lock bitmap pixels"); + if (bitmapInfo.format != ANDROID_BITMAP_FORMAT_RGBA_8888) { + throw std::runtime_error("Decoded bitmap is not RGBA_8888"); + } + if (bitmapInfo.width == 0 || bitmapInfo.height == 0) { + throw std::runtime_error("Decoded bitmap has empty dimensions"); } - ImageData result; - result.width = static_cast(bitmapInfo.width); - result.height = static_cast(bitmapInfo.height); - result.data.resize(bitmapInfo.height * bitmapInfo.stride); - memcpy(result.data.data(), bitmapPixels, result.data.size()); - - AndroidBitmap_unlockPixels(env, bitmap); + const std::size_t width = static_cast(bitmapInfo.width); + const std::size_t height = static_cast(bitmapInfo.height); + const std::size_t bytesPerRow = + checkedMultiply(width, 4, "Decoded bitmap row size overflow"); + if (bitmapInfo.stride < bytesPerRow) { + throw std::runtime_error("Decoded bitmap stride is smaller than a row"); + } + const std::size_t outputSize = checkedMultiply( + height, bytesPerRow, "Decoded bitmap buffer size overflow"); + + LockedBitmapPixels bitmapPixels(env, bitmap.get()); + ImageData result{}; + result.width = width; + result.height = height; + result.format = wgpu::TextureFormat::RGBA8Unorm; + if (outputSize > result.data.max_size()) { + throw std::length_error("Decoded bitmap exceeds native buffer capacity"); + } + result.data.resize(outputSize); + for (std::size_t row = 0; row < height; ++row) { + std::memcpy(result.data.data() + row * bytesPerRow, + bitmapPixels.data() + row * bitmapInfo.stride, bytesPerRow); + } - env->DeleteLocalRef(byteArray); - env->DeleteLocalRef(bitmap); + bitmapPixels.unlock(); return result; } + ImageData createImageBitmapFromData( + std::span data) override { + return decodeImageBitmapFromData(data); + } + void createImageBitmapFromDataAsync( std::span data, std::function onSuccess, std::function onError) override { - std::thread([this, + const auto decodeState = _decodeState; + auto task = [decodeState, ownedData = std::vector(data.begin(), data.end()), - onSuccess = std::move(onSuccess), - onError = std::move(onError)]() mutable { - jni::Environment::ensureCurrentThreadIsAttached(); + onSuccess = std::move(onSuccess), onError]() mutable { + if (!decodeState || !decodeState->isActive()) { + onError(kDecodeCancelledError); + return; + } try { - auto result = createImageBitmapFromData(ownedData); + auto result = decodeImageBitmapFromData(ownedData); + if (!decodeState->isActive()) { + onError(kDecodeCancelledError); + return; + } onSuccess(std::move(result)); - } catch (const std::exception &e) { - onError(e.what()); + } catch (const std::exception &error) { + onError(error.what()); + } catch (...) { + onError("Unknown error while decoding image data"); } - }).detach(); + }; + + auto cancel = [onError]() { + onError("Android image worker pool is shutting down"); + }; + if (!androidImageWorkerPool().submit(std::move(task), std::move(cancel))) { + onError("Android image worker queue is full or shutting down"); + } } VideoFrameHandle loadVideoFrame(const std::string & /*path*/) override { diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 2a3023740a..98b2de7256 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -1,93 +1,326 @@ +#include +#include #include -#include +#include +#include #include #include #include #include +#include #include #include #include "AndroidPlatformContext.h" -#include "GPUCanvasContext.h" #include "RNWebGPUManager.h" +#include "SurfaceRegistry.h" #define LOG_TAG "WebGPUModule" -std::shared_ptr manager; - -extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative( - JNIEnv *env, jobject /* this */, jlong jsRuntime, - jobject jsCallInvokerHolder, jobject blobModule) { - auto runtime = reinterpret_cast(jsRuntime); - jobject globalBlobModule = env->NewGlobalRef(blobModule); - auto jsCallInvoker{ - facebook::jni::alias_ref{ - reinterpret_cast( - jsCallInvokerHolder)} -> cthis()->getCallInvoker()}; - auto platformContext = - std::make_shared(globalBlobModule); - manager = std::make_shared(runtime, jsCallInvoker, - platformContext); +namespace { + +constexpr jint kSurfacePublishFailed = 0; +constexpr jint kSurfacePublishRegisteredOffscreen = 1; +constexpr jint kSurfacePublishOnscreen = 2; + +rnwgpu::RNWebGPUSessionId sessionIdFromJava(jlong sessionId) noexcept { + if (sessionId <= 0 || static_cast(sessionId) > + rnwgpu::kMaxRNWebGPUSessionId) { + return rnwgpu::kInvalidRNWebGPUSessionId; + } + return static_cast(sessionId); } -extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceChanged( - JNIEnv *env, jobject thiz, jobject surface, jint contextId, jfloat width, - jfloat height) { - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - if (auto info = registry.getSurfaceInfo(contextId)) { - info->resize(static_cast(width), static_cast(height)); +rnwgpu::SurfaceOwnerId surfaceOwnerIdFromJava(jlong ownerId) noexcept { + if (ownerId <= 0) { + return rnwgpu::kInvalidSurfaceOwnerId; } + return static_cast(ownerId); } -extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate( - JNIEnv *env, jobject thiz, jobject jSurface, jint contextId, jfloat width, - jfloat height) { - if (manager == nullptr) { +jlong sessionIdToJava(rnwgpu::RNWebGPUSessionId sessionId) { + if (sessionId == rnwgpu::kInvalidRNWebGPUSessionId || + sessionId > static_cast( + std::numeric_limits::max())) { + throw std::overflow_error( + "WebGPU session ID cannot be represented by Java"); + } + return static_cast(sessionId); +} + +void logError(const char *operation, const char *message) noexcept { + __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "%s failed: %s", operation, + message); +} + +void throwJavaRuntimeException(JNIEnv *env, const char *message) noexcept { + jclass exceptionClass = env->FindClass("java/lang/RuntimeException"); + if (exceptionClass == nullptr) { return; } - // ANativeWindow_fromSurface acquires a reference; SurfaceInfo releases it - // (via the releaser below) once it is done with the window. - auto window = ANativeWindow_fromSurface(env, jSurface); - if (window == nullptr) { + env->ThrowNew(exceptionClass, message); + env->DeleteLocalRef(exceptionClass); +} + +int surfaceDimension(jfloat value) noexcept { + if (!std::isfinite(value) || value <= 0.0F) { + return 0; + } + constexpr auto maxDimension = + static_cast(std::numeric_limits::max()); + return value >= maxDimension ? std::numeric_limits::max() + : static_cast(value); +} + +} // namespace + +extern "C" JNIEXPORT jlong JNICALL +Java_com_webgpu_WebGPUModule_initializeNative(JNIEnv *env, jobject /*thiz*/, + jlong jsRuntime, + jobject jsCallInvokerHolder, + jobject blobModule) { + rnwgpu::RNWebGPUSessionId createdSessionId = + rnwgpu::kInvalidRNWebGPUSessionId; + try { + if (jsRuntime == 0 || jsCallInvokerHolder == nullptr || + blobModule == nullptr) { + throw std::invalid_argument( + "A runtime, CallInvoker, and BlobModule are required"); + } + + auto *runtime = reinterpret_cast(jsRuntime); + auto ®istry = rnwgpu::RNWebGPUManagerRegistry::getInstance(); + + const auto runtimeSessionId = + rnwgpu::RNWebGPUManager::sessionForRuntime(*runtime); + if (runtimeSessionId != rnwgpu::kInvalidRNWebGPUSessionId) { + auto existingSession = registry.acquire(runtimeSessionId); + if (existingSession) { + return sessionIdToJava(existingSession.sessionId); + } + } + + auto jsCallInvoker = + facebook::jni::alias_ref< + facebook::react::CallInvokerHolder::javaobject>{ + reinterpret_cast( + jsCallInvokerHolder)} + ->cthis() + ->getCallInvoker(); + if (!jsCallInvoker) { + throw std::runtime_error("React Native returned an empty JS CallInvoker"); + } + + auto globalBlobModule = facebook::jni::make_global( + facebook::jni::alias_ref{blobModule}); + auto platformContext = std::make_shared( + std::move(globalBlobModule)); + + createdSessionId = registry.createSession(); + const auto javaSessionId = sessionIdToJava(createdSessionId); + auto manager = std::make_shared( + createdSessionId, runtime, std::move(jsCallInvoker), + std::move(platformContext)); + registry.publish(createdSessionId, std::move(manager)); + return javaSessionId; + } catch (const std::exception &error) { + if (createdSessionId != rnwgpu::kInvalidRNWebGPUSessionId) { + rnwgpu::SurfaceRegistry::getInstance().closeSession(createdSessionId); + } + logError("initializeNative", error.what()); + throwJavaRuntimeException(env, error.what()); + } catch (...) { + if (createdSessionId != rnwgpu::kInvalidRNWebGPUSessionId) { + rnwgpu::SurfaceRegistry::getInstance().closeSession(createdSessionId); + } + constexpr auto message = "Unknown native WebGPU initialization error"; + logError("initializeNative", message); + throwJavaRuntimeException(env, message); + } + return 0; +} + +extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_invalidateNative( + JNIEnv * /*env*/, jobject /*thiz*/, jlong javaSessionId) { + const auto sessionId = sessionIdFromJava(javaSessionId); + if (sessionId == rnwgpu::kInvalidRNWebGPUSessionId) { return; } - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - auto gpu = manager->_gpu; - auto surface = manager->_platformContext->makeSurface( - gpu, window, static_cast(width), static_cast(height)); - // Find-or-create + attach runs atomically under the registry lock so a - // concurrent destroyContext cannot orphan this surface. - auto info = registry.attachSurface( - contextId, gpu, static_cast(width), static_cast(height), window, - surface, [](void *nativeSurface) { - ANativeWindow_release(static_cast(nativeSurface)); - }); - // The attach is adopted at the next frame boundary by the rendering thread; - // schedule a flush so contexts that are not currently rendering still pick - // it up (and present their last offscreen frame). - manager->flushPendingSurfaceTransition(info); + + try { + auto manager = + rnwgpu::RNWebGPUManagerRegistry::getInstance().release(sessionId); + manager.reset(); + } catch (const std::exception &error) { + logError("invalidateNative", error.what()); + } catch (...) { + logError("invalidateNative", "Unknown native WebGPU teardown error"); + } +} + +extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceChanged( + JNIEnv * /*env*/, jobject /*thiz*/, jlong javaSessionId, + jlong javaSurfaceOwnerId, jobject /*surface*/, jint contextId, jfloat width, + jfloat height) { + try { + const auto sessionId = sessionIdFromJava(javaSessionId); + const auto surfaceOwnerId = surfaceOwnerIdFromJava(javaSurfaceOwnerId); + if (surfaceOwnerId == rnwgpu::kInvalidSurfaceOwnerId || contextId <= 0 || + !rnwgpu::RNWebGPUManagerRegistry::getInstance().get(sessionId)) { + return; + } + + auto surfaceInfo = + rnwgpu::SurfaceRegistry::getInstance().getSurfaceInfoIfOwnedBy( + sessionId, contextId, surfaceOwnerId); + if (surfaceInfo) { + surfaceInfo->resizeIfOwnedBy(surfaceOwnerId, surfaceDimension(width), + surfaceDimension(height)); + } + } catch (const std::exception &error) { + logError("onSurfaceChanged", error.what()); + } catch (...) { + logError("onSurfaceChanged", "Unknown native surface resize error"); + } +} + +extern "C" JNIEXPORT jint JNICALL Java_com_webgpu_WebGPUView_onSurfaceCreate( + JNIEnv *env, jobject /*thiz*/, jlong javaSessionId, + jlong javaSurfaceOwnerId, jobject javaSurface, jint contextId, jfloat width, + jfloat height) { + const auto surfaceOwnerId = surfaceOwnerIdFromJava(javaSurfaceOwnerId); + std::shared_ptr surfaceInfo; + try { + const auto sessionId = sessionIdFromJava(javaSessionId); + auto managerSnapshot = + rnwgpu::RNWebGPUManagerRegistry::getInstance().get(sessionId); + if (!managerSnapshot || surfaceOwnerId == rnwgpu::kInvalidSurfaceOwnerId || + contextId <= 0 || javaSurface == nullptr) { + return kSurfacePublishFailed; + } + + auto *nativeWindow = ANativeWindow_fromSurface(env, javaSurface); + if (nativeWindow == nullptr) { + logError("onSurfaceCreate", "ANativeWindow_fromSurface returned null"); + return kSurfacePublishFailed; + } + + auto windowOwner = + std::shared_ptr(nativeWindow, [](void *window) noexcept { + if (window != nullptr) { + ANativeWindow_release(static_cast(window)); + } + }); + + auto &surfaceRegistry = rnwgpu::SurfaceRegistry::getInstance(); + const int nativeWidth = surfaceDimension(width); + const int nativeHeight = surfaceDimension(height); + + // Claim under the registry lock before doing Dawn work. A claimed entry + // counts as native-owned, so concurrent JS unmount cleanup cannot erase it + // between surface creation and the latched attach. + surfaceInfo = surfaceRegistry.claimSurfaceInfo( + sessionId, contextId, surfaceOwnerId, managerSnapshot.manager->_gpu, + nativeWidth, nativeHeight); + if (!surfaceInfo) { + return kSurfacePublishFailed; + } + + auto webGpuSurface = managerSnapshot.manager->_platformContext->makeSurface( + managerSnapshot.manager->_gpu, nativeWindow, nativeWidth, nativeHeight); + if (!webGpuSurface) { + throw std::runtime_error("WebGPU surface creation returned null"); + } + if (!managerSnapshot.manager->isActive()) { + (void)surfaceRegistry.removeSurfaceInfoIfOwnedBy( + sessionId, contextId, surfaceOwnerId, surfaceInfo); + return kSurfacePublishFailed; + } + + const bool attached = surfaceInfo->attachSurfaceIfOwnedBy( + surfaceOwnerId, nativeWindow, std::move(webGpuSurface), windowOwner); + if (!attached) { + return kSurfacePublishFailed; + } + // The session can be invalidated while Dawn creates the surface. If that + // happened after claimSurfaceInfo(), do not leave an unregistered onscreen + // surface alive through the remainder of reload teardown. + if (!managerSnapshot.manager->isActive()) { + (void)surfaceRegistry.removeSurfaceInfoIfOwnedBy( + sessionId, contextId, surfaceOwnerId, surfaceInfo); + return kSurfacePublishFailed; + } + // Adoption happens at the next render frame boundary. Flush on the JS + // thread as well so static/offscreen content is republished immediately. + managerSnapshot.manager->flushPendingSurfaceTransition(surfaceInfo); + return kSurfacePublishOnscreen; + } catch (const std::exception &error) { + const bool registeredOffscreen = + surfaceInfo && surfaceInfo->unconfigureIfOwnedBy(surfaceOwnerId); + logError("onSurfaceCreate", error.what()); + return registeredOffscreen ? kSurfacePublishRegisteredOffscreen + : kSurfacePublishFailed; + } catch (...) { + const bool registeredOffscreen = + surfaceInfo && surfaceInfo->unconfigureIfOwnedBy(surfaceOwnerId); + logError("onSurfaceCreate", "Unknown WebGPU surface creation error"); + return registeredOffscreen ? kSurfacePublishRegisteredOffscreen + : kSurfacePublishFailed; + } } extern "C" JNIEXPORT void JNICALL -Java_com_webgpu_WebGPUView_switchToOffscreenSurface(JNIEnv *env, jobject thiz, +Java_com_webgpu_WebGPUView_switchToOffscreenSurface(JNIEnv * /*env*/, + jobject /*thiz*/, + jlong javaSessionId, + jlong javaSurfaceOwnerId, jint contextId) { - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - if (auto info = registry.getSurfaceInfo(contextId)) { - info->switchToOffscreen(); + try { + const auto sessionId = sessionIdFromJava(javaSessionId); + const auto surfaceOwnerId = surfaceOwnerIdFromJava(javaSurfaceOwnerId); + if (surfaceOwnerId == rnwgpu::kInvalidSurfaceOwnerId || contextId <= 0 || + !rnwgpu::RNWebGPUManagerRegistry::getInstance().get(sessionId)) { + return; + } + + auto surfaceInfo = + rnwgpu::SurfaceRegistry::getInstance().getSurfaceInfoIfOwnedBy( + sessionId, contextId, surfaceOwnerId); + if (surfaceInfo) { + surfaceInfo->switchToOffscreenIfOwnedBy(surfaceOwnerId); + } + } catch (const std::exception &error) { + logError("switchToOffscreenSurface", error.what()); + } catch (...) { + logError("switchToOffscreenSurface", + "Unknown native offscreen transition error"); } } -extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onViewDestroyed( - JNIEnv *env, jobject thiz, jint contextId) { - // The view dies with its Canvas (contextIds are never reused), so view - // teardown retires the registry entry. The JS-side cleanup - // (RNWebGPU.destroyContext) only handles entries that never had a native - // surface; see RNWebGPU::destroyContext for the ownership split. - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - if (auto info = registry.getSurfaceInfo(contextId)) { - info->detachSurface(); +extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUView_onSurfaceDestroy( + JNIEnv * /*env*/, jobject /*thiz*/, jlong javaSessionId, + jlong javaSurfaceOwnerId, jint contextId) { + try { + const auto sessionId = sessionIdFromJava(javaSessionId); + const auto surfaceOwnerId = surfaceOwnerIdFromJava(javaSurfaceOwnerId); + if (sessionId == rnwgpu::kInvalidRNWebGPUSessionId || + surfaceOwnerId == rnwgpu::kInvalidSurfaceOwnerId || contextId <= 0) { + return; + } + auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); + auto surfaceInfo = + registry.getSurfaceInfoIfOwnedBy(sessionId, contextId, surfaceOwnerId); + if (!surfaceInfo) { + return; + } + + (void)registry.removeSurfaceInfoIfOwnedBy(sessionId, contextId, + surfaceOwnerId, surfaceInfo); + } catch (const std::exception &error) { + logError("onSurfaceDestroy", error.what()); + } catch (...) { + logError("onSurfaceDestroy", "Unknown native surface teardown error"); } - registry.removeSurfaceInfo(contextId); } diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAHBView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAHBView.java index 6bf5cd1378..a0de095aa5 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAHBView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAHBView.java @@ -95,16 +95,16 @@ private void setupImageReader(int width, int height) { // Notify WebGPU about the new surface if (!mSurfaceCreated) { - mApi.surfaceCreated(mSurface); + mApi.surfaceCreated(this, mSurface); mSurfaceCreated = true; } else { - mApi.surfaceChanged(mSurface); + mApi.surfaceChanged(this, mSurface); } } catch (Exception e) { e.printStackTrace(); // Fallback to offscreen if ImageReader creation fails - mApi.surfaceOffscreen(); + mApi.surfaceOffscreen(this); } } @@ -254,9 +254,10 @@ private void cleanupImageReader() { protected void onDetachedFromWindow() { super.onDetachedFromWindow(); - // Detach: rendering falls back to the offscreen texture until reattach. + // Detach is reversible; preserve the registered canvas offscreen so it + // can be republished when this view is attached again. if (mSurfaceCreated) { - mApi.surfaceOffscreen(); + mApi.surfaceOffscreen(this); mSurfaceCreated = false; } diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java index 219a649fd7..e7134ec06b 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUAPI.java @@ -1,25 +1,23 @@ package com.webgpu; import android.view.Surface; +import android.view.View; import com.facebook.proguard.annotations.DoNotStrip; -/** - * Surface lifecycle events a WebGPU child view reports. The registry entry - * itself is owned by the JS Canvas component (created lazily, removed via - * RNWebGPU.destroyContext on unmount); views only attach and detach surfaces. - * A detached context keeps rendering into an offscreen texture whose content - * is blitted onto the next attached surface. - */ public interface WebGPUAPI { void surfaceCreated( + View source, Surface surface ); void surfaceChanged( + View source, Surface surface ); - void surfaceOffscreen(); + void surfaceDestroyed(View source); + + void surfaceOffscreen(View source); } diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java index de228999b0..b8aee68540 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java @@ -1,12 +1,7 @@ package com.webgpu; -import android.util.Log; - import androidx.annotation.OptIn; -import java.util.HashSet; -import java.util.Set; - import com.facebook.proguard.annotations.DoNotStrip; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.JavaScriptContextHolder; @@ -14,37 +9,83 @@ import com.facebook.react.common.annotations.FrameworkAPI; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.modules.blob.BlobModule; -import com.facebook.react.modules.blob.BlobProvider; import com.facebook.react.turbomodule.core.CallInvokerHolderImpl; import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder; @ReactModule(name = WebGPUModule.NAME) public class WebGPUModule extends NativeWebGPUModuleSpec { + private final Object mNativeLifecycleLock = new Object(); + private long mNativeSessionId = 0; + static { - System.loadLibrary("react-native-webgpu"); // Load the C++ library + System.loadLibrary("react-native-webgpu"); } public WebGPUModule(ReactApplicationContext reactContext) { super(reactContext); - // Initialize the C++ module - initialize(); } @OptIn(markerClass = FrameworkAPI.class) @ReactMethod(isBlockingSynchronousMethod = true) public boolean install() { - ReactApplicationContext context = getReactApplicationContext(); - JavaScriptContextHolder jsContext = context.getJavaScriptContextHolder(); - CallInvokerHolder callInvokerHolder = context.getCatalystInstance().getJSCallInvokerHolder(); - BlobModule blobModule = getReactApplicationContext().getNativeModule(BlobModule.class); - if (blobModule == null) { - throw new RuntimeException("React Native's BlobModule was not found!"); + synchronized (mNativeLifecycleLock) { + if (mNativeSessionId != 0) { + return true; + } + + ReactApplicationContext context = getReactApplicationContext(); + JavaScriptContextHolder jsContext = context.getJavaScriptContextHolder(); + CallInvokerHolder callInvokerHolder = context.getJSCallInvokerHolder(); + BlobModule blobModule = context.getNativeModule(BlobModule.class); + + if (jsContext == null) { + throw new IllegalStateException("React Native's JavaScript context is not available"); + } + if (!(callInvokerHolder instanceof CallInvokerHolderImpl)) { + throw new IllegalStateException("React Native's JS CallInvoker is not available"); + } + if (blobModule == null) { + throw new IllegalStateException("React Native's BlobModule was not found"); + } + + // React Native clears this pointer under the same monitor during reload. + // Keep it alive for the complete native installation transaction. + synchronized (jsContext) { + long jsRuntime = jsContext.get(); + if (jsRuntime == 0) { + return false; + } + + mNativeSessionId = initializeNative( + jsRuntime, + (CallInvokerHolderImpl) callInvokerHolder, + blobModule + ); + } + + return mNativeSessionId != 0; + } + } + + @Override + public void invalidate() { + synchronized (mNativeLifecycleLock) { + if (mNativeSessionId != 0) { + invalidateNative(mNativeSessionId); + mNativeSessionId = 0; + } } - initializeNative(jsContext.get(), (CallInvokerHolderImpl) callInvokerHolder, blobModule); - return true; + super.invalidate(); } @OptIn(markerClass = FrameworkAPI.class) @DoNotStrip - private native void initializeNative(long jsRuntime, CallInvokerHolderImpl jsInvoker, BlobModule blobModule); + private native long initializeNative( + long jsRuntime, + CallInvokerHolderImpl jsInvoker, + BlobModule blobModule + ); + + @DoNotStrip + private native void invalidateNative(long sessionId); } diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java index 3ef30c1bb7..e135aaa158 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java @@ -21,23 +21,21 @@ public WebGPUSurfaceView(Context context, WebGPUAPI api) { @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); - // surfaceDestroyed() normally fires during detach as well; going offscreen - // is idempotent, so this is just a safety net for paths where it does not. - mApi.surfaceOffscreen(); + mApi.surfaceOffscreen(this); } @Override public void surfaceCreated(@NonNull SurfaceHolder holder) { - mApi.surfaceCreated(holder.getSurface()); + mApi.surfaceCreated(this, holder.getSurface()); } @Override public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) { - mApi.surfaceChanged(holder.getSurface()); + mApi.surfaceChanged(this, holder.getSurface()); } @Override public void surfaceDestroyed(@NonNull SurfaceHolder holder) { - mApi.surfaceOffscreen(); + mApi.surfaceOffscreen(this); } } diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java index e999e098da..492f4df52e 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceViewWithSC.java @@ -35,6 +35,15 @@ protected void onLayout(boolean changed, int left, int top, int right, int botto @Override protected void onDetachedFromWindow() { + mApi.surfaceOffscreen(this); + if (mSurface != null) { + mSurface.release(); + mSurface = null; + } + if (mSurfaceControl != null) { + mSurfaceControl.release(); + mSurfaceControl = null; + } super.onDetachedFromWindow(); } @@ -54,8 +63,10 @@ public void surfaceCreated(@NonNull SurfaceHolder holder) { scb.setFormat(PixelFormat.RGBA_8888); mSurfaceControl = scb.build(); mSurface = new Surface(mSurfaceControl); - mApi.surfaceCreated(mSurface); } + // surfaceDestroyed() moves the existing SurfaceControl offscreen without + // releasing it. Re-publish both new and reused surfaces here. + mApi.surfaceCreated(this, mSurface); SurfaceControl.Transaction tr = new SurfaceControl.Transaction(); tr.setVisibility(mSurfaceControl, true); tr.apply(); @@ -63,7 +74,10 @@ public void surfaceCreated(@NonNull SurfaceHolder holder) { @Override public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) { - mApi.surfaceChanged(mSurface); + if (mSurface == null || mSurfaceControl == null) { + return; + } + mApi.surfaceChanged(this, mSurface); SurfaceControl.Transaction tr = new SurfaceControl.Transaction(); tr.setVisibility(mSurfaceControl, true); tr.setBufferSize(mSurfaceControl, getWidth(), getHeight()); @@ -72,6 +86,10 @@ public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, @Override public void surfaceDestroyed(@NonNull SurfaceHolder holder) { + mApi.surfaceOffscreen(this); + if (mSurfaceControl == null) { + return; + } SurfaceControl.Transaction tr = new SurfaceControl.Transaction(); tr.reparent(mSurfaceControl, null); tr.apply(); diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java index aa449b440d..168f743a85 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUTextureView.java @@ -10,7 +10,7 @@ @SuppressLint("ViewConstructor") public class WebGPUTextureView extends TextureView implements TextureView.SurfaceTextureListener { - WebGPUAPI mApi; + private final WebGPUAPI mApi; private Surface mSurface; public WebGPUTextureView(Context context, WebGPUAPI api) { @@ -22,24 +22,23 @@ public WebGPUTextureView(Context context, WebGPUAPI api) { @Override public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surfaceTexture, int width, int height) { + releaseSurface(); mSurface = new Surface(surfaceTexture); - mApi.surfaceCreated(mSurface); + mApi.surfaceCreated(this, mSurface); } @Override public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surfaceTexture, int width, int height) { - mApi.surfaceChanged(mSurface); + if (mSurface == null) { + mSurface = new Surface(surfaceTexture); + } + mApi.surfaceChanged(this, mSurface); } @Override public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture) { - // Detach first (synchronous through JNI) so the native side has dropped - // its window reference before we release ours. - mApi.surfaceOffscreen(); - if (mSurface != null) { - mSurface.release(); - mSurface = null; - } + mApi.surfaceOffscreen(this); + releaseSurface(); return true; } @@ -47,4 +46,18 @@ public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture) public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surfaceTexture) { // No implementation needed } + + @Override + protected void onDetachedFromWindow() { + mApi.surfaceOffscreen(this); + releaseSurface(); + super.onDetachedFromWindow(); + } + + private void releaseSurface() { + if (mSurface != null) { + mSurface.release(); + mSurface = null; + } + } } diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java index c0c2a2eec1..d937a57d81 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java @@ -1,93 +1,223 @@ package com.webgpu; import android.content.Context; -import android.os.Build; import android.view.Surface; import android.view.View; +import java.util.concurrent.atomic.AtomicLong; + import com.facebook.proguard.annotations.DoNotStrip; -import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.views.view.ReactViewGroup; public class WebGPUView extends ReactViewGroup implements WebGPUAPI { + private static final AtomicLong NEXT_SURFACE_OWNER_ID = new AtomicLong(1); + private static final int SURFACE_PUBLISH_FAILED = 0; + private static final int SURFACE_PUBLISH_REGISTERED_OFFSCREEN = 1; + private static final int SURFACE_PUBLISH_ONSCREEN = 2; - + private final long mSurfaceOwnerId = createSurfaceOwnerId(); + private long mSessionId; private int mContextId; private boolean mTransparent = false; - private WebGPUModule mModule; - private View mView = null; + private boolean mDisposed = false; + private boolean mSurfaceRegistered = false; + private boolean mSurfaceOnscreen = false; + private Surface mCurrentSurface; + private View mView; WebGPUView(Context context) { super(context); } + public void setSessionId(long sessionId) { + if (mSessionId == sessionId) { + return; + } + destroyRegisteredSurface(); + mSessionId = sessionId; + } + public void setContextId(int contextId) { - if (mModule == null) { - Context context = getContext(); - if (context instanceof ThemedReactContext) { - mModule = ((ThemedReactContext) context).getReactApplicationContext().getNativeModule(WebGPUModule.class); - } + if (mContextId == contextId) { + return; } + destroyRegisteredSurface(); mContextId = contextId; } + public void commitProperties() { + publishCurrentSurface(); + } + public void setTransparent(boolean value) { - Context ctx = getContext(); - if (value != mTransparent || mView == null) { - if (mView != null) { - removeView(mView); - } - mTransparent = value; - if (mTransparent) { -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { -// mView = new WebGPUAHBView(ctx, this); -// } else { - mView = new WebGPUTextureView(ctx, this); -// } - } else { - mView = new WebGPUSurfaceView(ctx, this); - } - addView(mView); + if (mDisposed || (value == mTransparent && mView != null)) { + return; + } + + View previousView = mView; + if (previousView != null) { + // Preserve the GPUCanvasContext while swapping SurfaceView and + // TextureView. The replacement surface claims the same native owner and + // copies the offscreen frame back to the new onscreen surface. + moveRegisteredSurfaceOffscreen(); + mCurrentSurface = null; + mView = null; + removeView(previousView); + } + + mTransparent = value; + mView = mTransparent + ? new WebGPUTextureView(getContext(), this) + : new WebGPUSurfaceView(getContext(), this); + addView(mView); + } + + public void dispose() { + if (mDisposed) { + return; + } + + mDisposed = true; + destroyRegisteredSurface(); + mCurrentSurface = null; + + View previousView = mView; + mView = null; + if (previousView != null) { + removeView(previousView); } } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); - mView.layout(0, 0, this.getMeasuredWidth(), this.getMeasuredHeight()); + if (mView != null) { + mView.layout(0, 0, getMeasuredWidth(), getMeasuredHeight()); + } } @Override - public void surfaceCreated(Surface surface) { - float density = getResources().getDisplayMetrics().density; - float width = getWidth() / density; - float height = getHeight() / density; - onSurfaceCreate(surface, mContextId, width, height); + public void surfaceCreated(View source, Surface surface) { + if (!acceptsCallbackFrom(source)) { + return; + } + if (mCurrentSurface != surface) { + moveRegisteredSurfaceOffscreen(); + } + mCurrentSurface = surface; + publishCurrentSurface(); } @Override - public void surfaceChanged(Surface surface) { + public void surfaceChanged(View source, Surface surface) { + if (!acceptsCallbackFrom(source)) { + return; + } + boolean surfaceWasReplaced = mCurrentSurface != surface; + if (surfaceWasReplaced) { + moveRegisteredSurfaceOffscreen(); + } + mCurrentSurface = surface; + if (!hasValidIdentity()) { + return; + } + + if (!mSurfaceRegistered || !mSurfaceOnscreen || surfaceWasReplaced) { + publishCurrentSurface(); + return; + } + float density = getResources().getDisplayMetrics().density; - float width = getWidth() / density; - float height = getHeight() / density; - onSurfaceChanged(surface, mContextId, width, height); + onSurfaceChanged( + mSessionId, + mSurfaceOwnerId, + surface, + mContextId, + getWidth() / density, + getHeight() / density + ); } @Override - public void surfaceOffscreen() { - switchToOffscreenSurface(mContextId); + public void surfaceDestroyed(View source) { + if (!acceptsCallbackFrom(source)) { + return; + } + mCurrentSurface = null; + destroyRegisteredSurface(); + } + + @Override + public void surfaceOffscreen(View source) { + if (!acceptsCallbackFrom(source)) { + return; + } + mCurrentSurface = null; + if (mSurfaceRegistered && hasValidIdentity()) { + switchToOffscreenSurface(mSessionId, mSurfaceOwnerId, mContextId); + mSurfaceOnscreen = false; + } } - /** - * Called from WebGPUViewManager.onDropViewInstance when React removes this - * view: the view dies with its Canvas, so it retires the registry entry. - */ - public void destroy() { - onViewDestroyed(mContextId); + private boolean acceptsCallbackFrom(View source) { + return !mDisposed && source == mView; + } + + private boolean hasValidIdentity() { + return mSessionId > 0 && mContextId > 0; + } + + private void publishCurrentSurface() { + if (mDisposed + || mCurrentSurface == null + || !hasValidIdentity() + || (mSurfaceRegistered && mSurfaceOnscreen)) { + return; + } + + float density = getResources().getDisplayMetrics().density; + boolean wasRegistered = mSurfaceRegistered; + int publishStatus = onSurfaceCreate( + mSessionId, + mSurfaceOwnerId, + mCurrentSurface, + mContextId, + getWidth() / density, + getHeight() / density + ); + mSurfaceRegistered = wasRegistered + || publishStatus == SURFACE_PUBLISH_REGISTERED_OFFSCREEN + || publishStatus == SURFACE_PUBLISH_ONSCREEN; + mSurfaceOnscreen = publishStatus == SURFACE_PUBLISH_ONSCREEN; + } + + private void destroyRegisteredSurface() { + if (mSurfaceRegistered && hasValidIdentity()) { + onSurfaceDestroy(mSessionId, mSurfaceOwnerId, mContextId); + } + mSurfaceRegistered = false; + mSurfaceOnscreen = false; + } + + private void moveRegisteredSurfaceOffscreen() { + if (mSurfaceRegistered && mSurfaceOnscreen && hasValidIdentity()) { + switchToOffscreenSurface(mSessionId, mSurfaceOwnerId, mContextId); + } + mSurfaceOnscreen = false; + } + + private static long createSurfaceOwnerId() { + long ownerId = NEXT_SURFACE_OWNER_ID.getAndIncrement(); + if (ownerId <= 0) { + throw new IllegalStateException("WebGPU surface owner ID overflow"); + } + return ownerId; } @DoNotStrip - private native void onSurfaceCreate( + private native int onSurfaceCreate( + long sessionId, + long surfaceOwnerId, Surface surface, int contextId, float width, @@ -96,6 +226,8 @@ private native void onSurfaceCreate( @DoNotStrip private native void onSurfaceChanged( + long sessionId, + long surfaceOwnerId, Surface surface, int contextId, float width, @@ -103,9 +235,12 @@ private native void onSurfaceChanged( ); @DoNotStrip - private native void switchToOffscreenSurface(int contextId); + private native void onSurfaceDestroy(long sessionId, long surfaceOwnerId, int contextId); @DoNotStrip - private native void onViewDestroyed(int contextId); - + private native void switchToOffscreenSurface( + long sessionId, + long surfaceOwnerId, + int contextId + ); } diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java index a7eae8ddc6..81c2676568 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java @@ -11,6 +11,7 @@ public class WebGPUViewManager extends WebGPUViewManagerSpec { public static final String NAME = "WebGPUView"; + private static final double MAX_SAFE_JAVASCRIPT_INTEGER = 9007199254740991.0; @NonNull @Override @@ -24,12 +25,6 @@ public WebGPUView createViewInstance(@NonNull ThemedReactContext context) { return new WebGPUView(context); } - @Override - public void onDropViewInstance(@NonNull WebGPUView view) { - super.onDropViewInstance(view); - view.destroy(); - } - @Override @ReactProp(name = "transparent") public void setTransparent(WebGPUView view, boolean value) { @@ -41,4 +36,32 @@ public void setTransparent(WebGPUView view, boolean value) { public void setContextId(WebGPUView view, int value) { view.setContextId(value); } + + @Override + @ReactProp(name = "sessionId") + public void setSessionId(WebGPUView view, double value) { + if (Double.isNaN(value) + || Double.isInfinite(value) + || value < 1 + || value > MAX_SAFE_JAVASCRIPT_INTEGER + || value != Math.rint(value)) { + throw new IllegalArgumentException("sessionId must be a positive JavaScript-safe integer"); + } + view.setSessionId((long) value); + } + + @Override + protected void onAfterUpdateTransaction(@NonNull WebGPUView view) { + super.onAfterUpdateTransaction(view); + // sessionId and contextId form one native identity. Publish only after all + // React props have been applied so a recycled view cannot briefly attach + // a new session to its previous context. + view.commitProperties(); + } + + @Override + public void onDropViewInstance(@NonNull WebGPUView view) { + view.dispose(); + super.onDropViewInstance(view); + } } diff --git a/packages/webgpu/apple/ApplePlatformContext.mm b/packages/webgpu/apple/ApplePlatformContext.mm index 594337cfcb..e6e607100c 100644 --- a/packages/webgpu/apple/ApplePlatformContext.mm +++ b/packages/webgpu/apple/ApplePlatformContext.mm @@ -86,8 +86,7 @@ void checkIfUsingSimulatorWithAPIValidation() { std::move(onError)); } -ImageData -ApplePlatformContext::createImageBitmapFromData(std::span data) { +static ImageData decodeImageBitmapData(std::span data) { // This avoids a copy by assuming the UIImage/NSImage constructors // decode `nsData` eagerly before the memory for the wrapped `data` // is freed. @@ -139,6 +138,11 @@ void checkIfUsingSimulatorWithAPIValidation() { return result; } +ImageData +ApplePlatformContext::createImageBitmapFromData(std::span data) { + return decodeImageBitmapData(data); +} + void ApplePlatformContext::createImageBitmapFromDataAsync( std::span data, std::function onSuccess, std::function onError) { @@ -149,7 +153,10 @@ void checkIfUsingSimulatorWithAPIValidation() { dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ @autoreleasepool { try { - auto result = createImageBitmapFromData(*ownedData); + // Decoding is deliberately independent of ApplePlatformContext. The + // module can be destroyed by an Expo reload while this block remains + // queued, so retaining or dereferencing its raw `this` is unsafe. + auto result = decodeImageBitmapData(*ownedData); onSuccess(std::move(result)); } catch (const std::exception &e) { onError(e.what()); @@ -158,12 +165,10 @@ void checkIfUsingSimulatorWithAPIValidation() { }); } -VideoFrameHandle -ApplePlatformContext::loadVideoFrame(const std::string &path) { +VideoFrameHandle ApplePlatformContext::loadVideoFrame(const std::string &path) { NSString *nsPath = [NSString stringWithUTF8String:path.c_str()]; - NSURL *url = [nsPath hasPrefix:@"file://"] - ? [NSURL URLWithString:nsPath] - : [NSURL fileURLWithPath:nsPath]; + NSURL *url = [nsPath hasPrefix:@"file://"] ? [NSURL URLWithString:nsPath] + : [NSURL fileURLWithPath:nsPath]; AVURLAsset *asset = [AVURLAsset assetWithURL:url]; NSArray *videoTracks = @@ -183,8 +188,7 @@ void checkIfUsingSimulatorWithAPIValidation() { } NSDictionary *outputSettings = @{ - (NSString *)kCVPixelBufferPixelFormatTypeKey : - @(kCVPixelFormatType_32BGRA), + (NSString *)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA), (NSString *)kCVPixelBufferIOSurfacePropertiesKey : @{}, (NSString *)kCVPixelBufferMetalCompatibilityKey : @YES, }; @@ -238,7 +242,7 @@ void checkIfUsingSimulatorWithAPIValidation() { std::unique_ptr ApplePlatformContext::createVideoPlayer(const std::string &path, - VideoPixelFormat format) { + VideoPixelFormat format) { return createAppleVideoPlayer(path, format); } @@ -250,8 +254,8 @@ void checkIfUsingSimulatorWithAPIValidation() { return wrapCVPixelBuffer(static_cast(pointer)); } -VideoFrameHandle -ApplePlatformContext::createTestVideoFrame(uint32_t width, uint32_t height) { +VideoFrameHandle ApplePlatformContext::createTestVideoFrame(uint32_t width, + uint32_t height) { NSDictionary *attrs = @{ (NSString *)kCVPixelBufferIOSurfacePropertiesKey : @{}, (NSString *)kCVPixelBufferMetalCompatibilityKey : @YES, diff --git a/packages/webgpu/apple/MetalView.h b/packages/webgpu/apple/MetalView.h index a563db9746..f691fc0730 100644 --- a/packages/webgpu/apple/MetalView.h +++ b/packages/webgpu/apple/MetalView.h @@ -5,7 +5,8 @@ @interface MetalView : RNWGPlatformView -@property NSNumber *contextId; +@property(nonatomic, strong) NSNumber *sessionId; +@property(nonatomic, strong) NSNumber *contextId; - (void)configure; - (void)update; diff --git a/packages/webgpu/apple/MetalView.mm b/packages/webgpu/apple/MetalView.mm index 6657a62646..83d24fda81 100644 --- a/packages/webgpu/apple/MetalView.mm +++ b/packages/webgpu/apple/MetalView.mm @@ -1,75 +1,191 @@ #import "MetalView.h" +#include "SurfaceRegistry.h" #import "webgpu/webgpu_cpp.h" +#include +#include +#include +#include +#include + +namespace { + +rnwgpu::SurfaceOwnerId nextMetalViewOwnerId() noexcept { + static std::atomic nextOwnerId{1}; + for (;;) { + const auto ownerId = nextOwnerId.fetch_add(1, std::memory_order_relaxed); + if (ownerId != rnwgpu::kInvalidSurfaceOwnerId) { + return ownerId; + } + } +} + +std::shared_ptr retainMetalLayer(CAMetalLayer *layer) { + if (layer == nil) { + return {}; + } + + void *retainedLayer = (__bridge_retained void *)layer; + return std::shared_ptr(retainedLayer, [](void *value) noexcept { + if ([NSThread isMainThread]) { + CFRelease(value); + return; + } + + // A Canvas can be released by a worklet. Keep the final Objective-C + // release on the UI thread even though retain/release themselves are + // thread-safe, because this may be the CAMetalLayer's last owner. + dispatch_async(dispatch_get_main_queue(), ^{ + CFRelease(value); + }); + }); +} + +void detachSurface(rnwgpu::SurfaceOwnerId ownerId, + rnwgpu::RNWebGPUSessionId &sessionId, int &contextId, + std::shared_ptr &surfaceInfo) noexcept { + auto detachedSurface = std::move(surfaceInfo); + const auto detachedSessionId = + std::exchange(sessionId, rnwgpu::kInvalidRNWebGPUSessionId); + const auto detachedContextId = std::exchange(contextId, 0); + if (!detachedSurface) { + return; + } + + try { + (void)rnwgpu::SurfaceRegistry::getInstance().removeSurfaceInfoIfOwnedBy( + detachedSessionId, detachedContextId, ownerId, detachedSurface); + } catch (...) { + // Cleanup must not escape view recycling or deallocation. + } +} + +} // namespace + @implementation MetalView { - BOOL _isConfigured; + rnwgpu::SurfaceOwnerId _surfaceOwnerId; + rnwgpu::RNWebGPUSessionId _configuredSessionId; + int _configuredContextId; + std::shared_ptr _surfaceInfo; } #if !TARGET_OS_OSX + (Class)layerClass { return [CAMetalLayer class]; } -#else // !TARGET_OS_OSX +#endif // !TARGET_OS_OSX + - (instancetype)init { self = [super init]; if (self) { + _surfaceOwnerId = nextMetalViewOwnerId(); +#if TARGET_OS_OSX self.wantsLayer = true; self.layer = [CAMetalLayer layer]; +#endif } return self; } -#endif // !TARGET_OS_OSX - (void)configure { - auto size = self.frame.size; - std::shared_ptr manager = [WebGPUModule getManager]; - if (manager == nullptr) { + if (_surfaceOwnerId == rnwgpu::kInvalidSurfaceOwnerId) { + _surfaceOwnerId = nextMetalViewOwnerId(); + } + detachSurface(_surfaceOwnerId, _configuredSessionId, _configuredContextId, + _surfaceInfo); + + if (_sessionId == nil || _contextId == nil || self.layer == nil) { + return; + } + + const auto sessionValue = [_sessionId doubleValue]; + const auto contextId = [_contextId intValue]; + if (!std::isfinite(sessionValue) || sessionValue < 1.0 || + sessionValue > static_cast(rnwgpu::kMaxRNWebGPUSessionId) || + sessionValue != std::trunc(sessionValue) || contextId <= 0) { return; } - // Retain the layer for as long as SurfaceInfo holds the pointer: the - // latched attach (and the flush lambda that adopts it) can outlive this - // view, e.g. across a dev reload where the registry is cleared before - // dealloc runs. Balanced by the releaser below. - void *nativeSurface = (void *)CFBridgingRetain(self.layer); - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - auto gpu = manager->_gpu; - auto surface = manager->_platformContext->makeSurface( - gpu, nativeSurface, size.width, size.height); - // Find-or-create + attach runs atomically under the registry lock so a - // concurrent destroyContext cannot orphan this surface. - auto info = registry.attachSurface( - [_contextId intValue], gpu, size.width, size.height, nativeSurface, - surface, [](void *layer) { - // The releaser can run on the rendering thread; CALayer teardown - // belongs on the main thread. - dispatch_async(dispatch_get_main_queue(), ^{ - CFBridgingRelease(layer); - }); - }); - // The attach is adopted at the next frame boundary by the rendering thread; - // schedule a flush so contexts that are not currently rendering still pick - // it up (and present their last offscreen frame). - manager->flushPendingSurfaceTransition(info); + const auto sessionId = static_cast(sessionValue); + + try { + auto &managerRegistry = rnwgpu::RNWebGPUManagerRegistry::getInstance(); + auto managerSnapshot = managerRegistry.get(sessionId); + if (!managerSnapshot || !managerSnapshot.manager->isActive()) { + return; + } + auto manager = std::move(managerSnapshot.manager); + + const auto size = self.frame.size; + const auto width = static_cast(size.width); + const auto height = static_cast(size.height); + auto *metalLayer = (CAMetalLayer *)self.layer; + void *nativeSurface = (__bridge void *)metalLayer; + auto nativeSurfaceOwner = retainMetalLayer(metalLayer); + auto gpu = manager->_gpu; + + auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); + auto surfaceInfo = registry.claimSurfaceInfo( + sessionId, contextId, _surfaceOwnerId, gpu, width, height); + if (!surfaceInfo) { + return; + } + + _configuredSessionId = sessionId; + _configuredContextId = contextId; + _surfaceInfo = std::move(surfaceInfo); + + auto surface = manager->_platformContext->makeSurface(gpu, nativeSurface, + width, height); + if (!manager->isActive() || + !_surfaceInfo->attachSurfaceIfOwnedBy(_surfaceOwnerId, nativeSurface, + std::move(surface), + std::move(nativeSurfaceOwner))) { + detachSurface(_surfaceOwnerId, _configuredSessionId, _configuredContextId, + _surfaceInfo); + return; + } + // Surface adoption is latched to a frame boundary. Flush on the JS thread + // too so a context with static/offscreen content is republished promptly. + manager->flushPendingSurfaceTransition(_surfaceInfo); + } catch (const std::exception &error) { + detachSurface(_surfaceOwnerId, _configuredSessionId, _configuredContextId, + _surfaceInfo); + NSLog(@"Failed to configure react-native-webgpu surface: %s", error.what()); + } catch (...) { + detachSurface(_surfaceOwnerId, _configuredSessionId, _configuredContextId, + _surfaceInfo); + NSLog(@"Failed to configure react-native-webgpu surface: unknown native " + "error"); + } } - (void)update { - auto size = self.frame.size; - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - if (auto info = registry.getSurfaceInfo([_contextId intValue])) { - info->resize(size.width, size.height); + if (!_surfaceInfo || + _configuredSessionId == rnwgpu::kInvalidRNWebGPUSessionId) { + return; + } + + auto manager = + rnwgpu::RNWebGPUManagerRegistry::getInstance().get(_configuredSessionId); + if (!manager || !manager.manager->isActive()) { + return; } + + auto ownedSurface = + rnwgpu::SurfaceRegistry::getInstance().getSurfaceInfoIfOwnedBy( + _configuredSessionId, _configuredContextId, _surfaceOwnerId); + if (!ownedSurface || ownedSurface != _surfaceInfo) { + return; + } + + const auto size = self.frame.size; + ownedSurface->resizeIfOwnedBy(_surfaceOwnerId, static_cast(size.width), + static_cast(size.height)); } - (void)dealloc { - // The view dies with its Canvas (contextIds are never reused), so view - // teardown retires the registry entry. The JS-side cleanup - // (RNWebGPU.destroyContext) only handles entries that never had a native - // surface; see RNWebGPU::destroyContext for the ownership split. - auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - if (auto info = registry.getSurfaceInfo([_contextId intValue])) { - info->detachSurface(); - } - registry.removeSurfaceInfo([_contextId intValue]); + detachSurface(_surfaceOwnerId, _configuredSessionId, _configuredContextId, + _surfaceInfo); } @end diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index e637633b0d..ea780d00d6 100644 --- a/packages/webgpu/apple/WebGPUModule.mm +++ b/packages/webgpu/apple/WebGPUModule.mm @@ -6,12 +6,46 @@ #import #import #import +#include +#include #import -#import +#include +#include +#include namespace jsi = facebook::jsi; namespace react = facebook::react; +namespace { + +class InstallOperationGuard final { +public: + InstallOperationGuard(std::mutex &mutex, std::condition_variable &condition, + bool &installInProgress) noexcept + : _mutex(mutex), _condition(condition), + _installInProgress(installInProgress) {} + + ~InstallOperationGuard() { + { + std::lock_guard lock(_mutex); + _installInProgress = false; + } + _condition.notify_all(); + } + + InstallOperationGuard(const InstallOperationGuard &) = delete; + InstallOperationGuard &operator=(const InstallOperationGuard &) = delete; + InstallOperationGuard(InstallOperationGuard &&) = delete; + InstallOperationGuard &operator=(InstallOperationGuard &&) = delete; + +private: + std::mutex &_mutex; + std::condition_variable &_condition; + bool &_installInProgress; +}; + +} // namespace + // Category to declare the runtime property on RCTBridge/RCTBridgeProxy. // In Bridgeless mode, self.bridge is an RCTBridgeProxy which implements // -(void *)runtime. In Legacy mode, self.bridge is the real RCTBridge @@ -20,41 +54,61 @@ @interface RCTBridge (JSIRuntime) - (void *)runtime; @end -@implementation WebGPUModule +@implementation WebGPUModule { + std::mutex _managerMutex; + std::condition_variable _managerCondition; + bool _installInProgress; + bool _invalidated; + rnwgpu::RNWebGPUSessionId _ownedSessionId; + std::shared_ptr _ownedManager; +} RCT_EXPORT_MODULE(WebGPUModule) -static std::shared_ptr webgpuManager; - // Synthesize callInvoker so RCTTurboModuleManager injects the JS CallInvoker. // When the module conforms to RCTCallInvokerModule, the TurboModule infra // calls setCallInvoker: during module initialization. @synthesize callInvoker = _callInvoker; + (std::shared_ptr)getManager { - return webgpuManager; + return rnwgpu::RNWebGPUManagerRegistry::getInstance().getActive().manager; } -#pragma Setup and invalidation +#pragma mark - Setup and invalidation + (BOOL)requiresMainQueueSetup { return YES; } - (void)invalidate { - webgpuManager = nil; -} + rnwgpu::RNWebGPUSessionId sessionId = rnwgpu::kInvalidRNWebGPUSessionId; + std::shared_ptr manager; + { + std::unique_lock lock(_managerMutex); + _invalidated = true; + _managerCondition.wait(lock, [self] { return !self->_installInProgress; }); + sessionId = + std::exchange(_ownedSessionId, rnwgpu::kInvalidRNWebGPUSessionId); + manager = std::move(_ownedManager); + } -- (std::shared_ptr)getManager { - return webgpuManager; + if (sessionId != rnwgpu::kInvalidRNWebGPUSessionId) { + try { + rnwgpu::RNWebGPUManagerRegistry::getInstance().release(sessionId); + } catch (const std::exception &error) { + NSLog(@"Failed to tear down react-native-webgpu: %s", error.what()); + } catch (...) { + NSLog(@"Failed to tear down react-native-webgpu: unknown native error"); + } + } + manager.reset(); + + // Mark the native session inactive and detach its dispatcher before React + // Native is allowed to start destroying the JSI runtime. + [super invalidate]; } RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(install) { - if (webgpuManager != nil) { - // Already initialized, ignore call. - return @true; - } - // self.bridge works in both Legacy (RCTBridge) and Bridgeless // (RCTBridgeProxy). jsi::Runtime *runtime = (jsi::Runtime *)self.bridge.runtime; @@ -65,20 +119,96 @@ - (void)invalidate { return [NSNumber numberWithBool:NO]; } - // _callInvoker is injected by RCTTurboModuleManager because we conform to - // RCTCallInvokerModule. Works in both Legacy and Bridgeless. - std::shared_ptr jsInvoker = _callInvoker.callInvoker; - if (!jsInvoker) { - NSLog(@"Failed to install react-native-webgpu: react::CallInvoker was " - @"null!"); - return [NSNumber numberWithBool:NO]; + auto ®istry = rnwgpu::RNWebGPUManagerRegistry::getInstance(); + rnwgpu::RNWebGPUSessionId runtimeSessionId; + rnwgpu::RNWebGPUSessionId previousSessionId; + std::shared_ptr previousManager; + { + std::unique_lock lock(_managerMutex); + _managerCondition.wait(lock, [self] { + return self->_invalidated || !self->_installInProgress; + }); + if (_invalidated) { + return @false; + } + + runtimeSessionId = rnwgpu::RNWebGPUManager::sessionForRuntime(*runtime); + if (_ownedManager && _ownedManager->isActive() && + _ownedSessionId == runtimeSessionId) { + // This module already owns the runtime's lease. Do not acquire it again. + return @true; + } + + previousSessionId = + std::exchange(_ownedSessionId, rnwgpu::kInvalidRNWebGPUSessionId); + previousManager = std::move(_ownedManager); + _installInProgress = true; } - - std::shared_ptr platformContext = - std::make_shared(); - webgpuManager = std::make_shared(runtime, jsInvoker, - platformContext); - return @true; + InstallOperationGuard installGuard(_managerMutex, _managerCondition, + _installInProgress); + + try { + if (previousSessionId != rnwgpu::kInvalidRNWebGPUSessionId) { + registry.release(previousSessionId); + } + previousManager.reset(); + + if (runtimeSessionId != rnwgpu::kInvalidRNWebGPUSessionId) { + auto existing = registry.acquire(runtimeSessionId); + if (existing) { + bool attached = false; + { + std::lock_guard lock(_managerMutex); + if (!_invalidated) { + _ownedSessionId = existing.sessionId; + _ownedManager = existing.manager; + attached = true; + } + } + + if (!attached) { + // Releasing may tear down Dawn and surfaces, so it must remain + // outside _managerMutex. + registry.release(existing.sessionId); + } + return [NSNumber numberWithBool:attached]; + } + } + + // _callInvoker is injected by RCTTurboModuleManager because we conform to + // RCTCallInvokerModule. Works in both Legacy and Bridgeless. + std::shared_ptr jsInvoker = _callInvoker.callInvoker; + if (!jsInvoker) { + NSLog(@"Failed to install react-native-webgpu: react::CallInvoker was " + @"null!"); + return @false; + } + + const auto sessionId = registry.createSession(); + auto platformContext = std::make_shared(); + auto manager = std::make_shared( + sessionId, runtime, std::move(jsInvoker), std::move(platformContext)); + + { + std::lock_guard lock(_managerMutex); + if (_invalidated) { + return @false; + } + + // Keep the invalidation state check and publication in one critical + // section. invalidate either wins before this point (and we do not + // publish), or runs afterwards and releases this exact lease. + registry.publish(sessionId, manager); + _ownedSessionId = sessionId; + _ownedManager = std::move(manager); + } + return @true; + } catch (const std::exception &error) { + NSLog(@"Failed to install react-native-webgpu: %s", error.what()); + } catch (...) { + NSLog(@"Failed to install react-native-webgpu: unknown native error"); + } + return @false; } - (std::shared_ptr)getTurboModule: diff --git a/packages/webgpu/apple/WebGPUView.mm b/packages/webgpu/apple/WebGPUView.mm index 651030bf15..f884b06412 100644 --- a/packages/webgpu/apple/WebGPUView.mm +++ b/packages/webgpu/apple/WebGPUView.mm @@ -41,13 +41,16 @@ - (void)updateProps:(const Props::Shared &)props const auto &newViewProps = *std::static_pointer_cast(props); - if (newViewProps.contextId != oldViewProps.contextId) { + if (self.contentView == nil || + newViewProps.sessionId != oldViewProps.sessionId || + newViewProps.contextId != oldViewProps.contextId) { /* - The context is set only once during mounting the component - and never changes because it isn't available for users to modify. + The session and context are set during mounting and change only when a + recycled component is attached to a new JavaScript runtime or canvas. */ MetalView *metalView = [MetalView new]; self.contentView = metalView; + [metalView setSessionId:@(newViewProps.sessionId)]; [metalView setContextId:@(newViewProps.contextId)]; [metalView configure]; } @@ -58,7 +61,9 @@ - (void)updateProps:(const Props::Shared &)props - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics { [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; - [(MetalView *)self.contentView update]; + if (self.contentView != nil) { + [(MetalView *)self.contentView update]; + } } Class WebGPUViewCls(void) { return WebGPUView.class; } diff --git a/packages/webgpu/cpp/jsi/NativeObject.h b/packages/webgpu/cpp/jsi/NativeObject.h index d3e09ed5cf..2ac0ae66e0 100644 --- a/packages/webgpu/cpp/jsi/NativeObject.h +++ b/packages/webgpu/cpp/jsi/NativeObject.h @@ -13,8 +13,8 @@ #include #include #include +#include -#include "RuntimeAwareCache.h" #include "WGPULogger.h" // Forward declare to avoid circular dependency @@ -42,8 +42,10 @@ class NativeObjectRegistry { using InstallerFunc = std::function; static NativeObjectRegistry &getInstance() { - static NativeObjectRegistry instance; - return instance; + // Boxed HostObjects can be released during process-wide static teardown. + // Keep this process registry alive so they never observe a destroyed map. + static auto *instance = new NativeObjectRegistry(); + return *instance; } void registerInstaller(const std::string &brand, InstallerFunc installer) { @@ -52,13 +54,17 @@ class NativeObjectRegistry { } bool installPrototype(jsi::Runtime &runtime, const std::string &brand) { - std::lock_guard lock(_mutex); - auto it = _installers.find(brand); - if (it != _installers.end()) { - it->second(runtime); - return true; + InstallerFunc installer; + { + std::lock_guard lock(_mutex); + const auto it = _installers.find(brand); + if (it == _installers.end()) { + return false; + } + installer = it->second; } - return false; + installer(runtime); + return true; } private: @@ -77,35 +83,39 @@ struct PrototypeCacheEntry { }; /** - * Wrapper for static RuntimeAwareCache that handles hot reload. - * - * When used with static storage (like prototype caches), the cache persists - * across hot reloads. But the JSI objects inside become invalid when the - * runtime is destroyed. This wrapper tracks which runtime the cache was - * created for and allocates a new cache when the runtime changes. - * - * The old cache is intentionally leaked - we cannot safely destroy JSI - * objects after their runtime is gone. + * Owns every NativeObject prototype created for one runtime. Storing this in + * runtimeData makes the JSI objects die as part of that runtime's teardown; + * no process-global map, raw lifecycle listener, or intentional leak is needed. */ -template struct StaticRuntimeAwareCache { - RuntimeAwareCache *cache = nullptr; - jsi::Runtime *cacheRuntime = nullptr; - - RuntimeAwareCache &get(jsi::Runtime &rt) { - auto mainRuntime = BaseRuntimeAwareCache::getMainJsRuntime(); - if (&rt == mainRuntime && cacheRuntime != mainRuntime) { - // Main runtime changed (hot reload) - allocate new cache, leak old one - cache = new RuntimeAwareCache(); - cacheRuntime = mainRuntime; - } - if (cache == nullptr) { - cache = new RuntimeAwareCache(); - cacheRuntime = mainRuntime; - } - return *cache; - } +struct PrototypeRuntimeData final { + std::unordered_map> entries; }; +inline jsi::UUID prototypeRuntimeDataUUID() { + // Hermes reserves the all-zero and all-0xff UUID values in its DenseMap. + static constexpr jsi::UUID uuid(0xEE748E30, 0xEE3D, 0x4FB4, 0x9C82, + 0xF943B44D3465ULL); + return uuid; +} + +inline std::shared_ptr +getPrototypeCacheEntry(jsi::Runtime &runtime, const std::string &brand) { + std::shared_ptr runtimeData; + if (const auto existing = + runtime.getRuntimeData(prototypeRuntimeDataUUID())) { + runtimeData = std::static_pointer_cast(existing); + } else { + runtimeData = std::make_shared(); + runtime.setRuntimeData(prototypeRuntimeDataUUID(), runtimeData); + } + + const auto [it, inserted] = runtimeData->entries.try_emplace(brand, nullptr); + if (inserted || !it->second) { + it->second = std::make_shared(); + } + return it->second; +} + /** * BoxedWebGPUObject is a HostObject wrapper that holds a reference to ANY * WebGPU NativeObject. This is used for Reanimated/Worklets serialization. @@ -135,28 +145,27 @@ class BoxedWebGPUObject : public jsi::HostObject { if (propName == "unbox") { return jsi::Function::createFromHostFunction( runtime, jsi::PropNameID::forUtf8(runtime, "unbox"), 0, - [this](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, - const jsi::Value * /*args*/, - size_t /*count*/) -> jsi::Value { + [nativeState = _nativeState, brand = _brand]( + jsi::Runtime &rt, const jsi::Value & /*thisVal*/, + const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value { // Try to get the prototype from the global constructor - auto ctor = rt.global().getProperty(rt, _brand.c_str()); + auto ctor = rt.global().getProperty(rt, brand.c_str()); if (!ctor.isObject()) { // Constructor doesn't exist on this runtime - install it - NativeObjectRegistry::getInstance().installPrototype(rt, _brand); - ctor = rt.global().getProperty(rt, _brand.c_str()); + NativeObjectRegistry::getInstance().installPrototype(rt, brand); + ctor = rt.global().getProperty(rt, brand.c_str()); } // Create a new object and attach the native state jsi::Object obj(rt); - obj.setNativeState(rt, _nativeState); + obj.setNativeState(rt, nativeState); // Set the prototype if constructor exists if (ctor.isObject()) { auto ctorObj = ctor.getObject(rt); auto proto = ctorObj.getProperty(rt, "prototype"); if (proto.isObject()) { - auto objectCtor = - rt.global().getPropertyAsObject(rt, "Object"); + auto objectCtor = rt.global().getPropertyAsObject(rt, "Object"); auto setPrototypeOf = objectCtor.getPropertyAsFunction(rt, "setPrototypeOf"); setPrototypeOf.call(rt, obj, proto); @@ -232,14 +241,11 @@ class NativeObject : public jsi::NativeState, /** * Get the prototype cache for this type. - * Each NativeObject type has its own static cache. - * Uses StaticRuntimeAwareCache to properly handle runtime lifecycle - * and hot reload (where the main runtime is destroyed and recreated). + * Every entry is owned by the calling runtime's runtimeData. */ - static RuntimeAwareCache & + static std::shared_ptr getPrototypeCache(jsi::Runtime &runtime) { - static StaticRuntimeAwareCache cache; - return cache.get(runtime); + return getPrototypeCacheEntry(runtime, Derived::CLASS_NAME); } /** @@ -247,8 +253,8 @@ class NativeObject : public jsi::NativeState, * Called automatically by create(), but can be called manually. */ static void installPrototype(jsi::Runtime &runtime) { - auto &entry = getPrototypeCache(runtime).get(runtime); - if (entry.prototype.has_value()) { + auto entry = getPrototypeCache(runtime); + if (entry->prototype.has_value()) { return; // Already installed } @@ -264,8 +270,7 @@ class NativeObject : public jsi::NativeState, if (!toStringTag.isUndefined()) { // Use Object.defineProperty to set symbol property since setProperty // doesn't support symbols directly - auto objectCtor = - runtime.global().getPropertyAsObject(runtime, "Object"); + auto objectCtor = runtime.global().getPropertyAsObject(runtime, "Object"); auto defineProperty = objectCtor.getPropertyAsFunction(runtime, "defineProperty"); jsi::Object descriptor(runtime); @@ -279,7 +284,7 @@ class NativeObject : public jsi::NativeState, } // Cache the prototype - entry.prototype = std::move(prototype); + entry->prototype = std::move(prototype); } /** @@ -293,7 +298,8 @@ class NativeObject : public jsi::NativeState, * BoxedWebGPUObject::unbox() can install prototypes on secondary runtimes. */ static void installConstructor(jsi::Runtime &runtime) { - // Register this class's installer in the registry (only needs to happen once) + // Register this class's installer in the registry (only needs to happen + // once) static std::once_flag registryFlag; std::call_once(registryFlag, []() { NativeObjectRegistry::getInstance().registerInstaller( @@ -303,8 +309,8 @@ class NativeObject : public jsi::NativeState, installPrototype(runtime); - auto &entry = getPrototypeCache(runtime).get(runtime); - if (!entry.prototype.has_value()) { + auto entry = getPrototypeCache(runtime); + if (!entry->prototype.has_value()) { return; } @@ -313,17 +319,17 @@ class NativeObject : public jsi::NativeState, runtime, jsi::PropNameID::forUtf8(runtime, Derived::CLASS_NAME), 0, [](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value { - throw jsi::JSError( - rt, std::string("Illegal constructor: ") + Derived::CLASS_NAME + - " objects are created by the WebGPU API"); + throw jsi::JSError(rt, std::string("Illegal constructor: ") + + Derived::CLASS_NAME + + " objects are created by the WebGPU API"); }); // Set the prototype property on the constructor // This is what makes `instanceof` work - ctor.setProperty(runtime, "prototype", *entry.prototype); + ctor.setProperty(runtime, "prototype", *entry->prototype); // Set constructor property on prototype pointing back to constructor - entry.prototype->setProperty(runtime, "constructor", ctor); + entry->prototype->setProperty(runtime, "constructor", ctor); // Install on global runtime.global().setProperty(runtime, Derived::CLASS_NAME, std::move(ctor)); @@ -336,9 +342,6 @@ class NativeObject : public jsi::NativeState, std::shared_ptr instance) { installPrototype(runtime); - // Store creation runtime for logging etc. - instance->setCreationRuntime(&runtime); - // Create a new object jsi::Object obj(runtime); @@ -346,14 +349,13 @@ class NativeObject : public jsi::NativeState, obj.setNativeState(runtime, instance); // Set prototype - auto &entry = getPrototypeCache(runtime).get(runtime); - if (entry.prototype.has_value()) { + auto entry = getPrototypeCache(runtime); + if (entry->prototype.has_value()) { // Use Object.setPrototypeOf to set the prototype - auto objectCtor = - runtime.global().getPropertyAsObject(runtime, "Object"); + auto objectCtor = runtime.global().getPropertyAsObject(runtime, "Object"); auto setPrototypeOf = objectCtor.getPropertyAsFunction(runtime, "setPrototypeOf"); - setPrototypeOf.call(runtime, obj, *entry.prototype); + setPrototypeOf.call(runtime, obj, *entry->prototype); } // Set memory pressure hint for GC @@ -390,17 +392,6 @@ class NativeObject : public jsi::NativeState, */ virtual size_t getMemoryPressure() { return 1024; } - /** - * Set the creation runtime. Called during create(). - */ - void setCreationRuntime(jsi::Runtime *runtime) { _creationRuntime = runtime; } - - /** - * Get the creation runtime. - * WARNING: This pointer may become invalid if the runtime is destroyed. - */ - jsi::Runtime *getCreationRuntime() const { return _creationRuntime; } - protected: explicit NativeObject(const char *name) : _name(name) { #if DEBUG && RNF_ENABLE_LOGS @@ -415,7 +406,6 @@ class NativeObject : public jsi::NativeState, } const char *_name; - jsi::Runtime *_creationRuntime = nullptr; // ============================================================ // Helper methods for definePrototype() implementations @@ -445,11 +435,9 @@ class NativeObject : public jsi::NativeState, * (e.g. GPU::requestAdapter, which creates a per-runtime RuntimeContext). */ template - static void - installMethodWithRuntime(jsi::Runtime &runtime, jsi::Object &prototype, - const char *name, - ReturnType (Derived::*method)(jsi::Runtime &, - Args...)) { + static void installMethodWithRuntime( + jsi::Runtime &runtime, jsi::Object &prototype, const char *name, + ReturnType (Derived::*method)(jsi::Runtime &, Args...)) { auto func = jsi::Function::createFromHostFunction( runtime, jsi::PropNameID::forUtf8(runtime, name), sizeof...(Args), [method](jsi::Runtime &rt, const jsi::Value &thisVal, @@ -499,6 +487,41 @@ class NativeObject : public jsi::NativeState, jsi::String::createFromUtf8(runtime, name), descriptor); } + /** Install a getter whose native implementation needs the calling runtime. */ + template + static void + installGetterWithRuntime(jsi::Runtime &runtime, jsi::Object &prototype, + const char *name, + ReturnType (Derived::*getter)(jsi::Runtime &)) { + auto getterFunc = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forUtf8(runtime, std::string("get_") + name), + 0, + [getter](jsi::Runtime &rt, const jsi::Value &thisVal, + const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value { + auto native = Derived::fromValue(rt, thisVal); + if constexpr (std::is_same_v) { + (native.get()->*getter)(rt); + return jsi::Value::undefined(); + } else { + ReturnType result = (native.get()->*getter)(rt); + return rnwgpu::JSIConverter>::toJSI( + rt, std::move(result)); + } + }); + + auto objectCtor = runtime.global().getPropertyAsObject(runtime, "Object"); + auto defineProperty = + objectCtor.getPropertyAsFunction(runtime, "defineProperty"); + + jsi::Object descriptor(runtime); + descriptor.setProperty(runtime, "get", getterFunc); + descriptor.setProperty(runtime, "enumerable", true); + descriptor.setProperty(runtime, "configurable", true); + + defineProperty.call(runtime, prototype, + jsi::String::createFromUtf8(runtime, name), descriptor); + } + /** * Install a setter on the prototype. */ @@ -515,8 +538,8 @@ class NativeObject : public jsi::NativeState, throw jsi::JSError(rt, "Setter requires a value argument"); } auto native = Derived::fromValue(rt, thisVal); - auto value = - rnwgpu::JSIConverter>::fromJSI(rt, args[0], false); + auto value = rnwgpu::JSIConverter>::fromJSI( + rt, args[0], false); (native.get()->*setter)(std::move(value)); return jsi::Value::undefined(); }); @@ -536,8 +559,8 @@ class NativeObject : public jsi::NativeState, if (existingDesc.isObject()) { auto existingDescObj = existingDesc.getObject(runtime); if (existingDescObj.hasProperty(runtime, "get")) { - descriptor.setProperty( - runtime, "get", existingDescObj.getProperty(runtime, "get")); + descriptor.setProperty(runtime, "get", + existingDescObj.getProperty(runtime, "get")); } } descriptor.setProperty(runtime, "set", setterFunc); @@ -563,8 +586,8 @@ class NativeObject : public jsi::NativeState, const jsi::Value *args, size_t count) -> jsi::Value { auto native = Derived::fromValue(rt, thisVal); ReturnType result = (native.get()->*getter)(); - return rnwgpu::JSIConverter>::toJSI(rt, - std::move(result)); + return rnwgpu::JSIConverter>::toJSI( + rt, std::move(result)); }); auto setterFunc = jsi::Function::createFromHostFunction( @@ -576,8 +599,8 @@ class NativeObject : public jsi::NativeState, throw jsi::JSError(rt, "Setter requires a value argument"); } auto native = Derived::fromValue(rt, thisVal); - auto value = - rnwgpu::JSIConverter>::fromJSI(rt, args[0], false); + auto value = rnwgpu::JSIConverter>::fromJSI( + rt, args[0], false); (native.get()->*setter)(std::move(value)); return jsi::Value::undefined(); }); @@ -628,10 +651,11 @@ class NativeObject : public jsi::NativeState, // This requires the method signature to match HostFunction return (obj->*method)(runtime, jsi::Value::undefined(), args, count); } else { - ReturnType result = (obj->*method)(rnwgpu::JSIConverter>::fromJSI( - runtime, args[Is], Is >= count)...); - return rnwgpu::JSIConverter>::toJSI(runtime, - std::move(result)); + ReturnType result = + (obj->*method)(rnwgpu::JSIConverter>::fromJSI( + runtime, args[Is], Is >= count)...); + return rnwgpu::JSIConverter>::toJSI( + runtime, std::move(result)); } } }; diff --git a/packages/webgpu/cpp/jsi/Promise.cpp b/packages/webgpu/cpp/jsi/Promise.cpp index 5d11e58b52..af63c6a0ee 100644 --- a/packages/webgpu/cpp/jsi/Promise.cpp +++ b/packages/webgpu/cpp/jsi/Promise.cpp @@ -1,44 +1,304 @@ #include "Promise.h" -#include -#include + +#include #include +#include #include +#include #include -#include namespace rnwgpu { -namespace jsi = facebook::jsi; +namespace { -Promise::Promise(jsi::Runtime& runtime, jsi::Function&& resolver, jsi::Function&& rejecter) - : runtime(runtime), _resolver(std::move(resolver)), _rejecter(std::move(rejecter)) {} +struct PromiseRuntimeData final { + explicit PromiseRuntimeData(std::shared_ptr context) + : context(std::move(context)) {} -jsi::Value Promise::createPromise(jsi::Runtime& runtime, RunPromise run) { - // Get Promise ctor from global - auto promiseCtor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + ~PromiseRuntimeData() { + if (context) { + context->invalidate(); + } + } - auto promiseCallback = jsi::Function::createFromHostFunction( - runtime, jsi::PropNameID::forUtf8(runtime, "PromiseCallback"), 2, - [=](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value { - // Call function - auto resolver = arguments[0].asObject(runtime).asFunction(runtime); - auto rejecter = arguments[1].asObject(runtime).asFunction(runtime); - auto promise = std::make_shared(runtime, std::move(resolver), std::move(rejecter)); - run(runtime, promise); + std::shared_ptr context; +}; - return jsi::Value::undefined(); - }); +} // namespace + +PromiseRuntimeContext::PromiseRuntimeContext(jsi::Runtime &runtime) noexcept + : _runtime(&runtime) {} + +PromiseRuntimeContext::~PromiseRuntimeContext() { invalidate(); } + +std::shared_ptr +PromiseRuntimeContext::getOrCreate(jsi::Runtime &runtime) { + if (const auto data = runtime.getRuntimeData(runtimeDataUUID())) { + const auto runtimeData = std::static_pointer_cast(data); + if (runtimeData->context && + runtimeData->context->runtimeIdentity() == &runtime) { + return runtimeData->context; + } + } + + auto context = std::make_shared(runtime); + runtime.setRuntimeData(runtimeDataUUID(), + std::make_shared(context)); + return context; +} + +void PromiseRuntimeContext::invalidate() noexcept { + std::unordered_map> promises; + { + std::lock_guard lock(_mutex); + if (_runtime == nullptr) { + return; + } + + // Make the runtime unavailable atomically, then destroy JSI functions + // without holding the context mutex. invalidate() runs on the owning + // runtime thread via runtimeData teardown/replacement. + promises.swap(_promises); + _runtime = nullptr; + } + + for (auto &[address, promise] : promises) { + (void)address; + if (promise) { + promise->invalidate(); + } + } +} + +bool PromiseRuntimeContext::isValid() const noexcept { + std::lock_guard lock(_mutex); + return _runtime != nullptr; +} + +const void *PromiseRuntimeContext::runtimeIdentity() const noexcept { + std::lock_guard lock(_mutex); + return _runtime; +} + +bool PromiseRuntimeContext::withRuntime( + const std::function &action) { + if (!action) { + return false; + } + + jsi::Runtime *runtime = nullptr; + { + std::lock_guard lock(_mutex); + if (_runtime == nullptr) { + return false; + } + runtime = _runtime; + } + + // withRuntime is called only from this runtime's owning thread. Do not call + // Promise constructors/resolvers or user callbacks under _mutex (CP.22). + action(*runtime); + return true; +} + +void PromiseRuntimeContext::trackPromise( + const std::shared_ptr &promise) { + if (!promise) { + return; + } + + bool shouldInvalidate = false; + { + std::lock_guard lock(_mutex); + if (_runtime == nullptr) { + shouldInvalidate = true; + } else { + _promises.insert_or_assign(promise.get(), promise); + } + } + if (shouldInvalidate) { + promise->invalidate(); + } +} - return promiseCtor.callAsConstructor(runtime, promiseCallback); +void PromiseRuntimeContext::untrackPromise(const Promise *promise) noexcept { + if (promise == nullptr) { + return; + } + + std::lock_guard lock(_mutex); + _promises.erase(promise); +} + +jsi::UUID PromiseRuntimeContext::runtimeDataUUID() { + // Hermes reserves the all-zero and all-0xff UUID values in its DenseMap. + static constexpr jsi::UUID uuid(0x4CA6E8C7, 0x79CB, 0x4C28, 0xAF31, + 0x8D59B641A92EULL); + return uuid; } -void Promise::resolve(jsi::Value&& result) { - _resolver.call(runtime, std::move(result)); +Promise::Promise(std::weak_ptr context, + jsi::Function &&resolver, jsi::Function &&rejecter) + : _context(std::move(context)), _resolver(std::move(resolver)), + _rejecter(std::move(rejecter)) {} + +jsi::Value Promise::createPromise(jsi::Runtime &runtime, RunPromise run) { + auto context = PromiseRuntimeContext::getOrCreate(runtime); + if (!context || !context->isValid()) { + throw std::runtime_error( + "Cannot create a Promise after its runtime was invalidated"); + } + + std::optional result; + const bool created = context->withRuntime([&](jsi::Runtime &owningRuntime) { + auto promiseConstructor = + owningRuntime.global().getPropertyAsFunction(owningRuntime, "Promise"); + auto executor = jsi::Function::createFromHostFunction( + owningRuntime, + jsi::PropNameID::forUtf8(owningRuntime, "PromiseCallback"), 2, + [context, run = std::move(run)]( + jsi::Runtime &callbackRuntime, const jsi::Value & /*thisValue*/, + const jsi::Value *arguments, size_t count) mutable -> jsi::Value { + if (count < 2 || arguments == nullptr || !arguments[0].isObject() || + !arguments[0] + .getObject(callbackRuntime) + .isFunction(callbackRuntime) || + !arguments[1].isObject() || + !arguments[1] + .getObject(callbackRuntime) + .isFunction(callbackRuntime)) { + throw jsi::JSError( + callbackRuntime, + "Promise executor did not receive resolve/reject functions"); + } + + auto resolver = arguments[0] + .getObject(callbackRuntime) + .getFunction(callbackRuntime); + auto rejecter = arguments[1] + .getObject(callbackRuntime) + .getFunction(callbackRuntime); + auto promise = std::make_shared(context, std::move(resolver), + std::move(rejecter)); + context->trackPromise(promise); + try { + run(callbackRuntime, promise); + } catch (const std::exception &exception) { + try { + promise->reject(exception.what()); + } catch (...) { + // The JavaScript Promise constructor will reject a throwing + // executor. Drop our resolver copies first so a failed native + // rejection cannot leave this Promise tracked indefinitely. + promise->invalidate(); + throw; + } + } catch (...) { + try { + promise->reject("Unknown native error while starting Promise"); + } catch (...) { + promise->invalidate(); + throw; + } + } + return jsi::Value::undefined(); + }); + + result.emplace( + promiseConstructor.callAsConstructor(owningRuntime, executor)); + }); + + if (!created || !result.has_value()) { + throw std::runtime_error( + "Promise runtime was invalidated while creating a Promise"); + } + return std::move(*result); +} + +void Promise::resolve(jsi::Value &&result) { + auto keepAlive = shared_from_this(); + auto context = _context.lock(); + if (!context) { + return; + } + + // jsi::Value is move-only, while withRuntime intentionally takes a + // copyable std::function. The shared holder keeps the closure copyable and + // still moves the value exactly once into the JavaScript resolver. + auto resultHolder = std::make_shared(std::move(result)); + + context->withRuntime( + [this, keepAlive, context, resultHolder](jsi::Runtime &runtime) mutable { + std::optional resolver; + { + std::lock_guard lock(_mutex); + if (_settled || !_resolver.has_value()) { + return; + } + _settled = true; + resolver.emplace(std::move(*_resolver)); + _resolver.reset(); + _rejecter.reset(); + } + + context->untrackPromise(this); + resolver->call(runtime, std::move(*resultHolder)); + }); } void Promise::reject(std::string message) { - jsi::JSError error(runtime, message); - _rejecter.call(runtime, error.value()); + auto keepAlive = shared_from_this(); + auto context = _context.lock(); + if (!context) { + return; + } + + context->withRuntime([this, keepAlive, context, message = std::move(message)]( + jsi::Runtime &runtime) mutable { + std::optional rejecter; + { + std::lock_guard lock(_mutex); + if (_settled || !_rejecter.has_value()) { + return; + } + _settled = true; + rejecter.emplace(std::move(*_rejecter)); + _resolver.reset(); + _rejecter.reset(); + } + + context->untrackPromise(this); + jsi::JSError error(runtime, message); + rejecter->call(runtime, error.value()); + }); +} + +void Promise::invalidate() noexcept { + try { + { + std::lock_guard lock(_mutex); + _settled = true; + _resolver.reset(); + _rejecter.reset(); + } + if (const auto context = _context.lock()) { + context->untrackPromise(this); + } + } catch (...) { + // Never propagate an exception through a runtimeData destructor. + } +} + +bool Promise::withRuntime( + const std::function &action) const { + const auto context = _context.lock(); + return context && context->withRuntime(action); +} + +bool Promise::belongsToRuntime(const void *runtimeIdentity) const noexcept { + const auto context = _context.lock(); + return context && runtimeIdentity != nullptr && + context->runtimeIdentity() == runtimeIdentity; } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/jsi/Promise.h b/packages/webgpu/cpp/jsi/Promise.h index b6b8d111a0..aa5a4b9542 100644 --- a/packages/webgpu/cpp/jsi/Promise.h +++ b/packages/webgpu/cpp/jsi/Promise.h @@ -1,35 +1,110 @@ #pragma once +#include +#include #include -#include -#include -#include #include +#include +#include +#include +#include +#include namespace rnwgpu { namespace jsi = facebook::jsi; -class Promise { +class Promise; + +/** + * Owns the JSI-backed state for Promises created in one runtime. + * + * The context itself is stored in that runtime's runtimeData. Its destructor + * therefore runs while the runtime can still safely release jsi::Function + * values. Native callbacks may keep Promise objects alive for longer, but + * invalidate() removes every runtime-backed value before the runtime goes away. + */ +class PromiseRuntimeContext final + : public std::enable_shared_from_this { public: - Promise(jsi::Runtime& runtime, jsi::Function&& resolver, jsi::Function&& rejecter); + explicit PromiseRuntimeContext(jsi::Runtime &runtime) noexcept; + ~PromiseRuntimeContext(); - void resolve(jsi::Value&& result); - void reject(std::string error); + PromiseRuntimeContext(const PromiseRuntimeContext &) = delete; + PromiseRuntimeContext &operator=(const PromiseRuntimeContext &) = delete; + PromiseRuntimeContext(PromiseRuntimeContext &&) = delete; + PromiseRuntimeContext &operator=(PromiseRuntimeContext &&) = delete; -public: - jsi::Runtime& runtime; + static std::shared_ptr + getOrCreate(jsi::Runtime &runtime); -private: - jsi::Function _resolver; - jsi::Function _rejecter; + void invalidate() noexcept; + bool isValid() const noexcept; + const void *runtimeIdentity() const noexcept; -public: - using RunPromise = std::function promise)>; /** - Create a new Promise and runs the given `run` function. + * Run an action only while this context still owns a live runtime. + * Callers must already be executing on that runtime's thread. */ - static jsi::Value createPromise(jsi::Runtime& runtime, RunPromise run); + bool withRuntime(const std::function &action); + + void trackPromise(const std::shared_ptr &promise); + void untrackPromise(const Promise *promise) noexcept; + +private: + static jsi::UUID runtimeDataUUID(); + + mutable std::recursive_mutex _mutex; + jsi::Runtime *_runtime; + std::unordered_map> _promises; +}; + +class Promise final : public std::enable_shared_from_this { +public: + using RunPromise = std::function promise)>; + + Promise(std::weak_ptr context, + jsi::Function &&resolver, jsi::Function &&rejecter); + + Promise(const Promise &) = delete; + Promise &operator=(const Promise &) = delete; + Promise(Promise &&) = delete; + Promise &operator=(Promise &&) = delete; + + void resolve(jsi::Value &&result); + void reject(std::string error); + + /** Release resolver/rejecter while their runtime is still alive. */ + void invalidate() noexcept; + + bool withRuntime(const std::function &action) const; + bool belongsToRuntime(const void *runtimeIdentity) const noexcept; + + template + void resolveWith(ValueFactory &&valueFactory) { + auto factory = std::forward(valueFactory); + withRuntime( + [this, factory = std::move(factory)](jsi::Runtime &runtime) mutable { + try { + resolve(jsi::Value(factory(runtime))); + } catch (const std::exception &exception) { + reject(exception.what()); + } catch (...) { + reject("Unknown native error while resolving Promise"); + } + }); + } + + /** Create a Promise owned by the calling runtime's runtimeData. */ + static jsi::Value createPromise(jsi::Runtime &runtime, RunPromise run); + +private: + std::weak_ptr _context; + mutable std::mutex _mutex; + std::optional _resolver; + std::optional _rejecter; + bool _settled{false}; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/jsi/RuntimeAwareCache.cpp b/packages/webgpu/cpp/jsi/RuntimeAwareCache.cpp deleted file mode 100644 index ea77f96b02..0000000000 --- a/packages/webgpu/cpp/jsi/RuntimeAwareCache.cpp +++ /dev/null @@ -1,7 +0,0 @@ -#include "RuntimeAwareCache.h" - -namespace rnwgpu { - -jsi::Runtime *BaseRuntimeAwareCache::_mainRuntime = nullptr; - -} // namespace rnwgpu diff --git a/packages/webgpu/cpp/jsi/RuntimeAwareCache.h b/packages/webgpu/cpp/jsi/RuntimeAwareCache.h deleted file mode 100644 index ff5ec539e2..0000000000 --- a/packages/webgpu/cpp/jsi/RuntimeAwareCache.h +++ /dev/null @@ -1,100 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include - -#include "RuntimeLifecycleMonitor.h" - -namespace rnwgpu { - -namespace jsi = facebook::jsi; - -class BaseRuntimeAwareCache { -public: - static void setMainJsRuntime(jsi::Runtime *rt) { _mainRuntime = rt; } - static jsi::Runtime *getMainJsRuntime() { - assert(_mainRuntime != nullptr && - "Expected main Javascript runtime to be set in the " - "BaseRuntimeAwareCache class."); - - return _mainRuntime; - } - -private: - static jsi::Runtime *_mainRuntime; -}; - -/** - * Provides a way to keep data specific to a jsi::Runtime instance that gets - * cleaned up when that runtime is destroyed. This is necessary because JSI does - * not allow for its associated objects to be retained past the runtime - * lifetime. If an object (e.g. jsi::Values or jsi::Function instances) is kept - * after the runtime is torn down, its destructor (once it is destroyed - * eventually) will result in a crash (JSI objects keep a pointer to memory - * managed by the runtime, accessing that portion of the memory after runtime is - * deleted is the root cause of that crash). - * - * In order to provide an efficient implementation that does not add an overhead - * for the cases when only a single runtime is used, which is the primary - * usecase, the following assumption has been made: Only for secondary runtimes - * we track destruction and clean up the store associated with that runtime. For - * the first runtime we assume that the object holding the store is destroyed - * prior to the destruction of that runtime. - * - * The above assumption makes it work without any overhead when only single - * runtime is in use. Specifically, we don't perform any additional operations - * related to tracking runtime lifecycle when only a single runtime is used. - */ -template -class RuntimeAwareCache : public BaseRuntimeAwareCache, - public RuntimeLifecycleListener { - -public: - void onRuntimeDestroyed(jsi::Runtime *rt) override { - if (getMainJsRuntime() != rt) { - // We are removing a secondary runtime - _secondaryRuntimeCaches.erase(rt); - } - } - - ~RuntimeAwareCache() { - for (auto &cache : _secondaryRuntimeCaches) { - RuntimeLifecycleMonitor::removeListener( - *static_cast(cache.first), this); - } - } - - T &get(jsi::Runtime &rt) { - // We check if we're accessing the main runtime - this is the happy path - // to avoid us having to lookup by runtime for caches that only has a single - // runtime - if (getMainJsRuntime() == &rt) { - return _primaryCache; - } else { - if (_secondaryRuntimeCaches.count(&rt) == 0) { - // we only add listener when the secondary runtime is used, this assumes - // that the secondary runtime is terminated first. This lets us avoid - // additional complexity for the majority of cases when objects are not - // shared between runtimes. Otherwise we'd have to register all objecrts - // with the RuntimeMonitor as opposed to only registering ones that are - // used in secondary runtime. Note that we can't register listener here - // with the primary runtime as it may run on a separate thread. - RuntimeLifecycleMonitor::addListener(rt, this); - - T cache; - _secondaryRuntimeCaches.emplace(&rt, std::move(cache)); - } - } - return _secondaryRuntimeCaches.at(&rt); - } - -private: - std::unordered_map _secondaryRuntimeCaches; - T _primaryCache; -}; - -} // namespace rnwgpu diff --git a/packages/webgpu/cpp/jsi/RuntimeLifecycleMonitor.cpp b/packages/webgpu/cpp/jsi/RuntimeLifecycleMonitor.cpp deleted file mode 100644 index 25a4db10a0..0000000000 --- a/packages/webgpu/cpp/jsi/RuntimeLifecycleMonitor.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include "RuntimeLifecycleMonitor.h" - -#include -#include -#include -#include - -namespace rnwgpu { - -static std::unordered_map> - listeners; -static std::mutex listenersMutex; - -struct RuntimeLifecycleMonitorObject : public jsi::HostObject { - jsi::Runtime *_rt; - explicit RuntimeLifecycleMonitorObject(jsi::Runtime *rt) : _rt(rt) {} - ~RuntimeLifecycleMonitorObject() { - std::unordered_set listenersCopy; - { - std::lock_guard lock(listenersMutex); - auto listenersSet = listeners.find(_rt); - if (listenersSet != listeners.end()) { - listenersCopy = listenersSet->second; - listeners.erase(listenersSet); - } - } - for (auto listener : listenersCopy) { - listener->onRuntimeDestroyed(_rt); - } - } -}; - -void RuntimeLifecycleMonitor::addListener(jsi::Runtime &rt, - RuntimeLifecycleListener *listener) { - bool shouldInstallMonitor = false; - { - std::lock_guard lock(listenersMutex); - auto listenersSet = listeners.find(&rt); - if (listenersSet == listeners.end()) { - std::unordered_set newSet; - newSet.insert(listener); - listeners.emplace(&rt, std::move(newSet)); - shouldInstallMonitor = true; - } else { - listenersSet->second.insert(listener); - } - } - if (shouldInstallMonitor) { - // We install a global host object in the provided runtime, this way we can - // use that host object destructor to get notified when the runtime is being - // terminated. We use a unique name for the object as it gets saved with the - // runtime's global object. - rt.global().setProperty( - rt, "__rnwgpu_rt_lifecycle_monitor", - jsi::Object::createFromHostObject( - rt, std::make_shared(&rt))); - } -} - -void RuntimeLifecycleMonitor::removeListener( - jsi::Runtime &rt, RuntimeLifecycleListener *listener) { - std::lock_guard lock(listenersMutex); - auto listenersSet = listeners.find(&rt); - if (listenersSet == listeners.end()) { - // nothing to do here - } else { - listenersSet->second.erase(listener); - } -} - -} // namespace rnwgpu diff --git a/packages/webgpu/cpp/jsi/RuntimeLifecycleMonitor.h b/packages/webgpu/cpp/jsi/RuntimeLifecycleMonitor.h deleted file mode 100644 index 7e45b22089..0000000000 --- a/packages/webgpu/cpp/jsi/RuntimeLifecycleMonitor.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include - -#include - -namespace rnwgpu { - -namespace jsi = facebook::jsi; - -/** - * Listener interface that allows for getting notified when a jsi::Runtime - * instance is destroyed. - */ -struct RuntimeLifecycleListener { - virtual ~RuntimeLifecycleListener() {} - virtual void onRuntimeDestroyed(jsi::Runtime *) = 0; -}; - -/** - * This class provides an API via static methods for registering and - * unregistering runtime lifecycle listeners. The listeners can be used to - * cleanup any data that references a given jsi::Runtime instance before it gets - * destroyed. - */ -struct RuntimeLifecycleMonitor { - static void addListener(jsi::Runtime &rt, RuntimeLifecycleListener *listener); - static void removeListener(jsi::Runtime &rt, - RuntimeLifecycleListener *listener); -}; - -} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index 871e311966..28b91272a5 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -3,6 +3,7 @@ #include "GPU.h" #include "NativeObject.h" #include "RNWebGPU.h" +#include "SurfaceRegistry.h" // GPU API classes (for instanceof support) #include "GPUAdapter.h" @@ -50,29 +51,73 @@ #include "GPUTextureUsage.h" #include +#include +#include #include namespace rnwgpu { +namespace { + +struct RuntimeSessionData { + RNWebGPUSessionId sessionId{kInvalidRNWebGPUSessionId}; +}; + +class SessionInstallationGuard final { +public: + SessionInstallationGuard( + RNWebGPUSessionId sessionId, + std::shared_ptr sessionState) noexcept + : _sessionId(sessionId), _sessionState(std::move(sessionState)) {} + + ~SessionInstallationGuard() { + if (_committed) { + return; + } + if (_sessionState) { + _sessionState->invalidate(); + } + async::RuntimeContext::unregisterMainRuntime(_sessionId); + } + + SessionInstallationGuard(const SessionInstallationGuard &) = delete; + SessionInstallationGuard & + operator=(const SessionInstallationGuard &) = delete; + + void commit() noexcept { _committed = true; } + +private: + RNWebGPUSessionId _sessionId; + std::shared_ptr _sessionState; + bool _committed{false}; +}; + +jsi::UUID runtimeSessionDataUUID() { + static constexpr jsi::UUID uuid(0x0C4D67A3, 0x944A, 0x4ECA, 0xB788, + 0xB86CC728D9A1ULL); + return uuid; +} + +} // namespace + RNWebGPUManager::RNWebGPUManager( - jsi::Runtime *jsRuntime, + RNWebGPUSessionId sessionId, jsi::Runtime *jsRuntime, std::shared_ptr jsCallInvoker, std::shared_ptr platformContext) : _jsRuntime(jsRuntime), _jsCallInvoker(jsCallInvoker), + _sessionState(std::make_shared(sessionId)), _platformContext(platformContext) { + if (sessionId == kInvalidRNWebGPUSessionId || _jsRuntime == nullptr || + !_jsCallInvoker || !_platformContext) { + throw std::invalid_argument( + "RNWebGPUManager requires a valid session, runtime, CallInvoker, and " + "PlatformContext"); + } + SessionInstallationGuard installationGuard(sessionId, _sessionState); - // Register main runtime for RuntimeAwareCache - BaseRuntimeAwareCache::setMainJsRuntime(_jsRuntime); - - // Register the main runtime + its CallInvoker so spontaneous events - // (device.lost / uncapturederror) on main-runtime devices can be delivered to - // the JS thread without the ProcessEvents pump. Worklet-runtime devices have - // no invoker (best-effort; see README "Threading model"). - async::RuntimeContext::registerMainRuntime(_jsRuntime, _jsCallInvoker); - - auto gpu = std::make_shared(*_jsRuntime); - auto rnWebGPU = - std::make_shared(gpu, _platformContext, _jsCallInvoker); + auto gpu = std::make_shared(_sessionState); + auto rnWebGPU = std::make_shared( + gpu, _platformContext, _jsCallInvoker, _sessionState, *_jsRuntime); _gpu = gpu->get(); // RNWebGPU needs its brand registered in NativeObjectRegistry so the boxing @@ -137,6 +182,24 @@ RNWebGPUManager::RNWebGPUManager( // Install global helper functions for Worklets serialization // These are standalone functions that don't require RNWebGPU instance installWebGPUWorkletHelpers(*_jsRuntime); + + auto runtimeSessionData = std::make_shared(); + runtimeSessionData->sessionId = sessionId; + _jsRuntime->setRuntimeData(runtimeSessionDataUUID(), runtimeSessionData); + + // Register only after every JSI operation has succeeded. A throwing + // constructor must not leave a process-global pointer to a partial session. + async::RuntimeContext::registerMainRuntime(sessionId, _jsRuntime, + _jsCallInvoker); + installationGuard.commit(); +} + +RNWebGPUSessionId RNWebGPUManager::sessionForRuntime(jsi::Runtime &runtime) { + const auto data = runtime.getRuntimeData(runtimeSessionDataUUID()); + if (!data) { + return kInvalidRNWebGPUSessionId; + } + return std::static_pointer_cast(data)->sessionId; } void RNWebGPUManager::installWebGPUWorkletHelpers(jsi::Runtime &runtime) { @@ -237,9 +300,16 @@ void RNWebGPUManager::installWebGPUWorkletHelpers(jsi::Runtime &runtime) { runtime.global().setProperty(runtime, "__webgpuBox", std::move(boxFunc)); } +void RNWebGPUManager::invalidate() noexcept { + if (_sessionState) { + _sessionState->invalidate(); + async::RuntimeContext::unregisterMainRuntime(_sessionState->id()); + } +} + void RNWebGPUManager::flushPendingSurfaceTransition( std::shared_ptr info) { - if (info == nullptr || _jsCallInvoker == nullptr) { + if (info == nullptr || _jsCallInvoker == nullptr || !isActive()) { return; } _jsCallInvoker->invokeAsync( @@ -247,12 +317,139 @@ void RNWebGPUManager::flushPendingSurfaceTransition( } RNWebGPUManager::~RNWebGPUManager() { - // Drop all canvas registry entries: after a reload the JS side restarts its - // contextId counter, and stale entries would alias new canvases onto dead - // surfaces. - SurfaceRegistry::getInstance().clear(); + invalidate(); _jsRuntime = nullptr; - _jsCallInvoker = nullptr; + _jsCallInvoker.reset(); +} + +RNWebGPUManagerRegistry &RNWebGPUManagerRegistry::getInstance() { + // Native views may be released during process-wide static teardown. Keep the + // registry alive until process exit so late view destructors never observe a + // destroyed mutex. + static auto *registry = new RNWebGPUManagerRegistry(); + return *registry; +} + +RNWebGPUSessionId RNWebGPUManagerRegistry::createSession() { + std::lock_guard lock(_mutex); + for (;;) { + const auto candidate = _nextSessionId++; + if (_nextSessionId > kMaxRNWebGPUSessionId) { + _nextSessionId = 1; + } + if (candidate != kInvalidRNWebGPUSessionId && + _entries.find(candidate) == _entries.end()) { + return candidate; + } + } +} + +void RNWebGPUManagerRegistry::publish( + RNWebGPUSessionId sessionId, std::shared_ptr manager) { + if (sessionId == kInvalidRNWebGPUSessionId || !manager || + manager->sessionId() != sessionId) { + throw std::invalid_argument("Cannot publish an invalid WebGPU session"); + } + + std::unique_lock lock(_mutex); + if (_entries.find(sessionId) != _entries.end()) { + throw std::logic_error("WebGPU session was already published"); + } + + const auto supersededSessionId = _activeSessionId; + std::shared_ptr supersededManager; + if (supersededSessionId != kInvalidRNWebGPUSessionId && + supersededSessionId != sessionId) { + const auto superseded = _entries.find(supersededSessionId); + if (superseded != _entries.end()) { + supersededManager = superseded->second.manager; + } + } + + SurfaceRegistry::getInstance().openSession(sessionId); + try { + _entries.emplace(sessionId, Entry{std::move(manager), 1}); + // Publication is the generation boundary. Invalidate the old token before + // exposing the new active id so a long-lived worklet cannot use a boxed GPU + // from the previous runtime to replace the new RuntimeContext. + if (supersededManager) { + supersededManager->invalidate(); + } + _activeSessionId = sessionId; + } catch (...) { + lock.unlock(); + SurfaceRegistry::getInstance().closeSession(sessionId); + throw; + } + lock.unlock(); + + if (supersededSessionId != kInvalidRNWebGPUSessionId && + supersededSessionId != sessionId) { + // The old lease remains in _entries until its module owners release it, + // but its native surfaces become terminal at the generation boundary. + SurfaceRegistry::getInstance().closeSession(supersededSessionId); + } +} + +RNWebGPUManagerSnapshot +RNWebGPUManagerRegistry::acquire(RNWebGPUSessionId sessionId) { + std::lock_guard lock(_mutex); + const auto it = _entries.find(sessionId); + if (it == _entries.end() || !it->second.manager || + !it->second.manager->isActive()) { + return {}; + } + ++it->second.owners; + return {sessionId, it->second.manager}; +} + +RNWebGPUManagerSnapshot RNWebGPUManagerRegistry::getActive() const { + std::lock_guard lock(_mutex); + const auto it = _entries.find(_activeSessionId); + if (it == _entries.end() || !it->second.manager || + !it->second.manager->isActive()) { + return {}; + } + return {_activeSessionId, it->second.manager}; +} + +RNWebGPUManagerSnapshot +RNWebGPUManagerRegistry::get(RNWebGPUSessionId sessionId) const { + std::lock_guard lock(_mutex); + const auto it = _entries.find(sessionId); + if (it == _entries.end() || !it->second.manager || + !it->second.manager->isActive()) { + return {}; + } + return {sessionId, it->second.manager}; +} + +std::shared_ptr +RNWebGPUManagerRegistry::release(RNWebGPUSessionId sessionId) { + std::shared_ptr releasedManager; + { + std::lock_guard lock(_mutex); + const auto it = _entries.find(sessionId); + if (it == _entries.end()) { + return nullptr; + } + if (it->second.owners > 1) { + --it->second.owners; + return nullptr; + } + + releasedManager = std::move(it->second.manager); + _entries.erase(it); + if (_activeSessionId == sessionId) { + _activeSessionId = kInvalidRNWebGPUSessionId; + } + } + + if (releasedManager) { + releasedManager->invalidate(); + } + SurfaceRegistry::getInstance().closeSession(sessionId); + return releasedManager; } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h index cd2ea85a8c..7cb64021e7 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.h @@ -1,10 +1,14 @@ #pragma once +#include +#include #include +#include +#include #include "GPU.h" #include "PlatformContext.h" -#include "SurfaceRegistry.h" +#include "RNWebGPUSession.h" namespace facebook { namespace jsi { @@ -20,13 +24,26 @@ namespace rnwgpu { namespace jsi = facebook::jsi; namespace react = facebook::react; +class SurfaceInfo; + class RNWebGPUManager { public: - RNWebGPUManager(jsi::Runtime *jsRuntime, + RNWebGPUManager(RNWebGPUSessionId sessionId, jsi::Runtime *jsRuntime, std::shared_ptr jsCallInvoker, std::shared_ptr platformContext); ~RNWebGPUManager(); + RNWebGPUManager(const RNWebGPUManager &) = delete; + RNWebGPUManager &operator=(const RNWebGPUManager &) = delete; + RNWebGPUManager(RNWebGPUManager &&) = delete; + RNWebGPUManager &operator=(RNWebGPUManager &&) = delete; + + RNWebGPUSessionId sessionId() const noexcept { return _sessionState->id(); } + bool isActive() const noexcept { return _sessionState->isActive(); } + void invalidate() noexcept; + + static RNWebGPUSessionId sessionForRuntime(jsi::Runtime &runtime); + /** * Install global helper functions for Worklets serialization. * This installs __webgpuIsWebGPUObject and __webgpuBox on the global object. @@ -46,10 +63,55 @@ class RNWebGPUManager { private: jsi::Runtime *_jsRuntime; std::shared_ptr _jsCallInvoker; + std::shared_ptr _sessionState; public: wgpu::Instance _gpu; std::shared_ptr _platformContext; }; +struct RNWebGPUManagerSnapshot { + RNWebGPUSessionId sessionId{kInvalidRNWebGPUSessionId}; + std::shared_ptr manager; + + explicit operator bool() const noexcept { + return sessionId != kInvalidRNWebGPUSessionId && manager != nullptr; + } +}; + +/** + * Synchronizes process-wide publication of the manager used by native views. + * Managers themselves remain owned by their module/runtime session. + */ +class RNWebGPUManagerRegistry final { +public: + static RNWebGPUManagerRegistry &getInstance(); + + RNWebGPUSessionId createSession(); + void publish(RNWebGPUSessionId sessionId, + std::shared_ptr manager); + RNWebGPUManagerSnapshot acquire(RNWebGPUSessionId sessionId); + RNWebGPUManagerSnapshot getActive() const; + RNWebGPUManagerSnapshot get(RNWebGPUSessionId sessionId) const; + + /** + * Releases one module owner. The session is closed when its last owner + * leaves; a stale release never changes a newer active session. + */ + std::shared_ptr release(RNWebGPUSessionId sessionId); + +private: + struct Entry { + std::shared_ptr manager; + std::size_t owners{0}; + }; + + RNWebGPUManagerRegistry() = default; + + mutable std::mutex _mutex; + std::unordered_map _entries; + RNWebGPUSessionId _activeSessionId{kInvalidRNWebGPUSessionId}; + RNWebGPUSessionId _nextSessionId{1}; +}; + } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUSession.h b/packages/webgpu/cpp/rnwgpu/RNWebGPUSession.h new file mode 100644 index 0000000000..2900cbd85b --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUSession.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace rnwgpu { + +using RNWebGPUSessionId = std::uint64_t; + +inline constexpr RNWebGPUSessionId kInvalidRNWebGPUSessionId = 0; +// Session ids cross the Fabric boundary as a JavaScript number/Codegen Double. +// Keeping them in the safe-integer range preserves their identity exactly on +// every supported platform. +inline constexpr RNWebGPUSessionId kMaxRNWebGPUSessionId = + (RNWebGPUSessionId{1} << 53U) - 1U; + +/** + * Process-independent lifetime token shared by objects created for one + * React Native JavaScript runtime. + */ +class RNWebGPUSessionState final { +public: + explicit RNWebGPUSessionState(RNWebGPUSessionId id) noexcept : _id(id) {} + + RNWebGPUSessionId id() const noexcept { return _id; } + + bool isActive() const noexcept { + return _active.load(std::memory_order_acquire); + } + + void invalidate() noexcept { + _active.store(false, std::memory_order_release); + } + +private: + RNWebGPUSessionId _id; + std::atomic _active{true}; +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 8dad9d36f0..a091831857 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -1,15 +1,19 @@ #pragma once #include +#include +#include #include #include #include #include #include #include +#include #include #include +#include "RNWebGPUSession.h" #include "webgpu/webgpu_cpp.h" #ifdef __APPLE__ @@ -29,6 +33,7 @@ void applyCAMetalLayerColorSpace(void *nativeSurface, struct NativeInfo { void *nativeSurface; + std::shared_ptr nativeSurfaceOwner; int width; int height; }; @@ -38,25 +43,35 @@ struct Size { int height; }; -// Invoked with the platform's native surface pointer once SurfaceInfo is done -// with it, so the platform can drop the reference it acquired on our behalf -// (ANativeWindow_release on Android, CFBridgingRelease of the retained -// CAMetalLayer on Apple platforms). May run on any thread. -using NativeSurfaceReleaser = std::function; +using SurfaceOwnerId = std::uint64_t; +inline constexpr SurfaceOwnerId kInvalidSurfaceOwnerId = 0; + +struct SurfaceKey { + RNWebGPUSessionId sessionId; + int contextId; + + bool operator==(const SurfaceKey &) const noexcept = default; +}; + +struct SurfaceKeyHash { + std::size_t operator()(const SurfaceKey &key) const noexcept { + const auto sessionHash = std::hash{}(key.sessionId); + const auto contextHash = std::hash{}(key.contextId); + return sessionHash ^ (contextHash + 0x9e3779b9U + (sessionHash << 6U) + + (sessionHash >> 2U)); + } +}; // Bridges the asynchronous native surface lifecycle (surfaces appear and // disappear on the platform UI thread) with the synchronous WebGPU canvas API // (the JS render loop must always be able to acquire a texture). // // Ownership & threading model: -// - A registry entry is created on first use (by whichever of JS/native gets -// there first) and lives exactly as long as its JS Canvas: contextIds are -// never reused. It is removed by the native view's teardown (MetalView -// dealloc / WebGPUViewManager.onDropViewInstance) when a surface is -// attached, or by RNWebGPU.destroyContext (the Canvas unmount cleanup) when -// none ever was — see RNWebGPU::destroyContext for why the split. Surface -// destruction alone (backgrounding, TextureView teardown) never removes an -// entry; it only detaches the surface. +// - A registry entry is keyed by runtime session + contextId. A native view +// claims it with a monotonically increasing ownerId, so callbacks from a +// recycled/stale view cannot detach a newer view's surface. Session teardown +// closes all of that runtime's entries without touching a replacement +// runtime whose contextId counter restarted from the same value. // - Attaching a surface is LATCHED: the UI thread stores it as pending // (attachSurface) and it is adopted at the next frame boundary — start of // getCurrentTexture or end of presentFrame — on whichever thread renders @@ -78,17 +93,75 @@ class SurfaceInfo { SurfaceInfo(wgpu::Instance gpu, int width, int height) : _gpu(std::move(gpu)), _width(width), _height(height) {} - ~SurfaceInfo() { - // Drop the Dawn objects before releasing the native surfaces they borrow. - _surface = nullptr; - _pendingSurface = nullptr; - _texture = nullptr; - if (_pendingReleaser && _pendingNativeSurface) { - _pendingReleaser(_pendingNativeSurface); + ~SurfaceInfo() { close(); } + + void close() noexcept { + std::shared_ptr nativeSurfaceOwner; + std::shared_ptr pendingNativeSurfaceOwner; + try { + std::unique_lock lock(_mutex); + if (_closeCleanupComplete) { + return; + } + _closed = true; + _ownerId = kInvalidSurfaceOwnerId; + _surface = nullptr; + _pendingSurface = nullptr; + _texture = nullptr; + _hasPendingAttach = false; + _nativeSurface = nullptr; + _pendingNativeSurface = nullptr; + nativeSurfaceOwner = std::move(_nativeSurfaceOwner); + pendingNativeSurfaceOwner = std::move(_pendingNativeSurfaceOwner); + _config = {}; + _viewFormats.clear(); + _frameInFlight = false; + _acquiredFromSurface = false; + _closeCleanupComplete = true; + } catch (...) { + // Native teardown must never terminate the process. + } + } + + void markClosed() noexcept { + try { + std::unique_lock lock(_mutex); + _closed = true; + _ownerId = kInvalidSurfaceOwnerId; + } catch (...) { + // A terminal marker must never escape registry invalidation. + } + } + + bool markClosedIfOwnedBy(SurfaceOwnerId ownerId) noexcept { + try { + std::unique_lock lock(_mutex); + if (_closed || ownerId == kInvalidSurfaceOwnerId || _ownerId != ownerId) { + return false; + } + _closed = true; + _ownerId = kInvalidSurfaceOwnerId; + return true; + } catch (...) { + return false; + } + } + + bool claimOwner(SurfaceOwnerId ownerId) { + if (ownerId == kInvalidSurfaceOwnerId) { + return false; } - if (_releaser && _nativeSurface) { - _releaser(_nativeSurface); + std::unique_lock lock(_mutex); + if (_closed || (_ownerId != kInvalidSurfaceOwnerId && ownerId < _ownerId)) { + return false; } + _ownerId = ownerId; + return true; + } + + bool isOwnedBy(SurfaceOwnerId ownerId) const { + std::shared_lock lock(_mutex); + return !_closed && ownerId != kInvalidSurfaceOwnerId && _ownerId == ownerId; } // --- Platform UI thread --------------------------------------------------- @@ -97,42 +170,71 @@ class SurfaceInfo { // frame boundary (applyPendingAttach); callers should follow up with // RNWebGPUManager::flushPendingSurfaceTransition so contexts that are not // currently rendering also pick it up. - void attachSurface(void *nativeSurface, wgpu::Surface surface, - NativeSurfaceReleaser releaser) { - void *replacedSurface = nullptr; - NativeSurfaceReleaser replacedReleaser; - { - std::unique_lock lock(_mutex); - if (_hasPendingAttach) { - // Replaced before it was ever adopted. - replacedSurface = _pendingNativeSurface; - replacedReleaser = std::move(_pendingReleaser); - } - _hasPendingAttach = true; - _pendingNativeSurface = nativeSurface; - _pendingSurface = std::move(surface); - _pendingReleaser = std::move(releaser); + bool attachSurfaceIfOwnedBy(SurfaceOwnerId ownerId, void *nativeSurface, + wgpu::Surface surface, + std::shared_ptr nativeSurfaceOwner = {}) { + std::shared_ptr replacedNativeSurfaceOwner; + std::unique_lock lock(_mutex); + if (_closed || ownerId == kInvalidSurfaceOwnerId || _ownerId != ownerId) { + return false; } - if (replacedReleaser && replacedSurface) { - replacedReleaser(replacedSurface); + if (_hasPendingAttach) { + replacedNativeSurfaceOwner = std::move(_pendingNativeSurfaceOwner); } + _hasPendingAttach = true; + _pendingNativeSurface = nativeSurface; + _pendingSurface = std::move(surface); + _pendingNativeSurfaceOwner = std::move(nativeSurfaceOwner); + return true; } // The platform surface is being destroyed: detach immediately. If the // context is configured, rendering continues into an offscreen texture whose // content is blitted to the next attached surface; present() no-ops until // then. Safe to call when already offscreen. - void switchToOffscreen() { detach(/* createFallbackTexture = */ true); } + bool switchToOffscreenIfOwnedBy(SurfaceOwnerId ownerId) noexcept { + try { + if (!isOwnedBy(ownerId)) { + return false; + } + detach(/* createFallbackTexture = */ true, ownerId); + return true; + } catch (...) { + return false; + } + } // Detach without creating the offscreen fallback: used when the context is // being destroyed and nothing will consume further frames. - void detachSurface() { detach(/* createFallbackTexture = */ false); } + void detachSurfaceIfOwnedBy(SurfaceOwnerId ownerId) noexcept { + try { + detach(/* createFallbackTexture = */ false, ownerId); + } catch (...) { + // View teardown must not escape into Java/Objective-C deallocation. + } + } // Reflects native view layout changes. Does not resize the drawing buffer: // that tracks canvas.width/height (like on the web), see // GPUCanvasContext::getCurrentTexture. + bool resizeIfOwnedBy(SurfaceOwnerId ownerId, int newWidth, + int newHeight) noexcept { + try { + std::unique_lock lock(_mutex); + if (_closed || ownerId == kInvalidSurfaceOwnerId || _ownerId != ownerId) { + return false; + } + _width = newWidth; + _height = newHeight; + return true; + } catch (...) { + return false; + } + } + void resize(int newWidth, int newHeight) { std::unique_lock lock(_mutex); + throwIfClosedLocked(); _width = newWidth; _height = newHeight; } @@ -151,12 +253,14 @@ class SurfaceInfo { // swaps the surface under a frame that is genuinely in flight. void applyPendingAttach(bool supersedeInFlightFrame = false) { bool presentBlit = false; - uint64_t blitEpoch = 0; + std::uint64_t blitEpoch = 0; wgpu::Device device = nullptr; - void *replacedSurface = nullptr; - NativeSurfaceReleaser replacedReleaser; + std::shared_ptr replacedNativeSurfaceOwner; { std::unique_lock lock(_mutex); + if (_closed) { + return; + } if (supersedeInFlightFrame) { _frameInFlight = false; _acquiredFromSurface = false; @@ -167,11 +271,10 @@ class SurfaceInfo { // Attach over attach without a detach in between: replace. Ownership // tracks the native window pointer, not the Dawn surface handle (which // can be null if surface creation failed). - replacedSurface = _nativeSurface; - replacedReleaser = std::move(_releaser); + replacedNativeSurfaceOwner = std::move(_nativeSurfaceOwner); _surface = std::move(_pendingSurface); _nativeSurface = _pendingNativeSurface; - _releaser = std::move(_pendingReleaser); + _nativeSurfaceOwner = std::move(_pendingNativeSurfaceOwner); _hasPendingAttach = false; _pendingNativeSurface = nullptr; _frameEpoch++; @@ -200,9 +303,6 @@ class SurfaceInfo { } } } - if (replacedReleaser && replacedSurface) { - replacedReleaser(replacedSurface); - } if (presentBlit) { #ifdef __APPLE__ if (device) { @@ -216,7 +316,7 @@ class SurfaceInfo { // or any other transition while we were unlocked skips this present // (their newer content stands; presenting here would be a Dawn // present-without-acquire error). - if (_surface && !_frameInFlight && _frameEpoch == blitEpoch) { + if (!_closed && _surface && !_frameInFlight && _frameEpoch == blitEpoch) { _surface.Present(); } } @@ -229,6 +329,7 @@ class SurfaceInfo { std::vector viewFormats) { applyPendingAttach(/* supersedeInFlightFrame = */ true); std::unique_lock lock(_mutex); + throwIfClosedLocked(); _viewFormats = std::move(viewFormats); _config = newConfig; // The caller's viewFormats storage dies with the call; point the stored @@ -248,6 +349,7 @@ class SurfaceInfo { // Resize the drawing buffer (canvas.width/height changed). void reconfigure(int newWidth, int newHeight) { std::unique_lock lock(_mutex); + throwIfClosedLocked(); if (_config.device == nullptr) { return; } @@ -260,6 +362,9 @@ class SurfaceInfo { void unconfigure() { std::unique_lock lock(_mutex); + if (_closed) { + return; + } if (_surface) { _surface.Unconfigure(); } @@ -270,9 +375,29 @@ class SurfaceInfo { _frameEpoch++; } + bool unconfigureIfOwnedBy(SurfaceOwnerId ownerId) noexcept { + try { + std::unique_lock lock(_mutex); + if (_closed || ownerId == kInvalidSurfaceOwnerId || _ownerId != ownerId) { + return false; + } + if (_surface) { + _surface.Unconfigure(); + } + _texture = nullptr; + _config = {}; + _viewFormats.clear(); + _acquiredFromSurface = false; + _frameEpoch++; + return true; + } catch (...) { + return false; + } + } + bool isConfigured() { std::shared_lock lock(_mutex); - return _config.device != nullptr; + return !_closed && _config.device != nullptr; } // True while a native view owns a surface for this context (attached or @@ -281,7 +406,7 @@ class SurfaceInfo { // otherwise (see RNWebGPU::destroyContext). bool hasNativeSurface() { std::shared_lock lock(_mutex); - return _nativeSurface != nullptr || _hasPendingAttach; + return !_closed && _ownerId != kInvalidSurfaceOwnerId; } // Returns the texture for the current frame: the surface's swapchain texture @@ -292,6 +417,7 @@ class SurfaceInfo { // that never presented. applyPendingAttach(/* supersedeInFlightFrame = */ true); std::unique_lock lock(_mutex); + throwIfClosedLocked(); if (_config.device == nullptr) { throw std::runtime_error( "[WebGPU] getCurrentTexture() called on a canvas context that is " @@ -329,6 +455,9 @@ class SurfaceInfo { wgpu::Device device; { std::shared_lock lock(_mutex); + if (_closed) { + return; + } device = _config.device; } if (device) { @@ -337,6 +466,9 @@ class SurfaceInfo { #endif { std::unique_lock lock(_mutex); + if (_closed) { + return; + } if (_surface && _acquiredFromSurface) { _surface.Present(); } @@ -349,35 +481,51 @@ class SurfaceInfo { NativeInfo getNativeInfo() { std::shared_lock lock(_mutex); + if (_closed) { + return {.nativeSurface = nullptr, + .nativeSurfaceOwner = {}, + .width = 0, + .height = 0}; + } // A surface that is still pending adoption is the one callers should see. void *native = _hasPendingAttach ? _pendingNativeSurface : _nativeSurface; - return {.nativeSurface = native, .width = _width, .height = _height}; + auto nativeSurfaceOwner = + _hasPendingAttach ? _pendingNativeSurfaceOwner : _nativeSurfaceOwner; + return {.nativeSurface = native, + .nativeSurfaceOwner = std::move(nativeSurfaceOwner), + .width = _width, + .height = _height}; } Size getSize() { std::shared_lock lock(_mutex); + if (_closed) { + return {.width = 0, .height = 0}; + } return {.width = _width, .height = _height}; } wgpu::SurfaceConfiguration getConfig() { std::shared_lock lock(_mutex); - return _config; + return _closed ? wgpu::SurfaceConfiguration{} : _config; } private: - void detach(bool createFallbackTexture) { - void *releasedSurfaces[2] = {nullptr, nullptr}; - NativeSurfaceReleaser releasers[2]; + void detach(bool createFallbackTexture, SurfaceOwnerId ownerId) { + std::shared_ptr nativeSurfaceOwner; + std::shared_ptr pendingNativeSurfaceOwner; { std::unique_lock lock(_mutex); + if (_closed || ownerId == kInvalidSurfaceOwnerId || _ownerId != ownerId) { + return; + } // The platform is tearing surfaces down; a not-yet-adopted attach is // stale, cancel it. if (_hasPendingAttach) { _hasPendingAttach = false; _pendingSurface = nullptr; - releasedSurfaces[0] = _pendingNativeSurface; - releasers[0] = std::move(_pendingReleaser); _pendingNativeSurface = nullptr; + pendingNativeSurfaceOwner = std::move(_pendingNativeSurfaceOwner); } if (_surface) { if (createFallbackTexture && _config.device != nullptr) { @@ -389,22 +537,19 @@ class SurfaceInfo { _acquiredFromSurface = false; } _frameEpoch++; - // Window ownership is independent of the Dawn surface handle (which can - // be null if surface creation failed): always return the window. - releasedSurfaces[1] = _nativeSurface; - releasers[1] = std::move(_releaser); _nativeSurface = nullptr; - } - // Release outside the lock: the platform may do real work here. - for (int i = 0; i < 2; i++) { - if (releasers[i] && releasedSurfaces[i]) { - releasers[i](releasedSurfaces[i]); - } + nativeSurfaceOwner = std::move(_nativeSurfaceOwner); } } // All *Locked helpers below require _mutex to be held exclusively. + void throwIfClosedLocked() const { + if (_closed) { + throw std::runtime_error("WebGPU surface session is no longer active"); + } + } + wgpu::Texture createOffscreenTextureLocked() { wgpu::TextureDescriptor descriptor; // Union with the user's usage so offscreen frames stay compatible with @@ -492,15 +637,15 @@ class SurfaceInfo { mutable std::shared_mutex _mutex; // Attached on-screen surface (null while offscreen). void *_nativeSurface = nullptr; + std::shared_ptr _nativeSurfaceOwner; wgpu::Surface _surface = nullptr; - NativeSurfaceReleaser _releaser; // Offscreen fallback drawing buffer. wgpu::Texture _texture = nullptr; // Surface attached by the UI thread, awaiting adoption at a frame boundary. bool _hasPendingAttach = false; void *_pendingNativeSurface = nullptr; + std::shared_ptr _pendingNativeSurfaceOwner; wgpu::Surface _pendingSurface = nullptr; - NativeSurfaceReleaser _pendingReleaser; // Frame state: set by getCurrentTexture, cleared by presentFrame. bool _frameInFlight = false; bool _acquiredFromSurface = false; @@ -508,7 +653,7 @@ class SurfaceInfo { // adoption, and detach. The deferred blit-present in applyPendingAttach // revalidates against it so it never presents a texture that stopped being // the surface's current one while the lock was released. - uint64_t _frameEpoch = 0; + std::uint64_t _frameEpoch = 0; // device == nullptr means "not configured". _viewFormats owns the storage // that _config.viewFormats points at. wgpu::SurfaceConfiguration _config; @@ -519,92 +664,172 @@ class SurfaceInfo { // canvas). int _width; int _height; + bool _closed = false; + bool _closeCleanupComplete = false; + SurfaceOwnerId _ownerId{kInvalidSurfaceOwnerId}; }; class SurfaceRegistry { public: static SurfaceRegistry &getInstance() { - static SurfaceRegistry instance; - return instance; + // Native views can be released during process-wide static teardown. + static auto *instance = new SurfaceRegistry(); + return *instance; } SurfaceRegistry(const SurfaceRegistry &) = delete; SurfaceRegistry &operator=(const SurfaceRegistry &) = delete; - std::shared_ptr getSurfaceInfo(int id) { - std::shared_lock lock(_mutex); - auto it = _registry.find(id); - if (it != _registry.end()) { - return it->second; + void openSession(RNWebGPUSessionId sessionId) { + if (sessionId == kInvalidRNWebGPUSessionId) { + return; } - return nullptr; + std::unique_lock lock(_mutex); + _openSessions.insert(sessionId); } - void removeSurfaceInfo(int id) { - std::unique_lock lock(_mutex); - _registry.erase(id); + void closeSession(RNWebGPUSessionId sessionId) noexcept { + if (sessionId == kInvalidRNWebGPUSessionId) { + return; + } + + { + std::unique_lock lock(_mutex); + _openSessions.erase(sessionId); + } + + // Release Dawn/platform resources without holding the registry lock. + for (;;) { + std::shared_ptr staleSurface; + { + std::unique_lock lock(_mutex); + auto stale = _registry.end(); + for (auto it = _registry.begin(); it != _registry.end(); ++it) { + if (it->first.sessionId == sessionId) { + stale = it; + break; + } + } + if (stale == _registry.end()) { + return; + } + if (stale->second) { + stale->second->markClosed(); + staleSurface = std::move(stale->second); + } + _registry.erase(stale); + } + if (staleSurface) { + staleSurface->close(); + } + } + } + + std::shared_ptr getSurfaceInfo(RNWebGPUSessionId sessionId, + int id) const { + std::shared_lock lock(_mutex); + const auto it = _registry.find(SurfaceKey{sessionId, id}); + return it == _registry.end() ? nullptr : it->second; } std::shared_ptr - getSurfaceInfoOrCreate(int id, wgpu::Instance gpu, int width, int height) { - std::unique_lock lock(_mutex); - return getSurfaceInfoOrCreateLocked(id, gpu, width, height); - } - - // Find-or-create + attach as one atomic step under the registry lock, so it - // serializes with removeSurfaceInfoIfDetached: an attach can never land on - // an entry that a concurrent destroyContext is erasing (it either marks the - // entry attached before the check, or re-creates the entry after the - // erase). Lock order is registry -> SurfaceInfo, matching every other path. - std::shared_ptr attachSurface(int id, wgpu::Instance gpu, - int width, int height, - void *nativeSurface, - wgpu::Surface surface, - NativeSurfaceReleaser releaser) { - std::unique_lock lock(_mutex); - auto info = getSurfaceInfoOrCreateLocked(id, gpu, width, height); - info->attachSurface(nativeSurface, std::move(surface), std::move(releaser)); - return info; + getSurfaceInfoIfOwnedBy(RNWebGPUSessionId sessionId, int id, + SurfaceOwnerId ownerId) const { + auto surfaceInfo = getSurfaceInfo(sessionId, id); + return surfaceInfo && surfaceInfo->isOwnedBy(ownerId) ? surfaceInfo + : nullptr; } - // Erase the entry only if no native surface is attached or pending; the - // atomic counterpart of attachSurface above (see RNWebGPU::destroyContext - // for the ownership split this implements). - void removeSurfaceInfoIfDetached(int id) { - std::unique_lock lock(_mutex); - auto it = _registry.find(id); - if (it == _registry.end() || it->second->hasNativeSurface()) { - return; + bool removeSurfaceInfoIfOwnedBy( + RNWebGPUSessionId sessionId, int id, SurfaceOwnerId ownerId, + const std::shared_ptr &expectedSurface) { + if (ownerId == kInvalidSurfaceOwnerId || !expectedSurface) { + return false; + } + + std::shared_ptr removedSurface; + { + std::unique_lock lock(_mutex); + const auto it = _registry.find(SurfaceKey{sessionId, id}); + if (it == _registry.end() || it->second != expectedSurface || + !it->second->markClosedIfOwnedBy(ownerId)) { + return false; + } + removedSurface = std::move(it->second); + _registry.erase(it); } - _registry.erase(it); + removedSurface->close(); + return true; } - // Drops all entries. Called when the RN instance tears down (dev reload): - // JS context ids restart from scratch, so surviving entries would alias new - // canvases onto dead surfaces. - void clear() { + std::shared_ptr + getSurfaceInfoOrCreate(RNWebGPUSessionId sessionId, int id, + wgpu::Instance gpu, int width, int height) { std::unique_lock lock(_mutex); - _registry.clear(); + if (_openSessions.find(sessionId) == _openSessions.end()) { + return nullptr; + } + const SurfaceKey key{sessionId, id}; + const auto it = _registry.find(key); + if (it != _registry.end()) { + return it->second; + } + auto info = std::make_shared(gpu, width, height); + _registry.emplace(key, info); + return info; } -private: - SurfaceRegistry() = default; - - std::shared_ptr getSurfaceInfoOrCreateLocked(int id, - wgpu::Instance gpu, - int width, - int height) { - auto it = _registry.find(id); + // Claiming and find-or-create happen under one registry lock. Because a + // claimed entry counts as native-owned, destroyContext cannot erase it in + // the interval between the claim and the latched attach. + std::shared_ptr claimSurfaceInfo(RNWebGPUSessionId sessionId, + int id, SurfaceOwnerId ownerId, + wgpu::Instance gpu, int width, + int height) { + if (ownerId == kInvalidSurfaceOwnerId) { + return nullptr; + } + std::unique_lock lock(_mutex); + if (_openSessions.find(sessionId) == _openSessions.end()) { + return nullptr; + } + const SurfaceKey key{sessionId, id}; + const auto it = _registry.find(key); if (it != _registry.end()) { - return it->second; + return it->second && it->second->claimOwner(ownerId) ? it->second + : nullptr; } auto info = std::make_shared(gpu, width, height); - _registry[id] = info; + if (!info->claimOwner(ownerId)) { + return nullptr; + } + _registry.emplace(key, info); return info; } + void removeSurfaceInfoIfDetached(RNWebGPUSessionId sessionId, int id) { + std::shared_ptr removedSurface; + { + std::unique_lock lock(_mutex); + const auto it = _registry.find(SurfaceKey{sessionId, id}); + if (it == _registry.end() || it->second->hasNativeSurface()) { + return; + } + it->second->markClosed(); + removedSurface = std::move(it->second); + _registry.erase(it); + } + if (removedSurface) { + removedSurface->close(); + } + } + +private: + SurfaceRegistry() = default; mutable std::shared_mutex _mutex; - std::unordered_map> _registry; + std::unordered_map, SurfaceKeyHash> + _registry; + std::unordered_set _openSessions; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/Canvas.h b/packages/webgpu/cpp/rnwgpu/api/Canvas.h index a8d99ac6fd..b206bca8b1 100644 --- a/packages/webgpu/cpp/rnwgpu/api/Canvas.h +++ b/packages/webgpu/cpp/rnwgpu/api/Canvas.h @@ -2,6 +2,7 @@ #include #include +#include #include "Unions.h" @@ -17,9 +18,11 @@ class Canvas : public NativeObject { public: static constexpr const char *CLASS_NAME = "Canvas"; - explicit Canvas(void *surface, const int width, const int height) - : NativeObject(CLASS_NAME), _surface(surface), _width(width), - _height(height), _clientWidth(width), _clientHeight(height) {} + explicit Canvas(void *surface, const int width, const int height, + std::shared_ptr surfaceOwner = {}) + : NativeObject(CLASS_NAME), _surfaceOwner(std::move(surfaceOwner)), + _surface(surface), _width(width), _height(height), _clientWidth(width), + _clientHeight(height) {} int getWidth() { return _width; } int getHeight() { return _height; } @@ -65,6 +68,9 @@ class Canvas : public NativeObject { } private: + // Keeps platform-owned handles (for example ANativeWindow) alive for as + // long as JavaScript can observe the raw surface pointer. + std::shared_ptr _surfaceOwner; void *_surface; int _width; int _height; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPU.cpp b/packages/webgpu/cpp/rnwgpu/api/GPU.cpp index 92939b28cf..51fd70c44f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPU.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPU.cpp @@ -1,7 +1,9 @@ #include "GPU.h" #include +#include #include +#include #include #include #include @@ -13,7 +15,12 @@ namespace rnwgpu { -GPU::GPU(jsi::Runtime & /*runtime*/) : NativeObject(CLASS_NAME) { +GPU::GPU(std::shared_ptr sessionState) + : NativeObject(CLASS_NAME), _sessionState(std::move(sessionState)) { + if (!_sessionState || !_sessionState->isActive()) { + throw std::invalid_argument("GPU requires an active WebGPU session"); + } + static const auto kTimedWaitAny = wgpu::InstanceFeatureName::TimedWaitAny; wgpu::InstanceDescriptor instanceDesc{.requiredFeatureCount = 1, .requiredFeatures = &kTimedWaitAny}; @@ -53,6 +60,10 @@ GPU::GPU(jsi::Runtime & /*runtime*/) : NativeObject(CLASS_NAME) { async::AsyncTaskHandle GPU::requestAdapter( jsi::Runtime &runtime, std::optional> options) { + if (!_sessionState || !_sessionState->isActive()) { + throw jsi::JSError(runtime, "WebGPU runtime session is no longer active"); + } + wgpu::RequestAdapterOptions aOptions; Convertor conv; if (!conv(aOptions, options)) { @@ -67,16 +78,17 @@ async::AsyncTaskHandle GPU::requestAdapter( // Per-runtime context: async ops requested on this runtime resolve on this // runtime's own thread (via its ProcessEvents pump). - auto context = async::RuntimeContext::getOrCreate(runtime, _instance); + auto context = + async::RuntimeContext::getOrCreate(runtime, _instance, _sessionState); return context->postTask( [this, aOptions, context](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { _instance.RequestAdapter( &aOptions, wgpu::CallbackMode::AllowProcessEvents, - [context, resolve, - reject](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, - wgpu::StringView message) { + [context, resolve, reject](wgpu::RequestAdapterStatus status, + wgpu::Adapter adapter, + wgpu::StringView message) { if (message.length) { fprintf(stderr, "%s", message.data); } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPU.h b/packages/webgpu/cpp/rnwgpu/api/GPU.h index f42589fc74..2ef0f16bef 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPU.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -8,6 +9,7 @@ #include "Unions.h" #include "NativeObject.h" +#include "RNWebGPUSession.h" #include "rnwgpu/async/AsyncTaskHandle.h" #include "rnwgpu/async/RuntimeContext.h" @@ -27,7 +29,7 @@ class GPU : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPU"; - explicit GPU(jsi::Runtime &runtime); + explicit GPU(std::shared_ptr sessionState); public: std::string getBrand() { return CLASS_NAME; } @@ -54,6 +56,7 @@ class GPU : public NativeObject { inline const wgpu::Instance get() { return _instance; } private: + std::shared_ptr _sessionState; wgpu::Instance _instance; }; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index 64317b51ae..c6dc8026b8 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -22,17 +22,50 @@ namespace rnwgpu { +namespace { + +void handleDeviceLog(wgpu::LoggingType type, wgpu::StringView message) { + try { + const char *level = "Unknown"; + switch (type) { + case wgpu::LoggingType::Warning: + level = "Warning"; + break; + case wgpu::LoggingType::Error: + level = "Error"; + break; + case wgpu::LoggingType::Verbose: + level = "Verbose"; + break; + case wgpu::LoggingType::Info: + level = "Info"; + break; + default: + break; + } + + const std::string text = + message.length > 0 ? std::string(message.data, message.length) : ""; + Logger::logToConsole("%s: %s", level, text.c_str()); + } catch (...) { + // Never propagate an allocation/logging error through Dawn's callback. + } +} + +} // namespace + async::AsyncTaskHandle GPUAdapter::requestDevice( jsi::Runtime &runtime, std::optional> descriptor) { - // Enable the react-native-wgpu "native-texture" umbrella by default, mirroring - // the web where importExternalTexture is core and needs no feature request. - // We append the umbrella's backing Dawn features to requiredFeatures so the - // capability is on without the caller listing it. Two rules keep this safe: + // Enable the react-native-wgpu "native-texture" umbrella by default, + // mirroring the web where importExternalTexture is core and needs no feature + // request. We append the umbrella's backing Dawn features to requiredFeatures + // so the capability is on without the caller listing it. Two rules keep this + // safe: // - All-or-nothing: only inject when the adapter supports *every* backing // feature (same semantics as maybeSynthesizeRnNativeTextureFeature). On a - // web/fallback adapter the backing set is empty or unsupported, so this is - // a no-op and device creation is unaffected. + // web/fallback adapter the backing set is empty or unsupported, so this + // is a no-op and device creation is unaffected. // - Requesting a feature the adapter doesn't support makes RequestDevice // fail, hence the support check below. // Callers can still pass "rnwebgpu/native-texture" explicitly; the dedupe @@ -44,9 +77,10 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( _instance.GetFeatures(&supported); std::unordered_set supportedSet( supported.features, supported.features + supported.featureCount); - bool allSupported = std::all_of( - backing.begin(), backing.end(), - [&](wgpu::FeatureName f) { return supportedSet.count(f) > 0; }); + bool allSupported = + std::all_of(backing.begin(), backing.end(), [&](wgpu::FeatureName f) { + return supportedSet.count(f) > 0; + }); if (allSupported) { if (!descriptor.has_value()) { descriptor = std::make_shared(); @@ -78,23 +112,27 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( [deviceLostBinding](const wgpu::Device & /*device*/, wgpu::DeviceLostReason reason, wgpu::StringView message) { - const char *lostReason = ""; - switch (reason) { - case wgpu::DeviceLostReason::Destroyed: - lostReason = "Destroyed"; - break; - case wgpu::DeviceLostReason::Unknown: - lostReason = "Unknown"; - break; - default: - lostReason = "Unknown"; - } - std::string msg = - message.length ? std::string(message.data, message.length) : ""; - Logger::logToConsole("GPU Device Lost (%s): %s", lostReason, - msg.c_str()); - if (auto deviceHost = deviceLostBinding->lock()) { - deviceHost->notifyDeviceLost(reason, std::move(msg)); + try { + const char *lostReason = ""; + switch (reason) { + case wgpu::DeviceLostReason::Destroyed: + lostReason = "Destroyed"; + break; + case wgpu::DeviceLostReason::Unknown: + lostReason = "Unknown"; + break; + default: + lostReason = "Unknown"; + } + std::string msg = + message.length ? std::string(message.data, message.length) : ""; + Logger::logToConsole("GPU Device Lost (%s): %s", lostReason, + msg.c_str()); + if (auto deviceHost = deviceLostBinding->lock()) { + deviceHost->notifyDeviceLost(reason, std::move(msg)); + } + } catch (...) { + // A spontaneous Dawn callback must never unwind across its ABI. } }); @@ -104,49 +142,52 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( aDescriptor.SetUncapturedErrorCallback([](const wgpu::Device &device, wgpu::ErrorType type, wgpu::StringView message) { - const char *errorType = ""; - switch (type) { - case wgpu::ErrorType::Validation: - errorType = "Validation"; - break; - case wgpu::ErrorType::OutOfMemory: - errorType = "Out of Memory"; - break; - case wgpu::ErrorType::Internal: - errorType = "Internal"; - break; - case wgpu::ErrorType::Unknown: - errorType = "Unknown"; - break; - default: - errorType = "Unknown"; - } - std::string msg = - message.length > 0 ? std::string(message.data, message.length) : ""; - std::string fullMessage = - msg.length() > 0 ? std::string(errorType) + ": " + msg : "no message"; - fprintf(stderr, "%s\n", fullMessage.c_str()); + try { + const char *errorType = ""; + switch (type) { + case wgpu::ErrorType::Validation: + errorType = "Validation"; + break; + case wgpu::ErrorType::OutOfMemory: + errorType = "Out of Memory"; + break; + case wgpu::ErrorType::Internal: + errorType = "Internal"; + break; + case wgpu::ErrorType::Unknown: + errorType = "Unknown"; + break; + default: + errorType = "Unknown"; + } + std::string msg = + message.length > 0 ? std::string(message.data, message.length) : ""; + std::string fullMessage = + msg.length() > 0 ? std::string(errorType) + ": " + msg : "no message"; + fprintf(stderr, "%s\n", fullMessage.c_str()); - // Look up the GPUDevice from the registry and notify it - if (auto gpuDevice = GPUDevice::lookupDevice(device.Get())) { - gpuDevice->notifyUncapturedError(type, std::move(msg)); + // Look up the GPUDevice from the registry and notify it. + if (auto gpuDevice = GPUDevice::lookupDevice(device.Get())) { + gpuDevice->notifyUncapturedError(type, std::move(msg)); + } + } catch (...) { + // A spontaneous Dawn callback must never unwind across its ABI. } }); std::string label = descriptor.has_value() ? descriptor.value()->label.value_or("") : ""; - auto creationRuntime = getCreationRuntime(); // Post to the CALLING runtime's context so the promise settles on the // thread that requested it (see GPUBuffer::mapAsync). The GPUDevice is also // bound to this context, honoring the contract that a device belongs to the // runtime that requested it. - auto context = - async::RuntimeContext::getOrCreate(runtime, _async->instance()); + auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance(), + _async->sessionState()); return context->postTask( [this, aDescriptor, descriptor, label = std::move(label), - deviceLostBinding, context, - creationRuntime](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction &reject) { + deviceLostBinding, + context](const async::AsyncTaskHandle::ResolveFunction &resolve, + const async::AsyncTaskHandle::RejectFunction &reject) { // Build a local mutable copy so we can chain Dawn's device toggles. // The toggle name strings are owned by `descriptor` (captured above), // and the const char* / DawnTogglesDescriptor locals live for the @@ -169,7 +210,8 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( } } } -// TODO: in the latest version of Dawn, this won't be needed (https://issues.chromium.org/issues/42241591) +// TODO: in the latest version of Dawn, this won't be needed +// (https://issues.chromium.org/issues/42241591) #if defined(TARGET_OS_SIMULATOR) && TARGET_OS_SIMULATOR // The iOS Simulator only advertises MTLFeatureSet_iOS_GPUFamily2, so // Dawn defaults disable_base_instance/disable_base_vertex on and then @@ -200,7 +242,7 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( } _instance.RequestDevice( &deviceDesc, wgpu::CallbackMode::AllowProcessEvents, - [context, resolve, reject, label, creationRuntime, + [context, resolve, reject, label, deviceLostBinding](wgpu::RequestDeviceStatus status, wgpu::Device device, wgpu::StringView message) { if (message.length) { @@ -215,38 +257,10 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( return; } - device.SetLoggingCallback( - [](wgpu::LoggingType type, wgpu::StringView msg, - jsi::Runtime *creationRuntime) { - if (creationRuntime == nullptr) { - return; - } - const char *logLevel = ""; - switch (type) { - case wgpu::LoggingType::Warning: - logLevel = "Warning"; - Logger::warnToJavascriptConsole( - *creationRuntime, std::string(msg.data, msg.length)); - break; - case wgpu::LoggingType::Error: - logLevel = "Error"; - Logger::errorToJavascriptConsole( - *creationRuntime, std::string(msg.data, msg.length)); - break; - case wgpu::LoggingType::Verbose: - logLevel = "Verbose"; - break; - case wgpu::LoggingType::Info: - logLevel = "Info"; - break; - default: - logLevel = "Unknown"; - Logger::logToConsole("%s: %.*s", logLevel, - static_cast(msg.length), - msg.data); - } - }, - creationRuntime); + // Dawn logging is spontaneous and may run after a JS runtime + // reload. Keep it native-only; never retain/dereference a raw + // jsi::Runtime from this callback. + device.SetLoggingCallback(handleDeviceLog); auto deviceHost = std::make_shared(std::move(device), context, label); diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp index db698fff78..e4b0d04b32 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp @@ -59,35 +59,36 @@ async::AsyncTaskHandle GPUBuffer::mapAsync(jsi::Runtime &runtime, // must be settled from that runtime's own thread — and postTask itself // schedules the pump through its context's runtime (setTimeout), which is // only safe for the runtime we are currently executing on. - auto context = - async::RuntimeContext::getOrCreate(runtime, _async->instance()); + auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance(), + _async->sessionState()); return context->postTask( [bufferHandle, mode, resolvedOffset, rangeSize](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { - bufferHandle.MapAsync( - mode, resolvedOffset, rangeSize, wgpu::CallbackMode::AllowProcessEvents, - [resolve, reject](wgpu::MapAsyncStatus status, - wgpu::StringView message) { - switch (status) { - case wgpu::MapAsyncStatus::Success: - resolve(nullptr); - break; - case wgpu::MapAsyncStatus::CallbackCancelled: - reject("MapAsyncStatus::CallbackCancelled"); - break; - case wgpu::MapAsyncStatus::Error: - reject("MapAsyncStatus::Error"); - break; - case wgpu::MapAsyncStatus::Aborted: - reject("MapAsyncStatus::Aborted"); - break; - default: - reject("MapAsyncStatus: " + - std::to_string(static_cast(status))); - break; - } - }); + bufferHandle.MapAsync(mode, resolvedOffset, rangeSize, + wgpu::CallbackMode::AllowProcessEvents, + [resolve, reject](wgpu::MapAsyncStatus status, + wgpu::StringView message) { + switch (status) { + case wgpu::MapAsyncStatus::Success: + resolve(nullptr); + break; + case wgpu::MapAsyncStatus::CallbackCancelled: + reject("MapAsyncStatus::CallbackCancelled"); + break; + case wgpu::MapAsyncStatus::Error: + reject("MapAsyncStatus::Error"); + break; + case wgpu::MapAsyncStatus::Aborted: + reject("MapAsyncStatus::Aborted"); + break; + default: + reject( + "MapAsyncStatus: " + + std::to_string(static_cast(status))); + break; + } + }); }); } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index 8ca6ede146..b1cce0ef09 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -8,8 +8,15 @@ namespace rnwgpu { +void GPUCanvasContext::throwIfSessionInactive() const { + if (!_sessionState || !_sessionState->isActive()) { + throw std::runtime_error("WebGPU runtime session is no longer active"); + } +} + void GPUCanvasContext::configure( std::shared_ptr configuration) { + throwIfSessionInactive(); Convertor conv; wgpu::SurfaceConfiguration surfaceConfiguration; surfaceConfiguration.device = configuration->device->get(); @@ -34,6 +41,7 @@ void GPUCanvasContext::configure( void GPUCanvasContext::unconfigure() { _surfaceInfo->unconfigure(); } std::shared_ptr GPUCanvasContext::getCurrentTexture() { + throwIfSessionInactive(); if (!_surfaceInfo->isConfigured()) { // Web parity: on the web this is an InvalidStateError, not a crash. throw std::runtime_error( @@ -72,7 +80,9 @@ void GPUCanvasContext::present() { // texture was acquired from the on-screen surface (offscreen and dropped // frames are skipped), clears the frame state, and adopts any surface that // attached while the frame was in flight. - _surfaceInfo->presentFrame(); + if (_sessionState && _sessionState->isActive()) { + _surfaceInfo->presentFrame(); + } } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h index fd7ef3bc29..b7b07a8d27 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h @@ -14,6 +14,7 @@ #include "GPU.h" #include "GPUCanvasConfiguration.h" #include "GPUTexture.h" +#include "RNWebGPUSession.h" #include "SurfaceRegistry.h" namespace rnwgpu { @@ -24,13 +25,21 @@ class GPUCanvasContext : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPUCanvasContext"; - GPUCanvasContext(std::shared_ptr gpu, int contextId, int width, - int height) - : NativeObject(CLASS_NAME), _gpu(std::move(gpu)) { + GPUCanvasContext(std::shared_ptr gpu, + std::shared_ptr sessionState, + int contextId, int width, int height) + : NativeObject(CLASS_NAME), _sessionState(std::move(sessionState)), + _gpu(std::move(gpu)) { + if (!_sessionState || !_sessionState->isActive()) { + throw std::runtime_error("WebGPU runtime session is no longer active"); + } _canvas = std::make_shared(nullptr, width, height); auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - _surfaceInfo = - registry.getSurfaceInfoOrCreate(contextId, _gpu->get(), width, height); + _surfaceInfo = registry.getSurfaceInfoOrCreate( + _sessionState->id(), contextId, _gpu->get(), width, height); + if (!_surfaceInfo) { + throw std::runtime_error("WebGPU surface session is no longer active"); + } } public: @@ -63,8 +72,11 @@ class GPUCanvasContext : public NativeObject { void present(); private: + void throwIfSessionInactive() const; + std::shared_ptr _canvas; std::shared_ptr _surfaceInfo; + std::shared_ptr _sessionState; std::shared_ptr _gpu; }; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index 96c5153cb6..c610034e6f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -1,6 +1,10 @@ #include "GPUDevice.h" +#include +#include +#include #include +#include #include #include #include @@ -363,8 +367,8 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( // Post to the CALLING runtime's context so the promise settles on the // thread that requested it (see GPUBuffer::mapAsync). - auto context = - async::RuntimeContext::getOrCreate(runtime, _async->instance()); + auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance(), + _async->sessionState()); return context->postTask([device = _instance, desc, descriptor, pipelineHolder]( const async::AsyncTaskHandle::ResolveFunction @@ -410,8 +414,8 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( // Post to the CALLING runtime's context so the promise settles on the // thread that requested it (see GPUBuffer::mapAsync). - auto context = - async::RuntimeContext::getOrCreate(runtime, _async->instance()); + auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance(), + _async->sessionState()); return context->postTask([device = _instance, desc, descriptor, pipelineHolder]( const async::AsyncTaskHandle::ResolveFunction @@ -448,8 +452,8 @@ async::AsyncTaskHandle GPUDevice::popErrorScope(jsi::Runtime &runtime) { // Post to the CALLING runtime's context so the promise settles on the // thread that requested it (see GPUBuffer::mapAsync). - auto context = - async::RuntimeContext::getOrCreate(runtime, _async->instance()); + auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance(), + _async->sessionState()); return context->postTask([device]( const async::AsyncTaskHandle::ResolveFunction &resolve, @@ -523,7 +527,28 @@ std::unordered_set GPUDevice::getFeatures() { return result; } -async::AsyncTaskHandle GPUDevice::getLost() { +async::AsyncTaskHandle GPUDevice::getLost(jsi::Runtime &runtime) { + if (!_async || !_async->ownsRuntime(runtime)) { + throw jsi::JSError(runtime, + "GPUDevice.lost must use the device's owning runtime"); + } + if (!_async->isValid()) { + throw jsi::JSError( + runtime, "Cannot access GPUDevice.lost after runtime invalidation"); + } + if (!_async->callInvoker()) { + // Spontaneous device-loss callbacks need a native dispatcher. Worklet + // runtimes do not have one, so reject immediately instead of registering a + // non-pumping task that can never settle or be session-invalidated on its + // owning thread. + return _async->postTask( + [](const async::AsyncTaskHandle::ResolveFunction & /*resolve*/, + const async::AsyncTaskHandle::RejectFunction &reject) { + reject("GPUDevice.lost requires a runtime dispatcher"); + }, + /*keepPumping=*/false); + } + // Held across the whole body: the postTask callback below runs synchronously // on this (JS) thread and touches the same _lost* fields, so it must not // re-lock. notifyDeviceLost() takes the same lock from its (possibly worker) @@ -565,17 +590,93 @@ async::AsyncTaskHandle GPUDevice::getLost() { _lostHandle = handle; return handle; } -void GPUDevice::addEventListener(std::string type, jsi::Function callback) { - auto funcPtr = std::make_shared(std::move(callback)); - _eventListeners[type].push_back(funcPtr); +void GPUDevice::addEventListener(jsi::Runtime &runtime, std::string type, + jsi::Function callback) { + if (!_async || !_async->ownsRuntime(runtime)) { + throw jsi::JSError( + runtime, + "GPUDevice event listeners must use the device's owning runtime"); + } + if (!_async->callInvoker()) { + throw jsi::JSError( + runtime, + "GPUDevice uncapturederror listeners require a runtime dispatcher"); + } + + const auto eventListeners = _eventListeners; + const bool added = _async->withRuntime([this, &runtime, &type, &callback, + eventListeners]( + jsi::Runtime &owningRuntime) { + if (&owningRuntime != &runtime) { + throw jsi::JSError( + runtime, + "GPUDevice event listener runtime changed during registration"); + } + + std::lock_guard lock(eventListeners->mutex); + if (_listenerCleanupCallbackId == 0) { + const auto cleanupId = _async->addInvalidationCallback( + [eventListeners]() { eventListeners->clear(); }); + if (cleanupId == 0) { + throw jsi::JSError( + runtime, "Cannot add an event listener during runtime teardown"); + } + _listenerCleanupCallbackId = cleanupId; + } + + auto &listeners = eventListeners->listeners[type]; + for (const auto &existing : listeners) { + if (existing && jsi::Object::strictEquals(runtime, *existing, callback)) { + return; + } + } + listeners.push_back(std::make_shared(std::move(callback))); + }); + if (!added) { + throw jsi::JSError(runtime, + "Cannot add an event listener after runtime teardown"); + } } -void GPUDevice::removeEventListener(std::string type, jsi::Function callback) { - // Note: Since jsi::Function doesn't support equality comparison, - // we cannot reliably remove a specific listener. This is a no-op. - // Most use cases (like BabylonJS) only need addEventListener to work. - (void)type; - (void)callback; +void GPUDevice::removeEventListener(jsi::Runtime &runtime, std::string type, + jsi::Function callback) { + if (!_async || !_async->ownsRuntime(runtime)) { + throw jsi::JSError( + runtime, + "GPUDevice event listeners must use the device's owning runtime"); + } + + const auto eventListeners = _eventListeners; + const bool removed = + _async->withRuntime([&runtime, &type, &callback, + eventListeners](jsi::Runtime &owningRuntime) { + if (&owningRuntime != &runtime) { + throw jsi::JSError( + runtime, + "GPUDevice event listener runtime changed during removal"); + } + + std::lock_guard lock(eventListeners->mutex); + const auto listenersIt = eventListeners->listeners.find(type); + if (listenersIt == eventListeners->listeners.end()) { + return; + } + auto &listeners = listenersIt->second; + listeners.erase( + std::remove_if(listeners.begin(), listeners.end(), + [&](const std::shared_ptr &stored) { + return stored && jsi::Object::strictEquals( + runtime, *stored, callback); + }), + listeners.end()); + if (listeners.empty()) { + eventListeners->listeners.erase(listenersIt); + } + }); + if (!removed) { + throw jsi::JSError( + runtime, "Cannot remove an event listener after runtime teardown"); + } } void GPUDevice::notifyUncapturedError(wgpu::ErrorType type, @@ -586,59 +687,75 @@ void GPUDevice::notifyUncapturedError(wgpu::ErrorType type, // invoker is wired only for the main JS runtime, so a device created on a // worklet runtime does not deliver uncaptured errors to JS (best-effort; see // README "Threading model"). - auto invoker = _async ? _async->callInvoker() : nullptr; + const auto invoker = _async ? _async->callInvoker() : nullptr; if (!invoker) { return; } - auto self = shared_from_this(); - invoker->invokeAsync([self, type, message = std::move(message)]() mutable { - self->deliverUncapturedError(type, std::move(message)); - }); + const auto weakSelf = std::weak_ptr(shared_from_this()); + try { + invoker->invokeAsync( + [weakSelf, type, message = std::move(message)]() mutable { + if (const auto self = weakSelf.lock()) { + self->deliverUncapturedError(type, std::move(message)); + } + }); + } catch (...) { + // Never unwind through Dawn's uncaptured-error callback. + } } void GPUDevice::deliverUncapturedError(wgpu::ErrorType type, std::string message) { - auto it = _eventListeners.find("uncapturederror"); - if (it == _eventListeners.end() || it->second.empty()) { + if (!_async) { return; } - auto runtime = getCreationRuntime(); - if (runtime == nullptr) { - return; - } + const auto eventListeners = _eventListeners; + _async->withRuntime([type, message = std::move(message), + eventListeners](jsi::Runtime &runtime) mutable { + std::vector> listeners; + { + std::lock_guard lock(eventListeners->mutex); + const auto it = eventListeners->listeners.find("uncapturederror"); + if (it == eventListeners->listeners.end() || it->second.empty()) { + return; + } + listeners = it->second; + } - // Create the appropriate error object based on type - GPUErrorVariant error; - switch (type) { - case wgpu::ErrorType::Validation: - error = std::make_shared(message); - break; - case wgpu::ErrorType::OutOfMemory: - error = std::make_shared(message); - break; - case wgpu::ErrorType::Internal: - case wgpu::ErrorType::Unknown: - default: - error = std::make_shared(message); - break; - } - - // Create the event object - auto event = std::make_shared(std::move(error)); - auto eventValue = - JSIConverter>::toJSI(*runtime, - event); - - // Call all registered listeners - for (const auto &listener : it->second) { - try { - listener->call(*runtime, eventValue); - } catch (const std::exception &e) { - // Log but don't throw - we don't want one listener to break others - fprintf(stderr, "Error in uncapturederror listener: %s\n", e.what()); + GPUErrorVariant error; + switch (type) { + case wgpu::ErrorType::Validation: + error = std::make_shared(message); + break; + case wgpu::ErrorType::OutOfMemory: + error = std::make_shared(message); + break; + case wgpu::ErrorType::Internal: + case wgpu::ErrorType::Unknown: + default: + error = std::make_shared(message); + break; } - } + + auto event = std::make_shared(std::move(error)); + auto eventValue = + JSIConverter>::toJSI(runtime, + event); + for (const auto &listener : listeners) { + if (!listener) { + continue; + } + try { + listener->call(runtime, eventValue); + } catch (const std::exception &exception) { + std::fprintf(stderr, "Error in uncapturederror listener: %s\n", + exception.what()); + } catch (...) { + std::fprintf(stderr, "Unknown error in uncapturederror listener\n"); + } + } + }); } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 58b1dd62c8..4a9548f244 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -45,11 +46,11 @@ #include "GPURenderPipelineDescriptor.h" #include "GPUSampler.h" #include "GPUSamplerDescriptor.h" +#include "GPUShaderModule.h" +#include "GPUShaderModuleDescriptor.h" #include "GPUSharedFenceDescriptor.h" #include "GPUSharedTextureMemory.h" #include "GPUSharedTextureMemoryDescriptor.h" -#include "GPUShaderModule.h" -#include "GPUShaderModuleDescriptor.h" #include "GPUSupportedLimits.h" #include "GPUTexture.h" #include "GPUTextureDescriptor.h" @@ -60,6 +61,29 @@ namespace rnwgpu { namespace jsi = facebook::jsi; class GPUDevice : public NativeObject { +private: + struct RuntimeEventListeners final { + void clear() { + std::lock_guard lock(mutex); + listeners.clear(); + } + + std::mutex mutex; + std::unordered_map>> + listeners; + }; + + struct DeviceRegistry final { + std::mutex mutex; + std::unordered_map> devices; + }; + + static DeviceRegistry &getRegistry() { + // Dawn callbacks can arrive during process-wide static teardown. + static auto *registry = new DeviceRegistry(); + return *registry; + } + public: static constexpr const char *CLASS_NAME = "GPUDevice"; @@ -67,46 +91,50 @@ class GPUDevice : public NativeObject { std::shared_ptr async, std::string label) : NativeObject(CLASS_NAME), _instance(instance), _async(async), - _label(label) {} + _label(std::move(label)), + _eventListeners(std::make_shared()) {} ~GPUDevice() override { // Unregister from the static registry - unregisterDevice(_instance.Get()); + unregisterDevice(_instance.Get(), this); + // The callback retains listener state until the owning runtime thread has + // cleared its JSI functions (or runtime invalidation performs the cleanup). + if (_async && _listenerCleanupCallbackId != 0) { + _async->removeInvalidationCallback(_listenerCleanupCallbackId); + } } // Static registry for looking up GPUDevice from wgpu::Device in callbacks static void registerDevice(WGPUDevice handle, std::weak_ptr device) { - std::lock_guard lock(getRegistryMutex()); - getRegistry()[handle] = device; + auto ®istry = getRegistry(); + std::lock_guard lock(registry.mutex); + registry.devices.insert_or_assign(handle, std::move(device)); } - static void unregisterDevice(WGPUDevice handle) { - std::lock_guard lock(getRegistryMutex()); - getRegistry().erase(handle); + static void unregisterDevice(WGPUDevice handle, const GPUDevice *expected) { + auto ®istry = getRegistry(); + std::lock_guard lock(registry.mutex); + const auto it = registry.devices.find(handle); + if (it == registry.devices.end()) { + return; + } + const auto current = it->second.lock(); + if (!current || current.get() == expected) { + registry.devices.erase(it); + } } static std::shared_ptr lookupDevice(WGPUDevice handle) { - std::lock_guard lock(getRegistryMutex()); - auto it = getRegistry().find(handle); - if (it != getRegistry().end()) { + auto ®istry = getRegistry(); + std::lock_guard lock(registry.mutex); + const auto it = registry.devices.find(handle); + if (it != registry.devices.end()) { return it->second.lock(); } return nullptr; } -private: - static std::unordered_map> & - getRegistry() { - static std::unordered_map> registry; - return registry; - } - - static std::mutex &getRegistryMutex() { - static std::mutex mutex; - return mutex; - } - public: std::string getBrand() { return CLASS_NAME; } @@ -121,8 +149,8 @@ class GPUDevice : public NativeObject { std::shared_ptr descriptor); std::shared_ptr importSharedTextureMemory( std::shared_ptr descriptor); - std::shared_ptr importSharedFence( - std::shared_ptr descriptor); + std::shared_ptr + importSharedFence(std::shared_ptr descriptor); std::shared_ptr createBindGroupLayout( std::shared_ptr descriptor); std::shared_ptr @@ -153,14 +181,16 @@ class GPUDevice : public NativeObject { std::unordered_set getFeatures(); std::shared_ptr getLimits(); std::shared_ptr getQueue(); - async::AsyncTaskHandle getLost(); + async::AsyncTaskHandle getLost(jsi::Runtime &runtime); void notifyDeviceLost(wgpu::DeviceLostReason reason, std::string message); void notifyUncapturedError(wgpu::ErrorType type, std::string message); void forceLossForTesting(); // EventTarget methods - void addEventListener(std::string type, jsi::Function callback); - void removeEventListener(std::string type, jsi::Function callback); + void addEventListener(jsi::Runtime &runtime, std::string type, + jsi::Function callback); + void removeEventListener(jsi::Runtime &runtime, std::string type, + jsi::Function callback); std::string getLabel() { return _label; } void setLabel(const std::string &label) { @@ -211,7 +241,7 @@ class GPUDevice : public NativeObject { installGetter(runtime, prototype, "features", &GPUDevice::getFeatures); installGetter(runtime, prototype, "limits", &GPUDevice::getLimits); installGetter(runtime, prototype, "queue", &GPUDevice::getQueue); - installGetter(runtime, prototype, "lost", &GPUDevice::getLost); + installGetterWithRuntime(runtime, prototype, "lost", &GPUDevice::getLost); installGetterSetter(runtime, prototype, "label", &GPUDevice::getLabel, &GPUDevice::setLabel); installMethod(runtime, prototype, "forceLossForTesting", @@ -227,7 +257,7 @@ class GPUDevice : public NativeObject { return jsi::Value::undefined(); } auto native = GPUDevice::fromValue(rt, thisVal); - native->addEventListener(args[0].getString(rt).utf8(rt), + native->addEventListener(rt, args[0].getString(rt).utf8(rt), args[1].getObject(rt).getFunction(rt)); return jsi::Value::undefined(); }); @@ -242,7 +272,7 @@ class GPUDevice : public NativeObject { return jsi::Value::undefined(); } auto native = GPUDevice::fromValue(rt, thisVal); - native->removeEventListener(args[0].getString(rt).utf8(rt), + native->removeEventListener(rt, args[0].getString(rt).utf8(rt), args[1].getObject(rt).getFunction(rt)); return jsi::Value::undefined(); }); @@ -255,16 +285,15 @@ class GPUDevice : public NativeObject { private: friend class GPUAdapter; - // Runs the uncapturederror listeners on the creation runtime's JS thread. - // Invoked from notifyUncapturedError via the main CallInvoker. + // Runs uncapturederror listeners on the owning runtime's JS thread. void deliverUncapturedError(wgpu::ErrorType type, std::string message); wgpu::Device _instance; std::shared_ptr _async; std::string _label; // Guards the device-lost state below. In the ProcessEvents model both - // notifyDeviceLost() (fired by Dawn during ProcessEvents) and getLost() run on - // the owning runtime's own thread, but device destruction can also trigger + // notifyDeviceLost() (fired by Dawn during ProcessEvents) and getLost() run + // on the owning runtime's own thread, but device destruction can also trigger // notifyDeviceLost() synchronously, so the mutex keeps these fields safe. std::mutex _lostMutex; std::optional _lostHandle; @@ -272,10 +301,11 @@ class GPUDevice : public NativeObject { bool _lostSettled = false; std::optional _lostResolve; - // Event listeners storage - keyed by event type - // Each entry contains a vector of shared_ptr to functions - std::unordered_map>> - _eventListeners; + // RuntimeContext owns a removable cleanup callback for this shared state. + // Keeping its id prevents a foreign-thread GPUDevice destructor from being + // the last owner of any jsi::Function. + std::shared_ptr _eventListeners; + async::RuntimeContext::InvalidationCallbackId _listenerCleanupCallbackId{0}; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index c0b3ff3a67..886d35e627 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -1,7 +1,11 @@ #include "GPUQueue.h" +#include +#include #include #include +#include +#include #include #include "Convertors.h" @@ -82,8 +86,8 @@ async::AsyncTaskHandle GPUQueue::onSubmittedWorkDone(jsi::Runtime &runtime) { auto queue = _instance; // Post to the CALLING runtime's context so the promise settles on the // thread that requested it (see GPUBuffer::mapAsync). - auto context = - async::RuntimeContext::getOrCreate(runtime, _async->instance()); + auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance(), + _async->sessionState()); return context->postTask( [queue](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp index 01c046d8f4..5715385bea 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp @@ -13,8 +13,8 @@ GPUShaderModule::getCompilationInfo(jsi::Runtime &runtime) { // Post to the CALLING runtime's context so the promise settles on the // thread that requested it (see GPUBuffer::mapAsync). - auto context = - async::RuntimeContext::getOrCreate(runtime, _async->instance()); + auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance(), + _async->sessionState()); return context->postTask( [module](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { diff --git a/packages/webgpu/cpp/rnwgpu/api/ImageBitmap.h b/packages/webgpu/cpp/rnwgpu/api/ImageBitmap.h index 3a76c35e07..737303a7cf 100644 --- a/packages/webgpu/cpp/rnwgpu/api/ImageBitmap.h +++ b/packages/webgpu/cpp/rnwgpu/api/ImageBitmap.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "webgpu/webgpu_cpp.h" @@ -15,8 +16,8 @@ class ImageBitmap : public NativeObject { public: static constexpr const char *CLASS_NAME = "ImageBitmap"; - explicit ImageBitmap(ImageData &imageData) - : NativeObject(CLASS_NAME), _imageData(imageData) {} + explicit ImageBitmap(ImageData imageData) + : NativeObject(CLASS_NAME), _imageData(std::move(imageData)) {} size_t getWidth() { return _imageData.width; } diff --git a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h index e7ecd8a7cc..8eef6170d9 100644 --- a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h @@ -1,7 +1,15 @@ #pragma once +#include +#include +#include #include +#include +#include +#include #include +#include +#include #include "NativeObject.h" @@ -11,6 +19,7 @@ #include "GPUCanvasContext.h" #include "ImageBitmap.h" #include "PlatformContext.h" +#include "RNWebGPUSession.h" #include "VideoFrame.h" #include "VideoPlayer.h" @@ -63,24 +72,52 @@ class RNWebGPU : public NativeObject { explicit RNWebGPU(std::shared_ptr gpu, std::shared_ptr platformContext, - std::shared_ptr callInvoker) - : NativeObject(CLASS_NAME), _gpu(gpu), _platformContext(platformContext), - _callInvoker(callInvoker) {} + std::shared_ptr callInvoker, + std::shared_ptr sessionState, + jsi::Runtime &owningRuntime) + : NativeObject(CLASS_NAME), _gpu(std::move(gpu)), + _platformContext(std::move(platformContext)), + _callInvoker(std::move(callInvoker)), + _sessionState(std::move(sessionState)), _owningRuntime(&owningRuntime) { + } - std::shared_ptr getGPU() { return _gpu; } + std::shared_ptr getGPU() { + ensureSessionActive(); + return _gpu; + } bool getFabric() { return true; } + double getSessionId() { + return _sessionState ? static_cast(_sessionState->id()) + : static_cast(kInvalidRNWebGPUSessionId); + } + std::shared_ptr MakeWebGPUCanvasContext(int contextId, float width, float height) { - auto ctx = - std::make_shared(_gpu, contextId, width, height); + if (!_sessionState || !_sessionState->isActive()) { + throw std::runtime_error("WebGPU runtime session is no longer active"); + } + auto ctx = std::make_shared(_gpu, _sessionState, + contextId, width, height); return ctx; } jsi::Value createImageBitmap(jsi::Runtime &runtime, const jsi::Value & /*thisVal*/, const jsi::Value *args, size_t count) { + if (!_sessionState || !_sessionState->isActive()) { + throw jsi::JSError(runtime, "WebGPU runtime session is no longer active"); + } + // RNWebGPU itself can be boxed into a worklet runtime, but image decoding + // completes through React Native's main CallInvoker. Creating the Promise + // anywhere else would settle JSI values on the wrong runtime thread. + if (&runtime != _owningRuntime) { + throw jsi::JSError( + runtime, + "createImageBitmap is only supported on the main React Native " + "runtime"); + } if (count < 1) { throw jsi::JSError(runtime, "createImageBitmap requires a Blob or ArrayBuffer " @@ -89,6 +126,7 @@ class RNWebGPU : public NativeObject { auto platformContext = _platformContext; auto callInvoker = _callInvoker; + auto sessionState = _sessionState; // Check if the argument is an ArrayBuffer or ArrayBufferView // (TypedArray / DataView). Only a real buffer source is run through the @@ -118,23 +156,20 @@ class RNWebGPU : public NativeObject { std::vector dataCopy(data.begin(), data.end()); return Promise::createPromise( - runtime, - [platformContext, callInvoker, dataCopy = std::move(dataCopy)]( - jsi::Runtime & /*runtime*/, - std::shared_ptr promise) mutable { + runtime, [platformContext, callInvoker, sessionState, + dataCopy = std::move(dataCopy)]( + jsi::Runtime & /*runtime*/, + std::shared_ptr promise) mutable { platformContext->createImageBitmapFromDataAsync( dataCopy, - [callInvoker, promise](ImageData imageData) { - auto imageBitmap = std::make_shared(imageData); - callInvoker->invokeAsync([promise, imageBitmap]() { - promise->resolve( - JSIConverter>::toJSI( - promise->runtime, imageBitmap)); - }); + [callInvoker, promise, sessionState](ImageData imageData) { + RNWebGPU::settleImageBitmapSuccess(callInvoker, promise, + sessionState, + std::move(imageData)); }, - [callInvoker, promise](std::string error) { - callInvoker->invokeAsync( - [promise, error]() { promise->reject(error); }); + [callInvoker, promise, sessionState](std::string error) { + RNWebGPU::settleImageBitmapError( + callInvoker, promise, sessionState, std::move(error)); }); }); } @@ -149,32 +184,30 @@ class RNWebGPU : public NativeObject { return Promise::createPromise( runtime, - [platformContext, callInvoker, blobId, offset, + [platformContext, callInvoker, sessionState, blobId, offset, size](jsi::Runtime & /*runtime*/, std::shared_ptr promise) { platformContext->createImageBitmapAsync( blobId, offset, size, - [callInvoker, promise](ImageData imageData) { - auto imageBitmap = std::make_shared(imageData); - callInvoker->invokeAsync([promise, imageBitmap]() { - promise->resolve( - JSIConverter>::toJSI( - promise->runtime, imageBitmap)); - }); + [callInvoker, promise, sessionState](ImageData imageData) { + RNWebGPU::settleImageBitmapSuccess( + callInvoker, promise, sessionState, std::move(imageData)); }, - [callInvoker, promise](std::string error) { - callInvoker->invokeAsync( - [promise, error]() { promise->reject(error); }); + [callInvoker, promise, sessionState](std::string error) { + RNWebGPU::settleImageBitmapError( + callInvoker, promise, sessionState, std::move(error)); }); }); } std::shared_ptr loadVideoFrame(std::string path) { + ensureSessionActive(); auto frame = _platformContext->loadVideoFrame(path); return std::make_shared(std::move(frame)); } std::shared_ptr createTestVideoFrame(double width, double height) { + ensureSessionActive(); auto frame = _platformContext->createTestVideoFrame( static_cast(width), static_cast(height)); return std::make_shared(std::move(frame)); @@ -184,12 +217,14 @@ class RNWebGPU : public NativeObject { // BigInt on the JS side) into one of our VideoFrames. The native side // CFRetains / acquires so the caller can release immediately. std::shared_ptr createVideoFrameFromNativeBuffer(void *pointer) { + ensureSessionActive(); auto handle = _platformContext->wrapNativeBuffer(pointer); return std::make_shared(std::move(handle)); } std::shared_ptr createVideoPlayer(std::string path, std::optional pixelFormat) { + ensureSessionActive(); auto format = (pixelFormat && pixelFormat.value() == "nv12") ? VideoPixelFormat::NV12 : VideoPixelFormat::BGRA8; @@ -198,18 +233,23 @@ class RNWebGPU : public NativeObject { } std::string writeTestVideoFile() { + ensureSessionActive(); return _platformContext->writeTestVideoFile(); } std::shared_ptr getNativeSurface(int contextId) { auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - auto info = registry.getSurfaceInfo(contextId); + if (!_sessionState || !_sessionState->isActive()) { + return std::make_shared(nullptr, 0, 0); + } + auto info = registry.getSurfaceInfo(_sessionState->id(), contextId); if (info == nullptr) { return std::make_shared(nullptr, 0, 0); } auto nativeInfo = info->getNativeInfo(); return std::make_shared(nativeInfo.nativeSurface, nativeInfo.width, - nativeInfo.height); + nativeInfo.height, + std::move(nativeInfo.nativeSurfaceOwner)); } // Retires a canvas context from the JS side (Canvas unmount cleanup). @@ -226,12 +266,16 @@ class RNWebGPU : public NativeObject { // find-or-create + attach (SurfaceRegistry::attachSurface), so a concurrent // attach can never be orphaned by this removal. void destroyContext(int contextId) { + if (!_sessionState) { + return; + } auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - registry.removeSurfaceInfoIfDetached(contextId); + registry.removeSurfaceInfoIfDetached(_sessionState->id(), contextId); } static void definePrototype(jsi::Runtime &runtime, jsi::Object &prototype) { installGetter(runtime, prototype, "fabric", &RNWebGPU::getFabric); + installGetter(runtime, prototype, "sessionId", &RNWebGPU::getSessionId); installGetter(runtime, prototype, "gpu", &RNWebGPU::getGPU); installMethod(runtime, prototype, "createImageBitmap", &RNWebGPU::createImageBitmap); @@ -254,9 +298,89 @@ class RNWebGPU : public NativeObject { } private: + static constexpr const char *kInactiveSessionError = + "WebGPU runtime session is no longer active"; + + void ensureSessionActive() const { + if (!_sessionState || !_sessionState->isActive()) { + throw std::runtime_error(kInactiveSessionError); + } + } + + static void settleImageBitmapSuccess( + const std::shared_ptr &callInvoker, + const std::shared_ptr &promise, + const std::shared_ptr &sessionState, + ImageData imageData) noexcept { + try { + callInvoker->invokeAsync( + [promise, sessionState, + imageData = std::move(imageData)](jsi::Runtime &runtime) mutable { + if (!promise->belongsToRuntime(&runtime)) { + return; + } + if (!sessionState || !sessionState->isActive()) { + rejectPromiseNoexcept(promise, kInactiveSessionError); + return; + } + try { + auto imageBitmap = + std::make_shared(std::move(imageData)); + promise->resolveWith([imageBitmap](jsi::Runtime &owningRuntime) { + return JSIConverter>::toJSI( + owningRuntime, imageBitmap); + }); + } catch (const std::exception &error) { + rejectPromiseNoexcept(promise, error.what()); + } catch (...) { + rejectPromiseNoexcept(promise, "Failed to create ImageBitmap"); + } + }); + } catch (...) { + settleImageBitmapError(callInvoker, promise, sessionState, + "Failed to dispatch ImageBitmap result"); + } + } + + static void settleImageBitmapError( + const std::shared_ptr &callInvoker, + const std::shared_ptr &promise, + const std::shared_ptr &sessionState, + std::string error) noexcept { + try { + callInvoker->invokeAsync( + [promise, sessionState, + error = std::move(error)](jsi::Runtime &runtime) mutable { + if (!promise->belongsToRuntime(&runtime)) { + return; + } + if (!sessionState || !sessionState->isActive()) { + rejectPromiseNoexcept(promise, kInactiveSessionError); + return; + } + rejectPromiseNoexcept(promise, std::move(error)); + }); + } catch (...) { + // No background thread may destroy the Promise's JSI functions. Its + // PromiseRuntimeContext remains the final cleanup fallback if dispatch + // itself cannot allocate a callback. + } + } + + static void rejectPromiseNoexcept(const std::shared_ptr &promise, + std::string error) noexcept { + try { + promise->reject(std::move(error)); + } catch (...) { + // A CallInvoker callback must not propagate through the runtime loop. + } + } + std::shared_ptr _gpu; std::shared_ptr _platformContext; std::shared_ptr _callInvoker; + std::shared_ptr _sessionState; + const jsi::Runtime *_owningRuntime; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp index e6ca59285d..fee88b5389 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp @@ -1,5 +1,6 @@ #include "AsyncTaskHandle.h" +#include #include #include #include @@ -11,151 +12,278 @@ namespace rnwgpu::async { +namespace { + using Action = std::function; -struct AsyncTaskHandle::State +} // namespace + +struct AsyncTaskHandle::State final : public std::enable_shared_from_this { - State(std::shared_ptr context, bool keepPumping) + State(std::weak_ptr context, bool keepPumping) noexcept : context(std::move(context)), keepPumping(keepPumping) {} - void settle(Action action); - void attachPromise(const std::shared_ptr &promise); - void schedule(Action action); + void settle(Action action) noexcept; + void attachPromise(const std::shared_ptr &newPromise); + void schedule(Action action) noexcept; + void + runScheduled(Action &action, + const std::shared_ptr &promiseRef) noexcept; + void cancel() noexcept; + void finish() noexcept; ResolveFunction createResolveFunction(); RejectFunction createRejectFunction(); - std::shared_ptr currentPromise(); - std::mutex mutex; - std::shared_ptr context; - bool keepPumping; + std::weak_ptr context; + const bool keepPumping; std::shared_ptr promise; std::optional pendingAction; - bool settled = false; - std::shared_ptr keepAlive; + bool settled{false}; + bool finished{false}; + bool cancelled{false}; + bool pumpStarted{false}; }; -// MARK: - State helpers +void AsyncTaskHandle::State::settle(Action action) noexcept { + if (!action) { + return; + } -void AsyncTaskHandle::State::settle(Action action) { - std::optional actionToSchedule; + try { + std::optional actionToSchedule; + { + std::lock_guard lock(mutex); + if (settled || cancelled || finished) { + return; + } + settled = true; + if (promise) { + actionToSchedule = std::move(action); + } else { + pendingAction = std::move(action); + } + } - { - std::lock_guard lock(mutex); - if (settled) { + if (actionToSchedule.has_value()) { + schedule(std::move(*actionToSchedule)); + } + } catch (...) { + // Dawn callbacks must never receive a C++ exception. + } +} + +void AsyncTaskHandle::State::attachPromise( + const std::shared_ptr &newPromise) { + if (!newPromise) { + cancel(); + return; + } + + auto contextRef = context.lock(); + if (!contextRef || + !newPromise->belongsToRuntime(contextRef->runtimeIdentity())) { + newPromise->reject( + "Asynchronous WebGPU objects must be used on their calling runtime"); + cancel(); + return; + } + + if (keepPumping) { + try { + if (!contextRef->beginPumping()) { + newPromise->reject("WebGPU runtime was invalidated"); + cancel(); + return; + } + } catch (const std::exception &exception) { + newPromise->reject(exception.what()); + cancel(); + return; + } catch (...) { + newPromise->reject("Failed to start the WebGPU event pump"); + cancel(); return; } - settled = true; + } - if (promise) { - actionToSchedule = std::move(action); + std::optional actionToRun; + bool rollbackPump = false; + bool unavailable = false; + { + std::lock_guard lock(mutex); + if (cancelled || finished) { + rollbackPump = keepPumping; + unavailable = true; } else { - pendingAction = std::move(action); + pumpStarted = keepPumping; + promise = newPromise; + if (pendingAction.has_value()) { + actionToRun = std::move(pendingAction); + pendingAction.reset(); + } } } - if (actionToSchedule.has_value()) { - schedule(std::move(*actionToSchedule)); + if (rollbackPump) { + contextRef->onTaskSettled(/*keepPumping=*/true); + } + if (unavailable) { + newPromise->reject("WebGPU runtime was invalidated"); + return; + } + + if (actionToRun.has_value()) { + // The Promise executor runs on the owning runtime. A Dawn operation that + // completed synchronously before attachment can settle directly here. + runScheduled(*actionToRun, newPromise); } } -void AsyncTaskHandle::State::attachPromise( - const std::shared_ptr &newPromise) { - std::optional actionToSchedule; +void AsyncTaskHandle::State::schedule(Action action) noexcept { + try { + std::shared_ptr promiseRef; + { + std::lock_guard lock(mutex); + if (cancelled || finished || !promise) { + return; + } + promiseRef = promise; + } + + auto contextRef = context.lock(); + if (!contextRef || !contextRef->isValid()) { + return; + } + + auto self = shared_from_this(); + if (!keepPumping) { + const auto invoker = contextRef->callInvoker(); + if (invoker) { + invoker->invokeAsync( + [self, action = std::move(action), promiseRef]() mutable { + self->runScheduled(action, promiseRef); + }); + } + // Worklet runtimes have no native dispatcher for spontaneous events. + // Keep the task runtime-owned so teardown can release it safely. + return; + } + + contextRef->postSettle( + [self, action = std::move(action), promiseRef]() mutable { + self->runScheduled(action, promiseRef); + }); + } catch (...) { + // Allocation/dispatcher failures must not unwind through a Dawn callback. + } +} + +void AsyncTaskHandle::State::runScheduled( + Action &action, + const std::shared_ptr &promiseRef) noexcept { { std::lock_guard lock(mutex); - promise = newPromise; - keepAlive = shared_from_this(); - if (pendingAction.has_value()) { - actionToSchedule = std::move(pendingAction); - pendingAction.reset(); + if (cancelled || finished) { + return; } } - if (actionToSchedule.has_value()) { - schedule(std::move(*actionToSchedule)); + const auto contextRef = context.lock(); + if (!contextRef) { + finish(); + return; } -} -void AsyncTaskHandle::State::schedule(Action action) { - auto promiseRef = currentPromise(); - if (!promiseRef) { + bool ranOnOwningRuntime = false; + try { + ranOnOwningRuntime = contextRef->withRuntime([&](jsi::Runtime &runtime) { + try { + action(runtime, *promiseRef); + } catch (const std::exception &exception) { + promiseRef->reject(exception.what()); + } catch (...) { + promiseRef->reject( + "Unknown native error while settling WebGPU Promise"); + } + }); + } catch (...) { + // CallInvoker/timer callbacks must never propagate a native exception. + } + + if (!ranOnOwningRuntime) { + // This callback is already executing on the task's owning runtime thread. + // If the session became inactive before the callback ran, release the JSI + // resolver/rejecter before unregistering the task. finish() alone would + // leave PromiseRuntimeContext as the sole owner until runtime destruction. + cancel(); return; } + finish(); +} - if (!context) { - // No context (shouldn't happen): best-effort inline settle. - action(promiseRef->runtime, *promiseRef); +void AsyncTaskHandle::State::cancel() noexcept { + std::shared_ptr promiseToInvalidate; + { std::lock_guard lock(mutex); - keepAlive.reset(); - return; + if (cancelled || finished) { + return; + } + cancelled = true; + promiseToInvalidate = std::move(promise); + pendingAction.reset(); } - auto self = shared_from_this(); - - if (!keepPumping) { - // Spontaneous task (e.g. device.lost): not driven by the ProcessEvents pump. - // Settle on the owning runtime's JS thread via its CallInvoker, which is - // wired only for the main JS runtime. A device created on a worklet runtime - // has no invoker, so its device.lost is dropped (best-effort; see the README - // "Threading model"). invokeAsync runs the closure on the main JS thread, - // where promiseRef->runtime lives for a main-runtime device. - auto invoker = context->callInvoker(); - if (invoker) { - invoker->invokeAsync( - [self, action = std::move(action), promiseRef]() mutable { - action(promiseRef->runtime, *promiseRef); - std::lock_guard lock(self->mutex); - self->keepAlive.reset(); - }); - } else { - std::lock_guard lock(mutex); - keepAlive.reset(); + // cancel() is called by RuntimeContext's runtimeData teardown path, so this + // is the owning runtime thread and JSI function destruction is safe. + if (promiseToInvalidate) { + promiseToInvalidate->invalidate(); + } + finish(); +} + +void AsyncTaskHandle::State::finish() noexcept { + std::shared_ptr contextRef; + bool decrementPump = false; + { + std::lock_guard lock(mutex); + if (finished) { + return; } - return; + finished = true; + decrementPump = keepPumping && pumpStarted; + promise.reset(); + pendingAction.reset(); + contextRef = context.lock(); } - // Pumping task (request/response op). The resolve/reject callback may fire on - // a thread that is NOT the owning runtime's thread: with a shared - // wgpu::Instance, another runtime's ProcessEvents() pump can consume this Dawn - // event. Touching the Promise's runtime off-thread would corrupt Hermes. So we - // deposit the actual settle (the only JSI-touching work) into the owning - // context's mailbox; the context drains it on its own thread during its next - // tick. The deposited closure captures only C++ state and runs no JSI until - // drained, so depositing from any thread is safe. - context->postSettle( - [self, action = std::move(action), promiseRef]() mutable { - action(promiseRef->runtime, *promiseRef); - if (self->context) { - self->context->onTaskSettled(/*keepPumping=*/true); - } - std::lock_guard lock(self->mutex); - self->keepAlive.reset(); - }); + if (contextRef) { + contextRef->onTaskSettled(decrementPump); + contextRef->unregisterTask(this); + } } AsyncTaskHandle::ResolveFunction AsyncTaskHandle::State::createResolveFunction() { - auto weakSelf = std::weak_ptr(shared_from_this()); + const auto weakSelf = std::weak_ptr(shared_from_this()); return [weakSelf](ValueFactory factory) { if (auto self = weakSelf.lock()) { ValueFactory resolvedFactory = - factory ? std::move(factory) : [](jsi::Runtime &runtime) { + factory ? std::move(factory) : [](jsi::Runtime & /*runtime*/) { return jsi::Value::undefined(); }; self->settle( [factory = std::move(resolvedFactory)]( jsi::Runtime &runtime, rnwgpu::Promise &promise) mutable { - auto value = factory(runtime); - promise.resolve(std::move(value)); + promise.resolve(jsi::Value(factory(runtime))); }); } }; } AsyncTaskHandle::RejectFunction AsyncTaskHandle::State::createRejectFunction() { - auto weakSelf = std::weak_ptr(shared_from_this()); + const auto weakSelf = std::weak_ptr(shared_from_this()); return [weakSelf](std::string reason) { if (auto self = weakSelf.lock()) { self->settle([reason = std::move(reason)](jsi::Runtime & /*runtime*/, @@ -166,25 +294,29 @@ AsyncTaskHandle::RejectFunction AsyncTaskHandle::State::createRejectFunction() { }; } -std::shared_ptr AsyncTaskHandle::State::currentPromise() { - std::lock_guard lock(mutex); - return promise; -} - -// MARK: - AsyncTaskHandle - -AsyncTaskHandle::AsyncTaskHandle() = default; - AsyncTaskHandle::AsyncTaskHandle(std::shared_ptr state) : _state(std::move(state)) {} -bool AsyncTaskHandle::valid() const { return _state != nullptr; } +bool AsyncTaskHandle::valid() const noexcept { return _state != nullptr; } AsyncTaskHandle AsyncTaskHandle::create(const std::shared_ptr &context, bool keepPumping) { + if (!context) { + return {}; + } + auto state = std::make_shared(context, keepPumping); - state->keepAlive = state; + const auto weakState = std::weak_ptr(state); + const bool registered = + context->registerTask(state.get(), state, [weakState]() { + if (const auto task = weakState.lock()) { + task->cancel(); + } + }); + if (!registered) { + return {}; + } return AsyncTaskHandle(std::move(state)); } @@ -210,4 +342,10 @@ void AsyncTaskHandle::attachPromise( } } +void AsyncTaskHandle::cancel() const noexcept { + if (_state) { + _state->cancel(); + } +} + } // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h index fea16c0f63..e2bde22505 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h +++ b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h @@ -1,13 +1,12 @@ #pragma once #include +#include #include #include #include #include -#include - namespace rnwgpu { class Promise; } @@ -17,15 +16,13 @@ namespace rnwgpu::async { class RuntimeContext; /** - * Represents a pending asynchronous WebGPU operation that can be converted into - * a JavaScript Promise. + * Native state for one asynchronous WebGPU operation. * - * In the ProcessEvents model the resolve/reject callbacks are invoked on the - * owning runtime's own thread (synchronously from instance.ProcessEvents() - * during the RuntimeContext tick, or synchronously from postTask), so the - * Promise is settled directly without any thread marshalling. + * RuntimeContext owns pending states. A State references its context weakly, + * so there is no ownership cycle; runtimeData teardown cancels every state and + * releases its Promise's JSI functions before the runtime disappears. */ -class AsyncTaskHandle { +class AsyncTaskHandle final { public: struct State; @@ -34,19 +31,14 @@ class AsyncTaskHandle { using ResolveFunction = std::function; using RejectFunction = std::function; - AsyncTaskHandle(); - - /** - * Internal constructor used by RuntimeContext. - */ + AsyncTaskHandle() = default; explicit AsyncTaskHandle(std::shared_ptr state); - bool valid() const; - + bool valid() const noexcept; ResolveFunction createResolveFunction() const; RejectFunction createRejectFunction() const; - void attachPromise(const std::shared_ptr &promise) const; + void cancel() const noexcept; static AsyncTaskHandle create(const std::shared_ptr &context, bool keepPumping); diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp index f21d6ffac6..80c42e8bd2 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include @@ -12,77 +14,482 @@ namespace rnwgpu::async { namespace { -struct RuntimeData { + +struct RuntimeData final { + explicit RuntimeData(std::shared_ptr context) + : context(std::move(context)) {} + + ~RuntimeData() { + if (context) { + context->invalidate(); + } + } + std::shared_ptr context; }; -constexpr const char *TAG = "RuntimeContext"; - -// The main JS runtime and its CallInvoker, registered once on install. The -// context created for sMainRuntime gets sMainInvoker; spontaneous events -// (device.lost) on a main-runtime device are delivered through it without the -// pump. Worklet runtimes have no invoker (best-effort, see the header doc). -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. + +struct MainRuntimeRegistration final { + jsi::Runtime *runtime{nullptr}; + std::shared_ptr invoker; + std::weak_ptr context; + const RuntimeContext *contextIdentity{nullptr}; +}; + +struct MainRuntimeRegistry final { + std::mutex mutex; + std::unordered_map registrations; +}; + +MainRuntimeRegistry &mainRuntimeRegistry() { + // Runtime teardown may run during process-wide static destruction. + static auto *registry = new MainRuntimeRegistry(); + return *registry; +} + +void unregisterRuntime(jsi::Runtime *runtimeIdentity, + const RuntimeContext *contextIdentity) noexcept { + if (runtimeIdentity == nullptr && contextIdentity == nullptr) { + return; + } + + auto ®istry = mainRuntimeRegistry(); + // Move owning fields out before erasing so CallInvoker destructors never run + // under the registry mutex. Reserve can fail only under extreme memory + // pressure; the fallback still removes every stale identity atomically, but + // may release an invoker while holding the mutex. + std::vector removed; + { + std::lock_guard lock(registry.mutex); + std::size_t matchingCount = 0; + for (const auto &[sessionId, registration] : registry.registrations) { + (void)sessionId; + const bool matchesRuntime = + runtimeIdentity != nullptr && registration.runtime == runtimeIdentity; + const bool matchesContext = + contextIdentity != nullptr && + registration.contextIdentity == contextIdentity; + if (matchesRuntime || matchesContext) { + ++matchingCount; + } + } + + bool canMoveOut = true; + try { + removed.reserve(matchingCount); + } catch (...) { + canMoveOut = false; + } + + for (auto it = registry.registrations.begin(); + it != registry.registrations.end();) { + const bool matchesRuntime = + runtimeIdentity != nullptr && it->second.runtime == runtimeIdentity; + const bool matchesContext = contextIdentity != nullptr && + it->second.contextIdentity == contextIdentity; + if (!matchesRuntime && !matchesContext) { + ++it; + continue; + } + if (canMoveOut) { + removed.push_back(std::move(it->second)); + } + it = registry.registrations.erase(it); + } + } +} + std::mutex &processEventsMutex() { - static std::mutex mutex; - return mutex; + static auto *mutex = new std::mutex(); + return *mutex; } + +constexpr const char *kLogTag = "RuntimeContext"; + } // namespace void RuntimeContext::registerMainRuntime( - jsi::Runtime *runtime, + RNWebGPUSessionId sessionId, jsi::Runtime *runtime, std::shared_ptr invoker) { - sMainRuntime = runtime; - sMainInvoker = std::move(invoker); + if (sessionId == kInvalidRNWebGPUSessionId || runtime == nullptr || + !invoker) { + throw std::invalid_argument( + "Main WebGPU runtime registration requires a valid session, runtime, " + "and CallInvoker"); + } + + // This is the only JSI operation below. Perform it before publishing the + // process-wide registration so an exception cannot leave a stale raw + // runtime identity behind. + const auto context = get(*runtime); + + auto ®istry = mainRuntimeRegistry(); + MainRuntimeRegistration newRegistration{ + runtime, std::move(invoker), {}, nullptr}; + MainRuntimeRegistration oldRegistration; + std::shared_ptr replacedContext; + { + std::lock_guard lock(registry.mutex); + const auto existing = registry.registrations.find(sessionId); + if (existing != registry.registrations.end()) { + replacedContext = existing->second.context.lock(); + oldRegistration = std::move(existing->second); + existing->second = std::move(newRegistration); + } else { + registry.registrations.emplace(sessionId, std::move(newRegistration)); + } + } + + // registerMainRuntime is called by module installation on the JS thread, so + // it may safely inspect runtimeData. Only bind a context created for this + // exact session; a persistent runtime may still contain the previous + // session until the first operation from the newly installed GPU replaces + // it. + if (context) { + std::shared_ptr dispatcher; + { + std::lock_guard lock(registry.mutex); + const auto current = registry.registrations.find(sessionId); + if (current != registry.registrations.end() && + current->second.runtime == runtime) { + dispatcher = current->second.invoker; + } + } + bool contextMatchesSession = false; + if (dispatcher) { + std::lock_guard lock(context->_lifecycleMutex); + if (context->_runtime == runtime && context->_sessionState && + context->_sessionState->id() == sessionId && + context->_sessionState->isActive()) { + context->_mainSessionId = sessionId; + context->_callInvoker = std::move(dispatcher); + contextMatchesSession = true; + } + } + + if (contextMatchesSession && context->ownsRuntime(*runtime)) { + std::lock_guard lock(registry.mutex); + const auto current = registry.registrations.find(sessionId); + if (current != registry.registrations.end() && + current->second.runtime == runtime) { + current->second.context = context; + current->second.contextIdentity = context.get(); + } + } + } + + if (replacedContext && replacedContext != context) { + replacedContext->detachMainRuntime(sessionId); + } } -RuntimeContext::RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance) - : _runtime(runtime), _instance(std::move(instance)) { - Logger::logToConsole("[%s] Created (runtime=%p)", TAG, &runtime); +void RuntimeContext::unregisterMainRuntime( + RNWebGPUSessionId sessionId) noexcept { + if (sessionId == kInvalidRNWebGPUSessionId) { + return; + } + + auto ®istry = mainRuntimeRegistry(); + std::shared_ptr context; + MainRuntimeRegistration removedRegistration; + { + std::lock_guard lock(registry.mutex); + const auto registration = registry.registrations.find(sessionId); + if (registration == registry.registrations.end()) { + return; + } + removedRegistration = std::move(registration->second); + context = removedRegistration.context.lock(); + registry.registrations.erase(registration); + } + + if (context) { + context->detachMainRuntime(sessionId); + const auto weakContext = std::weak_ptr(context); + if (removedRegistration.invoker) { + try { + removedRegistration.invoker->invokeAsync( + [weakContext, sessionId](jsi::Runtime &runtime) noexcept { + const auto context = weakContext.lock(); + if (!context || !context->ownsRuntime(runtime)) { + return; + } + const auto state = context->sessionState(); + if (state && state->id() == sessionId && !state->isActive()) { + // This callback executes on the owning JSI thread, so it can + // safely release Promise/listener values retained by the old + // session. Do not purge registrations by runtime identity: a + // newer session may already use the same persistent runtime. + context->invalidateForReplacement(); + } + }); + } catch (...) { + // A queued pump tick, runtimeData teardown, or the next session's + // getOrCreate() will perform the same owning-thread cleanup. + } + } + } } +RuntimeContext::RuntimeContext( + jsi::Runtime &runtime, wgpu::Instance instance, + std::shared_ptr sessionState) + : _runtime(&runtime), _instance(std::move(instance)), + _sessionState(std::move(sessionState)) { + if (!_sessionState || !_sessionState->isActive()) { + throw std::invalid_argument( + "RuntimeContext requires an active WebGPU session"); + } + Logger::logToConsole("[%s] Created", kLogTag); +} + +RuntimeContext::~RuntimeContext() { invalidate(); } + std::shared_ptr RuntimeContext::get(jsi::Runtime &runtime) { - auto data = runtime.getRuntimeData(runtimeDataUUID()); + const auto data = runtime.getRuntimeData(runtimeDataUUID()); if (!data) { return nullptr; } return std::static_pointer_cast(data)->context; } -std::shared_ptr -RuntimeContext::getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance) { +std::shared_ptr RuntimeContext::getOrCreate( + jsi::Runtime &runtime, wgpu::Instance instance, + std::shared_ptr sessionState) { + if (!sessionState || !sessionState->isActive()) { + throw jsi::JSError(runtime, "WebGPU runtime session is no longer active"); + } + if (!instance) { + throw std::runtime_error( + "Cannot bind a WebGPU runtime to an invalid Dawn Instance"); + } + const auto sessionId = sessionState->id(); + if (auto existing = get(runtime)) { - return existing; + bool hasRuntime = false; + bool valid = false; + bool ownsRuntime = false; + bool sameInstance = false; + bool sameSession = false; + { + std::lock_guard lock(existing->_lifecycleMutex); + hasRuntime = existing->_runtime != nullptr; + valid = hasRuntime && existing->_sessionState && + existing->_sessionState->isActive(); + ownsRuntime = existing->_runtime == &runtime; + sameInstance = hasRuntime && existing->_instance.Get() == instance.Get(); + sameSession = hasRuntime && existing->_sessionState == sessionState; + } + + if (hasRuntime && !ownsRuntime) { + throw std::logic_error( + "WebGPU runtimeData contains a context owned by another runtime"); + } + if (valid && sameInstance && sameSession) { + return existing; + } + + // A persistent JSI runtime can survive a native module reinstall while + // the manager creates a new Dawn Instance. Allocate every replacement + // owner before invalidating the old context, then clear old JSI state on + // this owning runtime thread and atomically overwrite runtimeData. + auto replacement = std::make_shared( + runtime, std::move(instance), std::move(sessionState)); + auto replacementData = std::make_shared(replacement); + existing->invalidateForReplacement(); + try { + runtime.setRuntimeData(runtimeDataUUID(), replacementData); + } catch (...) { + // The old context is now terminal. Do not leave its raw identity in the + // main-runtime registry if runtimeData replacement itself failed. + unregisterRuntime(&runtime, existing.get()); + throw; + } + + auto ®istry = mainRuntimeRegistry(); + { + std::lock_guard lock(registry.mutex); + const auto selected = registry.registrations.find(sessionId); + if (selected != registry.registrations.end()) { + if (selected->second.runtime == &runtime) { + replacement->_mainSessionId = sessionId; + replacement->_callInvoker = selected->second.invoker; + selected->second.context = replacement; + selected->second.contextIdentity = replacement.get(); + } + } + } + return replacement; } - auto context = std::make_shared(runtime, std::move(instance)); - // Only the main JS runtime's context carries the CallInvoker; it is used to - // deliver spontaneous events (device.lost) without the pump. - if (&runtime == sMainRuntime) { - context->_callInvoker = sMainInvoker; + + auto context = std::make_shared(runtime, std::move(instance), + std::move(sessionState)); + runtime.setRuntimeData(runtimeDataUUID(), + std::make_shared(context)); + + auto ®istry = mainRuntimeRegistry(); + { + std::lock_guard lock(registry.mutex); + const auto selected = registry.registrations.find(sessionId); + if (selected != registry.registrations.end()) { + if (selected->second.runtime == &runtime) { + context->_mainSessionId = sessionId; + context->_callInvoker = selected->second.invoker; + selected->second.context = context; + selected->second.contextIdentity = context.get(); + } + } } - auto data = std::make_shared(); - data->context = context; - runtime.setRuntimeData(runtimeDataUUID(), data); return context; } +void RuntimeContext::invalidate() noexcept { invalidateImpl(true); } + +void RuntimeContext::invalidateForReplacement() noexcept { + invalidateImpl(false); + // A persistent runtime can already be registered for its next session. + // Remove only entries that still point at this exact context, never every + // entry sharing the same runtime address. + unregisterRuntime(nullptr, this); +} + +void RuntimeContext::invalidateImpl(bool purgeRegistrations) noexcept { + jsi::Runtime *runtimeIdentity = nullptr; + wgpu::Instance instance; + { + std::lock_guard lock(_lifecycleMutex); + if (_runtime == nullptr) { + return; + } + + runtimeIdentity = _runtime; + _runtime = nullptr; + _mainSessionId = kInvalidRNWebGPUSessionId; + _callInvoker.reset(); + _tickScheduled.store(false, std::memory_order_release); + _pumpTasks.store(0, std::memory_order_release); + instance = std::move(_instance); + } + + // Remove every generation that still names this runtime/context while its + // address is unambiguously ours. Old manager teardown then becomes a no-op, + // and a future Runtime allocated at the same address cannot inherit an old + // CallInvoker. unregisterRuntime never dereferences runtimeIdentity and does + // not acquire any RuntimeContext lifecycle lock. + if (purgeRegistrations) { + unregisterRuntime(runtimeIdentity, this); + } + + std::unordered_map> callbacks; + { + std::lock_guard lock(_invalidationCallbacksMutex); + callbacks.swap(_invalidationCallbacks); + } + for (auto &[id, callback] : callbacks) { + (void)id; + if (!callback) { + continue; + } + try { + callback(); + } catch (...) { + // RuntimeData teardown must never propagate native exceptions. + } + } + + std::vector> mailbox; + { + std::lock_guard lock(_mailboxMutex); + mailbox.swap(_mailbox); + } + // Destroy queued closures on the runtime teardown thread. Task ownership + // below keeps their Promise state alive until cancel() clears its JSI values. + mailbox.clear(); + + std::unordered_map tasks; + { + std::lock_guard lock(_tasksMutex); + tasks.swap(_activeTasks); + } + for (auto &[id, task] : tasks) { + (void)id; + if (!task.cancel) { + continue; + } + try { + task.cancel(); + } catch (...) { + // RuntimeData teardown must never propagate native exceptions. + } + } + + instance = nullptr; +} + +bool RuntimeContext::isValid() const noexcept { + std::lock_guard lock(_lifecycleMutex); + return _runtime != nullptr && _sessionState && _sessionState->isActive(); +} + +bool RuntimeContext::ownsRuntime(const jsi::Runtime &runtime) const noexcept { + std::lock_guard lock(_lifecycleMutex); + return _runtime == &runtime; +} + +const void *RuntimeContext::runtimeIdentity() const noexcept { + std::lock_guard lock(_lifecycleMutex); + return _runtime; +} + +bool RuntimeContext::withRuntime( + const std::function &action) { + if (!action) { + return false; + } + + jsi::Runtime *runtime = nullptr; + { + std::lock_guard lock(_lifecycleMutex); + if (_runtime == nullptr || !_sessionState || !_sessionState->isActive()) { + return false; + } + runtime = _runtime; + } + + // Callers are already on this JSI runtime's owning thread. RuntimeData + // teardown and replacement also execute on that thread, so the raw runtime + // cannot disappear until this action returns. Never invoke JavaScript while + // holding _lifecycleMutex (C++ Core Guidelines CP.22). + action(*runtime); + return true; +} + +std::shared_ptr +RuntimeContext::callInvoker() const noexcept { + std::lock_guard lock(_lifecycleMutex); + return _callInvoker; +} + +wgpu::Instance RuntimeContext::instance() const { + std::lock_guard lock(_lifecycleMutex); + return _instance; +} + +std::shared_ptr +RuntimeContext::sessionState() const noexcept { + std::lock_guard lock(_lifecycleMutex); + return _sessionState; +} + AsyncTaskHandle RuntimeContext::postTask(const TaskCallback &callback, bool keepPumping) { - auto handle = AsyncTaskHandle::create(shared_from_this(), keepPumping); - if (!handle.valid()) { - throw std::runtime_error("Failed to create AsyncTaskHandle."); + if (!callback || !isValid()) { + throw std::runtime_error( + "Cannot post a WebGPU task after runtime invalidation"); } - // 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). - if (keepPumping) { - _pumpTasks.fetch_add(1, std::memory_order_acq_rel); - requestTick(); + auto handle = AsyncTaskHandle::create(shared_from_this(), keepPumping); + if (!handle.valid()) { + throw std::runtime_error("Failed to create AsyncTaskHandle"); } auto resolve = handle.createResolveFunction(); @@ -92,106 +499,276 @@ AsyncTaskHandle RuntimeContext::postTask(const TaskCallback &callback, } catch (const std::exception &exception) { reject(exception.what()); } catch (...) { - reject("Unknown native error in RuntimeContext::postTask."); + reject("Unknown native error in RuntimeContext::postTask"); } return handle; } -void RuntimeContext::onTaskSettled(bool keepPumping) { - if (keepPumping) { - _pumpTasks.fetch_sub(1, std::memory_order_acq_rel); +bool RuntimeContext::beginPumping() { + { + std::lock_guard lock(_lifecycleMutex); + if (_runtime == nullptr || !_sessionState || !_sessionState->isActive()) { + return false; + } + _pumpTasks.fetch_add(1, std::memory_order_acq_rel); + } + + try { + requestTick(); + } catch (...) { + onTaskSettled(/*keepPumping=*/true); + throw; } + return true; } -void RuntimeContext::postSettle(std::function job) { +bool RuntimeContext::postSettle(std::function job) { if (!job) { - return; + return false; } - std::lock_guard lock(_mailboxMutex); + + std::lock_guard lifecycleLock(_lifecycleMutex); + if (_runtime == nullptr || !_sessionState || !_sessionState->isActive()) { + return false; + } + std::lock_guard mailboxLock(_mailboxMutex); _mailbox.push_back(std::move(job)); + return true; } -void RuntimeContext::drainMailbox() { - std::vector> jobs; +void RuntimeContext::onTaskSettled(bool keepPumping) noexcept { + if (!keepPumping) { + return; + } + + auto count = _pumpTasks.load(std::memory_order_acquire); + while (count > 0 && !_pumpTasks.compare_exchange_weak( + count, count - 1, std::memory_order_acq_rel)) { + } +} + +bool RuntimeContext::registerTask(const void *id, std::shared_ptr owner, + std::function cancel) { + if (id == nullptr || !owner || !cancel) { + return false; + } + + std::lock_guard lifecycleLock(_lifecycleMutex); + if (_runtime == nullptr || !_sessionState || !_sessionState->isActive()) { + return false; + } + std::lock_guard tasksLock(_tasksMutex); + _activeTasks.insert_or_assign( + id, ActiveTask{std::move(owner), std::move(cancel)}); + return true; +} + +void RuntimeContext::unregisterTask(const void *id) noexcept { + if (id == nullptr) { + return; + } + + std::lock_guard lock(_tasksMutex); + _activeTasks.erase(id); +} + +RuntimeContext::InvalidationCallbackId +RuntimeContext::addInvalidationCallback(std::function callback) { + if (!callback) { + return 0; + } + + std::lock_guard lifecycleLock(_lifecycleMutex); + if (_runtime == nullptr || !_sessionState || !_sessionState->isActive()) { + return 0; + } + std::lock_guard callbacksLock(_invalidationCallbacksMutex); + const auto id = _nextInvalidationCallbackId++; + if (id == 0) { + return 0; + } + _invalidationCallbacks.emplace(id, std::move(callback)); + return id; +} + +void RuntimeContext::removeInvalidationCallback( + InvalidationCallbackId id) noexcept { + if (id == 0) { + return; + } + + const auto invoker = callInvoker(); + if (!invoker) { + // Keep the callback registered. Runtime invalidation is the only remaining + // point where JSI-owned state can be destroyed on its owning thread. + return; + } + + const auto weakContext = weak_from_this(); + try { + invoker->invokeAsync([weakContext, id](jsi::Runtime &runtime) noexcept { + if (const auto context = weakContext.lock(); + context && context->ownsRuntime(runtime)) { + context->executeAndRemoveInvalidationCallback(id); + } + }); + } catch (...) { + // The callback stays registered and will be executed by invalidate(). + } +} + +void RuntimeContext::executeAndRemoveInvalidationCallback( + InvalidationCallbackId id) noexcept { + std::function callback; { - std::lock_guard lock(_mailboxMutex); - jobs.swap(_mailbox); + std::lock_guard lock(_invalidationCallbacksMutex); + const auto callbackIt = _invalidationCallbacks.find(id); + if (callbackIt == _invalidationCallbacks.end()) { + return; + } + callback = std::move(callbackIt->second); + _invalidationCallbacks.erase(callbackIt); } - // Run settle-actions on this (the owning) thread, NOT under the ProcessEvents - // mutex, so JS continuations never execute while the pump lock is held. - for (auto &job : jobs) { - job(); + + if (!callback) { + return; + } + try { + callback(); + } catch (...) { + // Cleanup dispatched from a noexcept destructor path must not escape. } } void RuntimeContext::requestTick() { + jsi::Runtime *runtime = nullptr; + { + std::lock_guard lock(_lifecycleMutex); + if (_runtime == nullptr || !_instance) { + return; + } + if (!_sessionState || !_sessionState->isActive()) { + throw std::runtime_error("WebGPU runtime session is no longer active"); + } + runtime = _runtime; + } + bool expected = false; if (!_tickScheduled.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { return; } - // 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 / - // 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. - auto self = shared_from_this(); - jsi::Runtime &rt = _runtime; - auto tickCallback = jsi::Function::createFromHostFunction( - rt, jsi::PropNameID::forAscii(rt, "RNWGPUAsyncTick"), 0, - [self](jsi::Runtime & /*runtime*/, const jsi::Value & /*thisVal*/, - const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value { - self->tick(); - return jsi::Value::undefined(); - }); - - auto global = rt.global(); - auto setTimeoutValue = global.getProperty(rt, "setTimeout"); - if (setTimeoutValue.isObject() && - setTimeoutValue.asObject(rt).isFunction(rt)) { - setTimeoutValue.asObject(rt).asFunction(rt).call( - rt, jsi::Value(rt, tickCallback), jsi::Value(0.0)); - return; - } - auto setImmediateValue = global.getProperty(rt, "setImmediate"); - if (setImmediateValue.isObject() && - setImmediateValue.asObject(rt).isFunction(rt)) { - setImmediateValue.asObject(rt).asFunction(rt).call( - rt, jsi::Value(rt, tickCallback)); - return; + try { + auto self = shared_from_this(); + jsi::Runtime &owningRuntime = *runtime; + auto callback = jsi::Function::createFromHostFunction( + owningRuntime, + jsi::PropNameID::forAscii(owningRuntime, "RNWGPUAsyncTick"), 0, + [self](jsi::Runtime & /*runtime*/, const jsi::Value & /*thisValue*/, + const jsi::Value * /*arguments*/, + size_t /*count*/) -> jsi::Value { + self->tick(); + return jsi::Value::undefined(); + }); + + auto global = owningRuntime.global(); + auto setTimeout = global.getProperty(owningRuntime, "setTimeout"); + if (setTimeout.isObject() && + setTimeout.getObject(owningRuntime).isFunction(owningRuntime)) { + setTimeout.getObject(owningRuntime).getFunction(owningRuntime).call( + owningRuntime, jsi::Value(owningRuntime, callback), jsi::Value(0.0)); + return; + } + + auto setImmediate = global.getProperty(owningRuntime, "setImmediate"); + if (setImmediate.isObject() && + setImmediate.getObject(owningRuntime).isFunction(owningRuntime)) { + setImmediate.getObject(owningRuntime).getFunction(owningRuntime).call( + owningRuntime, jsi::Value(owningRuntime, callback)); + return; + } + owningRuntime.queueMicrotask(std::move(callback)); + } catch (...) { + _tickScheduled.store(false, std::memory_order_release); + throw; } - rt.queueMicrotask(std::move(tickCallback)); } -void RuntimeContext::tick() { - _tickScheduled.store(false, std::memory_order_release); +void RuntimeContext::tick() noexcept { + wgpu::Instance instance; + bool sessionExpired = false; { - // Serialize ProcessEvents across runtimes sharing this instance. Callbacks - // fired here only deposit into mailboxes (postSettle), they do not run JS. + std::lock_guard lock(_lifecycleMutex); + if (_runtime == nullptr) { + return; + } + _tickScheduled.store(false, std::memory_order_release); + sessionExpired = !_sessionState || !_sessionState->isActive(); + if (sessionExpired) { + instance = nullptr; + } else { + instance = _instance; + } + } + + if (sessionExpired) { + // Timer callbacks execute on this context's owning runtime thread, where + // releasing pending JSI values is safe. + invalidateForReplacement(); + return; + } + + try { std::lock_guard lock(processEventsMutex()); - _instance.ProcessEvents(); + instance.ProcessEvents(); + } catch (...) { + invalidate(); + return; } - // 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. + if (!isValid()) { + // The session may have expired while ProcessEvents was running. This tick + // is already on the owning runtime thread, so finish JSI cleanup instead + // of silently stopping the pump with tracked Promises still alive. + invalidateForReplacement(); + return; + } if (_pumpTasks.load(std::memory_order_acquire) > 0) { - requestTick(); + try { + requestTick(); + } catch (...) { + invalidate(); + } + } +} + +void RuntimeContext::drainMailbox() noexcept { + std::vector> jobs; + { + std::lock_guard lock(_mailboxMutex); + jobs.swap(_mailbox); + } + for (auto &job : jobs) { + try { + job(); + } catch (...) { + // Timer callbacks must not propagate through the JSI HostFunction. + } + } +} + +void RuntimeContext::detachMainRuntime(RNWebGPUSessionId sessionId) noexcept { + std::lock_guard lock(_lifecycleMutex); + if (_mainSessionId != sessionId) { + return; } + _mainSessionId = kInvalidRNWebGPUSessionId; + _callInvoker.reset(); } jsi::UUID RuntimeContext::runtimeDataUUID() { - // Fixed random UUID identifying react-native-webgpu's runtimeData slot. - // Never use the default-constructed all-zero UUID here: Hermes backs - // runtimeData with a DenseMap that reserves the all-zero UUID as its - // empty-bucket marker (and the all-0xff UUID as its tombstone), so using - // either as a key is undefined behavior. It crashes on worklet runtimes, - // where react-native-worklets seeds runtimeData before we do. static constexpr jsi::UUID uuid(0xC06D5D2D, 0xB19B, 0x431B, 0xA89E, 0x20F8A0B6E31BULL); return uuid; diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h index cb7fd8da22..5842e86bd6 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h @@ -2,15 +2,16 @@ #include #include +#include #include +#include #include #include +#include #include -#include - #include "AsyncTaskHandle.h" - +#include "RNWebGPUSession.h" #include "webgpu/webgpu_cpp.h" namespace jsi = facebook::jsi; @@ -22,108 +23,116 @@ class CallInvoker; namespace rnwgpu::async { /** - * Per-runtime coordinator for asynchronous WebGPU operations. - * - * Each JS runtime that uses WebGPU gets its own RuntimeContext, stored in the - * runtime's runtimeData. Async Dawn operations are registered with - * CallbackMode::AllowProcessEvents and driven to completion by pumping - * `instance.ProcessEvents()` on the runtime's OWN thread via a self- - * rescheduling tick (scheduled through that runtime's setTimeout). Because - * ProcessEvents invokes the Dawn callbacks synchronously on the pumping thread, - * the JS Promise is settled directly on the owning runtime, with no background - * thread and no cross-thread hop. - * - * The pump only runs while at least one "pumping" task is outstanding, so it - * costs nothing when idle and stops cleanly. + * Runtime-owned coordinator for asynchronous WebGPU operations. * - * Spontaneous events (keepPumping = false): events that may fire at any time, - * independent of any request/response op (today only GPUDevice::getLost, whose - * Dawn callback is registered AllowSpontaneous). These are NOT driven by the - * pump. Instead their settle is marshalled onto the owning runtime's JS thread - * via that runtime's CallInvoker, which is wired only for the MAIN JS runtime - * (callInvoker()). A device created on a worklet runtime has no invoker, so its - * device.lost is best-effort and may never fire. See the README "Threading - * model" section. + * One context is stored in each JSI runtime's runtimeData. The runtimeData + * destructor invalidates the context before the runtime disappears, cancels + * every pending task, and clears all queued settle actions. Dawn callbacks + * retain only weak task/context references, so callbacks arriving later are + * harmless no-ops. * - * Shared-instance safety (mailbox): multiple runtimes may share one - * wgpu::Instance. ProcessEvents() drains the whole instance queue and fires - * callbacks on the calling thread, which may NOT be the owning runtime's thread - * for a given promise. So a settled callback never touches JSI inline; it - * deposits a settle-action (a plain C++ closure, no JSI) into the OWNING - * context's thread-safe mailbox via postSettle(), and each context drains its - * own mailbox on its own thread during tick(). ProcessEvents() itself is - * serialized across runtimes by a process-wide mutex, since concurrent - * ProcessEvents on one instance is not guaranteed reentrant. - * - * Threading contract: a RuntimeContext must only be pumped from the runtime it - * was created for. Request/response async entry points (mapAsync, - * onSubmittedWorkDone, popErrorScope, createComputePipelineAsync, - * createRenderPipelineAsync, getCompilationInfo, requestAdapter, - * requestDevice) must NOT use a context captured when the object was created — - * the object may have been boxed across to another runtime. They resolve the - * CALLING runtime's context via getOrCreate(runtime, instance) so the promise - * is settled on the thread it was created on. Spontaneous events (device.lost, - * uncapturederror) remain bound to the device's own context (best-effort, main - * runtime only; see the class doc above). + * Request/response operations always use the CALLING runtime's context. This + * preserves the cross-runtime/boxed-object contract: a Promise is settled on + * the same runtime that created it, not necessarily the runtime that created + * the WebGPU native object. */ -class RuntimeContext : public std::enable_shared_from_this { +class RuntimeContext final + : public std::enable_shared_from_this { public: using TaskCallback = std::function; - RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance); + RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance, + std::shared_ptr sessionState); + ~RuntimeContext(); + + RuntimeContext(const RuntimeContext &) = delete; + RuntimeContext &operator=(const RuntimeContext &) = delete; + RuntimeContext(RuntimeContext &&) = delete; + RuntimeContext &operator=(RuntimeContext &&) = delete; static std::shared_ptr get(jsi::Runtime &runtime); - static std::shared_ptr getOrCreate(jsi::Runtime &runtime, - wgpu::Instance instance); + static std::shared_ptr + getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance, + std::shared_ptr sessionState); - // Register the main JS runtime and its CallInvoker. The RuntimeContext created - // for this runtime gets the invoker (callInvoker() returns it); every other - // runtime's context returns null. Called once from RNWebGPUManager on install. static void - registerMainRuntime(jsi::Runtime *runtime, + registerMainRuntime(RNWebGPUSessionId sessionId, jsi::Runtime *runtime, std::shared_ptr invoker); + static void unregisterMainRuntime(RNWebGPUSessionId sessionId) noexcept; + + void invalidate() noexcept; + bool isValid() const noexcept; + bool ownsRuntime(const jsi::Runtime &runtime) const noexcept; + const void *runtimeIdentity() const noexcept; - // CallInvoker for this runtime's JS thread, or null. Non-null only for the - // main JS runtime; used to deliver spontaneous events (device.lost) without - // the pump. See the class doc. - const std::shared_ptr &callInvoker() const { - return _callInvoker; - } + /** Call only from this context's owning runtime thread. */ + bool withRuntime(const std::function &action); - // The wgpu::Instance bound to this runtime. - wgpu::Instance instance() const { return _instance; } + std::shared_ptr callInvoker() const noexcept; + wgpu::Instance instance() const; + std::shared_ptr sessionState() const noexcept; AsyncTaskHandle postTask(const TaskCallback &callback, bool keepPumping = true); - // Deposit a settle-action to run on THIS context's runtime thread. Thread-safe - // (callable from any thread, e.g. another runtime that pumped ProcessEvents). - // The job must not touch JSI until it runs (it runs during drainMailbox on the - // owning thread). - void postSettle(std::function job); + bool beginPumping(); + bool postSettle(std::function job); + void onTaskSettled(bool keepPumping) noexcept; - // Invoked by a drained settle-action when its task settles. Runs on the owning - // runtime's thread. - void onTaskSettled(bool keepPumping); + /** RuntimeContext owns each task until it settles or runtimeData tears down. + */ + bool registerTask(const void *id, std::shared_ptr owner, + std::function cancel); + void unregisterTask(const void *id) noexcept; + + using InvalidationCallbackId = std::uint64_t; + InvalidationCallbackId + addInvalidationCallback(std::function callback); + + /** + * Schedule removal and execution of an invalidation callback on this + * context's owning runtime thread. If dispatch is unavailable, the callback + * remains registered and invalidate() executes it during runtime teardown. + * Safe to call from any thread. + */ + void removeInvalidationCallback(InvalidationCallbackId id) noexcept; private: static jsi::UUID runtimeDataUUID(); + void invalidateForReplacement() noexcept; + void invalidateImpl(bool purgeRegistrations) noexcept; void requestTick(); - void tick(); - void drainMailbox(); + void tick() noexcept; + void drainMailbox() noexcept; + void detachMainRuntime(RNWebGPUSessionId sessionId) noexcept; + void executeAndRemoveInvalidationCallback(InvalidationCallbackId id) noexcept; - jsi::Runtime &_runtime; + mutable std::recursive_mutex _lifecycleMutex; + jsi::Runtime *_runtime; wgpu::Instance _instance; - // Non-null only for the main JS runtime's context (see registerMainRuntime). + std::shared_ptr _sessionState; + RNWebGPUSessionId _mainSessionId{kInvalidRNWebGPUSessionId}; std::shared_ptr _callInvoker; std::atomic _pumpTasks{0}; std::atomic _tickScheduled{false}; std::mutex _mailboxMutex; std::vector> _mailbox; + + struct ActiveTask final { + std::shared_ptr owner; + std::function cancel; + }; + std::mutex _tasksMutex; + std::unordered_map _activeTasks; + + std::mutex _invalidationCallbacksMutex; + std::unordered_map> + _invalidationCallbacks; + InvalidationCallbackId _nextInvalidationCallbackId{1}; }; } // namespace rnwgpu::async diff --git a/packages/webgpu/src/Canvas.tsx b/packages/webgpu/src/Canvas.tsx index d19ccdf1e6..5f7aa8c5ef 100644 --- a/packages/webgpu/src/Canvas.tsx +++ b/packages/webgpu/src/Canvas.tsx @@ -94,6 +94,7 @@ export const Canvas = ({ transparent, ref, ...props }: CanvasProps) => { diff --git a/packages/webgpu/src/WebGPUViewNativeComponent.ts b/packages/webgpu/src/WebGPUViewNativeComponent.ts index 63c14ddb16..6cbbd2da7b 100644 --- a/packages/webgpu/src/WebGPUViewNativeComponent.ts +++ b/packages/webgpu/src/WebGPUViewNativeComponent.ts @@ -1,8 +1,9 @@ import { codegenNativeComponent } from "react-native"; -import type { Int32 } from "react-native/Libraries/Types/CodegenTypes"; +import type { Double, Int32 } from "react-native/Libraries/Types/CodegenTypes"; import type { ViewProps } from "react-native"; export interface NativeProps extends ViewProps { + sessionId: Double; contextId: Int32; transparent: boolean; } diff --git a/packages/webgpu/src/WebGPUViewNativeComponent.web.ts b/packages/webgpu/src/WebGPUViewNativeComponent.web.ts index af77bdb847..f84eabbe5b 100644 --- a/packages/webgpu/src/WebGPUViewNativeComponent.web.ts +++ b/packages/webgpu/src/WebGPUViewNativeComponent.web.ts @@ -1,11 +1,12 @@ import React, { useEffect, useRef } from "react"; import { StyleSheet } from "react-native"; -import type { Int32 } from "react-native/Libraries/Types/CodegenTypes"; +import type { Double, Int32 } from "react-native/Libraries/Types/CodegenTypes"; import type { ViewProps } from "react-native"; import { contextIdToId } from "./utils"; export interface NativeProps extends ViewProps { + sessionId: Double; contextId: Int32; transparent: boolean; } @@ -54,7 +55,13 @@ function resizeCanvas(canvas: HTMLCanvasElement | null) { export default function WebGPUViewNativeComponent( props: NativeProps, ): React.JSX.Element { - const { contextId, style, transparent, ...rest } = props; + const { + contextId, + sessionId: _sessionId, + style, + transparent, + ...rest + } = props; const canvasElm = useRef(); diff --git a/packages/webgpu/src/WebPolyfillGPUModule.ts b/packages/webgpu/src/WebPolyfillGPUModule.ts index 2448533c7b..4777377f08 100644 --- a/packages/webgpu/src/WebPolyfillGPUModule.ts +++ b/packages/webgpu/src/WebPolyfillGPUModule.ts @@ -58,4 +58,5 @@ window.RNWebGPU = { // On web the browser owns the canvas/context lifecycle; nothing to retire. destroyContext: (_contextId: number) => {}, fabric, + sessionId: 0, }; diff --git a/packages/webgpu/src/index.tsx b/packages/webgpu/src/index.tsx index 2f89eae6ef..6d411b5dbe 100644 --- a/packages/webgpu/src/index.tsx +++ b/packages/webgpu/src/index.tsx @@ -41,6 +41,7 @@ declare global { var RNWebGPU: { gpu: GPU; fabric: boolean; + sessionId: number; getNativeSurface: (contextId: number) => NativeCanvas; MakeWebGPUCanvasContext: ( contextId: number,