Interop Promise - ETS/JS Cross-Language Promise Bridging

SkillDev tools

ETS-JavaScript interop Promise bridging system in ArkCompiler. Use this skill when working on cross-language Promise conversion between ETS (ArkTS) and JavaScript, including JSConvertPromise Wrap/Unwrap, EtsPromise proxy creation, EtsPromiseRef bridging, CreatePromiseLink, OnJsPromiseCompleted callbacks, connectPromise, SettleJsPromise, PromiseInteropResolve/Reject, EtsAwaitPromise/AwaitProxyPromise, callback queue management, or any code under js_convert.h (Promise section), js_job_queue, ets_promise, ets_promise_ref, std_core_Promise.cpp, or PromiseInterop.ets. Also use when debugging cross-VM Promise state synchronization, coroutine suspension/resumption during await, or napi_deferred lifecycle issues.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Interop Promise - ETS/JS Cross-Language Promise Bridging skill

What this skill tells your AI

The instructions your AI receives, as published by openharmonyinsight/openharmony-skills in skills/oh-arkruntime-interop-promise/SKILL.md and read by ahel’s review.

Guide for understanding, developing, and debugging the Promise interop system that bridges ETS (ArkTS) Promises with JavaScript Promises in the ArkCompiler hybrid runtime.

Architecture Overview

The interop Promise system enables transparent bidirectional Promise conversion between the static ETS VM and dynamic JS VM. When ETS calls a JS async function (or vice versa), the system creates proxy objects and registers callbacks so that resolve/reject events propagate correctly across the language boundary.

ETS VM Side                              JS VM Side
──────────────                           ──────────────
EtsPromise                                JS Promise
    │                                          │
    ├── interopObject_ → EtsPromiseRef ────────┤
    ├── linkedPromise_ → EtsPromiseRef          │
    ├── event_ (await suspend/resume)            │
    ├── mutex_ (thread safety)                   │
    └── callbackQueue_ (.then handlers)          │
                                               │
         SharedReferenceStorage ────────────────┘
         (EtsPromiseRef ↔ JS Promise mapping)

JS→ETS path:  JS Promise → JSCONVERT_UNWRAP(Promise) → EtsPromise proxy
ETS→JS path:  EtsPromise → JSCONVERT_WRAP(Promise) → JS Promise

Source Code Locations

ComponentPath
JSConvertPromise (Wrap/Unwrap)plugins/ets/runtime/interop_js/js_convert.h
EtsPromise classplugins/ets/runtime/types/ets_promise.h/.cpp
EtsPromiseRef bridgeplugins/ets/runtime/types/ets_promise_ref.h
JsJobQueue / CreatePromiseLinkplugins/ets/runtime/interop_js/js_job_queue.h/.cpp
Promise intrinsicsplugins/ets/runtime/intrinsics/std_core_Promise.cpp
SettleJsPromise / PromiseInteropplugins/ets/runtime/interop_js/intrinsics_api_impl.cpp
PromiseInterop.etsplugins/ets/stdlib/std/interop/js/PromiseInterop.ets
ETS Promise.etsplugins/ets/stdlib/std/core/Promise.ets
CallJSHandler (ETS→JS calls)plugins/ets/runtime/interop_js/call/call_js.cpp
CallETSHandler (JS→ETS calls)plugins/ets/runtime/interop_js/call/call_ets.cpp
Type routing (ConvertArgToEts/JS)plugins/ets/runtime/interop_js/call/arg_convertors.h

Two Core Conversion Paths

Path 1: JS Promise → ETS Proxy (JSCONVERT_UNWRAP)

Triggered when ETS calls a JS function that returns a Promise.

Call chain:

CallJSHandler::Handle()
  → ConvertRetval() → ConvertArgToEts() → ConvertRefArgToEts()
  → JSConvertPromise::UnwrapImpl()  [JSCONVERT_UNWRAP(Promise)]

UnwrapImpl steps:

  1. Identity check: SharedReferenceStorage::GetReference(env, jsVal) — reuse existing proxy if JS Promise was already wrapped
  2. Create proxy EtsPromise: EtsPromise::Create(coro) — STATE_PENDING
  3. Create EtsPromiseRef bridge: Why? Because SharedReferenceStorage uses MarkWord for interop hash, and EtsPromise uses MarkWord for Lock — they conflict
  4. Register mapping: SharedReferenceStorage::CreateJSObjectRef(ctx, ref, jsVal) — bidirectional EtsPromiseRef ↔ JS Promise
  5. Mark as proxy: hpromise->SetLinkedPromise(coro, href) — enables IsProxy() check in await
  6. Create link: EtsPromise::CreateLink()JsJobQueue::CreatePromiseLink() — registers C++ callbacks on JS Promise's .then()

Path 2: ETS Promise → JS Promise (JSCONVERT_WRAP)

Triggered when JS calls an ETS function that returns a Promise.

Call chain:

CallETSHandler::HandleImpl()
  → ConvertArgToJS() → ConvertRefArgToJS()
  → JSConvertPromise::WrapImpl()  [JSCONVERT_WRAP(Promise)]

WrapImpl has two paths:

Fast path (EtsPromise already settled when Wrap is called):

  1. Identity check via GetInteropObject() + HasReference()
  2. napi_create_promise(env, &deferred, &jsPromise) — create pending JS Promise
  3. Lock() → check !IsPending() && !IsLinked() → fast path
  4. Convert ETS value to JS value via JSRefConvertResolve
  5. napi_resolve_deferred() or napi_reject_deferred() — immediately settle JS Promise
  6. Create EtsPromiseRef + register in SharedReferenceStorage

Slow path (EtsPromise still pending): 1-2. Same as fast path 3. Lock()IsPending() || IsLinked() → slow path 4. Unlock() → call PromiseInterop.connectPromise(promise, deferred) via Invoke 5. connectPromise registers .then() callbacks that call PromiseInteropResolve/Reject native methods 6. When EtsPromise resolves later: OnPromiseCompletion()LaunchCallback()PromiseInteropResolve()SettleJsPromise()napi_resolve_deferred() 7. Create EtsPromiseRef + register in SharedReferenceStorage

Key Classes

EtsPromise (ets_promise.h)

States: STATE_PENDING(0), STATE_RESOLVED(1), STATE_REJECTED(2), STATE_LINKED(3)

MethodDescription
Create(coro)Create PENDING promise with mutex + event
Resolve(coro, value)Set value, transition to RESOLVED, call OnPromiseCompletion
Reject(coro, error)Set error, transition to REJECTED, call OnPromiseCompletion
Wait()Block coroutine via EtsEvent::Wait()
IsProxy()linkedPromise_ != nullptr — true for JS Promise proxies
IsPending/Resolved/Rejected/Linked()State checkers
SubmitCallback(cb, workerDomain)Add .then handler to callbackQueue
CreateLink(source, target)Delegate to JobQueue::CreateLink()
OnPromiseCompletion(coro)Fire event, launch queued callbacks, handle unhandled rejection
LaunchCallback(coro, cb, groupId)Execute callback in new coroutine (PROMISE_CALLBACK priority)
ChangeStateToPendingFromLinked()LINKED → PENDING state transition
GetInteropObject() / SetInteropObject()EtsPromiseRef bridge object
GetLinkedPromise() / SetLinkedPromise()For proxy detection
Lock() / Unlock() / IsLocked()Thread-safe mutex via MarkWord

Member variables: value_, mutex_, event_, callbackQueue_, workerDomainQueue_, interopObject_, linkedPromise_, queueSize_, state_

EtsPromiseRef (ets_promise_ref.h)

Minimal bridge object to avoid MarkWord conflict between SharedReferenceStorage (interop hash) and EtsPromise (Lock).

class EtsPromiseRef : public EtsObject {
    EtsObject *target_ {};  // Points to the actual EtsPromise
    // MarkWord used by SharedReferenceStorage for interop hash index
};

JsJobQueue (js_job_queue.h/.cpp)

Extends JobQueue with JS-specific callback and promise linking.

MethodDescription
CreatePromiseLink(jsObject, etsPromise)Register C++ then/catch callbacks on JS Promise
Post(fn, data)Post callback to JS job queue via JS Promise

Global C++ callbacks registered on JS Promise:

  • OnJsPromiseResolved(env, info) → delegates to OnJsPromiseCompleted(env, info, true)
  • OnJsPromiseRejected(env, info) → delegates to OnJsPromiseCompleted(env, info, false)
  • OnJsPromiseCompleted(env, info, isResolved): Converts JS value to ETS, calls EtsPromiseResolve or EtsPromiseReject

ETS Await Mechanism

ETS: await p;
  → EtsAwaitPromise(p)
    → IsProxy()?
      → YES: AwaitProxyPromise()
        → promise->Wait()  // EtsEvent::Wait() — coroutine suspends
        → [JS resolves → OnJsPromiseCompleted → EtsPromiseResolve → Resolve → OnPromiseCompletion → Fire()]
        → Wait() returns
        → IsResolved()? return GetValue() : throw exception
      → NO: promise->Wait()  // Direct ETS Promise await

Key: EtsAwaitPromise first yields CPU via coro->GetManager()->Schedule() to allow other coroutines (including JS microtasks) to execute before checking proxy status.

Promise State Machine

                    Create()
                       │
                       ▼
                 STATE_PENDING
                /       │        \
    resolve()  /   CreateLink()  \  reject()
              /        │          \
             ▼         ▼           ▼
    STATE_RESOLVED  STATE_LINKED  STATE_REJECTED
                        │
             resolve() │ (from subscribeOnAnotherPromise)
                        ▼
                  STATE_RESOLVED
  • PENDING: Initial state, or after ChangeStateToPendingFromLinked()
  • LINKED: Proxy Promise waiting for JS source to settle
  • RESOLVED/REJECTED: Terminal states, value_ holds result

Callback Queue and Execution

.then() registration flow:

p.then(onResolve, onReject)
  → Promise.ets: thenImpl()
  → [native] EtsPromiseSubmitCallback(promise, callback, workerDomain)
  → SubmitCallback(): if settled → execute immediately; else → add to callbackQueue_

Execution on completion:

OnPromiseCompletion(coro)
  → Fire()  // Wake awaiters
  → for each callback in queue:
      → LaunchCallback(coro, callback, groupId)
        → Create CompletionEvent
        → coroManager->Launch(event, method, args, groupId, PROMISE_CALLBACK)

Queue capacity management: Dynamic resizing with EnsureCapacity() — growth strategy is 2 * oldSize + 1.

SettleJsPromise — ETS→JS Bridge

The final step that completes a JS Promise from ETS:

void SettleJsPromise(EtsObject *value, napi_deferred deferred, EtsInt state)
{
    // Must run on main worker thread
    INTEROP_CODE_SCOPE_ETS_TO_JS(executionCtx);
    // Convert ETS value to JS value
    completionValue = refconv->Wrap(ctx, value);
    // Complete the JS Promise
    napi_resolve_deferred(env, deferred, completionValue);  // or napi_reject_deferred
}

Called from PromiseInteropResolve() / PromiseInteropReject() which are native methods invoked by PromiseInterop.ets callbacks.

PromiseInterop.ets — Slow Path Connection

final class PromiseInterop {
    static connectPromise<T>(p: Promise<T>, deferred: long): void {
        p.then<void, void>(
            (value: T): void => { PromiseInterop.resolve<T>(value, deferred); },
            (error: Any): void => { PromiseInterop.reject(error, deferred); }
        );
    }
    private static native resolve<T>(value: T, deferred: long): void;
    private static native reject(error: Any, deferred: long): void;
}

