From cff1ee068176441a096f16adf46bd9174820f9e6 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 7 Nov 2026 11:45:30 -0800 Subject: [PATCH] feat(ffi/objc): queue void callbacks from other threads instead of waiting A void callback from another thread whose arguments can be captured is queued to the JS thread and its native caller returns at once. Waiting deadlocks whenever the JS thread is itself blocked on that caller, e.g. a synchronous native call whose completion handler fires on a worker thread. - Only an allowlist of argument types is captured: scalars, classes, selectors, structs without pointer fields (copied), objects (retained) and blocks (copied). Everything else, and any non-void callback or setter, still waits for JS. - A JS error in a queued callback goes through the closure uncaught-error handling, like one on the JS thread; the Objective-C exception that handling raises is logged, since no native caller is left to receive it. --- NativeScript/ffi/objc/napi/Closure.mm | 231 +++++++++++++++++++++++--- 1 file changed, 212 insertions(+), 19 deletions(-) diff --git a/NativeScript/ffi/objc/napi/Closure.mm b/NativeScript/ffi/objc/napi/Closure.mm index 2c6ca998..1873f890 100644 --- a/NativeScript/ffi/objc/napi/Closure.mm +++ b/NativeScript/ffi/objc/napi/Closure.mm @@ -15,6 +15,7 @@ #include "node_api_util.h" #include "objc/message.h" +#include #include #include #include @@ -391,14 +392,30 @@ void invokeInNativeCallbackScope(napi_env env, ClosureInvoker invoke, ffi_cif* c } // A closure invoked on a thread other than the JS thread is forwarded to it -// through one threadsafe function per env, and the native caller waits for JS -// to return. +// through one threadsafe function per env. A void callback whose arguments +// can all be captured is queued and its native caller returns at once: +// waiting deadlocks whenever the JS thread is itself blocked on that caller +// (e.g. a synchronous native call whose completion handler fires on a worker +// thread). Every other call waits for JS to return. +// +// Limits of queued calls: +// - The queue is unbounded and every pending call keeps its object arguments +// retained. When JS falls behind a producer such as a capture stream, the +// retained buffers pile up and can exhaust the producer's buffer pool. +// - Object arguments are retained, not copied. JS sees the state of an object +// at the time the call runs, not when it was queued, so native code that +// recycles or mutates an object after the callback returns (a reused +// buffer, for one) shows JS the later contents. +// - A queued call has no native caller left to receive an exception. JS errors +// still go through the closure uncaught-error handling, and the Objective-C +// exception that handling raises is logged. struct ForeignThreadCall { ClosureInvoker invoke; ffi_cif* cif; void* ret; void** args; void* data; + bool queued = false; std::mutex mutex; std::condition_variable cv; @@ -406,12 +423,169 @@ void invokeInNativeCallbackScope(napi_env env, ClosureInvoker invoke, ffi_cif* c NSException* exception = nil; }; +enum class CapturedArg { Copy, Retain, CopyBlock }; + +bool isPlainValue(ffi_type* type) { + if (type->type == FFI_TYPE_POINTER) { + return false; + } + if (type->type == FFI_TYPE_STRUCT || type->type == FFI_TYPE_COMPLEX) { + for (ffi_type** element = type->elements; element != nullptr && *element != nullptr; + element++) { + if (!isPlainValue(*element)) { + return false; + } + } + } + return true; +} + +// Only values that stay valid after the native caller returns can be +// captured; anything that may point into memory the caller owns cannot. +bool capturedArgHandling(TypeConv* type, ffi_type* ffiType, CapturedArg* handling) { + switch (type->kind) { + case mdTypeAnyObject: + case mdTypeProtocolObject: + case mdTypeClassObject: + case mdTypeInstanceObject: + case mdTypeNSStringObject: + case mdTypeNSMutableStringObject: + *handling = CapturedArg::Retain; + return true; + case mdTypeBlock: + *handling = CapturedArg::CopyBlock; + return true; + case mdTypeChar: + case mdTypeSInt: + case mdTypeSShort: + case mdTypeSLong: + case mdTypeSInt64: + case mdTypeUChar: + case mdTypeUInt: + case mdTypeUShort: + case mdTypeULong: + case mdTypeUInt64: + case mdTypeFloat: + case mdTypeDouble: + case mdTypeUInt8: + case mdTypeBool: + case mdTypeUnichar: + case mdTypeF16: + case mdTypeUInt128: + case mdTypeLongDouble: + case mdTypeComplex: + case mdTypeVector: + case mdTypeExtVector: + // Classes and selectors live as long as the process. + case mdTypeClass: + case mdTypeSelector: + *handling = CapturedArg::Copy; + return true; + case mdTypeStruct: + *handling = CapturedArg::Copy; + return isPlainValue(ffiType); + default: + return false; + } +} + +// The block literal (argument 0 of a block invoke) has no TypeConv; it is not +// passed to JS. +TypeConv* foreignCallArgType(Closure* closure, bool isBlock, unsigned index) { + if (isBlock) { + if (index == 0) { + return nullptr; + } + index -= 1; + } + return index < closure->argTypes.size() ? closure->argTypes[index].get() : nullptr; +} + +bool canQueueForeignCall(Closure* closure, bool isBlock, ffi_cif* cif) { + // A setter waits, so a getter that follows on the same thread sees the value. + if (closure->returnType == nullptr || closure->returnType->kind != mdTypeVoid || + closure->isSetter) { + return false; + } + for (unsigned i = isBlock ? 1 : 0; i < cif->nargs; i++) { + TypeConv* type = foreignCallArgType(closure, isBlock, i); + CapturedArg handling; + if (type == nullptr || !capturedArgHandling(type, cif->arg_types[i], &handling)) { + return false; + } + } + return true; +} + +struct QueuedForeignThreadCall : ForeignThreadCall { + Closure* closure = nullptr; + std::vector argStorage; + std::vector argValues; + std::vector> ownedArgs; + std::max_align_t returnStorage[2]; + + QueuedForeignThreadCall(Closure* closure, ClosureInvoker invoke, bool isBlock, ffi_cif* cif, + void* sourceArgs[]) + : closure(closure) { + this->invoke = invoke; + this->cif = cif; + this->data = closure; + this->ret = returnStorage; + queued = true; + closure->retain(); + + const size_t slotSize = sizeof(std::max_align_t); + size_t slots = 0; + for (unsigned i = 0; i < cif->nargs; i++) { + slots += (cif->arg_types[i]->size + slotSize - 1) / slotSize; + } + argStorage.resize(slots > 0 ? slots : 1); + argValues.resize(cif->nargs); + + std::max_align_t* cursor = argStorage.data(); + for (unsigned i = 0; i < cif->nargs; i++) { + size_t size = cif->arg_types[i]->size; + memcpy(cursor, sourceArgs[i], size); + argValues[i] = cursor; + + TypeConv* type = foreignCallArgType(closure, isBlock, i); + CapturedArg handling = CapturedArg::Copy; + if (type != nullptr) { + capturedArgHandling(type, cif->arg_types[i], &handling); + } + void** value = (void**)cursor; + if (handling == CapturedArg::Retain && *value != nullptr) { + CFRetain((CFTypeRef)*value); + ownedArgs.emplace_back(*value, handling); + } else if (handling == CapturedArg::CopyBlock && *value != nullptr) { + *value = Block_copy(*value); + ownedArgs.emplace_back(*value, handling); + } + + cursor += (size + slotSize - 1) / slotSize; + } + args = argValues.data(); + } + + ~QueuedForeignThreadCall() { + for (auto& owned : ownedArgs) { + if (owned.second == CapturedArg::CopyBlock) { + Block_release(owned.first); + } else { + CFRelease((CFTypeRef)owned.first); + } + } + closure->release(); + } +}; + std::mutex gForeignThreadDispatchersMutex; std::unordered_map gForeignThreadDispatchers; void runForeignThreadCall(napi_env env, napi_value /*jsCallback*/, void* /*context*/, void* data) { auto call = static_cast(data); bool ran = false; + NSException* exception = nil; // env is null when the env is torn down with calls still pending. if (env != nullptr) { // The JS thread of a Node-API host has no autorelease pool of its own. @@ -419,15 +593,26 @@ void runForeignThreadCall(napi_env env, napi_value /*jsCallback*/, void* /*conte @try { call->invoke(call->cif, call->ret, call->args, call->data); ran = true; - } @catch (NSException* exception) { - call->exception = [exception retain]; + } @catch (NSException* caught) { + exception = [caught retain]; } } } + + if (call->queued) { + if (exception != nil) { + NSLog(@"Uncaught exception in a JS callback invoked from a background thread: %@", + exception); + [exception release]; + } + delete static_cast(call); + return; + } + + call->exception = exception; if (!ran && call->cif->rtype->type != FFI_TYPE_VOID) { memset(call->ret, 0, call->cif->rtype->size); } - { std::lock_guard lock(call->mutex); call->done = true; @@ -440,24 +625,32 @@ void finalizeForeignThreadDispatcher(napi_env /*env*/, void* finalizeData, void* gForeignThreadDispatchers.erase(static_cast(finalizeData)); } -void callFromForeignThread(napi_env env, ClosureInvoker invoke, ffi_cif* cif, void* ret, - void* args[], void* data) { +bool postForeignThreadCall(napi_env env, ForeignThreadCall* call) { + std::lock_guard lock(gForeignThreadDispatchersMutex); + auto it = gForeignThreadDispatchers.find(env); + return it != gForeignThreadDispatchers.end() && + napi_call_threadsafe_function(it->second, call, napi_tsfn_blocking) == napi_ok; +} + +void callFromForeignThread(Closure* closure, ClosureInvoker invoke, bool isBlock, ffi_cif* cif, + void* ret, void* args[]) { + if (canQueueForeignCall(closure, isBlock, cif)) { + auto call = new QueuedForeignThreadCall(closure, invoke, isBlock, cif, args); + if (!postForeignThreadCall(closure->env, call)) { + delete call; + } + return; + } + ForeignThreadCall call; call.invoke = invoke; call.cif = cif; call.ret = ret; call.args = args; - call.data = data; + call.data = closure; std::unique_lock lock(call.mutex); - bool posted = false; - { - std::lock_guard dispatchersLock(gForeignThreadDispatchersMutex); - auto it = gForeignThreadDispatchers.find(env); - posted = it != gForeignThreadDispatchers.end() && - napi_call_threadsafe_function(it->second, &call, napi_tsfn_blocking) == napi_ok; - } - if (!posted) { + if (!postForeignThreadCall(closure->env, &call)) { lock.unlock(); if (cif->rtype->type != FFI_TYPE_VOID) { memset(ret, 0, cif->rtype->size); @@ -479,7 +672,7 @@ void JSMethodCallback(ffi_cif* cif, void* ret, void* args[], void* data) { #else Closure* closure = (Closure*)data; if (std::this_thread::get_id() != closure->jsThreadId) { - callFromForeignThread(closure->env, invokeJSMethodCallback, cif, ret, args, data); + callFromForeignThread(closure, invokeJSMethodCallback, false, cif, ret, args); return; } invokeInNativeCallbackScope(closure->env, invokeJSMethodCallback, cif, ret, args, data); @@ -492,7 +685,7 @@ void JSFunctionCallback(ffi_cif* cif, void* ret, void* args[], void* data) { #else Closure* closure = (Closure*)data; if (std::this_thread::get_id() != closure->jsThreadId) { - callFromForeignThread(closure->env, invokeJSFunctionCallback, cif, ret, args, data); + callFromForeignThread(closure, invokeJSFunctionCallback, false, cif, ret, args); return; } invokeInNativeCallbackScope(closure->env, invokeJSFunctionCallback, cif, ret, args, data); @@ -565,7 +758,7 @@ void JSBlockCallback(ffi_cif* cif, void* ret, void* args[], void* data) { #endif } else { #ifndef ENABLE_JS_RUNTIME - callFromForeignThread(env, invokeJSBlockCallback, cif, ret, args, closure); + callFromForeignThread(closure, invokeJSBlockCallback, true, cif, ret, args); #else auto runloop = closure->jsRunLoop; if (runloop == nullptr) {