Skip to content

Capture and enter async context frames in Rust JSG - #7980

Draft
bartlomieju wants to merge 9 commits into
mainfrom
biwanczuk/rust-jsg-async-context
Draft

bartlomieju wants to merge 9 commits into
mainfrom
biwanczuk/rust-jsg-async-context

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Lock::await_io, and any Rust code that resumes JavaScript after a KJ event, must run in the async context frame that started the work, as IoContext::awaitIo does in C++. Add jsg::AsyncContextFrame, which captures the current frame and later runs a closure in it, restoring the previous frame however the closure ends, and TracedAsyncContextFrame for resource fields and Function::new data. Both use V8's continuation-preserved embedder data directly; nothing outside tests uses them yet.

C++ AsyncContextFrame::current treats embedder data that is not a frame wrapper as the root frame, while Rust, which cannot recognize the wrapper without C++ JSG, treats anything but undefined as a frame. They agree because nothing else is ever set: C++ sets only a frame's wrapper or undefined, and Rust can only set undefined or a value it read from the embedder data, which a private handle type enforces. A test checks that Rust's is_root() agrees with C++ in every state Rust can leave the isolate in.

There is no production consumer yet. The real consumer, await_io, also needs the IoContext bindings (#7963) and bridging between KJ and JavaScript promises. Like #7956, this is a building block, tested through src/rust/jsg-test, whose test-only shims create real C++ jsg::AsyncContextFrames the way AsyncLocalStorage.run() does.

Open questions:

  • Naming: AsyncContextFrame / TracedAsyncContextFrame follow C++ and Global / TracedGlobal; the reverse conversion is TracedAsyncContextFrame::to_async_context_frame(lock).
  • Reading and writing AsyncLocalStorage values (StorageKey, get) and AsyncContextFrame::wrap are left for later.

Porting C++ APIs such as node:module's createRequire() to Rust needs
the counterpart of C++ JSG's wrapReturningFunction(). Add
v8::Function::new(lock, data, f): f captures nothing and receives data,
which the function owns through a Wrappable, so the GC traces it and
collects cycles through it. No JS-visible change; nothing uses it yet.
Functions that C++ JSG wraps also have an opaque wrapper as their
data, so fci_get_data_wrappable() checks the Wrappable's type. Add a
jsg-test helper that wraps a function through
Lock::wrapReturningFunction() and a test that its data is rejected.
…nction-new

# Conflicts:
#	src/rust/jsg/README.md
#	src/rust/jsg/v8.rs
Function::new reported a failure to create the function, which happens
only while execution is terminating, as a new internal error. It now
checks for the termination first and returns the pending error that
Local<Function>::call returns, so callers handle both alike and pass
the termination on with `?`.
A spike toward async Rust APIs such as MemoryCache.read(). Add untyped
promise handles, like C++ JSG's JsPromise:

- v8::PromiseResolver::new(), resolve(), reject() and
  reject_with_error(); a resolver never converts to a JS value.
- v8::Promise::resolved(), rejected() and rejected_with_error().
- Local<Promise>::state(), result(), mark_as_handled() and then(),
  whose reactions are v8::Function::new functions sharing GC-traced
  data, so reactions capture nothing and cycles through them are
  collected.
- FromJS for Local<Promise>, which turns any value into a promise as
  C++ JSG's promise arguments do; ToJS comes from the casts.
- Lock::exception(), the exception throw_exception() would throw,
  with internal errors redacted.

As with v8::Function::new, an operation fails, with an internal
jsg::Error, only when execution is terminating.

The test harness gains run_microtasks(), since isolates use an explicit
microtask policy. The tests include two consumers: a resource that
settles a promise from a later call, and a read-through cache shaped
like MemoryCache.read(key, fallback).

No JS-visible change; nothing outside tests uses it yet.
then() gave each of its two reactions a Wrappable of its own over one
std::rc::Rc of the data, so a jsg::Rc in the data had two parents and
failed C++'s single-owner assertion when the second reaction was
created. Function::new_sharing_data now creates any number of functions
over one Wrappable, attaching its opaque wrapper once; Function::new
uses it too, and function_new takes the wrapper as the data.

Reactions follow V8's API: then(on_fulfilled), catch(on_rejected), and
then_or_catch(on_fulfilled, on_rejected), sharing one data. The first
two pass the other outcome through natively, so passing a rejection on
no longer creates a rejected promise.

A failed operation now returns a pending error, as Function::call does,
rather than an internal error that claimed a termination. The shims
also enter V8 for nothing while an exception or termination is pending:
Promise::Resolver::New, Then, Catch and ObjectTemplate::NewInstance open
a PrepareForExecutionScope, which clears the pending exception, so a
terminated script ran on. Lock::exception returns a pending error as
is, so rejecting with one propagates it.

The read-through cache test keeps its state in a resource that the
cache and each in-flight fill hold a jsg::Rc to, instead of sharing
TracedGlobals through std::rc::Rc. New tests cover a jsg::Rc in
reaction data, teardown with a pending promise, a reaction that calls
back into its resource, and every operation while terminating.

The promise shims add no C++ JSG: exception_create_internal shares
isolate_throw_internal_error's makeInternalError() call, and the test
harness runs microtasks through V8. local_promise_result no longer
fails. promise.rs is now a crate module, re-exported from jsg::v8.
Lock::await_io, and any Rust code that resumes JavaScript after a KJ
event, must run in the async context frame that started the work, as
IoContext::awaitIo does in C++. Add jsg::AsyncContextFrame, which
captures the current frame and later runs a closure in it, restoring
the previous frame however the closure ends, and
TracedAsyncContextFrame for resource fields and Function::new data.
Both use V8's continuation-preserved embedder data directly; nothing
outside tests uses them yet.
…ync-context

# Conflicts:
#	src/rust/AGENTS.md
#	src/rust/jsg-test/ffi.c++
#	src/rust/jsg-test/ffi.h
#	src/rust/jsg-test/lib.rs
#	src/rust/jsg-test/tests/function_new.rs
#	src/rust/jsg-test/tests/promise.rs
#	src/rust/jsg/README.md
#	src/rust/jsg/ffi.c++
#	src/rust/jsg/lib.rs
#	src/rust/jsg/promise.rs
#	src/rust/jsg/v8.rs
C++ AsyncContextFrame::current() treats embedder data that is not a
frame wrapper as the root frame, while Rust treats anything but
undefined as a frame. They agree only because nothing else sets the
embedder data. Rust cannot recognize the wrapper without C++ JSG, so
enforce its side instead: the frame handles now use a private marker
type that only the embedder-data getter produces, so Rust can make
current only undefined or a value that was current before.

Document the invariant at the C++ setter, and test that Rust's
is_root() agrees with C++ in every state Rust can leave the isolate
in.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 41.94%. Comparing base (f0df7ae) to head (fdaabf0).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
src/rust/jsg/async_context.rs 0.00% 75 Missing ⚠️
src/rust/jsg/v8.rs 0.00% 7 Missing ⚠️
src/rust/jsg/ffi.c++ 0.00% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7980      +/-   ##
==========================================
- Coverage   41.96%   41.94%   -0.02%     
==========================================
  Files        1064     1065       +1     
  Lines      302088   302224     +136     
  Branches    29079    29079              
==========================================
+ Hits       126771   126772       +1     
- Misses     158284   158420     +136     
+ Partials    17033    17032       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants