Repository navigation
Capture and enter async context frames in Rust JSG - #7980
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
…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.
|
This pull request's reviews are posted as pull request reviews: see the review of |
A merge from main left a second copy of the promise shim declarations in src/rust/jsg/ffi.h.
…ync-context # Conflicts: # src/rust/jsg/README.md
There was a problem hiding this comment.
✅ 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
undefinedor 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::newdata, 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)
Rust code that resumes JavaScript after a KJ event must run in the async context frame that started the work, as
IoContext::awaitIodoes in C++. Addjsg::AsyncContextFrame, which captures the current frame and later runs a closure in it, restoring the previous frame however the closure ends, andTracedAsyncContextFramefor resource fields andFunction::newdata. Both use V8's continuation-preserved embedder data directly; nothing outside tests uses them yet.C++
AsyncContextFrame::currenttreats embedder data that is not a frame wrapper as the root frame, while Rust, which cannot recognize the wrapper without C++ JSG, treats anything butundefinedas a frame. They agree because nothing else is ever set: C++ sets only a frame's wrapper orundefined, and Rust can only setundefinedor a value it read from the embedder data, which a private handle type enforces. A test checks that Rust'sis_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, throughIoContext::awaitIoAndRun(). The consumers are Rust code that resumes JavaScript some other way, such as from a Rust-created callback, and theAsyncLocalStorageandwrapsupport listed below. Like #7956, this is a building block, tested throughsrc/rust/jsg-test, whose test-only shims create real C++jsg::AsyncContextFrames the wayAsyncLocalStorage.run()does.Open questions:
AsyncContextFrame/TracedAsyncContextFramefollow C++ andGlobal/TracedGlobal; the reverse conversion isTracedAsyncContextFrame::to_async_context_frame(lock).AsyncLocalStoragevalues (StorageKey,get) andAsyncContextFrame::wrapare left for later.