This ETS code is invoked via PlatformTypes()->interopPromiseInteropConnectPromise->GetPandaMethod()->Invoke() from C++ during JSCONVERT_WRAP slow path.

Type Routing — How Promise Conversion is Triggered

Promise conversion is not triggered by runtime type detection (e.g., napi_is_promise). Instead, it's driven by compile-time type signatures from .d.ets files:

.d.ets: export declare function jsAsync(): Promise<string>;
         ↓ (compiler generates ProtoReader type info)
ProtoReader return type = EtsPromise class
         ↓ (runtime type routing)
ConvertRefArgToEts → JSRefConvertResolve(ctx, EtsPromise.RuntimeClass)
         ↓ (finds JSConvertPromise converter)
JSConvertPromise::UnwrapImpl() or WrapImpl()

If .d.ets declares Promise<T> but JS returns non-Promise, ASSERT(isPromise) fails in Debug mode.

Return Value Type Routing (Fast Path Reference)

DirectionEntry PointRouterConverter
JS→ETS (return)CallJSHandler::ConvertRetval()ConvertArgToEts()ConvertRefArgToEts()JSConvertPromise::UnwrapImpl()
ETS→JS (return)CallETSHandler::ConvertArgToJS()ConvertRefArgToJS()JSConvertPromise::WrapImpl()
JS→ETS (param)CallETSHandler::ConvertArgs()ConvertArgToEts()JSConvertPromise::UnwrapImpl()
ETS→JS (param)CallJSHandler::ConvertArgsAndCall()ConvertArgToJS()JSConvertPromise::WrapImpl()

Testing Patterns

Three verification patterns exist for interop Promise testing:

PatternETS Promise State at ReturnJSCONVERT_WRAP PathJS Verification
A: ETS internal verifyPENDINGSlow pathPoll ETS global state
B: JS verify resolvedRESOLVEDFast pathJS .then() on returned Promise
C: JS verify pendingPENDINGSlow pathJS .then() + setTimeout trigger resolve

Threading and Coroutine Considerations

  • SettleJsPromise must execute on main worker thread (ASSERT(IsMainWorker()))
  • Promise callbacks launch in new coroutines with PROMISE_CALLBACK priority
  • Worker domains: MAIN or GENERAL — affects which coroutine group handles the callback
  • Coroutine switch: EtsAwaitPromise yields CPU before suspending to allow JS microtask processing
  • Mutex: EtsPromise uses MarkWord-based Lock for thread safety (hence need for EtsPromiseRef)
  • Event: EtsEvent provides coroutine suspension/resumption for await

Common Tasks

Adding a new interop type that can cross Promise boundaries

  1. Implement JSConvert<YourType>::Wrap() and UnwrapImpl() in js_convert.h
  2. Register in JSRefConvertResolve lookup chain
  3. Handle conversion in OnJsPromiseCompleted and SettleJsPromise (already generic via JSRefConvertResolve)

Debugging a stuck await

  1. Check EtsPromise::IsProxy() — is it a JS proxy?
  2. Check state — stuck in PENDING or LINKED?
  3. Verify CreatePromiseLink registered callbacks — check JS Promise's .then() was called
  4. Check OnJsPromiseCompleted was invoked — JS microtask queue running?
  5. Check EtsEvent::Fire() was called — OnPromiseCompletion executed?
  6. Check coroutine scheduling — Schedule() yields properly?

Debugging type conversion failures

  1. Verify .d.ets type signature matches actual JS return type
  2. Check ProtoReader type info at runtime
  3. In Debug: ASSERT(isPromise) in UnwrapImpl catches mismatch
  4. Check JSRefConvertResolve finds the correct converter for the value type

Signals

GitHub stars
34
Forks
7
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
interop-promise
Source
github.com/openharmonyinsight/openharmony-skills