From e8db6896672c98285a87aa7e9add2826d9c7af62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 11:27:05 +0200 Subject: [PATCH 1/7] Create JS functions from Rust closures in Rust JSG 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. --- src/rust/AGENTS.md | 2 +- .../function-new-closure-must-not-capture.rs | 15 + src/rust/jsg-compile-tests/ok-function-new.rs | 15 + src/rust/jsg-test/tests/function_new.rs | 325 ++++++++++++++++++ src/rust/jsg-test/tests/mod.rs | 1 + src/rust/jsg/README.md | 30 ++ src/rust/jsg/ffi.c++ | 22 ++ src/rust/jsg/ffi.h | 7 + src/rust/jsg/v8.rs | 183 +++++++++- 9 files changed, 592 insertions(+), 8 deletions(-) create mode 100644 src/rust/jsg-compile-tests/function-new-closure-must-not-capture.rs create mode 100644 src/rust/jsg-compile-tests/ok-function-new.rs create mode 100644 src/rust/jsg-test/tests/function_new.rs diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index 6bfe43feb9f..5845b5fb549 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -14,7 +14,7 @@ _Snapshot — the set drifts as crates come and go; `bazel query //src/rust/...` | Crate | Purpose | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `jsg/` | Rust JSG bindings: `Lock`, `Rc`, `Resource`, `Struct`, `Type`, `Realm`, `FeatureFlags`, module registration; V8 handle types including typed arrays, `ArrayBuffer`, `ArrayBufferView`, `SharedArrayBuffer`, `BackingStore` | +| `jsg/` | Rust JSG bindings: `Lock`, `Rc`, `Resource`, `Struct`, `Type`, `Realm`, `FeatureFlags`, module registration; V8 handle types including typed arrays, `ArrayBuffer`, `ArrayBufferView`, `SharedArrayBuffer`, `BackingStore`; JS functions from Rust closures (`v8::Function::new`) | | `jsg-macros/` | Proc macros: `#[jsg_struct]`, `#[jsg_method]`, `#[jsg_resource]`, `#[jsg_oneof]`, `#[jsg_static_constant]`, `#[jsg_constructor]`, `#[jsg_property]`, `#[jsg_inspect_property]` | | `jsg-test/` | Test harness (`Harness`) for JSG Rust bindings | | `api/` | Rust-implemented Node.js APIs; registers modules via `register_nodejs_modules()` | diff --git a/src/rust/jsg-compile-tests/function-new-closure-must-not-capture.rs b/src/rust/jsg-compile-tests/function-new-closure-must-not-capture.rs new file mode 100644 index 00000000000..81c42118bdf --- /dev/null +++ b/src/rust/jsg-compile-tests/function-new-closure-must-not-capture.rs @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A function's closure is called through a plain function pointer, so its state goes in the +// function's data, which the garbage collector can see, rather than in captures. +use jsg::v8; + +pub fn t<'s>(lock: &mut jsg::Lock<'s>) -> jsg::Result> { + let name = String::from("captured"); + v8::Function::new(lock, (), move |lock, (), _args| { + //~^ ERROR E0277 + Ok(jsg::ToJS::to_js(name.as_str(), lock)) + }) +} diff --git a/src/rust/jsg-compile-tests/ok-function-new.rs b/src/rust/jsg-compile-tests/ok-function-new.rs new file mode 100644 index 00000000000..3dde25a84a8 --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-function-new.rs @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A function created from a closure reads its data and arguments and returns a value of its own +// scope, without `unsafe`. +use jsg::v8; + +pub fn t<'s>(lock: &mut jsg::Lock<'s>) -> jsg::Result> { + v8::Function::new(lock, String::from("data"), |lock, data, args| { + let object = lock.new_object(); + object.set(lock, data, args.get(0)); + Ok(object.into()) + }) +} diff --git a/src/rust/jsg-test/tests/function_new.rs b/src/rust/jsg-test/tests/function_new.rs new file mode 100644 index 00000000000..64bce11f442 --- /dev/null +++ b/src/rust/jsg-test/tests/function_new.rs @@ -0,0 +1,325 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! `v8::Function::new` creates a JavaScript function from a Rust closure and the data it owns, +//! like C++ JSG's `Lock::wrapReturningFunction()`. + +use std::cell::Cell; +use std::cell::RefCell; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use jsg::FromJS; +use jsg::ToJS; +use jsg::v8; +use jsg_macros::jsg_resource; + +/// Creates a function whose data is `greeting` and stores it as the global `greet`. +fn install_greet(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, greeting: &str) { + let greet = v8::Function::new(lock, greeting.to_owned(), |lock, greeting, args| { + let name = String::from_js(lock, args.get(0))?; + Ok(format!("{greeting}, {name}").to_js(lock)) + }) + .unwrap(); + ctx.set_global(lock, "greet", v8::Local::::from(greet)); +} + +/// Returns `"Name: message"` for what `code` throws, or `"ok"`. +fn error(lock: &mut jsg::Lock, ctx: &crate::EvalContext<'_>, code: &str) -> String { + let code = format!("try {{ {code}; 'ok' }} catch (e) {{ `${{e.name}}: ${{e.message}}` }}"); + ctx.eval(lock, &code).unwrap() +} + +#[test] +fn function_calls_closure_with_its_data_and_arguments() { + crate::Harness::new().run_in_context(|lock, ctx| { + install_greet(lock, ctx, "hello"); + assert_eq!( + ctx.eval::(lock, "greet('world')").unwrap(), + "hello, world" + ); + Ok(()) + }); +} + +#[test] +fn functions_keep_their_own_data() { + crate::Harness::new().run_in_context(|lock, ctx| { + let hello = v8::Function::new(lock, "hello".to_owned(), |lock, data, _args| { + Ok(data.clone().to_js(lock)) + }) + .unwrap(); + let count = v8::Function::new(lock, Cell::new(0u32), |lock, calls, _args| { + calls.set(calls.get() + 1); + Ok(jsg::Number::from(calls.get()).to_js(lock)) + }) + .unwrap(); + ctx.set_global(lock, "hello", v8::Local::::from(hello)); + ctx.set_global(lock, "count", v8::Local::::from(count)); + assert_eq!( + ctx.eval::(lock, "[hello(), count(), count(), hello(), count()].join()") + .unwrap(), + "hello,1,2,hello,3" + ); + Ok(()) + }); +} + +#[test] +fn function_sees_its_receiver() { + crate::Harness::new().run_in_context(|lock, ctx| { + let this = v8::Function::new(lock, (), |_lock, (), args| Ok(args.this())).unwrap(); + ctx.set_global(lock, "getThis", v8::Local::::from(this)); + assert!( + ctx.eval::(lock, "const o = {}; getThis.call(o) === o") + .unwrap() + ); + Ok(()) + }); +} + +#[test] +fn function_throws_closure_error() { + crate::Harness::new().run_in_context(|lock, ctx| { + install_greet(lock, ctx, "hello"); + let fail = v8::Function::new(lock, (), |_lock, (), _args| { + Err(jsg::Error::new_range_error("out of range")) + }) + .unwrap(); + ctx.set_global(lock, "fail", v8::Local::::from(fail)); + assert_eq!(error(lock, ctx, "fail()"), "RangeError: out of range"); + // A JavaScript exception raised while converting an argument propagates. + assert_eq!( + error( + lock, + ctx, + "greet({ toString() { throw new TypeError('no name'); } })" + ), + "TypeError: no name" + ); + Ok(()) + }); +} + +#[test] +fn panic_in_closure_is_an_internal_error() { + crate::Harness::new().run_in_context(|lock, ctx| { + let panics = v8::Function::new(lock, (), |_lock, (), _args| -> Result<_, jsg::Error> { + panic!("intentional panic in a function closure"); + }) + .unwrap(); + ctx.set_global(lock, "panics", v8::Local::::from(panics)); + let err = ctx + .eval::(lock, "panics()") + .unwrap_err() + .unwrap_jsg_err(lock); + assert!(err.message.contains("internal error"), "{}", err.message); + assert!( + !err.message.contains("intentional panic"), + "{}", + err.message + ); + Ok(()) + }); +} + +#[test] +fn function_shape_matches_cpp_jsg() { + crate::Harness::new().run_in_context(|lock, ctx| { + install_greet(lock, ctx, "hello"); + let code = "[typeof greet, JSON.stringify(greet.name), greet.length, \ + typeof new greet('x')].join()"; + assert_eq!( + ctx.eval::(lock, code).unwrap(), + "function,\"\",0,object" + ); + Ok(()) + }); +} + +// ============================================================================= +// Garbage collection +// ============================================================================= + +/// Counts its drops in the counter it points to. +struct DropCounter(&'static AtomicUsize); + +impl Drop for DropCounter { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +impl jsg::Traced for DropCounter {} + +static UNREACHABLE_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[test] +fn data_is_dropped_once_function_is_unreachable() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let function = v8::Function::new(lock, DropCounter(&UNREACHABLE_DROPS), |lock, _, _| { + Ok(lock.undefined()) + }) + .unwrap(); + ctx.set_global(lock, "f", v8::Local::::from(function)); + crate::Harness::request_gc(lock); + assert_eq!(UNREACHABLE_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(UNREACHABLE_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +static LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct Leaf { + _drops: DropCounter, +} + +#[jsg_resource] +impl Leaf {} + +#[test] +fn data_keeps_traced_resources_alive() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let leaf = jsg::Rc::new(Leaf { + _drops: DropCounter(&LEAF_DROPS), + }); + let function = + v8::Function::new(lock, leaf, |lock, leaf, _| Ok(leaf.clone().to_js(lock))).unwrap(); + ctx.set_global(lock, "getLeaf", v8::Local::::from(function)); + // Only the function's data holds the resource now. + crate::Harness::request_gc(lock); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + assert!(ctx.eval::(lock, "getLeaf() === getLeaf()").unwrap()); + Ok(()) + }); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +static CYCLE_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// Data that can hold a handle back to its own function. +struct CycleData { + _drops: DropCounter, + stored: RefCell>>, +} + +impl jsg::Traced for CycleData { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.stored.trace(visitor); + } +} + +#[test] +fn cycle_through_data_is_collected() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let data = CycleData { + _drops: DropCounter(&CYCLE_DROPS), + stored: RefCell::new(None), + }; + let function = v8::Function::new(lock, data, |lock, data, args| { + *data.stored.borrow_mut() = Some(args.get(0).to_traced_global(lock)); + Ok(lock.undefined()) + }) + .unwrap(); + ctx.set_global(lock, "store", v8::Local::::from(function)); + // The data now holds the function that owns it. + ctx.eval_raw(lock, "store(store)").unwrap(); + crate::Harness::request_gc(lock); + assert_eq!(CYCLE_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(CYCLE_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +#[test] +fn reentrant_call_sees_its_own_data() { + crate::Harness::new().run_in_context(|lock, ctx| { + // Each call logs its depth on entry and exit, and calls `again` in between, which calls + // the same function again. No `RefCell` borrow is held across the call into JavaScript. + let f = v8::Function::new(lock, RefCell::new(Vec::new()), |lock, log, args| { + let depth = jsg::Number::from_js(lock, args.get(0))?.value(); + log.borrow_mut().push(format!("+{depth}")); + if depth > 0.0 { + let again = args.get(1).try_as::().unwrap(); + let next = jsg::Number::from(depth - 1.0).to_js(lock); + again.call::>(lock, None, &[next])?; + } + log.borrow_mut().push(format!("-{depth}")); + Ok(log.borrow().join(" ").to_js(lock)) + }) + .unwrap(); + ctx.set_global(lock, "f", v8::Local::::from(f)); + assert_eq!( + ctx.eval::(lock, "const again = (n) => f(n, again); f(2, again)") + .unwrap(), + "+2 +1 +0 -0 -1 -2" + ); + Ok(()) + }); +} + +static TEARDOWN_DATA_DROPS: AtomicUsize = AtomicUsize::new(0); +static TEARDOWN_LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// Data that holds a resource and a handle back to its own function. +struct TeardownData { + _drops: DropCounter, + leaf: jsg::Rc, + stored: RefCell>>, +} + +impl jsg::Traced for TeardownData { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.leaf.trace(visitor); + self.stored.trace(visitor); + } +} + +/// The data of a function that is still reachable when the isolate is torn down is dropped by +/// the teardown, together with the resource and the `TracedGlobal` it owns. +#[test] +fn data_of_reachable_function_is_dropped_with_its_isolate() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let data = TeardownData { + _drops: DropCounter(&TEARDOWN_DATA_DROPS), + leaf: jsg::Rc::new(Leaf { + _drops: DropCounter(&TEARDOWN_LEAF_DROPS), + }), + stored: RefCell::new(None), + }; + let function = v8::Function::new(lock, data, |lock, data, args| { + *data.stored.borrow_mut() = Some(args.get(0).to_traced_global(lock)); + Ok(lock.undefined()) + }) + .unwrap(); + ctx.set_global(lock, "store", v8::Local::::from(function)); + ctx.eval_raw(lock, "store(store)").unwrap(); + // Tracing puts the `TracedGlobal` in traced mode; the global object keeps the function. + crate::Harness::request_gc(lock); + assert_eq!(TEARDOWN_DATA_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(TEARDOWN_LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + drop(harness); + assert_eq!(TEARDOWN_DATA_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(TEARDOWN_LEAF_DROPS.load(Ordering::SeqCst), 1); +} diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index a7d75a6da7d..c30097977e5 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -10,6 +10,7 @@ mod collections_gc; mod eval; mod fast_api; mod function; +mod function_new; mod gc; mod global; mod harness; diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 096c4c3845e..66c63a614db 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -123,6 +123,7 @@ scope. There is no public constructor; locks come from: accessors likewise with `jsg::getter_callback` / `jsg::setter_callback`, - `jsg::modules::add_builtin`, whose callback is a closure that captures nothing, of type `for<'s> Fn(&mut Lock<'s>) -> Local<'s, Value>`, +- `v8::Function::new`, whose closure captures nothing and gets a lock per call, - `jsg::callback_scope!(unsafe let lock, isolate)` for other Rust code that C++ calls with a scope already open; the caller vouches for the isolate's state, hence `unsafe`. A panic that reaches C++ aborts, so entry points instead pin a `jsg::CallbackScope` and open it @@ -237,6 +238,35 @@ impl EventEmitter { Without tracing, storing a `TracedGlobal` back to the resource's own wrapper creates an unbreakable reference cycle that leaks until the worker is torn down. With `visit_traced_global` tracing (generated automatically by `#[jsg_resource]`), the cycle is collected by the next full GC after all strong Rust `Rc`s are dropped. +### Functions from Rust closures + +`v8::Function::new(lock, data, f)` creates a JavaScript function that calls `f` with `data`, +the counterpart of C++ JSG's `Lock::wrapReturningFunction()`. `f` captures nothing; its state +is `data`, which the function owns through a C++ `Wrappable`, as a resource's wrapper owns the +resource. The garbage collector traces `data` (`T: Traced`) through the function and drops it +once the function is unreachable, so the `TracedGlobal`s and `jsg::Rc`s that `data` traces may +lead back to the function. Two things break this: + +- A reference that `T::trace` does not visit, such as a `v8::Global` (`Traced::trace` does + nothing by default). If it can reach the function, the cycle is invisible to the garbage + collector, and the function and `data` are freed only when the isolate is torn down. +- Another owner that the garbage collector also traces, such as a resource sharing part of + `data` through a `std::rc::Rc`. `data` must be the only owner that traces what it traces: + tracing one `jsg::Rc` from two owners fails an assertion. + +```rust +let greet = v8::Function::new(lock, String::from("hello"), |lock, greeting, args| { + let name = String::from_js(lock, args.get(0))?; + Ok(format!("{greeting}, {name}").to_js(lock)) +})?; +``` + +`f` returns `Result, jsg::Error>`: an `Err` is thrown, and a panic throws an +internal error and terminates execution, as in a `#[jsg_method]`. `Function::new` itself returns +an `Err` if V8 cannot create the function, which happens only when execution is terminating. As +with the functions C++ JSG wraps, the function's `name` is empty, its `length` is 0, and it can +be called with `new`. + ## Union Types To accept JavaScript values that can be one of several types, define an enum with `#[jsg_oneof]`: diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 4215a1fd316..ac3ecd7619f 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -419,6 +419,15 @@ Local local_function_call( return to_ffi(::workerd::jsg::check(fn->Call(context, receiver, v8Args.size(), v8Args.data()))); } +MaybeLocal function_new(Isolate* isolate, size_t callback, kj::Rc wrappable) { + auto context = isolate->GetCurrentContext(); + // Like the functions C++ JSG wraps (FunctionWrapper::wrap() in jsg/function.h): the wrapper + // keeps the Wrappable alive and traced for as long as the function is reachable. + auto data = wrappable->attachOpaqueWrapper(context, true); + return maybe_local_to_ffi(v8::Function::New( + context, reinterpret_cast(reinterpret_cast(callback)), data)); +} + // Local void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value) { auto v8_obj = local_as_ref_from_ffi(object); @@ -949,6 +958,19 @@ void fci_set_return_value(FunctionCallbackInfo* args, Local value) { args->GetReturnValue().Set(local_from_ffi(kj::mv(value))); } +kj::Maybe> fci_get_data_wrappable(FunctionCallbackInfo* args) { + auto data = args->DataV2().As(); + KJ_IF_SOME(wrappable, ::workerd::jsg::Wrappable::tryUnwrapOpaque(args->GetIsolate(), data)) { + // C++ JSG's wrapped functions also have an opaque wrapper as their data, so check the type, + // as ::workerd::jsg::downcastWrappable() does. + const auto& actualType = typeid(wrappable); + if (&actualType == &typeid(Wrappable) || actualType == typeid(Wrappable)) { + return kj::downcast(wrappable).toRc(); + } + } + return kj::none; +} + Isolate* pci_get_isolate(PropertyCallbackInfo* info) { return info->GetIsolate(); } diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index e2a0821ad98..d1cd5563552 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -188,6 +188,10 @@ MaybeLocal local_symbol_description(Isolate* isolate, const Local& value); // Local Local local_function_call( Isolate* isolate, const Local& function, const Local& recv, ::rust::Slice args); +// Creates a function that calls `callback` with an opaque wrapper of `wrappable` as its data. +// Throws if V8 could not create the wrapper and returns empty if it could not create the +// function, both only when execution is terminating. +MaybeLocal function_new(Isolate* isolate, size_t callback, kj::Rc wrappable); // Local void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); @@ -311,6 +315,9 @@ size_t fci_get_length(FunctionCallbackInfo* args); bool fci_is_construct_call(FunctionCallbackInfo* args); Local fci_get_arg(FunctionCallbackInfo* args, size_t index); void fci_set_return_value(FunctionCallbackInfo* args, Local value); +// The Rust Wrappable whose opaque wrapper is the running function's data, as function_new() +// attaches it; none if the data is anything else. +kj::Maybe> fci_get_data_wrappable(FunctionCallbackInfo* args); // PropertyCallbackInfo, for native data properties Isolate* pci_get_isolate(PropertyCallbackInfo* info); diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index d2666ff8833..eab473e6fc1 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -29,7 +29,9 @@ //! drops it, which may drop the resource. The Rust `jsg::Rc` smart pointer holds its own //! `std::Rc` plus a `WrappableRc` (`KjRc`) for reference-counted ownership. On //! drop, `wrappable_remove_strong_ref()` handles GC cleanup via `maybeDeferDestruction()`, -//! then the `WrappableRc` drop decrements the `kj::Rc` refcount. +//! then the `WrappableRc` drop decrements the `kj::Rc` refcount. A function created by +//! [`Function::new`] owns its data the same way, through a Wrappable whose opaque wrapper is the +//! function's V8 data. #![allow( unsafe_code, @@ -53,6 +55,8 @@ use crate::Lock; use crate::Nullable; use crate::Number; use crate::Resource; +use crate::Traced; +use crate::UnitType; use crate::isolate_state::IsolateState; #[expect( clippy::missing_safety_doc, @@ -369,6 +373,13 @@ pub mod ffi { recv: &Local, args: &[Local], ) -> Local; + // Fallible: creating the wrapper throws, and creating the function returns empty, when + // execution is terminating. + pub unsafe fn function_new( + isolate: *mut Isolate, + callback: usize, + wrappable: KjRc, + ) -> Result; // Local pub unsafe fn local_object_set_property( @@ -518,6 +529,9 @@ pub mod ffi { pub unsafe fn fci_is_construct_call(args: *mut FunctionCallbackInfo) -> bool; pub unsafe fn fci_get_arg(args: *mut FunctionCallbackInfo, index: usize) -> Local; pub unsafe fn fci_set_return_value(args: *mut FunctionCallbackInfo, value: Local); + pub unsafe fn fci_get_data_wrappable( + args: *mut FunctionCallbackInfo, + ) -> KjMaybe>; // PropertyCallbackInfo, for native data properties pub unsafe fn pci_get_isolate(info: *mut PropertyCallbackInfo) -> *mut Isolate; @@ -1516,6 +1530,121 @@ impl PartialEq for Local<'_, String> { impl Eq for Local<'_, String> {} +/// The state of a function created by [`Function::new`], owned by the function's C++ +/// `Wrappable`. +struct FunctionData(T); + +impl Traced for FunctionData { + fn holds_gc_references() -> bool { + T::holds_gc_references() + } + + fn trace(&self, visitor: &mut GcVisitor) { + self.0.trace(visitor); + } +} + +impl GarbageCollected for FunctionData { + fn memory_name(&self) -> &'static std::ffi::CStr { + // C++ JSG's name for the object that owns a wrapped function. + c"WrappableFunction" + } +} + +impl Function { + /// Creates a JavaScript function that calls `f` with `data`, like C++ JSG's + /// `Lock::wrapReturningFunction()`. + /// + /// The function owns `data`: the garbage collector traces it through the function and drops + /// it once the function is unreachable, so the `TracedGlobal`s and `jsg::Rc`s that `data` + /// traces may lead back to the function. Two things break this: + /// + /// - A reference that `T::trace` does not visit, such as a `v8::Global` (`Traced::trace` does + /// nothing by default). If it can reach the function, the cycle is invisible to the garbage + /// collector, and the function and `data` are freed only when the isolate is torn down. + /// - Another owner that the garbage collector also traces, such as a resource sharing part of + /// `data` through a `std::rc::Rc`. `data` must be the only owner that traces what it + /// traces: tracing one `jsg::Rc` from two owners fails an assertion. + /// + /// `f` must capture nothing, which a compile-time assertion checks; whatever it needs comes + /// from `data`. + /// + /// `f`'s `Ok` value is the call's result and an `Err` is thrown. A panic throws an internal + /// error and terminates execution, as in a `#[jsg_method]`. As with the functions C++ JSG + /// wraps, the function's `name` is empty, its `length` is 0, and it can be called with `new`. + /// + /// Returns an `Err` if V8 could not create the function, which happens only when execution + /// is terminating. + /// + /// ```ignore + /// let greet = v8::Function::new(lock, String::from("hello"), |lock, greeting, args| { + /// let name = String::from_js(lock, args.get(0))?; + /// Ok(format!("{greeting}, {name}").to_js(lock)) + /// })?; + /// ``` + pub fn new<'s, T, F>(lock: &mut Lock<'s>, data: T, f: F) -> crate::Result> + where + T: Traced + 'static, + F: UnitType + + 'static + + for<'a> Fn( + &mut Lock<'a>, + &T, + &mut FunctionCallbackInfo<'a>, + ) -> Result, Error>, + { + let _ = f; + // As in `jsg::Rc::new`, the Wrappable owns a leaked `Rc` of the data, released when the + // Wrappable is destroyed. + let raw: *const FunctionData = Rc::into_raw(Rc::new(FunctionData(data))); + let fat: *mut dyn GarbageCollected = raw as *const dyn GarbageCollected as *mut _; + let mut wrappable: WrappableRc = + ffi::TraitObjectPtr::from_raw(fat, TypeId::of::>()).into(); + let function = + wrappable.new_function(lock, crate::function_callback(call_function::)); + // A new Wrappable starts with one strong reference, which `jsg::Rc` would hold. Here only + // the function owns it, so the reference is dropped and the garbage collector decides. + wrappable.remove_strong_ref(true); + function + } +} + +/// The callback of a function created by [`Function::new`] with data `T` and closure `F`. +fn call_function<'s, T, F>(lock: &mut Lock<'s>, args: &mut FunctionCallbackInfo<'s>) +where + T: Traced + 'static, + F: UnitType + + 'static + + for<'a> Fn( + &mut Lock<'a>, + &T, + &mut FunctionCallbackInfo<'a>, + ) -> Result, Error>, +{ + let Some(data) = WrappableRc::of_function(args) else { + unreachable!("a function created by Function::new has a Wrappable as its data"); + }; + let Some(data_ptr) = data.resolve::>() else { + unreachable!( + "a function created by Function::new has data of the type it was created with" + ); + }; + // SAFETY: `data` keeps the Wrappable, and with it the `FunctionData`, alive for this call. + // Only shared references to the `FunctionData` are ever created. + let data_ref = unsafe { &data_ptr.as_ref().0 }; + // SAFETY: `Function::new` was handed a value of `F`. + let f = unsafe { crate::scope::unit_value::() }; + match f(lock, data_ref, args) { + Ok(value) => args.set_return_value(value), + Err(err) => lock.throw_exception(&err), + } +} + +/// The error for an operation that V8 refused because execution is terminating. +pub(crate) fn terminated() -> Error { + Error::new_internal_error("JavaScript execution terminated") +} + impl<'s> Local<'s, Function> { /// Calls this function and converts the result via [`FromJS`]. /// @@ -3575,9 +3704,9 @@ impl ffi::TraitObjectPtr { unsafe { std::mem::transmute([self.type_id_lo, self.type_id_hi]) } } - /// Returns the resource as a typed `NonNull`, or `None` if the trait object has been + /// Returns the object as a typed `NonNull`, or `None` if the trait object has been /// cleared or holds a type other than `R`. - pub(crate) fn resolve(&self) -> Option> { + pub(crate) fn resolve(&self) -> Option> { if self.is_cleared() || self.type_id() != std::any::TypeId::of::() { return None; } @@ -4051,12 +4180,43 @@ impl WrappableRc { unsafe { ffi::wrappable_strong_refcount(&*self.handle.get()) } } + /// Creates a JavaScript function that calls `callback`, with an opaque wrapper of this + /// Wrappable as its data, which keeps the Wrappable alive while the function is reachable. + /// + /// Returns [`terminated`]'s error if V8 could not create the function, which happens only + /// when execution is terminating. + pub(crate) fn new_function<'s>( + &self, + lock: &mut Lock<'s>, + callback: crate::RawFunctionCallback, + ) -> crate::Result> { + // SAFETY: isolate is valid and locked (guaranteed by Lock); `callback` is a V8 function + // callback. + let handle = unsafe { + ffi::function_new( + lock.isolate().as_ffi(), + callback as usize, + self.handle.clone(), + ) + } + .map_err(|_| terminated())?; + // SAFETY: the function is created in the scope of `lock`. + unsafe { Local::from_ffi_maybe(handle) }.ok_or_else(terminated) + } + + /// Returns the Rust Wrappable whose opaque wrapper is the running function's data, as + /// [`new_function`](Self::new_function) attaches it, or `None` if the data is anything else. + pub(crate) fn of_function(args: &FunctionCallbackInfo<'_>) -> Option { + // SAFETY: args.0 is the running callback's FunctionCallbackInfo. + let handle: Option> = + unsafe { ffi::fci_get_data_wrappable(args.0) }.into(); + handle.map(|handle| Self { handle }) + } + /// Resolves the `Rc::into_raw` pointer stored in the Wrappable as a typed `NonNull`. /// - /// Returns `None` if the trait object has been cleared or the stored `TypeId` - /// does not match `R`, preventing type confusion in all builds. - #[doc(hidden)] - pub fn resolve_resource(&self) -> Option> { + /// Returns `None` if the trait object has been cleared or holds a type other than `R`. + pub(crate) fn resolve(&self) -> Option> { // SAFETY: wrappable pointer is valid (guaranteed by KjRc lifetime). let trait_ptr = unsafe { let wrappable = &*self.handle.get(); @@ -4064,4 +4224,13 @@ impl WrappableRc { }; trait_ptr.resolve::() } + + /// Resolves the `Rc::into_raw` pointer stored in the Wrappable as a typed `NonNull`. + /// + /// Returns `None` if the trait object has been cleared or the stored `TypeId` + /// does not match `R`, preventing type confusion in all builds. + #[doc(hidden)] + pub fn resolve_resource(&self) -> Option> { + self.resolve::() + } } From a4b764f1976b26ca808ea448afe7677ff0788440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 16:28:05 +0200 Subject: [PATCH 2/7] Test that C++ JSG function data is not read as Rust data 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. --- src/rust/jsg-test/ffi.c++ | 15 +++++++++++++++ src/rust/jsg-test/ffi.h | 5 +++++ src/rust/jsg-test/lib.rs | 14 ++++++++++++++ src/rust/jsg-test/tests/function_new.rs | 10 ++++++++++ 4 files changed, 44 insertions(+) diff --git a/src/rust/jsg-test/ffi.c++ b/src/rust/jsg-test/ffi.c++ index eea3b24943d..738632e2d32 100644 --- a/src/rust/jsg-test/ffi.c++ +++ b/src/rust/jsg-test/ffi.c++ @@ -192,6 +192,21 @@ size_t used_global_handles_size(Isolate* isolate) { return ::workerd::rust::jsg::to_ffi(v8::Local::Cast(obj)); } +bool cpp_wrapped_function_data_is_rust_wrappable(Isolate* isolate) { + auto& js = ::workerd::jsg::Lock::from(isolate); + auto context = isolate->GetCurrentContext(); + bool found = false; + auto function = js.wrapReturningFunction(context, + [&found](::workerd::jsg::Lock& js, + const v8::FunctionCallbackInfo& info) -> v8::Local { + auto& args = const_cast&>(info); + found = ::workerd::rust::jsg::fci_get_data_wrappable(&args) != kj::none; + return js.v8Undefined(); + }); + ::workerd::jsg::check(function->Call(context, v8::Undefined(isolate), 0, nullptr)); + return found; +} + ::workerd::rust::jsg::MaybeLocal empty_maybe_local() { return ::workerd::rust::jsg::maybe_local_to_ffi(v8::MaybeLocal()); } diff --git a/src/rust/jsg-test/ffi.h b/src/rust/jsg-test/ffi.h index 2bbabe28525..5251c926679 100644 --- a/src/rust/jsg-test/ffi.h +++ b/src/rust/jsg-test/ffi.h @@ -82,6 +82,11 @@ size_t used_global_handles_size(Isolate* isolate); // Used to test that Rust unwrap correctly rejects non-Rust wrappable objects. ::workerd::rust::jsg::Local create_cpp_tagged_object(Isolate* isolate); +// Calls a function that C++ JSG wrapped (Lock::wrapReturningFunction()), whose data is also an +// opaque wrapper, and returns whether rust::jsg::fci_get_data_wrappable() finds a Rust Wrappable +// in it. +bool cpp_wrapped_function_data_is_rust_wrappable(Isolate* isolate); + // Returns an empty v8::MaybeLocal, to test how it reaches Rust. ::workerd::rust::jsg::MaybeLocal empty_maybe_local(); diff --git a/src/rust/jsg-test/lib.rs b/src/rust/jsg-test/lib.rs index 5ea318dd856..3eedf27829a 100644 --- a/src/rust/jsg-test/lib.rs +++ b/src/rust/jsg-test/lib.rs @@ -102,6 +102,12 @@ mod ffi { #[allow(dead_code)] pub unsafe fn create_cpp_tagged_object(isolate: *mut Isolate) -> Local; + /// Calls a function that C++ JSG wrapped and returns whether + /// `jsg::v8::ffi::fci_get_data_wrappable` finds a Rust Wrappable in its data. + #[expect(clippy::allow_attributes)] + #[allow(dead_code)] + pub unsafe fn cpp_wrapped_function_data_is_rust_wrappable(isolate: *mut Isolate) -> bool; + /// Returns an empty `v8::MaybeLocal`. #[expect(clippy::allow_attributes)] #[allow(dead_code)] @@ -436,6 +442,14 @@ impl Harness { v8::Local::from_ffi(local) } } + + /// Calls a function that C++ JSG wrapped, whose data is an opaque wrapper like that of a + /// `v8::Function::new` function, and returns whether the data is taken for a Rust Wrappable. + pub fn cpp_wrapped_function_data_is_rust_wrappable(lock: &mut jsg::Lock<'_>) -> bool { + // SAFETY: `lock` proves the isolate is locked, a context is entered and a HandleScope is + // open. + unsafe { ffi::cpp_wrapped_function_data_is_rust_wrappable(lock.isolate().as_ffi()) } + } } /// Whether `a` and `b` belong to the same `v8::IsolateGroup`. diff --git a/src/rust/jsg-test/tests/function_new.rs b/src/rust/jsg-test/tests/function_new.rs index 64bce11f442..64bb9bc5694 100644 --- a/src/rust/jsg-test/tests/function_new.rs +++ b/src/rust/jsg-test/tests/function_new.rs @@ -138,6 +138,16 @@ fn function_shape_matches_cpp_jsg() { }); } +#[test] +fn data_of_cpp_wrapped_function_is_not_taken_for_rust_data() { + crate::Harness::new().run_in_context(|lock, _ctx| { + // Functions that C++ JSG wraps also have an opaque wrapper as their data, holding a C++ + // Wrappable that must not be read as a Rust one. + assert!(!crate::Harness::cpp_wrapped_function_data_is_rust_wrappable(lock)); + Ok(()) + }); +} + // ============================================================================= // Garbage collection // ============================================================================= From e5475fa338276b59009d812e175caf3877aac1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 13:10:29 +0100 Subject: [PATCH 3/7] Return a pending error from Function::new while terminating 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::call returns, so callers handle both alike and pass the termination on with `?`. --- src/rust/jsg-test/tests/function_new.rs | 53 +++++++++++++++++++++++++ src/rust/jsg/README.md | 5 ++- src/rust/jsg/v8.rs | 21 +++++----- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/rust/jsg-test/tests/function_new.rs b/src/rust/jsg-test/tests/function_new.rs index 64bb9bc5694..d4d26ff7023 100644 --- a/src/rust/jsg-test/tests/function_new.rs +++ b/src/rust/jsg-test/tests/function_new.rs @@ -13,6 +13,7 @@ use std::sync::atomic::Ordering; use jsg::FromJS; use jsg::ToJS; use jsg::v8; +use jsg_macros::jsg_method; use jsg_macros::jsg_resource; /// Creates a function whose data is `greeting` and stores it as the global `greet`. @@ -185,6 +186,58 @@ fn data_is_dropped_once_function_is_unreachable() { }); } +/// Terminates execution, then creates a function. +#[jsg_resource] +struct TerminatingCreator { + /// Whether `Function::new` failed with a pending error. + saw_pending: Cell, +} + +#[jsg_resource] +impl TerminatingCreator { + /// Requests a termination, which V8 delivers while `spin` runs, then creates a function. + #[jsg_method] + fn create_after_termination<'s>( + &self, + lock: &mut jsg::Lock<'s>, + spin: v8::Local<'s, v8::Value>, + ) -> Result<(), jsg::Error> { + lock.terminate_execution(); + let spin = spin.try_as::().unwrap(); + let err = spin + .call::>(lock, None, &[]) + .unwrap_err(); + assert!(err.is_pending()); + let err = v8::Function::new(lock, (), |lock, (), _args| Ok(lock.undefined())).unwrap_err(); + self.saw_pending.set(err.is_pending()); + Err(err) + } +} + +/// While execution is terminating, `Function::new` creates nothing and returns a pending error, +/// and returning it keeps the termination: JavaScript cannot catch it or run on. +#[test] +fn function_new_while_terminating_keeps_the_termination() { + crate::Harness::new().run_in_context(|lock, ctx| { + let creator = jsg::Rc::new(TerminatingCreator { + saw_pending: Cell::new(false), + }); + ctx.set_global(lock, "creator", creator.clone()); + let result = ctx.eval_raw( + lock, + "globalThis.caught = false; globalThis.after = false; + try { creator.createAfterTermination(() => { while (true) {} }); } + catch (e) { globalThis.caught = true; } + globalThis.after = true;", + ); + assert!(result.is_err(), "the script was not terminated"); + assert!(creator.saw_pending.get()); + assert!(!ctx.eval::(lock, "globalThis.caught").unwrap()); + assert!(!ctx.eval::(lock, "globalThis.after").unwrap()); + Ok(()) + }); +} + static LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); #[jsg_resource] diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index dcc63ab4f46..bdbc4bcfa69 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -262,8 +262,9 @@ let greet = v8::Function::new(lock, String::from("hello"), |lock, greeting, args ``` `f` returns `Result, jsg::Error>`: an `Err` is thrown, and a panic throws an -internal error and terminates execution, as in a `#[jsg_method]`. `Function::new` itself returns -an `Err` if V8 cannot create the function, which happens only when execution is terminating. As +internal error and terminates execution, as in a `#[jsg_method]`. While execution is terminating, +`Function::new` creates nothing and returns a pending error (see "Errors from JavaScript"), as +`call` does; return it to keep the termination propagating. As with the functions C++ JSG wraps, the function's `name` is empty, its `length` is 0, and it can be called with `new`. diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index b584f83f672..af4a4cf37aa 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -1615,8 +1615,8 @@ impl Function { /// error and terminates execution, as in a `#[jsg_method]`. As with the functions C++ JSG /// wraps, the function's `name` is empty, its `length` is 0, and it can be called with `new`. /// - /// Returns an `Err` if V8 could not create the function, which happens only when execution - /// is terminating. + /// While execution is terminating, creates nothing and returns an error for which + /// [`Error::is_pending`] is true, so that returning it propagates the termination. /// /// ```ignore /// let greet = v8::Function::new(lock, String::from("hello"), |lock, greeting, args| { @@ -1682,11 +1682,6 @@ where } } -/// The error for an operation that V8 refused because execution is terminating. -pub(crate) fn terminated() -> Error { - Error::new_internal_error("JavaScript execution terminated") -} - impl<'s> Local<'s, Function> { /// Calls this function and converts the result via [`FromJS`]. /// @@ -4269,13 +4264,17 @@ impl WrappableRc { /// Creates a JavaScript function that calls `callback`, with an opaque wrapper of this /// Wrappable as its data, which keeps the Wrappable alive while the function is reachable. /// - /// Returns [`terminated`]'s error if V8 could not create the function, which happens only - /// when execution is terminating. + /// While execution is terminating, creates nothing and returns [`Error::pending`]'s error, + /// as [`Local::::call`] does. V8 fails to create a function only then. pub(crate) fn new_function<'s>( &self, lock: &mut Lock<'s>, callback: crate::RawFunctionCallback, ) -> crate::Result> { + // SAFETY: isolate is valid and locked (guaranteed by Lock). + if unsafe { ffi::isolate_is_execution_terminating(lock.isolate().as_ffi()) } { + return Err(Error::pending()); + } // SAFETY: isolate is valid and locked (guaranteed by Lock); `callback` is a V8 function // callback. let handle = unsafe { @@ -4285,9 +4284,9 @@ impl WrappableRc { self.handle.clone(), ) } - .map_err(|_| terminated())?; + .map_err(|_| Error::pending())?; // SAFETY: the function is created in the scope of `lock`. - unsafe { Local::from_ffi_maybe(handle) }.ok_or_else(terminated) + unsafe { Local::from_ffi_maybe(handle) }.ok_or_else(Error::pending) } /// Returns the Rust Wrappable whose opaque wrapper is the running function's data, as From 67139116d3c958ed960dbaf85d57664db6ee6c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 11:35:11 +0200 Subject: [PATCH 4/7] Add JavaScript promises to Rust JSG 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::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, 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. --- src/rust/jsg-compile-tests/ok-promise.rs | 40 ++ .../promise-resolver-is-not-a-value.rs | 12 + .../promise-then-callback-must-not-capture.rs | 23 + src/rust/jsg-test/ffi.c++ | 4 + src/rust/jsg-test/ffi.h | 3 + src/rust/jsg-test/lib.rs | 12 + src/rust/jsg-test/tests/mod.rs | 1 + src/rust/jsg-test/tests/promise.rs | 572 ++++++++++++++++++ src/rust/jsg/ffi.c++ | 57 ++ src/rust/jsg/ffi.h | 24 + src/rust/jsg/lib.rs | 18 + src/rust/jsg/promise.rs | 280 +++++++++ src/rust/jsg/v8.rs | 52 ++ 13 files changed, 1098 insertions(+) create mode 100644 src/rust/jsg-compile-tests/ok-promise.rs create mode 100644 src/rust/jsg-compile-tests/promise-resolver-is-not-a-value.rs create mode 100644 src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs create mode 100644 src/rust/jsg-test/tests/promise.rs create mode 100644 src/rust/jsg/promise.rs diff --git a/src/rust/jsg-compile-tests/ok-promise.rs b/src/rust/jsg-compile-tests/ok-promise.rs new file mode 100644 index 00000000000..1c0ea5674f3 --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-promise.rs @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A method returns a promise, keeps its resolver past the call, and reacts to another promise, +// without `unsafe`. +use std::cell::RefCell; + +use jsg::v8; +use jsg_macros::jsg_method; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Waiter { + resolver: RefCell>>, +} + +#[jsg_resource] +impl Waiter { + #[jsg_method] + fn wait<'s>( + &self, + lock: &mut jsg::Lock<'s>, + input: v8::Local<'s, v8::Promise>, + ) -> Result, jsg::Error> { + let resolver = v8::PromiseResolver::new(lock)?; + *self.resolver.borrow_mut() = Some(resolver.to_traced_global(lock)); + input.then( + lock, + String::from("data"), + |lock, data, value| { + let object = lock.new_object(); + object.set(lock, data, value); + Ok(object.into()) + }, + |lock, _, reason| Ok(v8::Promise::rejected(lock, reason)?.into()), + )?; + Ok(resolver.promise()) + } +} diff --git a/src/rust/jsg-compile-tests/promise-resolver-is-not-a-value.rs b/src/rust/jsg-compile-tests/promise-resolver-is-not-a-value.rs new file mode 100644 index 00000000000..7fef747e539 --- /dev/null +++ b/src/rust/jsg-compile-tests/promise-resolver-is-not-a-value.rs @@ -0,0 +1,12 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// JavaScript only ever sees a promise, never the capability to settle it. +use jsg::v8; + +pub fn t<'s>(lock: &mut jsg::Lock<'s>) -> jsg::Result> { + let resolver = v8::PromiseResolver::new(lock)?; + Ok(jsg::ToJS::to_js(resolver, lock)) + //~^ ERROR E0277 +} diff --git a/src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs b/src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs new file mode 100644 index 00000000000..a45c25c3f41 --- /dev/null +++ b/src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A promise reaction is called through a plain function pointer, so its state goes in the +// reaction's data, which the garbage collector can see, rather than in captures. +use jsg::v8; + +pub fn t<'s>( + lock: &mut jsg::Lock<'s>, + promise: v8::Local<'s, v8::Promise>, +) -> jsg::Result> { + let name = String::from("captured"); + promise.then( + lock, + (), + move |lock, (), _value| { + //~^ ERROR E0277 + Ok(jsg::ToJS::to_js(name.as_str(), lock)) + }, + |_lock, (), reason| Ok(reason), + ) +} diff --git a/src/rust/jsg-test/ffi.c++ b/src/rust/jsg-test/ffi.c++ index 738632e2d32..515b6f0d210 100644 --- a/src/rust/jsg-test/ffi.c++ +++ b/src/rust/jsg-test/ffi.c++ @@ -167,6 +167,10 @@ void request_gc(Isolate* isolate, GcType gc_type) { } } +void run_microtasks(Isolate* isolate) { + ::workerd::jsg::Lock::from(isolate).runMicrotasks(); +} + void run_unlocked(Isolate* isolate, size_t data, ::rust::Fn callback) { v8::Unlocker unlocker(isolate); callback(data); diff --git a/src/rust/jsg-test/ffi.h b/src/rust/jsg-test/ffi.h index 5251c926679..644d6271d58 100644 --- a/src/rust/jsg-test/ffi.h +++ b/src/rust/jsg-test/ffi.h @@ -72,6 +72,9 @@ enum class GcType : uint8_t; // Triggers garbage collection for testing purposes. void request_gc(Isolate* isolate, GcType gc_type); +// Runs the isolate's pending microtasks, as the event loop does after each task. +void run_microtasks(Isolate* isolate); + // Calls `callback` with the isolate unlocked, then locks it again. void run_unlocked(Isolate* isolate, size_t data, ::rust::Fn callback); diff --git a/src/rust/jsg-test/lib.rs b/src/rust/jsg-test/lib.rs index 3eedf27829a..fafcce82e55 100644 --- a/src/rust/jsg-test/lib.rs +++ b/src/rust/jsg-test/lib.rs @@ -81,6 +81,11 @@ mod ffi { #[allow(dead_code)] pub unsafe fn request_gc(isolate: *mut Isolate, gc_type: GcType); + /// Runs the isolate's pending microtasks. + #[expect(clippy::allow_attributes)] + #[allow(dead_code)] + pub unsafe fn run_microtasks(isolate: *mut Isolate); + /// Calls `callback` with the isolate unlocked (`v8::Unlocker`), then locks it again. #[expect(clippy::allow_attributes)] #[allow(dead_code)] @@ -366,6 +371,13 @@ impl Harness { unsafe { ffi::request_gc(lock.isolate().as_ffi(), ffi::GcType::Full) }; } + /// Runs the pending microtasks, such as promise reactions, as the event loop does after + /// each task. The isolate's microtask policy is explicit, so nothing else runs them. + pub fn run_microtasks(lock: &mut jsg::Lock<'_>) { + // SAFETY: isolate is valid and locked (guaranteed by Lock). + unsafe { ffi::run_microtasks(lock.isolate().as_ffi()) }; + } + pub fn request_minor_gc(lock: &mut jsg::Lock<'_>) { // SAFETY: isolate is valid and locked (guaranteed by Lock). unsafe { ffi::request_gc(lock.isolate().as_ffi(), ffi::GcType::Minor) }; diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index d37cb58e79a..8d21761bea5 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -26,6 +26,7 @@ mod name; mod nested_type; mod non_coercible; mod option; +mod promise; mod resource_callback; mod resource_conversion; mod resource_properties; diff --git a/src/rust/jsg-test/tests/promise.rs b/src/rust/jsg-test/tests/promise.rs new file mode 100644 index 00000000000..08fd04b7a57 --- /dev/null +++ b/src/rust/jsg-test/tests/promise.rs @@ -0,0 +1,572 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! JavaScript promises in Rust: creating and settling them through `v8::PromiseResolver`, and +//! reacting to them with `Local::then`. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use jsg::FromJS; +use jsg::ToJS; +use jsg::v8; +use jsg::v8::PromiseState; +use jsg_macros::jsg_method; +use jsg_macros::jsg_resource; + +/// Evaluates `code`, which records what happens in the global array `log`, runs the microtasks, +/// and returns the log joined with `|`. +fn log_after_microtasks(lock: &mut jsg::Lock, ctx: &crate::EvalContext<'_>, code: &str) -> String { + ctx.eval_raw(lock, &format!("globalThis.log = []; {code}")) + .unwrap(); + crate::Harness::run_microtasks(lock); + ctx.eval(lock, "log.join('|')").unwrap() +} + +/// What a promise settled with, as `"fulfilled: "` or `"rejected: : "`. +const RECORD: &str = "globalThis.record = (p) => p.then( + (v) => log.push(`fulfilled: ${v}`), + (e) => log.push(`rejected: ${e?.name}: ${e?.message ?? e}`));"; + +// ============================================================================= +// Resolvers and promise state +// ============================================================================= + +#[test] +fn resolver_settles_its_promise_once() { + crate::Harness::new().run_in_context(|lock, _ctx| { + let resolver = v8::PromiseResolver::new(lock).unwrap(); + let promise = resolver.promise(); + assert_eq!(promise.state(), PromiseState::Pending); + assert!(promise.result(lock).is_none()); + + let value = jsg::Number::new(42.0).to_js(lock); + resolver.resolve(lock, value)?; + let other = "ignored".to_js(lock); + // Settling a settled promise does nothing. + resolver.reject(lock, other)?; + + assert_eq!(promise.state(), PromiseState::Fulfilled); + let result = promise.result(lock).unwrap(); + assert_eq!(jsg::Number::from_js(lock, result)?.value(), 42.0); + Ok(()) + }); +} + +#[test] +fn rejected_promise_holds_its_reason() { + crate::Harness::new().run_in_context(|lock, _ctx| { + let reason = lock.range_error("too far"); + let promise = v8::Promise::rejected(lock, reason.into()).unwrap(); + promise.mark_as_handled(); + assert_eq!(promise.state(), PromiseState::Rejected); + assert!(promise.result(lock).unwrap() == reason.into()); + Ok(()) + }); +} + +#[test] +fn resolved_returns_promises_unchanged_and_follows_thenables() { + crate::Harness::new().run_in_context(|lock, ctx| { + let existing = ctx + .eval_raw(lock, "globalThis.p = Promise.resolve(1); p") + .unwrap(); + let same = v8::Promise::resolved(lock, existing).unwrap(); + assert!(v8::Local::::from(same) == existing); + + let thenable = ctx + .eval_raw(lock, "({ then(resolve) { resolve('from thenable'); } })") + .unwrap(); + let followed = v8::Promise::resolved(lock, thenable).unwrap(); + ctx.set_global(lock, "followed", followed); + assert_eq!( + log_after_microtasks(lock, ctx, &format!("{RECORD} record(followed)")), + "fulfilled: from thenable" + ); + Ok(()) + }); +} + +#[test] +fn rejecting_with_an_error_creates_its_exception() { + crate::Harness::new().run_in_context(|lock, ctx| { + let err = jsg::Error::new_type_error("bad input"); + let promise = v8::Promise::rejected_with_error(lock, &err).unwrap(); + ctx.set_global(lock, "p", promise); + assert_eq!( + log_after_microtasks(lock, ctx, &format!("{RECORD} record(p)")), + "rejected: TypeError: bad input" + ); + Ok(()) + }); +} + +#[test] +fn promise_argument_accepts_any_value() { + crate::Harness::new().run_in_context(|lock, ctx| { + let value = "plain".to_js(lock); + let promise = v8::Local::::from_js(lock, value)?; + ctx.set_global(lock, "p", promise); + assert_eq!( + log_after_microtasks(lock, ctx, &format!("{RECORD} record(p)")), + "fulfilled: plain" + ); + Ok(()) + }); +} + +// ============================================================================= +// then +// ============================================================================= + +#[test] +fn then_runs_callbacks_as_microtasks_with_their_data() { + crate::Harness::new().run_in_context(|lock, ctx| { + let promise = ctx.eval_raw(lock, "Promise.resolve('world')").unwrap(); + let promise = promise.try_as::().unwrap(); + let greeted = promise + .then( + lock, + "hello".to_owned(), + |lock, greeting, value| { + let name = String::from_js(lock, value)?; + Ok(format!("{greeting}, {name}").to_js(lock)) + }, + |_lock, _, reason| Ok(reason), + ) + .unwrap(); + // Reactions never run synchronously. + assert_eq!(greeted.state(), PromiseState::Pending); + crate::Harness::run_microtasks(lock); + let result = greeted.result(lock).unwrap(); + assert_eq!(String::from_js(lock, result)?, "hello, world"); + Ok(()) + }); +} + +#[test] +fn then_rejects_with_callback_error_and_handles_rejections() { + crate::Harness::new().run_in_context(|lock, ctx| { + let fulfilled = ctx.eval_raw(lock, "Promise.resolve(1)").unwrap(); + let fulfilled = fulfilled.try_as::().unwrap(); + let failed = fulfilled + .then( + lock, + (), + |_lock, (), _value| Err(jsg::Error::new_range_error("out of range")), + |_lock, (), reason| Ok(reason), + ) + .unwrap(); + ctx.set_global(lock, "failed", failed); + + let rejected = ctx + .eval_raw(lock, "Promise.reject(new TypeError('nope'))") + .unwrap(); + let rejected = rejected.try_as::().unwrap(); + let recovered = rejected + .then( + lock, + (), + |_lock, (), value| Ok(value), + |lock, (), reason| { + let err = jsg::Error::from_js(lock, reason)?; + Ok(format!("recovered from {}", err.message).to_js(lock)) + }, + ) + .unwrap(); + ctx.set_global(lock, "recovered", recovered); + + assert_eq!( + log_after_microtasks( + lock, + ctx, + &format!("{RECORD} record(failed).then(() => record(recovered))") + ), + "rejected: RangeError: out of range|fulfilled: recovered from nope" + ); + Ok(()) + }); +} + +#[test] +fn panic_in_then_callback_is_an_internal_error() { + crate::Harness::new().run_in_context(|lock, ctx| { + let promise = ctx.eval_raw(lock, "Promise.resolve(1)").unwrap(); + let promise = promise.try_as::().unwrap(); + let panicked = promise + .then( + lock, + (), + |_lock, (), _value| -> Result, jsg::Error> { + panic!("intentional panic in a then callback"); + }, + |_lock, (), reason| Ok(reason), + ) + .unwrap(); + panicked.mark_as_handled(); + crate::Harness::run_microtasks(lock); + assert_eq!(panicked.state(), PromiseState::Rejected); + let reason = panicked.result(lock).unwrap(); + let err = jsg::Error::from_js(lock, reason)?; + assert!(err.message.contains("internal error"), "{}", err.message); + Ok(()) + }); +} + +/// Counts its drops in the counter it points to. +struct DropCounter(&'static AtomicUsize); + +impl Drop for DropCounter { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +impl jsg::Traced for DropCounter {} + +static SETTLED_DATA_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[test] +fn then_data_is_dropped_once_the_promise_settles() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resolver = v8::PromiseResolver::new(lock).unwrap(); + resolver + .promise() + .then( + lock, + DropCounter(&SETTLED_DATA_DROPS), + |lock, _, _| Ok(lock.undefined()), + |lock, _, _| Ok(lock.undefined()), + ) + .unwrap(); + // The pending promise keeps its reactions, and with them the data. + ctx.set_global(lock, "resolver", resolver.promise()); + crate::Harness::request_gc(lock); + assert_eq!(SETTLED_DATA_DROPS.load(Ordering::SeqCst), 0); + let value = lock.undefined(); + resolver.resolve(lock, value)?; + crate::Harness::run_microtasks(lock); + Ok(()) + }); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(SETTLED_DATA_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// Consumer: a resource that settles a promise from a later call +// ============================================================================= + +/// Hands out a promise and settles it when JavaScript calls `resolve` or `fail` later, holding +/// the resolver in between, as an API waiting on an event would. +#[jsg_resource] +struct Deferred { + resolver: RefCell>>, +} + +#[jsg_resource] +impl Deferred { + #[jsg_method] + fn wait<'s>(&self, lock: &mut jsg::Lock<'s>) -> Result, jsg::Error> { + let resolver = v8::PromiseResolver::new(lock)?; + *self.resolver.borrow_mut() = Some(resolver.to_traced_global(lock)); + Ok(resolver.promise()) + } + + #[jsg_method] + fn resolve<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: v8::Local<'s, v8::Value>, + ) -> Result<(), jsg::Error> { + let resolver = self.take_resolver(lock)?; + resolver.resolve(lock, value) + } + + #[jsg_method] + fn fail(&self, lock: &mut jsg::Lock<'_>, message: String) -> Result<(), jsg::Error> { + let resolver = self.take_resolver(lock)?; + resolver.reject_with_error(lock, &jsg::Error::new_range_error(message)) + } +} + +impl Deferred { + fn take_resolver<'s>( + &self, + lock: &mut jsg::Lock<'s>, + ) -> Result, jsg::Error> { + let resolver = self.resolver.borrow_mut().take(); + resolver + .map(|resolver| resolver.as_local(lock)) + .ok_or_else(|| jsg::Error::new_type_error("nothing is waiting")) + } +} + +#[test] +fn resource_settles_promise_from_later_call() { + crate::Harness::new().run_in_context(|lock, ctx| { + let deferred = jsg::Rc::new(Deferred { + resolver: RefCell::new(None), + }); + ctx.set_global(lock, "deferred", deferred); + let code = format!( + "{RECORD} + const first = deferred.wait(); + record(first); + log.push(`pending: ${{first instanceof Promise}}`); + deferred.resolve(42); + record(deferred.wait()); + deferred.fail('too late');" + ); + assert_eq!( + log_after_microtasks(lock, ctx, &code), + "pending: true|fulfilled: 42|rejected: RangeError: too late" + ); + Ok(()) + }); +} + +static DEFERRED_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// A promise that only its resolver's owner references. +#[jsg_resource] +struct DroppedDeferred { + _drops: DropCounter, + resolver: RefCell>>, +} + +#[jsg_resource] +impl DroppedDeferred { + #[jsg_method] + fn wait<'s>(&self, lock: &mut jsg::Lock<'s>) -> Result, jsg::Error> { + let resolver = v8::PromiseResolver::new(lock)?; + *self.resolver.borrow_mut() = Some(resolver.to_traced_global(lock)); + Ok(resolver.promise()) + } +} + +#[test] +fn cycle_through_held_resolver_is_collected() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let deferred = jsg::Rc::new(DroppedDeferred { + _drops: DropCounter(&DEFERRED_DROPS), + resolver: RefCell::new(None), + }); + ctx.set_global(lock, "deferred", deferred); + // The promise's reaction holds the resource, which holds the resolver of the promise. + ctx.eval_raw( + lock, + "const d = deferred; d.wait().then(() => d); delete globalThis.deferred", + ) + .unwrap(); + Ok(()) + }); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(DEFERRED_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// Consumer: a read-through cache, shaped like MemoryCache.read(key, fallback) +// ============================================================================= + +/// The cache's state, shared by the resource and the reactions to in-flight fallbacks. +#[derive(Default)] +struct CacheState { + entries: RefCell>>, + in_flight: RefCell>>, +} + +impl jsg::Traced for CacheState { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.entries.trace(visitor); + self.in_flight.trace(visitor); + } +} + +/// `read(key, fallback)` returns a promise for the cached value. On a miss it calls +/// `fallback(key)` once, however many reads wait for it, caches what its promise fulfills with, +/// and passes a rejection through to every waiting read without caching it. +#[jsg_resource] +struct ReadThroughCache { + state: std::rc::Rc, +} + +/// What the reactions to a fallback's promise need. +struct Fill { + state: std::rc::Rc, + key: String, +} + +impl jsg::Traced for Fill { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.state.trace(visitor); + } +} + +#[jsg_resource] +impl ReadThroughCache { + #[jsg_method] + fn read<'s>( + &self, + lock: &mut jsg::Lock<'s>, + key: String, + fallback: v8::Local<'s, v8::Value>, + ) -> Result, jsg::Error> { + let cached = self + .state + .entries + .borrow() + .get(&key) + .map(|value| value.as_local(lock)); + if let Some(value) = cached { + return v8::Promise::resolved(lock, value); + } + let in_flight = self + .state + .in_flight + .borrow() + .get(&key) + .map(|promise| promise.as_local(lock)); + if let Some(promise) = in_flight { + return Ok(promise); + } + + let fallback = fallback + .try_as::() + .ok_or_else(|| jsg::Error::new_type_error("fallback must be a function"))?; + let key_js = key.as_str().to_js(lock); + // No RefCell borrow is held here: the fallback is JavaScript and may call `read` again. + let produced = fallback.call::>(lock, None, &[key_js])?; + let produced = v8::Local::::from_js(lock, produced)?; + let filled = produced.then( + lock, + Fill { + state: std::rc::Rc::clone(&self.state), + key: key.clone(), + }, + |lock, fill, value| { + fill.state.in_flight.borrow_mut().remove(&fill.key); + fill.state + .entries + .borrow_mut() + .insert(fill.key.clone(), value.to_traced_global(lock)); + Ok(value) + }, + |lock, fill, reason| { + fill.state.in_flight.borrow_mut().remove(&fill.key); + // Resolving with a rejected promise passes `reason` on unchanged; an `Err` + // would reject with a new exception instead. + let rejected = v8::Promise::rejected(lock, reason)?; + Ok(rejected.into()) + }, + )?; + self.state + .in_flight + .borrow_mut() + .insert(key, filled.to_traced_global(lock)); + Ok(filled) + } +} + +fn install_cache(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>) { + let cache = jsg::Rc::new(ReadThroughCache { + state: std::rc::Rc::default(), + }); + ctx.set_global(lock, "cache", cache); +} + +#[test] +fn cache_coalesces_reads_and_caches_fulfillment() { + crate::Harness::new().run_in_context(|lock, ctx| { + install_cache(lock, ctx); + let code = format!( + "{RECORD} + globalThis.calls = 0; + globalThis.fallback = (key) => {{ + calls++; + return new Promise((resolve) => {{ globalThis.fill = () => resolve(key.toUpperCase()); }}); + }}; + const a = cache.read('a', fallback); + const b = cache.read('a', fallback); + log.push(`same promise: ${{a === b}}`, `calls: ${{calls}}`); + record(a); + record(b); + fill();" + ); + assert_eq!( + log_after_microtasks(lock, ctx, &code), + "same promise: true|calls: 1|fulfilled: A|fulfilled: A" + ); + // Served from the cache: the fallback does not run again. + assert_eq!( + log_after_microtasks( + lock, + ctx, + &format!("{RECORD} record(cache.read('a', fallback)); log.push(`calls: ${{calls}}`)") + ), + "calls: 1|fulfilled: A" + ); + Ok(()) + }); +} + +#[test] +fn cache_accepts_plain_values_from_fallback() { + crate::Harness::new().run_in_context(|lock, ctx| { + install_cache(lock, ctx); + assert_eq!( + log_after_microtasks( + lock, + ctx, + &format!("{RECORD} record(cache.read('k', (key) => `value of ${{key}}`))") + ), + "fulfilled: value of k" + ); + Ok(()) + }); +} + +#[test] +fn cache_passes_rejection_through_without_caching_it() { + crate::Harness::new().run_in_context(|lock, ctx| { + install_cache(lock, ctx); + let code = format!( + "{RECORD} + globalThis.error = new TypeError('backend down'); + globalThis.calls = 0; + cache.read('k', () => {{ calls++; return Promise.reject(error); }}) + .catch((e) => log.push(`same error: ${{e === error}}`)) + .then(() => record(cache.read('k', () => {{ calls++; return 'recovered'; }}))) + .then(() => log.push(`calls: ${{calls}}`));" + ); + assert_eq!( + log_after_microtasks(lock, ctx, &code), + "same error: true|fulfilled: recovered|calls: 2" + ); + Ok(()) + }); +} + +#[test] +fn cache_rejects_non_function_fallback() { + crate::Harness::new().run_in_context(|lock, ctx| { + install_cache(lock, ctx); + let code = + "try { cache.read('k', 42); 'no error' } catch (e) { `${e.name}: ${e.message}` }"; + assert_eq!( + ctx.eval::(lock, code).unwrap(), + "TypeError: fallback must be a function" + ); + Ok(()) + }); +} diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 78f8cb6831f..37f3436e5e3 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -254,6 +254,10 @@ bool local_is_function(const Local& val) { return local_as_ref_from_ffi(val)->IsFunction(); } +bool local_is_promise(const Local& val) { + return local_as_ref_from_ffi(val)->IsPromise(); +} + bool local_is_symbol(const Local& val) { return local_as_ref_from_ffi(val)->IsSymbol(); } @@ -488,6 +492,53 @@ Local local_object_get_property(Isolate* isolate, const Local& object, ::rust::S return to_ffi(unwrapCoerce(isolate, [&] { return v8_obj->Get(context, v8_key); })); } +// Local +static_assert(static_cast(PromiseState::Pending) == v8::Promise::kPending); +static_assert(static_cast(PromiseState::Fulfilled) == v8::Promise::kFulfilled); +static_assert(static_cast(PromiseState::Rejected) == v8::Promise::kRejected); + +PromiseState local_promise_state(const Local& promise) { + return static_cast(local_as_ref_from_ffi(promise)->State()); +} + +Local local_promise_result(const Local& promise) { + auto v8Promise = local_as_ref_from_ffi(promise); + KJ_REQUIRE(v8Promise->State() != v8::Promise::kPending, "a pending promise has no result"); + return to_ffi(v8Promise->Result()); +} + +void local_promise_mark_as_handled(const Local& promise) { + local_as_ref_from_ffi(promise)->MarkAsHandled(); +} + +MaybeLocal local_promise_then( + Isolate* isolate, const Local& promise, const Local& on_fulfilled, const Local& on_rejected) { + return maybe_local_to_ffi(local_as_ref_from_ffi(promise)->Then( + isolate->GetCurrentContext(), local_as_ref_from_ffi(on_fulfilled), + local_as_ref_from_ffi(on_rejected))); +} + +// Local +MaybeLocal promise_resolver_new(Isolate* isolate) { + return maybe_local_to_ffi(v8::Promise::Resolver::New(isolate->GetCurrentContext())); +} + +Local local_promise_resolver_promise(const Local& resolver) { + return to_ffi(local_as_ref_from_ffi(resolver)->GetPromise()); +} + +bool local_promise_resolver_resolve(Isolate* isolate, const Local& resolver, const Local& value) { + return local_as_ref_from_ffi(resolver) + ->Resolve(isolate->GetCurrentContext(), local_as_ref_from_ffi(value)) + .IsJust(); +} + +bool local_promise_resolver_reject(Isolate* isolate, const Local& resolver, const Local& reason) { + return local_as_ref_from_ffi(resolver) + ->Reject(isolate->GetCurrentContext(), local_as_ref_from_ffi(reason)) + .IsJust(); +} + // Local Local local_new_array(Isolate* isolate, size_t length) { return to_ffi(v8::Array::New(isolate, length)); @@ -1372,6 +1423,12 @@ Local exception_create_from_bytes( return exceptionCreateFromMessage(exception_type, message); } +Local exception_create_internal(Isolate* isolate, ::rust::Str internalMessage) { + // See isolate_throw_internal_error() for why the message is copied. + auto message = kj::heapString(internalMessage.data(), internalMessage.size()); + return to_ffi(::workerd::jsg::makeInternalError(isolate, message)); +} + // TryCatch bool catch_exception(v8::TryCatch& tryCatch, MaybeLocal& exception) { // The rule of jsg::Lock::tryCatch(): nothing caught means a termination, which V8 may already diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index cba2392a5bd..afbe94442d9 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -33,6 +33,7 @@ struct IsolateState; struct GcVisitor; enum class BackingStoreInitializationMode : ::std::uint8_t; enum class ExceptionType : ::std::uint8_t; +enum class PromiseState : ::std::uint8_t; enum class FastApiType : ::std::uint8_t; // ModuleType enum is generated by CXX in v8.rs.h - forward declare it here enum class ModuleType : ::std::uint8_t; @@ -142,6 +143,7 @@ bool local_is_array_buffer(const Local& val); bool local_is_array_buffer_view(const Local& val); bool local_is_shared_array_buffer(const Local& val); bool local_is_function(const Local& val); +bool local_is_promise(const Local& val); bool local_is_symbol(const Local& val); bool local_is_name(const Local& val); bool local_is_big_int(const Local& val); @@ -207,6 +209,25 @@ void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::Str key); Local local_object_get_property(Isolate* isolate, const Local& object, ::rust::Str key); +// Local +PromiseState local_promise_state(const Local& promise); +// The fulfillment value or rejection reason; `promise` must not be pending. +Local local_promise_result(const Local& promise); +void local_promise_mark_as_handled(const Local& promise); +// `promise.then(on_fulfilled, on_rejected)`, without looking up `then`. Empty if execution is +// terminating. +MaybeLocal local_promise_then( + Isolate* isolate, const Local& promise, const Local& on_fulfilled, const Local& on_rejected); + +// Local +// Empty if execution is terminating. +MaybeLocal promise_resolver_new(Isolate* isolate); +Local local_promise_resolver_promise(const Local& resolver); +// Resolving with a thenable reads its `then` property, which can run JavaScript; an exception it +// throws rejects the promise. Both return false only if execution is terminating. +bool local_promise_resolver_resolve(Isolate* isolate, const Local& resolver, const Local& value); +bool local_promise_resolver_reject(Isolate* isolate, const Local& resolver, const Local& reason); + // Local Local local_new_array(Isolate* isolate, size_t length); uint32_t local_array_length(const Local& array); @@ -390,6 +411,9 @@ void realm_detach(Isolate* isolate, const IsolateState* state); // than rejected, so this accepts arbitrary byte input (e.g. filesystem paths). Local exception_create_from_bytes( Isolate* isolate, ExceptionType exception_type, const uint8_t* data, int32_t length); +// The exception `isolate_throw_internal_error` throws, without throwing it: logs +// `internalMessage` with a new reference ID and returns a generic error carrying only the ID. +Local exception_create_internal(Isolate* isolate, ::rust::Str internalMessage); // TryCatch // Decides what `tryCatch` caught as jsg::Lock::tryCatch() does: stores a catchable exception in diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 212eeedab62..626e0d07287 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -948,6 +948,24 @@ impl<'s> Lock<'s> { } } + /// Returns the exception that [`Lock::throw_exception`] would throw for `err`, without + /// throwing it: an internal error is redacted (and its message logged) the same way. + /// + /// Use it to reject a promise with an `Error`. + pub fn exception(&mut self, err: &Error) -> v8::Local<'s, v8::Value> { + if err.is_internal { + // SAFETY: isolate is valid and locked (guaranteed by Lock); the exception lives in + // the lock's scope. + return unsafe { + v8::Local::from_ffi(v8::ffi::exception_create_internal( + self.isolate().as_ffi(), + &err.message, + )) + }; + } + err.to_local(self) + } + /// Throws an internal error as a V8 exception, matching `makeInternalError()` in C++. /// /// Generates a unique error reference ID, logs `internal_message` with the ID via diff --git a/src/rust/jsg/promise.rs b/src/rust/jsg/promise.rs new file mode 100644 index 00000000000..1fd9a528882 --- /dev/null +++ b/src/rust/jsg/promise.rs @@ -0,0 +1,280 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! JavaScript promises: [`Promise`] and [`PromiseResolver`] handles. +//! +//! A Rust method returns a promise like any other handle, as a `Local<'s, Promise>`, and +//! settles it through a [`PromiseResolver`], now or from a later call that holds the resolver in a +//! [`TracedGlobal`](super::TracedGlobal). [`Local::then`] reacts to a promise with Rust +//! callbacks, which run as microtasks like the reactions JavaScript registers. +//! +//! Every operation that can fail fails only because execution is terminating, and then returns +//! an internal [`Error`], as [`Function::new`] does when V8 cannot create a function. +//! +//! The handles are untyped, like C++ JSG's `JsPromise`: values are JavaScript values, converted +//! with [`ToJS`](crate::ToJS) and [`FromJS`] at the edges. C++ JSG's typed `jsg::Promise`, +//! which can carry C++ values through JavaScript, has no counterpart. + +use super::As; +use super::Function; +use super::Local; +use super::Object; +use super::Value; +use super::ffi; +pub use super::ffi::PromiseState; +use crate::Error; +use crate::FromJS; +use crate::Lock; +use crate::Traced; +use crate::UnitType; + +/// The error for an operation that V8 refused because execution is terminating. +fn terminated() -> Error { + Error::new_internal_error("JavaScript execution terminated") +} + +/// Marker for a `v8::Promise` handle. +pub struct Promise; + +/// Marker for a `v8::Promise::Resolver` handle: the capability to settle one promise. +/// +/// Unlike a [`Promise`], a resolver does not convert to a JavaScript value, so it cannot reach +/// JavaScript, which only ever sees the promise. +pub struct PromiseResolver; + +impl PromiseResolver { + /// Creates a pending promise and the resolver that settles it. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn new<'s>(lock: &mut Lock<'s>) -> crate::Result> { + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; a + // non-empty handle is a resolver V8 created in the lock's scope. + unsafe { Local::from_ffi_maybe(ffi::promise_resolver_new(lock.isolate().as_ffi())) } + .ok_or_else(terminated) + } +} + +impl<'s> Local<'s, PromiseResolver> { + /// The promise this resolver settles. + pub fn promise(self) -> Local<'s, Promise> { + // SAFETY: self is a valid resolver handle; the promise lives in the same scope. + unsafe { Local::from_ffi(ffi::local_promise_resolver_promise(self.raw())) } + } + + /// Resolves the promise with `value`, like the `resolve` function of `new Promise()`: a + /// thenable `value` is followed, and settling an already-settled promise does nothing. + /// + /// Following a thenable reads its `then` property, which can run JavaScript. If that + /// throws, the promise is rejected with the exception. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn resolve(self, lock: &mut Lock<'s>, value: Local<'s, Value>) -> crate::Result<()> { + self.debug_assert_isolate(lock); + value.debug_assert_isolate(lock); + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; self and + // value are valid handles. + unsafe { + ffi::local_promise_resolver_resolve(lock.isolate().as_ffi(), self.raw(), value.raw()) + } + .then_some(()) + .ok_or_else(terminated) + } + + /// Rejects the promise with `reason`. Settling an already-settled promise does nothing. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn reject(self, lock: &mut Lock<'s>, reason: Local<'s, Value>) -> crate::Result<()> { + self.debug_assert_isolate(lock); + reason.debug_assert_isolate(lock); + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; self and + // reason are valid handles. + unsafe { + ffi::local_promise_resolver_reject(lock.isolate().as_ffi(), self.raw(), reason.raw()) + } + .then_some(()) + .ok_or_else(terminated) + } + + /// Rejects the promise with the exception that throwing `err` would throw; see + /// [`Lock::exception`]. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn reject_with_error(self, lock: &mut Lock<'s>, err: &Error) -> crate::Result<()> { + let reason = lock.exception(err); + self.reject(lock, reason) + } +} + +impl Promise { + /// A promise resolved with `value`, like `Promise.resolve(value)`: a promise is returned + /// unchanged and a thenable is followed. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn resolved<'s>( + lock: &mut Lock<'s>, + value: Local<'s, Value>, + ) -> crate::Result> { + if let Some(promise) = value.try_as::() { + return Ok(promise); + } + let resolver = PromiseResolver::new(lock)?; + resolver.resolve(lock, value)?; + Ok(resolver.promise()) + } + + /// A promise rejected with `reason`, like `Promise.reject(reason)`. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn rejected<'s>( + lock: &mut Lock<'s>, + reason: Local<'s, Value>, + ) -> crate::Result> { + let resolver = PromiseResolver::new(lock)?; + resolver.reject(lock, reason)?; + Ok(resolver.promise()) + } + + /// A promise rejected with the exception that throwing `err` would throw; see + /// [`Lock::exception`]. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn rejected_with_error<'s>( + lock: &mut Lock<'s>, + err: &Error, + ) -> crate::Result> { + let reason = lock.exception(err); + Self::rejected(lock, reason) + } +} + +/// The data of the two functions [`Local::then`] registers, which share it. +struct Reactions(std::rc::Rc); + +impl Traced for Reactions { + fn trace(&self, visitor: &mut crate::GcVisitor) { + self.0.trace(visitor); + } +} + +impl<'s> Local<'s, Promise> { + /// The promise's state. + pub fn state(self) -> PromiseState { + // SAFETY: self is a valid promise handle. + unsafe { ffi::local_promise_state(self.raw()) } + } + + /// The fulfillment value or rejection reason, or `None` while the promise is pending. + pub fn result(self, _lock: &mut Lock<'s>) -> Option> { + if self.state() == PromiseState::Pending { + return None; + } + // SAFETY: self is a valid promise handle, which is not pending. + let result = unsafe { ffi::local_promise_result(self.raw()) }.ok()?; + // SAFETY: the result is a handle in the lock's scope. + Some(unsafe { Local::from_ffi(result) }) + } + + /// Marks the promise as handled, so that its rejection is not reported as unhandled. + pub fn mark_as_handled(self) { + // SAFETY: self is a valid promise handle. + unsafe { ffi::local_promise_mark_as_handled(self.raw()) } + } + + /// Registers `on_fulfilled` and `on_rejected` to run when the promise settles, like + /// `promise.then(onFulfilled, onRejected)` without looking up `then`, and returns the promise + /// for the result of whichever runs. + /// + /// The callbacks run as microtasks, with `data` and the fulfillment value or rejection + /// reason. An `Ok` value resolves the returned promise (a promise or thenable is followed) + /// and an `Err` rejects it with the exception that throwing it would throw. A panic throws an + /// internal error and terminates execution, as in a `#[jsg_method]`. + /// + /// As with [`Function::new`], the callbacks must capture nothing, which a compile-time + /// assertion checks, and whatever they need comes from `data`. The garbage collector traces + /// `data` until the promise settles and drops it once neither callback can run, so it may + /// hold `TracedGlobal`s and `jsg::Rc`s, including ones that lead back to the promise. + /// + /// Registering `on_rejected` marks the promise as handled, as in JavaScript. + /// + /// # Errors + /// + /// Fails if execution is terminating. + pub fn then( + self, + lock: &mut Lock<'s>, + data: T, + on_fulfilled: F, + on_rejected: R, + ) -> crate::Result + where + T: Traced + 'static, + F: UnitType + + 'static + + for<'a> Fn(&mut Lock<'a>, &T, Local<'a, Value>) -> Result, Error>, + R: UnitType + + 'static + + for<'a> Fn(&mut Lock<'a>, &T, Local<'a, Value>) -> Result, Error>, + { + self.debug_assert_isolate(lock); + let _ = (on_fulfilled, on_rejected); + let data = std::rc::Rc::new(data); + let fulfilled = Function::new( + lock, + Reactions(std::rc::Rc::clone(&data)), + |lock, data, args| { + // SAFETY: `then` was handed a value of `F`. + let f = unsafe { crate::scope::unit_value::() }; + f(lock, &data.0, args.get(0)) + }, + )?; + let rejected = Function::new(lock, Reactions(data), |lock, data, args| { + // SAFETY: `then` was handed a value of `R`. + let f = unsafe { crate::scope::unit_value::() }; + f(lock, &data.0, args.get(0)) + })?; + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; all three + // are valid handles, and a non-empty result is a promise in the lock's scope. + unsafe { + Local::from_ffi_maybe(ffi::local_promise_then( + lock.isolate().as_ffi(), + self.raw(), + fulfilled.raw(), + rejected.raw(), + )) + } + .ok_or_else(terminated) + } +} + +impl<'s> FromJS<'s> for Local<'s, Promise> { + type ResultType = Self; + + /// Like C++ JSG's promise arguments: a promise is taken as is, and any other value becomes + /// a promise resolved with it. + fn from_js(lock: &mut Lock<'s>, value: Local<'s, Value>) -> Result { + Promise::resolved(lock, value) + } + + fn type_name() -> String { + "Promise".to_owned() + } +} + +impl_as!(Promise, is_promise); +impl_local_cast!(Promise -> Value, is_promise); +impl_local_cast!(Promise -> Object, is_promise); diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index af4a4cf37aa..489ad06e3ba 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -133,6 +133,14 @@ pub mod ffi { ReferenceError, } + /// Mirrors `v8::Promise::PromiseState`; `ffi.c++` asserts the values match. + #[derive(Debug, PartialEq, Eq, Copy, Clone)] + pub enum PromiseState { + Pending = 0, + Fulfilled = 1, + Rejected = 2, + } + extern "Rust" { /// Called from C++ Wrappable destructor to drop the Rust object. /// Reconstructs the `Rc` from `data[0..1]` and drops it. @@ -295,6 +303,7 @@ pub mod ffi { pub unsafe fn local_is_array_buffer_view(value: &Local) -> bool; pub unsafe fn local_is_shared_array_buffer(value: &Local) -> bool; pub unsafe fn local_is_function(value: &Local) -> bool; + pub unsafe fn local_is_promise(value: &Local) -> bool; pub unsafe fn local_is_symbol(value: &Local) -> bool; pub unsafe fn local_is_name(value: &Local) -> bool; pub unsafe fn local_is_big_int(value: &Local) -> bool; @@ -407,6 +416,32 @@ pub mod ffi { key: &str, ) -> Result; + // Local + pub unsafe fn local_promise_state(promise: &Local) -> PromiseState; + // Fallible: requires that the promise is not pending. + pub unsafe fn local_promise_result(promise: &Local) -> Result; + pub unsafe fn local_promise_mark_as_handled(promise: &Local); + pub unsafe fn local_promise_then( + isolate: *mut Isolate, + promise: &Local, + on_fulfilled: &Local, + on_rejected: &Local, + ) -> MaybeLocal; + + // Local + pub unsafe fn promise_resolver_new(isolate: *mut Isolate) -> MaybeLocal; + pub unsafe fn local_promise_resolver_promise(resolver: &Local) -> Local; + pub unsafe fn local_promise_resolver_resolve( + isolate: *mut Isolate, + resolver: &Local, + value: &Local, + ) -> bool; + pub unsafe fn local_promise_resolver_reject( + isolate: *mut Isolate, + resolver: &Local, + reason: &Local, + ) -> bool; + // Local pub unsafe fn local_array_length(array: &Local) -> u32; pub unsafe fn local_array_get(isolate: *mut Isolate, array: &Local, index: u32) -> Local; @@ -571,6 +606,10 @@ pub mod ffi { data: *const u8, length: i32, ) -> Local; + pub unsafe fn exception_create_internal( + isolate: *mut Isolate, + internal_message: &str, + ) -> Local; // TryCatch pub unsafe fn isolate_try_catch( @@ -1454,6 +1493,12 @@ impl<'s, T> Local<'s, T> { unsafe { ffi::local_is_function(self.raw()) } } + /// Returns true if the value is a JavaScript Promise. + pub fn is_promise(&self) -> bool { + // SAFETY: handle is valid within the current HandleScope. + unsafe { ffi::local_is_promise(self.raw()) } + } + /// Returns true if the value is a JavaScript Symbol. /// /// Corresponds to `v8::Value::IsSymbol()`. @@ -1834,6 +1879,13 @@ impl_local_cast!(Float64Array -> ArrayBufferView, is_float64_array); impl_local_cast!(BigInt64Array -> ArrayBufferView, is_bigint64_array); impl_local_cast!(BigUint64Array -> ArrayBufferView, is_biguint64_array); +// Declared after `impl_as!` and `impl_local_cast!`, which it uses. +#[path = "promise.rs"] +mod promise; +pub use promise::Promise; +pub use promise::PromiseResolver; +pub use promise::PromiseState; + impl Array { /// Creates a new JavaScript array with the given length. pub fn new<'s>(lock: &mut crate::Lock<'s>, len: usize) -> Local<'s, Self> { From 060eea233f017c78b8ec6b0b595b42c0886a7b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 14:13:11 +0100 Subject: [PATCH 5/7] Give Rust JSG promise reactions one GC owner 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. --- src/rust/jsg-compile-tests/ok-promise.rs | 22 +- .../promise-then-callback-must-not-capture.rs | 13 +- src/rust/jsg-test/ffi.c++ | 2 +- src/rust/jsg-test/tests/promise.rs | 488 ++++++++++++++++-- src/rust/jsg/README.md | 4 +- src/rust/jsg/ffi.c++ | 75 ++- src/rust/jsg/ffi.h | 31 +- src/rust/jsg/lib.rs | 17 +- src/rust/jsg/promise.rs | 251 ++++++--- src/rust/jsg/v8.rs | 147 ++++-- 10 files changed, 808 insertions(+), 242 deletions(-) diff --git a/src/rust/jsg-compile-tests/ok-promise.rs b/src/rust/jsg-compile-tests/ok-promise.rs index 1c0ea5674f3..c89ada1d304 100644 --- a/src/rust/jsg-compile-tests/ok-promise.rs +++ b/src/rust/jsg-compile-tests/ok-promise.rs @@ -2,8 +2,8 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -// A method returns a promise, keeps its resolver past the call, and reacts to another promise, -// without `unsafe`. +// A method returns a promise, keeps its resolver past the call, and reacts to another promise +// in each of the three ways, without `unsafe`. use std::cell::RefCell; use jsg::v8; @@ -25,15 +25,17 @@ impl Waiter { ) -> Result, jsg::Error> { let resolver = v8::PromiseResolver::new(lock)?; *self.resolver.borrow_mut() = Some(resolver.to_traced_global(lock)); - input.then( + let wrapped = input.then(lock, String::from("data"), |lock, data, value| { + let object = lock.new_object(); + object.set(lock, data, value); + Ok(object.into()) + })?; + wrapped.catch(lock, (), |_lock, (), reason| Ok(reason))?; + input.then_or_catch( lock, - String::from("data"), - |lock, data, value| { - let object = lock.new_object(); - object.set(lock, data, value); - Ok(object.into()) - }, - |lock, _, reason| Ok(v8::Promise::rejected(lock, reason)?.into()), + (), + |_lock, (), value| Ok(value), + |lock, (), _reason| Ok(lock.undefined()), )?; Ok(resolver.promise()) } diff --git a/src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs b/src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs index a45c25c3f41..869b3be9533 100644 --- a/src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs +++ b/src/rust/jsg-compile-tests/promise-then-callback-must-not-capture.rs @@ -11,13 +11,8 @@ pub fn t<'s>( promise: v8::Local<'s, v8::Promise>, ) -> jsg::Result> { let name = String::from("captured"); - promise.then( - lock, - (), - move |lock, (), _value| { - //~^ ERROR E0277 - Ok(jsg::ToJS::to_js(name.as_str(), lock)) - }, - |_lock, (), reason| Ok(reason), - ) + promise.then(lock, (), move |lock, (), _value| { + //~^ ERROR E0277 + Ok(jsg::ToJS::to_js(name.as_str(), lock)) + }) } diff --git a/src/rust/jsg-test/ffi.c++ b/src/rust/jsg-test/ffi.c++ index 515b6f0d210..c1039998f14 100644 --- a/src/rust/jsg-test/ffi.c++ +++ b/src/rust/jsg-test/ffi.c++ @@ -168,7 +168,7 @@ void request_gc(Isolate* isolate, GcType gc_type) { } void run_microtasks(Isolate* isolate) { - ::workerd::jsg::Lock::from(isolate).runMicrotasks(); + isolate->PerformMicrotaskCheckpoint(); } void run_unlocked(Isolate* isolate, size_t data, ::rust::Fn callback) { diff --git a/src/rust/jsg-test/tests/promise.rs b/src/rust/jsg-test/tests/promise.rs index 08fd04b7a57..577aaf9e974 100644 --- a/src/rust/jsg-test/tests/promise.rs +++ b/src/rust/jsg-test/tests/promise.rs @@ -3,8 +3,9 @@ // https://opensource.org/licenses/Apache-2.0 //! JavaScript promises in Rust: creating and settling them through `v8::PromiseResolver`, and -//! reacting to them with `Local::then`. +//! reacting to them with `Local::then`, `catch` and `then_or_catch`. +use std::cell::Cell; use std::cell::RefCell; use std::collections::HashMap; use std::sync::atomic::AtomicUsize; @@ -128,15 +129,10 @@ fn then_runs_callbacks_as_microtasks_with_their_data() { let promise = ctx.eval_raw(lock, "Promise.resolve('world')").unwrap(); let promise = promise.try_as::().unwrap(); let greeted = promise - .then( - lock, - "hello".to_owned(), - |lock, greeting, value| { - let name = String::from_js(lock, value)?; - Ok(format!("{greeting}, {name}").to_js(lock)) - }, - |_lock, _, reason| Ok(reason), - ) + .then(lock, "hello".to_owned(), |lock, greeting, value| { + let name = String::from_js(lock, value)?; + Ok(format!("{greeting}, {name}").to_js(lock)) + }) .unwrap(); // Reactions never run synchronously. assert_eq!(greeted.state(), PromiseState::Pending); @@ -153,12 +149,9 @@ fn then_rejects_with_callback_error_and_handles_rejections() { let fulfilled = ctx.eval_raw(lock, "Promise.resolve(1)").unwrap(); let fulfilled = fulfilled.try_as::().unwrap(); let failed = fulfilled - .then( - lock, - (), - |_lock, (), _value| Err(jsg::Error::new_range_error("out of range")), - |_lock, (), reason| Ok(reason), - ) + .then(lock, (), |_lock, (), _value| { + Err(jsg::Error::new_range_error("out of range")) + }) .unwrap(); ctx.set_global(lock, "failed", failed); @@ -167,15 +160,10 @@ fn then_rejects_with_callback_error_and_handles_rejections() { .unwrap(); let rejected = rejected.try_as::().unwrap(); let recovered = rejected - .then( - lock, - (), - |_lock, (), value| Ok(value), - |lock, (), reason| { - let err = jsg::Error::from_js(lock, reason)?; - Ok(format!("recovered from {}", err.message).to_js(lock)) - }, - ) + .catch(lock, (), |lock, (), reason| { + let err = jsg::Error::from_js(lock, reason)?; + Ok(format!("recovered from {}", err.message).to_js(lock)) + }) .unwrap(); ctx.set_global(lock, "recovered", recovered); @@ -191,6 +179,91 @@ fn then_rejects_with_callback_error_and_handles_rejections() { }); } +/// `then` passes a rejection through and `catch` a fulfillment, natively: the value arrives +/// unchanged, and no intermediate promise is rejected without a handler. +#[test] +fn then_and_catch_pass_the_other_outcome_through() { + crate::Harness::new().run_in_context(|lock, ctx| { + let rejected = ctx + .eval_raw( + lock, + "globalThis.error = new TypeError('nope'); Promise.reject(error)", + ) + .unwrap(); + let rejected = rejected.try_as::().unwrap(); + let skipped = rejected + .then(lock, (), |_lock, (), _value| { + Err(jsg::Error::new_type_error("on_fulfilled ran")) + }) + .unwrap(); + ctx.set_global(lock, "skipped", skipped); + + let fulfilled = ctx.eval_raw(lock, "Promise.resolve('kept')").unwrap(); + let fulfilled = fulfilled.try_as::().unwrap(); + let kept = fulfilled + .catch(lock, (), |_lock, (), _reason| { + Err(jsg::Error::new_type_error("on_rejected ran")) + }) + .unwrap(); + ctx.set_global(lock, "kept", kept); + + assert_eq!( + log_after_microtasks( + lock, + ctx, + &format!( + "{RECORD} skipped.catch((e) => log.push(`same error: ${{e === error}}`)); + record(kept);" + ) + ), + "same error: true|fulfilled: kept" + ); + Ok(()) + }); +} + +/// Reacts to the promise that `code` evaluates to with both callbacks of `then_or_catch`. +fn react_to_outcome<'s>( + lock: &mut jsg::Lock<'s>, + ctx: &crate::EvalContext<'_>, + code: &str, +) -> v8::Local<'s, v8::Promise> { + let promise = ctx.eval_raw(lock, code).unwrap(); + promise + .try_as::() + .unwrap() + .then_or_catch( + lock, + "outcome".to_owned(), + |lock, what, value| { + let value = String::from_js(lock, value)?; + Ok(format!("{what}: fulfilled with {value}").to_js(lock)) + }, + |lock, what, reason| { + let err = jsg::Error::from_js(lock, reason)?; + Ok(format!("{what}: rejected with {}", err.message).to_js(lock)) + }, + ) + .unwrap() +} + +#[test] +fn then_or_catch_runs_the_callback_for_the_outcome() { + crate::Harness::new().run_in_context(|lock, ctx| { + let fulfilled = react_to_outcome(lock, ctx, "Promise.resolve('yes')"); + let rejected = react_to_outcome(lock, ctx, "Promise.reject(new Error('no'))"); + crate::Harness::run_microtasks(lock); + let result = fulfilled.result(lock).unwrap(); + assert_eq!( + String::from_js(lock, result)?, + "outcome: fulfilled with yes" + ); + let result = rejected.result(lock).unwrap(); + assert_eq!(String::from_js(lock, result)?, "outcome: rejected with no"); + Ok(()) + }); +} + #[test] fn panic_in_then_callback_is_an_internal_error() { crate::Harness::new().run_in_context(|lock, ctx| { @@ -203,7 +276,6 @@ fn panic_in_then_callback_is_an_internal_error() { |_lock, (), _value| -> Result, jsg::Error> { panic!("intentional panic in a then callback"); }, - |_lock, (), reason| Ok(reason), ) .unwrap(); panicked.mark_as_handled(); @@ -236,14 +308,14 @@ fn then_data_is_dropped_once_the_promise_settles() { let resolver = v8::PromiseResolver::new(lock).unwrap(); resolver .promise() - .then( + .then_or_catch( lock, DropCounter(&SETTLED_DATA_DROPS), |lock, _, _| Ok(lock.undefined()), |lock, _, _| Ok(lock.undefined()), ) .unwrap(); - // The pending promise keeps its reactions, and with them the data. + // The pending promise keeps its reactions, and with them the data, which they share. ctx.set_global(lock, "resolver", resolver.promise()); crate::Harness::request_gc(lock); assert_eq!(SETTLED_DATA_DROPS.load(Ordering::SeqCst), 0); @@ -259,6 +331,300 @@ fn then_data_is_dropped_once_the_promise_settles() { }); } +static SHARED_LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct Leaf { + _drops: DropCounter, +} + +#[jsg_resource] +impl Leaf {} + +/// Both reactions of `then_or_catch` trace a `jsg::Rc` in their data through one owner. Two +/// owners of one `jsg::Rc` would fail C++'s single-parent assertion as soon as the second +/// reaction was created, and again whenever the garbage collector traced them. +#[test] +fn then_or_catch_data_holds_a_resource() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let leaf = jsg::Rc::new(Leaf { + _drops: DropCounter(&SHARED_LEAF_DROPS), + }); + let resolver = v8::PromiseResolver::new(lock).unwrap(); + let settled = resolver + .promise() + .then_or_catch( + lock, + leaf, + |lock, leaf, _| Ok(leaf.clone().to_js(lock)), + |lock, _, _| Ok(lock.undefined()), + ) + .unwrap(); + ctx.set_global(lock, "resolver", resolver.promise()); + crate::Harness::request_gc(lock); + assert_eq!(SHARED_LEAF_DROPS.load(Ordering::SeqCst), 0); + let value = lock.undefined(); + resolver.resolve(lock, value)?; + crate::Harness::run_microtasks(lock); + // The reaction returned the resource, which the settled promise now holds. + let result = settled.result(lock).unwrap(); + assert!(jsg::Rc::::from_js(lock, result).is_ok()); + Ok(()) + }); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(SHARED_LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +static TEARDOWN_DATA_DROPS: AtomicUsize = AtomicUsize::new(0); +static TEARDOWN_LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// Reaction data that holds a resource and a handle back to the promise it reacts to. +struct TeardownData { + _drops: DropCounter, + leaf: jsg::Rc, + promise: v8::TracedGlobal, +} + +impl jsg::Traced for TeardownData { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.leaf.trace(visitor); + self.promise.trace(visitor); + } +} + +/// The data of the reactions to a promise that is still pending, and reachable, when the +/// isolate is torn down is dropped by the teardown, with what it owns. +#[test] +fn reaction_data_of_pending_promise_is_dropped_with_its_isolate() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resolver = v8::PromiseResolver::new(lock).unwrap(); + let promise = resolver.promise(); + let data = TeardownData { + _drops: DropCounter(&TEARDOWN_DATA_DROPS), + leaf: jsg::Rc::new(Leaf { + _drops: DropCounter(&TEARDOWN_LEAF_DROPS), + }), + promise: promise.to_traced_global(lock), + }; + promise + .then_or_catch( + lock, + data, + |lock, _, _| Ok(lock.undefined()), + |lock, _, _| Ok(lock.undefined()), + ) + .unwrap(); + ctx.set_global(lock, "pending", promise); + crate::Harness::request_gc(lock); + assert_eq!(TEARDOWN_DATA_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(TEARDOWN_LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + drop(harness); + assert_eq!(TEARDOWN_DATA_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(TEARDOWN_LEAF_DROPS.load(Ordering::SeqCst), 1); +} + +/// What a `Watcher` has seen, shared by the watcher and the reactions it registers. +#[jsg_resource] +struct WatcherState { + seen: RefCell>, +} + +#[jsg_resource] +impl WatcherState {} + +/// Calls a JavaScript callback with what each watched promise fulfills with. +#[jsg_resource] +struct Watcher { + state: jsg::Rc, +} + +/// The data of a `Watcher`'s reaction: the watcher's state, through its own `jsg::Rc`, and the +/// callback. +struct Watch { + state: jsg::Rc, + callback: v8::TracedGlobal, +} + +impl jsg::Traced for Watch { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.state.trace(visitor); + self.callback.trace(visitor); + } +} + +#[jsg_resource] +impl Watcher { + #[jsg_method] + fn watch<'s>( + &self, + lock: &mut jsg::Lock<'s>, + promise: v8::Local<'s, v8::Promise>, + callback: v8::Local<'s, v8::Value>, + ) -> Result, jsg::Error> { + let callback = callback + .try_as::() + .ok_or_else(|| jsg::Error::new_type_error("callback must be a function"))?; + let watch = Watch { + state: self.state.clone(), + callback: callback.to_traced_global(lock), + }; + promise.then(lock, watch, |lock, watch, value| { + let seen = String::from_js(lock, value)?; + watch.state.seen.borrow_mut().push(seen); + // No borrow is held across the call, whose JavaScript calls back into the watcher. + let callback = watch.callback.as_local(lock); + callback.call::>(lock, None, &[value]) + }) + } + + #[jsg_method] + fn seen(&self) -> String { + self.state.seen.borrow().join(",") + } +} + +#[test] +fn reaction_calls_back_into_the_resource_that_registered_it() { + crate::Harness::new().run_in_context(|lock, ctx| { + let watcher = jsg::Rc::new(Watcher { + state: jsg::Rc::new(WatcherState { + seen: RefCell::new(Vec::new()), + }), + }); + ctx.set_global(lock, "watcher", watcher); + let code = format!( + "{RECORD} + watcher.watch(Promise.resolve('a'), (v) => {{ + log.push(`got ${{v}}, seen ${{watcher.seen()}}`); + record(watcher.watch(Promise.resolve('b'), (v) => {{ + log.push(`got ${{v}}, seen ${{watcher.seen()}}`); + return 'done'; + }})); + }});" + ); + assert_eq!( + log_after_microtasks(lock, ctx, &code), + "got a, seen a|got b, seen a,b|fulfilled: done" + ); + Ok(()) + }); +} + +/// Terminates execution, then tries each promise operation. +#[jsg_resource] +struct TerminatingPromises { + /// Each operation, in order, and whether it failed with a pending error. + pending: RefCell>, + /// Whether the promise that was settled while terminating stayed pending. + still_pending: Cell, +} + +#[jsg_resource] +impl TerminatingPromises { + /// Requests a termination, which V8 delivers while `spin` runs, then uses promises. + #[jsg_method] + fn run<'s>( + &self, + lock: &mut jsg::Lock<'s>, + spin: v8::Local<'s, v8::Value>, + ) -> Result<(), jsg::Error> { + let resolver = v8::PromiseResolver::new(lock)?; + let promise = resolver.promise(); + lock.terminate_execution(); + let spin = spin.try_as::().unwrap(); + let err = spin + .call::>(lock, None, &[]) + .unwrap_err(); + assert!(err.is_pending()); + + let mut pending = Vec::new(); + let mut record = |what: &'static str, result: Result<(), jsg::Error>| { + pending.push(format!( + "{what}: {}", + result.is_err_and(|err| err.is_pending()) + )); + }; + record( + "PromiseResolver::new", + v8::PromiseResolver::new(lock).map(|_| ()), + ); + record( + "then", + promise.then(lock, (), |_, (), value| Ok(value)).map(|_| ()), + ); + record( + "catch", + promise + .catch(lock, (), |_, (), reason| Ok(reason)) + .map(|_| ()), + ); + record( + "then_or_catch", + promise + .then_or_catch( + lock, + (), + |_, (), value| Ok(value), + |_, (), reason| Ok(reason), + ) + .map(|_| ()), + ); + let value = lock.undefined(); + record("resolve", resolver.resolve(lock, value)); + record("reject", resolver.reject(lock, value)); + record("reject_with_error", resolver.reject_with_error(lock, &err)); + self.still_pending + .set(promise.state() == PromiseState::Pending); + *self.pending.borrow_mut() = pending; + Err(err) + } +} + +/// While execution is terminating, every promise operation creates and settles nothing and +/// returns a pending error, so that returning it keeps the termination. Entering V8 would clear +/// the termination, and JavaScript would run on. +#[test] +fn promise_operations_while_terminating_keep_the_termination() { + crate::Harness::new().run_in_context(|lock, ctx| { + let promises = jsg::Rc::new(TerminatingPromises { + pending: RefCell::new(Vec::new()), + still_pending: Cell::new(false), + }); + ctx.set_global(lock, "promises", promises.clone()); + let result = ctx.eval_raw( + lock, + "globalThis.caught = false; globalThis.after = false; + try { promises.run(() => { while (true) {} }); } + catch (e) { globalThis.caught = true; } + globalThis.after = true;", + ); + assert!(result.is_err(), "the script was not terminated"); + assert_eq!( + *promises.pending.borrow(), + [ + "PromiseResolver::new: true", + "then: true", + "catch: true", + "then_or_catch: true", + "resolve: true", + "reject: true", + "reject_with_error: true", + ] + ); + assert!(promises.still_pending.get()); + assert!(!ctx.eval::(lock, "globalThis.caught").unwrap()); + assert!(!ctx.eval::(lock, "globalThis.after").unwrap()); + Ok(()) + }); +} + // ============================================================================= // Consumer: a resource that settles a promise from a later call // ============================================================================= @@ -379,37 +745,35 @@ fn cycle_through_held_resolver_is_collected() { // Consumer: a read-through cache, shaped like MemoryCache.read(key, fallback) // ============================================================================= -/// The cache's state, shared by the resource and the reactions to in-flight fallbacks. -#[derive(Default)] -struct CacheState { +/// The cache's entries and in-flight fallbacks. A resource of its own, so that the cache and the +/// reactions to in-flight fallbacks each hold a `jsg::Rc` to it and the garbage collector traces +/// its handles once, through it. +#[jsg_resource] +struct CacheStore { entries: RefCell>>, in_flight: RefCell>>, } -impl jsg::Traced for CacheState { - fn trace(&self, visitor: &mut jsg::GcVisitor) { - self.entries.trace(visitor); - self.in_flight.trace(visitor); - } -} +#[jsg_resource] +impl CacheStore {} /// `read(key, fallback)` returns a promise for the cached value. On a miss it calls /// `fallback(key)` once, however many reads wait for it, caches what its promise fulfills with, /// and passes a rejection through to every waiting read without caching it. #[jsg_resource] struct ReadThroughCache { - state: std::rc::Rc, + store: jsg::Rc, } /// What the reactions to a fallback's promise need. struct Fill { - state: std::rc::Rc, + store: jsg::Rc, key: String, } impl jsg::Traced for Fill { fn trace(&self, visitor: &mut jsg::GcVisitor) { - self.state.trace(visitor); + self.store.trace(visitor); } } @@ -423,7 +787,7 @@ impl ReadThroughCache { fallback: v8::Local<'s, v8::Value>, ) -> Result, jsg::Error> { let cached = self - .state + .store .entries .borrow() .get(&key) @@ -432,7 +796,7 @@ impl ReadThroughCache { return v8::Promise::resolved(lock, value); } let in_flight = self - .state + .store .in_flight .borrow() .get(&key) @@ -448,39 +812,47 @@ impl ReadThroughCache { // No RefCell borrow is held here: the fallback is JavaScript and may call `read` again. let produced = fallback.call::>(lock, None, &[key_js])?; let produced = v8::Local::::from_js(lock, produced)?; + // A rejection passes through `filled` unchanged. The `catch` reaction only forgets the + // fallback; the promise it returns is dropped, and fulfilled, so nothing reports it. let filled = produced.then( lock, Fill { - state: std::rc::Rc::clone(&self.state), + store: self.store.clone(), key: key.clone(), }, |lock, fill, value| { - fill.state.in_flight.borrow_mut().remove(&fill.key); - fill.state + let entry = value.to_traced_global(lock); + fill.store.in_flight.borrow_mut().remove(&fill.key); + fill.store .entries .borrow_mut() - .insert(fill.key.clone(), value.to_traced_global(lock)); + .insert(fill.key.clone(), entry); Ok(value) }, - |lock, fill, reason| { - fill.state.in_flight.borrow_mut().remove(&fill.key); - // Resolving with a rejected promise passes `reason` on unchanged; an `Err` - // would reject with a new exception instead. - let rejected = v8::Promise::rejected(lock, reason)?; - Ok(rejected.into()) + )?; + produced.catch( + lock, + Fill { + store: self.store.clone(), + key: key.clone(), + }, + |lock, fill, _reason| { + fill.store.in_flight.borrow_mut().remove(&fill.key); + Ok(lock.undefined()) }, )?; - self.state - .in_flight - .borrow_mut() - .insert(key, filled.to_traced_global(lock)); + let in_flight = filled.to_traced_global(lock); + self.store.in_flight.borrow_mut().insert(key, in_flight); Ok(filled) } } fn install_cache(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>) { let cache = jsg::Rc::new(ReadThroughCache { - state: std::rc::Rc::default(), + store: jsg::Rc::new(CacheStore { + entries: RefCell::new(HashMap::new()), + in_flight: RefCell::new(HashMap::new()), + }), }); ctx.set_global(lock, "cache", cache); } @@ -507,6 +879,8 @@ fn cache_coalesces_reads_and_caches_fulfillment() { log_after_microtasks(lock, ctx, &code), "same promise: true|calls: 1|fulfilled: A|fulfilled: A" ); + // The store has no wrapper of its own: the cache traces it, and with it the cached value. + crate::Harness::request_gc(lock); // Served from the cache: the fallback does not run again. assert_eq!( log_after_microtasks( diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index bdbc4bcfa69..ed57cf12bae 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -252,7 +252,9 @@ lead back to the function. Two things break this: collector, and the function and `data` are freed only when the isolate is torn down. - Another owner that the garbage collector also traces, such as a resource sharing part of `data` through a `std::rc::Rc`. `data` must be the only owner that traces what it traces: - tracing one `jsg::Rc` from two owners fails an assertion. + tracing one `jsg::Rc` from two owners fails an assertion, and a `TracedGlobal` traced from + two owners can be freed while one of them still uses it. To share state, hold a `jsg::Rc` or + `jsg::Weak` to the resource that owns it. ```rust let greet = v8::Function::new(lock, String::from("hello"), |lock, greeting, args| { diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 37f3436e5e3..48cb9927c59 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -445,13 +445,10 @@ MaybeLocal local_function_call( return maybe_local_to_ffi(fn->Call(context, receiver, v8Args.size(), v8Args.data())); } -MaybeLocal function_new(Isolate* isolate, size_t callback, kj::Rc wrappable) { - auto context = isolate->GetCurrentContext(); - // Like the functions C++ JSG wraps (FunctionWrapper::wrap() in jsg/function.h): the wrapper - // keeps the Wrappable alive and traced for as long as the function is reachable. - auto data = wrappable->attachOpaqueWrapper(context, true); - return maybe_local_to_ffi(v8::Function::New( - context, reinterpret_cast(reinterpret_cast(callback)), data)); +MaybeLocal function_new(Isolate* isolate, size_t callback, const Local& data) { + return maybe_local_to_ffi(v8::Function::New(isolate->GetCurrentContext(), + reinterpret_cast(reinterpret_cast(callback)), + local_as_ref_from_ffi(data))); } // Local @@ -502,17 +499,39 @@ PromiseState local_promise_state(const Local& promise) { } Local local_promise_result(const Local& promise) { - auto v8Promise = local_as_ref_from_ffi(promise); - KJ_REQUIRE(v8Promise->State() != v8::Promise::kPending, "a pending promise has no result"); - return to_ffi(v8Promise->Result()); + return to_ffi(local_as_ref_from_ffi(promise)->Result()); } void local_promise_mark_as_handled(const Local& promise) { local_as_ref_from_ffi(promise)->MarkAsHandled(); } -MaybeLocal local_promise_then( +namespace { +// Whether an exception or a termination is pending, in which case the promise shims call nothing +// in V8 and fail, leaving it pending. Promise::Resolver::New(), Then() and Catch() would clear +// it: they open a PrepareForExecutionScope, which clears the pending exception, a termination +// included, so that a terminated script would run on. V8 also requires that embedders don't +// enter it at all while execution is terminating. +bool exceptionPending(Isolate* isolate) { + return isolate->IsExecutionTerminating() || isolate->HasPendingException(); +} +} // namespace + +MaybeLocal local_promise_then(Isolate* isolate, const Local& promise, const Local& on_fulfilled) { + if (exceptionPending(isolate)) return maybe_local_to_ffi(v8::MaybeLocal()); + return maybe_local_to_ffi(local_as_ref_from_ffi(promise)->Then( + isolate->GetCurrentContext(), local_as_ref_from_ffi(on_fulfilled))); +} + +MaybeLocal local_promise_catch(Isolate* isolate, const Local& promise, const Local& on_rejected) { + if (exceptionPending(isolate)) return maybe_local_to_ffi(v8::MaybeLocal()); + return maybe_local_to_ffi(local_as_ref_from_ffi(promise)->Catch( + isolate->GetCurrentContext(), local_as_ref_from_ffi(on_rejected))); +} + +MaybeLocal local_promise_then_or_catch( Isolate* isolate, const Local& promise, const Local& on_fulfilled, const Local& on_rejected) { + if (exceptionPending(isolate)) return maybe_local_to_ffi(v8::MaybeLocal()); return maybe_local_to_ffi(local_as_ref_from_ffi(promise)->Then( isolate->GetCurrentContext(), local_as_ref_from_ffi(on_fulfilled), local_as_ref_from_ffi(on_rejected))); @@ -520,6 +539,7 @@ MaybeLocal local_promise_then( // Local MaybeLocal promise_resolver_new(Isolate* isolate) { + if (exceptionPending(isolate)) return maybe_local_to_ffi(v8::MaybeLocal()); return maybe_local_to_ffi(v8::Promise::Resolver::New(isolate->GetCurrentContext())); } @@ -528,12 +548,14 @@ Local local_promise_resolver_promise(const Local& resolver) { } bool local_promise_resolver_resolve(Isolate* isolate, const Local& resolver, const Local& value) { + if (exceptionPending(isolate)) return false; return local_as_ref_from_ffi(resolver) ->Resolve(isolate->GetCurrentContext(), local_as_ref_from_ffi(value)) .IsJust(); } bool local_promise_resolver_reject(Isolate* isolate, const Local& resolver, const Local& reason) { + if (exceptionPending(isolate)) return false; return local_as_ref_from_ffi(resolver) ->Reject(isolate->GetCurrentContext(), local_as_ref_from_ffi(reason)) .IsJust(); @@ -738,6 +760,11 @@ void wrappable_attach_wrapper(kj::Rc wrappable, FunctionCallbackInfo& static_cast(::workerd::jsg::Wrappable::WRAPPABLE_TAG_FIELD_INDEX)); } +Local wrappable_attach_opaque_wrapper(Isolate* isolate, kj::Rc wrappable) { + // Like the data of the functions C++ JSG wraps (FunctionWrapper::wrap() in jsg/function.h). + return to_ffi(wrappable->attachOpaqueWrapper(isolate->GetCurrentContext(), true)); +} + // Unwrappers ::rust::String unwrap_string(Isolate* isolate, Local value) { @@ -1423,10 +1450,21 @@ Local exception_create_from_bytes( return exceptionCreateFromMessage(exception_type, message); } -Local exception_create_internal(Isolate* isolate, ::rust::Str internalMessage) { - // See isolate_throw_internal_error() for why the message is copied. +namespace { +// Mirrors makeInternalError() from util.c++, which it calls: generates a unique error ID, logs +// the internal message (with ID) to KJ_LOG(ERROR) for Sentry, and returns a generic +// "internal error; reference = " JS Error. +v8::Local createInternalError(Isolate* isolate, ::rust::Str internalMessage) { + // kj::heapString(data, size) copies exactly `size` bytes and appends a NUL, + // avoiding the out-of-bounds read that kj::StringPtr(data, size) would cause + // on non-NUL-terminated Rust &str data. auto message = kj::heapString(internalMessage.data(), internalMessage.size()); - return to_ffi(::workerd::jsg::makeInternalError(isolate, message)); + return ::workerd::jsg::makeInternalError(isolate, message); +} +} // namespace + +Local exception_create_internal(Isolate* isolate, ::rust::Str internalMessage) { + return to_ffi(createInternalError(isolate, internalMessage)); } // TryCatch @@ -1466,15 +1504,8 @@ void isolate_throw_error(Isolate* isolate, ::rust::Str description) { } void isolate_throw_internal_error(Isolate* isolate, ::rust::Str internalMessage) { - // Mirrors makeInternalError() from util.c++: generates a unique error ID, - // logs the internal message (with ID) to KJ_LOG(ERROR) for Sentry, and - // throws a generic "internal error; reference = " JS Error to the caller. - // kj::heapString(data, size) copies exactly `size` bytes and appends a NUL, - // avoiding the out-of-bounds read that kj::StringPtr(data, size) would cause - // on non-NUL-terminated Rust &str data. - auto message = kj::heapString(internalMessage.data(), internalMessage.size()); // Logs the message even when execution is terminating, but then throws nothing. - auto error = ::workerd::jsg::makeInternalError(isolate, message); + auto error = createInternalError(isolate, internalMessage); if (isolate->IsExecutionTerminating()) return; isolate->ThrowException(error); } diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index afbe94442d9..1dd4744c361 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -199,10 +199,10 @@ double local_date_value_of(const Local& value); // Returns the call's result, or none if it threw, leaving the exception pending. MaybeLocal local_function_call( Isolate* isolate, const Local& function, const Local& recv, ::rust::Slice args); -// Creates a function that calls `callback` with an opaque wrapper of `wrappable` as its data. -// Throws if V8 could not create the wrapper and returns empty if it could not create the -// function, both only when execution is terminating. -MaybeLocal function_new(Isolate* isolate, size_t callback, kj::Rc wrappable); +// Creates a function that calls `callback` with `data`, an opaque wrapper that +// wrappable_attach_opaque_wrapper() returned, as its data. Returns empty if V8 could not create +// the function, which happens only when execution is terminating. +MaybeLocal function_new(Isolate* isolate, size_t callback, const Local& data); // Local void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); @@ -211,20 +211,26 @@ Local local_object_get_property(Isolate* isolate, const Local& object, ::rust::S // Local PromiseState local_promise_state(const Local& promise); -// The fulfillment value or rejection reason; `promise` must not be pending. +// The fulfillment value or rejection reason. `promise` must not be pending: V8 aborts the process +// if it is. Local local_promise_result(const Local& promise); void local_promise_mark_as_handled(const Local& promise); -// `promise.then(on_fulfilled, on_rejected)`, without looking up `then`. Empty if execution is -// terminating. -MaybeLocal local_promise_then( +// `promise.then(on_fulfilled)`, `promise.catch(on_rejected)` and +// `promise.then(on_fulfilled, on_rejected)`, without looking up `then` or the species +// constructor. An empty result means an exception is pending (one that a promise hook threw, or a +// termination), as local_function_call() leaves it. While one is pending these, and the +// PromiseResolver shims, call nothing in V8, which would clear it. +MaybeLocal local_promise_then(Isolate* isolate, const Local& promise, const Local& on_fulfilled); +MaybeLocal local_promise_catch(Isolate* isolate, const Local& promise, const Local& on_rejected); +MaybeLocal local_promise_then_or_catch( Isolate* isolate, const Local& promise, const Local& on_fulfilled, const Local& on_rejected); // Local -// Empty if execution is terminating. +// An empty result, or false, means an exception is pending, as for local_promise_then(). MaybeLocal promise_resolver_new(Isolate* isolate); Local local_promise_resolver_promise(const Local& resolver); // Resolving with a thenable reads its `then` property, which can run JavaScript; an exception it -// throws rejects the promise. Both return false only if execution is terminating. +// throws rejects the promise rather than failing the call. bool local_promise_resolver_resolve(Isolate* isolate, const Local& resolver, const Local& value); bool local_promise_resolver_reject(Isolate* isolate, const Local& resolver, const Local& reason); @@ -314,6 +320,11 @@ kj::uint wrappable_strong_refcount(const Wrappable& wrappable); // Returns an empty handle if the Wrappable has a live wrapper in another isolate. MaybeLocal wrap_resource(Isolate* isolate, kj::Rc wrappable, const Global& tmpl); void wrappable_attach_wrapper(kj::Rc wrappable, FunctionCallbackInfo& args); +// Attaches an opaque wrapper to `wrappable` and returns it, to be the data of the functions that +// function_new() creates: the wrapper keeps the Wrappable alive and traced for as long as one of +// them is reachable. Throws if `wrappable` already has a wrapper, or if V8 could not create one, +// which happens only when execution is terminating. +Local wrappable_attach_opaque_wrapper(Isolate* isolate, kj::Rc wrappable); // Unwrappers ::rust::String unwrap_string(Isolate* isolate, Local value); diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 626e0d07287..ccbd28725e2 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -15,6 +15,7 @@ pub mod lenient; pub mod macros; pub mod modules; pub mod nullable; +mod promise; pub mod resource; mod scope; pub mod v8; @@ -952,18 +953,26 @@ impl<'s> Lock<'s> { /// throwing it: an internal error is redacted (and its message logged) the same way. /// /// Use it to reject a promise with an `Error`. - pub fn exception(&mut self, err: &Error) -> v8::Local<'s, v8::Value> { + /// + /// # Errors + /// + /// Returns `err` itself if [`Error::is_pending`] is true: the exception it stands for is + /// pending in V8, and returning the error propagates it. + pub fn exception(&mut self, err: &Error) -> Result> { + if err.is_pending() { + return Err(err.clone()); + } if err.is_internal { // SAFETY: isolate is valid and locked (guaranteed by Lock); the exception lives in // the lock's scope. - return unsafe { + return Ok(unsafe { v8::Local::from_ffi(v8::ffi::exception_create_internal( self.isolate().as_ffi(), &err.message, )) - }; + }); } - err.to_local(self) + Ok(err.to_local(self)) } /// Throws an internal error as a V8 exception, matching `makeInternalError()` in C++. diff --git a/src/rust/jsg/promise.rs b/src/rust/jsg/promise.rs index 1fd9a528882..d2deb92772e 100644 --- a/src/rust/jsg/promise.rs +++ b/src/rust/jsg/promise.rs @@ -2,39 +2,41 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -//! JavaScript promises: [`Promise`] and [`PromiseResolver`] handles. -//! -//! A Rust method returns a promise like any other handle, as a `Local<'s, Promise>`, and -//! settles it through a [`PromiseResolver`], now or from a later call that holds the resolver in a -//! [`TracedGlobal`](super::TracedGlobal). [`Local::then`] reacts to a promise with Rust -//! callbacks, which run as microtasks like the reactions JavaScript registers. -//! -//! Every operation that can fail fails only because execution is terminating, and then returns -//! an internal [`Error`], as [`Function::new`] does when V8 cannot create a function. -//! -//! The handles are untyped, like C++ JSG's `JsPromise`: values are JavaScript values, converted -//! with [`ToJS`](crate::ToJS) and [`FromJS`] at the edges. C++ JSG's typed `jsg::Promise`, -//! which can carry C++ values through JavaScript, has no counterpart. +//! JavaScript promises: the [`Promise`] and [`PromiseResolver`] handles, which `jsg::v8` +//! exports with the other handle types. + +#![allow( + unsafe_code, + reason = "settles and reacts to promises through the V8 FFI" +)] -use super::As; -use super::Function; -use super::Local; -use super::Object; -use super::Value; -use super::ffi; -pub use super::ffi::PromiseState; use crate::Error; use crate::FromJS; use crate::Lock; use crate::Traced; use crate::UnitType; - -/// The error for an operation that V8 refused because execution is terminating. -fn terminated() -> Error { - Error::new_internal_error("JavaScript execution terminated") -} +use crate::v8::Function; +use crate::v8::FunctionCallbackInfo; +use crate::v8::Local; +use crate::v8::PromiseState; +use crate::v8::Value; +use crate::v8::ffi; /// Marker for a `v8::Promise` handle. +/// +/// A Rust method returns a promise like any other handle, as a `Local<'s, Promise>`, and +/// settles it through a [`PromiseResolver`], now or from a later call that holds the resolver in a +/// [`TracedGlobal`](crate::v8::TracedGlobal). [`Local::then`], [`Local::catch`] and +/// [`Local::then_or_catch`] react to a promise with Rust callbacks, which run as microtasks like +/// the reactions JavaScript registers. +/// +/// An operation fails when V8 does and leaves an exception pending: a termination, or an +/// exception that a promise hook threw. It then returns an error for which [`Error::is_pending`] +/// is true, as [`Local::::call`] does, so that returning it propagates the exception. +/// +/// The handles are untyped, like C++ JSG's `JsPromise`: values are JavaScript values, converted +/// with [`ToJS`](crate::ToJS) and [`FromJS`] at the edges. C++ JSG's typed `jsg::Promise`, +/// which can carry C++ values through JavaScript, has no counterpart. pub struct Promise; /// Marker for a `v8::Promise::Resolver` handle: the capability to settle one promise. @@ -48,12 +50,12 @@ impl PromiseResolver { /// /// # Errors /// - /// Fails if execution is terminating. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. pub fn new<'s>(lock: &mut Lock<'s>) -> crate::Result> { // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; a // non-empty handle is a resolver V8 created in the lock's scope. unsafe { Local::from_ffi_maybe(ffi::promise_resolver_new(lock.isolate().as_ffi())) } - .ok_or_else(terminated) + .ok_or_else(Error::pending) } } @@ -72,7 +74,7 @@ impl<'s> Local<'s, PromiseResolver> { /// /// # Errors /// - /// Fails if execution is terminating. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. pub fn resolve(self, lock: &mut Lock<'s>, value: Local<'s, Value>) -> crate::Result<()> { self.debug_assert_isolate(lock); value.debug_assert_isolate(lock); @@ -82,14 +84,14 @@ impl<'s> Local<'s, PromiseResolver> { ffi::local_promise_resolver_resolve(lock.isolate().as_ffi(), self.raw(), value.raw()) } .then_some(()) - .ok_or_else(terminated) + .ok_or_else(Error::pending) } /// Rejects the promise with `reason`. Settling an already-settled promise does nothing. /// /// # Errors /// - /// Fails if execution is terminating. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. pub fn reject(self, lock: &mut Lock<'s>, reason: Local<'s, Value>) -> crate::Result<()> { self.debug_assert_isolate(lock); reason.debug_assert_isolate(lock); @@ -99,7 +101,7 @@ impl<'s> Local<'s, PromiseResolver> { ffi::local_promise_resolver_reject(lock.isolate().as_ffi(), self.raw(), reason.raw()) } .then_some(()) - .ok_or_else(terminated) + .ok_or_else(Error::pending) } /// Rejects the promise with the exception that throwing `err` would throw; see @@ -107,9 +109,10 @@ impl<'s> Local<'s, PromiseResolver> { /// /// # Errors /// - /// Fails if execution is terminating. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. Returns + /// `err` itself, rejecting nothing, if [`Error::is_pending`] is true for it. pub fn reject_with_error(self, lock: &mut Lock<'s>, err: &Error) -> crate::Result<()> { - let reason = lock.exception(err); + let reason = lock.exception(err)?; self.reject(lock, reason) } } @@ -118,9 +121,14 @@ impl Promise { /// A promise resolved with `value`, like `Promise.resolve(value)`: a promise is returned /// unchanged and a thenable is followed. /// + /// Unlike `Promise.resolve(value)`, which returns a promise unchanged only if its + /// `constructor` is the current realm's `Promise`, this returns any promise unchanged, + /// including an instance of a subclass and a promise from another context, as C++ JSG does + /// for its promise arguments. + /// /// # Errors /// - /// Fails if execution is terminating. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. pub fn resolved<'s>( lock: &mut Lock<'s>, value: Local<'s, Value>, @@ -137,7 +145,7 @@ impl Promise { /// /// # Errors /// - /// Fails if execution is terminating. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. pub fn rejected<'s>( lock: &mut Lock<'s>, reason: Local<'s, Value>, @@ -152,23 +160,45 @@ impl Promise { /// /// # Errors /// - /// Fails if execution is terminating. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. Returns + /// `err` itself if [`Error::is_pending`] is true for it. pub fn rejected_with_error<'s>( lock: &mut Lock<'s>, err: &Error, ) -> crate::Result> { - let reason = lock.exception(err); + let reason = lock.exception(err)?; Self::rejected(lock, reason) } } -/// The data of the two functions [`Local::then`] registers, which share it. -struct Reactions(std::rc::Rc); +/// The callback of a reaction that [`Local::then`], [`Local::catch`] or +/// [`Local::then_or_catch`] registers: calls `F` with the reaction's data and its argument, the +/// fulfillment value or rejection reason. +fn react<'a, T, F>( + lock: &mut Lock<'a>, + data: &T, + args: &mut FunctionCallbackInfo<'a>, +) -> Result, Error> +where + T: Traced + 'static, + F: UnitType + + 'static + + for<'b> Fn(&mut Lock<'b>, &T, Local<'b, Value>) -> Result, Error>, +{ + // SAFETY: `then`, `catch` and `then_or_catch` were handed a value of `F`. + let f = unsafe { crate::scope::unit_value::() }; + f(lock, data, args.get(0)) +} -impl Traced for Reactions { - fn trace(&self, visitor: &mut crate::GcVisitor) { - self.0.trace(visitor); - } +/// The callback of a function created with data `T` that calls the reaction `F`. +fn reaction() -> crate::RawFunctionCallback +where + T: Traced + 'static, + F: UnitType + + 'static + + for<'b> Fn(&mut Lock<'b>, &T, Local<'b, Value>) -> Result, Error>, +{ + Function::callback::(react::) } impl<'s> Local<'s, Promise> { @@ -183,10 +213,9 @@ impl<'s> Local<'s, Promise> { if self.state() == PromiseState::Pending { return None; } - // SAFETY: self is a valid promise handle, which is not pending. - let result = unsafe { ffi::local_promise_result(self.raw()) }.ok()?; - // SAFETY: the result is a handle in the lock's scope. - Some(unsafe { Local::from_ffi(result) }) + // SAFETY: self is a valid promise handle, which is not pending, and the result is a + // handle in the lock's scope. + Some(unsafe { Local::from_ffi(ffi::local_promise_result(self.raw())) }) } /// Marks the promise as handled, so that its rejection is not reported as unhandled. @@ -195,26 +224,95 @@ impl<'s> Local<'s, Promise> { unsafe { ffi::local_promise_mark_as_handled(self.raw()) } } - /// Registers `on_fulfilled` and `on_rejected` to run when the promise settles, like - /// `promise.then(onFulfilled, onRejected)` without looking up `then`, and returns the promise - /// for the result of whichever runs. + /// Registers `on_fulfilled` to run when the promise is fulfilled, like + /// `promise.then(onFulfilled)`, and returns the promise for its result. A rejection passes + /// through to the returned promise unchanged. + /// + /// The callback runs as a microtask, with `data` and the fulfillment value. An `Ok` value + /// resolves the returned promise (a promise or thenable is followed) and an `Err` rejects it + /// with the exception that throwing it would throw. A panic throws an internal error and + /// terminates execution, as in a `#[jsg_method]`. + /// + /// Unlike `promise.then()`, this neither looks up `then` nor reads `promise.constructor`, so + /// no JavaScript runs until the promise settles. + /// + /// As with [`Function::new`], the callback must capture nothing, which a compile-time + /// assertion checks, and whatever it needs comes from `data`. The garbage collector traces + /// `data` until the promise settles and drops it once the callback can no longer run, so it + /// may hold `TracedGlobal`s and `jsg::Rc`s, including ones that lead back to the promise. It + /// must be their only owner that the garbage collector traces: to share a resource's state, + /// hold a `jsg::Rc` or `jsg::Weak` to the resource rather than an `std::rc::Rc` of the + /// state. + /// + /// # Errors + /// + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. + pub fn then(self, lock: &mut Lock<'s>, data: T, on_fulfilled: F) -> crate::Result + where + T: Traced + 'static, + F: UnitType + + 'static + + for<'a> Fn(&mut Lock<'a>, &T, Local<'a, Value>) -> Result, Error>, + { + self.debug_assert_isolate(lock); + let _ = on_fulfilled; + let [on_fulfilled] = Function::new_sharing_data(lock, data, [reaction::()])?; + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; both are + // valid handles, and a non-empty result is a promise in the lock's scope. + unsafe { + Local::from_ffi_maybe(ffi::local_promise_then( + lock.isolate().as_ffi(), + self.raw(), + on_fulfilled.raw(), + )) + } + .ok_or_else(Error::pending) + } + + /// Registers `on_rejected` to run when the promise is rejected, like + /// `promise.catch(onRejected)`, and returns the promise for its result. A fulfillment passes + /// through to the returned promise unchanged. /// - /// The callbacks run as microtasks, with `data` and the fulfillment value or rejection - /// reason. An `Ok` value resolves the returned promise (a promise or thenable is followed) - /// and an `Err` rejects it with the exception that throwing it would throw. A panic throws an - /// internal error and terminates execution, as in a `#[jsg_method]`. + /// Like [`Local::then`] otherwise, with the rejection reason as the callback's argument. + /// Unlike `promise.catch()`, this does not call `promise.then`. Registering `on_rejected` + /// marks the promise as handled, as in JavaScript. /// - /// As with [`Function::new`], the callbacks must capture nothing, which a compile-time - /// assertion checks, and whatever they need comes from `data`. The garbage collector traces - /// `data` until the promise settles and drops it once neither callback can run, so it may - /// hold `TracedGlobal`s and `jsg::Rc`s, including ones that lead back to the promise. + /// # Errors /// - /// Registering `on_rejected` marks the promise as handled, as in JavaScript. + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. + pub fn catch(self, lock: &mut Lock<'s>, data: T, on_rejected: R) -> crate::Result + where + T: Traced + 'static, + R: UnitType + + 'static + + for<'a> Fn(&mut Lock<'a>, &T, Local<'a, Value>) -> Result, Error>, + { + self.debug_assert_isolate(lock); + let _ = on_rejected; + let [on_rejected] = Function::new_sharing_data(lock, data, [reaction::()])?; + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; both are + // valid handles, and a non-empty result is a promise in the lock's scope. + unsafe { + Local::from_ffi_maybe(ffi::local_promise_catch( + lock.isolate().as_ffi(), + self.raw(), + on_rejected.raw(), + )) + } + .ok_or_else(Error::pending) + } + + /// Registers `on_fulfilled` and `on_rejected` to run when the promise settles, like + /// `promise.then(onFulfilled, onRejected)`, and returns the promise for the result of + /// whichever runs. + /// + /// Like [`Local::then`] and [`Local::catch`] otherwise. The two callbacks share `data`: one + /// owner holds it for both, and the garbage collector traces it once. /// /// # Errors /// - /// Fails if execution is terminating. - pub fn then( + /// Fails, leaving an exception pending, if V8 does; see [`Promise`]. + pub fn then_or_catch( self, lock: &mut Lock<'s>, data: T, @@ -232,32 +330,19 @@ impl<'s> Local<'s, Promise> { { self.debug_assert_isolate(lock); let _ = (on_fulfilled, on_rejected); - let data = std::rc::Rc::new(data); - let fulfilled = Function::new( - lock, - Reactions(std::rc::Rc::clone(&data)), - |lock, data, args| { - // SAFETY: `then` was handed a value of `F`. - let f = unsafe { crate::scope::unit_value::() }; - f(lock, &data.0, args.get(0)) - }, - )?; - let rejected = Function::new(lock, Reactions(data), |lock, data, args| { - // SAFETY: `then` was handed a value of `R`. - let f = unsafe { crate::scope::unit_value::() }; - f(lock, &data.0, args.get(0)) - })?; + let [on_fulfilled, on_rejected] = + Function::new_sharing_data(lock, data, [reaction::(), reaction::()])?; // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; all three // are valid handles, and a non-empty result is a promise in the lock's scope. unsafe { - Local::from_ffi_maybe(ffi::local_promise_then( + Local::from_ffi_maybe(ffi::local_promise_then_or_catch( lock.isolate().as_ffi(), self.raw(), - fulfilled.raw(), - rejected.raw(), + on_fulfilled.raw(), + on_rejected.raw(), )) } - .ok_or_else(terminated) + .ok_or_else(Error::pending) } } @@ -265,7 +350,7 @@ impl<'s> FromJS<'s> for Local<'s, Promise> { type ResultType = Self; /// Like C++ JSG's promise arguments: a promise is taken as is, and any other value becomes - /// a promise resolved with it. + /// a promise resolved with it; see [`Promise::resolved`]. fn from_js(lock: &mut Lock<'s>, value: Local<'s, Value>) -> Result { Promise::resolved(lock, value) } @@ -274,7 +359,3 @@ impl<'s> FromJS<'s> for Local<'s, Promise> { "Promise".to_owned() } } - -impl_as!(Promise, is_promise); -impl_local_cast!(Promise -> Value, is_promise); -impl_local_cast!(Promise -> Object, is_promise); diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 489ad06e3ba..8c71143aead 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -48,6 +48,7 @@ use std::ptr::NonNull; use std::rc::Rc; use std::sync::Arc; +pub use self::ffi::PromiseState; use crate::Error; use crate::FromJS; use crate::GarbageCollected; @@ -58,6 +59,8 @@ use crate::Resource; use crate::Traced; use crate::UnitType; use crate::isolate_state::IsolateState; +pub use crate::promise::Promise; +pub use crate::promise::PromiseResolver; #[expect( clippy::missing_safety_doc, reason = "bridge declarations of raw V8 FFI functions; their contracts are V8's own" @@ -390,13 +393,11 @@ pub mod ffi { recv: &Local, args: &[Local], ) -> MaybeLocal; - // Fallible: creating the wrapper throws, and creating the function returns empty, when - // execution is terminating. pub unsafe fn function_new( isolate: *mut Isolate, callback: usize, - wrappable: KjRc, - ) -> Result; + data: &Local, + ) -> MaybeLocal; // Local pub unsafe fn local_object_set_property( @@ -418,13 +419,22 @@ pub mod ffi { // Local pub unsafe fn local_promise_state(promise: &Local) -> PromiseState; - // Fallible: requires that the promise is not pending. - pub unsafe fn local_promise_result(promise: &Local) -> Result; + pub unsafe fn local_promise_result(promise: &Local) -> Local; pub unsafe fn local_promise_mark_as_handled(promise: &Local); pub unsafe fn local_promise_then( isolate: *mut Isolate, promise: &Local, on_fulfilled: &Local, + ) -> MaybeLocal; + pub unsafe fn local_promise_catch( + isolate: *mut Isolate, + promise: &Local, + on_rejected: &Local, + ) -> MaybeLocal; + pub unsafe fn local_promise_then_or_catch( + isolate: *mut Isolate, + promise: &Local, + on_fulfilled: &Local, on_rejected: &Local, ) -> MaybeLocal; @@ -727,6 +737,13 @@ pub mod ffi { args: Pin<&mut FunctionCallbackInfo>, ); + // Fallible: throws if the Wrappable already has a wrapper, or if V8 cannot create one, + // which happens only when execution is terminating. + pub unsafe fn wrappable_attach_opaque_wrapper( + isolate: *mut Isolate, + wrappable: KjRc, + ) -> Result; + // Infallible: only defensive tag checks; returns kj::none for non-Rust // wrappables rather than throwing. pub unsafe fn unwrap_resource( @@ -1283,7 +1300,7 @@ impl<'s, T> Local<'s, T> { /// The scope rules keep handles in their isolate, except inside /// [`allow_scopes_of_another_isolate`](crate::allow_scopes_of_another_isolate); this /// catches handles that cross isolates there. - fn debug_assert_isolate(&self, lock: &Lock<'_>) { + pub(crate) fn debug_assert_isolate(&self, lock: &Lock<'_>) { debug_assert!( // SAFETY: the lock's isolate is locked, and the check reads only the value's page // header, which is valid whichever isolate owns the value. @@ -1293,7 +1310,7 @@ impl<'s, T> Local<'s, T> { } /// The handle as the FFI type that C++ functions take, without giving up its lifetime. - fn raw(&self) -> &ffi::Local { + pub(crate) fn raw(&self) -> &ffi::Local { // SAFETY: `Local` is a `repr(transparent)` pointer and `ffi::Local` a pointer-sized // integer, with the same size and alignment (asserted above). unsafe { &*std::ptr::from_ref(self).cast::() } @@ -1578,6 +1595,7 @@ macro_rules! impl_as { } impl_as!(Function, is_function); +impl_as!(Promise, is_promise); impl_as!(Object, is_object); impl_as!(Array, is_array); impl_as!(String, is_string); @@ -1651,7 +1669,9 @@ impl Function { /// collector, and the function and `data` are freed only when the isolate is torn down. /// - Another owner that the garbage collector also traces, such as a resource sharing part of /// `data` through a `std::rc::Rc`. `data` must be the only owner that traces what it - /// traces: tracing one `jsg::Rc` from two owners fails an assertion. + /// traces: tracing one `jsg::Rc` from two owners fails an assertion, and a `TracedGlobal` + /// traced from two owners can be freed while one of them still uses it. To share state, + /// hold a `jsg::Rc` or `jsg::Weak` to the resource that owns it. /// /// `f` must capture nothing, which a compile-time assertion checks; whatever it needs comes /// from `data`. @@ -1680,19 +1700,52 @@ impl Function { &mut FunctionCallbackInfo<'a>, ) -> Result, Error>, { - let _ = f; + let [function] = Self::new_sharing_data(lock, data, [Self::callback::(f)])?; + Ok(function) + } + + /// Creates one function per callback, all with the same data: one Wrappable owns `data`, + /// and its opaque wrapper is the V8 data of every function, so the garbage collector traces + /// `data` once, whichever of the functions are reachable, and drops it once none is. + /// + /// Each callback comes from [`Function::callback`] with the same `T`. Fails as + /// [`Function::new`] does. + pub(crate) fn new_sharing_data<'s, T, const N: usize>( + lock: &Lock<'s>, + data: T, + callbacks: [crate::RawFunctionCallback; N], + ) -> crate::Result<[Local<'s, Self>; N]> + where + T: Traced + 'static, + { // As in `jsg::Rc::new`, the Wrappable owns a leaked `Rc` of the data, released when the // Wrappable is destroyed. let raw: *const FunctionData = Rc::into_raw(Rc::new(FunctionData(data))); let fat: *mut dyn GarbageCollected = raw as *const dyn GarbageCollected as *mut _; let mut wrappable: WrappableRc = ffi::TraitObjectPtr::from_raw(fat, TypeId::of::>()).into(); - let function = - wrappable.new_function(lock, crate::function_callback(call_function::)); + let functions = wrappable.new_functions(lock, callbacks); // A new Wrappable starts with one strong reference, which `jsg::Rc` would hold. Here only - // the function owns it, so the reference is dropped and the garbage collector decides. + // the functions own it, so the reference is dropped and the garbage collector decides. wrappable.remove_strong_ref(true); - function + functions + } + + /// The callback with which a function that [`Function::new_sharing_data`] creates with + /// data `T` calls `f`, as [`Function::new`]'s function calls its closure. + pub(crate) fn callback(f: F) -> crate::RawFunctionCallback + where + T: Traced + 'static, + F: UnitType + + 'static + + for<'a> Fn( + &mut Lock<'a>, + &T, + &mut FunctionCallbackInfo<'a>, + ) -> Result, Error>, + { + let _ = f; + crate::function_callback(call_function::) } } @@ -1822,6 +1875,7 @@ impl_local_cast!(BigInt -> Value, is_big_int); impl_local_cast!(Date -> Value, is_date); impl_local_cast!(Object -> Value, is_object); impl_local_cast!(Function -> Value, is_function); +impl_local_cast!(Promise -> Value, is_promise); impl_local_cast!(Array -> Value, is_array); // String and Symbol are both subtypes of Name @@ -1853,8 +1907,9 @@ impl_local_cast!(BigInt64Array -> TypedArray, is_bigint64_array); impl_local_cast!(BigUint64Array -> TypedArray, is_biguint64_array); impl_local_cast!(Uint8ClampedArray -> TypedArray, is_uint8clamped_array); -// Upcasts to Object (Function, Array, TypedArray are all Object subtypes in V8) +// Upcasts to Object (Function, Promise, Array, TypedArray are all Object subtypes in V8) impl_local_cast!(Function -> Object, is_function); +impl_local_cast!(Promise -> Object, is_promise); impl_local_cast!(Array -> Object, is_array); impl_local_cast!(TypedArray -> Object, is_typed_array); @@ -1879,13 +1934,6 @@ impl_local_cast!(Float64Array -> ArrayBufferView, is_float64_array); impl_local_cast!(BigInt64Array -> ArrayBufferView, is_bigint64_array); impl_local_cast!(BigUint64Array -> ArrayBufferView, is_biguint64_array); -// Declared after `impl_as!` and `impl_local_cast!`, which it uses. -#[path = "promise.rs"] -mod promise; -pub use promise::Promise; -pub use promise::PromiseResolver; -pub use promise::PromiseState; - impl Array { /// Creates a new JavaScript array with the given length. pub fn new<'s>(lock: &mut crate::Lock<'s>, len: usize) -> Local<'s, Self> { @@ -4313,36 +4361,49 @@ impl WrappableRc { unsafe { ffi::wrappable_strong_refcount(&*self.handle.get()) } } - /// Creates a JavaScript function that calls `callback`, with an opaque wrapper of this - /// Wrappable as its data, which keeps the Wrappable alive while the function is reachable. + /// Creates one JavaScript function per callback, each with an opaque wrapper of this + /// Wrappable as its data, which keeps the Wrappable alive while one of them is reachable. + /// The Wrappable must not have a wrapper yet, and gets one wrapper for all of them. /// - /// While execution is terminating, creates nothing and returns [`Error::pending`]'s error, - /// as [`Local::::call`] does. V8 fails to create a function only then. - pub(crate) fn new_function<'s>( + /// While execution is terminating, or an exception is pending, creates nothing and returns + /// [`Error::pending`]'s error, as [`Local::::call`] does. V8 fails to create a + /// function only while execution is terminating. + pub(crate) fn new_functions<'s, const N: usize>( &self, - lock: &mut Lock<'s>, - callback: crate::RawFunctionCallback, - ) -> crate::Result> { + lock: &Lock<'s>, + callbacks: [crate::RawFunctionCallback; N], + ) -> crate::Result<[Local<'s, Function>; N]> { + let isolate = lock.isolate().as_ffi(); + // Creating the wrapper opens V8's `PrepareForExecutionScope`, which clears a pending + // exception or termination, so nothing is created while one is pending. // SAFETY: isolate is valid and locked (guaranteed by Lock). - if unsafe { ffi::isolate_is_execution_terminating(lock.isolate().as_ffi()) } { + if unsafe { + ffi::isolate_is_execution_terminating(isolate) + || ffi::isolate_has_pending_exception(isolate) + } { return Err(Error::pending()); } - // SAFETY: isolate is valid and locked (guaranteed by Lock); `callback` is a V8 function - // callback. - let handle = unsafe { - ffi::function_new( - lock.isolate().as_ffi(), - callback as usize, - self.handle.clone(), - ) + // SAFETY: isolate is valid and locked, with a context entered and a HandleScope open + // (guaranteed by Lock). + let data = unsafe { ffi::wrappable_attach_opaque_wrapper(isolate, self.handle.clone()) } + .map_err(|_| Error::pending())?; + let mut functions = Vec::with_capacity(N); + for callback in callbacks { + // SAFETY: as above; `callback` is a V8 function callback and `data` a handle in the + // lock's scope, in which the function is created. + let function = unsafe { + Local::from_ffi_maybe(ffi::function_new(isolate, callback as usize, &data)) + }; + functions.push(function.ok_or_else(Error::pending)?); } - .map_err(|_| Error::pending())?; - // SAFETY: the function is created in the scope of `lock`. - unsafe { Local::from_ffi_maybe(handle) }.ok_or_else(Error::pending) + let Ok(functions) = functions.try_into() else { + unreachable!("one function per callback") + }; + Ok(functions) } /// Returns the Rust Wrappable whose opaque wrapper is the running function's data, as - /// [`new_function`](Self::new_function) attaches it, or `None` if the data is anything else. + /// [`new_functions`](Self::new_functions) attaches it, or `None` if the data is anything else. pub(crate) fn of_function(args: &FunctionCallbackInfo<'_>) -> Option { // SAFETY: args.0 is the running callback's FunctionCallbackInfo. let handle: Option> = From 2436ca0e6c7f19934a66c18a9c94a93d7aa7a7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 17:05:05 +0100 Subject: [PATCH 6/7] Capture and enter async context frames in Rust JSG 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. --- src/rust/AGENTS.md | 2 +- .../async-context-enter-holds-the-lock.rs | 11 + .../async-context-enter-lock-cannot-escape.rs | 11 + .../async-context-frame-is-not-traced.rs | 18 + .../ok-async-context-frame.rs | 34 ++ .../traced-async-context-frame-is-not-sync.rs | 17 + src/rust/jsg-test/ffi.c++ | 30 ++ src/rust/jsg-test/ffi.h | 11 + src/rust/jsg-test/lib.rs | 71 +++ src/rust/jsg-test/tests/async_context.rs | 503 ++++++++++++++++++ src/rust/jsg-test/tests/mod.rs | 1 + src/rust/jsg/README.md | 41 ++ src/rust/jsg/async_context.rs | 215 ++++++++ src/rust/jsg/ffi.c++ | 8 + src/rust/jsg/ffi.h | 5 + src/rust/jsg/lib.rs | 3 + src/rust/jsg/v8.rs | 7 + 17 files changed, 987 insertions(+), 1 deletion(-) create mode 100644 src/rust/jsg-compile-tests/async-context-enter-holds-the-lock.rs create mode 100644 src/rust/jsg-compile-tests/async-context-enter-lock-cannot-escape.rs create mode 100644 src/rust/jsg-compile-tests/async-context-frame-is-not-traced.rs create mode 100644 src/rust/jsg-compile-tests/ok-async-context-frame.rs create mode 100644 src/rust/jsg-compile-tests/traced-async-context-frame-is-not-sync.rs create mode 100644 src/rust/jsg-test/tests/async_context.rs create mode 100644 src/rust/jsg/async_context.rs diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index 55edd626fb4..158468c1062 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -14,7 +14,7 @@ _Snapshot — the set drifts as crates come and go; `bazel query //src/rust/...` | Crate | Purpose | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `jsg/` | Rust JSG bindings: `Lock`, `Rc`, `Resource`, `Struct`, `Type`, `Realm`, `FeatureFlags`, module registration; V8 handle types including typed arrays, `ArrayBuffer`, `ArrayBufferView`, `SharedArrayBuffer`, `BackingStore`; JS functions from Rust closures (`v8::Function::new`) | +| `jsg/` | Rust JSG bindings: `Lock`, `Rc`, `Resource`, `Struct`, `Type`, `Realm`, `FeatureFlags`, module registration; V8 handle types including typed arrays, `ArrayBuffer`, `ArrayBufferView`, `SharedArrayBuffer`, `BackingStore`; JS functions from Rust closures (`v8::Function::new`); async context frames (`AsyncContextFrame`, `TracedAsyncContextFrame`) | | `jsg-macros/` | Proc macros: `#[jsg_struct]`, `#[jsg_method]`, `#[jsg_resource]`, `#[jsg_oneof]`, `#[jsg_static_constant]`, `#[jsg_constructor]`, `#[jsg_property]`, `#[jsg_inspect_property]` | | `jsg-test/` | Test harness (`Harness`) for JSG Rust bindings | | `api/` | Rust-implemented Node.js APIs; registers modules via `register_nodejs_modules()` | diff --git a/src/rust/jsg-compile-tests/async-context-enter-holds-the-lock.rs b/src/rust/jsg-compile-tests/async-context-enter-holds-the-lock.rs new file mode 100644 index 00000000000..41fa2d22a2a --- /dev/null +++ b/src/rust/jsg-compile-tests/async-context-enter-holds-the-lock.rs @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// `enter` borrows the caller's lock for as long as the frame is current, so the closure can only +// use the lock it is handed, not the caller's. +pub fn t(lock: &mut jsg::Lock<'_>, frame: &jsg::AsyncContextFrame) { + frame.enter(lock, |_inner| lock.new_object()); //~ ERROR E0501 + + //~| ERROR E0500 +} diff --git a/src/rust/jsg-compile-tests/async-context-enter-lock-cannot-escape.rs b/src/rust/jsg-compile-tests/async-context-enter-lock-cannot-escape.rs new file mode 100644 index 00000000000..7429cd9e070 --- /dev/null +++ b/src/rust/jsg-compile-tests/async-context-enter-lock-cannot-escape.rs @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The lock that `enter` hands its closure is only borrowed for the closure, while the frame is +// current, so it cannot be kept for use after the previous frame is restored. +pub fn t<'s>(lock: &mut jsg::Lock<'s>, frame: &jsg::AsyncContextFrame) { + let mut escaped = None; + frame.enter(lock, |lock| escaped = Some(lock)); //~ ERROR E0521 + let _ = escaped.map(|lock: &mut jsg::Lock<'s>| lock.new_object()); +} diff --git a/src/rust/jsg-compile-tests/async-context-frame-is-not-traced.rs b/src/rust/jsg-compile-tests/async-context-frame-is-not-traced.rs new file mode 100644 index 00000000000..6e56a8cefe1 --- /dev/null +++ b/src/rust/jsg-compile-tests/async-context-frame-is-not-traced.rs @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// An `AsyncContextFrame` holds a strong `Global`, so a cycle through it is never collected. It is +// not `Traced`, so it cannot be a field of a resource that GC traces; a resource holds a +// `TracedAsyncContextFrame`. +use std::cell::Cell; + +use jsg_macros::jsg_resource; + +#[jsg_resource] //~ ERROR E0277 +struct Holder { + frame: Cell>, +} + +#[jsg_resource] +impl Holder {} diff --git a/src/rust/jsg-compile-tests/ok-async-context-frame.rs b/src/rust/jsg-compile-tests/ok-async-context-frame.rs new file mode 100644 index 00000000000..b5774d64cb7 --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-async-context-frame.rs @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// An `AsyncContextFrame` is `'static`, `Send` and `Sync`, so a future passed to async I/O can hold +// it, and a `TracedAsyncContextFrame` is `Send` and can be a resource field. `enter` opens no +// scope, so a handle created in the frame stays valid after it. +use std::cell::Cell; + +use jsg::v8; +use jsg_macros::jsg_resource; + +fn assert_static_send_and_sync() {} +fn assert_static_send() {} + +#[jsg_resource] +struct Holder { + frame: Cell>, +} + +#[jsg_resource] +impl Holder {} + +pub fn t<'s>(lock: &mut jsg::Lock<'s>) -> v8::Local<'s, v8::Object> { + assert_static_send_and_sync::(); + assert_static_send::(); + let frame = jsg::AsyncContextFrame::current(lock); + let object = frame.enter(lock, |lock| lock.new_object()); + let holder = Holder { + frame: Cell::new(Some(frame.into())), + }; + drop(holder); + object +} diff --git a/src/rust/jsg-compile-tests/traced-async-context-frame-is-not-sync.rs b/src/rust/jsg-compile-tests/traced-async-context-frame-is-not-sync.rs new file mode 100644 index 00000000000..912dac0d822 --- /dev/null +++ b/src/rust/jsg-compile-tests/traced-async-context-frame-is-not-sync.rs @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A `TracedAsyncContextFrame` holds a `TracedGlobal`, which can move to another thread but not be +// shared with one. +// +// This compiles only if the type is not Sync; see `traced-global-is-not-sync.rs`. +trait AmbiguousIfSync { + fn some_item() {} +} +impl AmbiguousIfSync<()> for T {} +impl AmbiguousIfSync for T {} + +pub fn t() { + let _ = >::some_item; +} diff --git a/src/rust/jsg-test/ffi.c++ b/src/rust/jsg-test/ffi.c++ index c1039998f14..c655b13d841 100644 --- a/src/rust/jsg-test/ffi.c++ +++ b/src/rust/jsg-test/ffi.c++ @@ -5,6 +5,7 @@ #include "ffi.h" #include +#include #include #include #include @@ -223,6 +224,35 @@ bool isolates_share_group(Isolate* a, Isolate* b) { return a->GetGroup() == b->GetGroup(); } +namespace { +// The storage key of the async context frames the tests create, like the key of one +// AsyncLocalStorage. +const kj::Arc<::workerd::jsg::AsyncContextFrame::StorageKey>& testStorageKey() { + static const auto key = kj::arc<::workerd::jsg::AsyncContextFrame::StorageKey>(); + return key; +} +} // namespace + +void run_in_async_context(Isolate* isolate, + ::workerd::rust::jsg::Local value, + size_t data, + ::rust::Fn callback) { + auto& js = ::workerd::jsg::Lock::from(isolate); + ::workerd::jsg::AsyncContextFrame::StorageScope scope(js, testStorageKey().addRef(), + js.v8Ref(::workerd::rust::jsg::local_from_ffi(kj::mv(value)))); + callback(data); +} + +::workerd::rust::jsg::MaybeLocal async_context_value(Isolate* isolate) { + KJ_IF_SOME(frame, ::workerd::jsg::AsyncContextFrame::current(isolate)) { + KJ_IF_SOME(value, frame.get(*testStorageKey())) { + return ::workerd::rust::jsg::maybe_local_to_ffi( + v8::MaybeLocal(value.getHandle(isolate))); + } + } + return ::workerd::rust::jsg::maybe_local_to_ffi(v8::MaybeLocal()); +} + } // namespace rust::jsg_test } // namespace workerd diff --git a/src/rust/jsg-test/ffi.h b/src/rust/jsg-test/ffi.h index 644d6271d58..48b59dcffcd 100644 --- a/src/rust/jsg-test/ffi.h +++ b/src/rust/jsg-test/ffi.h @@ -99,6 +99,17 @@ bool cpp_sees_empty_maybe_local(::workerd::rust::jsg::MaybeLocal value); // Returns whether `a` and `b` belong to the same v8::IsolateGroup. bool isolates_share_group(Isolate* a, Isolate* b); +// Calls `callback` in a new C++ jsg::AsyncContextFrame that stores `value` under the tests' +// storage key, as AsyncLocalStorage.run() does, then makes the previous frame current again. +void run_in_async_context(Isolate* isolate, + ::workerd::rust::jsg::Local value, + size_t data, + ::rust::Fn callback); + +// The value the current async context frame stores under the tests' storage key, or empty in the +// root frame or a frame that stores none. +::workerd::rust::jsg::MaybeLocal async_context_value(Isolate* isolate); + } // namespace rust::jsg_test } // namespace workerd diff --git a/src/rust/jsg-test/lib.rs b/src/rust/jsg-test/lib.rs index fafcce82e55..4e470b25976 100644 --- a/src/rust/jsg-test/lib.rs +++ b/src/rust/jsg-test/lib.rs @@ -125,6 +125,19 @@ mod ffi { /// Returns whether `a` and `b` belong to the same `v8::IsolateGroup`. pub unsafe fn isolates_share_group(a: *mut Isolate, b: *mut Isolate) -> bool; + + /// Calls `callback` in a new C++ `jsg::AsyncContextFrame` that stores `value` under the + /// tests' storage key, as `AsyncLocalStorage.run()` does. + pub unsafe fn run_in_async_context( + isolate: *mut Isolate, + value: Local, + data: usize, /* callback */ + callback: unsafe fn(usize /* callback */), + ); + + /// The value the current async context frame stores under the tests' storage key, or + /// empty if it stores none. + pub unsafe fn async_context_value(isolate: *mut Isolate) -> MaybeLocal; } } @@ -462,6 +475,64 @@ impl Harness { // open. unsafe { ffi::cpp_wrapped_function_data_is_rust_wrappable(lock.isolate().as_ffi()) } } + + /// Runs `f` in a new C++ `jsg::AsyncContextFrame` that stores `value` under the tests' + /// storage key, as `AsyncLocalStorage.run()` does, and makes the previous frame current + /// again afterwards. [`Harness::async_context_value`] reads the value back. + /// + /// # Panics + /// + /// Panics if `f` panics, once the previous frame is current again. + pub fn run_in_async_context<'s, F: FnOnce(&mut jsg::Lock<'s>) -> R, R>( + lock: &mut jsg::Lock<'s>, + value: v8::Local<'s, v8::Value>, + f: F, + ) -> R { + struct Call<'a, 's, F, R> { + lock: &'a mut jsg::Lock<'s>, + f: Option, + result: Option>>, + } + + fn trampoline<'s, F: FnOnce(&mut jsg::Lock<'s>) -> R, R>(data: usize) { + // SAFETY: data was cast from &raw mut Call in run_in_async_context below. + let call = unsafe { &mut *(data as *mut Call<'_, 's, F, R>) }; + let Some(f) = call.f.take() else { + return; + }; + let lock = &mut *call.lock; + call.result = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || f(lock), + ))); + } + + let isolate = lock.isolate().as_ffi(); + // SAFETY: `value` is valid in the lock's scope. + let value = unsafe { value.into_ffi() }; + let mut call = Call:: { + lock, + f: Some(f), + result: None, + }; + // SAFETY: the lock keeps the isolate locked with a context entered and a scope open, and + // C++ opens no scope around the callback, so the lock stays valid in it; `call` outlives + // the call. + unsafe { + ffi::run_in_async_context(isolate, value, &raw mut call as usize, trampoline::); + } + match call.result.expect("the callback ran") { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), + } + } + + /// The value that the current async context frame stores under the tests' storage key (see + /// [`Harness::run_in_async_context`]), or `None` in the root frame. + pub fn async_context_value<'s>(lock: &mut jsg::Lock<'s>) -> Option> { + // SAFETY: the lock keeps the isolate locked with a scope open, which the value belongs + // to. + unsafe { v8::Local::from_ffi_maybe(ffi::async_context_value(lock.isolate().as_ffi())) } + } } /// Whether `a` and `b` belong to the same `v8::IsolateGroup`. diff --git a/src/rust/jsg-test/tests/async_context.rs b/src/rust/jsg-test/tests/async_context.rs new file mode 100644 index 00000000000..be3790c16f5 --- /dev/null +++ b/src/rust/jsg-test/tests/async_context.rs @@ -0,0 +1,503 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! `jsg::AsyncContextFrame` and `jsg::TracedAsyncContextFrame`: capturing the current async +//! context frame and running code in it later. +//! +//! The frames are C++ `jsg::AsyncContextFrame`s that `Harness::run_in_async_context` creates, +//! as `AsyncLocalStorage.run()` does, each storing a value under one test storage key. Rust +//! reads the current frame's value with `Harness::async_context_value`, and JavaScript with +//! `probe.value()`. + +use std::cell::Cell; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use jsg::AsyncContextFrame; +use jsg::FromJS; +use jsg::ToJS; +use jsg::TracedAsyncContextFrame; +use jsg::v8; +use jsg_macros::jsg_method; +use jsg_macros::jsg_resource; + +use crate::Harness; + +/// The value the current frame stores, or `None` in the root frame. +fn current(lock: &mut jsg::Lock) -> Option { + Harness::async_context_value(lock).map(|value| String::from_js(lock, value).unwrap()) +} + +/// Captures the frame of a new `run_in_async_context` that stores `value`. +fn frame_storing(lock: &mut jsg::Lock, value: &str) -> AsyncContextFrame { + let value = value.to_js(lock); + Harness::run_in_async_context(lock, value, AsyncContextFrame::current) +} + +/// Lets JavaScript read the current frame, and holds a frame for later calls. +#[jsg_resource] +struct Probe { + frame: Cell>, + /// Whether the frame current before `run_in_frame` entered its frame was current again + /// after it. + restored: Cell, + /// Whether the callback `run_in_frame` called failed with a pending error. + saw_pending: Cell, +} + +impl Probe { + /// A strong handle to the captured frame. The traced frame goes back into its field before + /// anything can run JavaScript, which the caller may do with the handle it gets. + fn frame(&self, lock: &mut jsg::Lock) -> AsyncContextFrame { + let traced = self.frame.take().unwrap(); + let frame = traced.to_async_context_frame(lock); + self.frame.set(Some(traced)); + frame + } +} + +#[jsg_resource] +impl Probe { + /// The value the current frame stores, or `undefined` in the root frame. + #[jsg_method] + fn value<'s>(&self, lock: &mut jsg::Lock<'s>) -> v8::Local<'s, v8::Value> { + Harness::async_context_value(lock).unwrap_or_else(|| lock.undefined()) + } + + /// Captures the frame of a new `run_in_async_context` that stores `value`. Nothing but + /// this resource holds the frame afterwards. + #[jsg_method] + fn capture_in<'s>(&self, lock: &mut jsg::Lock<'s>, value: v8::Local<'s, v8::Value>) { + let frame = Harness::run_in_async_context(lock, value, TracedAsyncContextFrame::current); + self.frame.set(Some(frame)); + } + + /// The value the captured frame stores, read in that frame. + #[jsg_method] + fn value_in_frame<'s>(&self, lock: &mut jsg::Lock<'s>) -> v8::Local<'s, v8::Value> { + self.frame(lock).enter(lock, |lock| self.value(lock)) + } + + /// Calls `callback` in the captured frame. + #[jsg_method] + fn run_in_frame<'s>( + &self, + lock: &mut jsg::Lock<'s>, + callback: v8::Local<'s, v8::Value>, + ) -> Result<(), jsg::Error> { + let callback = callback.try_as::().unwrap(); + let frame = self.frame(lock); + // Compared as handles, since converting them could run JavaScript, which must not run + // while execution is terminating. + let before = Harness::async_context_value(lock); + let result = frame.enter(lock, |lock| { + callback.call::>(lock, None, &[]) + }); + self.restored + .set(Harness::async_context_value(lock) == before); + self.saw_pending + .set(result.as_ref().is_err_and(jsg::Error::is_pending)); + result.map(|_| ()) + } + + /// Requests termination, which takes effect when JavaScript next checks for interrupts. + #[jsg_method] + fn terminate(&self, lock: &mut jsg::Lock) { + lock.terminate_execution(); + } +} + +fn install_probe(lock: &mut jsg::Lock, ctx: &crate::EvalContext<'_>) -> jsg::Rc { + let probe = jsg::Rc::new(Probe { + frame: Cell::new(None), + restored: Cell::new(false), + saw_pending: Cell::new(false), + }); + ctx.set_global(lock, "probe", probe.clone()); + probe +} + +#[test] +fn captured_frame_is_entered_later() { + Harness::new().run_in_context(|lock, ctx| { + install_probe(lock, ctx); + let frame = frame_storing(lock, "a"); + assert!(!frame.is_root()); + assert_eq!(current(lock), None); + let seen = frame.enter(lock, |lock| { + assert_eq!(current(lock).as_deref(), Some("a")); + ctx.eval::(lock, "probe.value()").unwrap() + }); + assert_eq!(seen, "a"); + assert_eq!(current(lock), None); + Ok(()) + }); +} + +#[test] +fn frame_captured_in_root_is_root() { + Harness::new().run_in_context(|lock, _ctx| { + let frame = AsyncContextFrame::current(lock); + assert!(frame.is_root()); + assert!(AsyncContextFrame::root().is_root()); + assert!(TracedAsyncContextFrame::current(lock).is_root()); + frame.enter(lock, |lock| assert_eq!(current(lock), None)); + Ok(()) + }); +} + +#[test] +fn entering_root_frame_leaves_frame_for_its_duration() { + Harness::new().run_in_context(|lock, _ctx| { + let value = "outer".to_js(lock); + Harness::run_in_async_context(lock, value, |lock| { + AsyncContextFrame::root().enter(lock, |lock| assert_eq!(current(lock), None)); + TracedAsyncContextFrame::root().enter(lock, |lock| assert_eq!(current(lock), None)); + assert_eq!(current(lock).as_deref(), Some("outer")); + }); + Ok(()) + }); +} + +#[test] +fn nested_enters_restore_each_previous_frame() { + Harness::new().run_in_context(|lock, _ctx| { + let a = frame_storing(lock, "a"); + let b: TracedAsyncContextFrame = frame_storing(lock, "b").into(); + a.enter(lock, |lock| { + b.enter(lock, |lock| { + assert_eq!(current(lock).as_deref(), Some("b")); + a.enter(lock, |lock| assert_eq!(current(lock).as_deref(), Some("a"))); + assert_eq!(current(lock).as_deref(), Some("b")); + }); + assert_eq!(current(lock).as_deref(), Some("a")); + }); + assert_eq!(current(lock), None); + Ok(()) + }); +} + +#[test] +fn clone_enters_same_frame() { + Harness::new().run_in_context(|lock, _ctx| { + let a = frame_storing(lock, "a"); + let clone = a.clone(lock); + drop(a); + clone.enter(lock, |lock| assert_eq!(current(lock).as_deref(), Some("a"))); + let traced = TracedAsyncContextFrame::from(clone); + let traced_clone = traced.clone(lock); + drop(traced); + traced_clone.enter(lock, |lock| assert_eq!(current(lock).as_deref(), Some("a"))); + Ok(()) + }); +} + +#[test] +fn enter_returns_closure_result_and_restores_after_error() { + Harness::new().run_in_context(|lock, _ctx| { + let a = frame_storing(lock, "a"); + let value = "outer".to_js(lock); + Harness::run_in_async_context(lock, value, |lock| { + let result: Result<(), jsg::Error> = + a.enter(lock, |_lock| Err(jsg::Error::new_type_error("failed"))); + assert_eq!(result.unwrap_err().message, "failed"); + assert_eq!(current(lock).as_deref(), Some("outer")); + let handle = a.enter(lock, |lock| "from a".to_js(lock)); + assert_eq!(String::from_js(lock, handle).unwrap(), "from a"); + }); + Ok(()) + }); +} + +#[test] +fn enter_restores_after_panic() { + Harness::new().run_in_context(|lock, _ctx| { + let a = frame_storing(lock, "a"); + let value = "outer".to_js(lock); + Harness::run_in_async_context(lock, value, |lock| { + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + a.enter(lock, |_lock| panic!("panic in frame")); + })); + assert!(result.is_err()); + assert_eq!(current(lock).as_deref(), Some("outer")); + }); + Ok(()) + }); +} + +/// A callback that terminates execution fails, and the frame current before `enter` is +/// current again while the termination still propagates. +#[test] +fn enter_restores_while_terminating() { + Harness::new().run_in_context(|lock, ctx| { + let probe = install_probe(lock, ctx); + let value = "a".to_js(lock); + probe.capture_in(lock, value); + let value = "outer".to_js(lock); + let result = Harness::run_in_async_context(lock, value, |lock| { + let result = ctx.eval_raw( + lock, + "probe.runInFrame(() => { probe.terminate(); while (true) {} }); 'not terminated'", + ); + assert_eq!(current(lock).as_deref(), Some("outer")); + result.is_err() + }); + assert!(result, "the script was not terminated"); + assert!(probe.saw_pending.get()); + assert!(probe.restored.get()); + assert_eq!(current(lock), None); + Ok(()) + }); +} + +/// V8 carries the current frame into the promise reactions registered in it, by JavaScript or +/// by Rust, which run in that frame after `enter` has returned. +#[test] +fn promise_reactions_registered_in_entered_frame_run_in_it() { + fn record<'a>( + lock: &mut jsg::Lock<'a>, + _data: &(), + _value: v8::Local<'a, v8::Value>, + ) -> Result, jsg::Error> { + Ok(Harness::async_context_value(lock).unwrap_or_else(|| lock.undefined())) + } + + Harness::new().run_in_context(|lock, ctx| { + install_probe(lock, ctx); + let a = frame_storing(lock, "a"); + let resolver = v8::PromiseResolver::new(lock)?; + let promise = resolver.promise(); + ctx.set_global(lock, "pending", v8::Local::::from(promise)); + let in_root = promise.then(lock, (), record)?; + let in_a = a.enter(lock, |lock| { + ctx.eval_raw( + lock, + "globalThis.seen = []; pending.then(() => seen.push(probe.value()))", + ) + .unwrap(); + promise.then(lock, (), record) + })?; + let undefined = lock.undefined(); + resolver.resolve(lock, undefined)?; + Harness::run_microtasks(lock); + + assert_eq!(ctx.eval::(lock, "seen.join()").unwrap(), "a"); + let result = in_a.result(lock).unwrap(); + assert_eq!(String::from_js(lock, result)?, "a"); + assert!(in_root.result(lock).unwrap().is_undefined()); + assert_eq!(current(lock), None); + Ok(()) + }); +} + +/// A frame captured under one lock is entered under a later one, as code that resumes after +/// async I/O does. +#[test] +fn frame_is_entered_in_later_lock() { + let harness = Harness::new(); + let mut kept = None; + harness.run_in_context(|lock, _ctx| { + kept = Some(frame_storing(lock, "a")); + Ok(()) + }); + let frame = kept.unwrap(); + harness.run_in_context(|lock, _ctx| { + // Only the `AsyncContextFrame` holds the frame's wrapper now. A minor GC would drop a + // wrapper that nothing else reaches, and the C++ frame with it. + Harness::request_minor_gc(lock); + Harness::request_gc(lock); + assert_eq!(current(lock), None); + frame.enter(lock, |lock| assert_eq!(current(lock).as_deref(), Some("a"))); + Ok(()) + }); +} + +static STORED_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// A value to store in a frame, which counts its drops. +#[jsg_resource] +struct Stored { + /// The probe whose frame stores this value, which closes a cycle through the frame. + owner: Cell>>, +} + +impl Drop for Stored { + fn drop(&mut self) { + STORED_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl Stored {} + +/// A frame held only by a resource keeps itself and the values it stores alive across GC, and +/// GC collects a cycle through it: the probe holds the frame, which stores a value that holds +/// the probe. +/// +/// Each step runs under its own lock, so that no handle of an earlier step's scope keeps +/// anything alive. +#[test] +fn frame_in_resource_is_traced() { + STORED_DROPS.store(0, Ordering::SeqCst); + let harness = Harness::new(); + let mut kept = None; + harness.run_in_context(|lock, _ctx| { + let probe = jsg::Rc::new(Probe { + frame: Cell::new(None), + restored: Cell::new(false), + saw_pending: Cell::new(false), + }); + // The probe's wrapper is reachable, but the probe has no strong reference from Rust, so + // GC traces its frame through the wrapper. + let wrapper = probe.clone().to_js(lock); + let stored = jsg::Rc::new(Stored { + owner: Cell::new(Some(probe.clone())), + }) + .to_js(lock); + probe.capture_in(lock, stored); + kept = Some((wrapper.to_global(lock), probe.downgrade())); + Ok(()) + }); + let (wrapper, probe) = kept.unwrap(); + + // Each step runs two GCs, so that the result does not depend on whether a visit during the + // first one is what turns a strong handle into a traced one. + harness.run_in_context(|lock, _ctx| { + Harness::request_gc(lock); + Harness::request_gc(lock); + assert_eq!( + STORED_DROPS.load(Ordering::SeqCst), + 0, + "the stored value was collected while the frame was held" + ); + let probe = probe.upgrade().unwrap(); + let stored = probe.value_in_frame(lock); + assert!(jsg::Rc::::from_js(lock, stored).is_ok()); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + drop(wrapper); + Harness::request_gc(lock); + Harness::request_gc(lock); + assert_eq!( + STORED_DROPS.load(Ordering::SeqCst), + 1, + "the cycle through the frame was not collected" + ); + Ok(()) + }); +} + +static MARKER_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// A value to store in a frame, which counts its drops. +#[jsg_resource] +struct Marker; + +impl Drop for Marker { + fn drop(&mut self) { + MARKER_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl Marker {} + +/// A frame in the data of a `v8::Function::new` function, which enters it to call its argument, +/// like C++ JSG's `AsyncContextFrame::wrap()`, survives GC while the function is reachable, and +/// is released with it. +/// +/// Each step runs under its own lock, and sets no globals, so that only the function keeps the +/// frame alive across the GCs between them. +#[test] +fn frame_in_function_data_lives_as_long_as_function() { + MARKER_DROPS.store(0, Ordering::SeqCst); + let harness = Harness::new(); + let mut kept = None; + harness.run_in_context(|lock, _ctx| { + let marker = jsg::Rc::new(Marker).to_js(lock); + let frame = Harness::run_in_async_context(lock, marker, TracedAsyncContextFrame::current); + let run_in_frame = v8::Function::new(lock, frame, |lock, frame, args| { + let callback = args.get(0).try_as::().unwrap(); + frame.enter(lock, |lock| { + callback.call::>(lock, None, &[]) + }) + })?; + kept = Some(run_in_frame.to_global(lock)); + Ok(()) + }); + let run_in_frame = kept.unwrap(); + + harness.run_in_context(|lock, _ctx| { + Harness::request_gc(lock); + Harness::request_gc(lock); + assert_eq!(MARKER_DROPS.load(Ordering::SeqCst), 0); + let read_value = v8::Function::new(lock, (), |lock, (), _args| { + Ok(Harness::async_context_value(lock).unwrap_or_else(|| lock.undefined())) + })?; + let value = run_in_frame.as_local(lock).call::>( + lock, + None, + &[read_value.into()], + )?; + assert!(jsg::Rc::::from_js(lock, value).is_ok()); + assert_eq!(current(lock), None); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + drop(run_in_frame); + Harness::request_gc(lock); + Harness::request_gc(lock); + assert_eq!( + MARKER_DROPS.load(Ordering::SeqCst), + 1, + "the frame outlived the function that held it" + ); + Ok(()) + }); +} + +/// A frame converted from a resource's traced frame holds the frame strongly on its own. +#[test] +fn traced_frame_converts_to_async_context_frame() { + let harness = Harness::new(); + let mut kept = None; + harness.run_in_context(|lock, _ctx| { + let traced: TracedAsyncContextFrame = frame_storing(lock, "a").into(); + kept = Some(traced.to_async_context_frame(lock)); + assert!( + TracedAsyncContextFrame::root() + .to_async_context_frame(lock) + .is_root() + ); + Ok(()) + }); + let frame = kept.unwrap(); + harness.run_in_context(|lock, _ctx| { + Harness::request_minor_gc(lock); + Harness::request_gc(lock); + frame.enter(lock, |lock| assert_eq!(current(lock).as_deref(), Some("a"))); + Ok(()) + }); +} + +/// Frames may outlive their isolate: one held by a resource at teardown, and one held by Rust +/// and dropped after the isolate is disposed. +#[test] +fn frames_dropped_at_or_after_teardown() { + let harness = Harness::new(); + let mut kept = None; + harness.run_in_context(|lock, ctx| { + let probe = install_probe(lock, ctx); + let value = "held by the probe".to_js(lock); + probe.capture_in(lock, value); + kept = Some(frame_storing(lock, "held by Rust")); + Ok(()) + }); + drop(harness); + drop(kept.unwrap()); +} diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index 8d21761bea5..bffc97932ca 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -4,6 +4,7 @@ mod argument_errors; mod arrays; +mod async_context; mod buffer_types; mod coercion_safety; mod collections_gc; diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index ed57cf12bae..6fb3aa2070f 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -99,6 +99,8 @@ let r: jsg::Rc = jsg::Rc::from_js(&mut lock, js_val)?; | `Cell>>` | Yes — when `Some` | | | `jsg::Weak` | No | Doesn't keep target alive | | `jsg::v8::Global` | Compile error — no `Traced` impl | Use `TracedGlobal` in resource fields | +| `jsg::TracedAsyncContextFrame` | Yes — like `TracedGlobal` | Nothing to trace for the root frame | +| `jsg::AsyncContextFrame` | Compile error — no `Traced` impl | Use `TracedAsyncContextFrame` in resource fields | | Any `T: Traced` | Depends on `T` | `#[jsg_resource]` calls `Traced::trace` on every field | - **`Cell` for interior mutability**: `Traced::trace` takes `&self`. Fields that need to be mutated after construction (e.g. a callback set in a method) can use `Cell`. `Cell` implements `Traced` by reading through `as_ptr()` during single-threaded GC tracing. @@ -270,6 +272,45 @@ internal error and terminates execution, as in a `#[jsg_method]`. While executio with the functions C++ JSG wraps, the function's `name` is empty, its `length` is 0, and it can be called with `new`. +### Async context + +`jsg::AsyncContextFrame::current(lock)` captures the async context frame that is current now, +which carries the values of `AsyncLocalStorage`, and `frame.enter(lock, |lock| ...)` runs a +closure with it current, as C++ JSG's `AsyncContextFrame::currentRef()` and +`AsyncContextFrame::Scope` do. `enter` makes the previous frame current again however the closure +ends, including with an error, while execution is terminating, or by panicking. It opens no handle +scope, so handles created in the closure stay valid after it. A microtask checkpoint run inside the +closure leaves the root frame current after it, as under C++ JSG's `AsyncContextFrame::Scope`. + +V8 carries the current frame into promise reactions, those that `Local::then` registers +included, so they need nothing more. Code that resumes JavaScript from outside a JavaScript +continuation does: the continuation of async I/O, or a callback that runs later, captures the frame +when the work starts and enters it when it resumes. + +The current frame is the isolate's continuation-preserved embedder data: `undefined` for the root +frame (`AsyncContextFrame::root()`, `is_root()`), otherwise the opaque wrapper of a C++ +`jsg::AsyncContextFrame`. A captured frame holds that wrapper, which owns the C++ frame, so the +frame and its values live as long as an `AsyncContextFrame` holding it, or as long as the resource +that traces a `TracedAsyncContextFrame` holding it. + +As with `Global` and `TracedGlobal`, there are two types: + +- `AsyncContextFrame` holds a `Global`, for a frame that Rust holds outside a resource, such as in + a future passed to async I/O. It is `Send` and `Sync`, and has no `Traced` impl. +- `TracedAsyncContextFrame` holds a `TracedGlobal`, for a resource field or `Function::new` data, + so that GC collects a cycle through the frame's values. It is `Send`, not `Sync`, and must have + exactly one owner that GC traces. `TracedAsyncContextFrame::from` converts an + `AsyncContextFrame`, and `to_async_context_frame(lock)` goes the other way, for example to pass + a resource's frame to async I/O. Like a `TracedGlobal` taken out of its resource, a + `TracedAsyncContextFrame` taken out of its field is traced by nothing: enter, clone or convert + it before anything can run JavaScript or allocate on the V8 heap, and put it back. + +```rust +let frame = jsg::AsyncContextFrame::current(lock); +// ... later, under another lock: +frame.enter(lock, |lock| callback.call::>(lock, None, &[]))?; +``` + ## Errors from JavaScript When a function that `Local::call` calls throws, the exception stays pending in V8, as with C++ JSG's `JsExceptionThrown`, and `call` returns an error for which `Error::is_pending()` is true. `Lock::throw_exception` throws nothing for that error, so a callback that returns it propagates the original exception to its JavaScript caller. V8 keeps reporting the exception where it was thrown, and DevTools' "pause on uncaught exceptions" still sees whether JavaScript further up catches it. A termination is handled the same way. diff --git a/src/rust/jsg/async_context.rs b/src/rust/jsg/async_context.rs new file mode 100644 index 00000000000..4137eaa445c --- /dev/null +++ b/src/rust/jsg/async_context.rs @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! Async context frames: capture the frame that is current now, and later run code in it, as +//! C++ JSG's `AsyncContextFrame::currentRef()` and `AsyncContextFrame::Scope` do. +//! +//! The current frame is the isolate's continuation-preserved embedder data: `undefined` for the +//! root frame, otherwise the opaque JavaScript wrapper of a C++ `AsyncContextFrame` (see +//! `src/workerd/jsg/async-context.h`). V8 carries it into promise reactions, so a reaction runs +//! in the frame that was current when it was registered, whether JavaScript or Rust registered +//! it. Code that resumes JavaScript from outside a JavaScript continuation, such as the +//! completion of async I/O or a callback that runs later, enters the frame it captured by hand. +//! +//! A captured frame holds the wrapper, and the wrapper owns its C++ frame, so the frame and the +//! values stored in it stay alive for as long as an [`AsyncContextFrame`] does, and for as long +//! as the resource that traces a [`TracedAsyncContextFrame`] does. + +#![allow( + unsafe_code, + reason = "reads and sets the isolate's continuation-preserved embedder data through the FFI" +)] + +use crate::Lock; +use crate::Traced; +use crate::v8::GcVisitor; +use crate::v8::Global; +use crate::v8::Local; +use crate::v8::TracedGlobal; +use crate::v8::Value; +use crate::v8::ffi; + +/// An async context frame that Rust holds outside a resource, for example in a future passed +/// to async I/O, to run the code that resumes after it in the frame that started it. +/// +/// It holds a strong [`Global`], so it has no [`Traced`] impl and cannot be a resource field; +/// a resource holds a [`TracedAsyncContextFrame`] instead. Like a `Global`, it is `Send` and +/// `Sync` and may be dropped on any thread. Unless it is the root frame, it panics when entered +/// or cloned in another isolate. +pub struct AsyncContextFrame { + /// The frame's wrapper, or `None` for the root frame. + value: Option>, +} + +impl AsyncContextFrame { + /// Captures the frame that is current now. + pub fn current(lock: &mut Lock<'_>) -> Self { + Self { + value: current_value(lock).map(|value| value.to_global(lock)), + } + } + + /// The root frame, which holds no stored values. + #[must_use] + pub const fn root() -> Self { + Self { value: None } + } + + /// Returns whether this is the root frame, captured while the isolate's + /// continuation-preserved embedder data was `undefined`. + #[must_use] + pub const fn is_root(&self) -> bool { + self.value.is_none() + } + + /// Creates another handle to the same frame. + #[must_use] + pub fn clone(&self, lock: &mut Lock<'_>) -> Self { + Self { + value: self.value.as_ref().map(|value| value.clone(lock)), + } + } + + /// Runs `f` with this frame current, then makes the frame that was current before current + /// again, also when `f` returns an error, execution is terminating, or `f` panics. + /// + /// `enter` opens no handle scope, so the handles `f` creates stay valid after it. A microtask + /// checkpoint that `f` runs leaves the root frame current after it, as V8 runs each microtask + /// in its own frame, the same as under C++ JSG's `AsyncContextFrame::Scope`. + pub fn enter<'s, R>(&self, lock: &mut Lock<'s>, f: impl FnOnce(&mut Lock<'s>) -> R) -> R { + let frame = self.value.as_ref().map(|value| value.as_local(lock)); + enter_value(lock, frame, f) + } +} + +/// An async context frame held by a resource field, which the garbage collector traces through +/// the resource, so that a cycle through the frame's stored values can be collected. +/// +/// It holds a [`TracedGlobal`]: like any traced state, it must have exactly one owner that the +/// garbage collector traces, so never put it behind an `std::rc::Rc`. It is `Send`, not `Sync`. +/// A frame that Rust holds outside a resource is an [`AsyncContextFrame`]: +/// [`TracedAsyncContextFrame::from`] turns one into one of these, and +/// [`TracedAsyncContextFrame::to_async_context_frame`] makes one of these into one, for example +/// to pass a resource's frame to async I/O. +/// +/// Once the resource has no strong reference from Rust, the garbage collector keeps the frame +/// alive only by tracing it through the resource, like any `TracedGlobal`. A frame taken out of +/// its field, for example with `Cell::take`, is traced by nothing, so enter it, clone it or +/// convert it before anything can run JavaScript or allocate on the V8 heap, and put it back. +pub struct TracedAsyncContextFrame { + /// The frame's wrapper, or `None` for the root frame. + value: Option>, +} + +impl TracedAsyncContextFrame { + /// Captures the frame that is current now. + pub fn current(lock: &mut Lock<'_>) -> Self { + AsyncContextFrame::current(lock).into() + } + + /// The root frame, which holds no stored values. + #[must_use] + pub const fn root() -> Self { + Self { value: None } + } + + /// Returns whether this is the root frame; see [`AsyncContextFrame::is_root`]. + #[must_use] + pub const fn is_root(&self) -> bool { + self.value.is_none() + } + + /// Creates another handle to the same frame. It is a separate owner, for another resource, + /// and holds the frame strongly until the garbage collector first traces it. + #[must_use] + pub fn clone(&self, lock: &mut Lock<'_>) -> Self { + Self { + value: self.value.as_ref().map(|value| value.clone(lock)), + } + } + + /// Creates an [`AsyncContextFrame`] for the same frame, which holds it strongly wherever it + /// goes. + #[must_use] + pub fn to_async_context_frame(&self, lock: &mut Lock<'_>) -> AsyncContextFrame { + AsyncContextFrame { + value: self + .value + .as_ref() + .map(|value| value.as_local(lock).to_global(lock)), + } + } + + /// Runs `f` with this frame current; see [`AsyncContextFrame::enter`]. + pub fn enter<'s, R>(&self, lock: &mut Lock<'s>, f: impl FnOnce(&mut Lock<'s>) -> R) -> R { + let frame = self.value.as_ref().map(|value| value.as_local(lock)); + enter_value(lock, frame, f) + } +} + +impl From for TracedAsyncContextFrame { + fn from(frame: AsyncContextFrame) -> Self { + Self { + value: frame.value.map(TracedGlobal::from), + } + } +} + +impl Traced for TracedAsyncContextFrame { + fn trace(&self, visitor: &mut GcVisitor) { + if let Some(value) = &self.value { + visitor.visit_traced_global(value); + } + } +} + +/// The current frame's wrapper, or `None` in the root frame. +fn current_value<'s>(lock: &mut Lock<'s>) -> Option> { + // SAFETY: the lock keeps the isolate locked with a scope open, which the handle V8 returns + // belongs to. + let value = unsafe { + Local::from_ffi(ffi::isolate_get_continuation_preserved_embedder_data( + lock.isolate().as_ffi(), + )) + }; + (!value.is_undefined()).then_some(value) +} + +/// Makes `frame` (`None` for the root frame) current. +fn set_current(lock: &mut Lock<'_>, frame: Option>) { + let value = frame.unwrap_or_else(|| lock.undefined()); + // SAFETY: the lock keeps the isolate locked, and `value` is a valid handle of its isolate. + unsafe { + ffi::isolate_set_continuation_preserved_embedder_data( + lock.isolate().as_ffi(), + value.into_ffi(), + ); + } +} + +/// Runs `f` with `frame` current, and makes the previous frame current again on every exit. +fn enter_value<'s, R>( + lock: &mut Lock<'s>, + frame: Option>, + f: impl FnOnce(&mut Lock<'s>) -> R, +) -> R { + /// Makes `previous` current again when dropped, also while a panic unwinds. + struct Restore<'a, 's> { + lock: &'a mut Lock<'s>, + previous: Option>, + } + + impl Drop for Restore<'_, '_> { + fn drop(&mut self) { + // Setting the embedder data runs no JavaScript and throws nothing, so this is safe + // while execution is terminating. + set_current(self.lock, self.previous); + } + } + + let previous = current_value(lock); + set_current(lock, frame); + let restore = Restore { lock, previous }; + f(&mut *restore.lock) +} diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 48cb9927c59..6e962285eae 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -1522,6 +1522,14 @@ bool isolate_has_pending_exception(Isolate* isolate) { return isolate->HasPendingException(); } +Local isolate_get_continuation_preserved_embedder_data(Isolate* isolate) { + return to_ffi(isolate->GetContinuationPreservedEmbedderData().As()); +} + +void isolate_set_continuation_preserved_embedder_data(Isolate* isolate, Local value) { + isolate->SetContinuationPreservedEmbedderData(local_from_ffi(kj::mv(value))); +} + void ignore_leak(size_t ptr) { #if KJ_HAS_COMPILER_FEATURE(address_sanitizer) || defined(__SANITIZE_ADDRESS__) __lsan_ignore_object(reinterpret_cast(ptr)); diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index 1dd4744c361..e55b9a796c6 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -445,6 +445,11 @@ void isolate_throw_internal_error(Isolate* isolate, ::rust::Str internalMessage) void isolate_terminate_execution(Isolate* isolate); bool isolate_is_execution_terminating(Isolate* isolate); bool isolate_has_pending_exception(Isolate* isolate); +// The isolate's continuation-preserved embedder data, which holds the current async context +// frame: `undefined` for the root frame, otherwise the frame's opaque JS wrapper (see +// workerd/jsg/async-context.h). V8 carries it into promise reactions. Neither runs JavaScript. +Local isolate_get_continuation_preserved_embedder_data(Isolate* isolate); +void isolate_set_continuation_preserved_embedder_data(Isolate* isolate, Local value); bool isolate_is_locked(Isolate* isolate); bool isolate_in_context(Isolate* isolate); void ignore_leak(size_t ptr); diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index ccbd28725e2..289613f1956 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -8,6 +8,7 @@ use std::ops::Deref; use std::pin::Pin; use std::sync::Arc; +mod async_context; pub mod fast_api; pub mod feature_flags; mod isolate_state; @@ -21,6 +22,8 @@ mod scope; pub mod v8; mod wrappable; +pub use async_context::AsyncContextFrame; +pub use async_context::TracedAsyncContextFrame; pub use feature_flags::FeatureFlags; use isolate_state::IsolateState; pub use lenient::Lenient; diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 8c71143aead..a23726e7f8c 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -636,6 +636,13 @@ pub mod ffi { pub unsafe fn isolate_terminate_execution(isolate: *mut Isolate); pub unsafe fn isolate_is_execution_terminating(isolate: *mut Isolate) -> bool; pub unsafe fn isolate_has_pending_exception(isolate: *mut Isolate) -> bool; + pub unsafe fn isolate_get_continuation_preserved_embedder_data( + isolate: *mut Isolate, + ) -> Local; + pub unsafe fn isolate_set_continuation_preserved_embedder_data( + isolate: *mut Isolate, + value: Local, + ); pub unsafe fn isolate_is_locked(isolate: *mut Isolate) -> bool; pub unsafe fn isolate_in_context(isolate: *mut Isolate) -> bool; From fdaabf00be947377d4da53c6d3f490b62fd52caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 26 Oct 2026 19:14:55 +0000 Subject: [PATCH 7/7] Make Rust and C++ agree on which async context is the root 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. --- src/rust/jsg-test/ffi.c++ | 4 +++ src/rust/jsg-test/ffi.h | 3 ++ src/rust/jsg-test/lib.rs | 10 ++++++ src/rust/jsg-test/tests/async_context.rs | 31 +++++++++++++++++ src/rust/jsg/async_context.rs | 42 ++++++++++++++++-------- src/workerd/jsg/async-context.c++ | 3 ++ 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/rust/jsg-test/ffi.c++ b/src/rust/jsg-test/ffi.c++ index 4a183d9e31d..e0f80d13262 100644 --- a/src/rust/jsg-test/ffi.c++ +++ b/src/rust/jsg-test/ffi.c++ @@ -284,6 +284,10 @@ void run_in_async_context(Isolate* isolate, return ::workerd::rust::jsg::maybe_local_to_ffi(v8::MaybeLocal()); } +bool cpp_sees_root_frame(Isolate* isolate) { + return ::workerd::jsg::AsyncContextFrame::current(isolate) == kj::none; +} + } // namespace rust::jsg_test } // namespace workerd diff --git a/src/rust/jsg-test/ffi.h b/src/rust/jsg-test/ffi.h index 7334bd6a0a6..edacdb68dca 100644 --- a/src/rust/jsg-test/ffi.h +++ b/src/rust/jsg-test/ffi.h @@ -122,6 +122,9 @@ void run_in_async_context(Isolate* isolate, // root frame or a frame that stores none. ::workerd::rust::jsg::MaybeLocal async_context_value(Isolate* isolate); +// Returns whether C++ jsg::AsyncContextFrame::current() finds no frame, i.e. sees the root frame. +bool cpp_sees_root_frame(Isolate* isolate); + } // namespace rust::jsg_test } // namespace workerd diff --git a/src/rust/jsg-test/lib.rs b/src/rust/jsg-test/lib.rs index 605f620e9c3..c9bc2a53e2c 100644 --- a/src/rust/jsg-test/lib.rs +++ b/src/rust/jsg-test/lib.rs @@ -150,6 +150,9 @@ mod ffi { /// The value the current async context frame stores under the tests' storage key, or /// empty if it stores none. pub unsafe fn async_context_value(isolate: *mut Isolate) -> MaybeLocal; + + /// Whether C++ `jsg::AsyncContextFrame::current()` finds no frame. + pub unsafe fn cpp_sees_root_frame(isolate: *mut Isolate) -> bool; } } @@ -569,6 +572,13 @@ impl Harness { // to. unsafe { v8::Local::from_ffi_maybe(ffi::async_context_value(lock.isolate().as_ffi())) } } + + /// Whether C++ `jsg::AsyncContextFrame::current()` sees the root frame, to compare with + /// Rust's [`jsg::AsyncContextFrame::is_root`]. + pub fn cpp_sees_root_frame(lock: &mut jsg::Lock<'_>) -> bool { + // SAFETY: the lock keeps the isolate locked. + unsafe { ffi::cpp_sees_root_frame(lock.isolate().as_ffi()) } + } } /// Whether `a` and `b` belong to the same `v8::IsolateGroup`. diff --git a/src/rust/jsg-test/tests/async_context.rs b/src/rust/jsg-test/tests/async_context.rs index c7bf185e2d9..00376adb0b7 100644 --- a/src/rust/jsg-test/tests/async_context.rs +++ b/src/rust/jsg-test/tests/async_context.rs @@ -148,6 +148,37 @@ fn frame_captured_in_root_is_root() { }); } +/// Rust treats any embedder data but `undefined` as a frame, while C++ recognizes a frame by its +/// wrapper. Since Rust sets only `undefined` and values it read, the two agree on whether the +/// current frame is the root in every state Rust can leave the isolate in. +#[test] +fn rust_and_cpp_agree_on_root_frame() { + /// Asserts that Rust and C++ agree on whether the current frame is the root, and returns it. + fn is_root(lock: &mut jsg::Lock<'_>) -> bool { + let rust = AsyncContextFrame::current(lock).is_root(); + assert_eq!(rust, Harness::cpp_sees_root_frame(lock)); + rust + } + + Harness::new().run_in_context(|lock, _ctx| { + assert!(is_root(lock)); + let frame = frame_storing(lock, "a"); + frame.enter(lock, |lock| assert!(!is_root(lock))); + assert!(is_root(lock)); + + let value = "outer".to_js(lock); + Harness::run_in_async_context(lock, value, |lock| { + assert!(!is_root(lock)); + AsyncContextFrame::root().enter(lock, |lock| assert!(is_root(lock))); + TracedAsyncContextFrame::root().enter(lock, |lock| assert!(is_root(lock))); + frame.enter(lock, |lock| assert!(!is_root(lock))); + assert!(!is_root(lock)); + }); + assert!(is_root(lock)); + Ok(()) + }); +} + #[test] fn entering_root_frame_leaves_frame_for_its_duration() { Harness::new().run_in_context(|lock, _ctx| { diff --git a/src/rust/jsg/async_context.rs b/src/rust/jsg/async_context.rs index 4137eaa445c..06ad4ec5070 100644 --- a/src/rust/jsg/async_context.rs +++ b/src/rust/jsg/async_context.rs @@ -7,7 +7,12 @@ //! //! The current frame is the isolate's continuation-preserved embedder data: `undefined` for the //! root frame, otherwise the opaque JavaScript wrapper of a C++ `AsyncContextFrame` (see -//! `src/workerd/jsg/async-context.h`). V8 carries it into promise reactions, so a reaction runs +//! `src/workerd/jsg/async-context.h`). C++ treats any value that is not such a wrapper as the +//! root frame, while Rust, which cannot recognize the wrapper without C++ JSG, treats any value +//! but `undefined` as a frame. The two agree because the embedder data never holds anything +//! else: C++ sets only a frame's wrapper or `undefined`, and this module sets only `undefined` +//! or a value it read from the embedder data, which its private handle type enforces (see +//! [`FrameWrapper`]). V8 carries the value into promise reactions, so a reaction runs //! in the frame that was current when it was registered, whether JavaScript or Rust registered //! it. Code that resumes JavaScript from outside a JavaScript continuation, such as the //! completion of async I/O or a callback that runs later, enters the frame it captured by hand. @@ -27,9 +32,16 @@ use crate::v8::GcVisitor; use crate::v8::Global; use crate::v8::Local; use crate::v8::TracedGlobal; -use crate::v8::Value; use crate::v8::ffi; +/// Marker for a handle to a value read from the isolate's continuation-preserved embedder data, +/// which is a C++ frame's wrapper (the root frame's `undefined` is never held). +/// +/// It is private, and only [`current_value`] creates handles of it, so the only values that +/// [`set_current`] can make current are `undefined` and ones that were current before. Rust +/// therefore never puts a value in the embedder data that C++ would not recognize as a frame. +struct FrameWrapper; + /// An async context frame that Rust holds outside a resource, for example in a future passed /// to async I/O, to run the code that resumes after it in the frame that started it. /// @@ -39,7 +51,7 @@ use crate::v8::ffi; /// or cloned in another isolate. pub struct AsyncContextFrame { /// The frame's wrapper, or `None` for the root frame. - value: Option>, + value: Option>, } impl AsyncContextFrame { @@ -99,7 +111,7 @@ impl AsyncContextFrame { /// convert it before anything can run JavaScript or allocate on the V8 heap, and put it back. pub struct TracedAsyncContextFrame { /// The frame's wrapper, or `None` for the root frame. - value: Option>, + value: Option>, } impl TracedAsyncContextFrame { @@ -165,9 +177,9 @@ impl Traced for TracedAsyncContextFrame { } /// The current frame's wrapper, or `None` in the root frame. -fn current_value<'s>(lock: &mut Lock<'s>) -> Option> { +fn current_value<'s>(lock: &mut Lock<'s>) -> Option> { // SAFETY: the lock keeps the isolate locked with a scope open, which the handle V8 returns - // belongs to. + // belongs to. `FrameWrapper` is only a marker, and the value is the embedder data itself. let value = unsafe { Local::from_ffi(ffi::isolate_get_continuation_preserved_embedder_data( lock.isolate().as_ffi(), @@ -177,27 +189,29 @@ fn current_value<'s>(lock: &mut Lock<'s>) -> Option> { } /// Makes `frame` (`None` for the root frame) current. -fn set_current(lock: &mut Lock<'_>, frame: Option>) { - let value = frame.unwrap_or_else(|| lock.undefined()); +fn set_current(lock: &mut Lock<'_>, frame: Option>) { + let value = match frame { + // SAFETY: the handle is valid in the lock's scope, and passed straight back to V8. + Some(frame) => unsafe { frame.into_ffi() }, + // SAFETY: the handle is valid in the lock's scope, and passed straight back to V8. + None => unsafe { lock.undefined().into_ffi() }, + }; // SAFETY: the lock keeps the isolate locked, and `value` is a valid handle of its isolate. unsafe { - ffi::isolate_set_continuation_preserved_embedder_data( - lock.isolate().as_ffi(), - value.into_ffi(), - ); + ffi::isolate_set_continuation_preserved_embedder_data(lock.isolate().as_ffi(), value); } } /// Runs `f` with `frame` current, and makes the previous frame current again on every exit. fn enter_value<'s, R>( lock: &mut Lock<'s>, - frame: Option>, + frame: Option>, f: impl FnOnce(&mut Lock<'s>) -> R, ) -> R { /// Makes `previous` current again when dropped, also while a panic unwinds. struct Restore<'a, 's> { lock: &'a mut Lock<'s>, - previous: Option>, + previous: Option>, } impl Drop for Restore<'_, '_> { diff --git a/src/workerd/jsg/async-context.c++ b/src/workerd/jsg/async-context.c++ index dd5a1a61d22..a8dae39cc5e 100644 --- a/src/workerd/jsg/async-context.c++ +++ b/src/workerd/jsg/async-context.c++ @@ -12,6 +12,9 @@ namespace workerd::jsg { namespace { +// Sets only a frame's wrapper or undefined. Rust JSG (src/rust/jsg/async_context.rs) relies on +// this: it treats any embedder data but undefined as a frame, which agrees with current() only +// while nothing else is ever set. inline void maybeSetV8ContinuationContext( v8::Isolate* isolate, kj::Maybe maybeFrame) { v8::Local value;