ios icon indicating copy to clipboard operation
ios copied to clipboard

Promise never resolves when resolve() is called inside setTimeout from a background thread

Open adrian-niculescu opened this issue 3 weeks ago • 1 comments

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: https://github.com/NativeScript/ios/blob/73332e13667730b3084aa3dd7973685196efa086/NativeScript/runtime/PromiseProxy.cpp#L16

If resolve() is later called from a different thread, it uses CFRunLoopPerformBlock to schedule the actual resolution on the original thread's runloop: https://github.com/NativeScript/ios/blob/73332e13667730b3084aa3dd7973685196efa086/NativeScript/runtime/PromiseProxy.cpp#L36-L37

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(): https://github.com/NativeScript/ios/blob/73332e13667730b3084aa3dd7973685196efa086/NativeScript/runtime/Timers.cpp#L125

Timers are added to that runloop here: https://github.com/NativeScript/ios/blob/73332e13667730b3084aa3dd7973685196efa086/NativeScript/runtime/Timers.cpp#L252

This is typically the main thread (captured at Runtime creation), which is different from the background thread where the Promise was created.

The flow:

  1. JS callback runs on background thread B (V8 locker acquired)
  2. Promise created, PromiseProxy captures thread B's runloop
  3. setTimeout schedules timer on Runtime's runloop (thread A)
  4. Callback completes, thread B returns to pool (runloop dormant)
  5. Timer fires on thread A
  6. resolve() called on thread A
  7. PromiseProxy sees different runloops, uses CFRunLoopPerformBlock to schedule on thread B
  8. Thread B's runloop is dormant - nobody is pumping it
  9. 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: https://github.com/NativeScript/ios/blob/73332e13667730b3084aa3dd7973685196efa086/NativeScript/runtime/Runtime.h#L27

adrian-niculescu avatar Dec 18 '25 16:12 adrian-niculescu

This is a tricky thing to do because sometimes you want to resolve in a background thread. For example, if you're doing a heavy operation or using APIs that shouldn't be called in the main thread, the promise now resolves on the wrong thread. That said, maybe there's a compromise. The main reason for the promise proxy is to prevent promises that should resolve in the main thread from resolving in a background thread. So maybe we make a different approach where we only "delay" the resolution when the origin thread is the runtime thread.

Example:

  1. Create promise in runtime thread
  2. Promises resolve in background thread
  3. schedule resolve to runtime thread

Alternatively:

  1. Create promise in background thread
  2. Promise resolves anywhere
  3. Resolve without delay

Additionally we could mark a thread as "safe" to schedule by adding a helper function you call and it marks the runloop as safe to delay, so for advanced users they could mark their threads as safe (ideally they should also cleanup after done). The safe threads are all runtime specific (meaning that if you share a thread they should be marked as safe in both runtimes, otherwise they'd just get delayed on the one that is marked).

Would that solve your use case?

edusperoni avatar Dec 18 '25 16:12 edusperoni