Skip to content

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

@adrian-niculescu

Description

@adrian-niculescu

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:

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:

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():

timerState->runloop = Runtime::GetRuntime(isolate)->RuntimeLoop();

Timers are added to that runloop here:

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:

  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:

inline CFRunLoopRef RuntimeLoop() { return runtimeLoop_; }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions