-
-
Notifications
You must be signed in to change notification settings - Fork 38
Description
A common JS pattern for small delays hangs indefinitely on iOS when the Promise is created on a background thread:
async function doSomething() {
// wait 25ms for some events to propagate
await new Promise(resolve => setTimeout(() => resolve(), 25));
console.log('never reached');
}This happens when native code (e.g. a NativeScript plugin) invokes JS callbacks on a background thread.
Reproduction
Any native plugin that calls into JS from a background thread can trigger this. Example with a WebSocket delegate:
Native WebSocket library (background thread)
│
▼
Plugin's native callback handler
│ (running on background thread)
▼
NativeScript bridge invokes JS callback
│ (acquires V8 locker on this background thread)
▼
JS event handler runs
│
▼
await new Promise(resolve => {
setTimeout(() => {
resolve(); ← never executes
}, 25);
});
The Promise hangs forever.
Cause
PromiseProxy.cpp captures CFRunLoopGetCurrent() when the Promise is constructed:
ios/NativeScript/runtime/PromiseProxy.cpp
Line 16 in 73332e1
| let runloop = CFRunLoopGetCurrent(); |
If resolve() is later called from a different thread, it uses CFRunLoopPerformBlock to schedule the actual resolution on the original thread's runloop:
ios/NativeScript/runtime/PromiseProxy.cpp
Lines 36 to 37 in 73332e1
| CFRunLoopPerformBlock(runloop, kCFRunLoopDefaultMode, resolveCall); | |
| CFRunLoopWakeUp(runloop); |
The problem: background threads from dispatch queues or thread pools have dormant runloops. No code is calling CFRunLoopRun() on them - they just execute tasks and return to the pool. The scheduled block never gets processed.
Meanwhile, setTimeout always fires on Runtime::RuntimeLoop():
ios/NativeScript/runtime/Timers.cpp
Line 125 in 73332e1
| timerState->runloop = Runtime::GetRuntime(isolate)->RuntimeLoop(); |
Timers are added to that runloop here:
ios/NativeScript/runtime/Timers.cpp
Line 252 in 73332e1
| CFRunLoopAddTimer(state->runloop, timer, kCFRunLoopCommonModes); |
This is typically the main thread (captured at Runtime creation), which is different from the background thread where the Promise was created.
The flow:
- JS callback runs on background thread B (V8 locker acquired)
- Promise created, PromiseProxy captures thread B's runloop
- setTimeout schedules timer on Runtime's runloop (thread A)
- Callback completes, thread B returns to pool (runloop dormant)
- Timer fires on thread A
- resolve() called on thread A
- PromiseProxy sees different runloops, uses CFRunLoopPerformBlock to schedule on thread B
- Thread B's runloop is dormant - nobody is pumping it
- Block sits there forever, Promise never resolves
Suggested fix
Instead of marshaling to the creation thread's runloop (which may be dormant), marshal to the Runtime's runloop which is always active:
// In PromiseProxy.cpp JS source
let runtimeRunloop = __getRuntimeRunloop(); // new global exposing Runtime::RuntimeLoop()
origFunc(value => {
if (isFulfilled()) return;
let res = resolve;
markFulfilled();
if (runtimeRunloop === CFRunLoopGetCurrent()) {
res(value);
} else {
CFRunLoopPerformBlock(runtimeRunloop, kCFRunLoopDefaultMode, () => res(value));
CFRunLoopWakeUp(runtimeRunloop);
}
}, ...);This ensures Promise resolution always happens on the Runtime's thread (where timers fire), regardless of which thread created the Promise. The Runtime's runloop is always being pumped, so the block will execute.
This would require exposing Runtime::RuntimeLoop() to JS, perhaps via a global function:
ios/NativeScript/runtime/Runtime.h
Line 27 in 73332e1
| inline CFRunLoopRef RuntimeLoop() { return runtimeLoop_; } |