Skip to content

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

Merged
bartlomieju merged 14 commits into
mainfrom
biwanczuk/rust-jsg-async-context
Oct 30, 2026
Merged

bartlomieju merged 14 commits into
mainfrom
biwanczuk/rust-jsg-async-context

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

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. IoContext::await_io (#8030) does not need one, since it enters the frame on the C++ side, through IoContext::awaitIoAndRun(). The consumers are Rust code that resumes JavaScript some other way, such as from a Rust-created callback, and the AsyncLocalStorage and wrap support listed below. 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

codecov-commenter commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.46%. Comparing base (29767ec) to head (4ca5a50).

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   68.49%   68.46%   -0.04%     
==========================================
  Files         721      722       +1     
  Lines      186647   186735      +88     
  Branches    29364    29364              
==========================================
- Hits       127843   127841       -2     
- Misses      41638    41727      +89     
- Partials    17166    17167       +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.

…ync-context

# Conflicts:
#	src/rust/AGENTS.md
#	src/rust/jsg-test/ffi.c++
#	src/rust/jsg-test/tests/mod.rs
#	src/rust/jsg/lib.rs
ToJS::to_js now returns a Result, so the async context tests propagate
or unwrap its errors.
@bartlomieju
bartlomieju marked this pull request as ready for review October 29, 2026 13:47
@bartlomieju
bartlomieju requested review from a team as code owners October 29, 2026 13:47
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

This pull request's reviews are posted as pull request reviews: see the review of 4ca5a503.

A merge from main left a second copy of the promise shim
declarations in src/rust/jsg/ffi.h.

@clanky-the-clanker clanky-the-clanker Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — No findings in any area. The changes to src/rust/jsg-test/tests/async_context.rs and src/rust/jsg/README.md since the last review are correct and match the implementation.

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

No security concerns:

  • Rust only writes undefined or a value it read from the embedder data back into the embedder data.
  • The previous frame is restored on every exit path.

📖 AGENTS.md: ✅ LGTM

The change to src/rust/AGENTS.md is accurate.


📋 Code Quality: ✅ LGTM

The tests in src/rust/jsg-test/tests/async_context.rs cover each behaviour the module documents:

  • capture and enter, including nesting and clones;
  • restoring the previous frame after an error, a panic and a termination;
  • promise reactions registered inside an entered frame;
  • entering a frame under a later lock, and keeping it alive across GC;
  • GC tracing through a resource field and through Function::new data, including collection of a cycle;
  • Rust's is_root() agreeing with C++ on which frame is the root;
  • dropping frames at or after isolate teardown.

Every harness helper the tests call exists in jsg-test/lib.rs.

Restore::drop sets the previous frame back without running JavaScript, so it is safe while execution is terminating and while a panic unwinds.


⚡ Performance: ✅ LGTM

Entering a frame costs one handle read and two calls to V8's embedder-data setter. Nothing is added to a hot path, and only tests use these types so far.


📝 Documentation: ✅ LGTM

The new "Async context" section and the table rows in src/rust/jsg/README.md match the code. AsyncContextFrame is Send and Sync. TracedAsyncContextFrame is Send but not Sync, and implements Traced.


Reviewed commit: 4ca5a503 — same changes as 49e1ffb4, so this is that commit's review (no new review was run; line numbers are as of 49e1ffb4)

@bartlomieju
bartlomieju merged commit 1dc3230 into main Oct 30, 2026
24 checks passed
@bartlomieju
bartlomieju deleted the biwanczuk/rust-jsg-async-context branch October 30, 2026 10:27
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.

3 participants