Skip to content

feat(js): add js.spawn async task DSL - #76

Draft
nazarhussain wants to merge 1 commit into
mainfrom
nh/feat-async-task
Draft

feat(js): add js.spawn async task DSL#76
nazarhussain wants to merge 1 commit into
mainfrom
nh/feat-async-task

Conversation

@nazarhussain

Copy link
Copy Markdown
Contributor

Motivation

zapi has no DSL for worker-thread async. js.Promise is synchronous-only — its Deferred handle isn't preserved across the JS boundary — so async work means hand-rolling napi.AsyncWork + napi.Deferred at every call site. lodestar-z carries ~100 lines of identical raw plumbing per binding (blst.zig asyncAggregateWithRandomness, shuffle.zig).

There was also a hard blocker for doing this cleanly: js.env() panics inside an async completion callback, because only the synchronous wrappers (wrap_function, wrap_class, export_module) establish the thread-local env context. That's why DSL constructors and TypedArray.fromExternal abort if used there.

What this adds

js.spawn(Task, task, resource_name) runs task.compute() on the libuv worker pool and returns a JS Promise that settles on the JS thread. A task is any struct with:

  • compute(*Task) !void — worker thread; must not touch napi
  • resolve(*Task, napi.Env) !T — JS thread; T may be a DSL type (js.Number), an owned typed array (transferred without copying), napi.Value, or void
  • deinit(*Task) void — safe after resolve transferred ownership
  • optional errorMessage(anyerror) [:0]const u8 or reject(*Task, napi.Env, anyerror) !napi.Value for rejection control

The completion callback wraps context.setEnv/restoreEnv, which is what makes DSL types usable in resolve. It composes with OwnedTypedArray (#68) so results transfer to JS with no copy.

Design decisions

The env context is established in complete only, not execute. The handoff note suggested wrapping both, but current_env is threadlocal and napi calls are illegal on the libuv worker thread — setting it there would invite exactly the misuse the panic is meant to catch. compute panicking on js.env() is the guard rail.

The context lives in src/js/async_task.zig, not src/async_work.zig. Putting it in the raw N-API layer would make napi depend on js/context.zig, inverting the layering. Raw napi.AsyncWork users still get no implicit DSL context, which is correct — the DSL path is js.spawn.

resolve's return is converted directly rather than through wrap_function.convertReturn, whose failure path throws a JS exception. In a completion callback we want a rejected promise, not a pending exception. Returning a DSL class instance is not supported yet (it needs an addon identity); the compile error names the supported types.

Testing

8 new vitest cases written first and watched fail (js.spawn missing → compile error). Coverage: DSL value built in the completion callback, real Promise instance, concurrent tasks, owned typed array transfer, empty transfer, task-supplied rejection message, default @errorName rejection, Error instance.

The setEnv line is verified load-bearing — removing it and re-running reproduces exactly the predicted failure:

thread panic: js.env() called outside of a JS callback context

Stress run of 15,000 tasks (5,000 transfers + 5,000 rejections + 5,000 resolutions): no crash, RSS delta 5.1 MB.

Full suite: zig build test:zapi, 141 vitest tests across 6 example addons, zig fmt --check and biome clean.

Follow-up

Once released, lodestar-z deletes bindings/napi/async_task.zig (this was lifted from it, written to be upstreamed) and migrates shuffle.zig ShuffleTask and blst.zig asyncAggregateWithRandomness, guarded by its existing 32+ vitest cases.

Worth deciding separately: #72 (double free in external typed array creation) was closed unmerged, and its described hazard in fromExternal's catch-all is still present. It doesn't block this PR — OwnedTypedArray.intoValue is the path used here — but it's adjacent.

🤖 Generated with Claude Code

Worker-thread async previously meant hand-rolling napi.AsyncWork plus
Deferred at every call site, and DSL values were unusable in the
completion callback: js.env() panics there because only the sync
wrappers establish the thread-local env context.

js.spawn takes a comptime duck-typed task (compute/resolve/deinit, with
optional errorMessage/reject) and returns a Promise that settles on the
JS thread. Its completion callback sets the DSL env context, so resolve
can return DSL types, and composes with OwnedTypedArray to hand results
to JS without copying.

compute deliberately does NOT get the env context — napi calls are
illegal on the worker thread, so a panic there is the guard rail.

Lifted from lodestar-z bindings/napi/async_task.zig, which was written
to be upstreamed and is deleted once this ships.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant