Skip to content

v0.15.0 — promise continuations that don't crash or leak

Choose a tag to compare

@codetalcott codetalcott released this 14 Sep 02:19
cdeee3b

Promise continuations that do not crash or leak. Host-mode code gets a
framework primitive for continuing after a JavaScript promise,
JsPromise.on_settled, replacing a hand-rolled pattern the docs recommended
that crashed the process on rejection, swallowed its own errors, and leaked
for every promise that rejected or never settled. The reason host mode cannot
simply wait for a promise is also corrected: it was never that Mojo lacks
await.

No existing Mojo signature changed — on_settled, Settlement and
JsObject.add_finalizer are additions — so downstream addons compile against
0.15.0 unmodified. The demo addon's thenDouble and deferredRequire exports
do change shape; see Fixed.

Added

  • JsPromise.on_settled attaches a Mojo continuation to a promise — or to
    any value, like await — and returns the promise .then() made. The
    continuation reads the outcome with Settlement.read(env, info): ok,
    value, the JS values passed as captures, and native state via
    user_data(). Nothing is freed when the continuation fires, because it may
    never fire: captures are bound arguments the GC traces, and data is
    adopted by a GC finalizer on every path, including failure.
  • JsObject.add_finalizer — napi_add_finalizer on any object, the way
    to tie native memory to a JS object's lifetime. Previously raw-only.
  • thenScaled(value, factor, counter, onResult) demo export: a continuation
    with native state whose finalizer is observable from JavaScript.

Fixed

  • The host-mode continuation examples crashed, hid errors and leaked.
    thenDouble and deferredRequire were the pattern the docs told users to
    copy, and three things were wrong with it, each now tested and each
    mutation-checked against the suite:

    • it attached no rejection handler, so a rejected promise crashed the
      process
      with an unhandled rejection and onResult never ran;
    • its except: pass swallowed Mojo-side failures, so a promise resolving to
      the wrong type made onResult silently never run;
    • it freed its payload only when the continuation fired and held onResult
      in a strong napi_ref, so every promise that rejected or never settled
      leaked both — 209.7 MB for 200 closures over 1 MB, where the same
      shape in plain JavaScript retains nothing.

    Both are rebuilt on on_settled. Their JS signatures changed: they now
    return the promise .then() made, and call onResult node-style —
    onResult(null, value) on success, onResult(reason) on rejection.

  • napi-mojo release --scaffold pinned a stale setup-pixi. The release
    workflow it writes lives in a template string Dependabot cannot see, so it
    kept v0.10.1 after this repo moved to v0.10.2. It now matches, and
    tests/template_action_pins.test.js fails whenever an action the template
    shares with this repo's workflows drifts.

Docs

  • "Mojo has no await" was wrong, and is gone from README, CLAUDE.md,
    js_host.mojo and the plan docs. Mojo has await; it suspends Mojo
    coroutines. What host mode cannot do is wait for a JS promise, because
    its code runs on the JS thread and a promise settles only after that code
    returns. spike/await_probe.mojo records the measurement behind "do not add
    a blocking await helper": re-entering uv_run from a callback runs other JS
    on the nested stack and still never runs the promise's reactions.
  • docs/plan-promise-bridge.md records how
    napi-rs lets Rust await a JS promise (worker thread, threadsafe function,
    one-shot channel — read from the 3.12.1 source), what napi-mojo would still
    need, and the gate for building it.