From 25c0c9f323678104aecc8395f7b42326f3634982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 11:29:56 +0200 Subject: [PATCH 01/23] Propagate JavaScript exceptions through Rust JSG function calls A JS function that threw made Local::call panic and terminate execution, unlike C++ JSG, where the exception reaches the caller. The call now catches it and returns a jsg::Error holding the thrown value, which Lock::throw_exception rethrows unchanged; a termination is not caught and still propagates. Error stays Send, Sync and Clone, as the value is kept in an Arc. --- src/rust/AGENTS.md | 2 +- .../ok-error-is-send-and-sync.rs | 11 +++ src/rust/jsg-test/tests/function.rs | 54 ++++++++++ src/rust/jsg-test/tests/resource_callback.rs | 84 ++++++++++++++++ src/rust/jsg/fast_api.rs | 3 + src/rust/jsg/ffi.c++ | 26 ++++- src/rust/jsg/ffi.h | 11 ++- src/rust/jsg/lib.rs | 99 ++++++++++++++++++- src/rust/jsg/v8.rs | 56 +++++++++-- 9 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 src/rust/jsg-compile-tests/ok-error-is-send-and-sync.rs diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index 8df74116ea9..fcd1eb8a40b 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -121,7 +121,7 @@ Functions returning `Result` in `extern "Rust"` blocks translate to C++ funct - **`std::io::Error`**: Acceptable for purely I/O-related errors. - **`cxx::KjError`**: Use the `kj` crate's error macros (`kj::failed!`, `kj::overloaded!`, `kj::disconnected!`, `kj::not_implemented!` to construct a value; `kj::fail_require!`, `kj::overloaded_require!`, `kj::disconnected_require!`, `kj::not_implemented_require!` to early-return `Err(...)`) when you need direct control over the KJ exception type. These mirror C++'s `KJ_EXCEPTION`/`KJ_FAIL_REQUIRE`/`KJ_UNIMPLEMENTED` macros from `kj/debug.h`; see `src/rust/kj/macros.rs`. -The reverse direction has a stricter, non-optional rule: any `extern "C++"` shim that can throw (`jsg::check()`, `KJ_REQUIRE`/`KJ_ASSERT`, constructing a `rust::String`/`rust::Vec` that can panic, etc.) **MUST** return `Result`. The fork's generated shim catches every C++ exception at the bridge. For an infallible signature, ordinary exceptions become a Rust panic, which callers cannot handle; a `Result` signature turns them into an `Err` instead (`//src/rust/cxx/gen/src/write.rs`, `write_cxx_function_shim`). `kj::CanceledException` is the exception: with either signature it propagates as a dedicated Rust panic, so cancellation unwinds rather than surfacing as an `Err` (`//src/rust/cxx/src/result.rs`, `into_result`). See `//src/rust/jsg/v8.rs` "Unwrappers" section for worked examples: `unwrap_string`/`unwrap_number`/the typed-array unwrap shims are fallible (they call `jsg::check()` / construct `rust::String`), while `unwrap_boolean` is the deliberate infallible counterexample (no `jsg::check()`, no fallible construction). Fallible shims typically funnel their C++-side `TryCatch`/`KJ_REQUIRE` handling into a `cxx::KjException`, which `impl From for jsg::Error` (`//src/rust/jsg/lib.rs`) then converts back into a throwable JS error on the Rust side. +The reverse direction has a stricter, non-optional rule: any `extern "C++"` shim that can throw (`jsg::check()`, `KJ_REQUIRE`/`KJ_ASSERT`, constructing a `rust::String`/`rust::Vec` that can panic, etc.) **MUST** return `Result`. The fork's generated shim catches every C++ exception at the bridge. For an infallible signature, ordinary exceptions become a Rust panic, which callers cannot handle; a `Result` signature turns them into an `Err` instead (`//src/rust/cxx/gen/src/write.rs`, `write_cxx_function_shim`). `kj::CanceledException` is the exception: with either signature it propagates as a dedicated Rust panic, so cancellation unwinds rather than surfacing as an `Err` (`//src/rust/cxx/src/result.rs`, `into_result`). See `//src/rust/jsg/v8.rs` "Unwrappers" section for worked examples: `unwrap_string`/`unwrap_number`/the typed-array unwrap shims are fallible (they call `jsg::check()` / construct `rust::String`), while `unwrap_boolean` is the deliberate infallible counterexample (no `jsg::check()`, no fallible construction). Fallible shims typically funnel their C++-side `TryCatch`/`KJ_REQUIRE` handling into a `cxx::KjException`, which `impl From for jsg::Error` (`//src/rust/jsg/lib.rs`) then converts back into a throwable JS error on the Rust side. That conversion keeps only the error's type and message. A shim that calls into JavaScript, whose exception must reach JavaScript unchanged as in C++ JSG, instead catches it with a `v8::TryCatch` and hands the value back (`local_function_call`): `jsg::Error::from_exception` holds it, `Lock::throw_exception` rethrows it, and a termination is left to propagate and becomes `jsg::Error::terminated()`, which throws nothing. ## CXX BRIDGE: BUILD WIRING diff --git a/src/rust/jsg-compile-tests/ok-error-is-send-and-sync.rs b/src/rust/jsg-compile-tests/ok-error-is-send-and-sync.rs new file mode 100644 index 00000000000..8ecb5cd8101 --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-error-is-send-and-sync.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 + +// A `jsg::Error` may hold what JavaScript threw, in a `Global`, and still crosses threads like +// any other error. +fn assert_send_and_sync() {} + +pub fn t() { + assert_send_and_sync::(); +} diff --git a/src/rust/jsg-test/tests/function.rs b/src/rust/jsg-test/tests/function.rs index f515b1214ed..b637a07e81e 100644 --- a/src/rust/jsg-test/tests/function.rs +++ b/src/rust/jsg-test/tests/function.rs @@ -4,6 +4,7 @@ //! Tests for `Local::call()`, `As` trait, and `impl_local_cast!` conversions. +use jsg::FromJS; use jsg::Number; use jsg::ToJS; use jsg::v8; @@ -156,6 +157,59 @@ fn call_with_object_receiver_via_into() { }); } +/// `Local::call` returns what the function threw in the error, described like C++ JSG +/// describes it. +#[test] +fn call_returns_thrown_error() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let thrown = ctx + .eval_raw(lock, "globalThis.thrown = new RangeError('boom')") + .unwrap(); + let func = ctx.eval_raw(lock, "(() => { throw thrown; })").unwrap(); + let func = func.try_as::().unwrap(); + + let err = func + .call::>(lock, None, &[]) + .unwrap_err(); + assert_eq!(err.message, "boom"); + assert_eq!(err.name, jsg::ExceptionType::RangeError); + assert!(!err.is_termination()); + assert!(err.exception(lock).unwrap() == thrown); + assert!(err.to_local(lock) == thrown); + Ok(()) + }); +} + +/// A thrown value that is not an `Error` is kept too. +#[test] +fn call_returns_thrown_non_error() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let func = ctx.eval_raw(lock, "(() => { throw 42; })").unwrap(); + let func = func.try_as::().unwrap(); + + let err = func.call::(lock, None, &[]).unwrap_err(); + let thrown = err.exception(lock).unwrap(); + let thrown = Number::from_js(lock, thrown)?; + assert!((thrown.value() - 42.0).abs() < f64::EPSILON); + Ok(()) + }); +} + +/// An error that was not thrown by JavaScript holds no exception. +#[test] +fn constructed_error_holds_no_exception() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, _ctx| { + let err = jsg::Error::new_type_error("constructed"); + assert!(err.exception(lock).is_none()); + assert!(!err.is_termination()); + assert!(jsg::Error::terminated().is_termination()); + Ok(()) + }); +} + /// `impl_local_cast!` conversions: `Local` → `Local` via `Into`. #[test] fn local_object_into_value() { diff --git a/src/rust/jsg-test/tests/resource_callback.rs b/src/rust/jsg-test/tests/resource_callback.rs index e19a7fd5cc1..e04a5630c65 100644 --- a/src/rust/jsg-test/tests/resource_callback.rs +++ b/src/rust/jsg-test/tests/resource_callback.rs @@ -585,6 +585,90 @@ fn method_can_be_reentered() { }); } +/// A resource whose method calls a callback and returns what it threw, as C++ JSG does when a +/// `jsg::Function` throws. +#[jsg_resource] +struct CallingResource { + /// Set when the call is interrupted by execution terminating. + saw_termination: Cell, +} + +#[jsg_resource] +impl CallingResource { + #[jsg_method] + pub fn call<'s>( + &self, + lock: &mut jsg::Lock<'s>, + callback: v8::Local<'s, v8::Value>, + ) -> Result<(), jsg::Error> { + let callback = callback + .try_as::() + .ok_or_else(|| jsg::Error::new_type_error("not a function"))?; + callback + .call::>(lock, None, &[]) + .inspect_err(|err| self.saw_termination.set(err.is_termination()))?; + Ok(()) + } +} + +/// An exception thrown by a callback propagates unchanged through the method that called it. +#[test] +fn exception_from_callback_propagates_through_method() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = jsg::Rc::new(CallingResource { + saw_termination: Cell::new(false), + }); + ctx.set_global(lock, "obj", resource.clone()); + let caught = ctx.eval::( + lock, + "const thrown = new Error('boom'); + thrown.extra = 1; + let caught; + try { obj.call(() => { throw thrown; }); } catch (e) { caught = e; } + caught === thrown && caught.extra === 1", + ); + assert!(caught.unwrap()); + let caught = ctx.eval::( + lock, + "try { obj.call(() => { throw 'a string'; }); false } catch (e) { e === 'a string' }", + ); + assert!(caught.unwrap()); + assert!(!resource.saw_termination.get()); + Ok(()) + }); +} + +/// A callback that terminates execution returns `Error::terminated`, and the termination keeps +/// propagating past the method. +#[test] +fn termination_in_callback_propagates_through_method() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = jsg::Rc::new(CallingResource { + saw_termination: Cell::new(false), + }); + ctx.set_global(lock, "obj", resource.clone()); + ctx.set_global(lock, "panicker", jsg::Rc::new(PanicResource)); + // `panicNow()` throws an internal error and requests termination, which takes effect at + // the next interrupt check: in the loop, while `obj.call()` is calling the callback. + // JavaScript cannot catch the termination. + let result = ctx.eval::( + lock, + "try { + obj.call(() => { + try { panicker.panicNow(); } catch (e) {} + while (true) {} + }); + } catch (e) {} + true", + ); + assert!(result.is_err()); + assert!(resource.saw_termination.get()); + Ok(()) + }); +} + // ============================================================================= // Receiver guard tests // ============================================================================= diff --git a/src/rust/jsg/fast_api.rs b/src/rust/jsg/fast_api.rs index 4fc251312d2..d877a60bd24 100644 --- a/src/rust/jsg/fast_api.rs +++ b/src/rust/jsg/fast_api.rs @@ -267,6 +267,9 @@ unsafe fn run( } /// Throws `err` as [`crate::Lock::throw_exception`] does, and returns the value V8 ignores. +/// +/// A fast callback cannot run JavaScript or return a `Result`, so `err` never holds what +/// JavaScript threw (see [`Error::from_exception`]). unsafe fn throw(options: *mut ffi::FastApiCallbackOptions, err: &Error) -> T { // SAFETY: `options` comes from V8 (caller contract). unsafe { diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index aa2ce80f245..27414e4503c 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -405,8 +405,11 @@ 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) { +MaybeLocal local_function_call(Isolate* isolate, + const Local& function, + const Local& recv, + ::rust::Slice args, + MaybeLocal& exception) { auto context = isolate->GetCurrentContext(); auto fn = local_as_ref_from_ffi(function); auto receiver = local_as_ref_from_ffi(recv); @@ -416,7 +419,17 @@ Local local_function_call( v8Args[i] = local_as_ref_from_ffi(args[i]); } - return to_ffi(::workerd::jsg::check(fn->Call(context, receiver, v8Args.size(), v8Args.data()))); + v8::TryCatch tryCatch(isolate); + auto result = fn->Call(context, receiver, v8Args.size(), v8Args.data()); + if (result.IsEmpty()) { + // The same distinction JSG_CATCH makes: a termination cannot be caught. + if (tryCatch.CanContinue() && tryCatch.HasCaught() && !tryCatch.Exception().IsEmpty()) { + exception = maybe_local_to_ffi(v8::MaybeLocal(tryCatch.Exception())); + } else { + tryCatch.ReThrow(); + } + } + return maybe_local_to_ffi(result); } // Local @@ -1316,6 +1329,13 @@ Local exception_create_from_bytes( return exceptionCreateFromMessage(exception_type, message); } +::rust::String exception_describe(Isolate* isolate, const Local& exception) { + auto tunneled = + ::workerd::jsg::createTunneledException(isolate, local_as_ref_from_ffi(exception)); + auto description = tunneled.getDescription(); + return ::rust::String::lossy(description.begin(), description.size()); +} + // Isolate void isolate_throw_exception(Isolate* isolate, Local exception) { isolate->ThrowException(local_from_ffi(kj::mv(exception))); diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index 35c60bfd07e..3f7adb1764b 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -186,8 +186,13 @@ Local local_symbol_new_with_description(Isolate* isolate, Local description); 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); +// Returns the call's result, or none if it threw. Then `exception` receives the exception, unless +// execution is terminating: the termination is left to propagate, and `exception` stays empty. +MaybeLocal local_function_call(Isolate* isolate, + const Local& function, + const Local& recv, + ::rust::Slice args, + MaybeLocal& exception); // Local void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); @@ -372,6 +377,8 @@ 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); +// Describes a thrown value as C++ JSG tunnels it in a kj::Exception (`jsg.: `). +::rust::String exception_describe(Isolate* isolate, const Local& exception); // Isolate void isolate_throw_exception(Isolate* isolate, Local exception); diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index da708b3d4ad..d3ff70b508c 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -158,6 +158,27 @@ pub struct Error { /// `impl_error_constructors!` macro) always set this to `false`; only /// `from_kj_description()` sets it to `true`. is_internal: bool, + /// What JavaScript threw, if this error was caught from it (see [`Error::from_exception`]). + thrown: Option, +} + +/// What JavaScript threw, for an [`Error`] caught from it. +#[derive(Clone)] +enum Thrown { + /// A value, which throwing the `Error` rethrows. + Exception(Arc>), + /// Nothing: execution is terminating, and throwing the `Error` throws nothing so that the + /// termination keeps propagating. + Termination, +} + +impl std::fmt::Debug for Thrown { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Exception(_) => f.write_str("Exception"), + Self::Termination => f.write_str("Termination"), + } + } } impl std::fmt::Display for Error { @@ -177,6 +198,7 @@ macro_rules! impl_error_constructors { name: ExceptionType::$variant, message: message.into(), is_internal: false, + thrown: None, } } )* @@ -234,6 +256,7 @@ impl<'s> FromJS<'s> for Error { name: name.map_or(ExceptionType::Error, |n| ExceptionType::from(n.as_str())), message, is_internal: false, + thrown: None, }) } else { Err(Self::new_type_error("Unknown error")) @@ -247,6 +270,7 @@ impl Error { name: ExceptionType::from(name), message: message.to_owned(), is_internal: false, + thrown: None, } } @@ -263,12 +287,63 @@ impl Error { name, message: message.into(), is_internal: false, + thrown: None, + } + } + + /// Creates an `Error` holding `exception`, a value JavaScript threw, which throwing the + /// `Error` rethrows unchanged. `name` and `message` describe the value as C++ JSG does when + /// it converts an exception to a `kj::Exception`. + /// + /// Rethrowing throws the value anew, as C++ `jsg::Lock::throwException()` does with a caught + /// value: V8 reports it at the rethrow, so the location of an uncaught exception is the + /// rethrowing call rather than the original `throw`. An `Error` object's `stack` is unchanged, + /// since V8 captures it when the object is created. Keeping the original location would need + /// `v8::TryCatch::ReThrow()`, which only works while the `TryCatch` that caught the exception + /// is active, and that ends when the call into JavaScript returns to Rust. + pub fn from_exception<'s>(lock: &mut Lock<'s>, exception: v8::Local<'s, v8::Value>) -> Self { + let description = exception.describe(lock); + let mut err = match description { + Ok(description) => Self::from_kj_description(&description), + Err(err) => Self::from(err), + }; + err.thrown = Some(Thrown::Exception(Arc::new(v8::Global::new( + lock, exception, + )))); + err + } + + /// Creates an `Error` for a call into JavaScript that execution terminating interrupted. + /// Throwing it throws nothing, so the termination keeps propagating. + #[must_use] + pub fn terminated() -> Self { + Self { + thrown: Some(Thrown::Termination), + ..Self::new_internal_error("JavaScript execution terminated") + } + } + + /// Returns whether this error stands for execution terminating; see [`Error::terminated`]. + #[must_use] + pub const fn is_termination(&self) -> bool { + matches!(self.thrown, Some(Thrown::Termination)) + } + + /// Returns the value JavaScript threw, if this error was created from one in the isolate of + /// `lock`; see [`Error::from_exception`]. + pub fn exception<'s>(&self, lock: &mut Lock<'s>) -> Option> { + match &self.thrown { + Some(Thrown::Exception(exception)) if exception.belongs_to(lock) => { + Some(exception.as_local(lock)) + } + _ => None, } } - /// Creates a V8 exception from this error. + /// Creates a V8 exception from this error: the value JavaScript threw, if it was created + /// from one (see [`Error::from_exception`]), otherwise a new error object. /// - /// This always builds the exception from `self.message` verbatim: it does + /// A new error object is always built from `self.message` verbatim: this does /// not check `is_internal` / redact anything. Prefer `Lock::throw_exception()`, /// which does perform that check before falling back to this method. #[expect( @@ -276,6 +351,9 @@ impl Error { reason = "creates the V8 exception object through the FFI" )] pub fn to_local<'s>(&self, lock: &mut Lock<'s>) -> v8::Local<'s, v8::Value> { + if let Some(exception) = self.exception(lock) { + return exception; + } let isolate = lock.isolate(); let message = self.message.as_bytes(); // Strings longer than i32::MAX bytes are truncated to satisfy V8's @@ -395,6 +473,7 @@ impl Error { name: ExceptionType::from(name), message: message.to_owned(), is_internal, + thrown: None, }); } @@ -403,6 +482,7 @@ impl Error { name: ExceptionType::from(ty), message: message.to_owned(), is_internal, + thrown: None, }) } @@ -417,6 +497,7 @@ impl Error { name: ExceptionType::TypeError, message: message.into(), is_internal: true, + thrown: None, } } } @@ -782,10 +863,22 @@ impl<'s> Lock<'s> { /// Throws an error as a V8 exception. /// - /// If `err.is_internal` is set, the message is redacted (via + /// An error caught from JavaScript rethrows what was caught: the original value (see + /// [`Error::from_exception`]), or nothing for a termination (see [`Error::terminated`]). + /// Otherwise, if `err.is_internal` is set, the message is redacted (via /// `throw_internal_error()`) rather than thrown verbatim, matching how /// C++ `decodeTunneledException()` handles `isInternal` KJ exceptions. pub fn throw_exception(&mut self, err: &Error) { + if err.is_termination() { + return; + } + if let Some(exception) = err.exception(self) { + // SAFETY: isolate is valid and locked (guaranteed by Lock). + unsafe { + v8::ffi::isolate_throw_exception(self.isolate().as_ffi(), exception.into_ffi()); + } + return; + } if err.is_internal { self.throw_internal_error(&err.message); return; diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 96b40fd3090..3cfe40b9f51 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -368,7 +368,8 @@ pub mod ffi { function: &Local, recv: &Local, args: &[Local], - ) -> Local; + exception: &mut MaybeLocal, + ) -> MaybeLocal; // Local pub unsafe fn local_object_set_property( @@ -549,6 +550,10 @@ pub mod ffi { data: *const u8, length: i32, ) -> Local; + pub unsafe fn exception_describe( + isolate: *mut Isolate, + exception: &Local, + ) -> Result; // Isolate pub unsafe fn isolate_throw_exception(isolate: *mut Isolate, exception: Local); @@ -1509,6 +1514,20 @@ impl PartialEq for Local<'_, String> { impl Eq for Local<'_, String> {} +impl<'s> Local<'s, Value> { + /// Describes this value, thrown as an exception, as C++ JSG does in the `kj::Exception` it + /// converts the exception to. + pub(crate) fn describe( + self, + lock: &mut Lock<'s>, + ) -> Result { + self.debug_assert_isolate(lock); + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; self is a + // valid handle. + unsafe { ffi::exception_describe(lock.isolate().as_ffi(), self.raw()) } + } +} + impl<'s> Local<'s, Function> { /// Calls this function and converts the result via [`FromJS`]. /// @@ -1517,6 +1536,14 @@ impl<'s> Local<'s, Function> { /// `args` is a slice of `Local` — use [`ToJS::to_js`](crate::ToJS::to_js) to /// convert Rust values, or `.into()` for other `Local` handles. /// + /// # Errors + /// + /// If the function throws, returns an [`Error`] holding the exception, which + /// [`Lock::throw_exception`] rethrows unchanged, so that returning the error from a callback + /// propagates the exception to its caller as C++ JSG does (but reported at the rethrow; see + /// [`Error::from_exception`]). If execution is terminating, + /// returns [`Error::terminated`]. Also fails if the result does not convert to `R`. + /// /// # Example /// /// ```ignore @@ -1537,17 +1564,28 @@ impl<'s> Local<'s, Function> { arg.debug_assert_isolate(lock); } + let mut exception = ffi::MaybeLocal { ptr: 0 }; // SAFETY: lock guarantees the isolate is locked and a HandleScope is active. - // self is a valid Local; recv and args are valid Local handles. - let result = unsafe { - Local::from_ffi(ffi::local_function_call( + // self is a valid Local; recv and args are valid Local handles. The handles + // the call returns belong to the lock's scope. + let (result, exception) = unsafe { + let result = ffi::local_function_call( lock.isolate().as_ffi(), self.raw(), recv.raw(), Local::raw_slice(args), - )) + &mut exception, + ); + ( + Local::from_ffi_maybe(result), + Local::::from_ffi_maybe(exception), + ) }; - R::from_js(lock, result) + match (result, exception) { + (Some(result), _) => R::from_js(lock, result), + (None, Some(exception)) => Err(Error::from_exception(lock, exception)), + (None, None) => Err(Error::terminated()), + } } } @@ -3168,6 +3206,12 @@ impl Global { } } + /// Returns whether this handle was created in the isolate of `lock`. + #[must_use] + pub fn belongs_to(&self, lock: &mut Lock<'_>) -> bool { + Arc::ptr_eq(&self.state, lock.isolate_state()) + } + /// Materializes this handle as a `Local` in the scope of `lock`. pub fn as_local<'s>(&self, lock: &mut Lock<'s>) -> Local<'s, T> { assert_in_isolate_of(lock, &self.state); From d9df7584de20365fb9169496adcca067d3af6113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 16:28:08 +0200 Subject: [PATCH 02/23] Use assert_eq! in Rust JSG function call tests --- src/rust/jsg-test/tests/function.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rust/jsg-test/tests/function.rs b/src/rust/jsg-test/tests/function.rs index b637a07e81e..0c432c822f6 100644 --- a/src/rust/jsg-test/tests/function.rs +++ b/src/rust/jsg-test/tests/function.rs @@ -175,8 +175,8 @@ fn call_returns_thrown_error() { assert_eq!(err.message, "boom"); assert_eq!(err.name, jsg::ExceptionType::RangeError); assert!(!err.is_termination()); - assert!(err.exception(lock).unwrap() == thrown); - assert!(err.to_local(lock) == thrown); + assert_eq!(err.exception(lock).unwrap(), thrown); + assert_eq!(err.to_local(lock), thrown); Ok(()) }); } From 38e3085b24d4d45f6b5cfb6ba9e622de1690bb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 16:37:12 +0200 Subject: [PATCH 03/23] Describe caught JavaScript exceptions without running JavaScript Describing a caught value through createTunneledException read its overloaded and retryable properties, running guest getters and proxy traps a second time and possibly replacing the pending exception. jsg::Error now takes its name and message from V8's side-effect-free ToDetailString instead. As an Error can hold a strong Global, it is no longer Traced, so a resource cannot keep one in an uncollectable cycle. --- .../jsg-compile-tests/error-is-not-traced.rs | 17 ++++++++++ src/rust/jsg-test/tests/function.rs | 34 +++++++++++++++++++ src/rust/jsg/README.md | 6 ++++ src/rust/jsg/ffi.c++ | 18 +++++++--- src/rust/jsg/ffi.h | 3 +- src/rust/jsg/lib.rs | 14 +++++--- src/rust/jsg/v8.rs | 16 +++------ src/rust/jsg/wrappable.rs | 1 - 8 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 src/rust/jsg-compile-tests/error-is-not-traced.rs diff --git a/src/rust/jsg-compile-tests/error-is-not-traced.rs b/src/rust/jsg-compile-tests/error-is-not-traced.rs new file mode 100644 index 00000000000..64e39d351be --- /dev/null +++ b/src/rust/jsg-compile-tests/error-is-not-traced.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 `jsg::Error` may hold what JavaScript threw in a strong `Global`, so like a `Global` it +// is not `Traced`: a resource that kept one would keep a cycle through the thrown value alive. +use std::cell::Cell; + +use jsg_macros::jsg_resource; + +#[jsg_resource] //~ ERROR E0277 +struct Holder { + last_error: Cell>, +} + +#[jsg_resource] +impl Holder {} diff --git a/src/rust/jsg-test/tests/function.rs b/src/rust/jsg-test/tests/function.rs index 0c432c822f6..19a32240350 100644 --- a/src/rust/jsg-test/tests/function.rs +++ b/src/rust/jsg-test/tests/function.rs @@ -197,6 +197,40 @@ fn call_returns_thrown_non_error() { }); } +/// Catching a thrown value runs no JavaScript: neither its getters nor proxy traps. +#[test] +fn call_does_not_run_thrown_value_accessors() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + for thrown in [ + "({ get overloaded() { reads++; }, get retryable() { reads++; }, \ + get message() { reads++; }, get name() { reads++; } })", + "new Proxy(new Error('boom'), { get() { reads++; }, has() { reads++; }, \ + getPrototypeOf() { reads++; return null; } })", + ] { + let thrown = ctx + .eval_raw( + lock, + &format!("globalThis.reads = 0; globalThis.thrown = {thrown}"), + ) + .unwrap(); + let func = ctx.eval_raw(lock, "(() => { throw thrown; })").unwrap(); + let func = func.try_as::().unwrap(); + + let err = func + .call::>(lock, None, &[]) + .unwrap_err(); + assert_eq!(err.exception(lock).unwrap(), thrown); + let reads = ctx.eval::(lock, "reads").unwrap(); + assert!( + reads.value().abs() < f64::EPSILON, + "{thrown:?} ran {reads:?} accessors" + ); + } + Ok(()) + }); +} + /// An error that was not thrown by JavaScript holds no exception. #[test] fn constructed_error_holds_no_exception() { diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index c2e53e8c994..61375693de3 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -235,6 +235,12 @@ 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. +## Errors from JavaScript + +`Local::call` returns an `Err` when the function throws. The `jsg::Error` holds the thrown value, and `Lock::throw_exception` (which a callback returning the `Err` reaches) rethrows that value unchanged, as C++ JSG propagates the exception. A termination is not caught: the call returns `Error::terminated()`, which throws nothing, so the termination keeps propagating. + +The error's `name` and `message` describe the value without running JavaScript (V8's `ToDetailString`), so its getters and proxy traps don't run. The value is held through a strong `Global`, so `Error` is not `Traced` and cannot be a field of a resource: a resource that kept the error of a value referring back to the resource would form a cycle that is never collected. Keep the thrown value as a `TracedGlobal` instead (`err.exception(lock)` returns it). + ## 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 27414e4503c..6b75650a376 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -1330,10 +1330,20 @@ Local exception_create_from_bytes( } ::rust::String exception_describe(Isolate* isolate, const Local& exception) { - auto tunneled = - ::workerd::jsg::createTunneledException(isolate, local_as_ref_from_ffi(exception)); - auto description = tunneled.getDescription(); - return ::rust::String::lossy(description.begin(), description.size()); + // ToDetailString() is side-effect free: it runs no JavaScript, so neither getters nor proxy + // traps on the thrown value. It fails only on, e.g., a stack overflow. + v8::TryCatch tryCatch(isolate); + v8::Local detail; + if (!local_as_ref_from_ffi(exception) + ->ToDetailString(isolate->GetCurrentContext()) + .ToLocal(&detail)) { + if (!tryCatch.CanContinue()) { + tryCatch.ReThrow(); + } + return ::rust::String(); + } + v8::String::Utf8Value utf8(isolate, detail); + return ::rust::String::lossy(*utf8, utf8.length()); } // Isolate diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index 3f7adb1764b..e6ab04200d6 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -377,7 +377,8 @@ 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); -// Describes a thrown value as C++ JSG tunnels it in a kj::Exception (`jsg.: `). +// Describes a thrown value without running JavaScript: `: ` for an error. Empty if +// V8 cannot describe it. ::rust::String exception_describe(Isolate* isolate, const Local& exception); // Isolate diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index d3ff70b508c..51641300410 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -292,8 +292,12 @@ impl Error { } /// Creates an `Error` holding `exception`, a value JavaScript threw, which throwing the - /// `Error` rethrows unchanged. `name` and `message` describe the value as C++ JSG does when - /// it converts an exception to a `kj::Exception`. + /// `Error` rethrows unchanged. + /// + /// `name` and `message` describe the value without running JavaScript, so neither getters + /// nor proxy traps on it run: for a native error, its type and message; otherwise `Error` + /// and the value as a string. The `Error` keeps a strong handle to the value, which is why + /// `Error` is not [`Traced`](crate::Traced) and cannot be a field of a resource. /// /// Rethrowing throws the value anew, as C++ `jsg::Lock::throwException()` does with a caught /// value: V8 reports it at the rethrow, so the location of an uncaught exception is the @@ -303,9 +307,9 @@ impl Error { /// is active, and that ends when the call into JavaScript returns to Rust. pub fn from_exception<'s>(lock: &mut Lock<'s>, exception: v8::Local<'s, v8::Value>) -> Self { let description = exception.describe(lock); - let mut err = match description { - Ok(description) => Self::from_kj_description(&description), - Err(err) => Self::from(err), + let mut err = match description.split_once(": ") { + Some((name, message)) if exception.is_native_error() => Self::new(name, message), + _ => Self::new_error(description), }; err.thrown = Some(Thrown::Exception(Arc::new(v8::Global::new( lock, exception, diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 3cfe40b9f51..3ed8a9cc07b 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -550,10 +550,7 @@ pub mod ffi { data: *const u8, length: i32, ) -> Local; - pub unsafe fn exception_describe( - isolate: *mut Isolate, - exception: &Local, - ) -> Result; + pub unsafe fn exception_describe(isolate: *mut Isolate, exception: &Local) -> String; // Isolate pub unsafe fn isolate_throw_exception(isolate: *mut Isolate, exception: Local); @@ -1515,12 +1512,9 @@ impl PartialEq for Local<'_, String> { impl Eq for Local<'_, String> {} impl<'s> Local<'s, Value> { - /// Describes this value, thrown as an exception, as C++ JSG does in the `kj::Exception` it - /// converts the exception to. - pub(crate) fn describe( - self, - lock: &mut Lock<'s>, - ) -> Result { + /// Describes this value, thrown as an exception, without running JavaScript: as + /// `: ` for an error. Empty if V8 cannot describe it. + pub(crate) fn describe(self, lock: &mut Lock<'s>) -> std::string::String { self.debug_assert_isolate(lock); // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; self is a // valid handle. @@ -3208,7 +3202,7 @@ impl Global { /// Returns whether this handle was created in the isolate of `lock`. #[must_use] - pub fn belongs_to(&self, lock: &mut Lock<'_>) -> bool { + pub(crate) fn belongs_to(&self, lock: &mut Lock<'_>) -> bool { Arc::ptr_eq(&self.state, lock.isolate_state()) } diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index dc5a4eb61e9..2245cf1e50a 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -119,7 +119,6 @@ impl_traced_noop!( (), bool, String, - crate::Error, u8, u16, u32, From 12751a915d67fb9e803965f305bb71fe6aef8b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 17:28:10 +0200 Subject: [PATCH 04/23] Keep what JavaScript throws during Rust JSG coercions Coercing a value to a string or number, reading or testing for a property, and iterating an array can run JavaScript. When it threw, the shims converted the exception to a tunneled kj::Exception, which kept only its type and message, so JavaScript caught a new error rather than what it threw, unlike C++ JSG. These shims now catch the exception with catchException(), shared with local_function_call, into an out-parameter, and Rust converts the failure with v8::catching(): to Error::from_exception for what JavaScript threw, to Error::terminated if execution is terminating, or otherwise to the shim's own error. A termination that a callback requests now takes effect the next time JavaScript runs, as in C++ JSG, rather than as a side effect of reading properties of the caught error, so the re-entrant panic test runs JavaScript before checking a fresh context. --- src/rust/AGENTS.md | 2 +- src/rust/jsg-test/tests/coercion_safety.rs | 209 ++++++++++++++++++++- src/rust/jsg-test/tests/unwrap.rs | 66 +++++-- src/rust/jsg/ffi.c++ | 113 +++++------ src/rust/jsg/ffi.h | 17 +- src/rust/jsg/v8.rs | 64 ++++++- src/rust/jsg/wrappable.rs | 36 +++- 7 files changed, 404 insertions(+), 103 deletions(-) diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index fcd1eb8a40b..60ef1951d1a 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -121,7 +121,7 @@ Functions returning `Result` in `extern "Rust"` blocks translate to C++ funct - **`std::io::Error`**: Acceptable for purely I/O-related errors. - **`cxx::KjError`**: Use the `kj` crate's error macros (`kj::failed!`, `kj::overloaded!`, `kj::disconnected!`, `kj::not_implemented!` to construct a value; `kj::fail_require!`, `kj::overloaded_require!`, `kj::disconnected_require!`, `kj::not_implemented_require!` to early-return `Err(...)`) when you need direct control over the KJ exception type. These mirror C++'s `KJ_EXCEPTION`/`KJ_FAIL_REQUIRE`/`KJ_UNIMPLEMENTED` macros from `kj/debug.h`; see `src/rust/kj/macros.rs`. -The reverse direction has a stricter, non-optional rule: any `extern "C++"` shim that can throw (`jsg::check()`, `KJ_REQUIRE`/`KJ_ASSERT`, constructing a `rust::String`/`rust::Vec` that can panic, etc.) **MUST** return `Result`. The fork's generated shim catches every C++ exception at the bridge. For an infallible signature, ordinary exceptions become a Rust panic, which callers cannot handle; a `Result` signature turns them into an `Err` instead (`//src/rust/cxx/gen/src/write.rs`, `write_cxx_function_shim`). `kj::CanceledException` is the exception: with either signature it propagates as a dedicated Rust panic, so cancellation unwinds rather than surfacing as an `Err` (`//src/rust/cxx/src/result.rs`, `into_result`). See `//src/rust/jsg/v8.rs` "Unwrappers" section for worked examples: `unwrap_string`/`unwrap_number`/the typed-array unwrap shims are fallible (they call `jsg::check()` / construct `rust::String`), while `unwrap_boolean` is the deliberate infallible counterexample (no `jsg::check()`, no fallible construction). Fallible shims typically funnel their C++-side `TryCatch`/`KJ_REQUIRE` handling into a `cxx::KjException`, which `impl From for jsg::Error` (`//src/rust/jsg/lib.rs`) then converts back into a throwable JS error on the Rust side. That conversion keeps only the error's type and message. A shim that calls into JavaScript, whose exception must reach JavaScript unchanged as in C++ JSG, instead catches it with a `v8::TryCatch` and hands the value back (`local_function_call`): `jsg::Error::from_exception` holds it, `Lock::throw_exception` rethrows it, and a termination is left to propagate and becomes `jsg::Error::terminated()`, which throws nothing. +The reverse direction has a stricter, non-optional rule: any `extern "C++"` shim that can throw (`jsg::check()`, `KJ_REQUIRE`/`KJ_ASSERT`, constructing a `rust::String`/`rust::Vec` that can panic, etc.) **MUST** return `Result`. The fork's generated shim catches every C++ exception at the bridge. For an infallible signature, ordinary exceptions become a Rust panic, which callers cannot handle; a `Result` signature turns them into an `Err` instead (`//src/rust/cxx/gen/src/write.rs`, `write_cxx_function_shim`). `kj::CanceledException` is the exception: with either signature it propagates as a dedicated Rust panic, so cancellation unwinds rather than surfacing as an `Err` (`//src/rust/cxx/src/result.rs`, `into_result`). See `//src/rust/jsg/v8.rs` "Unwrappers" section for worked examples: `unwrap_string`/`unwrap_number`/the typed-array unwrap shims are fallible (they call `jsg::check()` / construct `rust::String`), while `unwrap_boolean` is the deliberate infallible counterexample (no `jsg::check()`, no fallible construction). Fallible shims typically funnel their C++-side `TryCatch`/`KJ_REQUIRE` handling into a `cxx::KjException`, which `impl From for jsg::Error` (`//src/rust/jsg/lib.rs`) then converts back into a throwable JS error on the Rust side. That conversion keeps only the error's type and message. A shim that can run JavaScript, whose exception must reach JavaScript unchanged as in C++ JSG, instead catches it with `catchException()` (`//src/rust/jsg/ffi.c++`) into a `MaybeLocal& exception` out-parameter: `local_function_call` returns no result then, and a `Result` shim (coercion, property access, array iteration) also fails, and is called through `v8::catching()`. `jsg::Error::from_exception` holds the value, `Lock::throw_exception` rethrows it, and a termination is left to propagate and becomes `jsg::Error::terminated()`, which throws nothing. ## CXX BRIDGE: BUILD WIRING diff --git a/src/rust/jsg-test/tests/coercion_safety.rs b/src/rust/jsg-test/tests/coercion_safety.rs index f42939f12b1..705ea09383a 100644 --- a/src/rust/jsg-test/tests/coercion_safety.rs +++ b/src/rust/jsg-test/tests/coercion_safety.rs @@ -363,7 +363,7 @@ fn number_coercion_reentrant_into_rust_resource_method() { } /// A three-level-deep chain of coercions, each crossing the FFI boundary via -/// `resource.echo`, to ensure nested `v8::TryCatch`/`unwrapCoerce` scopes +/// `resource.echo`, to ensure nested `v8::TryCatch`/`checkCatching` scopes /// don't corrupt each other or the isolate. #[test] fn string_coercion_handles_deeply_nested_reentrancy() { @@ -396,11 +396,10 @@ fn string_coercion_handles_deeply_nested_reentrancy() { /// A panic triggered by a Rust-backed method called *during* `ToString` /// coercion must surface as a catchable `Err` at the `unwrap_string` FFI -/// boundary, not abort the process. This exercises the "termination -/// exceptions propagate unchanged through `unwrapCoerce`'s `JSG_TRY`/ -/// `JSG_CATCH`" path described in `ffi.c++`, specifically when the -/// termination originates from a re-entrant call rather than directly inside -/// `ToString` itself. +/// boundary, not abort the process. This exercises the path where +/// `catchException()` in `ffi.c++` lets a termination propagate, specifically +/// when the termination originates from a re-entrant call rather than directly +/// inside `ToString` itself. #[test] fn string_coercion_reentrant_panic_is_catchable_not_abort() { let harness = crate::Harness::new(); @@ -472,6 +471,9 @@ fn reentrant_panic_during_coercion_does_not_poison_isolate() { ) .expect("expression should evaluate"); assert!(::from_js(lock, value).is_err()); + // The panic requested termination, which takes effect when JavaScript next runs, as it + // would when the Rust code that coerced returned to its JavaScript caller. + assert!(ctx.eval_raw(lock, "0").is_err()); Ok(()) }); @@ -523,3 +525,198 @@ fn object_has_from_throwing_proxy_returns_err() { Ok(()) }); } + +// ============================================================================= +// What a coercion or property access throws is kept, as C++ JSG keeps it. +// ============================================================================= + +/// Evaluates `setup` as `thrown`, then `expr`, and returns the error that converting `expr` to +/// `T` fails with. +fn conversion_error<'s, T: FromJS<'s>>( + lock: &mut jsg::Lock<'s>, + ctx: &crate::EvalContext<'_>, + setup: &str, + expr: &str, +) -> jsg::Error { + ctx.eval_raw(lock, &format!("globalThis.thrown = {setup}")) + .expect("setup should evaluate"); + let value = ctx + .eval_raw(lock, expr) + .expect("expression should evaluate"); + let Err(err) = T::from_js(lock, value) else { + panic!("{expr} should not convert"); + }; + err +} + +/// Returns whether `err` holds the value of `thrown`, by `Object.is()`. +fn holds_thrown<'s>( + lock: &mut jsg::Lock<'s>, + ctx: &crate::EvalContext<'_>, + err: &jsg::Error, +) -> bool { + let Some(exception) = err.exception(lock) else { + return false; + }; + ctx.set_global(lock, "caught", exception); + ctx.eval::(lock, "Object.is(caught, thrown)") + .expect("should evaluate") +} + +/// Converting to a string or a number fails with what `toString`, `valueOf` or +/// `Symbol.toPrimitive` threw, whether or not it is an `Error`. +#[test] +fn coercion_error_holds_thrown_value() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + for setup in ["new RangeError('boom')", "({ code: 7 })", "42"] { + for expr in [ + "({ toString() { throw thrown; } })", + "({ [Symbol.toPrimitive]() { throw thrown; } })", + ] { + let err = conversion_error::(lock, ctx, setup, expr); + assert!(holds_thrown(lock, ctx, &err), "{setup} {expr}: {err}"); + } + let expr = "({ valueOf() { throw thrown; } })"; + let err = conversion_error::(lock, ctx, setup, expr); + assert!(holds_thrown(lock, ctx, &err), "{setup}: {err}"); + let err = conversion_error::(lock, ctx, setup, expr); + assert!(holds_thrown(lock, ctx, &err), "{setup}: {err}"); + } + Ok(()) + }); +} + +/// A coercion that V8 itself rejects fails with the `TypeError` that V8 threw. +#[test] +fn coercion_error_holds_v8_type_error() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let value = ctx.eval_raw(lock, "Symbol('s')").expect("should evaluate"); + let err = ::from_js(lock, value).expect_err("should not convert"); + assert_eq!(err.name, ExceptionType::TypeError); + assert_eq!(err.message, "Cannot convert a Symbol value to a string"); + assert!(err.exception(lock).is_some_and(|e| e.is_native_error())); + Ok(()) + }); +} + +/// `Object::get`, `Object::has` and `Array::iterate` fail with what a getter or proxy trap +/// threw. +#[test] +fn property_access_error_holds_thrown_value() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + ctx.eval_raw(lock, "globalThis.thrown = new RangeError('trap')") + .expect("setup should evaluate"); + + let value = ctx + .eval_raw(lock, "({ get x() { throw thrown; } })") + .expect("expression should evaluate"); + let obj: jsg::v8::Local<'_, jsg::v8::Object> = value.into(); + let err = obj + .get(lock, "x") + .expect_err("a throwing getter should be an Err"); + assert!(holds_thrown(lock, ctx, &err), "{err}"); + + let value = ctx + .eval_raw(lock, "new Proxy({}, { has() { throw thrown; } })") + .expect("expression should evaluate"); + let obj: jsg::v8::Local<'_, jsg::v8::Object> = value.into(); + let err = obj + .has(lock, "x") + .expect_err("a throwing trap should be an Err"); + assert!(holds_thrown(lock, ctx, &err), "{err}"); + + let err = conversion_error::>( + lock, + ctx, + "new RangeError('element')", + "(() => { \ + const arr = [1, 2]; \ + Object.defineProperty(arr, 1, { get() { throw thrown; } }); \ + return arr; \ + })()", + ); + assert!(holds_thrown(lock, ctx, &err), "{err}"); + Ok(()) + }); +} + +/// What an argument's coercion throws reaches the JavaScript caller of a method unchanged. +#[test] +fn coercion_exception_propagates_through_method() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = jsg::Rc::new(ReentrantResource { + call_count: Cell::new(0), + }); + ctx.set_global(lock, "resource", resource.clone()); + let caught = ctx.eval::( + lock, + "const thrown = new RangeError('boom'); + let caught; + try { resource.echo({ toString() { throw thrown; } }); } catch (e) { caught = e; } + let caughtNumber; + try { resource.double({ valueOf() { throw 42; } }); } catch (e) { caughtNumber = e; } + caught === thrown && caughtNumber === 42", + ); + assert!(caught.unwrap()); + assert_eq!(resource.call_count.get(), 0); + Ok(()) + }); +} + +/// A Rust-backed resource that coerces a value to a string, and records whether execution +/// terminating interrupted the coercion. +#[jsg_resource] +struct CoercingResource { + saw_termination: Cell, +} + +#[jsg_resource] +impl CoercingResource { + #[jsg_method] + pub fn coerce<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: jsg::v8::Local<'s, jsg::v8::Value>, + ) -> Result<(), jsg::Error> { + let err = ::from_js(lock, value).expect_err("coercion should fail"); + self.saw_termination + .set(err.is_termination() && err.exception(lock).is_none()); + Err(err) + } +} + +/// Execution terminating during a coercion is `Error::terminated`, and the termination keeps +/// propagating past the method. +#[test] +fn termination_during_coercion_is_terminated() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = jsg::Rc::new(CoercingResource { + saw_termination: Cell::new(false), + }); + ctx.set_global(lock, "obj", resource.clone()); + ctx.set_global( + lock, + "panicker", + jsg::Rc::new(ReentrantResource { + call_count: Cell::new(0), + }), + ); + // `panicNow()` requests termination, which takes effect in the loop. JavaScript cannot + // catch the termination. + let result = ctx.eval::( + lock, + "try { + obj.coerce({ toString() { try { panicker.panicNow(); } catch (e) {} while (true) {} } }); + } catch (e) {} + true", + ); + assert!(result.is_err()); + assert!(resource.saw_termination.get()); + Ok(()) + }); +} diff --git a/src/rust/jsg-test/tests/unwrap.rs b/src/rust/jsg-test/tests/unwrap.rs index 2486a141e74..15ce932cbba 100644 --- a/src/rust/jsg-test/tests/unwrap.rs +++ b/src/rust/jsg-test/tests/unwrap.rs @@ -88,23 +88,38 @@ fn v8_unwrap_number_returns_correct_values() { harness.run_in_context(|lock, _ctx| { let num = Number::new(2.5).to_local(lock); // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped = - unsafe { jsg::v8::ffi::unwrap_number(lock.isolate().as_ffi(), num.into_ffi()) } - .unwrap(); + let unwrapped = unsafe { + jsg::v8::ffi::unwrap_number( + lock.isolate().as_ffi(), + num.into_ffi(), + &mut no_exception(), + ) + } + .unwrap(); assert!((unwrapped - 2.5).abs() < f64::EPSILON); let zero = Number::new(0.0).to_local(lock); // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_zero = - unsafe { jsg::v8::ffi::unwrap_number(lock.isolate().as_ffi(), zero.into_ffi()) } - .unwrap(); + let unwrapped_zero = unsafe { + jsg::v8::ffi::unwrap_number( + lock.isolate().as_ffi(), + zero.into_ffi(), + &mut no_exception(), + ) + } + .unwrap(); assert!(unwrapped_zero.abs() < f64::EPSILON); let negative = Number::new(-42.5).to_local(lock); // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_neg = - unsafe { jsg::v8::ffi::unwrap_number(lock.isolate().as_ffi(), negative.into_ffi()) } - .unwrap(); + let unwrapped_neg = unsafe { + jsg::v8::ffi::unwrap_number( + lock.isolate().as_ffi(), + negative.into_ffi(), + &mut no_exception(), + ) + } + .unwrap(); assert!((unwrapped_neg - (-42.5)).abs() < f64::EPSILON); Ok(()) }); @@ -116,27 +131,44 @@ fn v8_unwrap_string_returns_correct_values() { harness.run_in_context(|lock, _ctx| { let s = "hello world".to_local(lock); // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped = - unsafe { jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), s.into_ffi()) }.unwrap(); + let unwrapped = unsafe { + jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), s.into_ffi(), &mut no_exception()) + } + .unwrap(); assert_eq!(unwrapped.as_str(), "hello world"); let empty = "".to_local(lock); // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_empty = - unsafe { jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), empty.into_ffi()) } - .unwrap(); + let unwrapped_empty = unsafe { + jsg::v8::ffi::unwrap_string( + lock.isolate().as_ffi(), + empty.into_ffi(), + &mut no_exception(), + ) + } + .unwrap(); assert_eq!(unwrapped_empty.as_str(), ""); let unicode = "こんにちは".to_local(lock); // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_unicode = - unsafe { jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), unicode.into_ffi()) } - .unwrap(); + let unwrapped_unicode = unsafe { + jsg::v8::ffi::unwrap_string( + lock.isolate().as_ffi(), + unicode.into_ffi(), + &mut no_exception(), + ) + } + .unwrap(); assert_eq!(unwrapped_unicode.as_str(), "こんにちは"); Ok(()) }); } +/// The out-parameter for what a shim catches, for values whose conversion runs no JavaScript. +fn no_exception() -> jsg::v8::ffi::MaybeLocal { + jsg::v8::ffi::MaybeLocal { ptr: 0 } +} + /// Tests that `unwrap_resource` rejects a C++ JSG object (tagged with `WORKERD_WRAPPABLE_TAG`). /// /// Rust wrappables use `WORKERD_RUST_WRAPPABLE_TAG` (0xeb05), while C++ JSG objects use diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 6b75650a376..10b592f79b8 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -404,6 +404,31 @@ MaybeLocal local_symbol_description(Isolate* isolate, const Local& value) { return maybe_local_to_ffi(v8::MaybeLocal(desc.As())); } +// Call after JavaScript run under `tryCatch` threw. Stores what it threw in `exception`, unless +// execution is terminating: then rethrows the termination, so that it keeps propagating, and leaves +// `exception` empty. This is the distinction JSG_CATCH makes: a termination cannot be caught. +static void catchException(v8::TryCatch& tryCatch, MaybeLocal& exception) { + if (tryCatch.CanContinue() && tryCatch.HasCaught() && !tryCatch.Exception().IsEmpty()) { + exception = maybe_local_to_ffi(v8::MaybeLocal(tryCatch.Exception())); + } else { + tryCatch.ReThrow(); + } +} + +// Runs `fn`, which returns a v8::MaybeLocal or v8::Maybe that is empty if JavaScript threw, +// and returns its value. If JavaScript threw, stores what it threw with catchException() and throws +// JsExceptionThrown, which workerd-cxx's Result hands to Rust as an error. +template +static auto checkCatching(v8::Isolate* isolate, MaybeLocal& exception, Func&& fn) { + v8::TryCatch tryCatch(isolate); + try { + return ::workerd::jsg::check(fn()); + } catch (::workerd::jsg::JsExceptionThrown&) { + catchException(tryCatch, exception); + throw; + } +} + // Local MaybeLocal local_function_call(Isolate* isolate, const Local& function, @@ -422,12 +447,7 @@ MaybeLocal local_function_call(Isolate* isolate, v8::TryCatch tryCatch(isolate); auto result = fn->Call(context, receiver, v8Args.size(), v8Args.data()); if (result.IsEmpty()) { - // The same distinction JSG_CATCH makes: a termination cannot be caught. - if (tryCatch.CanContinue() && tryCatch.HasCaught() && !tryCatch.Exception().IsEmpty()) { - exception = maybe_local_to_ffi(v8::MaybeLocal(tryCatch.Exception())); - } else { - tryCatch.ReThrow(); - } + catchException(tryCatch, exception); } return maybe_local_to_ffi(result); } @@ -440,34 +460,20 @@ void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, ::workerd::jsg::check(v8_obj->Set(context, v8_key, local_from_ffi(kj::mv(value)))); } -// Runs `fn` (returning a v8::MaybeLocal or v8::Maybe) and, on failure, converts the pending -// JS exception into a `jsg.:`-prefixed kj::Exception and throws it, so -// workerd-cxx's Result::run catches it in C++ and hands Rust a catchable error -// instead of aborting. Termination exceptions are re-thrown unchanged. The -// TryCatch and termination handling are delegated to jsg::Lock::tryCatch. -template -static auto unwrapCoerce(v8::Isolate* isolate, Func&& fn) { - auto& js = ::workerd::jsg::Lock::from(isolate); - JSG_TRY(js) { - return ::workerd::jsg::check(fn()); - } - JSG_CATCH(error) { - kj::throwException(::workerd::jsg::createTunneledException(isolate, error.getHandle(js))); - }; -} - -bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::Str key) { +bool local_object_has_property( + Isolate* isolate, const Local& object, ::rust::Str key, MaybeLocal& exception) { auto v8_obj = local_as_ref_from_ffi(object); auto context = isolate->GetCurrentContext(); auto v8_key = makeInternedStr(isolate, key); - return unwrapCoerce(isolate, [&] { return v8_obj->Has(context, v8_key); }); + return checkCatching(isolate, exception, [&] { return v8_obj->Has(context, v8_key); }); } -Local local_object_get_property(Isolate* isolate, const Local& object, ::rust::Str key) { +Local local_object_get_property( + Isolate* isolate, const Local& object, ::rust::Str key, MaybeLocal& exception) { auto v8_obj = local_as_ref_from_ffi(object); auto context = isolate->GetCurrentContext(); auto v8_key = makeInternedStr(isolate, key); - return to_ffi(unwrapCoerce(isolate, [&] { return v8_obj->Get(context, v8_key); })); + return to_ffi(checkCatching(isolate, exception, [&] { return v8_obj->Get(context, v8_key); })); } // Local @@ -671,7 +677,7 @@ void wrappable_attach_wrapper(kj::Rc wrappable, FunctionCallbackInfo& // Unwrappers -::rust::String unwrap_string(Isolate* isolate, Local value) { +::rust::String unwrap_string(Isolate* isolate, Local value, MaybeLocal& exception) { auto v8Value = local_from_ffi(kj::mv(value)); // Fast path: a string primitive needs no coercion and can't throw, so skip // the TryCatch/ToString machinery. (String wrapper objects are not IsString.) @@ -680,7 +686,7 @@ void wrappable_attach_wrapper(kj::Rc wrappable, FunctionCallbackInfo& v8Str = v8Value.As(); } else { auto context = isolate->GetCurrentContext(); - v8Str = unwrapCoerce(isolate, [&] { return v8Value->ToString(context); }); + v8Str = checkCatching(isolate, exception, [&] { return v8Value->ToString(context); }); } v8::String::ValueView view(isolate, v8Str); if (!view.is_one_byte()) { @@ -694,14 +700,15 @@ bool unwrap_boolean(Isolate* isolate, Local value) { return local_from_ffi(kj::mv(value))->ToBoolean(isolate)->Value(); } -double unwrap_number(Isolate* isolate, Local value) { +double unwrap_number(Isolate* isolate, Local value, MaybeLocal& exception) { auto v8Value = local_from_ffi(kj::mv(value)); // Fast path: a number primitive needs no coercion and can't throw. if (v8Value->IsNumber()) { return v8Value.As()->Value(); } auto context = isolate->GetCurrentContext(); - v8::Local number = unwrapCoerce(isolate, [&] { return v8Value->ToNumber(context); }); + v8::Local number = + checkCatching(isolate, exception, [&] { return v8Value->ToNumber(context); }); return number->Value(); } @@ -754,12 +761,10 @@ DEFINE_TYPED_ARRAY_UNWRAP(biguint64_array, BigUint64Array, uint64_t) // Uses V8's Array::Iterate() which is faster than indexed access. // Returns Global handles because Local handles get reused during iteration. // -// Iterate() can fail with a *pending* V8 exception rather than a KJ one -- e.g. a -// throwing Proxy trap or getter on one of the array's elements. That must be tunneled -// through the same `jsg.:`-prefixed kj::Exception mechanism as `unwrapCoerce` -// uses (see above), rather than discarded in favor of a generic KJ_REQUIRE failure, -// or the real error is lost and the pending exception is left dangling on the isolate. -::rust::Vec local_array_iterate(Isolate* isolate, Local value) { +// Iterate() fails if a Proxy trap or getter on one of the array's elements throws. Then +// `exception` receives what it threw, as with checkCatching() (see above), so that the real error +// is kept and no exception is left pending on the isolate. +::rust::Vec local_array_iterate(Isolate* isolate, Local value, MaybeLocal& exception) { auto context = isolate->GetCurrentContext(); auto v8Val = local_from_ffi(kj::mv(value)); @@ -774,26 +779,20 @@ DEFINE_TYPED_ARRAY_UNWRAP(biguint64_array, BigUint64Array, uint64_t) ::rust::Vec result; Data data{isolate, &result}; - auto& js = ::workerd::jsg::Lock::from(isolate); - JSG_TRY(js) { - auto iterateResult = arr->Iterate(context, - [](uint32_t index, v8::Local element, - void* userData) -> v8::Array::CallbackResult { - auto* d = static_cast(userData); - d->result->push_back(to_ffi(v8::Global(d->isolate, element))); - return v8::Array::CallbackResult::kContinue; - }, - &data); - // iterateResult is a v8::Maybe, so the v8::Maybe overload of - // jsg::check() (which needs a default-constructible T to write into) doesn't - // apply; throw JsExceptionThrown() directly, same as check() does internally. - if (iterateResult.IsNothing()) { - throw ::workerd::jsg::JsExceptionThrown(); - } + v8::TryCatch tryCatch(isolate); + auto iterateResult = arr->Iterate(context, + [](uint32_t index, v8::Local element, + void* userData) -> v8::Array::CallbackResult { + auto* d = static_cast(userData); + d->result->push_back(to_ffi(v8::Global(d->isolate, element))); + return v8::Array::CallbackResult::kContinue; + }, + &data); + // iterateResult is a v8::Maybe, which jsg::check() and so checkCatching() don't accept. + if (iterateResult.IsNothing()) { + catchException(tryCatch, exception); + throw ::workerd::jsg::JsExceptionThrown(); } - JSG_CATCH(error) { - kj::throwException(::workerd::jsg::createTunneledException(isolate, error.getHandle(js))); - }; return result; } @@ -1371,6 +1370,10 @@ void isolate_throw_internal_error(Isolate* isolate, ::rust::Str internalMessage) isolate->ThrowException(::workerd::jsg::makeInternalError(isolate, message)); } +bool isolate_is_execution_terminating(Isolate* isolate) { + return isolate->IsExecutionTerminating(); +} + void isolate_terminate_execution(Isolate* isolate) { ::workerd::jsg::IsolateBase::from(isolate).terminateExecution(); } diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index e6ab04200d6..1743b59d15c 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -185,6 +185,10 @@ Local local_symbol_new(Isolate* isolate); Local local_symbol_new_with_description(Isolate* isolate, Local description); MaybeLocal local_symbol_description(Isolate* isolate, const Local& value); +// Shims that can run JavaScript take a `MaybeLocal& exception`. If JavaScript throws, it receives +// what JavaScript threw, unless execution is terminating: then the termination is left to +// propagate, and `exception` stays empty. A shim that returns a Result also fails then. + // Local // Returns the call's result, or none if it threw. Then `exception` receives the exception, unless // execution is terminating: the termination is left to propagate, and `exception` stays empty. @@ -196,15 +200,17 @@ MaybeLocal local_function_call(Isolate* isolate, // Local void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); -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); +bool local_object_has_property( + Isolate* isolate, const Local& object, ::rust::Str key, MaybeLocal& exception); +Local local_object_get_property( + Isolate* isolate, const Local& object, ::rust::Str key, MaybeLocal& exception); // Local Local local_new_array(Isolate* isolate, size_t length); uint32_t local_array_length(const Local& array); Local local_array_get(Isolate* isolate, const Local& array, uint32_t index); void local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value); -::rust::Vec local_array_iterate(Isolate* isolate, Local value); +::rust::Vec local_array_iterate(Isolate* isolate, Local value, MaybeLocal& exception); // Local Local local_new_uint8_array(Isolate* isolate, const uint8_t* data, size_t length); @@ -287,9 +293,9 @@ MaybeLocal wrap_resource(Isolate* isolate, kj::Rc wrappable, const Gl void wrappable_attach_wrapper(kj::Rc wrappable, FunctionCallbackInfo& args); // Unwrappers -::rust::String unwrap_string(Isolate* isolate, Local value); +::rust::String unwrap_string(Isolate* isolate, Local value, MaybeLocal& exception); bool unwrap_boolean(Isolate* isolate, Local value); -double unwrap_number(Isolate* isolate, Local value); +double unwrap_number(Isolate* isolate, Local value, MaybeLocal& exception); kj::Maybe> unwrap_resource(Isolate* isolate, Local value); ::rust::Vec unwrap_uint8_array(Isolate* isolate, Local value); ::rust::Vec unwrap_uint16_array(Isolate* isolate, Local value); @@ -385,6 +391,7 @@ ::rust::String exception_describe(Isolate* isolate, const Local& exception); void isolate_throw_exception(Isolate* isolate, Local exception); void isolate_throw_error(Isolate* isolate, ::rust::Str message); void isolate_throw_internal_error(Isolate* isolate, ::rust::Str internalMessage); +bool isolate_is_execution_terminating(Isolate* isolate); void isolate_terminate_execution(Isolate* isolate); bool isolate_is_locked(Isolate* isolate); bool isolate_in_context(Isolate* isolate); diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 3ed8a9cc07b..a2cfd0937c8 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -378,15 +378,18 @@ pub mod ffi { key: &str, value: Local, ); + // Fail if a getter or proxy trap throws. Called through `catching()`. pub unsafe fn local_object_has_property( isolate: *mut Isolate, object: &Local, key: &str, + exception: &mut MaybeLocal, ) -> Result; pub unsafe fn local_object_get_property( isolate: *mut Isolate, object: &Local, key: &str, + exception: &mut MaybeLocal, ) -> Result; // Local @@ -398,12 +401,12 @@ pub mod ffi { index: u32, value: Local, ); - // Fallible: KJ_REQUIREs the value is an array, and tunnels any JS exception - // thrown by a proxy trap/getter encountered while iterating elements. See - // "Unwrappers" below. + // Fallible: KJ_REQUIREs the value is an array, and fails if a proxy trap or getter + // throws while an element is read. Called through `catching()`. pub unsafe fn local_array_iterate( isolate: *mut Isolate, value: Local, + exception: &mut MaybeLocal, ) -> Result>; // Local @@ -481,10 +484,19 @@ pub mod ffi { // from V8 data, or KJ_REQUIRE) MUST return Result: workerd-cxx then // catches the throw in C++ instead of aborting across the nounwind FFI // frame. Shims that provably can't throw stay bare (see unwrap_boolean). - pub unsafe fn unwrap_string(isolate: *mut Isolate, value: Local) -> Result; + // Coercion runs JavaScript, so these two are called through `catching()`. + pub unsafe fn unwrap_string( + isolate: *mut Isolate, + value: Local, + exception: &mut MaybeLocal, + ) -> Result; // Infallible: ToBoolean can't throw and builds no rust::String/Vec. pub unsafe fn unwrap_boolean(isolate: *mut Isolate, value: Local) -> bool; - pub unsafe fn unwrap_number(isolate: *mut Isolate, value: Local) -> Result; + pub unsafe fn unwrap_number( + isolate: *mut Isolate, + value: Local, + exception: &mut MaybeLocal, + ) -> Result; pub unsafe fn unwrap_uint8_array(isolate: *mut Isolate, value: Local) -> Result>; pub unsafe fn unwrap_uint16_array(isolate: *mut Isolate, value: Local) -> Result>; pub unsafe fn unwrap_uint32_array(isolate: *mut Isolate, value: Local) -> Result>; @@ -556,6 +568,7 @@ pub mod ffi { pub unsafe fn isolate_throw_exception(isolate: *mut Isolate, exception: Local); pub unsafe fn isolate_throw_error(isolate: *mut Isolate, message: &str); pub unsafe fn isolate_throw_internal_error(isolate: *mut Isolate, internal_message: &str); + pub unsafe fn isolate_is_execution_terminating(isolate: *mut Isolate) -> bool; pub unsafe fn isolate_terminate_execution(isolate: *mut Isolate); pub unsafe fn isolate_is_locked(isolate: *mut Isolate) -> bool; pub unsafe fn isolate_in_context(isolate: *mut Isolate) -> bool; @@ -1583,6 +1596,30 @@ impl<'s> Local<'s, Function> { } } +/// Calls `shim`, an FFI function that can run JavaScript and fails if it throws, with the +/// `MaybeLocal` that receives what JavaScript threw (see `catchException()` in `ffi.c++`). +/// Converts the shim's failure to the error [`Error::from_exception`] creates for what JavaScript +/// threw, to [`Error::terminated`] if execution is terminating, or else to the shim's own error. +pub(crate) fn catching<'s, T>( + lock: &mut Lock<'s>, + shim: impl FnOnce(&mut ffi::MaybeLocal) -> Result, +) -> Result { + let mut exception = ffi::MaybeLocal { ptr: 0 }; + let err = match shim(&mut exception) { + Ok(value) => return Ok(value), + Err(err) => err, + }; + // SAFETY: the shim stores a handle created in the lock's HandleScope. + if let Some(exception) = unsafe { Local::::from_ffi_maybe(exception) } { + return Err(Error::from_exception(lock, exception)); + } + // SAFETY: lock guarantees the isolate is locked. + if unsafe { ffi::isolate_is_execution_terminating(lock.isolate().as_ffi()) } { + return Err(Error::terminated()); + } + Err(err.into()) +} + /// Implements bidirectional `From` conversions between `Local<$from>` and `Local<$to>`. /// /// All V8 handle subtypes share the same pointer representation, so these casts @@ -1716,8 +1753,10 @@ impl<'s> Local<'s, Array> { /// trap/getter throws while an element is being read during iteration. pub fn iterate(self, lock: &mut Lock<'s>) -> crate::Result>> { // SAFETY: lock guarantees the isolate is locked; handle is valid in scope 's. - let globals = - unsafe { ffi::local_array_iterate(lock.isolate().as_ffi(), self.into_ffi()) }?; + let isolate = lock.isolate().as_ffi(); + let globals = catching(lock, |exception| unsafe { + ffi::local_array_iterate(isolate, self.into_ffi(), exception) + })?; let state = lock.isolate_state(); Ok(globals .into_iter() @@ -2969,16 +3008,21 @@ impl<'s> Local<'s, Object> { /// Whether `key` is in the object or its prototype chain. A throwing /// Proxy `has` trap is an `Err`. pub fn has(self, lock: &mut Lock<'s>, key: &str) -> crate::Result { + let isolate = lock.isolate().as_ffi(); // SAFETY: isolate is valid and locked (guaranteed by Lock); handle is valid. - Ok(unsafe { ffi::local_object_has_property(lock.isolate().as_ffi(), self.raw(), key) }?) + catching(lock, |exception| unsafe { + ffi::local_object_has_property(isolate, self.raw(), key, exception) + }) } /// Reads `key`, like JS `obj[key]`: an absent property is `undefined`, /// and a throwing getter or Proxy trap is an `Err`. pub fn get(self, lock: &mut Lock<'s>, key: &str) -> crate::Result> { + let isolate = lock.isolate().as_ffi(); // SAFETY: isolate is valid and locked (guaranteed by Lock); handle is valid. - let local = - unsafe { ffi::local_object_get_property(lock.isolate().as_ffi(), self.raw(), key) }?; + let local = catching(lock, |exception| unsafe { + ffi::local_object_get_property(isolate, self.raw(), key, exception) + })?; // SAFETY: the handle was just created in the current HandleScope. Ok(unsafe { Local::from_ffi(local) }) } diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index 2245cf1e50a..074c817176a 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -402,8 +402,7 @@ impl<'s> FromJS<'s> for std::string::String { lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, ) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - Ok(unsafe { v8::ffi::unwrap_string(lock.isolate().as_ffi(), value.into_ffi()) }?) + unwrap_string(lock, value) } } @@ -437,9 +436,7 @@ impl<'s> FromJS<'s> for Number { lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, ) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - let f64_value = - unsafe { v8::ffi::unwrap_number(lock.isolate().as_ffi(), value.into_ffi()) }?; + let f64_value = unwrap_number(lock, value)?; Ok(Self::new(f64_value)) } } @@ -468,8 +465,7 @@ impl<'s> FromJS<'s> for &str { lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, ) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - Ok(unsafe { v8::ffi::unwrap_string(lock.isolate().as_ffi(), value.into_ffi()) }?) + unwrap_string(lock, value) } } @@ -568,8 +564,7 @@ macro_rules! impl_integer_from_js { } fn from_js(lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - let num = unsafe { v8::ffi::unwrap_number(lock.isolate().as_ffi(), value.into_ffi()) }?; + let num = unwrap_number(lock, value)?; Self::from_number(num) } } @@ -577,6 +572,29 @@ macro_rules! impl_integer_from_js { }; } +/// Converts `value` to a string, like JavaScript's `String()`. Fails with what the coercion +/// throws. +fn unwrap_string<'s>( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, +) -> Result { + let isolate = lock.isolate().as_ffi(); + // SAFETY: The isolate is locked and value is a valid V8 local handle. + v8::catching(lock, |exception| unsafe { + v8::ffi::unwrap_string(isolate, value.into_ffi(), exception) + }) +} + +/// Converts `value` to a number, like JavaScript's `Number()`. Fails with what the coercion +/// throws. +fn unwrap_number<'s>(lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>) -> Result { + let isolate = lock.isolate().as_ffi(); + // SAFETY: The isolate is locked and value is a valid V8 local handle. + v8::catching(lock, |exception| unsafe { + v8::ffi::unwrap_number(isolate, value.into_ffi(), exception) + }) +} + fn not_an_integer() -> Result { Err(Error::new_type_error( "The value cannot be converted because it is not an integer.", From 664cc5f7fafccc68b7efc995855a52e7d27d6954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 24 Oct 2026 17:37:08 +0200 Subject: [PATCH 05/23] Say in Rust JSG's README which conversions keep what JavaScript throws Since coercions, property access and array iteration keep what JavaScript throws, the README's errors section no longer applies to Local::call alone. --- src/rust/jsg/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 61375693de3..4c8ac78f287 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -237,7 +237,7 @@ Without tracing, storing a `TracedGlobal` back to the resource's own wrapper cre ## Errors from JavaScript -`Local::call` returns an `Err` when the function throws. The `jsg::Error` holds the thrown value, and `Lock::throw_exception` (which a callback returning the `Err` reaches) rethrows that value unchanged, as C++ JSG propagates the exception. A termination is not caught: the call returns `Error::terminated()`, which throws nothing, so the termination keeps propagating. +`Local::call` returns an `Err` when the function throws. So does anything else that runs JavaScript which throws: converting a value to a string or number (its `toString`, `valueOf` or `Symbol.toPrimitive`), `Object::get` and `Object::has` (a getter or proxy trap), and `Array::iterate` (an element's getter). The `jsg::Error` holds the thrown value, and `Lock::throw_exception` (which a callback returning the `Err` reaches) rethrows that value unchanged, as C++ JSG propagates the exception. A termination is not caught: these return `Error::terminated()`, which throws nothing, so the termination keeps propagating. The error's `name` and `message` describe the value without running JavaScript (V8's `ToDetailString`), so its getters and proxy traps don't run. The value is held through a strong `Global`, so `Error` is not `Traced` and cannot be a field of a resource: a resource that kept the error of a value referring back to the resource would form a cycle that is never collected. Keep the thrown value as a `TracedGlobal` instead (`err.exception(lock)` returns it). From 0ad7253894084ba5f28bc6f2c08b097471b719ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 13:29:58 +0100 Subject: [PATCH 06/23] Leave what JavaScript throws during Rust JSG coercions pending Coercing a value to a string or number, reading or testing for a property, and iterating an array can run JavaScript (toString, valueOf, Symbol.toPrimitive, getters, Proxy traps). When it threw, the shims caught the exception with JSG_TRY and tunneled it through a kj::Exception, which kept only its type and message: a Rust-backed method's JavaScript caller caught a new error, not what it threw. unwrap_string, unwrap_number, local_object_get_property, local_object_has_property and local_array_iterate now catch nothing, as local_function_call already doesn't: they return an empty result or false and leave the exception pending. Rust turns that into Error::pending(), which Lock::throw_exception doesn't throw, so the original exception reaches the caller unchanged; Lock::try_catch catches it when Rust wants to handle it. This removes the shims' remaining uses of C++ JSG for exceptions (jsg::Lock::from, JSG_TRY/JSG_CATCH, JsExceptionThrown, createTunneledException), and Result stays only where C++ can still throw (key interning, building a rust::String, the not-an-array KJ_REQUIRE); unwrap_number no longer returns one. Every call into JavaScript, including Local::call, now goes through call_into_js(), so that all of them decide alike: nothing is called while execution is terminating, and an empty result is a pending error. While terminating, a conversion therefore fails even for a value that would convert without running JavaScript. When Array::Iterate() fails partway, local_array_iterate now resets the handles it has collected. ffi::Global has no destructor in Rust, so they stayed strong roots until the isolate was disposed. ffi.h names local_object_set_property, local_array_get and local_array_set as the shims that still turn what JavaScript throws into a panic (EW-11138). Tests that read a coercion error's name and message now catch the thrown value with try_catch instead. New tests check that what is thrown is the value the caller catches (including from a Proxy get trap during ToPrimitive), and that string, number, get, has and iterate neither catch a termination nor run JavaScript while execution is terminating. --- src/rust/AGENTS.md | 2 +- src/rust/jsg-test/tests/coercion_safety.rs | 453 ++++++++++++++++----- src/rust/jsg-test/tests/date_bigint.rs | 12 +- src/rust/jsg-test/tests/jsg_struct.rs | 24 +- src/rust/jsg-test/tests/unwrap.rs | 60 +-- src/rust/jsg/README.md | 4 +- src/rust/jsg/ffi.c++ | 97 ++--- src/rust/jsg/ffi.h | 28 +- src/rust/jsg/v8.rs | 148 ++++--- src/rust/jsg/wrappable.rs | 47 ++- 10 files changed, 599 insertions(+), 276 deletions(-) diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index 311998bd4af..075a5aed2f9 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -121,7 +121,7 @@ Functions returning `Result` in `extern "Rust"` blocks translate to C++ funct - **`std::io::Error`**: Acceptable for purely I/O-related errors. - **`cxx::KjError`**: Use the `kj` crate's error macros (`kj::failed!`, `kj::overloaded!`, `kj::disconnected!`, `kj::not_implemented!` to construct a value; `kj::fail_require!`, `kj::overloaded_require!`, `kj::disconnected_require!`, `kj::not_implemented_require!` to early-return `Err(...)`) when you need direct control over the KJ exception type. These mirror C++'s `KJ_EXCEPTION`/`KJ_FAIL_REQUIRE`/`KJ_UNIMPLEMENTED` macros from `kj/debug.h`; see `src/rust/kj/macros.rs`. -The reverse direction has a stricter, non-optional rule: any `extern "C++"` shim that can throw (`jsg::check()`, `KJ_REQUIRE`/`KJ_ASSERT`, constructing a `rust::String`/`rust::Vec` that can panic, etc.) **MUST** return `Result`. The fork's generated shim catches every C++ exception at the bridge. For an infallible signature, ordinary exceptions become a Rust panic, which callers cannot handle; a `Result` signature turns them into an `Err` instead (`//src/rust/cxx/gen/src/write.rs`, `write_cxx_function_shim`). `kj::CanceledException` is the exception: with either signature it propagates as a dedicated Rust panic, so cancellation unwinds rather than surfacing as an `Err` (`//src/rust/cxx/src/result.rs`, `into_result`). See `//src/rust/jsg/v8.rs` "Unwrappers" section for worked examples: `unwrap_string`/`unwrap_number`/the typed-array unwrap shims are fallible (they call `jsg::check()` / construct `rust::String`), while `unwrap_boolean` is the deliberate infallible counterexample (no `jsg::check()`, no fallible construction). Fallible shims typically funnel their C++-side `TryCatch`/`KJ_REQUIRE` handling into a `cxx::KjException`, which `impl From for jsg::Error` (`//src/rust/jsg/lib.rs`) then converts back into a throwable JS error on the Rust side. That conversion keeps only the error's type and message. A shim that calls into JavaScript, whose exception must reach JavaScript unchanged as in C++ JSG, instead leaves the exception pending and returns an empty result (`local_function_call`), which Rust turns into an `Error` for which `is_pending()` is true and `Lock::throw_exception` throws nothing; `Lock::try_catch` catches such exceptions explicitly, and a shim that catches one in C++ decides with `catch_exception()` (`ffi.h`), which applies `jsg::Lock::tryCatch()`'s termination rule. +The reverse direction has a stricter, non-optional rule: any `extern "C++"` shim that can throw (`jsg::check()`, `KJ_REQUIRE`/`KJ_ASSERT`, constructing a `rust::String`/`rust::Vec` that can panic, etc.) **MUST** return `Result`. The fork's generated shim catches every C++ exception at the bridge. For an infallible signature, ordinary exceptions become a Rust panic, which callers cannot handle; a `Result` signature turns them into an `Err` instead (`//src/rust/cxx/gen/src/write.rs`, `write_cxx_function_shim`). `kj::CanceledException` is the exception: with either signature it propagates as a dedicated Rust panic, so cancellation unwinds rather than surfacing as an `Err` (`//src/rust/cxx/src/result.rs`, `into_result`). See `//src/rust/jsg/v8.rs` "Unwrappers" section for worked examples: `unwrap_string` and the typed-array unwrap shims are fallible (they construct a `rust::String`/`rust::Vec` or `KJ_REQUIRE`), while `unwrap_boolean` and `unwrap_number` are the deliberate infallible counterexamples (no `jsg::check()`, no fallible construction). Fallible shims typically funnel their C++-side `TryCatch`/`KJ_REQUIRE` handling into a `cxx::KjException`, which `impl From for jsg::Error` (`//src/rust/jsg/lib.rs`) then converts back into a throwable JS error on the Rust side. That conversion keeps only the error's type and message. A shim that can run JavaScript (a call, a coercion, a property read, array iteration), whose exception must reach JavaScript unchanged as in C++ JSG, instead leaves the exception pending and returns an empty result or `false` (`local_function_call`, `unwrap_string`, `local_object_get_property`), which Rust turns into an `Error` for which `is_pending()` is true and `Lock::throw_exception` throws nothing (all through `call_into_js()` in `jsg/v8.rs`, which also refuses to call while execution is terminating); `Lock::try_catch` catches such exceptions explicitly, and a shim that catches one in C++ decides with `catch_exception()` (`ffi.h`), which applies `jsg::Lock::tryCatch()`'s termination rule. ## CXX BRIDGE: BUILD WIRING diff --git a/src/rust/jsg-test/tests/coercion_safety.rs b/src/rust/jsg-test/tests/coercion_safety.rs index f42939f12b1..7d7eee85792 100644 --- a/src/rust/jsg-test/tests/coercion_safety.rs +++ b/src/rust/jsg-test/tests/coercion_safety.rs @@ -4,10 +4,14 @@ //! Regression tests for the Rust JSG `unwrap_*` boundary: hostile JS values //! must not abort the process (SIGABRT) as they previously did. Specifically: -//! coercion failures and wrong types must yield a catchable `Err`, while lone +//! coercion failures and wrong types must yield an `Err`, while lone //! surrogates are a deliberate exception -- they decode *lossily* to U+FFFD //! (`Ok`), rather than erroring. //! +//! What JavaScript throws during a coercion or property access stays pending, as for +//! `Local::call`: the `Err` is one for which `is_pending()` is true, and the tests +//! catch the thrown value with `Lock::try_catch`. +//! //! `unwrap_string`/`unwrap_number` call V8's `ToString`/`ToNumber`, which run //! arbitrary JS whenever the value isn't already a primitive of the right //! type (`toString`/`valueOf`/`Symbol.toPrimitive`, including monkeypatched @@ -23,10 +27,53 @@ use jsg::ExceptionType; use jsg::FromJS; use jsg::Number; use jsg::ToJS; +use jsg::v8; use jsg_macros::jsg_method; use jsg_macros::jsg_resource; -/// Coercion failures must surface as a catchable error, never abort. +/// Runs `f` in `try_catch`. `f` must fail with an error for a pending exception; returns what +/// JavaScript threw. +fn thrown_by<'s, T: std::fmt::Debug>( + lock: &mut jsg::Lock<'s>, + f: impl FnOnce(&mut jsg::Lock<'s>) -> jsg::Result, +) -> v8::Local<'s, v8::Value> { + let caught = lock + .try_catch(|lock| { + let err = f(lock).expect_err("should fail"); + assert!(err.is_pending(), "not pending: {err}"); + Err::<(), _>(err) + }) + .expect("execution should not be terminating"); + caught.expect_err("should have caught an exception") +} + +/// Converts the value of `expr` to `T`, which must throw, and returns what JavaScript threw. +fn conversion_throws<'s, T: FromJS<'s>>( + lock: &mut jsg::Lock<'s>, + ctx: &crate::EvalContext<'_>, + expr: &str, +) -> v8::Local<'s, v8::Value> +where + T::ResultType: std::fmt::Debug, +{ + let value = ctx + .eval_raw(lock, expr) + .expect("expression should evaluate"); + thrown_by(lock, |lock| T::from_js(lock, value)) +} + +/// Returns whether `value` is the value of the global `thrown`, by `Object.is()`. +fn is_thrown<'s>( + lock: &mut jsg::Lock<'s>, + ctx: &crate::EvalContext<'_>, + value: v8::Local<'s, v8::Value>, +) -> bool { + ctx.set_global(lock, "caught", value); + ctx.eval::(lock, "Object.is(caught, thrown)") + .expect("should evaluate") +} + +/// Coercion failures must surface as an error, never abort. #[test] fn string_from_hostile_values_returns_err() { let harness = crate::Harness::new(); @@ -40,35 +87,53 @@ fn string_from_hostile_values_returns_err() { "new Proxy({}, { get() { throw new Error('trap'); } })", ]; for expr in hostile { - let value = ctx - .eval_raw(lock, expr) - .expect("expression should evaluate"); - let result = ::from_js(lock, value); - assert!( - result.is_err(), - "String::from_js({expr}) should be Err, got {result:?}" - ); + conversion_throws::(lock, ctx, expr); } Ok(()) }); } -/// A throwing `toString` preserves the JS error type and message. +/// Converting to a string or a number throws what `toString`, `valueOf`, `Symbol.toPrimitive` +/// or a Proxy `get` trap (which `ToPrimitive` runs to look up `Symbol.toPrimitive`) threw, +/// whether or not it is an `Error`. #[test] -fn string_from_throwing_tostring_maps_to_type_error() { +fn coercion_throws_thrown_value() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let value = ctx - .eval_raw(lock, "({ toString() { throw new TypeError('boom'); } })") - .expect("expression should evaluate"); - let err = ::from_js(lock, value) - .expect_err("coercion failure should be an Err"); + for setup in ["new RangeError('boom')", "({ code: 7 })", "42"] { + ctx.eval_raw(lock, &format!("globalThis.thrown = {setup}")) + .expect("setup should evaluate"); + for expr in [ + "({ toString() { throw thrown; } })", + "({ [Symbol.toPrimitive]() { throw thrown; } })", + "new Proxy({}, { get() { throw thrown; } })", + ] { + let caught = conversion_throws::(lock, ctx, expr); + assert!(is_thrown(lock, ctx, caught), "{setup} {expr}"); + } + for expr in [ + "({ valueOf() { throw thrown; } })", + "new Proxy({}, { get() { throw thrown; } })", + ] { + let caught = conversion_throws::(lock, ctx, expr); + assert!(is_thrown(lock, ctx, caught), "{setup} {expr}"); + let caught = conversion_throws::(lock, ctx, expr); + assert!(is_thrown(lock, ctx, caught), "{setup} {expr}"); + } + } + Ok(()) + }); +} + +/// A coercion that V8 itself rejects throws V8's `TypeError`. +#[test] +fn coercion_throws_v8_type_error() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let caught = conversion_throws::(lock, ctx, "Symbol('s')"); + let err = jsg::Error::from_js(lock, caught)?; assert_eq!(err.name, ExceptionType::TypeError); - assert!( - err.message.contains("boom"), - "message should preserve the original text, got {:?}", - err.message - ); + assert_eq!(err.message, "Cannot convert a Symbol value to a string"); Ok(()) }); } @@ -95,7 +160,7 @@ fn string_from_lone_surrogate_is_lossy_not_abort() { }); } -/// `Number` coercion of a `Symbol`/`BigInt` throws in JS; it must be catchable. +/// `Number` coercion of a `Symbol`/`BigInt` throws in JS. #[test] fn number_from_hostile_values_returns_err() { let harness = crate::Harness::new(); @@ -106,32 +171,24 @@ fn number_from_hostile_values_returns_err() { "({ valueOf() { throw new TypeError('no number'); } })", ]; for expr in hostile { - let value = ctx - .eval_raw(lock, expr) - .expect("expression should evaluate"); - let result = ::from_js(lock, value); - assert!( - result.is_err(), - "Number::from_js({expr}) should be Err, got {result:?}" - ); + conversion_throws::(lock, ctx, expr); } Ok(()) }); } -/// Integer conversions go through the same fallible `unwrap_number`. +/// Integer conversions go through the same `unwrap_number`. #[test] fn integer_from_symbol_returns_err() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let value = ctx.eval_raw(lock, "Symbol('s')").expect("should evaluate"); - let result = ::from_js(lock, value); - assert!(result.is_err(), "u32::from_js(Symbol) should be Err"); + conversion_throws::(lock, ctx, "Symbol('s')"); Ok(()) }); } -/// A wrong-typed typed-array argument must be a catchable error, not an abort. +/// A wrong-typed typed-array argument must be an error, not an abort. No JavaScript runs, so +/// the error is not pending. #[test] fn typed_array_from_wrong_type_returns_err() { let harness = crate::Harness::new(); @@ -141,8 +198,8 @@ fn typed_array_from_wrong_type_returns_err() { let value = ctx.eval_raw(lock, expr).expect("should evaluate"); let result = as FromJS>::from_js(lock, value); assert!( - result.is_err(), - "Vec::::from_js({expr}) should be Err, got {result:?}" + result.as_ref().is_err_and(|err| !err.is_pending()), + "Vec::::from_js({expr}) should be a non-pending Err, got {result:?}" ); } Ok(()) @@ -150,32 +207,24 @@ fn typed_array_from_wrong_type_returns_err() { } /// An accessor property that throws while `Vec::from_js`'s underlying -/// `Array::Iterate()` reads an element must surface as a catchable error -/// carrying the original JS exception, not a generic KJ failure message, and -/// must not leave a JS exception pending on the isolate. +/// `Array::Iterate()` reads an element throws what the accessor threw, and the +/// isolate stays usable. #[test] fn array_iterate_throwing_element_getter_returns_err() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let value = ctx - .eval_raw( - lock, - "(() => { \ - const arr = [1, 2, 3]; \ - Object.defineProperty(arr, 1, { get() { throw new RangeError('trap'); } }); \ - return arr; \ - })()", - ) - .expect("expression should evaluate"); - - let err = as FromJS>::from_js(lock, value) - .expect_err("throwing element getter should be an Err"); - assert_eq!(err.name, ExceptionType::RangeError); - assert!( - err.message.contains("trap"), - "message should preserve the original JS exception, got {:?}", - err.message + ctx.eval_raw(lock, "globalThis.thrown = new RangeError('trap')") + .expect("setup should evaluate"); + let caught = conversion_throws::>( + lock, + ctx, + "(() => { \ + const arr = [1, 2, 3]; \ + Object.defineProperty(arr, 1, { get() { throw thrown; } }); \ + return arr; \ + })()", ); + assert!(is_thrown(lock, ctx, caught)); // The isolate must still be usable afterwards. let ok = ctx.eval_raw(lock, "'still fine'").expect("should evaluate"); @@ -190,8 +239,7 @@ fn array_iterate_throwing_element_getter_returns_err() { fn coercion_failure_does_not_poison_isolate() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let hostile = ctx.eval_raw(lock, "Symbol('s')").expect("should evaluate"); - assert!(::from_js(lock, hostile).is_err()); + conversion_throws::(lock, ctx, "Symbol('s')"); // A subsequent, well-formed coercion still works. let ok = ctx.eval_raw(lock, "'still fine'").expect("should evaluate"); @@ -363,8 +411,8 @@ fn number_coercion_reentrant_into_rust_resource_method() { } /// A three-level-deep chain of coercions, each crossing the FFI boundary via -/// `resource.echo`, to ensure nested `v8::TryCatch`/`unwrapCoerce` scopes -/// don't corrupt each other or the isolate. +/// `resource.echo`, to ensure nested coercions don't corrupt each other or the +/// isolate. #[test] fn string_coercion_handles_deeply_nested_reentrancy() { let harness = crate::Harness::new(); @@ -395,14 +443,12 @@ fn string_coercion_handles_deeply_nested_reentrancy() { } /// A panic triggered by a Rust-backed method called *during* `ToString` -/// coercion must surface as a catchable `Err` at the `unwrap_string` FFI -/// boundary, not abort the process. This exercises the "termination -/// exceptions propagate unchanged through `unwrapCoerce`'s `JSG_TRY`/ -/// `JSG_CATCH`" path described in `ffi.c++`, specifically when the -/// termination originates from a re-entrant call rather than directly inside -/// `ToString` itself. +/// coercion must surface as an `Err` at the `unwrap_string` FFI boundary, not +/// abort the process. The panic throws an internal error and requests +/// termination, so the error is pending; `try_catch` either catches the +/// internal error or fails because execution is terminating. #[test] -fn string_coercion_reentrant_panic_is_catchable_not_abort() { +fn string_coercion_reentrant_panic_is_err_not_abort() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { let resource = jsg::Rc::new(ReentrantResource { @@ -418,10 +464,10 @@ fn string_coercion_reentrant_panic_is_catchable_not_abort() { ) .expect("expression should evaluate"); - let result = ::from_js(lock, value); + let result = lock.try_catch(|lock| ::from_js(lock, value)); assert!( - result.is_err(), - "panic during ToString coercion must be a catchable Err, got {result:?}" + !matches!(result, Ok(Ok(_))), + "panic during ToString coercion must be an Err, got {result:?}" ); Ok(()) }); @@ -429,7 +475,7 @@ fn string_coercion_reentrant_panic_is_catchable_not_abort() { /// Same as above, but for `Number`/`valueOf`/`unwrap_number`. #[test] -fn number_coercion_reentrant_panic_is_catchable_not_abort() { +fn number_coercion_reentrant_panic_is_err_not_abort() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { let resource = jsg::Rc::new(ReentrantResource { @@ -442,10 +488,10 @@ fn number_coercion_reentrant_panic_is_catchable_not_abort() { .eval_raw(lock, "({ valueOf() { resource.panicNow(); return 0; } })") .expect("expression should evaluate"); - let result = ::from_js(lock, value); + let result = lock.try_catch(|lock| ::from_js(lock, value)); assert!( - result.is_err(), - "panic during ToNumber coercion must be a catchable Err, got {result:?}" + !matches!(result, Ok(Ok(_))), + "panic during ToNumber coercion must be an Err, got {result:?}" ); Ok(()) }); @@ -471,7 +517,11 @@ fn reentrant_panic_during_coercion_does_not_poison_isolate() { "({ toString() { resource.panicNow(); return 'unreachable'; } })", ) .expect("expression should evaluate"); - assert!(::from_js(lock, value).is_err()); + let result = lock.try_catch(|lock| ::from_js(lock, value)); + assert!(!matches!(result, Ok(Ok(_)))); + // The panic requested termination, which takes effect when JavaScript next runs, as it + // would when the Rust code that coerced returned to its JavaScript caller. + assert!(ctx.eval_raw(lock, "0").is_err()); Ok(()) }); @@ -484,42 +534,247 @@ fn reentrant_panic_during_coercion_does_not_poison_isolate() { }); } -/// A throwing getter is an `Err` from `Object::get`, with its JS error intact. +/// `Object::get` throws what a getter or Proxy trap threw; an absent property is `undefined`. #[test] fn object_get_from_throwing_getter_returns_err() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let value = ctx - .eval_raw(lock, "({ get x() { throw new RangeError('getter'); } })") - .expect("expression should evaluate"); - let obj: jsg::v8::Local<'_, jsg::v8::Object> = value.into(); - let err = obj - .get(lock, "x") - .expect_err("a throwing getter should be an Err"); - assert_eq!(err.name, ExceptionType::RangeError); - assert!(err.message.contains("getter"), "got {:?}", err.message); + ctx.eval_raw(lock, "globalThis.thrown = new RangeError('getter')") + .expect("setup should evaluate"); + for expr in [ + "({ get x() { throw thrown; } })", + "new Proxy({}, { get() { throw thrown; } })", + ] { + let value = ctx + .eval_raw(lock, expr) + .expect("expression should evaluate"); + let obj: v8::Local<'_, v8::Object> = value.into(); + let caught = thrown_by(lock, |lock| obj.get(lock, "x")); + assert!(is_thrown(lock, ctx, caught), "{expr}"); + } + let obj: v8::Local<'_, v8::Object> = ctx.eval_raw(lock, "({})").unwrap().into(); assert!(obj.get(lock, "absent").unwrap().is_undefined()); Ok(()) }); } -/// A throwing Proxy `has` trap is an `Err` from `Object::has`, not an abort. +/// `Object::has` throws what a Proxy `has` trap threw. #[test] fn object_has_from_throwing_proxy_returns_err() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { + ctx.eval_raw(lock, "globalThis.thrown = new TypeError('trap')") + .expect("setup should evaluate"); let value = ctx - .eval_raw( - lock, - "new Proxy({}, { has() { throw new TypeError('trap'); } })", - ) + .eval_raw(lock, "new Proxy({}, { has() { throw thrown; } })") .expect("expression should evaluate"); - let obj: jsg::v8::Local<'_, jsg::v8::Object> = value.into(); - let err = obj - .has(lock, "x") - .expect_err("a throwing trap should be an Err"); - assert_eq!(err.name, ExceptionType::TypeError); - assert!(err.message.contains("trap"), "got {:?}", err.message); + let obj: v8::Local<'_, v8::Object> = value.into(); + let caught = thrown_by(lock, |lock| obj.has(lock, "x")); + assert!(is_thrown(lock, ctx, caught)); + Ok(()) + }); +} + +/// What an argument's coercion throws reaches the JavaScript caller of a method unchanged. +#[test] +fn coercion_exception_propagates_through_method() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = jsg::Rc::new(ReentrantResource { + call_count: Cell::new(0), + }); + ctx.set_global(lock, "resource", resource.clone()); + let caught = ctx.eval::( + lock, + "const thrown = new RangeError('boom'); + let caught; + try { resource.echo({ toString() { throw thrown; } }); } catch (e) { caught = e; } + let caughtNumber; + try { resource.double({ valueOf() { throw 42; } }); } catch (e) { caughtNumber = e; } + caught === thrown && caughtNumber === 42", + ); + assert!(caught.unwrap()); + assert_eq!(resource.call_count.get(), 0); Ok(()) }); } + +// ============================================================================= +// Termination during, and before, a conversion or property access. +// ============================================================================= + +/// What `ConvertingResource` does with its value. +#[derive(Clone, Copy, Debug)] +enum Conversion { + String, + Number, + Get, + Has, + Iterate, +} + +impl Conversion { + const ALL: [Self; 5] = [ + Self::String, + Self::Number, + Self::Get, + Self::Has, + Self::Iterate, + ]; + + /// Converts `value`, or reads its property `x`. + fn run<'s>(self, lock: &mut jsg::Lock<'s>, value: v8::Local<'s, v8::Value>) -> jsg::Result<()> { + match self { + Self::String => String::from_js(lock, value).map(drop), + Self::Number => Number::from_js(lock, value).map(drop), + Self::Get => v8::Local::::from(value) + .get(lock, "x") + .map(drop), + Self::Has => v8::Local::::from(value) + .has(lock, "x") + .map(drop), + Self::Iterate => Vec::::from_js(lock, value).map(drop), + } + } + + /// A value whose conversion, or whose property `x`, calls `f`. + fn value_calling(self, f: &str) -> String { + match self { + Self::String => format!("({{ toString: {f} }})"), + Self::Number => format!("({{ valueOf: {f} }})"), + Self::Get => format!("({{ get x() {{ return ({f})(); }} }})"), + Self::Has => format!("new Proxy({{}}, {{ has: {f} }})"), + Self::Iterate => { + format!( + "(() => {{ const a = [0]; Object.defineProperty(a, 0, {{ get: {f} }}); return a; }})()" + ) + } + } + } +} + +jsg::jsg_traced!(Conversion); + +/// A Rust-backed resource that converts values, and records whether a conversion failed with a +/// pending error. +#[jsg_resource] +struct ConvertingResource { + conversion: Cell>, + saw_pending: Cell, +} + +#[jsg_resource] +impl ConvertingResource { + /// Converts `value`, and fails as the conversion does. + #[jsg_method] + pub fn convert<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: v8::Local<'s, v8::Value>, + ) -> Result<(), jsg::Error> { + let conversion = self.conversion.get().unwrap(); + conversion + .run(lock, value) + .inspect_err(|err| self.saw_pending.set(err.is_pending())) + } + + /// Calls `callback`, then converts `value` even if the call failed, and fails as the call + /// did. + #[jsg_method] + pub fn call_then_convert<'s>( + &self, + lock: &mut jsg::Lock<'s>, + callback: v8::Local<'s, v8::Value>, + value: v8::Local<'s, v8::Value>, + ) -> Result<(), jsg::Error> { + let callback = callback + .try_as::() + .ok_or_else(|| jsg::Error::new_type_error("not a function"))?; + let called = callback.call::>(lock, None, &[]); + let conversion = self.conversion.get().unwrap(); + let converted = conversion.run(lock, value); + self.saw_pending + .set(converted.is_err_and(|err| err.is_pending())); + called.map(drop) + } +} + +/// JavaScript that terminates execution: `panicNow()` throws an internal error and requests +/// termination, which takes effect at the next interrupt check, in the loop. +const TERMINATE: &str = "() => { try { panicker.panicNow(); } catch (e) {} while (true) {} }"; + +fn converting_resource( + lock: &mut jsg::Lock, + ctx: &crate::EvalContext<'_>, + conversion: Conversion, +) -> jsg::Rc { + let resource = jsg::Rc::new(ConvertingResource { + conversion: Cell::new(Some(conversion)), + saw_pending: Cell::new(false), + }); + ctx.set_global(lock, "obj", resource.clone()); + ctx.set_global( + lock, + "panicker", + jsg::Rc::new(ReentrantResource { + call_count: Cell::new(0), + }), + ); + resource +} + +/// Execution terminating during a conversion or property access fails it with a pending error, +/// and the termination keeps propagating past the method: JavaScript cannot catch it. +#[test] +fn termination_during_conversion_propagates() { + for conversion in Conversion::ALL { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = converting_resource(lock, ctx, conversion); + let value = conversion.value_calling(TERMINATE); + let result = ctx.eval::( + lock, + &format!("try {{ obj.convert({value}); }} catch (e) {{}} true"), + ); + assert!( + result.is_err(), + "{conversion:?}: not terminated: {result:?}" + ); + assert!(resource.saw_pending.get(), "{conversion:?}"); + Ok(()) + }); + } +} + +/// A conversion or property access while execution is terminating runs no JavaScript and fails +/// with a pending error, as `Local::call` does. +#[test] +fn conversion_while_terminating_runs_nothing() { + for conversion in Conversion::ALL { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = converting_resource(lock, ctx, conversion); + let value = + conversion.value_calling("() => { globalThis.secondRan = true; return 0; }"); + let result = ctx.eval::( + lock, + &format!( + "globalThis.secondRan = false; + obj.callThenConvert({TERMINATE}, {value}); + true" + ), + ); + assert!( + result.is_err(), + "{conversion:?}: not terminated: {result:?}" + ); + assert!(resource.saw_pending.get(), "{conversion:?}"); + // V8 clears the termination once no JavaScript is running. + assert!( + !ctx.eval::(lock, "secondRan").unwrap(), + "{conversion:?} ran JavaScript while terminating" + ); + Ok(()) + }); + } +} diff --git a/src/rust/jsg-test/tests/date_bigint.rs b/src/rust/jsg-test/tests/date_bigint.rs index b854fe87c4f..f5d9879d22e 100644 --- a/src/rust/jsg-test/tests/date_bigint.rs +++ b/src/rust/jsg-test/tests/date_bigint.rs @@ -25,16 +25,24 @@ fn convert<'s, T: FromJS<'s>>( T::from_js(lock, value) } -/// Asserts that converting `expr` to `T` is a `TypeError` with `message`. +/// Asserts that converting `expr` to `T` throws a `TypeError` with `message`: either an error +/// the conversion fails with, or what V8 threw during the coercion, which stays pending. fn assert_type_error<'s, T: FromJS<'s>>( lock: &mut jsg::Lock<'s>, ctx: &crate::EvalContext<'_>, expr: &str, message: &str, ) { - let Err(err) = convert::(lock, ctx, expr) else { + let value = ctx + .eval_raw(lock, expr) + .expect("expression should evaluate"); + let caught = lock + .try_catch(|lock| T::from_js(lock, value).map(drop)) + .expect("execution should not be terminating"); + let Err(exception) = caught else { panic!("{expr} should not convert"); }; + let err = jsg::Error::from_js(lock, exception).unwrap(); assert_eq!(err.name, ExceptionType::TypeError, "{expr}"); assert_eq!(err.message, message, "{expr}"); } diff --git a/src/rust/jsg-test/tests/jsg_struct.rs b/src/rust/jsg-test/tests/jsg_struct.rs index 58084250261..013cdee7756 100644 --- a/src/rust/jsg-test/tests/jsg_struct.rs +++ b/src/rust/jsg-test/tests/jsg_struct.rs @@ -279,19 +279,25 @@ fn required_struct_from_null_and_absent_property() { }); } -/// A throwing getter fails the conversion with its own error. +/// A throwing getter fails the conversion, leaving what it threw pending. #[test] fn struct_from_throwing_getter_propagates_error() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let err = convert::( - lock, - ctx, - "({ get label() { throw new RangeError('getter'); } })", - ) - .unwrap_err(); - assert_eq!(err.name, ExceptionType::RangeError); - assert!(err.message.contains("getter"), "got {:?}", err.message); + let value = ctx + .eval_raw( + lock, + "globalThis.thrown = new RangeError('getter'); + ({ get label() { throw thrown; } })", + ) + .unwrap(); + let caught = lock.try_catch(|lock| { + let err = ::from_js(lock, value).unwrap_err(); + assert!(err.is_pending()); + Err::<(), _>(err) + })?; + ctx.set_global(lock, "caught", caught.unwrap_err()); + assert!(ctx.eval::(lock, "caught === thrown").unwrap()); Ok(()) }); } diff --git a/src/rust/jsg-test/tests/unwrap.rs b/src/rust/jsg-test/tests/unwrap.rs index 2486a141e74..5fbf28a8730 100644 --- a/src/rust/jsg-test/tests/unwrap.rs +++ b/src/rust/jsg-test/tests/unwrap.rs @@ -86,26 +86,16 @@ fn v8_unwrap_boolean_returns_correct_values() { fn v8_unwrap_number_returns_correct_values() { let harness = crate::Harness::new(); harness.run_in_context(|lock, _ctx| { - let num = Number::new(2.5).to_local(lock); - // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped = - unsafe { jsg::v8::ffi::unwrap_number(lock.isolate().as_ffi(), num.into_ffi()) } - .unwrap(); - assert!((unwrapped - 2.5).abs() < f64::EPSILON); - - let zero = Number::new(0.0).to_local(lock); - // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_zero = - unsafe { jsg::v8::ffi::unwrap_number(lock.isolate().as_ffi(), zero.into_ffi()) } - .unwrap(); - assert!(unwrapped_zero.abs() < f64::EPSILON); - - let negative = Number::new(-42.5).to_local(lock); - // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_neg = - unsafe { jsg::v8::ffi::unwrap_number(lock.isolate().as_ffi(), negative.into_ffi()) } - .unwrap(); - assert!((unwrapped_neg - (-42.5)).abs() < f64::EPSILON); + for expected in [2.5, 0.0, -42.5] { + let num = Number::new(expected).to_local(lock); + let mut unwrapped = f64::NAN; + // SAFETY: The isolate is locked and the value is a valid V8 local handle. + let ok = unsafe { + jsg::v8::ffi::unwrap_number(lock.isolate().as_ffi(), num.into_ffi(), &mut unwrapped) + }; + assert!(ok); + assert!((unwrapped - expected).abs() < f64::EPSILON); + } Ok(()) }); } @@ -114,25 +104,17 @@ fn v8_unwrap_number_returns_correct_values() { fn v8_unwrap_string_returns_correct_values() { let harness = crate::Harness::new(); harness.run_in_context(|lock, _ctx| { - let s = "hello world".to_local(lock); - // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped = - unsafe { jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), s.into_ffi()) }.unwrap(); - assert_eq!(unwrapped.as_str(), "hello world"); - - let empty = "".to_local(lock); - // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_empty = - unsafe { jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), empty.into_ffi()) } - .unwrap(); - assert_eq!(unwrapped_empty.as_str(), ""); - - let unicode = "こんにちは".to_local(lock); - // SAFETY: The isolate is locked and the value is a valid V8 local handle. - let unwrapped_unicode = - unsafe { jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), unicode.into_ffi()) } - .unwrap(); - assert_eq!(unwrapped_unicode.as_str(), "こんにちは"); + for expected in ["hello world", "", "こんにちは"] { + let s = expected.to_local(lock); + let mut unwrapped = String::new(); + // SAFETY: The isolate is locked and the value is a valid V8 local handle. + let ok = unsafe { + jsg::v8::ffi::unwrap_string(lock.isolate().as_ffi(), s.into_ffi(), &mut unwrapped) + } + .unwrap(); + assert!(ok); + assert_eq!(unwrapped, expected); + } Ok(()) }); } diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 3eff2a4f4e2..ef084b194a9 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -239,9 +239,9 @@ Without tracing, storing a `TracedGlobal` back to the resource's own wrapper cre ## 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. +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. The same goes for everything else that runs JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). `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. -Code that gets a pending error must not call into JavaScript, and should return the error. Converting it to another error and throwing that replaces the exception, as when C++ code throws while a `JsExceptionThrown` unwinds; nothing ever replaces a termination, and `call` refuses to run while execution is terminating. A pending error is only meaningful while its exception is pending: one kept past that, such as a clone stored after `try_catch` caught the exception, is thrown as an internal error when V8 has no exception pending. +Code that gets a pending error must not call into JavaScript, and should return the error. Converting it to another error and throwing that replaces the exception, as when C++ code throws while a `JsExceptionThrown` unwinds; nothing ever replaces a termination, and none of these run while execution is terminating: they fail with a pending error without calling into V8, even for a value that would convert without running JavaScript. A pending error is only meaningful while its exception is pending: one kept past that, such as a clone stored after `try_catch` caught the exception, is thrown as an internal error when V8 has no exception pending. To handle an exception instead, run the code in `Lock::try_catch`, which catches as C++ `jsg::Lock::tryCatch()` does: the exception, or an error the closure returns, as a value; a termination keeps propagating. diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 8ca8a2220ae..74ebea17879 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -449,34 +449,21 @@ void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, ::workerd::jsg::check(v8_obj->Set(context, v8_key, local_from_ffi(kj::mv(value)))); } -// Runs `fn` (returning a v8::MaybeLocal or v8::Maybe) and, on failure, converts the pending -// JS exception into a `jsg.:`-prefixed kj::Exception and throws it, so -// workerd-cxx's Result::run catches it in C++ and hands Rust a catchable error -// instead of aborting. Termination exceptions are re-thrown unchanged. The -// TryCatch and termination handling are delegated to jsg::Lock::tryCatch. -template -static auto unwrapCoerce(v8::Isolate* isolate, Func&& fn) { - auto& js = ::workerd::jsg::Lock::from(isolate); - JSG_TRY(js) { - return ::workerd::jsg::check(fn()); - } - JSG_CATCH(error) { - kj::throwException(::workerd::jsg::createTunneledException(isolate, error.getHandle(js))); - }; -} +// No TryCatch in the shims below that run JavaScript: what it throws stays pending, as in +// local_function_call(). -bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::Str key) { +bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::Str key, bool& has) { auto v8_obj = local_as_ref_from_ffi(object); auto context = isolate->GetCurrentContext(); auto v8_key = makeInternedStr(isolate, key); - return unwrapCoerce(isolate, [&] { return v8_obj->Has(context, v8_key); }); + return v8_obj->Has(context, v8_key).To(&has); } -Local local_object_get_property(Isolate* isolate, const Local& object, ::rust::Str key) { +MaybeLocal local_object_get_property(Isolate* isolate, const Local& object, ::rust::Str key) { auto v8_obj = local_as_ref_from_ffi(object); auto context = isolate->GetCurrentContext(); auto v8_key = makeInternedStr(isolate, key); - return to_ffi(unwrapCoerce(isolate, [&] { return v8_obj->Get(context, v8_key); })); + return maybe_local_to_ffi(v8_obj->Get(context, v8_key)); } // Local @@ -680,38 +667,38 @@ void wrappable_attach_wrapper(kj::Rc wrappable, FunctionCallbackInfo& // Unwrappers -::rust::String unwrap_string(Isolate* isolate, Local value) { +bool unwrap_string(Isolate* isolate, Local value, ::rust::String& result) { auto v8Value = local_from_ffi(kj::mv(value)); - // Fast path: a string primitive needs no coercion and can't throw, so skip - // the TryCatch/ToString machinery. (String wrapper objects are not IsString.) + // Fast path: a string primitive needs no coercion and can't throw. (String wrapper objects are + // not IsString.) v8::Local v8Str; if (v8Value->IsString()) { v8Str = v8Value.As(); - } else { - auto context = isolate->GetCurrentContext(); - v8Str = unwrapCoerce(isolate, [&] { return v8Value->ToString(context); }); + } else if (!v8Value->ToString(isolate->GetCurrentContext()).ToLocal(&v8Str)) { + return false; } v8::String::ValueView view(isolate, v8Str); if (!view.is_one_byte()) { // lossy(): lone surrogates become U+FFFD instead of throwing (which aborts). - return ::rust::String::lossy(reinterpret_cast(view.data16()), view.length()); + result = ::rust::String::lossy(reinterpret_cast(view.data16()), view.length()); + } else { + result = ::rust::String::latin1(reinterpret_cast(view.data8()), view.length()); } - return ::rust::String::latin1(reinterpret_cast(view.data8()), view.length()); + return true; } bool unwrap_boolean(Isolate* isolate, Local value) { return local_from_ffi(kj::mv(value))->ToBoolean(isolate)->Value(); } -double unwrap_number(Isolate* isolate, Local value) { +bool unwrap_number(Isolate* isolate, Local value, double& result) { auto v8Value = local_from_ffi(kj::mv(value)); // Fast path: a number primitive needs no coercion and can't throw. if (v8Value->IsNumber()) { - return v8Value.As()->Value(); + result = v8Value.As()->Value(); + return true; } - auto context = isolate->GetCurrentContext(); - v8::Local number = unwrapCoerce(isolate, [&] { return v8Value->ToNumber(context); }); - return number->Value(); + return v8Value->NumberValue(isolate->GetCurrentContext()).To(&result); } namespace { @@ -762,13 +749,7 @@ DEFINE_TYPED_ARRAY_UNWRAP(biguint64_array, BigUint64Array, uint64_t) // Uses V8's Array::Iterate() which is faster than indexed access. // Returns Global handles because Local handles get reused during iteration. -// -// Iterate() can fail with a *pending* V8 exception rather than a KJ one -- e.g. a -// throwing Proxy trap or getter on one of the array's elements. That must be tunneled -// through the same `jsg.:`-prefixed kj::Exception mechanism as `unwrapCoerce` -// uses (see above), rather than discarded in favor of a generic KJ_REQUIRE failure, -// or the real error is lost and the pending exception is left dangling on the isolate. -::rust::Vec local_array_iterate(Isolate* isolate, Local value) { +bool local_array_iterate(Isolate* isolate, Local value, ::rust::Vec& result) { auto context = isolate->GetCurrentContext(); auto v8Val = local_from_ffi(kj::mv(value)); @@ -779,32 +760,26 @@ DEFINE_TYPED_ARRAY_UNWRAP(biguint64_array, BigUint64Array, uint64_t) Isolate* isolate; ::rust::Vec* result; }; - - ::rust::Vec result; Data data{isolate, &result}; - auto& js = ::workerd::jsg::Lock::from(isolate); - JSG_TRY(js) { - auto iterateResult = arr->Iterate(context, - [](uint32_t index, v8::Local element, - void* userData) -> v8::Array::CallbackResult { - auto* d = static_cast(userData); - d->result->push_back(to_ffi(v8::Global(d->isolate, element))); - return v8::Array::CallbackResult::kContinue; - }, - &data); - // iterateResult is a v8::Maybe, so the v8::Maybe overload of - // jsg::check() (which needs a default-constructible T to write into) doesn't - // apply; throw JsExceptionThrown() directly, same as check() does internally. - if (iterateResult.IsNothing()) { - throw ::workerd::jsg::JsExceptionThrown(); + auto iterateResult = arr->Iterate(context, + [](uint32_t index, v8::Local element, + void* userData) -> v8::Array::CallbackResult { + auto* d = static_cast(userData); + d->result->push_back(to_ffi(v8::Global(d->isolate, element))); + return v8::Array::CallbackResult::kContinue; + }, + &data); + if (iterateResult.IsNothing()) { + // A getter or Proxy trap on an element threw. ffi::Global has no destructor in Rust, so the + // handles collected so far would stay strong roots until the isolate is disposed. + for (auto& global: result) { + global_reset(global); } + result.clear(); + return false; } - JSG_CATCH(error) { - kj::throwException(::workerd::jsg::createTunneledException(isolate, error.getHandle(js))); - }; - - return result; + return true; } // Local diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index d2b883171cb..b284444243f 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -193,6 +193,15 @@ int64_t local_big_int_int64_value(const Local& value, bool& lossless); // Local double local_date_value_of(const Local& value); +// Shims that run JavaScript (a call, a getter or Proxy trap, or a coercion through `toString`, +// `valueOf` or `Symbol.toPrimitive`) catch nothing: if it throws, they return an empty result or +// false and leave the exception pending, as C++ JSG's `JsExceptionThrown` does. Rust turns that +// into an `Error` for which `is_pending()` is true (`call_into_js()` in v8.rs), and doesn't call +// them while execution is terminating. The exceptions are `local_object_set_property`, +// `local_array_get` and `local_array_set`, which can also run JavaScript (a setter, getter or +// Proxy trap) but use `jsg::check()` and are not declared as returning `Result`: what JavaScript +// throws there becomes a Rust panic, which terminates execution (EW-11138). + // Local // Returns the call's result, or none if it threw, leaving the exception pending. MaybeLocal local_function_call( @@ -200,15 +209,21 @@ MaybeLocal local_function_call( // Local void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); -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); +// Stores in `has` whether `key` is in the object, and returns true, or returns false if a Proxy +// trap threw, leaving the exception pending. +bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::Str key, bool& has); +// Returns the value of `key`, or none if a getter or Proxy trap threw, leaving the exception +// pending. +MaybeLocal local_object_get_property(Isolate* isolate, const Local& object, ::rust::Str key); // Local Local local_new_array(Isolate* isolate, size_t length); uint32_t local_array_length(const Local& array); Local local_array_get(Isolate* isolate, const Local& array, uint32_t index); void local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value); -::rust::Vec local_array_iterate(Isolate* isolate, Local value); +// Appends the elements to `result` and returns true, or returns false with `result` empty if a +// getter or Proxy trap on an element threw, leaving the exception pending. +bool local_array_iterate(Isolate* isolate, Local value, ::rust::Vec& result); // Local Local local_new_uint8_array(Isolate* isolate, const uint8_t* data, size_t length); @@ -291,9 +306,12 @@ MaybeLocal wrap_resource(Isolate* isolate, kj::Rc wrappable, const Gl void wrappable_attach_wrapper(kj::Rc wrappable, FunctionCallbackInfo& args); // Unwrappers -::rust::String unwrap_string(Isolate* isolate, Local value); +// Convert with V8's ToString and ToNumber (not JavaScript's String() and Number(), which accept a +// Symbol and a BigInt): store the result in `result` and return true, or return false if the +// coercion threw, leaving the exception pending. +bool unwrap_string(Isolate* isolate, Local value, ::rust::String& result); bool unwrap_boolean(Isolate* isolate, Local value); -double unwrap_number(Isolate* isolate, Local value); +bool unwrap_number(Isolate* isolate, Local value, double& result); kj::Maybe> unwrap_resource(Isolate* isolate, Local value); ::rust::Vec unwrap_uint8_array(Isolate* isolate, Local value); ::rust::Vec unwrap_uint16_array(Isolate* isolate, Local value); diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 3dbfe9b3298..36b52bc28a1 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -385,16 +385,19 @@ pub mod ffi { key: &str, value: Local, ); + // Leave what a getter or Proxy trap throws pending; see `call_into_js()`. Fallible: + // the key is interned with KJ_REQUIRE and jsg::check. pub unsafe fn local_object_has_property( isolate: *mut Isolate, object: &Local, key: &str, + has: &mut bool, ) -> Result; pub unsafe fn local_object_get_property( isolate: *mut Isolate, object: &Local, key: &str, - ) -> Result; + ) -> Result; // Local pub unsafe fn local_array_length(array: &Local) -> u32; @@ -405,13 +408,13 @@ pub mod ffi { index: u32, value: Local, ); - // Fallible: KJ_REQUIREs the value is an array, and tunnels any JS exception - // thrown by a proxy trap/getter encountered while iterating elements. See - // "Unwrappers" below. + // Leaves what a getter or Proxy trap on an element throws pending; see + // `call_into_js()`. Fallible: KJ_REQUIREs the value is an array. pub unsafe fn local_array_iterate( isolate: *mut Isolate, value: Local, - ) -> Result>; + result: &mut Vec, + ) -> Result; // Local // Fallible: KJ_REQUIREs the view is within the buffer's bounds. @@ -484,14 +487,22 @@ pub mod ffi { // Unwrappers // - // Shims that can throw (coercion via jsg::check, building rust::String/Vec - // from V8 data, or KJ_REQUIRE) MUST return Result: workerd-cxx then - // catches the throw in C++ instead of aborting across the nounwind FFI - // frame. Shims that provably can't throw stay bare (see unwrap_boolean). - pub unsafe fn unwrap_string(isolate: *mut Isolate, value: Local) -> Result; + // Shims that can throw a C++ exception (building rust::String/Vec from V8 + // data, or KJ_REQUIRE) MUST return Result: workerd-cxx then catches the + // throw in C++ instead of aborting across the nounwind FFI frame. Shims that + // provably can't throw stay bare (see unwrap_boolean, unwrap_number). + // + // unwrap_string and unwrap_number leave what a coercion throws pending; see + // `call_into_js()`. + pub unsafe fn unwrap_string( + isolate: *mut Isolate, + value: Local, + result: &mut String, + ) -> Result; // Infallible: ToBoolean can't throw and builds no rust::String/Vec. pub unsafe fn unwrap_boolean(isolate: *mut Isolate, value: Local) -> bool; - pub unsafe fn unwrap_number(isolate: *mut Isolate, value: Local) -> Result; + // Infallible: NumberValue reports what JavaScript threw in its result. + pub unsafe fn unwrap_number(isolate: *mut Isolate, value: Local, result: &mut f64) -> bool; pub unsafe fn unwrap_uint8_array(isolate: *mut Isolate, value: Local) -> Result>; pub unsafe fn unwrap_uint16_array(isolate: *mut Isolate, value: Local) -> Result>; pub unsafe fn unwrap_uint32_array(isolate: *mut Isolate, value: Local) -> Result>; @@ -1597,27 +1608,39 @@ impl<'s> Local<'s, Function> { arg.debug_assert_isolate(lock); } - // Calling into V8 while execution is terminating would clear the termination. - // SAFETY: lock guarantees the isolate is locked. - if unsafe { ffi::isolate_is_execution_terminating(lock.isolate().as_ffi()) } { - return Err(Error::pending()); - } - // SAFETY: lock guarantees the isolate is locked and a HandleScope is active. - // self is a valid Local; recv and args are valid Local handles. The result - // belongs to the lock's scope. - let result = unsafe { - Local::from_ffi_maybe(ffi::local_function_call( - lock.isolate().as_ffi(), - self.raw(), - recv.raw(), - Local::raw_slice(args), - )) - }; - match result { - Some(result) => R::from_js(lock, result), - None => Err(Error::pending()), - } + let result = call_into_js(lock, |lock| { + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active. + // self is a valid Local; recv and args are valid Local handles. The + // result belongs to the lock's scope. + Ok(unsafe { + Local::from_ffi_maybe(ffi::local_function_call( + lock.isolate().as_ffi(), + self.raw(), + recv.raw(), + Local::raw_slice(args), + )) + }) + })?; + R::from_js(lock, result) + } +} + +/// Runs `shim`, a call through the FFI that can run JavaScript, and returns what it returns. +/// +/// Every such call goes through here, so that all of them handle exceptions and termination +/// alike. `shim` returns `None` if JavaScript threw, leaving the exception pending (see `ffi.h`); +/// that becomes an error for which [`Error::is_pending`] is true. While execution is terminating, +/// `shim` is not run and the error is the same: calling into V8 then would clear the termination. +/// An error from `shim` itself (a C++ exception, such as a failed `KJ_REQUIRE`) is passed on. +pub(crate) fn call_into_js<'s, T>( + lock: &mut Lock<'s>, + shim: impl FnOnce(&mut Lock<'s>) -> Result, Error>, +) -> Result { + // SAFETY: lock guarantees the isolate is locked. + if unsafe { ffi::isolate_is_execution_terminating(lock.isolate().as_ffi()) } { + return Err(Error::pending()); } + shim(lock)?.ok_or_else(Error::pending) } /// Implements bidirectional `From` conversions between `Local<$from>` and `Local<$to>`. @@ -1751,12 +1774,21 @@ impl<'s> Local<'s, Array> { /// Iterates over array elements using V8's native `Array::Iterate()`. /// Returns `TracedGlobal` handles because `Local` handles get reused during iteration. - /// Errs (rather than aborting) if the value is not an array, or if a proxy - /// trap/getter throws while an element is being read during iteration. + /// + /// # Errors + /// + /// Fails if the value is not an array. If a getter or Proxy trap on an element throws, or + /// execution is terminating, the exception stays pending and the error is one for which + /// [`Error::is_pending`] is true, as for [`Local::::call`]. pub fn iterate(self, lock: &mut Lock<'s>) -> crate::Result>> { - // SAFETY: lock guarantees the isolate is locked; handle is valid in scope 's. - let globals = - unsafe { ffi::local_array_iterate(lock.isolate().as_ffi(), self.into_ffi()) }?; + let globals = call_into_js(lock, |lock| { + let mut globals = Vec::new(); + // SAFETY: lock guarantees the isolate is locked; handle is valid in scope 's. + let ok = unsafe { + ffi::local_array_iterate(lock.isolate().as_ffi(), self.into_ffi(), &mut globals) + }?; + Ok(ok.then_some(globals)) + })?; let state = lock.isolate_state(); Ok(globals .into_iter() @@ -3027,21 +3059,43 @@ impl<'s> Local<'s, Object> { } } - /// Whether `key` is in the object or its prototype chain. A throwing - /// Proxy `has` trap is an `Err`. + /// Whether `key` is in the object or its prototype chain. + /// + /// # Errors + /// + /// If a Proxy `has` trap throws, or execution is terminating, the exception stays pending + /// and the error is one for which [`Error::is_pending`] is true, as for + /// [`Local::::call`]. pub fn has(self, lock: &mut Lock<'s>, key: &str) -> crate::Result { - // SAFETY: isolate is valid and locked (guaranteed by Lock); handle is valid. - Ok(unsafe { ffi::local_object_has_property(lock.isolate().as_ffi(), self.raw(), key) }?) + call_into_js(lock, |lock| { + let mut has = false; + // SAFETY: isolate is valid and locked (guaranteed by Lock); handle is valid. + let ok = unsafe { + ffi::local_object_has_property(lock.isolate().as_ffi(), self.raw(), key, &mut has) + }?; + Ok(ok.then_some(has)) + }) } - /// Reads `key`, like JS `obj[key]`: an absent property is `undefined`, - /// and a throwing getter or Proxy trap is an `Err`. + /// Reads `key`, like JS `obj[key]`: an absent property is `undefined`. + /// + /// # Errors + /// + /// If a getter or Proxy trap throws, or execution is terminating, the exception stays + /// pending and the error is one for which [`Error::is_pending`] is true, as for + /// [`Local::::call`]. pub fn get(self, lock: &mut Lock<'s>, key: &str) -> crate::Result> { - // SAFETY: isolate is valid and locked (guaranteed by Lock); handle is valid. - let local = - unsafe { ffi::local_object_get_property(lock.isolate().as_ffi(), self.raw(), key) }?; - // SAFETY: the handle was just created in the current HandleScope. - Ok(unsafe { Local::from_ffi(local) }) + call_into_js(lock, |lock| { + // SAFETY: isolate is valid and locked (guaranteed by Lock); handle is valid. The + // result was created in the current HandleScope. + Ok(unsafe { + Local::from_ffi_maybe(ffi::local_object_get_property( + lock.isolate().as_ffi(), + self.raw(), + key, + )?) + }) + }) } } diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index ba442e9dc5a..97d0f143e4f 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -494,8 +494,7 @@ impl<'s> FromJS<'s> for std::string::String { lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, ) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - Ok(unsafe { v8::ffi::unwrap_string(lock.isolate().as_ffi(), value.into_ffi()) }?) + unwrap_string(lock, value) } } @@ -529,9 +528,7 @@ impl<'s> FromJS<'s> for Number { lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, ) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - let f64_value = - unsafe { v8::ffi::unwrap_number(lock.isolate().as_ffi(), value.into_ffi()) }?; + let f64_value = unwrap_number(lock, value)?; Ok(Self::new(f64_value)) } } @@ -560,8 +557,7 @@ impl<'s> FromJS<'s> for &str { lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, ) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - Ok(unsafe { v8::ffi::unwrap_string(lock.isolate().as_ffi(), value.into_ffi()) }?) + unwrap_string(lock, value) } } @@ -660,8 +656,7 @@ macro_rules! impl_integer_from_js { } fn from_js(lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>) -> Result { - // SAFETY: The isolate is locked and value is a valid V8 local handle. - let num = unsafe { v8::ffi::unwrap_number(lock.isolate().as_ffi(), value.into_ffi()) }?; + let num = unwrap_number(lock, value)?; Self::from_number(num) } } @@ -669,6 +664,37 @@ macro_rules! impl_integer_from_js { }; } +/// Converts `value` with V8's `ToString` (which, unlike JavaScript's `String()`, rejects a +/// Symbol). If the coercion throws, or +/// execution is terminating, fails with an error for which [`Error::is_pending`] is true. +fn unwrap_string<'s>( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, +) -> Result { + v8::call_into_js(lock, |lock| { + let mut result = String::new(); + // SAFETY: The isolate is locked and value is a valid V8 local handle. + let ok = unsafe { + v8::ffi::unwrap_string(lock.isolate().as_ffi(), value.into_ffi(), &mut result) + }?; + Ok(ok.then_some(result)) + }) +} + +/// Converts `value` with V8's `ToNumber` (which, unlike JavaScript's `Number()`, rejects a +/// BigInt). If the coercion throws, or +/// execution is terminating, fails with an error for which [`Error::is_pending`] is true. +fn unwrap_number<'s>(lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>) -> Result { + v8::call_into_js(lock, |lock| { + let mut result = 0.0; + // SAFETY: The isolate is locked and value is a valid V8 local handle. + let ok = unsafe { + v8::ffi::unwrap_number(lock.isolate().as_ffi(), value.into_ffi(), &mut result) + }; + Ok(ok.then_some(result)) + }) +} + fn not_an_integer() -> Result { Err(Error::new_type_error( "The value cannot be converted because it is not an integer.", @@ -715,8 +741,7 @@ impl<'s> FromJS<'s> for i64 { ) }); } - // SAFETY: The isolate is locked and value is a valid V8 local handle. - let num = unsafe { v8::ffi::unwrap_number(lock.isolate().as_ffi(), value.into_ffi()) }?; + let num = unwrap_number(lock, value)?; if !num.is_finite() { return not_an_integer(); } From eb9cdcef9cb60df05d8f45a0e7eec72409b8ddcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 13:30:25 +0100 Subject: [PATCH 07/23] Pass on what reading a jsg::Error's name and message throws FromJS for jsg::Error reads `name` and `message`, which can run a getter, a Proxy trap or a toString. It discarded their errors with .ok() and went on, so it ran more JavaScript with an exception pending, and could drop a termination. Now that those errors stand for a pending exception, it passes them on with `?`. --- src/rust/jsg-test/tests/coercion_safety.rs | 20 +++++++++++++++ src/rust/jsg/README.md | 2 +- src/rust/jsg/lib.rs | 29 +++++++++++++--------- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/rust/jsg-test/tests/coercion_safety.rs b/src/rust/jsg-test/tests/coercion_safety.rs index 7d7eee85792..9897d1b23dc 100644 --- a/src/rust/jsg-test/tests/coercion_safety.rs +++ b/src/rust/jsg-test/tests/coercion_safety.rs @@ -778,3 +778,23 @@ fn conversion_while_terminating_runs_nothing() { }); } } + +/// `Error`'s `FromJS` passes on what reading `name` or `message` throws, rather than discarding +/// it and running more JavaScript with an exception pending. +#[test] +fn error_from_js_passes_on_what_reading_it_throws() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + ctx.eval_raw(lock, "globalThis.thrown = new RangeError('getter')") + .expect("setup should evaluate"); + for expr in [ + "Object.defineProperty(new Error('x'), 'name', { get() { throw thrown; } })", + "Object.defineProperty(new Error('x'), 'message', { get() { throw thrown; } })", + "Object.assign(new Error('x'), { message: { toString() { throw thrown; } } })", + ] { + let caught = conversion_throws::(lock, ctx, expr); + assert!(is_thrown(lock, ctx, caught), "{expr}"); + } + Ok(()) + }); +} diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index ef084b194a9..bf0ceec98f9 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -239,7 +239,7 @@ Without tracing, storing a `TracedGlobal` back to the resource's own wrapper cre ## 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. The same goes for everything else that runs JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). `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. +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. The same goes for everything else that runs JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. Code that gets a pending error must not call into JavaScript, and should return the error. Converting it to another error and throwing that replaces the exception, as when C++ code throws while a `JsExceptionThrown` unwinds; nothing ever replaces a termination, and none of these run while execution is terminating: they fail with a pending error without calling into V8, even for a value that would convert without running JavaScript. A pending error is only meaningful while its exception is pending: one kept past that, such as a clone stored after `try_catch` caught the exception, is thrown as an internal error when V8 has no exception pending. diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 212eeedab62..a7abdfc0193 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -215,6 +215,10 @@ impl<'s> FromJS<'s> for Error { /// /// If the value is a native error, extracts the name and message properties. /// Otherwise, converts the value to a string for the message. + /// + /// Reading `name` and `message` can run JavaScript (a getter, a Proxy trap, or `toString`). + /// If it throws, or execution is terminating, this fails with an error for which + /// [`Error::is_pending`] is true. fn from_js( lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, @@ -222,18 +226,19 @@ impl<'s> FromJS<'s> for Error { if value.is_native_error() { let obj: v8::Local = value.into(); - let name = obj - .get(lock, "name") - .ok() - .filter(|v| !v.is_undefined()) - .and_then(|v| String::from_js(lock, v).ok()); - - let message = obj - .get(lock, "message") - .ok() - .filter(|v| !v.is_undefined()) - .and_then(|v| String::from_js(lock, v).ok()) - .unwrap_or_else(|| "Unknown error".to_owned()); + let name = obj.get(lock, "name")?; + let name = if name.is_undefined() { + None + } else { + Some(String::from_js(lock, name)?) + }; + + let message = obj.get(lock, "message")?; + let message = if message.is_undefined() { + "Unknown error".to_owned() + } else { + String::from_js(lock, message)? + }; Ok(Self { name: name.map_or(ExceptionType::Error, |n| ExceptionType::from(n.as_str())), From 13674f2069c9efadb2ce07b94974e8861201e6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 14:01:25 +0100 Subject: [PATCH 08/23] Convert Vecs and structs to JavaScript without running JavaScript ToJS for Vec and for a #[jsg_struct] set each element or field with Object::Set(), which looks up the prototype chain, so a setter a script put on Array.prototype or Object.prototype ran, and could throw. ToJS can't fail, and C++ JSG doesn't call Set() there either: it builds an array with v8::Array::New(isolate, elements, length) and a struct from an ObjectTemplate. * Array::with_elements builds an array from its elements with v8::Array::New, as C++ JSG does, and ToJS for Vec uses it. * Object::with_data_properties creates an object and defines its properties with CreateDataProperty(), which runs no setter on a new ordinary object; #[jsg_struct]'s ToJS uses it. A test defines throwing setters on Array.prototype and Object.prototype and checks that converting a Vec of structs, directly and through a method's arguments and return value, calls none of them and defines own properties in field order. It fails with the previous Set()-based conversions. ok-empty-struct checks that a struct without fields still compiles without a warning. --- src/rust/jsg-compile-tests/ok-empty-struct.rs | 13 ++++ src/rust/jsg-macros/lib.rs | 15 ++-- src/rust/jsg-test/tests/arrays.rs | 65 +++++++++++++++++ src/rust/jsg/README.md | 2 +- src/rust/jsg/ffi.c++ | 25 +++++++ src/rust/jsg/ffi.h | 6 ++ src/rust/jsg/v8.rs | 69 +++++++++++++++++++ src/rust/jsg/wrappable.rs | 10 ++- 8 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 src/rust/jsg-compile-tests/ok-empty-struct.rs diff --git a/src/rust/jsg-compile-tests/ok-empty-struct.rs b/src/rust/jsg-compile-tests/ok-empty-struct.rs new file mode 100644 index 00000000000..facc3fa7ea6 --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-empty-struct.rs @@ -0,0 +1,13 @@ +// 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 `#[jsg_struct]` without fields converts to an empty object, without a warning. +#![deny(warnings)] + +#[jsg_macros::jsg_struct] +pub struct Empty {} + +pub fn t<'s>(lock: &mut jsg::Lock<'s>) -> jsg::v8::Local<'s, jsg::v8::Value> { + jsg::ToJS::to_js(Empty {}, lock) +} diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index 2924b4c0b22..16358af28a1 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -89,7 +89,7 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { // Like C++ JSG, an absent optional field is omitted, not undefined. let set = quote! { let value = jsg::v8::ToLocalValue::to_local(value, lock); - obj.set(lock, #field_name_str, value); + properties.push((#field_name_str, value)); }; field_assignments.push(if optional { quote! { if let Some(value) = &this.#field_name { #set } } @@ -130,15 +130,16 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { impl<'s> jsg::ToJS<'s> for #name { fn to_js(self, lock: &mut jsg::Lock<'s>) -> jsg::v8::Local<'s, jsg::v8::Value> { + // The fields are defined as data properties, as C++ JSG's ObjectTemplate does, + // so no setter a script put on Object.prototype runs. // TODO(soon): Use a precached ObjectTemplate instance to create the object, // similar to how C++ JSG optimizes object creation. This would avoid recreating // the object shape on every wrap() call and improve performance. - { - let this = self; - let obj = lock.new_object(); - #(#field_assignments)* - obj.into() - } + let this = self; + #[allow(unused_mut, reason = "a struct without public fields pushes nothing")] + let mut properties = ::std::vec::Vec::new(); + #(#field_assignments)* + jsg::v8::Object::with_data_properties(lock, &properties).into() } } diff --git a/src/rust/jsg-test/tests/arrays.rs b/src/rust/jsg-test/tests/arrays.rs index dfe4139c3e4..9575f2948bd 100644 --- a/src/rust/jsg-test/tests/arrays.rs +++ b/src/rust/jsg-test/tests/arrays.rs @@ -1455,3 +1455,68 @@ fn is_float16_array_check() { Ok(()) }); } + +/// Converting a `Vec` or a `#[jsg_struct]` to JavaScript defines the elements and fields as own +/// data properties, as C++ JSG does: it runs no JavaScript, so no setter a script put on +/// `Array.prototype` or `Object.prototype` is called, not even one that throws. +#[test] +fn to_js_calls_no_prototype_setters() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = jsg::Rc::new(ArrayResource); + let wrapped = resource.to_js(lock); + ctx.set_global(lock, "arr", wrapped); + ctx.eval_raw( + lock, + "globalThis.settersRan = []; + for (const [proto, key] of [[Array.prototype, 0], [Array.prototype, 1], + [Object.prototype, 'name'], [Object.prototype, 'age']]) { + Object.defineProperty(proto, key, { + set(v) { settersRan.push(key); throw new Error('setter ran'); }, + configurable: true, + }); + }", + ) + .expect("setup should evaluate"); + + let people = vec![ + Person { + name: "Alice".to_owned(), + age: Number::new(25.0), + }, + Person { + name: "Bob".to_owned(), + age: Number::new(30.0), + }, + ]; + let value = people.to_js(lock); + ctx.set_global(lock, "people", value); + let ok: bool = ctx + .eval( + lock, + "Array.isArray(people) && people.length === 2 && + Object.hasOwn(people, 0) && Object.hasOwn(people, 1) && + people.every(p => Object.keys(p).join() === 'name,age') && + people[0].name === 'Alice' && people[0].age === 25 && + people[1].name === 'Bob' && people[1].age === 30", + ) + .unwrap(); + assert!(ok); + + // The same through a method's arguments and return value. + let ok: bool = ctx + .eval( + lock, + "const adults = arr.filterAdults([{name: 'Carol', age: 40}, {name: 'Dan', age: 50}]); + const doubled = arr.double([1, 2]); + Object.hasOwn(adults, 0) && Object.hasOwn(adults[0], 'name') && + adults[1].name === 'Dan' && Object.hasOwn(doubled, 1) && doubled[1] === 4", + ) + .unwrap(); + assert!(ok); + + let setters_ran: String = ctx.eval(lock, "settersRan.join()").unwrap(); + assert_eq!(setters_ran, ""); + Ok(()) + }); +} diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index bf0ceec98f9..8e807674d6c 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -239,7 +239,7 @@ Without tracing, storing a `TracedGlobal` back to the resource's own wrapper cre ## 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. The same goes for everything else that runs JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. +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. The same goes for everything else that runs JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). Converting a `Vec` or a `#[jsg_struct]` to JavaScript runs no JavaScript and can't fail: like C++ JSG, it defines the elements and fields as data properties (`Array::with_elements`, `Object::with_data_properties`), so no setter on `Array.prototype` or `Object.prototype` runs. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. Code that gets a pending error must not call into JavaScript, and should return the error. Converting it to another error and throwing that replaces the exception, as when C++ code throws while a `JsExceptionThrown` unwinds; nothing ever replaces a termination, and none of these run while execution is terminating: they fail with a pending error without calling into V8, even for a value that would convert without running JavaScript. A pending error is only meaningful while its exception is pending: one kept past that, such as a clone stored after `try_catch` caught the exception, is thrown as an internal error when V8 has no exception pending. diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 74ebea17879..46cb4cf75f3 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -442,6 +442,22 @@ MaybeLocal local_function_call( } // Local +Local object_new_with_data_properties( + Isolate* isolate, ::rust::Slice keys, ::rust::Slice values) { + KJ_REQUIRE(keys.size() == values.size()); + auto context = isolate->GetCurrentContext(); + auto object = v8::Object::New(isolate); + for (size_t i = 0; i < keys.size(); i++) { + // Unlike Set(), CreateDataProperty() doesn't look up the prototype chain, so no setter runs, + // and on an ordinary object no Proxy trap runs either: nothing here runs JavaScript, so it + // can't throw. + auto defined = object->CreateDataProperty( + context, makeInternedStr(isolate, keys[i]), local_as_ref_from_ffi(values[i])); + KJ_ASSERT(defined.FromMaybe(false), "defining a property of a new object failed", keys[i]); + } + return to_ffi(kj::mv(object)); +} + void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value) { auto v8_obj = local_as_ref_from_ffi(object); auto context = isolate->GetCurrentContext(); @@ -475,6 +491,15 @@ uint32_t local_array_length(const Local& array) { return local_as_ref_from_ffi(array)->Length(); } +Local array_new_with_elements(Isolate* isolate, ::rust::Slice elements) { + v8::LocalVector v8Elements(isolate, elements.size()); + for (size_t i = 0; i < elements.size(); i++) { + v8Elements[i] = local_as_ref_from_ffi(elements[i]); + } + // Stores the elements directly, without Set(), so no setter on Array.prototype runs. + return to_ffi(v8::Array::New(isolate, v8Elements.data(), v8Elements.size())); +} + Local local_array_get(Isolate* isolate, const Local& array, uint32_t index) { auto context = isolate->GetCurrentContext(); auto v8Array = local_as_ref_from_ffi(array); diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index b284444243f..6493d40b0a2 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -208,6 +208,10 @@ MaybeLocal local_function_call( Isolate* isolate, const Local& function, const Local& recv, ::rust::Slice args); // Local +// Returns a new object with `keys[i]` an own data property of value `values[i]`. Runs no +// JavaScript: no setter on Object.prototype is called. +Local object_new_with_data_properties( + Isolate* isolate, ::rust::Slice keys, ::rust::Slice values); void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); // Stores in `has` whether `key` is in the object, and returns true, or returns false if a Proxy // trap threw, leaving the exception pending. @@ -219,6 +223,8 @@ MaybeLocal local_object_get_property(Isolate* isolate, const Local& object, ::ru // Local Local local_new_array(Isolate* isolate, size_t length); uint32_t local_array_length(const Local& array); +// Returns a new array of `elements`. Runs no JavaScript: no setter on Array.prototype is called. +Local array_new_with_elements(Isolate* isolate, ::rust::Slice elements); Local local_array_get(Isolate* isolate, const Local& array, uint32_t index); void local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value); // Appends the elements to `result` and returns true, or returns false with `result` empty if a diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 36b52bc28a1..92fe8b24888 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -379,6 +379,13 @@ pub mod ffi { ) -> MaybeLocal; // Local + // Fallible: the keys are interned with KJ_REQUIRE and jsg::check, and KJ_REQUIREs as + // many keys as values. + pub unsafe fn object_new_with_data_properties( + isolate: *mut Isolate, + keys: &[&str], + values: &[Local], + ) -> Result; pub unsafe fn local_object_set_property( isolate: *mut Isolate, object: &mut Local, @@ -401,6 +408,7 @@ pub mod ffi { // Local pub unsafe fn local_array_length(array: &Local) -> u32; + pub unsafe fn array_new_with_elements(isolate: *mut Isolate, elements: &[Local]) -> Local; pub unsafe fn local_array_get(isolate: *mut Isolate, array: &Local, index: u32) -> Local; pub unsafe fn local_array_set( isolate: *mut Isolate, @@ -1740,6 +1748,27 @@ impl Array { // SAFETY: isolate is valid and locked (guaranteed by Lock). unsafe { Local::from_ffi(ffi::local_new_array(isolate.as_ffi(), len)) } } + + /// Creates a new JavaScript array of `elements`. + /// + /// Runs no JavaScript: unlike [`Local::::set`], it calls no setter that a script + /// defined on `Array.prototype`. Corresponds to `v8::Array::New(isolate, elements, length)`. + pub fn with_elements<'s>( + lock: &mut crate::Lock<'s>, + elements: &[Local<'s, Value>], + ) -> Local<'s, Self> { + for element in elements { + element.debug_assert_isolate(lock); + } + // SAFETY: lock guarantees the isolate is locked and a HandleScope is active; the + // elements are valid handles in it. + unsafe { + Local::from_ffi(ffi::array_new_with_elements( + lock.isolate().as_ffi(), + Local::raw_slice(elements), + )) + } + } } impl<'s> Local<'s, Array> { @@ -3042,6 +3071,46 @@ impl Drop for Utf8Value { } // Object-specific implementations +impl Object { + /// Creates a new object with `properties` as its own data properties, in that order. + /// + /// Runs no JavaScript: unlike [`Local::::set`], it calls no setter that a script + /// defined on `Object.prototype`. Each property is defined as by `v8::Object::CreateDataProperty` + /// (writable, enumerable and configurable); a later duplicate key replaces the earlier value. + /// + /// # Panics + /// + /// Panics if a key is longer than [`String::MAX_LENGTH`]. + #[expect( + clippy::expect_used, + reason = "fails only for a key longer than V8's maximum string length" + )] + pub fn with_data_properties<'s>( + lock: &mut Lock<'s>, + properties: &[(&str, Local<'s, Value>)], + ) -> Local<'s, Self> { + let (keys, values): (Vec<&str>, Vec>) = properties + .iter() + .map(|(key, value)| { + value.debug_assert_isolate(lock); + (*key, *value) + }) + .unzip(); + // SAFETY: lock guarantees the isolate is locked, a context is entered and a HandleScope + // is active; the values are valid handles in it. The result belongs to that scope. + unsafe { + Local::from_ffi( + ffi::object_new_with_data_properties( + lock.isolate().as_ffi(), + &keys, + Local::raw_slice(&values), + ) + .expect("object property key is too long for a V8 string"), + ) + } + } +} + impl<'s> Local<'s, Object> { /// Sets `key` to `value`. May run JavaScript (setters, proxy traps). pub fn set(self, lock: &mut Lock<'s>, key: &str, value: Local<'s, Value>) { diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index 97d0f143e4f..2826fa8337d 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -1000,12 +1000,10 @@ impl Type for Vec { impl<'s, T: ToJS<'s>> ToJS<'s> for Vec { fn to_js(self, lock: &mut Lock<'s>) -> v8::Local<'s, v8::Value> { - let array = v8::Array::new(lock, self.len()); - for (i, item) in self.into_iter().enumerate() { - let value = item.to_js(lock); - array.set(lock, i, value); - } - array.into() + // As C++ JSG does, this builds the array from its elements instead of setting them + // one by one, so no setter a script put on Array.prototype runs. + let elements: Vec<_> = self.into_iter().map(|item| item.to_js(lock)).collect(); + v8::Array::with_elements(lock, &elements).into() } } From 3a6703250a109f1792649a4f93ccdf40673a378a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 14:01:53 +0100 Subject: [PATCH 09/23] Leave what JavaScript setters throw pending in Rust JSG Object::set and Array::set can run JavaScript, a setter on the object or its prototype chain, or a Proxy trap, but local_object_set_property and local_array_set used jsg::check() and weren't declared as returning Result, so what JavaScript threw became a Rust panic, which terminates execution (EW-11138). Both shims now catch nothing: they return false and leave the exception pending, as local_object_get_property does. Object::set and Array::set go through call_into_js(), so they return an error for which is_pending() is true, and run nothing while execution is terminating. Array::set takes a u32 index, V8's, instead of truncating a usize. local_array_get, which nothing called, is removed. This takes the last three jsg::check() uses out of the shims that run JavaScript. The Node.js exception helpers in node-exceptions set `code`, `syscall`, `path` and `dest` on a new error, which runs a setter a script put on the error's prototype chain. They now pass on its error, and the C++ caller throws JsExceptionThrown for the exception left pending, as C++'s JsObject::set does. New tests check that Object::set and Array::set throw what a setter on the object, on its prototype chain, or a Proxy trap threw, and that they neither catch a termination nor run JavaScript while execution is terminating. --- .../callback-must-not-capture.rs | 4 +- src/rust/jsg-compile-tests/ok-callback.rs | 5 +- src/rust/jsg-test/tests/coercion_safety.rs | 82 +++++++++++++++++- src/rust/jsg-test/tests/isolates.rs | 2 +- src/rust/jsg-test/tests/jsg_struct.rs | 8 +- src/rust/jsg/README.md | 2 +- src/rust/jsg/ffi.c++ | 20 ++--- src/rust/jsg/ffi.h | 22 ++--- src/rust/jsg/v8.rs | 83 ++++++++++++------- src/rust/node-exceptions/lib-test.rs | 13 +-- src/rust/node-exceptions/lib.rs | 34 ++++---- src/workerd/api/node/exceptions.c++ | 2 +- 12 files changed, 187 insertions(+), 90 deletions(-) diff --git a/src/rust/jsg-compile-tests/callback-must-not-capture.rs b/src/rust/jsg-compile-tests/callback-must-not-capture.rs index 197bff3ad07..82c2696358c 100644 --- a/src/rust/jsg-compile-tests/callback-must-not-capture.rs +++ b/src/rust/jsg-compile-tests/callback-must-not-capture.rs @@ -8,6 +8,8 @@ pub fn t() -> jsg::RawFunctionCallback { jsg::function_callback(move |lock, args| { //~^ ERROR E0277 let value = lock.new_object(); - value.set(lock, &name, args.this()); + if value.set(lock, &name, args.this()).is_ok() { + args.set_return_value(value.into()); + } }) } diff --git a/src/rust/jsg-compile-tests/ok-callback.rs b/src/rust/jsg-compile-tests/ok-callback.rs index 956f577b3d9..7678bc5c9de 100644 --- a/src/rust/jsg-compile-tests/ok-callback.rs +++ b/src/rust/jsg-compile-tests/ok-callback.rs @@ -7,7 +7,8 @@ pub fn t() -> jsg::RawFunctionCallback { jsg::function_callback(|lock, args| { let object = lock.new_object(); - object.set(lock, "this", args.this()); - args.set_return_value(object.into()); + if object.set(lock, "this", args.this()).is_ok() { + args.set_return_value(object.into()); + } }) } diff --git a/src/rust/jsg-test/tests/coercion_safety.rs b/src/rust/jsg-test/tests/coercion_safety.rs index 9897d1b23dc..12cf27ae90d 100644 --- a/src/rust/jsg-test/tests/coercion_safety.rs +++ b/src/rust/jsg-test/tests/coercion_safety.rs @@ -575,6 +575,64 @@ fn object_has_from_throwing_proxy_returns_err() { }); } +/// `Object::set` throws what a setter, on the object or its prototype chain, or a Proxy `set` +/// trap threw. +#[test] +fn object_set_from_throwing_setter_returns_err() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + ctx.eval_raw(lock, "globalThis.thrown = new RangeError('setter')") + .expect("setup should evaluate"); + for expr in [ + "({ set x(v) { throw thrown; } })", + "Object.create({ set x(v) { throw thrown; } })", + "new Proxy({}, { set() { throw thrown; } })", + ] { + let value = ctx + .eval_raw(lock, expr) + .expect("expression should evaluate"); + let obj: v8::Local<'_, v8::Object> = value.into(); + let one = Number::new(1.0).to_js(lock); + let caught = thrown_by(lock, |lock| obj.set(lock, "x", one)); + assert!(is_thrown(lock, ctx, caught), "{expr}"); + } + let obj: v8::Local<'_, v8::Object> = ctx.eval_raw(lock, "({})").unwrap().into(); + let one = Number::new(1.0).to_js(lock); + obj.set(lock, "x", one)?; + let x = obj.get(lock, "x")?; + assert!((Number::from_js(lock, x)?.value() - 1.0).abs() < f64::EPSILON); + Ok(()) + }); +} + +/// `Array::set` throws what an element's setter, on the array or `Array.prototype`, threw. +#[test] +fn array_set_from_throwing_setter_returns_err() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + ctx.eval_raw(lock, "globalThis.thrown = new RangeError('setter')") + .expect("setup should evaluate"); + for expr in [ + "Object.defineProperty([0], 0, { set(v) { throw thrown; } })", + "Object.setPrototypeOf([], Object.create(Array.prototype, \ + { 0: { set(v) { throw thrown; } } }))", + ] { + let value = ctx + .eval_raw(lock, expr) + .expect("expression should evaluate"); + let array: v8::Local<'_, v8::Array> = value.into(); + let one = Number::new(1.0).to_js(lock); + let caught = thrown_by(lock, |lock| array.set(lock, 0, one)); + assert!(is_thrown(lock, ctx, caught), "{expr}"); + } + let array: v8::Local<'_, v8::Array> = ctx.eval_raw(lock, "[]").unwrap().into(); + let one = Number::new(1.0).to_js(lock); + array.set(lock, 2, one)?; + assert_eq!(array.len(), 3); + Ok(()) + }); +} + /// What an argument's coercion throws reaches the JavaScript caller of a method unchanged. #[test] fn coercion_exception_propagates_through_method() { @@ -611,18 +669,22 @@ enum Conversion { Get, Has, Iterate, + Set, + SetElement, } impl Conversion { - const ALL: [Self; 5] = [ + const ALL: [Self; 7] = [ Self::String, Self::Number, Self::Get, Self::Has, Self::Iterate, + Self::Set, + Self::SetElement, ]; - /// Converts `value`, or reads its property `x`. + /// Converts `value`, reads or sets its property `x`, or sets its element 0. fn run<'s>(self, lock: &mut jsg::Lock<'s>, value: v8::Local<'s, v8::Value>) -> jsg::Result<()> { match self { Self::String => String::from_js(lock, value).map(drop), @@ -634,10 +696,18 @@ impl Conversion { .has(lock, "x") .map(drop), Self::Iterate => Vec::::from_js(lock, value).map(drop), + Self::Set => { + let undefined = lock.undefined(); + v8::Local::::from(value).set(lock, "x", undefined) + } + Self::SetElement => { + let undefined = lock.undefined(); + v8::Local::::from(value).set(lock, 0, undefined) + } } } - /// A value whose conversion, or whose property `x`, calls `f`. + /// A value whose conversion, property `x` or element 0 calls `f`. fn value_calling(self, f: &str) -> String { match self { Self::String => format!("({{ toString: {f} }})"), @@ -649,6 +719,12 @@ impl Conversion { "(() => {{ const a = [0]; Object.defineProperty(a, 0, {{ get: {f} }}); return a; }})()" ) } + Self::Set => format!("({{ set x(v) {{ ({f})(); }} }})"), + Self::SetElement => { + format!( + "(() => {{ const a = [0]; Object.defineProperty(a, 0, {{ set: {f} }}); return a; }})()" + ) + } } } } diff --git a/src/rust/jsg-test/tests/isolates.rs b/src/rust/jsg-test/tests/isolates.rs index 57ff2c1a277..b06150d7c80 100644 --- a/src/rust/jsg-test/tests/isolates.rs +++ b/src/rust/jsg-test/tests/isolates.rs @@ -374,7 +374,7 @@ fn object_set_with_value_of_another_isolate_panics_in_debug_builds() { let object = v8::Local::from_ffi(object.into_ffi()); let target = lock_b.new_object(); assert_isolate_check_panics("setting A's object on B's object", || { - target.set(lock_b, "value", object); + let _ = target.set(lock_b, "value", object); }); Ok(()) }); diff --git a/src/rust/jsg-test/tests/jsg_struct.rs b/src/rust/jsg-test/tests/jsg_struct.rs index 013cdee7756..d69baa845e0 100644 --- a/src/rust/jsg-test/tests/jsg_struct.rs +++ b/src/rust/jsg-test/tests/jsg_struct.rs @@ -54,7 +54,7 @@ fn objects_can_be_wrapped_and_unwrapped() { assert!(!obj.has(lock, "test").unwrap()); let value = "value".to_local(lock); assert!(value.is_string()); - obj.set(lock, "test", value); + obj.set(lock, "test", value)?; assert!(obj.has(lock, "test").unwrap()); Ok(()) }); @@ -115,11 +115,11 @@ fn empty_object_and_property_setting() { assert!(obj.get(lock, "nonexistent").unwrap().is_undefined()); let str_value = "hello".to_local(lock); - obj.set(lock, "key1", str_value); + obj.set(lock, "key1", str_value)?; assert!(obj.has(lock, "key1").unwrap()); let num_value = 100u32.to_local(lock); - obj.set(lock, "key2", num_value); + obj.set(lock, "key2", num_value)?; assert!(obj.has(lock, "key2").unwrap()); let val1 = obj.get(lock, "key1"); @@ -156,7 +156,7 @@ fn nested_object_properties() { inner: "nested value".to_owned(), }; let inner_wrapped = inner_instance.to_js(lock); - outer.set(lock, "nested", inner_wrapped); + outer.set(lock, "nested", inner_wrapped)?; assert!(outer.has(lock, "nested").unwrap()); let nested_val = outer.get(lock, "nested"); diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 8e807674d6c..c51c6e96a35 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -239,7 +239,7 @@ Without tracing, storing a `TracedGlobal` back to the resource's own wrapper cre ## 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. The same goes for everything else that runs JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). Converting a `Vec` or a `#[jsg_struct]` to JavaScript runs no JavaScript and can't fail: like C++ JSG, it defines the elements and fields as data properties (`Array::with_elements`, `Object::with_data_properties`), so no setter on `Array.prototype` or `Object.prototype` runs. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. +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. The same goes for everything else that runs JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), `Object::set` and `Array::set` (a setter, also one on the prototype chain, or a Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). Converting a `Vec` or a `#[jsg_struct]` to JavaScript runs no JavaScript and can't fail: like C++ JSG, it defines the elements and fields as data properties (`Array::with_elements`, `Object::with_data_properties`), so no setter on `Array.prototype` or `Object.prototype` runs. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. Code that gets a pending error must not call into JavaScript, and should return the error. Converting it to another error and throwing that replaces the exception, as when C++ code throws while a `JsExceptionThrown` unwinds; nothing ever replaces a termination, and none of these run while execution is terminating: they fail with a pending error without calling into V8, even for a value that would convert without running JavaScript. A pending error is only meaningful while its exception is pending: one kept past that, such as a clone stored after `try_catch` caught the exception, is thrown as an internal error when V8 has no exception pending. diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 46cb4cf75f3..82ab0d67563 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -458,16 +458,16 @@ Local object_new_with_data_properties( return to_ffi(kj::mv(object)); } -void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value) { +// No TryCatch in the shims below that run JavaScript: what it throws stays pending, as in +// local_function_call(). + +bool local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value) { auto v8_obj = local_as_ref_from_ffi(object); auto context = isolate->GetCurrentContext(); auto v8_key = makeInternedStr(isolate, key); - ::workerd::jsg::check(v8_obj->Set(context, v8_key, local_from_ffi(kj::mv(value)))); + return v8_obj->Set(context, v8_key, local_from_ffi(kj::mv(value))).IsJust(); } -// No TryCatch in the shims below that run JavaScript: what it throws stays pending, as in -// local_function_call(). - bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::Str key, bool& has) { auto v8_obj = local_as_ref_from_ffi(object); auto context = isolate->GetCurrentContext(); @@ -500,16 +500,10 @@ Local array_new_with_elements(Isolate* isolate, ::rust::Slice eleme return to_ffi(v8::Array::New(isolate, v8Elements.data(), v8Elements.size())); } -Local local_array_get(Isolate* isolate, const Local& array, uint32_t index) { - auto context = isolate->GetCurrentContext(); - auto v8Array = local_as_ref_from_ffi(array); - return to_ffi(::workerd::jsg::check(v8Array->Get(context, index))); -} - -void local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value) { +bool local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value) { auto context = isolate->GetCurrentContext(); auto v8Array = local_as_ref_from_ffi(array); - ::workerd::jsg::check(v8Array->Set(context, index, local_from_ffi(kj::mv(value)))); + return v8Array->Set(context, index, local_from_ffi(kj::mv(value))).IsJust(); } // Local diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index 6493d40b0a2..e8755164308 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -193,14 +193,11 @@ int64_t local_big_int_int64_value(const Local& value, bool& lossless); // Local double local_date_value_of(const Local& value); -// Shims that run JavaScript (a call, a getter or Proxy trap, or a coercion through `toString`, -// `valueOf` or `Symbol.toPrimitive`) catch nothing: if it throws, they return an empty result or -// false and leave the exception pending, as C++ JSG's `JsExceptionThrown` does. Rust turns that -// into an `Error` for which `is_pending()` is true (`call_into_js()` in v8.rs), and doesn't call -// them while execution is terminating. The exceptions are `local_object_set_property`, -// `local_array_get` and `local_array_set`, which can also run JavaScript (a setter, getter or -// Proxy trap) but use `jsg::check()` and are not declared as returning `Result`: what JavaScript -// throws there becomes a Rust panic, which terminates execution (EW-11138). +// Shims that run JavaScript (a call, a getter, setter or Proxy trap, or a coercion through +// `toString`, `valueOf` or `Symbol.toPrimitive`) catch nothing: if it throws, they return an empty +// result or false and leave the exception pending, as C++ JSG's `JsExceptionThrown` does. Rust +// turns that into an `Error` for which `is_pending()` is true (`call_into_js()` in v8.rs), and +// doesn't call them while execution is terminating. // Local // Returns the call's result, or none if it threw, leaving the exception pending. @@ -212,7 +209,9 @@ MaybeLocal local_function_call( // JavaScript: no setter on Object.prototype is called. Local object_new_with_data_properties( Isolate* isolate, ::rust::Slice keys, ::rust::Slice values); -void local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); +// Sets `key` to `value` and returns true, or returns false if a setter or Proxy trap threw, +// leaving the exception pending. +bool local_object_set_property(Isolate* isolate, Local& object, ::rust::Str key, Local value); // Stores in `has` whether `key` is in the object, and returns true, or returns false if a Proxy // trap threw, leaving the exception pending. bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::Str key, bool& has); @@ -225,8 +224,9 @@ Local local_new_array(Isolate* isolate, size_t length); uint32_t local_array_length(const Local& array); // Returns a new array of `elements`. Runs no JavaScript: no setter on Array.prototype is called. Local array_new_with_elements(Isolate* isolate, ::rust::Slice elements); -Local local_array_get(Isolate* isolate, const Local& array, uint32_t index); -void local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value); +// Sets element `index` to `value` and returns true, or returns false if a setter or Proxy trap +// threw, leaving the exception pending. +bool local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value); // Appends the elements to `result` and returns true, or returns false with `result` empty if a // getter or Proxy trap on an element threw, leaving the exception pending. bool local_array_iterate(Isolate* isolate, Local value, ::rust::Vec& result); diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 92fe8b24888..65c71ebcd87 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -386,14 +386,14 @@ pub mod ffi { keys: &[&str], values: &[Local], ) -> Result; + // Leave what a setter, getter or Proxy trap throws pending; see `call_into_js()`. + // Fallible: the key is interned with KJ_REQUIRE and jsg::check. pub unsafe fn local_object_set_property( isolate: *mut Isolate, object: &mut Local, key: &str, value: Local, - ); - // Leave what a getter or Proxy trap throws pending; see `call_into_js()`. Fallible: - // the key is interned with KJ_REQUIRE and jsg::check. + ) -> Result; pub unsafe fn local_object_has_property( isolate: *mut Isolate, object: &Local, @@ -409,13 +409,13 @@ pub mod ffi { // Local pub unsafe fn local_array_length(array: &Local) -> u32; pub unsafe fn array_new_with_elements(isolate: *mut Isolate, elements: &[Local]) -> Local; - pub unsafe fn local_array_get(isolate: *mut Isolate, array: &Local, index: u32) -> Local; + // Leaves what a setter or Proxy trap throws pending; see `call_into_js()`. pub unsafe fn local_array_set( isolate: *mut Isolate, array: &mut Local, index: u32, value: Local, - ); + ) -> bool; // Leaves what a getter or Proxy trap on an element throws pending; see // `call_into_js()`. Fallible: KJ_REQUIREs the value is an array. pub unsafe fn local_array_iterate( @@ -1785,20 +1785,34 @@ impl<'s> Local<'s, Array> { self.len() == 0 } - /// Sets an element at the given index. + /// Sets element `index` to `value`, like JS `array[index] = value`. /// - /// May run JavaScript (setters on the prototype chain). - pub fn set(self, lock: &mut Lock<'s>, index: usize, value: Local<'s, Value>) { - let mut handle = *self.raw(); - // SAFETY: lock guarantees the isolate is locked; both handles are valid in scope 's. - unsafe { - ffi::local_array_set( - lock.isolate().as_ffi(), - &mut handle, - index as u32, - value.into_ffi(), - ); - } + /// # Errors + /// + /// If a setter on the prototype chain or a Proxy trap throws, or execution is terminating, + /// the exception stays pending and the error is one for which [`Error::is_pending`] is + /// true, as for [`Local::::call`]. + pub fn set( + self, + lock: &mut Lock<'s>, + index: u32, + value: Local<'s, Value>, + ) -> crate::Result<()> { + self.debug_assert_isolate(lock); + value.debug_assert_isolate(lock); + call_into_js(lock, |lock| { + let mut handle = *self.raw(); + // SAFETY: lock guarantees the isolate is locked; both handles are valid in scope 's. + let ok = unsafe { + ffi::local_array_set( + lock.isolate().as_ffi(), + &mut handle, + index, + value.into_ffi(), + ) + }; + Ok(ok.then_some(())) + }) } /// Iterates over array elements using V8's native `Array::Iterate()`. @@ -3112,20 +3126,29 @@ impl Object { } impl<'s> Local<'s, Object> { - /// Sets `key` to `value`. May run JavaScript (setters, proxy traps). - pub fn set(self, lock: &mut Lock<'s>, key: &str, value: Local<'s, Value>) { + /// Sets `key` to `value`, like JS `obj[key] = value`. + /// + /// # Errors + /// + /// If a setter on the prototype chain or a Proxy trap throws, or execution is terminating, + /// the exception stays pending and the error is one for which [`Error::is_pending`] is + /// true, as for [`Local::::call`]. + pub fn set(self, lock: &mut Lock<'s>, key: &str, value: Local<'s, Value>) -> crate::Result<()> { self.debug_assert_isolate(lock); value.debug_assert_isolate(lock); - let mut handle = *self.raw(); - // SAFETY: isolate is valid and locked (guaranteed by Lock); handles are valid in 's. - unsafe { - ffi::local_object_set_property( - lock.isolate().as_ffi(), - &mut handle, - key, - value.into_ffi(), - ); - } + call_into_js(lock, |lock| { + let mut handle = *self.raw(); + // SAFETY: isolate is valid and locked (guaranteed by Lock); handles are valid in 's. + let ok = unsafe { + ffi::local_object_set_property( + lock.isolate().as_ffi(), + &mut handle, + key, + value.into_ffi(), + ) + }?; + Ok(ok.then_some(())) + }) } /// Whether `key` is in the object or its prototype chain. diff --git a/src/rust/node-exceptions/lib-test.rs b/src/rust/node-exceptions/lib-test.rs index 73ca6bd1fb3..90f2e6baae1 100644 --- a/src/rust/node-exceptions/lib-test.rs +++ b/src/rust/node-exceptions/lib-test.rs @@ -27,7 +27,7 @@ fn node_exception_uses_default_message_and_code() { ffi::NodeExceptionCode::ErrFsEisdir, ffi::JsErrorType::Error, None, - ); + )?; let value: v8::Local = err.into(); assert!(value.is_native_error()); assert_eq!( @@ -51,7 +51,7 @@ fn node_exception_uses_explicit_message() { ffi::NodeExceptionCode::ErrFsCpEexist, ffi::JsErrorType::TypeError, Some(b"custom message".as_slice()), - ); + )?; assert_eq!( get_string(lock, err, "message").as_deref(), Some("custom message") @@ -75,7 +75,7 @@ fn uv_exception_formats_default_message_with_path() { None, Some(b"/tmp/missing".as_slice()), None, - ); + )?; assert_eq!( get_string(lock, err, "message").as_deref(), Some("no such file or directory, open '/tmp/missing'") @@ -102,7 +102,7 @@ fn uv_exception_uses_explicit_message_and_dest() { Some(b"File already exists".as_slice()), Some(b"/a".as_slice()), Some(b"/b".as_slice()), - ); + )?; assert_eq!( get_string(lock, err, "message").as_deref(), Some("File already exists") @@ -119,7 +119,7 @@ fn uv_exception_uses_explicit_message_and_dest() { fn uv_exception_unknown_errno() { let harness = Harness::new(); harness.run_in_context(|lock, _ctx| { - let err = create_uv_exception_impl(lock, 12345, "stat", None, None, None); + let err = create_uv_exception_impl(lock, 12345, "stat", None, None, None)?; assert_eq!( get_string(lock, err, "message").as_deref(), Some("unknown error: 12345") @@ -139,7 +139,8 @@ fn uv_exception_non_utf8_path_is_lossy_not_a_panic() { harness.run_in_context(|lock, _ctx| { // 0x80 is a lone continuation byte — invalid UTF-8. let bad_path: &[u8] = b"/tmp/\x80bad"; - let err = create_uv_exception_impl(lock, -libc::ENOENT, "open", None, Some(bad_path), None); + let err = + create_uv_exception_impl(lock, -libc::ENOENT, "open", None, Some(bad_path), None)?; // U+FFFD (the Unicode replacement character) stands in for 0x80. let expected_path = "/tmp/\u{FFFD}bad"; diff --git a/src/rust/node-exceptions/lib.rs b/src/rust/node-exceptions/lib.rs index adafdcc9de5..0620e50c4f0 100644 --- a/src/rust/node-exceptions/lib.rs +++ b/src/rust/node-exceptions/lib.rs @@ -54,7 +54,8 @@ mod ffi { /// property. Returns a `v8::MaybeLocal` handle (the reused /// `jsg::v8::ffi::MaybeLocal` shared struct), which the C++ caller /// reconstitutes via `maybe_local_from_ffi`. It is empty if the Rust code - /// panicked, which leaves an exception pending. + /// panicked or a setter that a script put on the error's prototype chain + /// threw; either leaves an exception pending. /// /// An absent `message` (`kj::none`) means the default message for the /// given `code` is used. `message` is passed as raw bytes because @@ -167,13 +168,13 @@ fn create_node_exception_impl<'s>( code: ffi::NodeExceptionCode, error_type: ffi::JsErrorType, message: Option<&[u8]>, -) -> v8::Local<'s, v8::Object> { +) -> jsg::Result> { let message = message.unwrap_or_else(|| node_default_message(code).as_bytes()); let err = create_js_error(lock, error_type, message); // The "code" value is always a fixed ASCII enumerator name. let code_value = node_code_name(code).to_local(lock); - err.set(lock, "code", code_value); - err + err.set(lock, "code", code_value)?; + Ok(err) } // ====================================================================================== @@ -246,7 +247,7 @@ fn create_uv_exception_impl<'s>( message: Option<&[u8]>, path: Option<&[u8]>, dest: Option<&[u8]>, -) -> v8::Local<'s, v8::Object> { +) -> jsg::Result> { debug_assert!(!syscall.is_empty(), "syscall must not be null"); // Format the message to match Node.js format: @@ -273,20 +274,20 @@ fn create_uv_exception_impl<'s>( // `syscall` and the `code` name are always fixed ASCII strings. let syscall_value = syscall.to_local(lock); - obj.set(lock, "syscall", syscall_value); + obj.set(lock, "syscall", syscall_value)?; let code_value = uv_err_name(errorno).to_local(lock); - obj.set(lock, "code", code_value); + obj.set(lock, "code", code_value)?; if let Some(path) = path { let path_value = bytes_to_value(lock, path); - obj.set(lock, "path", path_value); + obj.set(lock, "path", path_value)?; } if let Some(dest) = dest { let dest_value = bytes_to_value(lock, dest); - obj.set(lock, "dest", dest_value); + obj.set(lock, "dest", dest_value)?; } - obj + Ok(obj) } // ====================================================================================== @@ -295,7 +296,8 @@ fn create_uv_exception_impl<'s>( // These are thin adapters over the `*_impl` functions: they convert the nullable // `KjMaybe` arguments to `Option` and run the function through // `v8::run_ffi_callback`, which enters the caller's current scope and returns the -// handle for the C++ caller, or an empty one if it panicked. +// handle for the C++ caller, or an empty one, with an exception pending, if it failed or +// panicked. /// # Safety /// `isolate` must be a valid pointer to a locked `v8::Isolate` with an entered context, @@ -313,9 +315,8 @@ unsafe fn create_node_exception( // SAFETY: forwarded from the caller (C++ exception helpers). unsafe { v8::run_ffi_callback(isolate, |lock| { - let exception: v8::Local = - create_node_exception_impl(lock, code, error_type, message.into()).into(); - Ok::<_, jsg::Error>(exception) + create_node_exception_impl(lock, code, error_type, message.into()) + .map(v8::Local::::from) }) } } @@ -338,7 +339,7 @@ unsafe fn create_uv_exception( // SAFETY: forwarded from the caller (C++ exception helpers). unsafe { v8::run_ffi_callback(isolate, |lock| { - let exception: v8::Local = create_uv_exception_impl( + create_uv_exception_impl( lock, errorno, syscall, @@ -346,8 +347,7 @@ unsafe fn create_uv_exception( path.into(), dest.into(), ) - .into(); - Ok::<_, jsg::Error>(exception) + .map(v8::Local::::from) }) } } diff --git a/src/workerd/api/node/exceptions.c++ b/src/workerd/api/node/exceptions.c++ index 0e3233ab95e..347a5dfefff 100644 --- a/src/workerd/api/node/exceptions.c++ +++ b/src/workerd/api/node/exceptions.c++ @@ -9,7 +9,7 @@ namespace workerd::api::node { namespace rust_exc = ::workerd::rust::node_exceptions; -// An empty result means the Rust code panicked and threw. +// An empty result means the Rust code failed or panicked, leaving an exception pending. static jsg::JsValue fromRust(::workerd::rust::jsg::MaybeLocal value) { v8::Local local; if (!::workerd::rust::jsg::maybe_local_from_ffi(kj::mv(value)).ToLocal(&local)) { From 88bb87aa5e5e615178af2103d8bc9bbc48570c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 25 Oct 2026 20:43:01 +0100 Subject: [PATCH 10/23] Fix Rust JSG docs on fallible setters and key interning The FFI comments for object_new_with_data_properties and local_object_set_property said keys are interned with jsg::check, which the interning helpers no longer use; they now say what can fail. The review checklist and the README's first safety rule list Object::set and Array::set among the calls that run JavaScript. --- docs/reference/rust-review-checklist.md | 4 ++-- src/rust/jsg/README.md | 2 +- src/rust/jsg/v8.rs | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/reference/rust-review-checklist.md b/docs/reference/rust-review-checklist.md index fa4040ea0aa..50b383b19e0 100644 --- a/docs/reference/rust-review-checklist.md +++ b/docs/reference/rust-review-checklist.md @@ -119,8 +119,8 @@ some violations, and the README says which. Reviewers flag these: - A shim, other than the `isolate_throw_*` shims, that calls `isolate->ThrowException` with no `IsExecutionTerminating()` check first. `isolate_throw_*` already check, and so do `Lock::throw_*` and `fast_api_throw_*`, which call them. -- A `jsg::Error` from a call that ran JavaScript (`Function::call`, `Object::get`/`has`, - `Array::iterate`, a coercion) that is discarded with `.ok()` or `unwrap_or*`, or replaced with +- A `jsg::Error` from a call that ran JavaScript (`Function::call`, `Object::get`/`has`/`set`, + `Array::set`/`iterate`, a coercion) that is discarded with `.ok()` or `unwrap_or*`, or replaced with `map_err` or a custom `Into`. It may be the termination or a pending exception; it should be passed on with `?`, or caught with `Lock::try_catch`. - An empty result from an FFI shim that is treated as "terminating" without checking. diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 6c99f40693b..6e697f8b1e2 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -41,7 +41,7 @@ keeps running. and the fast API error path are safe. A shim that calls `isolate->ThrowException` itself must check first. - Don't discard a `jsg::Error` from a call that ran JavaScript (`Function::call`, `Object::get`, - a coercion). It may be the termination, or a JavaScript exception that is still pending (see + `Object::set`, a coercion). It may be the termination, or a JavaScript exception that is still pending (see [Errors from JavaScript](#errors-from-javascript)). Pass it on with `?` instead of using `.ok()`, `unwrap_or`, or `map_err` to a different error. To handle the exception, use `Lock::try_catch`. diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 54fc7d779f0..77a620de444 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -390,15 +390,16 @@ pub mod ffi { ) -> Result; // Local - // Fallible: the keys are interned with KJ_REQUIRE and jsg::check, and KJ_REQUIREs as - // many keys as values. + // Fallible: interning a key fails if it is longer than V8's string length limit or V8 + // fails to create the string, and there must be as many keys as values. pub unsafe fn object_new_with_data_properties( isolate: *mut Isolate, keys: &[&str], values: &[Local], ) -> Result; // Leave what a setter, getter or Proxy trap throws pending; see `call_into_js()`. - // Fallible: the key is interned with KJ_REQUIRE and jsg::check. + // Fallible: interning the key fails if it is longer than V8's string length limit or V8 + // fails to create the string. pub unsafe fn local_object_set_property( isolate: *mut Isolate, object: &mut Local, From c84c1836f606b9167ddac3f65803738f9564f970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 26 Oct 2026 10:54:08 +0000 Subject: [PATCH 11/23] Make Rust JSG collection GC tests depend on tracing A jsg::Rc is strong until a GC visit makes it traced, so a resource field that nothing traces leaks its children but never frees them early. The collection GC tests checked only that the children of a reachable parent survive, which they do whether or not the field is traced: with tracing disabled for Vec, HashMap, BTreeMap, HashSet, BTreeSet or Cell, none of them failed. Each child now keeps its parent's wrapper, which makes a cycle that GC can collect only by tracing the parent's field, so the tests' checks that the parent and children are collected fail if the field is not traced. --- src/rust/jsg-test/tests/collections_gc.rs | 94 ++++++++++++++++------- 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/src/rust/jsg-test/tests/collections_gc.rs b/src/rust/jsg-test/tests/collections_gc.rs index a04d9b526ca..5e1fbc96f00 100644 --- a/src/rust/jsg-test/tests/collections_gc.rs +++ b/src/rust/jsg-test/tests/collections_gc.rs @@ -5,17 +5,24 @@ //! GC tracing tests for collection fields in `#[jsg_resource]` structs. //! //! Verifies that `Vec>`, `HashMap>`, `BTreeMap>`, -//! `HashSet>`, `BTreeSet>`, and their `Cell<…>` variants all -//! produce correct GC trace edges so that children are kept alive as long as the parent -//! is reachable, and collected once the parent is collected. +//! `HashSet>`, `BTreeSet>`, and their `Cell<…>` variants all produce +//! correct GC trace edges so that children are kept alive as long as the parent is reachable, +//! and collected once the parent is collected. +//! +//! A `jsg::Rc` is strong until a GC visit makes it traced, so a field that is not traced only +//! leaks: it never frees a child early. Each child therefore keeps its parent's wrapper, which +//! makes a cycle that GC can collect only by tracing the parent's field. //! //! Each test follows the same pattern: -//! 1. Wrap the parent for JavaScript (gives it a JS wrapper held by a context global). +//! 1. Wrap the parent for JavaScript (gives it a JS wrapper held by a context global), and have +//! each child keep that wrapper (`keep_owner`). //! 2. Drop all Rust `Rc` handles — the parent is now only alive via the JS wrapper. -//! 3. Run a minor + major GC while the global is reachable → children must survive. +//! 3. Run a minor + major GC while the global is reachable → children must survive. The GC also +//! makes the references traced. //! 4. Move to a fresh context (global is gone) → GC must collect parent and all children. use std::cell::Cell; +use std::cell::RefCell; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; @@ -36,6 +43,27 @@ static LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); #[jsg_resource] struct Leaf { pub value: u32, + /// The wrapper of the leaf's parent, which makes a cycle through the parent's field. + owner: RefCell>>, +} + +fn leaf(value: u32) -> jsg::Rc { + jsg::Rc::new(Leaf { + value, + owner: RefCell::new(None), + }) +} + +/// Has each of `leaves` keep `owner`, the wrapper of their parent. +fn keep_owner<'a>( + lock: &mut jsg::Lock<'_>, + owner: jsg::v8::Local<'_, jsg::v8::Value>, + leaves: impl IntoIterator>, +) { + for leaf in leaves { + let owner = jsg::v8::TracedGlobal::new(lock, owner); + *leaf.owner.borrow_mut() = Some(owner); + } } impl Drop for Leaf { @@ -80,14 +108,15 @@ fn vec_rc_children_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let c1 = jsg::Rc::new(Leaf { value: 1 }); - let c2 = jsg::Rc::new(Leaf { value: 2 }); - let c3 = jsg::Rc::new(Leaf { value: 3 }); + let c1 = leaf(1); + let c2 = leaf(2); + let c3 = leaf(3); let parent = jsg::Rc::new(VecParent { children: vec![c1.clone(), c2.clone(), c3.clone()], }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&c1, &c2, &c3]); ctx.set_global(lock, "parent", wrapped); // Drop all Rust refs. @@ -164,8 +193,8 @@ fn hashmap_rc_values_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let a = jsg::Rc::new(Leaf { value: 10 }); - let b = jsg::Rc::new(Leaf { value: 20 }); + let a = leaf(10); + let b = leaf(20); let mut map = HashMap::new(); map.insert("a".to_owned(), a.clone()); @@ -173,6 +202,7 @@ fn hashmap_rc_values_kept_alive_through_gc() { let parent = jsg::Rc::new(HashMapParent { children: map }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&a, &b]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(a); @@ -247,8 +277,8 @@ fn btreemap_rc_values_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let x = jsg::Rc::new(Leaf { value: 100 }); - let y = jsg::Rc::new(Leaf { value: 200 }); + let x = leaf(100); + let y = leaf(200); let mut map = BTreeMap::new(); map.insert("x".to_owned(), x.clone()); @@ -256,6 +286,7 @@ fn btreemap_rc_values_kept_alive_through_gc() { let parent = jsg::Rc::new(BTreeMapParent { children: map }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&x, &y]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(x); @@ -337,8 +368,8 @@ fn hashset_rc_elements_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let p = jsg::Rc::new(Leaf { value: 1 }); - let q = jsg::Rc::new(Leaf { value: 2 }); + let p = leaf(1); + let q = leaf(2); let mut set = HashSet::new(); set.insert(p.clone()); @@ -346,6 +377,7 @@ fn hashset_rc_elements_kept_alive_through_gc() { let parent = jsg::Rc::new(HashSetParent { children: set }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&p, &q]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(p); @@ -425,8 +457,8 @@ fn btreeset_rc_elements_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let r = jsg::Rc::new(Leaf { value: 7 }); - let s = jsg::Rc::new(Leaf { value: 8 }); + let r = leaf(7); + let s = leaf(8); let mut set = BTreeSet::new(); set.insert(r.clone()); @@ -434,6 +466,7 @@ fn btreeset_rc_elements_kept_alive_through_gc() { let parent = jsg::Rc::new(BTreeSetParent { children: set }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&r, &s]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(r); @@ -508,13 +541,14 @@ fn cell_vec_rc_children_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let c1 = jsg::Rc::new(Leaf { value: 11 }); - let c2 = jsg::Rc::new(Leaf { value: 22 }); + let c1 = leaf(11); + let c2 = leaf(22); let parent = jsg::Rc::new(CellVecParent { children: Cell::new(vec![c1.clone(), c2.clone()]), }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&c1, &c2]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(c1); @@ -589,7 +623,7 @@ fn cell_hashmap_rc_values_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let m = jsg::Rc::new(Leaf { value: 99 }); + let m = leaf(99); let mut map = HashMap::new(); map.insert("m".to_owned(), m.clone()); @@ -598,6 +632,7 @@ fn cell_hashmap_rc_values_kept_alive_through_gc() { children: Cell::new(map), }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&m]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(m); @@ -647,9 +682,9 @@ fn mixed_collection_fields_all_traced() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let vec_child = jsg::Rc::new(Leaf { value: 1 }); - let map_child = jsg::Rc::new(Leaf { value: 2 }); - let direct_child = jsg::Rc::new(Leaf { value: 3 }); + let vec_child = leaf(1); + let map_child = leaf(2); + let direct_child = leaf(3); let mut named = HashMap::new(); named.insert("key".to_owned(), map_child.clone()); @@ -660,6 +695,7 @@ fn mixed_collection_fields_all_traced() { direct: direct_child.clone(), }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&vec_child, &map_child, &direct_child]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(vec_child); @@ -695,14 +731,15 @@ fn vec_rc_child_kept_alive_by_parent_after_rust_ref_dropped() { let harness = crate::Harness::new(); harness.run_in_context(|lock, _ctx| { - let child = jsg::Rc::new(Leaf { value: 42 }); + let child = leaf(42); let parent = jsg::Rc::new(VecParent { children: vec![child.clone()], }); // Wrap parent so it gets a JS object. - let _ = parent.clone().to_js(lock); + let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&child]); // Drop the child Rust ref — vec inside parent is the only holder. std::mem::drop(child); @@ -753,7 +790,7 @@ fn hashmap_integer_key_rc_values_traced() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let v = jsg::Rc::new(Leaf { value: 55 }); + let v = leaf(55); let mut map = HashMap::new(); map.insert(0u32, v.clone()); @@ -761,6 +798,7 @@ fn hashmap_integer_key_rc_values_traced() { let parent = jsg::Rc::new(IntKeyMapParent { children: map }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&v]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(v); @@ -828,7 +866,7 @@ fn trace_delegation_child_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let child = jsg::Rc::new(Leaf { value: 77 }); + let child = leaf(77); let parent = jsg::Rc::new(TraceDelegationParent { data: PrivateData { @@ -836,6 +874,7 @@ fn trace_delegation_child_kept_alive_through_gc() { }, }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&child]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(child); @@ -894,12 +933,13 @@ fn custom_trace_child_kept_alive_through_gc() { let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let child = jsg::Rc::new(Leaf { value: 88 }); + let child = leaf(88); let parent = jsg::Rc::new(CustomTraceParent { child: child.clone(), }); let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&child]); ctx.set_global(lock, "parent", wrapped); std::mem::drop(child); From da095b33aec6de812fd50d8d238e817cb4b68dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 26 Oct 2026 11:17:29 +0000 Subject: [PATCH 12/23] Add jsg::Dict to Rust JSG Rust JSG had no counterpart to C++ JSG's jsg::Dict, Web IDL's record: a plain object used as a map from strings to values. jsg::Dict holds its entries in order, in a Vec, and converts like C++ JSG's DictWrapper. From JavaScript, it takes any object other than an array and reads its own enumerable properties that are not symbols, as Object.keys() lists them, converting each value before reading the next. A value that is not a V is a TypeError naming its entry, with C++ JSG's message, and what a Proxy trap, getter or conversion throws reaches the caller unchanged. To JavaScript, it is a new plain object. Local gains keys(), which lists properties that way, and get_by_name(), which reads a property by a string or symbol handle. A Dict reads each value with the name V8 listed, so even a key that does not convert losslessly to a Rust string gives its value. Converting a Dict to JavaScript sets its properties with set(), so what a setter throws stays pending, as in C++ JSG. --- src/rust/jsg-macros/README.md | 2 +- src/rust/jsg-test/tests/coercion_safety.rs | 15 +- src/rust/jsg-test/tests/collections_gc.rs | 67 ++- src/rust/jsg-test/tests/dict.rs | 455 +++++++++++++++++++++ src/rust/jsg-test/tests/mod.rs | 1 + src/rust/jsg/README.md | 7 + src/rust/jsg/dict.rs | 76 ++++ src/rust/jsg/ffi.c++ | 33 ++ src/rust/jsg/ffi.h | 8 + src/rust/jsg/lib.rs | 2 + src/rust/jsg/v8.rs | 68 +++ src/rust/jsg/wrappable.rs | 104 ++++- 12 files changed, 830 insertions(+), 8 deletions(-) create mode 100644 src/rust/jsg-test/tests/dict.rs create mode 100644 src/rust/jsg/dict.rs diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index 2186ff950ca..8c65c587b62 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -428,7 +428,7 @@ use no-op `Traced` impls; containers/wrappers recurse to inner values. | `jsg::Weak` | **Not traced** — does not keep the target alive | | `jsg::v8::Global` | **Not allowed** — no `Traced` impl; use `jsg::v8::TracedGlobal` | | `Option` / `jsg::Nullable` | Delegates to `T` when present | -| `Vec`, `HashMap`, `BTreeMap`, `HashSet`, `BTreeSet` | Delegates recursively to contained values | +| `Vec`, `jsg::Dict`, `HashMap`, `BTreeMap`, `HashSet`, `BTreeSet` | Delegates recursively to contained values | | `Cell` / `std::cell::Cell` | Delegates via `as_ptr()` read (safe under single-threaded, non-reentrant GC tracing) | | `RefCell` | Delegates via `try_borrow()`; skipped while mutably borrowed (debug builds abort if `T` can hold GC references) | | Plain data / primitives / `#[jsg_struct]` types | No-op `Traced` | diff --git a/src/rust/jsg-test/tests/coercion_safety.rs b/src/rust/jsg-test/tests/coercion_safety.rs index eddc8e7051f..83b66dd068f 100644 --- a/src/rust/jsg-test/tests/coercion_safety.rs +++ b/src/rust/jsg-test/tests/coercion_safety.rs @@ -673,10 +673,13 @@ enum Conversion { Iterate, Set, SetElement, + DictKeys, + DictValue, + DictValueCoercion, } impl Conversion { - const ALL: [Self; 7] = [ + const ALL: [Self; 10] = [ Self::String, Self::Number, Self::Get, @@ -684,6 +687,9 @@ impl Conversion { Self::Iterate, Self::Set, Self::SetElement, + Self::DictKeys, + Self::DictValue, + Self::DictValueCoercion, ]; /// Converts `value`, reads or sets its property `x`, or sets its element 0. @@ -712,6 +718,9 @@ impl Conversion { .unwrap() .set(lock, 0, undefined) } + Self::DictKeys | Self::DictValue | Self::DictValueCoercion => { + jsg::Dict::::from_js(lock, value).map(drop) + } } } @@ -720,7 +729,7 @@ impl Conversion { match self { Self::String => format!("({{ toString: {f} }})"), Self::Number => format!("({{ valueOf: {f} }})"), - Self::Get => format!("({{ get x() {{ return ({f})(); }} }})"), + Self::Get | Self::DictValue => format!("({{ get x() {{ return ({f})(); }} }})"), Self::Has => format!("new Proxy({{}}, {{ has: {f} }})"), Self::Iterate => { format!( @@ -733,6 +742,8 @@ impl Conversion { "(() => {{ const a = [0]; Object.defineProperty(a, 0, {{ set: {f} }}); return a; }})()" ) } + Self::DictKeys => format!("new Proxy({{}}, {{ ownKeys: {f} }})"), + Self::DictValueCoercion => format!("({{ x: {{ valueOf: {f} }} }})"), } } } diff --git a/src/rust/jsg-test/tests/collections_gc.rs b/src/rust/jsg-test/tests/collections_gc.rs index 5e1fbc96f00..93497cc2ac7 100644 --- a/src/rust/jsg-test/tests/collections_gc.rs +++ b/src/rust/jsg-test/tests/collections_gc.rs @@ -4,10 +4,10 @@ //! GC tracing tests for collection fields in `#[jsg_resource]` structs. //! -//! Verifies that `Vec>`, `HashMap>`, `BTreeMap>`, -//! `HashSet>`, `BTreeSet>`, and their `Cell<…>` variants all produce -//! correct GC trace edges so that children are kept alive as long as the parent is reachable, -//! and collected once the parent is collected. +//! Verifies that `Vec>`, `jsg::Dict>`, `HashMap>`, +//! `BTreeMap>`, `HashSet>`, `BTreeSet>`, and their `Cell<…>` +//! variants all produce correct GC trace edges so that children are kept alive as long as the +//! parent is reachable, and collected once the parent is collected. //! //! A `jsg::Rc` is strong until a GC visit makes it traced, so a field that is not traced only //! leaks: it never frees a child early. Each child therefore keeps its parent's wrapper, which @@ -513,6 +513,65 @@ fn btreeset_rc_empty_does_not_crash_during_gc() { }); } +// ============================================================================= +// jsg::Dict> +// ============================================================================= + +static DICT_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct DictParent { + pub children: jsg::Dict>, +} + +impl Drop for DictParent { + fn drop(&mut self) { + DICT_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl DictParent {} + +/// `jsg::Dict>` values are kept alive while parent JS wrapper is reachable. +#[test] +fn dict_rc_values_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + DICT_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let a = leaf(1); + let b = leaf(2); + + let parent = jsg::Rc::new(DictParent { + children: jsg::Dict::from(vec![ + ("a".to_owned(), a.clone()), + ("b".to_owned(), b.clone()), + ]), + }); + let wrapped = parent.clone().to_js(lock); + keep_owner(lock, wrapped, [&a, &b]); + ctx.set_global(lock, "parent", wrapped); + + std::mem::drop(a); + std::mem::drop(b); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(DICT_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(DICT_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 2); + Ok(()) + }); +} + // ============================================================================= // Cell>> // ============================================================================= diff --git a/src/rust/jsg-test/tests/dict.rs b/src/rust/jsg-test/tests/dict.rs new file mode 100644 index 00000000000..b30580060b1 --- /dev/null +++ b/src/rust/jsg-test/tests/dict.rs @@ -0,0 +1,455 @@ +// 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::Dict` converts like C++ JSG's `jsg::Dict` (`DictWrapper` in `jsg/value.h`), which +//! is Web IDL's `record`. + +use jsg::Dict; +use jsg::FromJS; +use jsg::Number; +use jsg::v8; +use jsg_macros::jsg_method; +use jsg_macros::jsg_property; +use jsg_macros::jsg_resource; + +#[jsg_resource] +struct Records { + headers: Dict, +} + +#[jsg_resource] +impl Records { + /// Describes the entries, in order, as `key=value` pairs. + #[jsg_method] + pub fn describe(&self, dict: Dict) -> String { + dict.into_iter() + .map(|(key, value)| format!("{key}={}", value.value())) + .collect::>() + .join(",") + } + + #[jsg_method] + pub fn describe_strings(&self, dict: Dict) -> String { + dict.into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(",") + } + + #[jsg_method] + pub fn count_present(&self, dict: Dict>) -> Number { + Number::from(u32::try_from(dict.into_iter().filter(|(_, v)| v.is_some()).count()).unwrap()) + } + + #[jsg_method] + pub fn total_lengths(&self, dict: Dict>) -> Number { + Number::from(u32::try_from(dict.into_iter().map(|(_, v)| v.len()).sum::()).unwrap()) + } + + #[jsg_method] + pub fn sum_nested(&self, dict: Dict>) -> Number { + Number::new( + dict.into_iter() + .flat_map(|(_, inner)| inner.into_iter().map(|(_, v)| v.value())) + .sum(), + ) + } + + #[jsg_method] + pub fn count_functions<'s>(&self, dict: Dict>) -> Number { + Number::from( + u32::try_from(dict.into_iter().filter(|(_, v)| v.is_function()).count()).unwrap(), + ) + } + + #[jsg_method] + pub fn lookup(&self, dict: Dict, key: String) -> Option { + dict.get(&key).cloned() + } + + #[jsg_method] + pub fn make(&self, keys: Vec) -> Dict { + keys.into_iter() + .zip(1..) + .map(|(key, n)| (key, Number::from(n))) + .collect() + } + + /// Is terminated and then returns a `Dict`, which is converted after the termination. + #[jsg_method] + pub fn terminate_then_make(&self, lock: &mut jsg::Lock<'_>) -> Dict { + crate::Harness::terminate_and_deliver(lock); + self.make(vec!["a".to_owned()]) + } + + #[jsg_property(instance, readonly)] + pub fn get_headers(&self) -> Dict { + self.headers.clone() + } +} + +fn with_records(f: impl for<'s> FnOnce(&mut jsg::Lock<'s>, &crate::EvalContext<'_>)) { + crate::Harness::new().run_in_context(|lock, ctx| { + let records = jsg::Rc::new(Records { + headers: Dict::from(vec![ + ("content-type".to_owned(), "text/plain".to_owned()), + ("x-id".to_owned(), "42".to_owned()), + ]), + }); + ctx.set_global(lock, "records", records); + f(lock, ctx); + Ok(()) + }); +} + +fn expect_eval(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str, expected: &str) { + let result: String = ctx.eval(lock, code).unwrap(); + assert_eq!(result, expected, "{code}"); +} + +/// Evaluates `code`, which calls a method, and returns what it threw as `name: message`. +fn thrown(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str) -> String { + let result: String = ctx + .eval( + lock, + &format!("try {{ {code}; 'nothing' }} catch (e) {{ `${{e.name}}: ${{e.message}}` }}"), + ) + .unwrap(); + result +} + +// Like Object.keys(): integer-like keys first in ascending order, then the other strings in +// the order they were added. +#[test] +fn entries_are_in_property_order() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "records.describe({ b: 1, a: 2, 2: 3, 1: 4 })", + "1=4,2=3,b=1,a=2", + ); + expect_eval(lock, ctx, "records.describe({})", ""); + }); +} + +#[test] +fn only_own_enumerable_string_keys_are_entries() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "const o = Object.create({ inherited: 1 }, { + hidden: { value: 2, enumerable: false }, + shown: { value: 3, enumerable: true }, + }); + o[Symbol('s')] = 4; + records.describe(o)", + "shown=3", + ); + }); +} + +#[test] +fn any_object_other_than_an_array_is_a_dict() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "const f = () => {}; f.x = 1; + class C { constructor() { this.y = 2; } } + [records.describe(f), records.describe(new C()), + records.describe(new Proxy({ z: 3 }, {})), records.describe(new Date(0))].join(' ')", + "x=1 y=2 z=3 ", + ); + }); +} + +// The error matches C++ JSG's for an argument of the wrong type. +#[test] +fn values_other_than_objects_are_not_dicts() { + with_records(|lock, ctx| { + for value in [ + "[1]", + "null", + "undefined", + "1", + "'a'", + "Symbol()", + "1n", + "true", + ] { + assert_eq!( + thrown(lock, ctx, &format!("records.describe({value})")), + "TypeError: Failed to execute 'describe' on 'Records': parameter 1 is not of type \ + 'object'.", + "{value}" + ); + } + let value = ctx.eval_raw(lock, "[1]").unwrap(); + let err = Dict::::from_js(lock, value).unwrap_err(); + assert_eq!(err.message, "Expected object but got array"); + let value = ctx.eval_raw(lock, "1").unwrap(); + let err = Dict::::from_js(lock, value).unwrap_err(); + assert_eq!(err.message, "Expected object but got number"); + }); +} + +// Values convert as the value type does, coercing like an argument would. +#[test] +fn values_convert_as_their_type_does() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "records.describeStrings({ a: 1, b: true, c: null, d: { toString() { return 'x'; } } })", + "a=1,b=true,c=null,d=x", + ); + expect_eval( + lock, + ctx, + "records.describe({ a: '2', b: null })", + "a=2,b=0", + ); + expect_eval( + lock, + ctx, + "String(records.countPresent({ a: 1, b: undefined, c: null }))", + "2", + ); + expect_eval( + lock, + ctx, + "String(records.sumNested({ a: { x: 1, y: 2 }, b: { z: 3 } }))", + "6", + ); + expect_eval( + lock, + ctx, + "String(records.countFunctions({ a: () => {}, b: 1, c: class {} }))", + "2", + ); + }); +} + +// As in C++ JSG, a value that is not of the value type names its entry, and an error from +// converting a value passes through. +#[test] +fn value_errors_name_their_entry() { + with_records(|lock, ctx| { + assert_eq!( + thrown(lock, ctx, "records.totalLengths({ a: [1], b: 2 })"), + "TypeError: Incorrect type for map entry 'b': the provided value is not of type \ + 'Array'." + ); + assert_eq!( + thrown(lock, ctx, "records.sumNested({ a: { x: 1 }, b: [] })"), + "TypeError: Incorrect type for map entry 'b': the provided value is not of type \ + 'object'." + ); + assert_eq!( + thrown(lock, ctx, "records.describe({ a: Symbol() })"), + "TypeError: Cannot convert a Symbol value to a number" + ); + }); +} + +// Each value is read and then converted before the next one is read, as in C++ JSG. +#[test] +fn values_are_read_and_converted_in_turn() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "const log = []; + const value = (name) => ({ valueOf() { log.push('convert ' + name); return 1; } }); + records.describe({ + get a() { log.push('get a'); return value('a'); }, + get b() { log.push('get b'); return value('b'); }, + }); + log.join(', ')", + "get a, convert a, get b, convert b", + ); + }); +} + +// What a Proxy trap, a getter or a conversion throws reaches the caller unchanged, and nothing +// after it runs. +#[test] +fn exceptions_pass_through_unchanged() { + with_records(|lock, ctx| { + for (name, value) in [ + ("ownKeys", "new Proxy({}, { ownKeys() { throw thrown; } })"), + ( + "getOwnPropertyDescriptor", + "new Proxy({ a: 1 }, { getOwnPropertyDescriptor() { throw thrown; } })", + ), + ( + "getter", + "({ get a() { throw thrown; }, get b() { ran = true; } })", + ), + ( + "valueOf", + "({ a: { valueOf() { throw thrown; } }, get b() { ran = true; } })", + ), + ] { + let result: bool = ctx + .eval( + lock, + &format!( + "globalThis.thrown = new RangeError('{name}'); globalThis.ran = false; + {{ + let caught; + try {{ records.describe({value}); }} catch (e) {{ caught = e; }} + caught === thrown && !ran + }}" + ), + ) + .unwrap(); + assert!(result, "{name}"); + } + }); +} + +// A value is read with the name V8 listed, so even a key that does not survive conversion to a +// Rust string gives its value: a lone surrogate becomes U+FFFD, as in any string. +#[test] +fn values_are_read_by_the_listed_name() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "records.describe({ '\\ud800': 1 })", + "\u{fffd}=1", + ); + }); +} + +#[test] +fn dicts_convert_to_plain_objects() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "const d = records.make(['b', 'a', '1']); + [Object.getPrototypeOf(d) === Object.prototype, JSON.stringify(d)].join(' ')", + "true {\"1\":3,\"b\":1,\"a\":2}", + ); + expect_eval(lock, ctx, "JSON.stringify(records.make([]))", "{}"); + // A later entry with the same key overwrites an earlier one. + expect_eval( + lock, + ctx, + "JSON.stringify(records.make(['a', 'a']))", + "{\"a\":2}", + ); + expect_eval( + lock, + ctx, + "JSON.stringify(records.headers)", + "{\"content-type\":\"text/plain\",\"x-id\":\"42\"}", + ); + expect_eval( + lock, + ctx, + "String(records.headers === records.headers)", + "false", + ); + }); +} + +// Like C++ JSG, converting a Dict runs a setter that Object.prototype has for its key. What +// the setter throws reaches the caller unchanged, and no later entry is set. +#[test] +fn converting_a_dict_runs_inherited_setters() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "const seen = []; + Object.defineProperty(Object.prototype, 'a', { + set(v) { seen.push(v); }, configurable: true, + }); + const d = records.make(['a', 'b']); + delete Object.prototype.a; + `${seen} ${JSON.stringify(d)}`", + "1 {\"b\":2}", + ); + let result: bool = ctx + .eval( + lock, + "globalThis.thrown = new RangeError('setter'); globalThis.ran = false; + Object.defineProperty(Object.prototype, 'a', { + set(v) { throw thrown; }, configurable: true, + }); + Object.defineProperty(Object.prototype, 'b', { + set(v) { ran = true; }, configurable: true, + }); + let caught; + try { records.make(['a', 'b']); } catch (e) { caught = e; } + delete Object.prototype.a; + delete Object.prototype.b; + caught === thrown && !ran", + ) + .unwrap(); + assert!(result); + }); +} + +// Converting a Dict while execution is terminating keeps the termination: JavaScript neither +// catches it nor runs on. +#[test] +fn converting_a_dict_while_terminating_keeps_the_termination() { + with_records(|lock, ctx| { + let result = ctx.eval_raw( + lock, + "globalThis.after = false; + try { records.terminateThenMake(); } catch (e) {} + globalThis.after = true;", + ); + assert!(result.is_err(), "not terminated: {result:?}"); + assert!(!ctx.eval::(lock, "globalThis.after").unwrap()); + }); +} + +#[test] +fn dicts_round_trip() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "records.describe(records.make(['x', 'y']))", + "x=1,y=2", + ); + expect_eval( + lock, + ctx, + "records.lookup(records.headers, 'x-id') + ' ' + records.lookup({}, 'x-id')", + "42 undefined", + ); + }); +} + +#[test] +fn get_by_name_and_keys() { + with_records(|lock, ctx| { + let value = ctx + .eval_raw(lock, "({ b: 1, [Symbol.for('s')]: 2, a: 3 })") + .unwrap(); + let object = v8::Local::::try_from(value).unwrap(); + let keys = object.keys(lock).unwrap(); + let names: Vec = keys + .iter() + .map(|key| String::from_js(lock, (*key).into()).unwrap()) + .collect(); + assert_eq!(names, ["b", "a"]); + let value = object.get_by_name(lock, keys[1].into()).unwrap(); + assert_eq!(Number::from_js(lock, value).unwrap(), Number::new(3.0)); + + let symbol = ctx.eval_raw(lock, "Symbol.for('s')").unwrap(); + let symbol = v8::Local::::try_from(symbol).unwrap(); + let value = object.get_by_name(lock, symbol.into()).unwrap(); + assert_eq!(Number::from_js(lock, value).unwrap(), Number::new(2.0)); + }); +} diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index 4f484a311c8..4910c57cbfa 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -8,6 +8,7 @@ mod buffer_types; mod coercion_safety; mod collections_gc; mod date_bigint; +mod dict; mod eval; mod fast_api; mod function; diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 6e697f8b1e2..118f8595eb4 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -463,6 +463,13 @@ but not return values: `kj::Date`. Like C++ JSG's `kj::Date`. - `&[T]` and typed-array slices such as `&[u8]`. +`jsg::Dict` is C++ JSG's `jsg::Dict`, Web IDL's `record`: a plain object used +as a map from strings to values. It holds its entries in order, in a `Vec<(String, V)>`. From +JavaScript, it takes any object other than an array, and reads its own enumerable properties +that are not symbols, as `Object.keys()` lists them; a value that is not a `V` is a `TypeError` +naming its key. To JavaScript, it is a new plain object. `Local::keys()` and +`get_by_name()` list and read properties the same way. + ## 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/dict.rs b/src/rust/jsg/dict.rs new file mode 100644 index 00000000000..ebac4c55807 --- /dev/null +++ b/src/rust/jsg/dict.rs @@ -0,0 +1,76 @@ +// 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 JavaScript object used as a map from strings to values of type `V`: Web IDL's +/// `record`, and C++ JSG's `jsg::Dict`. +/// +/// Like C++ JSG's, it keeps the entries in a `Vec`, in order. +/// +/// - From JavaScript, it takes any object other than an array, and reads the object's own +/// enumerable properties that are not symbols, in property order, as `Object.keys()` lists +/// them. Each value is read and converted before the next one is read. A value of the wrong +/// type is a `TypeError` that names its key. +/// - To JavaScript, it is a new plain object with a property for each entry, set in order, so +/// that a later entry with the same key overwrites an earlier one. +/// +/// # Examples +/// +/// ```ignore +/// use jsg::Dict; +/// +/// #[jsg_method] +/// pub fn set_headers(&self, headers: Dict) { +/// for (name, value) in headers { +/// // ... +/// } +/// } +/// +/// #[jsg_method] +/// pub fn get_counts(&self) -> Dict { +/// Dict::from(vec![("hits".to_owned(), jsg::Number::new(3.0))]) +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Dict { + /// The entries, in order. + pub entries: Vec<(String, V)>, +} + +impl Dict { + /// Returns the value of the first entry with `key`. + pub fn get(&self, key: &str) -> Option<&V> { + self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } +} + +impl Default for Dict { + fn default() -> Self { + Self { + entries: Vec::new(), + } + } +} + +impl From> for Dict { + fn from(entries: Vec<(String, V)>) -> Self { + Self { entries } + } +} + +impl FromIterator<(String, V)> for Dict { + fn from_iter>(iter: I) -> Self { + Self { + entries: iter.into_iter().collect(), + } + } +} + +impl IntoIterator for Dict { + type Item = (String, V); + type IntoIter = std::vec::IntoIter<(String, V)>; + + fn into_iter(self) -> Self::IntoIter { + self.entries.into_iter() + } +} diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 33c300d4b8d..6c6e709fea8 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -508,6 +508,39 @@ MaybeLocal local_object_get_property(Isolate* isolate, const Local& object, ::ru return maybe_local_to_ffi(v8_obj->Get(context, v8_key)); } +MaybeLocal local_object_get_property_by_name( + Isolate* isolate, const Local& object, const Local& name) { + auto v8_obj = local_as_ref_from_ffi(object); + auto context = isolate->GetCurrentContext(); + return maybe_local_to_ffi(v8_obj->Get(context, local_as_ref_from_ffi(name))); +} + +bool local_object_get_own_property_names( + Isolate* isolate, const Local& object, ::rust::Vec& names) { + auto v8_obj = local_as_ref_from_ffi(object); + auto context = isolate->GetCurrentContext(); + // The filter of GetOwnPropertyNames(context), with the names as strings. + v8::Local array; + if (!v8_obj + ->GetOwnPropertyNames(context, + static_cast(v8::ONLY_ENUMERABLE | v8::SKIP_SYMBOLS), + v8::KeyConversionMode::kConvertToString) + .ToLocal(&array)) { + return false; + } + names.reserve(array->Length()); + for (uint32_t i = 0; i < array->Length(); i++) { + // The elements are the array's own, so reading them runs no JavaScript. + v8::Local name; + if (!array->Get(context, i).ToLocal(&name)) { + names.clear(); + return false; + } + names.push_back(to_ffi(kj::mv(name))); + } + return true; +} + // Local Local local_new_array(Isolate* isolate, size_t length) { return to_ffi(v8::Array::New(isolate, length)); diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index a6c85291579..bdf51f7ff48 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -236,6 +236,14 @@ bool local_object_has_property(Isolate* isolate, const Local& object, ::rust::St // Returns the value of `key`, or none if a getter or Proxy trap threw, leaving the exception // pending. MaybeLocal local_object_get_property(Isolate* isolate, const Local& object, ::rust::Str key); +// The same for a `v8::Name` key. +MaybeLocal local_object_get_property_by_name( + Isolate* isolate, const Local& object, const Local& name); +// Appends the names of the own enumerable string-keyed properties, converted to strings, to +// `names` and returns true, or returns false with `names` empty if a Proxy trap threw, leaving +// the exception pending. +bool local_object_get_own_property_names( + Isolate* isolate, const Local& object, ::rust::Vec& names); // Local Local local_new_array(Isolate* isolate, size_t length); diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 84927fbdfee..604f29d6f37 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 dict; pub mod fast_api; pub mod feature_flags; mod isolate_state; @@ -20,6 +21,7 @@ mod scope; pub mod v8; mod wrappable; +pub use dict::Dict; 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 77a620de444..538fd98b074 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -417,6 +417,17 @@ pub mod ffi { object: &Local, key: &str, ) -> Result; + // Like the three above, but infallible: they neither intern nor check. + pub unsafe fn local_object_get_property_by_name( + isolate: *mut Isolate, + object: &Local, + name: &Local, + ) -> MaybeLocal; + pub unsafe fn local_object_get_own_property_names( + isolate: *mut Isolate, + object: &Local, + names: &mut Vec, + ) -> bool; // Local pub unsafe fn local_array_length(array: &Local) -> u32; @@ -3264,6 +3275,63 @@ impl<'s> Local<'s, Object> { }) }) } + + /// Reads the property named `name`, like [`get`](Self::get) but with the key as a handle, + /// so that any string or symbol can be the key. + /// + /// # Errors + /// + /// As for [`get`](Self::get). + pub fn get_by_name( + self, + lock: &mut Lock<'s>, + name: Local<'s, Name>, + ) -> crate::Result> { + name.debug_assert_isolate(lock); + call_into_js(lock, |lock| { + // SAFETY: isolate is valid and locked (guaranteed by Lock); both handles are valid. + // The result was created in the current HandleScope. + Ok(unsafe { + Local::from_ffi_maybe(ffi::local_object_get_property_by_name( + lock.isolate().as_ffi(), + self.raw(), + name.raw(), + )) + }) + }) + } + + /// Returns the names of the object's own enumerable properties that are not symbols, in + /// property order, like JS `Object.keys(obj)`. An integer-like name comes as a string. + /// + /// # Errors + /// + /// If a Proxy `ownKeys` or `getOwnPropertyDescriptor` trap throws, or execution is + /// terminating, the exception stays pending and the error is one for which + /// [`Error::is_pending`] is true, as for [`Local::::call`]. + pub fn keys(self, lock: &mut Lock<'s>) -> crate::Result>> { + let names = call_into_js(lock, |lock| { + let mut names = Vec::new(); + // SAFETY: isolate is valid and locked (guaranteed by Lock); handle is valid. + let ok = unsafe { + ffi::local_object_get_own_property_names( + lock.isolate().as_ffi(), + self.raw(), + &mut names, + ) + }; + Ok(ok.then_some(names)) + })?; + Ok(names + .into_iter() + .map(|name| { + // SAFETY: V8 created the names in the current HandleScope, as strings. + let name: Local<'s, String> = unsafe { Local::from_ffi(name) }; + debug_assert!(Local::::from(name).is_string()); + name + }) + .collect()) + } } /// The V8 handles of a [`TracedGlobal`], in a box of their own. diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index b01787bbc0c..5d934579ebd 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -31,6 +31,7 @@ //! | `T: Struct` | `object` | //! | `Vec` | `Array` | //! | `&[T]` | `Array` (parameter only) | +//! | `Dict` | `Record`: an object other than an array | //! //! ## `TypedArray` Types //! @@ -75,6 +76,7 @@ //! `i64` converts like C++ JSG's `int64_t`: a `BigInt` that does not fit is a //! `TypeError`, and any other value converts as above. +use crate::Dict; use crate::Error; use crate::Lenient; use crate::Lock; @@ -99,7 +101,7 @@ use crate::v8; /// /// - **Primitives** (`bool`, `String`, integers, floats, `()`) — no-op. /// - **`Option`**, **`Nullable`** — delegates to the inner value if present. -/// - **Collections** (`Vec`, `HashMap`, `BTreeMap`, `HashSet`, +/// - **Collections** (`Vec`, `Dict`, `HashMap`, `BTreeMap`, `HashSet`, /// `BTreeSet`) — iterates elements/values and traces each. /// - **`Cell`** — reads through `as_ptr()` (sound under single-threaded GC). /// - **`RefCell`** — traces through `try_borrow()`, so a mutably borrowed cell is skipped; @@ -241,6 +243,18 @@ impl Traced for Vec { } } +impl Traced for Dict { + fn holds_gc_references() -> bool { + V::holds_gc_references() + } + + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + for (_, value) in &self.entries { + value.trace(visitor); + } + } +} + impl Traced for std::collections::HashMap { fn holds_gc_references() -> bool { V::holds_gc_references() @@ -1048,6 +1062,94 @@ impl<'s, T: Type + FromJS<'s, ResultType = T>> FromJS<'s> for Vec { } } +// ============================================================================= +// Record type implementations (Dict) +// ============================================================================= + +// Like C++ JSG's DictWrapper (jsg/value.h): any object other than an array. +impl Type for Dict { + fn class_name() -> &'static str { + "object" + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_object() && !value.is_array() + } +} + +impl<'s, V: ToJS<'s>> ToJS<'s> for Dict { + fn to_js(self, lock: &mut Lock<'s>) -> v8::Local<'s, v8::Value> { + let object = lock.new_object(); + for (key, value) in self.entries { + let value = value.to_js(lock); + // Like C++ JSG's, this runs a setter that `Object.prototype` has for the key. If it + // throws, or execution is terminating, that stays pending, and any other error is + // thrown, so JavaScript never sees the value returned in place of the object. + if let Err(err) = object.set(lock, &key, value) { + lock.throw_exception(&err); + return lock.undefined(); + } + } + object.into() + } +} + +impl<'s, V: FromJS<'s, ResultType = V>> FromJS<'s> for Dict { + type ResultType = Self; + + fn type_name() -> String { + "object".to_owned() + } + + fn from_js( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + ) -> Result { + if let Some(dict) = Self::try_from_js(lock, value)? { + return Ok(dict); + } + let type_name = if value.is_array() { + "array".to_owned() + } else { + value.type_of(lock) + }; + Err(Error::new_type_error(format!( + "Expected object but got {type_name}" + ))) + } + + // As C++ JSG's DictWrapper does, reads and converts the values one at a time, and reports + // a value that is not a `V` as the value of its entry, while an error from converting one + // passes through. + fn try_from_js( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + ) -> Result, Error> { + let Ok(object) = v8::Local::::try_from(value) else { + return Ok(None); + }; + if value.is_array() { + return Ok(None); + } + let keys = object.keys(lock)?; + let mut entries = Vec::with_capacity(keys.len()); + for key in keys { + let value = object.get_by_name(lock, key.into())?; + // Like a string argument: the name is a string, so this runs no JavaScript. + let name = String::from_js(lock, key.into())?; + let Some(value) = V::try_from_js(lock, value)? else { + return Err(Error::new_type_error(format!( + "Incorrect type for map entry '{name}': the provided value is not of type \ + '{}'.", + V::type_name() + ))); + }; + entries.push((name, value)); + } + Ok(Some(Self { entries })) + } +} + // ============================================================================= // TypedArray implementations // ============================================================================= From 5451f80377c2ff41f1194b61f1cd4dc638442e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 27 Oct 2026 08:58:29 +0100 Subject: [PATCH 13/23] Follow fast_jsg_struct when converting a #[jsg_struct] in Rust JSG C++ JSG converts a JSG_STRUCT with a DictionaryTemplate when fast_jsg_struct is on (from 2025-12-03), and with Set() for each field before that, which runs a setter a script put on Object.prototype. Rust JSG defined the fields as data properties at every compatibility date, so a Worker with an older date could tell the difference through node:dns's resolveCaa() and resolveNaptr(). Now that ToJS can fail, a #[jsg_struct] without fast_jsg_struct sets its fields with Object::set, as C++ JSG does, and passes on what a setter throws; with the flag, it still defines them as data properties. A Vec is still built from its elements, as C++ JSG builds arrays at every date. The conversion moves to struct_to_js_body() to keep jsg_struct under the function length limit. New tests check that without the flag a setter on Object.prototype takes the field's value, and that what it throws reaches the caller, from Rust and from a method's return value; the existing test now turns the flag on. --- src/rust/jsg-macros/README.md | 4 ++ src/rust/jsg-macros/lib.rs | 77 +++++++++++++++------ src/rust/jsg-test/tests/arrays.rs | 111 ++++++++++++++++++++++++++++-- src/rust/jsg/README.md | 2 +- 4 files changed, 167 insertions(+), 27 deletions(-) diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index a78e2f007e9..edaf7617add 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -20,6 +20,10 @@ Generates `jsg::Struct`, `jsg::Type`, `jsg::ToJS`, and `jsg::FromJS` for a plain struct. Only `pub` fields are projected into the JavaScript object. Use `#[jsg_struct(name = "MyName")]` to override the JavaScript class name. +Like C++ JSG's `JSG_STRUCT`, converting to JavaScript follows the `fast_jsg_struct` flag: with +it, the fields are defined as data properties and no JavaScript runs; without it, they are set +one by one, so a setter on `Object.prototype` runs, and what it throws is passed on. + ```rust #[jsg_struct] pub struct CaaRecord { diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index 43edb7c84f0..78ce7f3ec4a 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -72,7 +72,7 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { Err(err) => return err, }; - let mut field_assignments = Vec::new(); + let mut to_js_fields = Vec::new(); let mut field_extractions = Vec::new(); let mut field_names = Vec::new(); let mut all_optional = true; @@ -91,16 +91,7 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { let optional = is_option_type(field_type); all_optional &= optional; - // Like C++ JSG, an absent optional field is omitted, not undefined. - let set = quote! { - let value = jsg::v8::ToLocalValue::to_local(value, lock); - properties.push((#field_name_str, value)); - }; - field_assignments.push(if optional { - quote! { if let Some(value) = &this.#field_name { #set } } - } else { - quote! { { let value = &this.#field_name; #set } } - }); + to_js_fields.push((field_name, field_name_str.clone(), optional)); // Like C++ JSG, an absent property reads as undefined. field_extractions.push(quote! { let prop = obj.get(lock, #field_name_str)?; @@ -122,6 +113,8 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { } }; + let to_js_body = struct_to_js_body(&to_js_fields); + quote! { #input @@ -135,16 +128,7 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { impl<'s> jsg::ToJS<'s> for #name { fn to_js(self, lock: &mut jsg::Lock<'s>) -> Result, jsg::Error> { - // The fields are defined as data properties, as C++ JSG's ObjectTemplate does, - // so no setter a script put on Object.prototype runs. - // TODO(soon): Use a precached ObjectTemplate instance to create the object, - // similar to how C++ JSG optimizes object creation. This would avoid recreating - // the object shape on every wrap() call and improve performance. - let this = self; - #[allow(unused_mut, reason = "a struct without public fields pushes nothing")] - let mut properties = ::std::vec::Vec::new(); - #(#field_assignments)* - Ok(jsg::v8::Object::with_data_properties(lock, &properties).into()) + #to_js_body } } @@ -188,6 +172,57 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +/// The body of a `#[jsg_struct]`'s `ToJS::to_js`, given each public field's name, its +/// JavaScript name and whether it is an `Option`. Like C++ JSG's `JSG_STRUCT`, it follows the +/// `fast_jsg_struct` flag. +fn struct_to_js_body(fields: &[(&syn::Ident, String, bool)]) -> proc_macro2::TokenStream { + // Like C++ JSG, an absent optional field is omitted rather than set to undefined. + let for_each_field = |body: fn(&str) -> proc_macro2::TokenStream| -> Vec<_> { + fields + .iter() + .map(|(field_name, js_name, optional)| { + let body = body(js_name); + if *optional { + quote! { if let Some(value) = &this.#field_name { #body } } + } else { + quote! { { let value = &this.#field_name; #body } } + } + }) + .collect() + }; + let data_properties = for_each_field(|js_name| { + quote! { + let value = jsg::v8::ToLocalValue::to_local(value, lock); + properties.push((#js_name, value)); + } + }); + let field_sets = for_each_field(|js_name| { + quote! { + let value = jsg::v8::ToLocalValue::to_local(value, lock); + obj.set(lock, #js_name, value)?; + } + }); + quote! { + let this = self; + if lock.feature_flags().get_fast_jsg_struct() { + // The fields are defined as data properties, as C++ JSG's DictionaryTemplate does, so + // no setter a script put on Object.prototype runs. + // TODO(soon): Use a precached template to create the object, as C++ JSG does, rather + // than recreating its shape on every conversion. + #[allow(unused_mut, reason = "a struct without public fields pushes nothing")] + let mut properties = ::std::vec::Vec::new(); + #(#data_properties)* + Ok(jsg::v8::Object::with_data_properties(lock, &properties).into()) + } else { + // Without fast_jsg_struct, C++ JSG sets the fields one by one, which runs a setter on + // Object.prototype; what it throws is passed on. + let obj = lock.new_object(); + #(#field_sets)* + Ok(obj.into()) + } + } +} + // ============================================================================= // #[jsg_method] // ============================================================================= diff --git a/src/rust/jsg-test/tests/arrays.rs b/src/rust/jsg-test/tests/arrays.rs index 593b935b538..846c8fe662f 100644 --- a/src/rust/jsg-test/tests/arrays.rs +++ b/src/rust/jsg-test/tests/arrays.rs @@ -1456,12 +1456,13 @@ fn is_float16_array_check() { }); } -/// Converting a `Vec` or a `#[jsg_struct]` to JavaScript defines the elements and fields as own -/// data properties, as C++ JSG does: it runs no JavaScript, so no setter a script put on -/// `Array.prototype` or `Object.prototype` is called, not even one that throws. +/// With `fast_jsg_struct`, converting a `Vec` or a `#[jsg_struct]` to JavaScript defines the +/// elements and fields as own data properties, as C++ JSG does: it runs no JavaScript, so no +/// setter a script put on `Array.prototype` or `Object.prototype` is called, not even one that +/// throws. #[test] -fn to_js_calls_no_prototype_setters() { - let harness = crate::Harness::new(); +fn to_js_with_fast_jsg_struct_calls_no_prototype_setters() { + let harness = crate::Harness::with_flags(|mut flags| flags.set_fast_jsg_struct(true)); harness.run_in_context(|lock, ctx| { let resource = jsg::Rc::new(ArrayResource); let wrapped = resource.to_js(lock)?; @@ -1520,3 +1521,103 @@ fn to_js_calls_no_prototype_setters() { Ok(()) }); } + +/// Without `fast_jsg_struct`, a `#[jsg_struct]`'s fields are set one by one, as C++ JSG sets +/// them with `Set()`, so a setter on `Object.prototype` runs and takes the field's value. A +/// `Vec`'s elements are still defined as data properties, as C++ JSG defines them at every +/// compatibility date. +#[test] +fn struct_to_js_without_fast_jsg_struct_runs_object_prototype_setters() { + // `Harness::new()` starts with every flag off, `fast_jsg_struct` included. + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + ctx.eval_raw( + lock, + // A string, since pushing to an array would run the setters on Array.prototype. + "globalThis.settersRan = ''; + for (const key of [0, 1]) { + Object.defineProperty(Array.prototype, key, { + set(v) { settersRan += `[${key}]`; throw new Error('setter ran'); }, + configurable: true, + }); + } + Object.defineProperty(Object.prototype, 'name', { + set(v) { settersRan += `${v};`; }, + configurable: true, + });", + ) + .expect("setup should evaluate"); + + let people = vec![ + Person { + name: "Alice".to_owned(), + age: Number::new(25.0), + }, + Person { + name: "Bob".to_owned(), + age: Number::new(30.0), + }, + ]; + let value = people.to_js(lock)?; + ctx.set_global(lock, "people", value); + let ok: bool = ctx + .eval( + lock, + "Object.hasOwn(people, 0) && Object.hasOwn(people, 1) && + people.every(p => Object.keys(p).join() === 'age') && + people[0].age === 25 && people[1].age === 30", + ) + .unwrap(); + assert!(ok, "the setter did not take the name"); + let setters_ran: String = ctx.eval(lock, "settersRan").unwrap(); + assert_eq!(setters_ran, "Alice;Bob;"); + Ok(()) + }); +} + +/// Without `fast_jsg_struct`, what a setter on `Object.prototype` throws while a `#[jsg_struct]` +/// is converted reaches the caller unchanged, from Rust and from a method's return value. +#[test] +fn struct_to_js_without_fast_jsg_struct_passes_on_what_a_setter_throws() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let resource = jsg::Rc::new(ArrayResource); + let wrapped = resource.to_js(lock)?; + ctx.set_global(lock, "arr", wrapped); + ctx.eval_raw( + lock, + "globalThis.thrown = new RangeError('setter'); + Object.defineProperty(Object.prototype, 'age', { + set(v) { throw thrown; }, + configurable: true, + });", + ) + .expect("setup should evaluate"); + + let person = Person { + name: "Alice".to_owned(), + age: Number::new(25.0), + }; + let caught = lock.try_catch(|lock| person.to_js(lock))?; + let Err(exception) = caught else { + panic!("converting the struct did not throw"); + }; + ctx.set_global(lock, "caught", exception); + assert!(ctx.eval::(lock, "caught === thrown").unwrap()); + + let ok: bool = ctx + .eval( + lock, + "let fromMethod; + try { arr.filterAdults([{name: 'Carol', age: 40}]); } + catch (e) { fromMethod = e; } + fromMethod === thrown", + ) + .unwrap(); + assert!( + ok, + "the method's caller did not catch what the setter threw" + ); + Ok(()) + }); +} diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 1dcae4f4cac..8bd7ce0badd 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -511,7 +511,7 @@ propagating. As with the functions C++ JSG wraps, the function's `name` is empty ## 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. The same goes for these other operations that run JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), `Object::set` and `Array::set` (a setter, also one on the prototype chain, or a Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). Converting a `Vec` or a `#[jsg_struct]` to JavaScript runs no JavaScript and can't fail: like C++ JSG, it defines the elements and fields as data properties (`Array::with_elements`, `Object::with_data_properties`), so no setter on `Array.prototype` or `Object.prototype` runs. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. +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. The same goes for these other operations that run JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), `Object::set` and `Array::set` (a setter, also one on the prototype chain, or a Proxy trap), and `Array::iterate` and so converting to a `Vec` (an element's getter). Converting a `Vec` to JavaScript runs no JavaScript: like C++ JSG, it defines the elements as data properties (`Array::with_elements`), so no setter on `Array.prototype` runs. A `#[jsg_struct]` follows C++ JSG's `fast_jsg_struct` flag: with it, the fields are defined as data properties (`Object::with_data_properties`) and no JavaScript runs; without it, they are set one by one with `Object::set`, so a setter on `Object.prototype` runs, and what it throws is passed on. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. Converting a value to JavaScript can fail too, as C++ JSG's `wrap()` can throw, so `ToJS::to_js` returns a `Result`: creating a `Date` or a resource's wrapper fails, leaving the exception pending, when V8 throws or execution is terminating, and a container passes on the error from converting an element. These conversions fail only with pending errors. A `#[jsg_method]`, property getter or wildcard property throws an error from converting its return value as it throws one the method returns, with `Lock::throw_exception`: for a pending error the original exception, or the termination, keeps propagating, and any other error is thrown as a new JavaScript error of its type. The method has already run by then; only its return value is lost. From ba3c25a168270082fdb59c52a2931e29ea0845a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 27 Oct 2026 12:25:02 +0100 Subject: [PATCH 14/23] Address review of Rust JSG struct conversion and setters With fast_jsg_struct, an absent optional field of a #[jsg_struct] is now an own property whose value is undefined, as C++ JSG's DictionaryTemplate gives every field a value; only the Set() path omits it. Object::with_data_properties returns the error from interning a key instead of panicking, since a caller can pass a key that is too long. The ok- compile tests pass on the error from Object::set with `?`, or throw it, rather than dropping it, and the review checklist names .is_ok() among the ways to discard one. Array::set's docs no longer mention a Proxy trap, which a Local can't be. The fast_jsg_struct test records which setters ran in a string, so a setter on Array.prototype that ran would fail its assertion rather than recurse through push(). New tests check optional fields in both flag states, that converting a Vec stops at the first element that fails, and that what a setter on Error.prototype throws while node-exceptions sets code, syscall, path or dest reaches the caller. --- MODULE.bazel.lock | 76 ++++++++++++++----- docs/reference/rust-review-checklist.md | 4 +- .../callback-must-not-capture.rs | 5 +- src/rust/jsg-compile-tests/ok-callback.rs | 5 +- src/rust/jsg-compile-tests/ok-function-new.rs | 2 +- src/rust/jsg-compile-tests/ok-promise.rs | 2 +- src/rust/jsg-macros/README.md | 5 +- src/rust/jsg-macros/lib.rs | 42 +++++----- src/rust/jsg-test/tests/arrays.rs | 37 ++++++++- src/rust/jsg-test/tests/jsg_struct.rs | 29 ++++++- src/rust/jsg/README.md | 8 +- src/rust/jsg/ffi.h | 7 +- src/rust/jsg/v8.rs | 36 ++++----- src/rust/node-exceptions/lib-test.rs | 53 +++++++++++++ 14 files changed, 230 insertions(+), 81 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index b4c5ffd6614..e5c627d6ddf 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1056,7 +1056,7 @@ }, "//build/deps:pyodide.bzl%pyodide": { "general": { - "bzlTransitiveDigest": "LB3zAeq0VUYhOz60yU53Dtv6kCYmOxwMeUZp59LCM5c=", + "bzlTransitiveDigest": "x0hizSFn3RuZD5g2sVAROCrLWqgtQ4LudX4OZourhCk=", "usagesDigest": "D34wPmZYfmECvOxNBPDzjLSxn305F92xp6ZX7mjGPnk=", "recordedInputs": [ "REPO_MAPPING:,bazel_lib bazel_lib+", @@ -1104,11 +1104,11 @@ ] } }, - "pyodide_0.26.0a2_2024-03-01_87.capnp.bin": { + "pyodide_0.26.0a2_2024-03-01_89.capnp.bin": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", "attributes": { - "integrity": "sha256-bB34c//zkqePYFERxSIS6fQpVbXyn+zA/MvgADp9UMA=", - "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_0.26.0a2_2024-03-01_87.capnp.bin" + "integrity": "sha256-c4pm9KBbdeAdfW2UkdU24aE3MadpzGHgbyNG+tEoorM=", + "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_0.26.0a2_2024-03-01_89.capnp.bin" } }, "beautifulsoup4_src_0.26.0a2": { @@ -1151,11 +1151,11 @@ "url": "https://pub-25a5b2f2f1b84655b185a505c7a3ad23.r2.dev/scipy-312-vendored-for-ew-testing.zip" } }, - "pyodide_0.28.2_2025-01-16_18.capnp.bin": { + "pyodide_0.28.2_2025-01-16_20.capnp.bin": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", "attributes": { - "integrity": "sha256-NbX7e8Aob43P/2dMc8UnAt+vbh017Y59odNJQ+5DbbY=", - "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_0.28.2_2025-01-16_18.capnp.bin" + "integrity": "sha256-Mw1A5bs3Ys1UiQ0JEi4XP8ponsQgqYod1HUhVyTj330=", + "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_0.28.2_2025-01-16_20.capnp.bin" } }, "beautifulsoup4_src_0.28.2": { @@ -1222,11 +1222,11 @@ "url": "https://pub-25a5b2f2f1b84655b185a505c7a3ad23.r2.dev/pywavelets-313-vendored-for-ew-testing.zip" } }, - "pyodide_314.0.4_2026-06-10_18.capnp.bin": { + "pyodide_314.0.4_2026-06-10_20.capnp.bin": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", "attributes": { - "integrity": "sha256-n/n+OEj+hatr5pX3RMwpQBmdZjXrh9kms8dHU968Y1c=", - "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_314.0.4_2026-06-10_18.capnp.bin" + "integrity": "sha256-IOPP3ValHq+VYLu0HNODqmB+YXN4lTp25LPXOLmCaGs=", + "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_314.0.4_2026-06-10_20.capnp.bin" } }, "beautifulsoup4_src_314.0.4": { @@ -1261,11 +1261,11 @@ "url": "https://pub-25a5b2f2f1b84655b185a505c7a3ad23.r2.dev/numpy-314-vendored-for-ew-testing.zip" } }, - "pyodide_314.0.6_2026-08-17_6.capnp.bin": { + "pyodide_314.0.6_2026-08-17_8.capnp.bin": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", "attributes": { - "integrity": "sha256-PD/VpBeSMOIeAY4osOc158Sr2P6OT/OdL9a7LqOi4mA=", - "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_314.0.6_2026-08-17_6.capnp.bin" + "integrity": "sha256-E80D9pADATNzTefFRcyJYbgn5knXbyccy+cKbkdpz0A=", + "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_314.0.6_2026-08-17_8.capnp.bin" } }, "beautifulsoup4_src_314.0.6": { @@ -1327,7 +1327,7 @@ "pyodide_dev.capnp.bin": { "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", "attributes": { - "integrity": "sha256-PD/VpBeSMOIeAY4osOc158Sr2P6OT/OdL9a7LqOi4mA=", + "integrity": "sha256-E80D9pADATNzTefFRcyJYbgn5knXbyccy+cKbkdpz0A=", "url": "https://pyodide-capnp-bin.edgeworker.net/pyodide_dev.capnp.bin" } }, @@ -1464,13 +1464,13 @@ "pyodide-0.28.2", "pyodide-314.0.4", "pyodide-314.0.6", - "pyodide_0.26.0a2_2024-03-01_87.capnp.bin", + "pyodide_0.26.0a2_2024-03-01_89.capnp.bin", "beautifulsoup4_src_0.26.0a2", "pytest-asyncio_src_0.26.0a2", "python-workers-runtime-sdk_src_0.26.0a2", "fastapi_src_0.26.0a2", "scipy_src_0.26.0a2", - "pyodide_0.28.2_2025-01-16_18.capnp.bin", + "pyodide_0.28.2_2025-01-16_20.capnp.bin", "beautifulsoup4_src_0.28.2", "pytest-asyncio_src_0.28.2", "python-workers-runtime-sdk_src_0.28.2", @@ -1479,12 +1479,12 @@ "shapely_src_0.28.2", "Pillow_src_0.28.2", "pywavelets_src_0.28.2", - "pyodide_314.0.4_2026-06-10_18.capnp.bin", + "pyodide_314.0.4_2026-06-10_20.capnp.bin", "beautifulsoup4_src_314.0.4", "pytest-asyncio_src_314.0.4", "python-workers-runtime-sdk_src_314.0.4", "numpy_src_314.0.4", - "pyodide_314.0.6_2026-08-17_6.capnp.bin", + "pyodide_314.0.6_2026-08-17_8.capnp.bin", "beautifulsoup4_src_314.0.6", "pytest-asyncio_src_314.0.6", "python-workers-runtime-sdk_src_314.0.6", @@ -1517,6 +1517,46 @@ } } }, + "//build/deps/formatters:rustfmt_repository.bzl%rustfmt_standalone": { + "general": { + "bzlTransitiveDigest": "UiKmUe3AzgXQ/tIz4cyKcsMbnQIgDDzAN3oRUHmnLSo=", + "usagesDigest": "x0djQIecdgW5WllZzOX3szq70ykdYpRdtOddeGMqmcw=", + "recordedInputs": [], + "generatedRepoSpecs": { + "rustfmt-standalone-linux-amd64": { + "repoRuleId": "@@//build/deps/formatters:rustfmt_repository.bzl%rustfmt_standalone_repository", + "attributes": { + "triple": "x86_64-unknown-linux-gnu", + "iso_date": "2026-02-12", + "rustfmt_sha256": "b32c674f6f00407bcc8570f376defc89c768f9ebd87616c8d2d59393b54e179a", + "rustc_sha256": "ecff4a1dd40734c2614c56b28f265eb8d771d7dd1a3c3cb4ed0740d95deec8a7" + } + }, + "rustfmt-standalone-linux-arm64": { + "repoRuleId": "@@//build/deps/formatters:rustfmt_repository.bzl%rustfmt_standalone_repository", + "attributes": { + "triple": "aarch64-unknown-linux-gnu", + "iso_date": "2026-02-12", + "rustfmt_sha256": "7c10fd492c921a779c59072eb950816a09d9422c83c479d73e8129fc04bbfa1f", + "rustc_sha256": "f07f1a4071401235870022c6bd99f29ffe890ff239223e289555f827e9f0a16a" + } + }, + "rustfmt-standalone-darwin-arm64": { + "repoRuleId": "@@//build/deps/formatters:rustfmt_repository.bzl%rustfmt_standalone_repository", + "attributes": { + "triple": "aarch64-apple-darwin", + "iso_date": "2026-02-12", + "rustfmt_sha256": "b4397c2247726e328c857f576a40e7e02b016f38860711f98beca92ac841d75a", + "rustc_sha256": "44dec0af289dd4c61d135222a2cee0de2f412804b2e9774639927e63f8d64e19" + } + } + }, + "moduleExtensionMetadata": { + "useAllRepos": "REGULAR", + "reproducible": false + } + } + }, "@@aspect_rules_esbuild+//esbuild:extensions.bzl%esbuild": { "general": { "bzlTransitiveDigest": "Q3c2vhEDKvrcz5RyrVz/J1Yo+PcIGHv5YYuxCP7mKCU=", diff --git a/docs/reference/rust-review-checklist.md b/docs/reference/rust-review-checklist.md index 50b383b19e0..6f701f0220b 100644 --- a/docs/reference/rust-review-checklist.md +++ b/docs/reference/rust-review-checklist.md @@ -120,8 +120,8 @@ some violations, and the README says which. Reviewers flag these: `IsExecutionTerminating()` check first. `isolate_throw_*` already check, and so do `Lock::throw_*` and `fast_api_throw_*`, which call them. - A `jsg::Error` from a call that ran JavaScript (`Function::call`, `Object::get`/`has`/`set`, - `Array::set`/`iterate`, a coercion) that is discarded with `.ok()` or `unwrap_or*`, or replaced with - `map_err` or a custom `Into`. It may be the termination or a pending exception; + `Array::set`/`iterate`, a coercion) that is discarded with `.ok()`, `.is_ok()` or `unwrap_or*`, + or replaced with `map_err` or a custom `Into`. It may be the termination or a pending exception; it should be passed on with `?`, or caught with `Lock::try_catch`. - An empty result from an FFI shim that is treated as "terminating" without checking. - An `std::rc::Rc` around a `TracedGlobal`, a `jsg::Rc`, or a type holding them, even if it is diff --git a/src/rust/jsg-compile-tests/callback-must-not-capture.rs b/src/rust/jsg-compile-tests/callback-must-not-capture.rs index 82c2696358c..f26c05571e3 100644 --- a/src/rust/jsg-compile-tests/callback-must-not-capture.rs +++ b/src/rust/jsg-compile-tests/callback-must-not-capture.rs @@ -8,8 +8,9 @@ pub fn t() -> jsg::RawFunctionCallback { jsg::function_callback(move |lock, args| { //~^ ERROR E0277 let value = lock.new_object(); - if value.set(lock, &name, args.this()).is_ok() { - args.set_return_value(value.into()); + match value.set(lock, &name, args.this()) { + Ok(()) => args.set_return_value(value.into()), + Err(err) => lock.throw_exception(&err), } }) } diff --git a/src/rust/jsg-compile-tests/ok-callback.rs b/src/rust/jsg-compile-tests/ok-callback.rs index 7678bc5c9de..57e7bd6940b 100644 --- a/src/rust/jsg-compile-tests/ok-callback.rs +++ b/src/rust/jsg-compile-tests/ok-callback.rs @@ -7,8 +7,9 @@ pub fn t() -> jsg::RawFunctionCallback { jsg::function_callback(|lock, args| { let object = lock.new_object(); - if object.set(lock, "this", args.this()).is_ok() { - args.set_return_value(object.into()); + match object.set(lock, "this", args.this()) { + Ok(()) => args.set_return_value(object.into()), + Err(err) => lock.throw_exception(&err), } }) } diff --git a/src/rust/jsg-compile-tests/ok-function-new.rs b/src/rust/jsg-compile-tests/ok-function-new.rs index 3dde25a84a8..61cf233f63d 100644 --- a/src/rust/jsg-compile-tests/ok-function-new.rs +++ b/src/rust/jsg-compile-tests/ok-function-new.rs @@ -9,7 +9,7 @@ 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)); + object.set(lock, data, args.get(0))?; Ok(object.into()) }) } diff --git a/src/rust/jsg-compile-tests/ok-promise.rs b/src/rust/jsg-compile-tests/ok-promise.rs index c89ada1d304..3889a0a4ea2 100644 --- a/src/rust/jsg-compile-tests/ok-promise.rs +++ b/src/rust/jsg-compile-tests/ok-promise.rs @@ -27,7 +27,7 @@ impl Waiter { *self.resolver.borrow_mut() = Some(resolver.to_traced_global(lock)); let wrapped = input.then(lock, String::from("data"), |lock, data, value| { let object = lock.new_object(); - object.set(lock, data, value); + object.set(lock, data, value)?; Ok(object.into()) })?; wrapped.catch(lock, (), |_lock, (), reason| Ok(reason))?; diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index edaf7617add..8779e404de8 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -21,8 +21,9 @@ struct. Only `pub` fields are projected into the JavaScript object. Use `#[jsg_struct(name = "MyName")]` to override the JavaScript class name. Like C++ JSG's `JSG_STRUCT`, converting to JavaScript follows the `fast_jsg_struct` flag: with -it, the fields are defined as data properties and no JavaScript runs; without it, they are set -one by one, so a setter on `Object.prototype` runs, and what it throws is passed on. +it, the fields are defined as data properties, an absent optional field as `undefined`, and no +JavaScript runs; without it, they are set one by one, an absent optional field is omitted, and a +setter on `Object.prototype` runs, and what it throws is passed on. ```rust #[jsg_struct] diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index 78ce7f3ec4a..c559dbf9b86 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -176,30 +176,30 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { /// JavaScript name and whether it is an `Option`. Like C++ JSG's `JSG_STRUCT`, it follows the /// `fast_jsg_struct` flag. fn struct_to_js_body(fields: &[(&syn::Ident, String, bool)]) -> proc_macro2::TokenStream { - // Like C++ JSG, an absent optional field is omitted rather than set to undefined. - let for_each_field = |body: fn(&str) -> proc_macro2::TokenStream| -> Vec<_> { - fields - .iter() - .map(|(field_name, js_name, optional)| { - let body = body(js_name); - if *optional { - quote! { if let Some(value) = &this.#field_name { #body } } - } else { - quote! { { let value = &this.#field_name; #body } } + // A DictionaryTemplate instance has every field, so an absent optional field is undefined. + let data_properties = fields.iter().map(|(field_name, js_name, optional)| { + let value = if *optional { + quote! { + match &this.#field_name { + Some(value) => jsg::v8::ToLocalValue::to_local(value, lock), + None => lock.undefined(), } - }) - .collect() - }; - let data_properties = for_each_field(|js_name| { - quote! { - let value = jsg::v8::ToLocalValue::to_local(value, lock); - properties.push((#js_name, value)); - } + } + } else { + quote! { jsg::v8::ToLocalValue::to_local(&this.#field_name, lock) } + }; + quote! { properties.push((#js_name, #value)); } }); - let field_sets = for_each_field(|js_name| { - quote! { + // Set() skips an absent optional field rather than setting it to undefined. + let field_sets = fields.iter().map(|(field_name, js_name, optional)| { + let set = quote! { let value = jsg::v8::ToLocalValue::to_local(value, lock); obj.set(lock, #js_name, value)?; + }; + if *optional { + quote! { if let Some(value) = &this.#field_name { #set } } + } else { + quote! { { let value = &this.#field_name; #set } } } }); quote! { @@ -212,7 +212,7 @@ fn struct_to_js_body(fields: &[(&syn::Ident, String, bool)]) -> proc_macro2::Tok #[allow(unused_mut, reason = "a struct without public fields pushes nothing")] let mut properties = ::std::vec::Vec::new(); #(#data_properties)* - Ok(jsg::v8::Object::with_data_properties(lock, &properties).into()) + Ok(jsg::v8::Object::with_data_properties(lock, &properties)?.into()) } else { // Without fast_jsg_struct, C++ JSG sets the fields one by one, which runs a setter on // Object.prototype; what it throws is passed on. diff --git a/src/rust/jsg-test/tests/arrays.rs b/src/rust/jsg-test/tests/arrays.rs index 846c8fe662f..cfb1c820eb6 100644 --- a/src/rust/jsg-test/tests/arrays.rs +++ b/src/rust/jsg-test/tests/arrays.rs @@ -1469,11 +1469,12 @@ fn to_js_with_fast_jsg_struct_calls_no_prototype_setters() { ctx.set_global(lock, "arr", wrapped); ctx.eval_raw( lock, - "globalThis.settersRan = []; + // A string, since pushing to an array would run the setters on Array.prototype. + "globalThis.settersRan = ''; for (const [proto, key] of [[Array.prototype, 0], [Array.prototype, 1], [Object.prototype, 'name'], [Object.prototype, 'age']]) { Object.defineProperty(proto, key, { - set(v) { settersRan.push(key); throw new Error('setter ran'); }, + set(v) { settersRan += `${key};`; throw new Error('setter ran'); }, configurable: true, }); }", @@ -1516,7 +1517,7 @@ fn to_js_with_fast_jsg_struct_calls_no_prototype_setters() { .unwrap(); assert!(ok); - let setters_ran: String = ctx.eval(lock, "settersRan.join()").unwrap(); + let setters_ran: String = ctx.eval(lock, "settersRan").unwrap(); assert_eq!(setters_ran, ""); Ok(()) }); @@ -1621,3 +1622,33 @@ fn struct_to_js_without_fast_jsg_struct_passes_on_what_a_setter_throws() { Ok(()) }); } + +/// Converting a `Vec` stops at the first element whose conversion fails: without +/// `fast_jsg_struct`, a struct after the one whose setter threw is not converted. +#[test] +fn vec_to_js_stops_at_the_first_element_that_fails() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + ctx.eval_raw( + lock, + "globalThis.names = ''; + Object.defineProperty(Object.prototype, 'name', { + set(v) { names += `${v};`; if (v === 'Bob') throw new Error('Bob'); }, + configurable: true, + });", + ) + .expect("setup should evaluate"); + let people = ["Alice", "Bob", "Carol"] + .into_iter() + .map(|name| Person { + name: name.to_owned(), + age: Number::new(30.0), + }) + .collect::>(); + let caught = lock.try_catch(|lock| people.to_js(lock))?; + assert!(caught.is_err(), "converting the Vec did not throw"); + let names: String = ctx.eval(lock, "names").unwrap(); + assert_eq!(names, "Alice;Bob;"); + Ok(()) + }); +} diff --git a/src/rust/jsg-test/tests/jsg_struct.rs b/src/rust/jsg-test/tests/jsg_struct.rs index a47cd727154..836e15d2e06 100644 --- a/src/rust/jsg-test/tests/jsg_struct.rs +++ b/src/rust/jsg-test/tests/jsg_struct.rs @@ -302,9 +302,10 @@ fn struct_from_throwing_getter_propagates_error() { }); } -/// Like C++ JSG, an absent optional field is omitted from the JS object. +/// Without `fast_jsg_struct`, an absent optional field is omitted from the JS object, as C++ +/// JSG's `Set()` path omits it. #[test] -fn struct_omits_absent_optional_fields() { +fn struct_without_fast_jsg_struct_omits_absent_optional_fields() { let harness = crate::Harness::new(); harness.run_in_context(|lock, _ctx| { let value = OptionalStruct { @@ -318,3 +319,27 @@ fn struct_omits_absent_optional_fields() { Ok(()) }); } + +/// With `fast_jsg_struct`, an absent optional field is an own property whose value is undefined, +/// as C++ JSG's DictionaryTemplate gives every field a value. +#[test] +fn struct_with_fast_jsg_struct_has_absent_optional_fields_as_undefined() { + let harness = crate::Harness::with_flags(|mut flags| flags.set_fast_jsg_struct(true)); + harness.run_in_context(|lock, ctx| { + let value = OptionalStruct { + label: None, + count: Some(1), + } + .to_js(lock)?; + ctx.set_global(lock, "value", value); + let ok: bool = ctx + .eval( + lock, + "Object.keys(value).join() === 'label,count' && value.label === undefined && + value.count === 1", + ) + .unwrap(); + assert!(ok); + Ok(()) + }); +} diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 461c66e9b6c..de595cbf59a 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -41,9 +41,9 @@ keeps running. and the fast API error path are safe. A shim that calls `isolate->ThrowException` itself must check first. - Don't discard a `jsg::Error` from a call that ran JavaScript (`Function::call`, `Object::get`, - `Object::set`, a coercion). It may be the termination, or a JavaScript exception that is still pending (see - [Errors from JavaScript](#errors-from-javascript)). Pass it on with `?` instead of using - `.ok()`, `unwrap_or`, or `map_err` to a different error. To handle the exception, use + `Object::set`, a coercion). It may be the termination, or a JavaScript exception that is still + pending (see [Errors from JavaScript](#errors-from-javascript)). Pass it on with `?` instead of + using `.ok()`, `unwrap_or`, or `map_err` to a different error. To handle the exception, use `Lock::try_catch`. - An empty result from an FFI shim doesn't always mean termination. It can also mean a stack overflow or an ordinary exception. Check `IsExecutionTerminating()`, or catch the exception @@ -513,7 +513,7 @@ propagating. As with the functions C++ JSG wraps, the function's `name` is empty ## 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. The same goes for these other operations that run JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), `Object::set` and `Array::set` (a setter, also one on the prototype chain, or a Proxy trap), `Array::iterate` and so converting to a `Vec` (an element's getter), and `ser::serialize` (a getter or Proxy trap, or a `DataCloneError`). Converting a `Vec` to JavaScript runs no JavaScript: like C++ JSG, it defines the elements as data properties (`Array::with_elements`), so no setter on `Array.prototype` runs. A `#[jsg_struct]` follows C++ JSG's `fast_jsg_struct` flag: with it, the fields are defined as data properties (`Object::with_data_properties`) and no JavaScript runs; without it, they are set one by one with `Object::set`, so a setter on `Object.prototype` runs, and what it throws is passed on. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. +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. The same goes for these other operations that run JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), `Object::set` (a setter, also one on the prototype chain, or a Proxy trap) and `Array::set` (a setter, also one on the prototype chain), `Array::iterate` and so converting to a `Vec` (an element's getter), and `ser::serialize` (a getter or Proxy trap, or a `DataCloneError`). Building the array when converting a `Vec` to JavaScript runs no JavaScript: like C++ JSG, it defines the elements as data properties (`Array::with_elements`), so no setter on `Array.prototype` runs. A `#[jsg_struct]` follows C++ JSG's `fast_jsg_struct` flag: with it, the fields are defined as data properties (`Object::with_data_properties`), an absent optional field as `undefined`, and no JavaScript runs; without it, they are set one by one with `Object::set`, an absent optional field is omitted, so a setter on `Object.prototype` runs, and what it throws is passed on. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. Converting a value to JavaScript can fail too, as C++ JSG's `wrap()` can throw, so `ToJS::to_js` returns a `Result`: creating a `Date` or a resource's wrapper fails, leaving the exception pending, when V8 throws or execution is terminating, and a container passes on the error from converting an element. These conversions fail only with pending errors. A `#[jsg_method]`, property getter or wildcard property throws an error from converting its return value as it throws one the method returns, with `Lock::throw_exception`: for a pending error the original exception, or the termination, keeps propagating, and any other error is thrown as a new JavaScript error of its type. The method has already run by then; only its return value is lost. diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index df0e69d9567..0278032f19d 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -232,7 +232,8 @@ MaybeLocal function_new(Isolate* isolate, size_t callback, const Local& data); // Local // Returns a new object with `keys[i]` an own data property of value `values[i]`. Runs no -// JavaScript: no setter on Object.prototype is called. +// JavaScript: no setter on Object.prototype is called. Throws a KJ exception if a key is too +// long for a V8 string, V8 fails to create one, or there are not as many keys as values. Local object_new_with_data_properties( Isolate* isolate, ::rust::Slice keys, ::rust::Slice values); // Sets `key` to `value` and returns true, or returns false if a setter or Proxy trap threw, @@ -275,8 +276,8 @@ Local local_new_array(Isolate* isolate, size_t length); uint32_t local_array_length(const Local& array); // Returns a new array of `elements`. Runs no JavaScript: no setter on Array.prototype is called. Local array_new_with_elements(Isolate* isolate, ::rust::Slice elements); -// Sets element `index` to `value` and returns true, or returns false if a setter or Proxy trap -// threw, leaving the exception pending. +// Sets element `index` to `value` and returns true, or returns false if a setter threw, leaving +// the exception pending. bool local_array_set(Isolate* isolate, Local& array, uint32_t index, Local value); // Appends the elements to `result` and returns true, or returns false with `result` empty if a // getter or Proxy trap on an element threw, leaving the exception pending. diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index b77638f80e4..a3c4fa8e51c 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -470,7 +470,7 @@ pub mod ffi { // Local pub unsafe fn local_array_length(array: &Local) -> u32; pub unsafe fn array_new_with_elements(isolate: *mut Isolate, elements: &[Local]) -> Local; - // Leaves what a setter or Proxy trap throws pending; see `call_into_js()`. + // Leaves what a setter throws pending; see `call_into_js()`. pub unsafe fn local_array_set( isolate: *mut Isolate, array: &mut Local, @@ -1997,7 +1997,7 @@ impl<'s> Local<'s, Array> { /// /// # Errors /// - /// If a setter on the prototype chain or a Proxy trap throws, or execution is terminating, + /// If a setter, on the array or its prototype chain, throws, or execution is terminating, /// the exception stays pending and the error is one for which [`Error::is_pending`] is /// true, as for [`Local::::call`]. pub fn set( @@ -3339,20 +3339,17 @@ impl Object { /// Creates a new object with `properties` as its own data properties, in that order. /// /// Runs no JavaScript: unlike [`Local::::set`], it calls no setter that a script - /// defined on `Object.prototype`. Each property is defined as by `v8::Object::CreateDataProperty` - /// (writable, enumerable and configurable); a later duplicate key replaces the earlier value. + /// defined on `Object.prototype`. Each property is defined as by + /// `v8::Object::CreateDataProperty` (writable, enumerable and configurable); a later duplicate + /// key replaces the earlier value. /// - /// # Panics + /// # Errors /// - /// Panics if a key is longer than [`String::MAX_LENGTH`]. - #[expect( - clippy::expect_used, - reason = "fails only for a key longer than V8's maximum string length" - )] + /// If a key is longer than [`String::MAX_LENGTH`], or V8 fails to create it. pub fn with_data_properties<'s>( lock: &mut Lock<'s>, properties: &[(&str, Local<'s, Value>)], - ) -> Local<'s, Self> { + ) -> crate::Result> { let (keys, values): (Vec<&str>, Vec>) = properties .iter() .map(|(key, value)| { @@ -3362,16 +3359,15 @@ impl Object { .unzip(); // SAFETY: lock guarantees the isolate is locked, a context is entered and a HandleScope // is active; the values are valid handles in it. The result belongs to that scope. - unsafe { - Local::from_ffi( - ffi::object_new_with_data_properties( - lock.isolate().as_ffi(), - &keys, - Local::raw_slice(&values), - ) - .expect("object property key is too long for a V8 string"), + let object = unsafe { + ffi::object_new_with_data_properties( + lock.isolate().as_ffi(), + &keys, + Local::raw_slice(&values), ) - } + }?; + // SAFETY: as above. + Ok(unsafe { Local::from_ffi(object) }) } } diff --git a/src/rust/node-exceptions/lib-test.rs b/src/rust/node-exceptions/lib-test.rs index 90f2e6baae1..a03befe7075 100644 --- a/src/rust/node-exceptions/lib-test.rs +++ b/src/rust/node-exceptions/lib-test.rs @@ -156,3 +156,56 @@ fn uv_exception_non_utf8_path_is_lossy_not_a_panic() { Ok(()) }); } + +/// What a setter on `Error.prototype` throws while an exception's properties are set reaches +/// the caller as a pending error, for `code`, `syscall`, `path` and `dest`. +#[test] +fn a_throwing_prototype_setter_reaches_the_caller() { + for key in ["code", "syscall", "path", "dest"] { + let harness = Harness::new(); + harness.run_in_context(|lock, ctx| { + ctx.eval_raw( + lock, + &format!( + "globalThis.thrown = new RangeError('setter'); + Object.defineProperty(Error.prototype, '{key}', {{ + set(v) {{ throw thrown; }}, + configurable: true, + }});" + ), + ) + .expect("setup should evaluate"); + let caught = lock.try_catch(|lock| { + let result = if key == "code" { + create_node_exception_impl( + lock, + ffi::NodeExceptionCode::ErrFsEisdir, + ffi::JsErrorType::Error, + None, + ) + } else { + create_uv_exception_impl( + lock, + -libc::ENOENT, + "open", + None, + Some(b"/from"), + Some(b"/to"), + ) + }; + let err = result.expect_err("setting the property did not fail"); + assert!(err.is_pending(), "{key}: the error is not pending"); + Err::<(), _>(err) + })?; + let Err(exception) = caught else { + unreachable!("the closure always fails"); + }; + ctx.set_global(lock, "caught", exception); + assert!( + ctx.eval::(lock, "caught === thrown").unwrap(), + "{key}: the caller did not catch what the setter threw" + ); + Ok(()) + }); + } +} From c7f3b3cc639b52aa72e35b346ee15e7ff71888f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 27 Oct 2026 12:45:54 +0100 Subject: [PATCH 15/23] Put DictionaryTemplate in backticks in a Rust JSG test doc comment --- src/rust/jsg-test/tests/jsg_struct.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rust/jsg-test/tests/jsg_struct.rs b/src/rust/jsg-test/tests/jsg_struct.rs index 836e15d2e06..4a900b11696 100644 --- a/src/rust/jsg-test/tests/jsg_struct.rs +++ b/src/rust/jsg-test/tests/jsg_struct.rs @@ -321,7 +321,7 @@ fn struct_without_fast_jsg_struct_omits_absent_optional_fields() { } /// With `fast_jsg_struct`, an absent optional field is an own property whose value is undefined, -/// as C++ JSG's DictionaryTemplate gives every field a value. +/// as C++ JSG's `DictionaryTemplate` gives every field a value. #[test] fn struct_with_fast_jsg_struct_has_absent_optional_fields_as_undefined() { let harness = crate::Harness::with_flags(|mut flags| flags.set_fast_jsg_struct(true)); From a5ff3f97e270cd4e7db32358ffef4f0607c4efc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 27 Oct 2026 14:21:44 +0000 Subject: [PATCH 16/23] Test that Rust JSG's Dict::get() returns the first duplicate A Dict built in Rust can hold a key twice, which no object read from JavaScript can, so no test covered which entry get() returns. Also list Dict in the README's table of traced field types. --- src/rust/jsg/README.md | 1 + src/rust/jsg/dict-test.rs | 13 +++++++++++++ src/rust/jsg/dict.rs | 4 ++++ 3 files changed, 18 insertions(+) create mode 100644 src/rust/jsg/dict-test.rs diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 9c2405eb179..a88ffba1e0a 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -246,6 +246,7 @@ does for `&self` methods. A method that takes `this` has no Fast API callback. | `Vec>` | Yes — each element | Iterates and visits every `Rc` in the vec | | `HashMap>` | Yes — each value | Iterates `.values()` and visits each `Rc` | | `BTreeMap>` | Yes — each value | Iterates `.values()` and visits each `Rc` | +| `jsg::Dict>` | Yes — each value | Visits the value of every entry | | `HashSet>` | Yes — each element | Iterates and visits every `Rc` | | `BTreeSet>` | Yes — each element | Iterates and visits every `Rc` | | `Cell>>` | Yes — each element | `Cell` variant of above | diff --git a/src/rust/jsg/dict-test.rs b/src/rust/jsg/dict-test.rs new file mode 100644 index 00000000000..3a017bf106c --- /dev/null +++ b/src/rust/jsg/dict-test.rs @@ -0,0 +1,13 @@ +// 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 + +use super::*; + +/// A `Dict` built in Rust can hold a key twice, which an object read from JavaScript can't. +#[test] +fn get_returns_the_first_entry_with_a_duplicate_key() { + let dict = Dict::from(vec![("key".to_owned(), 1), ("key".to_owned(), 2)]); + assert_eq!(dict.get("key"), Some(&1)); + assert_eq!(dict.get("other"), None); +} diff --git a/src/rust/jsg/dict.rs b/src/rust/jsg/dict.rs index ebac4c55807..65e0718b52c 100644 --- a/src/rust/jsg/dict.rs +++ b/src/rust/jsg/dict.rs @@ -74,3 +74,7 @@ impl IntoIterator for Dict { self.entries.into_iter() } } + +#[cfg(test)] +#[path = "dict-test.rs"] +mod tests; From 3db21ea7eac83658091f132969f80ad5acdbd6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 27 Oct 2026 16:36:41 +0000 Subject: [PATCH 17/23] Make Rust JSG's Dict entries private and rename get() to find() Keeping the entries private leaves room to change how a Dict stores them, for example to add a faster lookup. The lookup is a linear search, which find() says better than get(). Add iter(), len() and is_empty() for code that read the field. --- src/rust/jsg-test/tests/dict.rs | 2 +- src/rust/jsg/dict-test.rs | 22 +++++++++++++++++++--- src/rust/jsg/dict.rs | 23 ++++++++++++++++++++--- src/rust/jsg/wrappable.rs | 6 +++--- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/rust/jsg-test/tests/dict.rs b/src/rust/jsg-test/tests/dict.rs index b30580060b1..783cf3582ad 100644 --- a/src/rust/jsg-test/tests/dict.rs +++ b/src/rust/jsg-test/tests/dict.rs @@ -65,7 +65,7 @@ impl Records { #[jsg_method] pub fn lookup(&self, dict: Dict, key: String) -> Option { - dict.get(&key).cloned() + dict.find(&key).cloned() } #[jsg_method] diff --git a/src/rust/jsg/dict-test.rs b/src/rust/jsg/dict-test.rs index 3a017bf106c..cb61a056062 100644 --- a/src/rust/jsg/dict-test.rs +++ b/src/rust/jsg/dict-test.rs @@ -6,8 +6,24 @@ use super::*; /// A `Dict` built in Rust can hold a key twice, which an object read from JavaScript can't. #[test] -fn get_returns_the_first_entry_with_a_duplicate_key() { +fn find_returns_the_first_entry_with_a_duplicate_key() { let dict = Dict::from(vec![("key".to_owned(), 1), ("key".to_owned(), 2)]); - assert_eq!(dict.get("key"), Some(&1)); - assert_eq!(dict.get("other"), None); + assert_eq!(dict.find("key"), Some(&1)); + assert_eq!(dict.find("other"), None); +} + +#[test] +fn iter_and_len_see_every_entry_in_order() { + let dict = Dict::from(vec![ + ("b".to_owned(), 1), + ("a".to_owned(), 2), + ("b".to_owned(), 3), + ]); + assert_eq!(dict.len(), 3); + assert!(!dict.is_empty()); + assert_eq!( + dict.iter().collect::>(), + [("b", &1), ("a", &2), ("b", &3)] + ); + assert!(Dict::::default().is_empty()); } diff --git a/src/rust/jsg/dict.rs b/src/rust/jsg/dict.rs index 65e0718b52c..733c3ea7d35 100644 --- a/src/rust/jsg/dict.rs +++ b/src/rust/jsg/dict.rs @@ -34,14 +34,31 @@ #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Dict { /// The entries, in order. - pub entries: Vec<(String, V)>, + entries: Vec<(String, V)>, } impl Dict { - /// Returns the value of the first entry with `key`. - pub fn get(&self, key: &str) -> Option<&V> { + /// Returns the value of the first entry with `key`, searching the entries in order. + pub fn find(&self, key: &str) -> Option<&V> { self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) } + + /// Returns an iterator over the entries' keys and values, in order. + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(k, v)| (k.as_str(), v)) + } + + /// Returns the number of entries, counting each entry of a duplicate key. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns whether there are no entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } } impl Default for Dict { diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index 4c4e546a62c..d7ca99606ef 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -251,7 +251,7 @@ impl Traced for Dict { } fn trace(&self, visitor: &mut crate::v8::GcVisitor) { - for (_, value) in &self.entries { + for (_, value) in self.iter() { value.trace(visitor); } } @@ -1121,7 +1121,7 @@ impl Type for Dict { impl<'s, V: ToJS<'s>> ToJS<'s> for Dict { fn to_js(self, lock: &mut Lock<'s>) -> Result, Error> { let object = lock.new_object(); - for (key, value) in self.entries { + for (key, value) in self { let value = value.to_js(lock)?; // Like C++ JSG's, this runs a setter that `Object.prototype` has for the key, and // stops at the first entry that fails, including when the setter throws. @@ -1183,7 +1183,7 @@ impl<'s, V: FromJS<'s, ResultType = V>> FromJS<'s> for Dict { }; entries.push((name, value)); } - Ok(Some(Self { entries })) + Ok(Some(Self::from(entries))) } } From 4dafc2a892f9815cf910e43c905ebbfb58529c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 28 Oct 2026 10:15:40 +0100 Subject: [PATCH 18/23] Define Rust JSG Dict entries as data properties Converting a jsg::Dict to JavaScript set each entry, as C++ JSG's DictWrapper does, so a setter on Object.prototype ran, and a __proto__ entry replaced the prototype or was dropped. Define the entries as own data properties instead, as Web IDL does for a record: no JavaScript runs, and __proto__ is an own property, as with Object.fromEntries(). --- src/rust/jsg-test/tests/dict.rs | 50 ++++++++++++++++++--------------- src/rust/jsg/README.md | 8 ++++-- src/rust/jsg/dict.rs | 6 ++-- src/rust/jsg/wrappable.rs | 20 +++++++------ 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/rust/jsg-test/tests/dict.rs b/src/rust/jsg-test/tests/dict.rs index 783cf3582ad..fe064ece779 100644 --- a/src/rust/jsg-test/tests/dict.rs +++ b/src/rust/jsg-test/tests/dict.rs @@ -359,10 +359,10 @@ fn dicts_convert_to_plain_objects() { }); } -// Like C++ JSG, converting a Dict runs a setter that Object.prototype has for its key. What -// the setter throws reaches the caller unchanged, and no later entry is set. +// As Web IDL does for a record, converting a Dict defines its entries as own data properties, +// so a setter that Object.prototype has for a key neither runs nor hides the entry. #[test] -fn converting_a_dict_runs_inherited_setters() { +fn converting_a_dict_runs_no_inherited_setters() { with_records(|lock, ctx| { expect_eval( lock, @@ -371,29 +371,33 @@ fn converting_a_dict_runs_inherited_setters() { Object.defineProperty(Object.prototype, 'a', { set(v) { seen.push(v); }, configurable: true, }); + Object.defineProperty(Object.prototype, 'b', { + set(v) { throw new RangeError('setter'); }, configurable: true, + }); const d = records.make(['a', 'b']); delete Object.prototype.a; - `${seen} ${JSON.stringify(d)}`", - "1 {\"b\":2}", + delete Object.prototype.b; + const own = Object.getOwnPropertyDescriptor(d, 'a'); + [seen.length, JSON.stringify(d), own.writable, own.enumerable, own.configurable] + .join(' ')", + "0 {\"a\":1,\"b\":2} true true true", + ); + }); +} + +// A `__proto__` entry is an own property, as `Object.fromEntries` makes it: it neither +// replaces the object's prototype nor is dropped. +#[test] +fn a_proto_entry_is_an_own_property() { + with_records(|lock, ctx| { + expect_eval( + lock, + ctx, + "const d = records.make(['__proto__', 'a']); + [Object.getPrototypeOf(d) === Object.prototype, Object.hasOwn(d, '__proto__'), + JSON.stringify(d)].join(' ')", + "true true {\"__proto__\":1,\"a\":2}", ); - let result: bool = ctx - .eval( - lock, - "globalThis.thrown = new RangeError('setter'); globalThis.ran = false; - Object.defineProperty(Object.prototype, 'a', { - set(v) { throw thrown; }, configurable: true, - }); - Object.defineProperty(Object.prototype, 'b', { - set(v) { ran = true; }, configurable: true, - }); - let caught; - try { records.make(['a', 'b']); } catch (e) { caught = e; } - delete Object.prototype.a; - delete Object.prototype.b; - caught === thrown && !ran", - ) - .unwrap(); - assert!(result); }); } diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index c67827fde30..fa282014c45 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -514,7 +514,7 @@ propagating. As with the functions C++ JSG wraps, the function's `name` is empty ## 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. The same goes for these other operations that run JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), `Object::set` (a setter, also one on the prototype chain, or a Proxy trap) and `Array::set` (a setter, also one on the prototype chain), `Array::iterate` and so converting to a `Vec` (an element's getter), and `ser::serialize` (a getter or Proxy trap, or a `DataCloneError`). Building the array when converting a `Vec` to JavaScript runs no JavaScript: like C++ JSG, it defines the elements as data properties (`Array::with_elements`), so no setter on `Array.prototype` runs. A `#[jsg_struct]` follows C++ JSG's `fast_jsg_struct` flag: with it, the fields are defined as data properties (`Object::with_data_properties`), an absent optional field as `undefined`, and no JavaScript runs; without it, they are set one by one with `Object::set`, an absent optional field is omitted, so a setter on `Object.prototype` runs, and what it throws is passed on. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. +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. The same goes for these other operations that run JavaScript: converting a value to a string or a number (its `toString`, `valueOf` or `Symbol.toPrimitive`, or a Proxy trap), `Object::get` and `Object::has` (a getter or Proxy trap), `Object::set` (a setter, also one on the prototype chain, or a Proxy trap) and `Array::set` (a setter, also one on the prototype chain), `Array::iterate` and so converting to a `Vec` (an element's getter), and `ser::serialize` (a getter or Proxy trap, or a `DataCloneError`). Building the array when converting a `Vec` to JavaScript runs no JavaScript: like C++ JSG, it defines the elements as data properties (`Array::with_elements`), so no setter on `Array.prototype` runs. Nor does converting a `jsg::Dict` to JavaScript, which defines its entries as data properties. A `#[jsg_struct]` follows C++ JSG's `fast_jsg_struct` flag: with it, the fields are defined as data properties (`Object::with_data_properties`), an absent optional field as `undefined`, and no JavaScript runs; without it, they are set one by one with `Object::set`, an absent optional field is omitted, so a setter on `Object.prototype` runs, and what it throws is passed on. `Error`'s own `FromJS`, which reads `name` and `message`, passes such an error on too. `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. Converting a value to JavaScript can fail too, as C++ JSG's `wrap()` can throw, so `ToJS::to_js` returns a `Result`: creating a `Date` or a resource's wrapper fails, leaving the exception pending, when V8 throws or execution is terminating, and a container passes on the error from converting an element. These conversions fail only with pending errors. A `#[jsg_method]`, property getter or wildcard property throws an error from converting its return value as it throws one the method returns, with `Lock::throw_exception`: for a pending error the original exception, or the termination, keeps propagating, and any other error is thrown as a new JavaScript error of its type. The method has already run by then; only its return value is lost. @@ -560,7 +560,11 @@ truncated toward zero, and from a `Date` or a number of milliseconds within the as a map from strings to values. It holds its entries in order, in a `Vec<(String, V)>`. From JavaScript, it takes any object other than an array, and reads its own enumerable properties that are not symbols, as `Object.keys()` lists them; a value that is not a `V` is a `TypeError` -naming its key. To JavaScript, it is a new plain object. `Local::keys()` and +naming its key. To JavaScript, it is a new plain object with each entry as an own data property +(`Object::with_data_properties`), as Web IDL defines a record. C++ JSG's `DictWrapper` sets the +entries instead, so with it a setter on `Object.prototype` runs and a `__proto__` entry replaces +the prototype or is dropped; with Rust JSG, no JavaScript runs and `__proto__` is an own property, +as with `Object.fromEntries()`. `Local::keys()` and `get_by_name()` list and read properties the same way. ### Arrays, typed arrays and `ArrayBuffer`s diff --git a/src/rust/jsg/dict.rs b/src/rust/jsg/dict.rs index 733c3ea7d35..b0cfcb82305 100644 --- a/src/rust/jsg/dict.rs +++ b/src/rust/jsg/dict.rs @@ -11,8 +11,10 @@ /// enumerable properties that are not symbols, in property order, as `Object.keys()` lists /// them. Each value is read and converted before the next one is read. A value of the wrong /// type is a `TypeError` that names its key. -/// - To JavaScript, it is a new plain object with a property for each entry, set in order, so -/// that a later entry with the same key overwrites an earlier one. +/// - To JavaScript, it is a new plain object with an own data property for each entry, defined +/// in order, so that a later entry with the same key overwrites an earlier one. As in Web IDL, +/// and unlike C++ JSG, which sets the properties, no setter on `Object.prototype` runs and a +/// `__proto__` entry is an own property. /// /// # Examples /// diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index c10c3a6f0ce..62a1b36eb24 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -1228,14 +1228,18 @@ impl Type for Dict { impl<'s, V: ToJS<'s>> ToJS<'s> for Dict { fn to_js(self, lock: &mut Lock<'s>) -> Result, Error> { - let object = lock.new_object(); - for (key, value) in self { - let value = value.to_js(lock)?; - // Like C++ JSG's, this runs a setter that `Object.prototype` has for the key, and - // stops at the first entry that fails, including when the setter throws. - object.set(lock, &key, value)?; - } - Ok(object.into()) + let entries = self + .into_iter() + .map(|(key, value)| Ok((key, value.to_js(lock)?))) + .collect::, Error>>()?; + let properties: Vec<(&str, v8::Local<'s, v8::Value>)> = entries + .iter() + .map(|(key, value)| (key.as_str(), *value)) + .collect(); + // As Web IDL does for a record, defines each entry as an own data property, unlike C++ + // JSG's DictWrapper, which sets it: no setter on `Object.prototype` runs, and a + // `__proto__` entry is an own property rather than replacing the prototype. + Ok(v8::Object::with_data_properties(lock, &properties)?.into()) } } From f82f657fd2811d82aa9ac313aa82c81c15ad1b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 28 Oct 2026 10:50:23 +0100 Subject: [PATCH 19/23] Don't define Rust JSG data properties while terminating V8 doesn't allow CreateDataProperty while execution is terminating: a debug check fails. Object::with_data_properties, which converting a Dict and, with fast_jsg_struct, a #[jsg_struct] use, called it regardless, which crashed the debug build when a method returned either after a termination. Check for termination first, through call_into_js, and return a pending error, as Object::set does. --- src/rust/jsg-test/tests/termination.rs | 45 +++++++++++++++++++++++++- src/rust/jsg/ffi.c++ | 2 +- src/rust/jsg/v8.rs | 42 +++++++++++++++--------- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/rust/jsg-test/tests/termination.rs b/src/rust/jsg-test/tests/termination.rs index f0559f30ad8..af30ebecccd 100644 --- a/src/rust/jsg-test/tests/termination.rs +++ b/src/rust/jsg-test/tests/termination.rs @@ -11,8 +11,18 @@ use jsg_macros::jsg_method; use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_struct] +struct Point { + pub x: u32, +} thread_local! { + /// Whether converting the struct in `terminate_then_return_struct` failed with a pending + /// error. + static STRUCT_CONVERSION_PENDING: std::cell::Cell> = + const { std::cell::Cell::new(None) }; /// Whether converting the array in `terminate_then_return_dates` failed with a pending error. static DATES_CONVERSION_PENDING: std::cell::Cell> = const { std::cell::Cell::new(None) }; @@ -73,6 +83,18 @@ impl Terminator { result } + /// Is terminated and then converts a `#[jsg_struct]`. + #[jsg_method] + fn terminate_then_return_struct<'s>( + &self, + lock: &mut jsg::Lock<'s>, + ) -> jsg::Result> { + crate::Harness::terminate_and_deliver(lock); + let result = jsg::ToJS::to_js(Point { x: 1 }, lock); + STRUCT_CONVERSION_PENDING.set(Some(result.as_ref().is_err_and(jsg::Error::is_pending))); + result + } + /// Is terminated and then wraps a new resource, which creates its wrapper. #[jsg_method] fn terminate_then_return_resource<'s>( @@ -120,7 +142,12 @@ impl Terminator { /// termination reaches the harness: the script fails without a catchable exception, and neither /// the `catch` block nor the code after it runs. fn termination_survives(method: &str) { - crate::Harness::new().run_in_context(|lock, ctx| { + termination_survives_in(&crate::Harness::new(), method); +} + +/// [`termination_survives`] in `harness`. +fn termination_survives_in(harness: &crate::Harness, method: &str) { + harness.run_in_context(|lock, ctx| { ctx.set_global(lock, "obj", jsg::Rc::new(Terminator)); let script = format!( "globalThis.caught = 'nothing'; globalThis.after = false; @@ -185,6 +212,22 @@ fn returning_dates_while_terminating_keeps_the_termination() { assert_eq!(DATES_CONVERSION_PENDING.get(), Some(true)); } +/// With `fast_jsg_struct`, the fields are defined as data properties, which V8 doesn't allow +/// while execution is terminating; without it, they are set. +#[test] +fn returning_a_struct_while_terminating_keeps_the_termination() { + for fast in [false, true] { + STRUCT_CONVERSION_PENDING.set(None); + let harness = crate::Harness::with_flags(|mut flags| flags.set_fast_jsg_struct(fast)); + termination_survives_in(&harness, "terminateThenReturnStruct"); + assert_eq!( + STRUCT_CONVERSION_PENDING.get(), + Some(true), + "fast_jsg_struct = {fast}" + ); + } +} + #[test] fn returning_a_resource_while_terminating_keeps_the_termination() { RESOURCE_CONVERSION_PENDING.set(None); diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index 080637dd250..489f797f8a3 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -479,7 +479,7 @@ Local object_new_with_data_properties( for (size_t i = 0; i < keys.size(); i++) { // Unlike Set(), CreateDataProperty() doesn't look up the prototype chain, so no setter runs, // and on an ordinary object no Proxy trap runs either: nothing here runs JavaScript, so it - // can't throw. + // can't throw. V8 doesn't allow it while execution is terminating, which the caller checks. auto defined = object->CreateDataProperty( context, makeInternedStr(isolate, keys[i]), local_as_ref_from_ffi(values[i])); KJ_ASSERT(defined.FromMaybe(false), "defining a property of a new object failed", keys[i]); diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 2757ac9c261..5b20bbb9edc 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -1830,11 +1830,14 @@ impl<'s> Local<'s, Function> { } /// Runs `shim`, a call through the FFI that can run JavaScript, and returns what it returns. +/// A call that runs no JavaScript but that V8 doesn't allow while execution is terminating, such +/// as `CreateDataProperty`, goes through here too, and its `shim` never returns `None`. /// -/// Every such call goes through here, so that all of them handle exceptions and termination -/// alike. `shim` returns `None` if JavaScript threw, leaving the exception pending (see `ffi.h`); -/// that becomes an error for which [`Error::is_pending`] is true. While execution is terminating, -/// `shim` is not run and the error is the same: calling into V8 then would clear the termination. +/// Every call that can run JavaScript goes through here, so that all of them handle exceptions +/// and termination alike. `shim` returns `None` if JavaScript threw, leaving the exception pending +/// (see `ffi.h`); that becomes an error for which [`Error::is_pending`] is true. While execution +/// is terminating, `shim` is not run and the error is the same: calling into V8 then would clear +/// the termination. /// An error from `shim` itself (a C++ exception, such as a failed `KJ_REQUIRE`) is passed on. /// /// The one exception is `wrap_resource`, which checks for termination itself, so that its common @@ -3351,7 +3354,9 @@ impl Object { /// /// # Errors /// - /// If a key is longer than [`String::MAX_LENGTH`], or V8 fails to create it. + /// If a key is longer than [`String::MAX_LENGTH`], or V8 fails to create it. While execution + /// is terminating, it creates nothing, and the error is one for which [`Error::is_pending`] + /// is true, as for [`Local::::set`]. pub fn with_data_properties<'s>( lock: &mut Lock<'s>, properties: &[(&str, Local<'s, Value>)], @@ -3363,17 +3368,22 @@ impl Object { (*key, *value) }) .unzip(); - // SAFETY: lock guarantees the isolate is locked, a context is entered and a HandleScope - // is active; the values are valid handles in it. The result belongs to that scope. - let object = unsafe { - ffi::object_new_with_data_properties( - lock.isolate().as_ffi(), - &keys, - Local::raw_slice(&values), - ) - }?; - // SAFETY: as above. - Ok(unsafe { Local::from_ffi(object) }) + // No JavaScript runs, but V8 doesn't allow `CreateDataProperty` while execution is + // terminating (a debug check fails), which `call_into_js` checks for. + call_into_js(lock, |lock| { + // SAFETY: lock guarantees the isolate is locked, a context is entered and a + // HandleScope is active; the values are valid handles in it. The result belongs to + // that scope. + let object = unsafe { + ffi::object_new_with_data_properties( + lock.isolate().as_ffi(), + &keys, + Local::raw_slice(&values), + ) + }?; + // SAFETY: as above. + Ok(Some(unsafe { Local::from_ffi(object) })) + }) } } From 43248bdf1fa962b11e4ff5f393f0f57cb86db8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 27 Oct 2026 11:49:14 +0000 Subject: [PATCH 20/23] Convert Rust JSG unions like C++ JSG kj::OneOf A #[jsg_oneof] union took only a value of exactly one of its variants' types, in declaration order, so it never coerced: a `String | Number` union rejected `true`, which C++ JSG converts to "true". Unions now follow C++ JSG's OneOfWrapper::tryUnwrap(), the Web IDL union conversion: variants are tried in passes (resources, other non- primitive types, exact booleans and numbers, then strings, numbers and booleans with coercion), in declaration order within a pass, and an error from a variant's conversion ends the conversion. Unions also implement Traced, so a union holding a jsg::Rc can be a resource field, and ToJS when every variant does. jsg::Rc and NonCoercible implement Type so that they can be variants. With unions converting this way, NonCoercible of a union or a resource would have coerced: it checks Type::is_exact, which for those is only a coarse check of the JavaScript type (any object, for a resource), and then runs T's conversion, string coercion included. So NonCoercible now requires the new sealed jsg::Coercible: String, bool, Number and the integer types, whose is_exact is precise, as C++ JSG's NonCoercible requires a CoercibleType. No existing use wraps another type. No production code uses #[jsg_oneof] yet, so no Worker-visible API changes. --- .../jsg-compile-tests/non-coercible-type.rs | 33 + src/rust/jsg-macros/README.md | 13 +- src/rust/jsg-macros/lib.rs | 86 +- src/rust/jsg-macros/resource.rs | 2 + src/rust/jsg-test/tests/argument_errors.rs | 6 +- src/rust/jsg-test/tests/jsg_oneof.rs | 780 +++++++++++++++--- src/rust/jsg/README.md | 37 +- src/rust/jsg/lib.rs | 39 +- src/rust/jsg/resource.rs | 16 + src/rust/jsg/wrappable.rs | 161 +++- 10 files changed, 984 insertions(+), 189 deletions(-) create mode 100644 src/rust/jsg-compile-tests/non-coercible-type.rs diff --git a/src/rust/jsg-compile-tests/non-coercible-type.rs b/src/rust/jsg-compile-tests/non-coercible-type.rs new file mode 100644 index 00000000000..4848832e356 --- /dev/null +++ b/src/rust/jsg-compile-tests/non-coercible-type.rs @@ -0,0 +1,33 @@ +// 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 + +// NonCoercible takes only the types a value is otherwise coerced to (String, bool, +// jsg::Number and the integers), as C++ JSG's takes only a CoercibleType. A union or a resource +// would convert a value as it always does, coercion included, once its JavaScript type passed a +// coarse check. + +use jsg::FromJS; +use jsg::NonCoercible; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +#[jsg_oneof] +pub enum ThingOrString { + Thing(jsg::Rc), + String(String), +} + +pub fn union_name() -> String { + as FromJS<'static>>::type_name() //~ ERROR E0277 +} + +pub fn resource_name() -> String { + > as FromJS<'static>>::type_name() //~ ERROR E0277 +} diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index ca13dc192b0..f0396b6e4bf 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -409,11 +409,14 @@ impl Env { ## `#[jsg_oneof]` -Generates `jsg::Type` and `jsg::FromJS` for a union enum — the Rust equivalent of -`kj::OneOf<…>`. Each variant must be a single-field tuple whose inner type implements -`jsg::Type` + `jsg::FromJS`. Variants are tried in declaration order using -exact-type matching; if none matches, a `TypeError` is thrown listing all expected -types. +Generates `jsg::Type`, `jsg::FromJS` and `jsg::Traced` for a union enum — the Rust +equivalent of `kj::OneOf<…>` — and `jsg::ToJS` when every variant implements it. Each +variant must be a single-field tuple whose inner type implements `jsg::Type`, +`jsg::FromJS` and `jsg::Traced`. A value converts as it would to the equivalent +`kj::OneOf`: variants are tried in passes that leave coercion for last, in declaration +order within a pass (see [Union Types](../jsg/README.md#union-types), which also lists +the differences from `kj::OneOf`). If no variant converts the value, a `TypeError` is +thrown naming the expected types, such as `Expected string or number but got symbol`. ```rust #[jsg_oneof] diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index 3dbf3831340..71d1beb613e 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -828,12 +828,17 @@ pub fn jsg_wildcard_property(attr: TokenStream, item: TokenStream) -> TokenStrea // #[jsg_oneof] // ============================================================================= -/// Generates `jsg::Type` and `jsg::FromJS` for a union enum, equivalent to -/// `kj::OneOf<…>` in C++ JSG. +/// Generates `jsg::Type`, `jsg::FromJS`, `jsg::Traced` and `jsg::ToJS` for a +/// union enum, equivalent to `kj::OneOf<…>` in C++ JSG. /// /// Each variant must be a single-field tuple variant whose inner type implements -/// `jsg::Type` and `jsg::FromJS`. The macro tries each variant in declaration -/// order using exact-type matching and returns the first that succeeds. +/// `jsg::Type`, `jsg::FromJS` and `jsg::Traced`. A value converts as it would to +/// the equivalent `kj::OneOf`: the variants are tried in passes that keep +/// coercion for last (see "Unions" in `src/rust/jsg/README.md`), and in +/// declaration order within a pass. A variant that is itself a union takes part +/// in each pass with its own variants. The union traces the variant it holds, +/// so it can be a resource field. `jsg::ToJS` is implemented when every variant +/// implements it. /// /// ```ignore /// #[jsg_oneof] @@ -872,37 +877,13 @@ pub fn jsg_oneof(_attr: TokenStream, item: TokenStream) -> TokenStream { return error(&input, "#[jsg_oneof] requires at least one variant"); } - let type_checks: Vec<_> = variants - .iter() - .map(|(variant_name, inner_type)| { - quote! { - if let Some(result) = <#inner_type as jsg::FromJS<'s>>::try_from_js_exact(lock, value) { - return result.map(|value| Some(Self::#variant_name(value))); - } - } - }) - .collect(); - + let variant_names: Vec<_> = variants.iter().map(|(n, _)| n).collect(); let inner_types: Vec<_> = variants.iter().map(|(_, t)| t).collect(); - let type_names: Vec<_> = variants - .iter() - .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::class_name() }) - .collect(); - let is_exact_checks: Vec<_> = variants .iter() .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::is_exact(value) }) .collect(); - let error_msg = quote! { - let expected: Vec<&str> = vec![#(#type_names),*]; - format!( - "Expected one of [{}] but got {}", - expected.join(", "), - type_name - ) - }; - quote! { #input @@ -928,15 +909,58 @@ pub fn jsg_oneof(_attr: TokenStream, item: TokenStream) -> TokenStream { fn from_js(lock: &mut jsg::Lock<'s>, value: jsg::v8::Local<'s, jsg::v8::Value>) -> Result { let type_name = value.type_of(lock); Self::try_from_js(lock, value)?.ok_or_else(|| { - jsg::Error::new_type_error({ #error_msg }) + jsg::Error::new_type_error(format!( + "Expected {} but got {}", + >::type_name(), + type_name + )) }) } fn try_from_js(lock: &mut jsg::Lock<'s>, value: jsg::v8::Local<'s, jsg::v8::Value>) -> Result, jsg::Error> { - #(#type_checks)* + jsg::try_union_from_js(lock, value, Self::try_from_js_in_union_pass) + } + + fn try_from_js_in_union_pass( + lock: &mut jsg::Lock<'s>, + value: jsg::v8::Local<'s, jsg::v8::Value>, + pass: jsg::UnionMember, + ) -> Result, jsg::Error> { + #( + if let Some(value) = <#inner_types as jsg::FromJS<'s>>::try_from_js_in_union_pass(lock, value, pass)? { + return Ok(Some(Self::#variant_names(value))); + } + )* Ok(None) } } + + // A union holds whatever its variant holds, so it can be a resource field. + #[automatically_derived] + impl jsg::Traced for #name { + fn holds_gc_references() -> bool { + #(<#inner_types as jsg::Traced>::holds_gc_references())||* + } + + fn trace(&self, visitor: &mut jsg::v8::GcVisitor) { + match self { + #(Self::#variant_names(value) => jsg::Traced::trace(value, visitor),)* + } + } + } + + // Only when every variant converts to JavaScript. + #[automatically_derived] + impl<'s> jsg::ToJS<'s> for #name + where + #(#inner_types: jsg::ToJS<'s>,)* + { + fn to_js(self, lock: &mut jsg::Lock<'s>) -> Result, jsg::Error> { + match self { + #(Self::#variant_names(value) => jsg::ToJS::to_js(value, lock),)* + } + } + } } .into() } diff --git a/src/rust/jsg-macros/resource.rs b/src/rust/jsg-macros/resource.rs index 2918d76455f..fb88e064dc8 100644 --- a/src/rust/jsg-macros/resource.rs +++ b/src/rust/jsg-macros/resource.rs @@ -122,6 +122,8 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> impl<'s> jsg::FromJS<'s> for #name { type ResultType = jsg::Rc; + const UNION_MEMBER: jsg::UnionMember = jsg::UnionMember::Resource; + fn type_name() -> String { ::class_name().to_owned() } diff --git a/src/rust/jsg-test/tests/argument_errors.rs b/src/rust/jsg-test/tests/argument_errors.rs index 67a4a49759e..cf43536bc05 100644 --- a/src/rust/jsg-test/tests/argument_errors.rs +++ b/src/rust/jsg-test/tests/argument_errors.rs @@ -19,10 +19,12 @@ struct Point { pub x: u32, } +// `Text` is `NonCoercible` so that a number is not converted to a string, +// leaving the union nothing to convert it to. #[jsg_oneof] #[derive(Debug)] enum Key { - Text(String), + Text(jsg::NonCoercible), Point(Point), } @@ -64,7 +66,7 @@ impl Args { #[jsg_method] pub fn take_key(&self, key: Key) -> String { match key { - Key::Text(text) => text, + Key::Text(text) => text.into_inner(), Key::Point(point) => point.x.to_string(), } } diff --git a/src/rust/jsg-test/tests/jsg_oneof.rs b/src/rust/jsg-test/tests/jsg_oneof.rs index 4853c7c4a6d..0a54fbf12dc 100644 --- a/src/rust/jsg-test/tests/jsg_oneof.rs +++ b/src/rust/jsg-test/tests/jsg_oneof.rs @@ -2,12 +2,79 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 +//! `#[jsg_oneof]` unions convert values as C++ JSG's `kj::OneOf` does +//! (`OneOfWrapper` in `jsg/value.h`). The first tests mirror the `OneOf` test in +//! `src/workerd/jsg/value-test.c++`, except `returnOneOf(null, null, null)`, +//! since a Rust enum can't be empty. Later ones port the unions in other tests +//! there. + +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + use jsg::ExceptionType; +use jsg::NonCoercible; +use jsg::Nullable; use jsg::Number; use jsg::ToJS; use jsg_macros::jsg_method; use jsg_macros::jsg_oneof; +use jsg_macros::jsg_property; use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_resource] +struct OneOfBox { + value: Number, +} + +#[jsg_resource] +impl OneOfBox { + #[jsg_property(prototype, readonly)] + pub fn get_value(&self) -> Number { + self.value + } +} + +#[jsg_struct] +#[derive(Debug, Clone, PartialEq)] +struct Point { + pub x: u32, +} + +#[jsg_struct] +#[derive(Debug, Clone, PartialEq)] +struct OptionalPoint { + pub x: Option, +} + +#[jsg_struct] +#[derive(Debug, Clone, PartialEq)] +struct OptionalSize { + pub size: Option, +} + +/// `kj::OneOf>`. +#[jsg_oneof] +#[derive(Debug, Clone)] +enum NumberStringOrBox { + Number(Number), + String(String), + Box(jsg::Rc), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StringOrBool { + String(String), + Bool(bool), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum NumberOrBool { + Number(Number), + Bool(bool), +} #[jsg_oneof] #[derive(Debug, Clone, PartialEq)] @@ -23,227 +90,682 @@ enum NumberOrString { String(String), } +/// `kj::OneOf>`. #[jsg_oneof] #[derive(Debug, Clone, PartialEq)] -enum StringOrBool { +enum NumberOrNested { + Number(Number), + Nested(StringOrBool), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StringOrBytes { String(String), - Bool(bool), + Bytes(Vec), } #[jsg_oneof] #[derive(Debug, Clone, PartialEq)] -enum ThreeTypes { +enum StringOrOptionalPoint { String(String), + Point(OptionalPoint), +} + +#[jsg_oneof] +#[derive(Debug, Clone)] +enum PointOrBox { + Point(OptionalPoint), + Box(jsg::Rc), +} + +/// Two dictionaries, which C++ JSG's `UnionTypeValidator` rejects. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum PointOrSize { + Point(OptionalPoint), + Size(OptionalSize), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum PointOrBytes { + Point(Point), + Bytes(Vec), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StrictStringOrNumber { + String(NonCoercible), + Number(Number), +} + +/// `kj::OneOf, NonCoercible>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StrictBoolOrString { + Bool(NonCoercible), + String(NonCoercible), +} + +/// `kj::OneOf>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StringOrStrings { + String(String), + Strings(Vec), +} + +/// `kj::OneOf>`, taken as a `kj::Maybe`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum OnlyStrictString { + String(NonCoercible), +} + +/// A string that converts from JavaScript but not to it. +#[derive(Debug, Clone, PartialEq)] +struct FromJsOnly(String); + +jsg::jsg_traced!(FromJsOnly); + +impl jsg::Type for FromJsOnly { + fn class_name() -> &'static str { + "FromJsOnly" + } + + fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { + value.is_string() + } +} + +impl<'s> jsg::FromJS<'s> for FromJsOnly { + type ResultType = Self; + + fn from_js( + lock: &mut jsg::Lock<'s>, + value: jsg::v8::Local<'s, jsg::v8::Value>, + ) -> Result { + >::from_js(lock, value).map(Self) + } + + fn try_from_js( + lock: &mut jsg::Lock<'s>, + value: jsg::v8::Local<'s, jsg::v8::Value>, + ) -> Result, jsg::Error> { + if !value.is_string() { + return Ok(None); + } + Self::from_js(lock, value).map(Some) + } +} + +/// A union that has no `ToJS`, since `FromJsOnly` has none. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum FromJsOnlyOrNumber { + Text(FromJsOnly), Number(Number), - Bool(bool), } #[jsg_resource] -struct EnumTestResource; +struct OneOfContext; #[jsg_resource] -#[expect(clippy::unnecessary_wraps)] -impl EnumTestResource { +impl OneOfContext { + #[jsg_method] + pub fn take_one_of(&self, value: NumberStringOrBox) -> String { + match value { + NumberStringOrBox::Number(n) => format!("number:{n}"), + NumberStringOrBox::String(s) => format!("string:{s}"), + NumberStringOrBox::Box(b) => format!("box:{}", b.value), + } + } + + #[jsg_method] + pub fn return_one_of(&self, kind: String) -> NumberStringOrBox { + match kind.as_str() { + "number" => NumberStringOrBox::Number(Number::new(123.0)), + "string" => NumberStringOrBox::String("foo".to_owned()), + _ => NumberStringOrBox::Box(jsg::Rc::new(OneOfBox { + value: Number::new(321.0), + })), + } + } + + #[jsg_method] + pub fn take_string_or_bool(&self, value: StringOrBool) -> String { + describe_string_or_bool(&value) + } + + #[jsg_method] + pub fn take_number_or_bool(&self, value: NumberOrBool) -> String { + match value { + NumberOrBool::Number(n) => format!("number:{n}"), + NumberOrBool::Bool(b) => format!("bool:{b}"), + } + } + + #[jsg_method] + pub fn take_string_or_number(&self, value: StringOrNumber) -> String { + match value { + StringOrNumber::String(s) => format!("string:{s}"), + StringOrNumber::Number(n) => format!("number:{n}"), + } + } + + #[jsg_method] + pub fn take_string_or_number_ref(&self, value: &StringOrNumber) -> String { + match value { + StringOrNumber::String(s) => format!("ref_string:{s}"), + StringOrNumber::Number(n) => format!("ref_number:{n}"), + } + } + + #[jsg_method] + pub fn take_number_or_string(&self, value: NumberOrString) -> String { + match value { + NumberOrString::Number(n) => format!("number:{n}"), + NumberOrString::String(s) => format!("string:{s}"), + } + } + + #[jsg_method] + pub fn take_nested_one_of(&self, value: NumberOrNested) -> String { + match value { + NumberOrNested::Number(n) => format!("number:{n}"), + NumberOrNested::Nested(nested) => describe_string_or_bool(&nested), + } + } + + #[jsg_method] + pub fn take_string_or_bytes(&self, value: StringOrBytes) -> String { + match value { + StringOrBytes::String(s) => format!("string:{s}"), + StringOrBytes::Bytes(b) => format!("bytes:{b:?}"), + } + } + + #[jsg_method] + pub fn take_string_or_optional_point(&self, value: StringOrOptionalPoint) -> String { + match value { + StringOrOptionalPoint::String(s) => format!("string:{s}"), + StringOrOptionalPoint::Point(p) => format!("point:{:?}", p.x), + } + } + + #[jsg_method] + pub fn take_point_or_box(&self, value: PointOrBox) -> String { + match value { + PointOrBox::Point(p) => format!("point:{:?}", p.x), + PointOrBox::Box(b) => format!("box:{}", b.value), + } + } + + #[jsg_method] + pub fn take_from_js_only_or_number(&self, value: FromJsOnlyOrNumber) -> String { + match value { + FromJsOnlyOrNumber::Text(t) => format!("text:{}", t.0), + FromJsOnlyOrNumber::Number(n) => format!("number:{n}"), + } + } + + #[jsg_method] + pub fn take_point_or_size(&self, value: PointOrSize) -> String { + match value { + PointOrSize::Point(p) => format!("point:{:?}", p.x), + PointOrSize::Size(s) => format!("size:{:?}", s.size), + } + } + #[jsg_method] - pub fn string_or_number(&self, value: StringOrNumber) -> Result { + pub fn take_point_or_bytes(&self, value: PointOrBytes) -> String { match value { - StringOrNumber::String(s) => Ok(format!("string:{s}")), - StringOrNumber::Number(n) => Ok(format!("number:{n}")), + PointOrBytes::Point(p) => format!("point:{}", p.x), + PointOrBytes::Bytes(b) => format!("bytes:{b:?}"), } } #[jsg_method] - pub fn string_or_number_ref(&self, value: &StringOrNumber) -> Result { + pub fn take_strict_string_or_number(&self, value: StrictStringOrNumber) -> String { match value { - StringOrNumber::String(s) => Ok(format!("ref_string:{s}")), - StringOrNumber::Number(n) => Ok(format!("ref_number:{n}")), + StrictStringOrNumber::String(s) => format!("string:{}", s.into_inner()), + StrictStringOrNumber::Number(n) => format!("number:{n}"), } } #[jsg_method] - pub fn string_or_bool(&self, value: StringOrBool) -> Result { + pub fn take_strict_bool_or_string(&self, value: StrictBoolOrString) -> String { match value { - StringOrBool::String(s) => Ok(format!("string:{s}")), - StringOrBool::Bool(b) => Ok(format!("bool:{b}")), + StrictBoolOrString::Bool(b) => format!("bool:{}", b.into_inner()), + StrictBoolOrString::String(s) => format!("string:{}", s.into_inner()), } } #[jsg_method] - pub fn three_types(&self, value: ThreeTypes) -> Result { + pub fn take_string_or_strings(&self, value: StringOrStrings) -> String { match value { - ThreeTypes::String(s) => Ok(format!("string:{s}")), - ThreeTypes::Number(n) => Ok(format!("number:{n}")), - ThreeTypes::Bool(b) => Ok(format!("bool:{b}")), + StringOrStrings::String(s) => format!("string:{s}"), + StringOrStrings::Strings(v) => format!("strings:{v:?}"), } } #[jsg_method] - pub fn number_or_string(&self, value: NumberOrString) -> Result { + pub fn take_maybe_strict_string(&self, value: Nullable) -> String { match value { - NumberOrString::Number(n) => Ok(format!("number:{n}")), - NumberOrString::String(s) => Ok(format!("string:{s}")), + Nullable::Some(OnlyStrictString::String(s)) => format!("string:{}", s.into_inner()), + Nullable::Null => "null".to_owned(), } } + + #[jsg_method] + pub fn take_points_or_bytes(&self, values: Vec) -> String { + format!("{}", values.len()) + } +} + +fn describe_string_or_bool(value: &StringOrBool) -> String { + match value { + StringOrBool::String(s) => format!("string:{s}"), + StringOrBool::Bool(b) => format!("bool:{b}"), + } } +/// Runs `f` with a `OneOfContext` installed as the global `cx` and a +/// `OneOfBox` holding 321 as the global `box321`. +fn with_context(f: impl for<'s, 'c> FnOnce(&mut jsg::Lock<'s>, &crate::EvalContext<'c>)) { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let cx = jsg::Rc::new(OneOfContext).to_js(lock)?; + ctx.set_global(lock, "cx", cx); + let boxed = jsg::Rc::new(OneOfBox { + value: Number::new(321.0), + }) + .to_js(lock)?; + ctx.set_global(lock, "box321", boxed); + f(lock, ctx); + Ok(()) + }); +} + +fn expect_eval(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str, expected: &str) { + let result: String = ctx.eval(lock, code).unwrap(); + assert_eq!(result, expected, "{code}"); +} + +fn expect_type_error(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str) -> String { + let err = ctx + .eval::(lock, code) + .unwrap_err() + .unwrap_jsg_err(lock); + assert_eq!(err.name, ExceptionType::TypeError, "{code}"); + err.message +} + +// ============================================================================= +// value-test.c++ "OneOf" +// ============================================================================= + #[test] -fn jsg_oneof_derives_debug_and_clone() { - let s = StringOrNumber::String("test".to_owned()); - let cloned = s.clone(); - assert_eq!(s, cloned); +fn takes_one_of_number_string_or_resource() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeOneOf(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeOneOf('foo')", "string:foo"); + expect_eval(lock, ctx, "cx.takeOneOf(box321)", "box:321"); + expect_eval(lock, ctx, "cx.takeOneOf(undefined)", "string:undefined"); + }); +} - let debug_str = format!("{s:?}"); - assert!(debug_str.contains("String")); - assert!(debug_str.contains("test")); +#[test] +fn returns_one_of() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "typeof cx.returnOneOf('number')", "number"); + expect_eval(lock, ctx, "String(cx.returnOneOf('number'))", "123"); + expect_eval(lock, ctx, "typeof cx.returnOneOf('string')", "string"); + expect_eval(lock, ctx, "cx.returnOneOf('string')", "foo"); + expect_eval( + lock, + ctx, + "String(Object.getPrototypeOf(cx.returnOneOf('box')) === \ + Object.getPrototypeOf(box321))", + "true", + ); + expect_eval(lock, ctx, "String(cx.returnOneOf('box').value)", "321"); + }); } #[test] -fn jsg_oneof_string_or_number_accepts_string() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn string_or_bool_coerces_to_string() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrBool(123)", "string:123"); + expect_eval(lock, ctx, "cx.takeStringOrBool('123')", "string:123"); + expect_eval(lock, ctx, "cx.takeStringOrBool(true)", "bool:true"); + }); +} - let result: String = ctx.eval(lock, "resource.stringOrNumber('hello')").unwrap(); - assert_eq!(result, "string:hello"); - Ok(()) +#[test] +fn number_or_bool_coerces_to_number() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeNumberOrBool(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeNumberOrBool('123')", "number:123"); + expect_eval(lock, ctx, "cx.takeNumberOrBool(true)", "bool:true"); }); } #[test] -fn jsg_oneof_string_or_number_accepts_number() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn string_or_number_coerces_boolean_to_string() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrNumber(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeStringOrNumber('123')", "string:123"); + expect_eval(lock, ctx, "cx.takeStringOrNumber(true)", "string:true"); + }); +} - let result: String = ctx.eval(lock, "resource.stringOrNumber(42)").unwrap(); - assert_eq!(result, "number:42"); - Ok(()) +#[test] +fn nested_one_of_is_flattened() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeNestedOneOf(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeNestedOneOf('123')", "string:123"); + expect_eval(lock, ctx, "cx.takeNestedOneOf(true)", "bool:true"); + expect_eval( + lock, + ctx, + "cx.takeNestedOneOf(undefined)", + "string:undefined", + ); + expect_eval(lock, ctx, "cx.takeNestedOneOf(null)", "string:null"); + expect_eval( + lock, + ctx, + "cx.takeNestedOneOf({})", + "string:[object Object]", + ); }); } +// ============================================================================= +// Unions in other value-test.c++ tests +// ============================================================================= + +// "NonCoercible Types". #[test] -fn jsg_oneof_string_or_number_rejects_boolean() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn non_coercible_variants_take_only_their_own_type() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStrictBoolOrString(false)", "bool:false"); + expect_eval(lock, ctx, "cx.takeStrictBoolOrString('')", "string:"); + assert_eq!( + expect_type_error(lock, ctx, "cx.takeStrictBoolOrString(new String(''))"), + "Failed to execute 'takeStrictBoolOrString' on 'OneOfContext': parameter 1 is not \ + of type 'boolean or string'." + ); + }); +} - let err = ctx - .eval::(lock, "resource.stringOrNumber(true)") - .unwrap_err() - .unwrap_jsg_err(lock); - assert_eq!(err.name, ExceptionType::TypeError); - assert!(err.message.contains("string")); - assert!(err.message.contains("number")); - Ok(()) +// "Sequence of types": a string is iterable, but goes to the string variant. +#[test] +fn string_is_not_taken_as_a_sequence() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrStrings('aa')", "string:aa"); + expect_eval( + lock, + ctx, + "cx.takeStringOrStrings(['b', 'b'])", + r#"strings:["b", "b"]"#, + ); }); } +// "kj::Maybe noSubstituteNull", whose union C++ tests with `noSubstituteNull`, as +// Rust JSG always converts `Nullable`. #[test] -fn jsg_oneof_string_or_bool_accepts_both() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn nullable_union_rejects_a_value_no_variant_takes() { + with_context(|lock, ctx| { + assert_eq!( + expect_type_error(lock, ctx, "cx.takeMaybeStrictString({})"), + "Failed to execute 'takeMaybeStrictString' on 'OneOfContext': parameter 1 is not \ + of type 'string'." + ); + expect_eval(lock, ctx, "cx.takeMaybeStrictString('a')", "string:a"); + expect_eval(lock, ctx, "cx.takeMaybeStrictString(null)", "null"); + }); +} - let result: String = ctx.eval(lock, "resource.stringOrBool('test')").unwrap(); - assert_eq!(result, "string:test"); +// ============================================================================= +// Pass order +// ============================================================================= - let result: String = ctx.eval(lock, "resource.stringOrBool(true)").unwrap(); - assert_eq!(result, "bool:true"); - Ok(()) +#[test] +fn pass_order_beats_declaration_order() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrNumber('42')", "string:42"); + expect_eval(lock, ctx, "cx.takeStringOrNumber(42)", "number:42"); + expect_eval(lock, ctx, "cx.takeNumberOrString('42')", "string:42"); + expect_eval(lock, ctx, "cx.takeNumberOrString(42)", "number:42"); }); } #[test] -fn jsg_oneof_three_types_accepts_all() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn resource_is_tried_before_dictionary() { + with_context(|lock, ctx| { + // A dictionary would take any object, the resource included. + expect_eval(lock, ctx, "cx.takePointOrBox(box321)", "box:321"); + expect_eval(lock, ctx, "cx.takePointOrBox({ x: 1 })", "point:Some(1)"); + }); +} - let result: String = ctx.eval(lock, "resource.threeTypes('hello')").unwrap(); - assert_eq!(result, "string:hello"); +#[test] +fn sequence_is_tried_before_string_coercion() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrBytes([1, 2])", "bytes:[1, 2]"); + expect_eval(lock, ctx, "cx.takeStringOrBytes(5)", "string:5"); + }); +} - let result: String = ctx.eval(lock, "resource.threeTypes(123.5)").unwrap(); - assert_eq!(result, "number:123.5"); +#[test] +fn dictionary_is_tried_before_string_coercion() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeStringOrOptionalPoint({ x: 1 })", + "point:Some(1)", + ); + // Like a C++ dictionary, null and undefined are an empty dictionary. + expect_eval( + lock, + ctx, + "cx.takeStringOrOptionalPoint(null)", + "point:None", + ); + expect_eval( + lock, + ctx, + "cx.takeStringOrOptionalPoint(undefined)", + "point:None", + ); + expect_eval(lock, ctx, "cx.takeStringOrOptionalPoint('a')", "string:a"); + }); +} - let result: String = ctx.eval(lock, "resource.threeTypes(false)").unwrap(); - assert_eq!(result, "bool:false"); - Ok(()) +#[test] +fn non_coercible_string_leaves_coercion_to_number() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStrictStringOrNumber('a')", "string:a"); + expect_eval(lock, ctx, "cx.takeStrictStringOrNumber(5)", "number:5"); + expect_eval(lock, ctx, "cx.takeStrictStringOrNumber(true)", "number:1"); }); } #[test] -fn jsg_oneof_three_types_rejects_null_and_undefined() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn big_int_goes_to_a_numeric_variant_first() { + with_context(|lock, ctx| { + // A number can't hold a BigInt, so the conversion throws rather than + // falling through to the string variant. + let message = expect_type_error(lock, ctx, "cx.takeStringOrNumber(1n)"); + assert!(message.contains("BigInt"), "{message}"); + // Without a numeric variant, a BigInt becomes a string. + expect_eval(lock, ctx, "cx.takeStringOrBool(1n)", "string:1"); + }); +} - let err = ctx - .eval::(lock, "resource.threeTypes(null)") - .unwrap_err() - .unwrap_jsg_err(lock); - assert_eq!(err.name, ExceptionType::TypeError); +#[test] +fn conversion_errors_are_kept() { + with_context(|lock, ctx| { + let message = expect_type_error(lock, ctx, "cx.takeStringOrNumber(Symbol())"); + assert!(message.contains("Symbol"), "{message}"); + }); +} +#[test] +fn exception_from_coercion_stops_the_conversion() { + with_context(|lock, ctx| { + // The string pass throws, so the number pass never calls valueOf(). let err = ctx - .eval::(lock, "resource.threeTypes(undefined)") + .eval::( + lock, + "globalThis.valueOfCalled = false; cx.takeStringOrNumber({ \ + toString() { throw new RangeError('from toString'); }, \ + valueOf() { globalThis.valueOfCalled = true; return 1; } })", + ) .unwrap_err() .unwrap_jsg_err(lock); - assert_eq!(err.name, ExceptionType::TypeError); - Ok(()) + assert_eq!(err.name, ExceptionType::RangeError); + assert_eq!(err.message, "from toString"); + expect_eval(lock, ctx, "String(globalThis.valueOfCalled)", "false"); }); } #[test] -fn jsg_oneof_variant_order_matches_declaration() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn value_no_variant_takes_is_a_type_error() { + with_context(|lock, ctx| { + assert_eq!( + expect_type_error(lock, ctx, "cx.takePointOrBytes(5)"), + "Failed to execute 'takePointOrBytes' on 'OneOfContext': parameter 1 is not of \ + type 'Point or Array'." + ); + }); +} + +#[test] +fn nested_value_no_variant_takes_names_the_union() { + with_context(|lock, ctx| { + let message = expect_type_error(lock, ctx, "cx.takePointsOrBytes([5])"); + assert!( + message.contains("Expected Point or Array but got number"), + "{message}" + ); + }); +} - // StringOrNumber has String first, Number second - let result: String = ctx.eval(lock, "resource.stringOrNumber('42')").unwrap(); - assert_eq!(result, "string:42"); +// ============================================================================= +// Rust-side behavior +// ============================================================================= - let result: String = ctx.eval(lock, "resource.stringOrNumber(42)").unwrap(); - assert_eq!(result, "number:42"); +static TRACED_BOX_DROPS: AtomicUsize = AtomicUsize::new(0); - // NumberOrString has Number first, String second - let result: String = ctx.eval(lock, "resource.numberOrString(42)").unwrap(); - assert_eq!(result, "number:42"); +#[jsg_resource] +struct TracedBox; - let result: String = ctx.eval(lock, "resource.numberOrString('42')").unwrap(); - assert_eq!(result, "string:42"); - Ok(()) - }); +impl Drop for TracedBox { + fn drop(&mut self) { + TRACED_BOX_DROPS.fetch_add(1, Ordering::SeqCst); + } } +#[jsg_resource] +impl TracedBox {} + +#[jsg_oneof] +#[derive(Clone)] +enum TracedBoxOrNumber { + Box(jsg::Rc), + Number(Number), +} + +#[jsg_resource] +struct HoldsUnion { + pub held: TracedBoxOrNumber, +} + +#[jsg_resource] +impl HoldsUnion {} + +// The union traces its resource variant, so a resource holding the union keeps the +// variant's resource alive, and both are collected together. #[test] -fn jsg_oneof_reference_parameter() { +fn union_field_traces_its_resource_variant() { + TRACED_BOX_DROPS.store(0, Ordering::SeqCst); let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); - - let result: String = ctx - .eval(lock, "resource.stringOrNumberRef('hello')") - .unwrap(); - assert_eq!(result, "ref_string:hello"); + let held = jsg::Rc::new(TracedBox); + let parent = jsg::Rc::new(HoldsUnion { + held: TracedBoxOrNumber::Box(held.clone()), + }); + // Give the inner box a wrapper too, so that only tracing keeps it alive. + let _wrapper = held.clone().to_js(lock)?; + let wrapped = parent.clone().to_js(lock)?; + ctx.set_global(lock, "parent", wrapped); + std::mem::drop(held); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(TRACED_BOX_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); - let result: String = ctx.eval(lock, "resource.stringOrNumberRef(42)").unwrap(); - assert_eq!(result, "ref_number:42"); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(TRACED_BOX_DROPS.load(Ordering::SeqCst), 1); Ok(()) }); } + +#[test] +fn union_holds_gc_references_if_a_variant_does() { + use jsg::Traced; + assert!(TracedBoxOrNumber::holds_gc_references()); + assert!(!StringOrNumber::holds_gc_references()); +} + +// C++ JSG rejects a union of two dictionaries at compile time, so this has no +// C++ counterpart; Rust takes the first in declaration order. +#[test] +fn declaration_order_decides_within_a_pass() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takePointOrSize({})", "point:None"); + expect_eval(lock, ctx, "cx.takePointOrSize({ size: 1 })", "point:None"); + }); +} + +#[test] +fn union_without_to_js_converts_from_js() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeFromJsOnlyOrNumber('a')", "text:a"); + expect_eval(lock, ctx, "cx.takeFromJsOnlyOrNumber(5)", "number:5"); + }); +} + +#[test] +fn reference_parameter() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeStringOrNumberRef('hello')", + "ref_string:hello", + ); + expect_eval(lock, ctx, "cx.takeStringOrNumberRef(42)", "ref_number:42"); + }); +} + +#[test] +fn derives_debug_and_clone() { + let s = StringOrNumber::String("test".to_owned()); + let cloned = s.clone(); + assert_eq!(s, cloned); + + let debug_str = format!("{s:?}"); + assert!(debug_str.contains("String")); + assert!(debug_str.contains("test")); +} diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index fa282014c45..84fb2010534 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -636,7 +636,7 @@ use jsg_macros::jsg_oneof; #[derive(Debug, Clone)] enum StringOrNumber { String(String), - Number(f64), + Number(jsg::Number), } // In a jsg_method: @@ -648,7 +648,40 @@ pub fn process(&self, value: StringOrNumber) -> Result { } ``` -This is similar to `kj::OneOf<>` in C++ JSG. +A union converts a value as the equivalent `kj::OneOf<>` does in C++ JSG, following +Web IDL's union conversion. It tries its variants in passes, in declaration order +within each pass, and takes the first that converts: + +1. resource types (`jsg::Rc`); +2. every type that is not a string, numeric or boolean type, such as resources, arrays, + typed arrays and structs; +3. boolean types, if the value is a boolean; +4. numeric types, if the value is a number or a `BigInt`; +5. string types; +6. numeric types; +7. boolean types. + +So a value is coerced only when no variant takes it as it is, and coerced to a string +in preference to a number or boolean: `StringOrNumber` takes `true` as `"true"`, and +`null` as `"null"`. An error from a variant's conversion, such as a `Symbol` that +cannot become a string, or an exception thrown by `toString()`, is thrown rather than +trying another variant. A `NonCoercible` variant, where `T` is `String`, `bool`, +`jsg::Number` or an integer type, takes part in `T`'s passes but converts only a value of +exactly that type. A variant that is itself a union takes part +in each pass with its own variants, as a nested `kj::OneOf` does. + +The union traces the variant it holds, so a union with a `jsg::Rc` variant can be a +resource field. It implements `jsg::ToJS` when every variant does. + +Two differences from `kj::OneOf<>`: + +- Variants must implement `jsg::Type`, which the integer types, `i64`, `SystemTime` and + `Nullable` don't, so they can't be variants yet. +- C++ checks at compile time that a union follows Web IDL's rules, for example at most + one string, numeric, boolean, dictionary-like or sequence-like type, and no duplicate + types (`webidl::UnionTypeValidator` in `src/workerd/jsg/web-idl.h`). Rust doesn't + check them; in a union that breaks them, the first variant that converts the value, in + pass and declaration order, wins. ## Feature Flags (Compatibility Flags) diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 201979b5231..72cfc3e7784 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -71,6 +71,10 @@ pub use v8::ffi::ExceptionType; pub use wrappable::FromJS; pub use wrappable::ToJS; pub use wrappable::Traced; +#[doc(hidden)] +pub use wrappable::UnionMember; +#[doc(hidden)] +pub use wrappable::try_union_from_js; #[cxx::bridge(namespace = "workerd::rust::jsg")] #[expect(unsafe_code, reason = "the cxx bridge expands to unsafe FFI glue")] @@ -487,8 +491,8 @@ impl Error { /// /// # Supported Types /// -/// Any type implementing the [`Type`] trait can be used with `NonCoercible`. Built-in -/// implementations include: +/// `T` is one of the types a value is otherwise coerced to, those that implement +/// [`Coercible`]: /// /// - `NonCoercible` - only accepts JavaScript strings /// - `NonCoercible` - only accepts JavaScript booleans @@ -554,6 +558,33 @@ impl Deref for NonCoercible { } } +/// A type that [`NonCoercible`] can wrap: one that JavaScript values are coerced to. +/// +/// These are `String`, `bool`, [`Number`] and the integer types. C++ JSG's `CoercibleType` +/// (jsg/jsg.h) is the first three; Rust JSG adds the integers. +/// +/// For these, [`Type::is_exact`] is a precise test of the JavaScript type. Other types, such as +/// resources and unions, have no coercion of their own to disable, and their `is_exact` is only +/// a coarse test. The trait is sealed. +#[diagnostic::on_unimplemented( + message = "`NonCoercible<{Self}>` is not supported", + note = "`NonCoercible` takes only `String`, `bool`, `jsg::Number` or an integer type" +)] +pub trait Coercible: Type + coercible::Sealed {} + +mod coercible { + pub trait Sealed {} +} + +macro_rules! impl_coercible { + ($($t:ty),* $(,)?) => { + $(impl coercible::Sealed for $t {} + impl Coercible for $t {})* + }; +} + +impl_coercible!(String, bool, Number, u8, u16, u32, i8, i16, i32, i64, u64); + /// A JavaScript number (an IEEE 754 double-precision float). /// /// `Number` is a plain value, not a V8 handle, so it needs no `Lock` until it converts to @@ -1061,6 +1092,10 @@ pub trait Type: Sized { /// Returns true if the V8 value is exactly this type (no coercion). /// Used by `NonCoercible` to reject values that would require coercion. + /// + /// It is precise only for the [`Coercible`] types that `NonCoercible` takes. For other + /// types it can only check the JavaScript type, without a lock: a resource's accepts any + /// object, and a union's any value that one of its variants' accepts. fn is_exact(value: v8::Local<'_, v8::Value>) -> bool; } diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index a9c9bb334cb..ef268be9194 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -26,6 +26,7 @@ use crate::Member; use crate::ToJS; use crate::Traced; use crate::Type; +use crate::UnionMember; use crate::feature_flags::compatibility_flags; use crate::isolate_state::IsolateState; use crate::v8; @@ -180,9 +181,24 @@ impl<'s, R: Resource + 'static> ToJS<'s> for Rc { } } +impl Type for Rc { + fn class_name() -> &'static str { + R::class_name() + } + + // Any object: telling whether it wraps an `R` takes a lock, which `is_exact` doesn't have. + // `FromJS::try_from_js` makes that check. A resource is not `Coercible`, so no + // `NonCoercible>` relies on this. + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_object() + } +} + impl<'s, R: Resource + 'static> FromJS<'s> for Rc { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Resource; + fn type_name() -> String { R::class_name().to_owned() } diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index 62a1b36eb24..67ad79118e4 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -32,6 +32,8 @@ //! | `Result` | `T` or throws | //! | `NonCoercible` | `T` (strict type checking) | //! | `T: Struct` | `object` | +//! | `#[jsg_oneof]` enum | any of its variants' types (see `src/rust/jsg/README.md`) | +//! | `jsg::Rc` | the resource's JavaScript object | //! | `Vec` | `Array` | //! | `&[T]` | `Array` (parameter only) | //! | `Dict` | `Record`: an object other than an array | @@ -68,6 +70,7 @@ //! `uint64_t`: a `BigInt` that does not fit is a `TypeError`, and any other value converts as //! above. +use crate::Coercible; use crate::Dict; use crate::Error; use crate::Lenient; @@ -390,8 +393,11 @@ pub trait ToJS<'s>: Sized { /// Trait for converting JavaScript values to Rust. /// /// `from_js()` is the normal, required conversion path. `try_from_js()` is an -/// internal shallow-probing path used by [`Lenient`]. Implementations only need -/// to override `try_from_js()` when their top-level type can fail to match. +/// internal shallow-probing path used by [`Lenient`] and by `#[jsg_oneof]` +/// unions. Implementations only need to override `try_from_js()` when their +/// top-level type can fail to match, and the override must return `Ok(None)` +/// rather than an error for a value of the wrong type, so that a union can go +/// on to its other variants. pub trait FromJS<'s>: Sized { type ResultType; @@ -429,26 +435,109 @@ pub trait FromJS<'s>: Sized { Self::from_js(lock, value).map(Some) } - /// Tries to convert only if the JavaScript type matches exactly. - /// Returns `None` if the type doesn't match, `Some(result)` if conversion was attempted. - /// Used by `#[jsg_oneof]` macro to try each variant without coercion. - fn try_from_js_exact( + /// The type's kind in the union conversion algorithm; see [`UnionMember`]. + #[doc(hidden)] + const UNION_MEMBER: UnionMember = UnionMember::Fallible; + + /// Converts `value` during `pass` of a union conversion, or returns + /// `Ok(None)` if this type does not take part in `pass` or does not match. + /// A union overrides this to try its own variants, which flattens nested + /// unions into the enclosing union's passes. + #[doc(hidden)] + fn try_from_js_in_union_pass( lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, - ) -> Option> - where - Self: Type, - { - if !Self::is_exact(value) { - return None; + pass: UnionMember, + ) -> Result, Error> { + if Self::UNION_MEMBER.takes_part_in(pass) { + Self::try_from_js(lock, value) + } else { + Ok(None) } + } +} - match Self::try_from_js(lock, value) { - Ok(Some(result)) => Some(Ok(result)), - Ok(None) => None, - Err(err) => Some(Err(err)), +// ============================================================================= +// Unions (`#[jsg_oneof]`) +// ============================================================================= + +/// The kind of a `#[jsg_oneof]` variant, which decides the passes in which the +/// variant is tried. +/// +/// A union converts a value as C++ JSG's `OneOfWrapper::tryUnwrap()` +/// (jsg/value.h) does, following Web IDL's union conversion: it makes a series +/// of passes over its variants and takes the first one that converts, trying +/// in order +/// +/// 1. resource types, +/// 2. every type that is not a string, numeric or boolean type, +/// 3. boolean types, if the value is a boolean, +/// 4. numeric types, if the value is a number or a `BigInt`, +/// 5. string types, +/// 6. numeric types, +/// 7. boolean types. +/// +/// Passes 5 to 7 coerce, so a value becomes a string only if no other variant +/// takes it, and becomes a number or boolean only if no string variant +/// converts it either. An error from a variant's conversion, such as a +/// `Symbol` that cannot become a string, ends the conversion with that error +/// rather than trying another variant. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnionMember { + /// A resource type (`jsg::Rc`). + Resource, + /// Any other type that is not a string, numeric or boolean type, such as + /// an array, a typed array or a struct (C++ JSG's `IsFallibleType`). + Fallible, + /// A string type. + String, + /// A numeric type. + Numeric, + /// A boolean type. + Boolean, +} + +impl UnionMember { + /// Whether a variant of this kind is tried in `pass`. + const fn takes_part_in(self, pass: Self) -> bool { + matches!( + (self, pass), + (Self::Resource, Self::Resource | Self::Fallible) + | (Self::Fallible, Self::Fallible) + | (Self::String, Self::String) + | (Self::Numeric, Self::Numeric) + | (Self::Boolean, Self::Boolean) + ) + } +} + +/// Converts `value` to a union by running `try_pass` for each pass of the +/// union conversion algorithm (see [`UnionMember`]) until one converts it. +#[doc(hidden)] +pub fn try_union_from_js<'s, R>( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + try_pass: impl Fn(&mut Lock<'s>, v8::Local<'s, v8::Value>, UnionMember) -> Result, Error>, +) -> Result, Error> { + let passes = [ + (true, UnionMember::Resource), + (true, UnionMember::Fallible), + (value.is_boolean(), UnionMember::Boolean), + ( + value.is_number() || value.is_big_int(), + UnionMember::Numeric, + ), + (true, UnionMember::String), + (true, UnionMember::Numeric), + (true, UnionMember::Boolean), + ]; + for (applies, pass) in passes { + if applies && let Some(result) = try_pass(lock, value, pass)? { + return Ok(Some(result)); } } + Ok(None) } // ============================================================================= @@ -478,6 +567,8 @@ impl<'s> ToJS<'s> for bool { impl<'s> FromJS<'s> for bool { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Boolean; + fn type_name() -> String { "boolean".to_owned() } @@ -520,6 +611,8 @@ impl<'s> ToJS<'s> for &str { impl<'s> FromJS<'s> for std::string::String { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::String; + fn type_name() -> String { "string".to_owned() } @@ -572,6 +665,8 @@ impl<'s> ToJS<'s> for f32 { impl<'s> FromJS<'s> for Number { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { "number".to_owned() } @@ -601,6 +696,8 @@ impl Type for &str { impl<'s> FromJS<'s> for &str { type ResultType = String; + const UNION_MEMBER: UnionMember = UnionMember::String; + fn type_name() -> String { "string".to_owned() } @@ -616,6 +713,8 @@ impl<'s> FromJS<'s> for &str { impl<'s, T: FromJS<'s, ResultType = T>> FromJS<'s> for &T { type ResultType = T; + const UNION_MEMBER: UnionMember = T::UNION_MEMBER; + fn type_name() -> String { T::type_name() } @@ -633,6 +732,14 @@ impl<'s, T: FromJS<'s, ResultType = T>> FromJS<'s> for &T { ) -> Result, Error> { T::try_from_js(lock, value) } + + fn try_from_js_in_union_pass( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + pass: UnionMember, + ) -> Result, Error> { + T::try_from_js_in_union_pass(lock, value, pass) + } } // Slice type - allows functions to accept &[T] parameters. @@ -722,6 +829,8 @@ macro_rules! impl_integer { impl<'s> FromJS<'s> for $type { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { $name.to_owned() } @@ -812,6 +921,8 @@ impl<'s> ToJS<'s> for i64 { impl<'s> FromJS<'s> for i64 { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { "bigint".to_owned() } @@ -999,7 +1110,7 @@ impl<'s, T: ToJS<'s>> ToJS<'s> for Option { } } -impl<'s, T: Type + ToJS<'s>> ToJS<'s> for NonCoercible { +impl<'s, T: Coercible + ToJS<'s>> ToJS<'s> for NonCoercible { fn to_js(self, lock: &mut Lock<'s>) -> Result, Error> { self.into_inner().to_js(lock) } @@ -1057,9 +1168,23 @@ impl<'s, T: FromJS<'s>> FromJS<'s> for Option { } } -impl<'s, T: Type + FromJS<'s>> FromJS<'s> for NonCoercible { +impl Type for NonCoercible { + fn class_name() -> &'static str { + T::class_name() + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + T::is_exact(value) + } +} + +// Like C++ `NonCoercible` (jsg/web-idl.h), this is the same kind of union +// member as `T`, but converts only a value of exactly that type. +impl<'s, T: Coercible + FromJS<'s>> FromJS<'s> for NonCoercible { type ResultType = NonCoercible; + const UNION_MEMBER: UnionMember = T::UNION_MEMBER; + fn type_name() -> String { T::type_name() } From 3cb955623cd697f8993d8a2f0735fb8468091b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 28 Oct 2026 12:32:21 +0100 Subject: [PATCH 21/23] Let Rust JSG unions hold every type kj::OneOf can A #[jsg_oneof] union now takes the variant types that C++ JSG's kj::OneOf can hold and Rust JSG couldn't: the integer types, SystemTime, Nullable, Dict and V8 handles. Each converts in the pass of its C++ counterpart. Variants need the new hidden jsg::UnionVariant, which every Type implements, and which Option and Lenient implement only so that a union can reject them with an explanation. A union of handles takes their scope as its one lifetime parameter. Handle parameters gain the FromJS impls that C++ JSG's V8HandleWrapper has: Local, Local, Local, Local and Local take a value of exactly that type. Unions are now checked at compile time against Web IDL's rules, as webidl::UnionTypeValidator checks a kj::OneOf. Each type declares its Web IDL categories, a union sums its variants' in a constant that fails to evaluate when a rule is broken, counting nested unions and nullable types, and a type used twice gets conflicting impls of a local trait, with a Nullable variant counted as T, as C++ JSG flattens kj::Maybe. Since Vec is a sequence, as C++ JSG's kj::Array is for any T but kj::byte, Rust JSG has no buffer source types: a typed array handle is in no category, like C++ JSG's v8::Local. A test union of two structs, which C++ rejects, is removed. The union support code moves to its own modules. --- src/rust/jsg-compile-tests/ok-oneof.rs | 57 ++ .../oneof-duplicate-types.rs | 31 + .../oneof-generic-parameters.rs | 23 + .../jsg-compile-tests/oneof-variant-type.rs | 15 + .../jsg-compile-tests/oneof-web-idl-rules.rs | 105 ++++ .../owned-fromjs-bound-rejects-local.rs | 2 +- src/rust/jsg-macros/README.md | 15 +- src/rust/jsg-macros/lib.rs | 139 +---- src/rust/jsg-macros/oneof-test.rs | 45 ++ src/rust/jsg-macros/oneof.rs | 357 +++++++++++ src/rust/jsg-test/tests/jsg_oneof.rs | 32 - src/rust/jsg-test/tests/jsg_oneof_variants.rs | 554 ++++++++++++++++++ src/rust/jsg-test/tests/mod.rs | 1 + src/rust/jsg/README.md | 60 +- src/rust/jsg/lib.rs | 18 +- src/rust/jsg/oneof-test.rs | 165 ++++++ src/rust/jsg/oneof.rs | 315 ++++++++++ src/rust/jsg/resource.rs | 4 + src/rust/jsg/wrappable.rs | 229 +++++--- 19 files changed, 1906 insertions(+), 261 deletions(-) create mode 100644 src/rust/jsg-compile-tests/ok-oneof.rs create mode 100644 src/rust/jsg-compile-tests/oneof-duplicate-types.rs create mode 100644 src/rust/jsg-compile-tests/oneof-generic-parameters.rs create mode 100644 src/rust/jsg-compile-tests/oneof-variant-type.rs create mode 100644 src/rust/jsg-compile-tests/oneof-web-idl-rules.rs create mode 100644 src/rust/jsg-macros/oneof-test.rs create mode 100644 src/rust/jsg-macros/oneof.rs create mode 100644 src/rust/jsg-test/tests/jsg_oneof_variants.rs create mode 100644 src/rust/jsg/oneof-test.rs create mode 100644 src/rust/jsg/oneof.rs diff --git a/src/rust/jsg-compile-tests/ok-oneof.rs b/src/rust/jsg-compile-tests/ok-oneof.rs new file mode 100644 index 00000000000..54fa06daafb --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-oneof.rs @@ -0,0 +1,57 @@ +// 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 unions that Web IDL allows compile, including several resources, a handle of any type with +// a dictionary, and an object handle with primitives. + +use jsg::Nullable; +use jsg::Number; +use jsg::v8; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_struct] +pub struct Point { + pub x: Number, +} + +#[jsg_resource] +pub struct First; + +#[jsg_resource] +impl First {} + +#[jsg_resource] +pub struct Second; + +#[jsg_resource] +impl Second {} + +#[jsg_oneof] +pub enum Everything<'s> { + First(jsg::Rc), + Second(jsg::Rc), + Point(Point), + Numbers(Vec), + Bytes(v8::Local<'s, v8::Uint8Array>), + String(String), + Number(i32), + Bool(bool), + Date(std::time::SystemTime), +} + +#[jsg_oneof] +pub enum Handles<'s> { + Object(v8::Local<'s, v8::Object>), + String(Nullable), + Number(v8::Local<'s, v8::BigInt>), + Function(v8::Local<'s, v8::Function>), +} + +#[jsg_oneof] +pub enum AnyOrPoint<'s> { + Any(v8::Local<'s, v8::Value>), + Point(Point), +} diff --git a/src/rust/jsg-compile-tests/oneof-duplicate-types.rs b/src/rust/jsg-compile-tests/oneof-duplicate-types.rs new file mode 100644 index 00000000000..6b4e57e32b5 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-duplicate-types.rs @@ -0,0 +1,31 @@ +// 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 #[jsg_oneof] union may not contain the same type twice, as C++ JSG's +// webidl::UnionTypeValidator requires of a kj::OneOf, even of a kind it may have more than one +// of, such as resources. As there, a nullable type counts as its inner type. + +use jsg::Nullable; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +type SameThing = jsg::Rc; + +#[jsg_oneof] +pub enum SameResourceTwice { + First(jsg::Rc), + Second(SameThing), //~ ERROR E0119 +} + +#[jsg_oneof] +pub enum ResourceAndNullableResource { + Thing(jsg::Rc), + Nullable(Nullable>), //~ ERROR E0119 +} diff --git a/src/rust/jsg-compile-tests/oneof-generic-parameters.rs b/src/rust/jsg-compile-tests/oneof-generic-parameters.rs new file mode 100644 index 00000000000..a034f6e31e9 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-generic-parameters.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 #[jsg_oneof] union, like a kj::OneOf, has no type parameters. It may have one lifetime +// parameter, for the scope of the handles it holds. + +use jsg::v8; +use jsg_macros::jsg_oneof; + +#[jsg_oneof] +pub enum TypeParameter { + //~^ ERROR only one lifetime parameter + Value(T), + String(String), +} + +#[jsg_oneof] +pub enum TwoLifetimes<'a, 'b> { + //~^ ERROR only one lifetime parameter + First(v8::Local<'a, v8::Object>), + Second(v8::Local<'b, v8::String>), +} diff --git a/src/rust/jsg-compile-tests/oneof-variant-type.rs b/src/rust/jsg-compile-tests/oneof-variant-type.rs new file mode 100644 index 00000000000..b08e51f67cc --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-variant-type.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 #[jsg_oneof] variant is a type that converts from JavaScript and that C++ JSG's kj::OneOf +// could hold. + +use jsg_macros::jsg_oneof; + +#[jsg_oneof] //~ ERROR E0277 +pub enum FloatOrString { + Float(f64), //~ ERROR cannot be a `#[jsg_oneof]` variant + //~| ERROR E0277 + String(String), +} diff --git a/src/rust/jsg-compile-tests/oneof-web-idl-rules.rs b/src/rust/jsg-compile-tests/oneof-web-idl-rules.rs new file mode 100644 index 00000000000..a92e08b1343 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-web-idl-rules.rs @@ -0,0 +1,105 @@ +// 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 #[jsg_oneof] union follows Web IDL's rules for unions, as C++ JSG's +// webidl::UnionTypeValidator checks a kj::OneOf at compile time: at most one type of each kind +// that a value can't tell apart, counting the types of nested unions and nullable types. + +use jsg::Dict; +use jsg::Nullable; +use jsg::Number; +use jsg::v8; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_struct] +pub struct Point { + pub x: Number, +} + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +#[jsg_oneof] //~ ERROR E0080: at most one boolean type +pub enum TwoBooleans { + Bool(bool), + Strict(jsg::NonCoercible), +} + +#[jsg_oneof] //~ ERROR E0080: at most one numeric type +pub enum TwoNumerics { + Number(Number), + Integer(u32), +} + +#[jsg_oneof] //~ ERROR E0080: at most one string type +pub enum TwoStrings<'s> { + String(String), + Handle(v8::Local<'s, v8::String>), +} + +#[jsg_oneof] //~ ERROR E0080: an object type only if +pub enum ObjectAndResource<'s> { + Object(v8::Local<'s, v8::Object>), + Thing(jsg::Rc), +} + +#[jsg_oneof] //~ ERROR E0080: an object type only if +pub enum ObjectAndRecord<'s> { + Object(v8::Local<'s, v8::Object>), + Dict(Dict), +} + +#[jsg_oneof] //~ ERROR E0080: an object type only if +pub enum ObjectAndSequence<'s> { + Object(v8::Local<'s, v8::Object>), + Sequence(Vec), +} + +#[jsg_oneof] //~ ERROR E0080: at most one dictionary-like type +pub enum DictionaryAndRecord { + Point(Point), + Dict(Dict), +} + +#[jsg_oneof] //~ ERROR E0080: at most one sequence-like type +pub enum TwoSequences { + Numbers(Vec), + Strings(Vec), +} + +#[jsg_oneof] //~ ERROR E0080: at most one nullable or dictionary type +pub enum NullableAndDictionary { + Number(Nullable), + Point(Point), +} + +#[jsg_oneof] //~ ERROR E0080: at most one nullable or dictionary type +pub enum TwoNullables { + Number(Nullable), + Integer(Nullable), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain Option or Lenient +pub enum Optional { + Number(Option), + String(String), +} + +#[jsg_oneof] +pub enum StringOrBool { + String(String), + Bool(bool), +} + +// The nested union's string type counts as the outer union's. +#[jsg_oneof] //~ ERROR E0080: at most one string type +pub enum NestedStrings { + String(String), + Nested(StringOrBool), +} diff --git a/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs b/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs index 39557933e8b..5fe877b2946 100644 --- a/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs +++ b/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs @@ -10,5 +10,5 @@ use jsg::v8; pub fn owned FromJS<'s, ResultType = T>>() {} pub fn t() { - owned::>(); //~ ERROR E0277 + owned::>(); //~ ERROR implementation of `FromJS` is not general enough } diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index f0396b6e4bf..cb289174093 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -411,12 +411,15 @@ impl Env { Generates `jsg::Type`, `jsg::FromJS` and `jsg::Traced` for a union enum — the Rust equivalent of `kj::OneOf<…>` — and `jsg::ToJS` when every variant implements it. Each -variant must be a single-field tuple whose inner type implements `jsg::Type`, -`jsg::FromJS` and `jsg::Traced`. A value converts as it would to the equivalent -`kj::OneOf`: variants are tried in passes that leave coercion for last, in declaration -order within a pass (see [Union Types](../jsg/README.md#union-types), which also lists -the differences from `kj::OneOf`). If no variant converts the value, a `TypeError` is -thrown naming the expected types, such as `Expected string or number but got symbol`. +variant must be a single-field tuple whose inner type converts from JavaScript and is +`jsg::Type`. A union that holds handles has one lifetime parameter, +their scope, and no `jsg::Traced` that can be used; a union has no other generic +parameters. A value converts as it would to the equivalent `kj::OneOf`: variants are +tried in passes that leave coercion for last, in declaration order within a pass. If no +variant converts the value, a `TypeError` is thrown naming the expected types, such as +`Expected string or number but got symbol`. Like `kj::OneOf`, a union that breaks Web +IDL's rules for unions, such as one with two string types, doesn't compile. See +[Union Types](../jsg/README.md#union-types). ```rust #[jsg_oneof] diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index 71d1beb613e..9e40a523d4e 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -22,6 +22,7 @@ #![forbid(unsafe_code)] +mod oneof; mod resource; mod trace; mod utils; @@ -31,16 +32,13 @@ use proc_macro::TokenStream; use quote::quote; use resource::generate_resource_impl; use resource::generate_resource_struct; -use syn::Data; use syn::DeriveInput; -use syn::Fields; use syn::FnArg; use syn::ItemFn; use syn::ItemImpl; use syn::parse_macro_input; use trace::generate_trace_statements; use utils::elide_named_lifetimes; -use utils::error; use utils::extract_name_attribute; use utils::extract_named_fields; use utils::fast_api_signature; @@ -123,6 +121,9 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { #input impl jsg::Type for #name { + // A Web IDL dictionary type, like a C++ `JSG_STRUCT`. + const UNION_TYPES: jsg::UnionTypes = jsg::UnionTypes::DICTIONARY; + fn class_name() -> &'static str { #class_name } fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { @@ -831,14 +832,16 @@ pub fn jsg_wildcard_property(attr: TokenStream, item: TokenStream) -> TokenStrea /// Generates `jsg::Type`, `jsg::FromJS`, `jsg::Traced` and `jsg::ToJS` for a /// union enum, equivalent to `kj::OneOf<…>` in C++ JSG. /// -/// Each variant must be a single-field tuple variant whose inner type implements -/// `jsg::Type`, `jsg::FromJS` and `jsg::Traced`. A value converts as it would to -/// the equivalent `kj::OneOf`: the variants are tried in passes that keep -/// coercion for last (see "Unions" in `src/rust/jsg/README.md`), and in -/// declaration order within a pass. A variant that is itself a union takes part -/// in each pass with its own variants. The union traces the variant it holds, -/// so it can be a resource field. `jsg::ToJS` is implemented when every variant -/// implements it. +/// Each variant must be a single-field tuple variant whose inner type converts +/// from JavaScript and is `jsg::Type`. A value converts as it +/// would to the equivalent `kj::OneOf`: the variants are tried in passes that +/// keep coercion for last (see "Union Types" in `src/rust/jsg/README.md`), and +/// in declaration order within a pass. A variant that is itself a union takes +/// part in each pass with its own variants. The union traces the variant it +/// holds, so it can be a resource field, unless it holds handles: such a union +/// has one lifetime parameter, their scope. `jsg::ToJS` is implemented when +/// every variant implements it. A union that breaks Web IDL's rules for unions, +/// as C++ JSG's `webidl::UnionTypeValidator` checks them, doesn't compile. /// /// ```ignore /// #[jsg_oneof] @@ -850,117 +853,5 @@ pub fn jsg_wildcard_property(attr: TokenStream, item: TokenStream) -> TokenStrea /// ``` #[proc_macro_attribute] pub fn jsg_oneof(_attr: TokenStream, item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as DeriveInput); - let name = &input.ident; - - let Data::Enum(data) = &input.data else { - return error(&input, "#[jsg_oneof] can only be applied to enums"); - }; - - let mut variants = Vec::new(); - for variant in &data.variants { - let variant_name = &variant.ident; - let Fields::Unnamed(fields) = &variant.fields else { - return error( - variant, - "#[jsg_oneof] variants must be tuple variants (e.g., `Variant(Type)`)", - ); - }; - if fields.unnamed.len() != 1 { - return error(variant, "#[jsg_oneof] variants must have exactly one field"); - } - let inner_type = &fields.unnamed[0].ty; - variants.push((variant_name, inner_type)); - } - - if variants.is_empty() { - return error(&input, "#[jsg_oneof] requires at least one variant"); - } - - let variant_names: Vec<_> = variants.iter().map(|(n, _)| n).collect(); - let inner_types: Vec<_> = variants.iter().map(|(_, t)| t).collect(); - let is_exact_checks: Vec<_> = variants - .iter() - .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::is_exact(value) }) - .collect(); - - quote! { - #input - - #[automatically_derived] - impl jsg::Type for #name { - fn class_name() -> &'static str { - stringify!(#name) - } - - fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { - #(#is_exact_checks)||* - } - } - - #[automatically_derived] - impl<'s> jsg::FromJS<'s> for #name { - type ResultType = Self; - - fn type_name() -> String { - [#(<#inner_types as jsg::FromJS<'s>>::type_name()),*].join(" or ") - } - - fn from_js(lock: &mut jsg::Lock<'s>, value: jsg::v8::Local<'s, jsg::v8::Value>) -> Result { - let type_name = value.type_of(lock); - Self::try_from_js(lock, value)?.ok_or_else(|| { - jsg::Error::new_type_error(format!( - "Expected {} but got {}", - >::type_name(), - type_name - )) - }) - } - - fn try_from_js(lock: &mut jsg::Lock<'s>, value: jsg::v8::Local<'s, jsg::v8::Value>) -> Result, jsg::Error> { - jsg::try_union_from_js(lock, value, Self::try_from_js_in_union_pass) - } - - fn try_from_js_in_union_pass( - lock: &mut jsg::Lock<'s>, - value: jsg::v8::Local<'s, jsg::v8::Value>, - pass: jsg::UnionMember, - ) -> Result, jsg::Error> { - #( - if let Some(value) = <#inner_types as jsg::FromJS<'s>>::try_from_js_in_union_pass(lock, value, pass)? { - return Ok(Some(Self::#variant_names(value))); - } - )* - Ok(None) - } - } - - // A union holds whatever its variant holds, so it can be a resource field. - #[automatically_derived] - impl jsg::Traced for #name { - fn holds_gc_references() -> bool { - #(<#inner_types as jsg::Traced>::holds_gc_references())||* - } - - fn trace(&self, visitor: &mut jsg::v8::GcVisitor) { - match self { - #(Self::#variant_names(value) => jsg::Traced::trace(value, visitor),)* - } - } - } - - // Only when every variant converts to JavaScript. - #[automatically_derived] - impl<'s> jsg::ToJS<'s> for #name - where - #(#inner_types: jsg::ToJS<'s>,)* - { - fn to_js(self, lock: &mut jsg::Lock<'s>) -> Result, jsg::Error> { - match self { - #(Self::#variant_names(value) => jsg::ToJS::to_js(value, lock),)* - } - } - } - } - .into() + oneof::expand(item) } diff --git a/src/rust/jsg-macros/oneof-test.rs b/src/rust/jsg-macros/oneof-test.rs new file mode 100644 index 00000000000..da5452ff5da --- /dev/null +++ b/src/rust/jsg-macros/oneof-test.rs @@ -0,0 +1,45 @@ +// 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 + +use quote::quote; + +use super::*; + +/// `tokens` with the lifetime `'s` replaced, as a string. +fn replaced(tokens: proc_macro2::TokenStream) -> String { + let name = proc_macro2::Ident::new("s", proc_macro2::Span::call_site()); + replace_lifetime(tokens, &name).to_string() +} + +#[test] +fn replace_lifetime_replaces_only_the_named_lifetime() { + assert_eq!( + replaced(quote! { jsg::v8::Local<'s, jsg::v8::Value> }), + quote! { jsg::v8::Local<'static, jsg::v8::Value> }.to_string() + ); + assert_eq!( + replaced(quote! { Vec<(&'a str, Local<'s, Value>, Nested<'s>)> }), + quote! { Vec<(&'a str, Local<'static, Value>, Nested<'static>)> }.to_string() + ); + assert_eq!(replaced(quote! { s }), quote! { s }.to_string()); + assert_eq!(replaced(quote! { 'x' }), quote! { 'x' }.to_string()); +} + +/// `ty`, parsed, without the `Nullable<…>`s around it, as a string. +fn stripped(ty: &str) -> String { + let ty: syn::Type = syn::parse_str(ty).unwrap(); + strip_nullable(&ty).to_token_stream().to_string() +} + +#[test] +fn strip_nullable_strips_every_nullable_as_written() { + assert_eq!(stripped("Nullable"), "String"); + assert_eq!( + stripped("jsg::Nullable>"), + "jsg :: Rc < Thing >" + ); + assert_eq!(stripped("Nullable>"), "u32"); + assert_eq!(stripped("Vec>"), "Vec < Nullable < u32 > >"); + assert_eq!(stripped("Option"), "Option < String >"); +} diff --git a/src/rust/jsg-macros/oneof.rs b/src/rust/jsg-macros/oneof.rs new file mode 100644 index 00000000000..416f8594ad3 --- /dev/null +++ b/src/rust/jsg-macros/oneof.rs @@ -0,0 +1,357 @@ +// 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_oneof]`: unions, the Rust counterpart of C++ JSG's `kj::OneOf`. + +use proc_macro::TokenStream; +use proc_macro2::Spacing; +use proc_macro2::TokenTree; +use quote::ToTokens; +use quote::quote; +use quote::quote_spanned; +use syn::Data; +use syn::DeriveInput; +use syn::Fields; +use syn::GenericParam; +use syn::Lifetime; +use syn::spanned::Spanned; + +use crate::utils::error; + +/// Expands `#[jsg_oneof]` on `item`. +pub fn expand(item: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(item as DeriveInput); + match Union::parse(&input) { + Ok(union) => union.generate(&input).into(), + Err(err) => err, + } +} + +/// A `#[jsg_oneof]` enum. +struct Union<'a> { + /// The enum's lifetime parameter, which its handle variants borrow, if it has one. + lifetime: Option<&'a Lifetime>, + /// The name of each variant. + variant_names: Vec<&'a syn::Ident>, + /// The type each variant holds. + inner_types: Vec<&'a syn::Type>, +} + +impl<'a> Union<'a> { + /// Checks that `input` is an enum the macro supports, and collects its variants. + fn parse(input: &'a DeriveInput) -> Result { + let Data::Enum(data) = &input.data else { + return Err(error(input, "#[jsg_oneof] can only be applied to enums")); + }; + let lifetime = Self::parse_lifetime(input)?; + + let mut variant_names = Vec::new(); + let mut inner_types = Vec::new(); + for variant in &data.variants { + let Fields::Unnamed(fields) = &variant.fields else { + return Err(error( + variant, + "#[jsg_oneof] variants must be tuple variants (e.g., `Variant(Type)`)", + )); + }; + if fields.unnamed.len() != 1 { + return Err(error( + variant, + "#[jsg_oneof] variants must have exactly one field", + )); + } + variant_names.push(&variant.ident); + inner_types.push(&fields.unnamed[0].ty); + } + if variant_names.is_empty() { + return Err(error(input, "#[jsg_oneof] requires at least one variant")); + } + + Ok(Self { + lifetime, + variant_names, + inner_types, + }) + } + + /// The enum's lifetime parameter. A union takes at most one, for the scope of the handles + /// it holds, and no type or const parameters, as `kj::OneOf` has none. + fn parse_lifetime(input: &'a DeriveInput) -> Result, TokenStream> { + let generics = &input.generics; + if let Some(where_clause) = &generics.where_clause { + return Err(error( + where_clause, + "#[jsg_oneof] unions can't have a where clause", + )); + } + let mut lifetime = None; + for param in &generics.params { + match param { + GenericParam::Lifetime(param) if lifetime.is_none() && param.bounds.is_empty() => { + lifetime = Some(¶m.lifetime); + } + _ => { + return Err(error( + param, + "#[jsg_oneof] unions can have only one lifetime parameter, with no \ + bounds, for the handles they hold", + )); + } + } + } + Ok(lifetime) + } + + /// Generates the enum and the union's trait implementations. + fn generate(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let checks = self.generate_checks(input); + let type_impl = self.generate_type(input); + let from_js_impl = self.generate_from_js(input); + let traced_impl = self.generate_traced(input); + let to_js_impl = self.generate_to_js(input); + quote! { + #input + #checks + #type_impl + #from_js_impl + #traced_impl + #to_js_impl + } + } + + /// The enum's type, with its lifetime parameter if it has one. + fn self_type(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let name = &input.ident; + let lifetime = self.lifetime.into_iter(); + quote! { #name<#(#lifetime)*> } + } + + /// `ty` with the enum's lifetime replaced by `'static`, so that it can be named outside the + /// enum's impls, in a constant. + fn with_static_lifetime(&self, ty: impl ToTokens) -> proc_macro2::TokenStream { + let tokens = ty.into_token_stream(); + if let Some(lifetime) = self.lifetime { + replace_lifetime(tokens, &lifetime.ident) + } else { + tokens + } + } + + /// Compile-time checks that the union follows Web IDL's rules for unions, as C++ JSG's + /// `webidl::UnionTypeValidator` checks a `kj::OneOf`. The rules on the variants' categories + /// are checked through their types' `jsg::UnionVariant::UNION_TYPES`, which flatten nested + /// unions and nullable types. A variant type that appears twice has two conflicting + /// implementations of a local trait; a `Nullable` variant implements it for `T`, as + /// `webidl::FlattenedTypeTraits` flattens `kj::Maybe` to `T`. + fn generate_checks(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.with_static_lifetime(self.self_type(input)); + // Spanned on the variant, so that a duplicate's error points at it. + let distinct_impls = self.inner_types.iter().map(|ty| { + let static_type = self.with_static_lifetime(strip_nullable(ty)); + quote_spanned! {ty.span()=> + impl JsgOneofVariantsMustHaveDistinctTypes for #static_type {} + } + }); + quote! { + const _: () = <#self_type as jsg::Type>::UNION_TYPES.validate(); + + const _: () = { + /// A `#[jsg_oneof]` union may not contain duplicate types; a type that has two + /// implementations of this trait is the type of two variants. + trait JsgOneofVariantsMustHaveDistinctTypes {} + #(#distinct_impls)* + }; + } + } + + /// `jsg::Type`, so that the union can be a variant of another union or a sequence's element. + /// A union is not `jsg::Coercible`, so it can't be a `NonCoercible`. + fn generate_type(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let name = &input.ident; + let self_type = self.self_type(input); + let lifetime = self.lifetime.into_iter(); + let inner_types = &self.inner_types; + quote! { + #[automatically_derived] + impl<#(#lifetime)*> jsg::Type for #self_type { + const UNION_TYPES: jsg::UnionTypes = jsg::UnionTypes::OTHER + #(.plus(<#inner_types as jsg::UnionVariant>::UNION_TYPES))*; + + fn class_name() -> &'static str { + stringify!(#name) + } + + fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { + #(<#inner_types as jsg::UnionVariant>::is_exact(value))||* + } + } + } + } + + /// `jsg::FromJS`, which tries the variants in the passes of `jsg::try_union_from_js`. + fn generate_from_js(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.self_type(input); + let s = self.scope_lifetime(); + let impl_lifetime = &s; + let variant_names = &self.variant_names; + let inner_types = &self.inner_types; + quote! { + #[automatically_derived] + impl<#impl_lifetime> jsg::FromJS<#s> for #self_type { + type ResultType = Self; + + fn type_name() -> String { + [#(<#inner_types as jsg::FromJS<#s>>::type_name()),*].join(" or ") + } + + fn from_js(lock: &mut jsg::Lock<#s>, value: jsg::v8::Local<#s, jsg::v8::Value>) -> Result { + let type_name = value.type_of(lock); + Self::try_from_js(lock, value)?.ok_or_else(|| { + jsg::Error::new_type_error(format!( + "Expected {} but got {}", + >::type_name(), + type_name + )) + }) + } + + fn try_from_js(lock: &mut jsg::Lock<#s>, value: jsg::v8::Local<#s, jsg::v8::Value>) -> Result, jsg::Error> { + jsg::try_union_from_js(lock, value, Self::try_from_js_in_union_pass) + } + + fn try_from_js_in_union_pass( + lock: &mut jsg::Lock<#s>, + value: jsg::v8::Local<#s, jsg::v8::Value>, + pass: jsg::UnionMember, + ) -> Result, jsg::Error> { + #( + if let Some(value) = <#inner_types as jsg::FromJS<#s>>::try_from_js_in_union_pass(lock, value, pass)? { + return Ok(Some(Self::#variant_names(value))); + } + )* + Ok(None) + } + } + } + } + + /// `jsg::Traced`: a union holds whatever its variant holds, so it can be a resource field. + /// A union that holds handles can't, so its implementation requires what its variants can't + /// provide, and it is never used. + fn generate_traced(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.self_type(input); + let lifetime = self.lifetime.into_iter(); + let variant_names = &self.variant_names; + let inner_types = &self.inner_types; + let where_clause = self.lifetime.map(|_| { + quote! { where #(#inner_types: jsg::Traced,)* } + }); + quote! { + #[automatically_derived] + impl<#(#lifetime)*> jsg::Traced for #self_type #where_clause { + fn holds_gc_references() -> bool { + #(<#inner_types as jsg::Traced>::holds_gc_references())||* + } + + fn trace(&self, visitor: &mut jsg::v8::GcVisitor) { + match self { + #(Self::#variant_names(value) => jsg::Traced::trace(value, visitor),)* + } + } + } + } + } + + /// `jsg::ToJS`, when every variant converts to JavaScript. + fn generate_to_js(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.self_type(input); + let s = self.scope_lifetime(); + let variant_names = &self.variant_names; + let inner_types = &self.inner_types; + quote! { + #[automatically_derived] + impl<#s> jsg::ToJS<#s> for #self_type + where + #(#inner_types: jsg::ToJS<#s>,)* + { + fn to_js(self, lock: &mut jsg::Lock<#s>) -> Result, jsg::Error> { + match self { + #(Self::#variant_names(value) => jsg::ToJS::to_js(value, lock),)* + } + } + } + } + } + + /// The lifetime of the lock in the union's `FromJS` and `ToJS` impls: the enum's lifetime + /// parameter, which its handles borrow, or a fresh one. + fn scope_lifetime(&self) -> Lifetime { + self.lifetime + .cloned() + .unwrap_or_else(|| Lifetime::new("'s", proc_macro2::Span::call_site())) + } +} + +/// `ty` without the `Nullable<…>`s around it, as written: `T` for `Nullable` or +/// `jsg::Nullable>`. A type alias of a `Nullable` is not seen through. +fn strip_nullable(ty: &syn::Type) -> &syn::Type { + let syn::Type::Path(path) = ty else { + return ty; + }; + if path.qself.is_some() { + return ty; + } + let Some(segment) = path.path.segments.last() else { + return ty; + }; + if segment.ident != "Nullable" { + return ty; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return ty; + }; + match args.args.first() { + Some(syn::GenericArgument::Type(inner)) if args.args.len() == 1 => strip_nullable(inner), + _ => ty, + } +} + +/// `tokens` with every use of the lifetime named `name` replaced by `'static`. +fn replace_lifetime( + tokens: proc_macro2::TokenStream, + name: &proc_macro2::Ident, +) -> proc_macro2::TokenStream { + let mut output = Vec::new(); + let mut tokens = tokens.into_iter().peekable(); + while let Some(token) = tokens.next() { + match token { + TokenTree::Punct(punct) + if punct.as_char() == '\'' && punct.spacing() == Spacing::Joint => + { + if let Some(TokenTree::Ident(ident)) = tokens.peek() + && ident == name + { + tokens.next(); + output.extend(quote! { 'static }); + } else { + output.push(TokenTree::Punct(punct)); + } + } + TokenTree::Group(group) => { + let mut replaced = proc_macro2::Group::new( + group.delimiter(), + replace_lifetime(group.stream(), name), + ); + replaced.set_span(group.span()); + output.push(TokenTree::Group(replaced)); + } + token => output.push(token), + } + } + output.into_iter().collect() +} + +#[cfg(test)] +#[path = "oneof-test.rs"] +mod tests; diff --git a/src/rust/jsg-test/tests/jsg_oneof.rs b/src/rust/jsg-test/tests/jsg_oneof.rs index 0a54fbf12dc..559387c4c11 100644 --- a/src/rust/jsg-test/tests/jsg_oneof.rs +++ b/src/rust/jsg-test/tests/jsg_oneof.rs @@ -47,12 +47,6 @@ struct OptionalPoint { pub x: Option, } -#[jsg_struct] -#[derive(Debug, Clone, PartialEq)] -struct OptionalSize { - pub size: Option, -} - /// `kj::OneOf>`. #[jsg_oneof] #[derive(Debug, Clone)] @@ -119,14 +113,6 @@ enum PointOrBox { Box(jsg::Rc), } -/// Two dictionaries, which C++ JSG's `UnionTypeValidator` rejects. -#[jsg_oneof] -#[derive(Debug, Clone, PartialEq)] -enum PointOrSize { - Point(OptionalPoint), - Size(OptionalSize), -} - #[jsg_oneof] #[derive(Debug, Clone, PartialEq)] enum PointOrBytes { @@ -311,14 +297,6 @@ impl OneOfContext { } } - #[jsg_method] - pub fn take_point_or_size(&self, value: PointOrSize) -> String { - match value { - PointOrSize::Point(p) => format!("point:{:?}", p.x), - PointOrSize::Size(s) => format!("size:{:?}", s.size), - } - } - #[jsg_method] pub fn take_point_or_bytes(&self, value: PointOrBytes) -> String { match value { @@ -728,16 +706,6 @@ fn union_holds_gc_references_if_a_variant_does() { assert!(!StringOrNumber::holds_gc_references()); } -// C++ JSG rejects a union of two dictionaries at compile time, so this has no -// C++ counterpart; Rust takes the first in declaration order. -#[test] -fn declaration_order_decides_within_a_pass() { - with_context(|lock, ctx| { - expect_eval(lock, ctx, "cx.takePointOrSize({})", "point:None"); - expect_eval(lock, ctx, "cx.takePointOrSize({ size: 1 })", "point:None"); - }); -} - #[test] fn union_without_to_js_converts_from_js() { with_context(|lock, ctx| { diff --git a/src/rust/jsg-test/tests/jsg_oneof_variants.rs b/src/rust/jsg-test/tests/jsg_oneof_variants.rs new file mode 100644 index 00000000000..60d09f84a6b --- /dev/null +++ b/src/rust/jsg-test/tests/jsg_oneof_variants.rs @@ -0,0 +1,554 @@ +// 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_oneof]` variants of the types that a `kj::OneOf` can hold beyond strings, numbers, +//! booleans, resources, structs and sequences: integers, `i64`, `SystemTime`, `Nullable`, +//! `Dict` and V8 handles. Each converts in the pass that C++ JSG's `OneOfWrapper` gives its C++ +//! counterpart (`int`, `int64_t`, `kj::Date`, `kj::Maybe`, `jsg::Dict` and `v8::Local`). +//! What a union may not contain is checked at compile time, by the snippets in +//! `src/rust/jsg-compile-tests/oneof-*.rs`. + +use std::time::Duration; +use std::time::SystemTime; + +use jsg::Dict; +use jsg::ExceptionType; +use jsg::FromJS; +use jsg::Nullable; +use jsg::Number; +use jsg::ToJS; +use jsg::v8; +use jsg_macros::jsg_method; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_struct] +#[derive(Debug, Clone, PartialEq)] +struct OptionalPoint { + pub x: Option, +} + +/// `kj::OneOf`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum U32OrString { + U32(u32), + String(String), +} + +/// `kj::OneOf`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum I64OrString { + I64(i64), + String(String), +} + +/// `kj::OneOf`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum DateOrNumber { + Date(SystemTime), + Number(Number), +} + +/// `kj::OneOf, double>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum NullableStringOrNumber { + String(Nullable), + Number(Number), +} + +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum NullableU32OrString { + U32(Nullable), + String(String), +} + +/// `kj::OneOf, kj::Array, kj::String>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum DictSequenceOrString { + Dict(Dict), + Sequence(Vec), + String(String), +} + +/// `kj::OneOf, double>`. +#[jsg_oneof] +enum StringHandleOrNumber<'s> { + String(v8::Local<'s, v8::String>), + Number(Number), +} + +/// `kj::OneOf, kj::String>`, with the lifetime named otherwise. +#[jsg_oneof] +enum ObjectOrString<'a> { + Object(v8::Local<'a, v8::Object>), + String(String), +} + +/// `kj::OneOf, double>`. +#[jsg_oneof] +enum FunctionOrNumber<'s> { + Function(v8::Local<'s, v8::Function>), + Number(Number), +} + +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +enum BigIntOrString<'s> { + BigInt(v8::Local<'s, v8::BigInt>), + String(String), +} + +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +enum Uint8ArrayOrString<'s> { + Bytes(v8::Local<'s, v8::Uint8Array>), + String(String), +} + +/// A dictionary and a value of any type, in both orders: both are tried in the same pass, in +/// declaration order. +#[jsg_oneof] +enum PointOrValue<'s> { + Point(OptionalPoint), + Value(v8::Local<'s, v8::Value>), +} + +#[jsg_oneof] +enum ValueOrPoint<'s> { + Value(v8::Local<'s, v8::Value>), + Point(OptionalPoint), +} + +/// A union with a handle, nested in another; the inner union's variants are tried in the outer +/// union's passes. +#[jsg_oneof] +enum NumberOrNestedHandle<'s> { + Number(Number), + Nested(StringHandleOrBool<'s>), +} + +#[jsg_oneof] +enum StringHandleOrBool<'s> { + String(v8::Local<'s, v8::String>), + Bool(bool), +} + +#[jsg_resource] +struct VariantContext; + +#[jsg_resource] +impl VariantContext { + #[jsg_method] + pub fn take_u32_or_string(&self, value: U32OrString) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_i64_or_string(&self, value: I64OrString) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_date_or_number(&self, value: DateOrNumber) -> String { + match value { + DateOrNumber::Date(date) => { + let millis = date + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("date:{millis}") + } + DateOrNumber::Number(n) => format!("number:{n}"), + } + } + + #[jsg_method] + pub fn return_date_or_number(&self, date: bool) -> DateOrNumber { + if date { + DateOrNumber::Date(SystemTime::UNIX_EPOCH + Duration::from_secs(1)) + } else { + DateOrNumber::Number(Number::new(1.0)) + } + } + + #[jsg_method] + pub fn take_nullable_string_or_number(&self, value: NullableStringOrNumber) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_nullable_u32_or_string(&self, value: NullableU32OrString) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_dict_sequence_or_string(&self, value: DictSequenceOrString) -> String { + match value { + DictSequenceOrString::Dict(dict) => { + let entries: Vec<_> = dict + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + format!("dict:{}", entries.join(",")) + } + DictSequenceOrString::Sequence(items) => format!("sequence:{}", items.join(",")), + DictSequenceOrString::String(s) => format!("string:{s}"), + } + } + + #[jsg_method] + pub fn return_dict_or_sequence(&self, dict: bool) -> DictSequenceOrString { + if dict { + DictSequenceOrString::Dict(Dict::from(vec![("a".to_owned(), Number::new(1.0))])) + } else { + DictSequenceOrString::Sequence(vec!["x".to_owned()]) + } + } + + #[jsg_method] + pub fn take_string_handle_or_number<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: StringHandleOrNumber<'s>, + ) -> Result { + Ok(match value { + StringHandleOrNumber::String(s) => { + format!("handle:{}", String::from_js(lock, s.into())?) + } + StringHandleOrNumber::Number(n) => format!("number:{n}"), + }) + } + + #[jsg_method] + pub fn take_object_or_string<'s>(&self, value: ObjectOrString<'s>) -> String { + match value { + ObjectOrString::Object(_) => "object".to_owned(), + ObjectOrString::String(s) => format!("string:{s}"), + } + } + + /// Returns the object it is given, as the same union. + #[jsg_method] + pub fn echo_object_or_string<'s>(&self, value: ObjectOrString<'s>) -> ObjectOrString<'s> { + value + } + + #[jsg_method] + pub fn take_function_or_number<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: FunctionOrNumber<'s>, + ) -> Result { + Ok(match value { + FunctionOrNumber::Function(f) => { + let result = f.call::(lock, None, &[])?; + format!("function:{result}") + } + FunctionOrNumber::Number(n) => format!("number:{n}"), + }) + } + + #[jsg_method] + pub fn take_big_int_or_string<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: BigIntOrString<'s>, + ) -> Result { + Ok(match value { + BigIntOrString::BigInt(b) => format!("bigint:{}", i64::from_js(lock, b.into())?), + BigIntOrString::String(s) => format!("string:{s}"), + }) + } + + #[jsg_method] + pub fn take_uint8_array_or_string<'s>(&self, value: Uint8ArrayOrString<'s>) -> String { + match value { + Uint8ArrayOrString::Bytes(bytes) => format!("bytes:{}", bytes.len()), + Uint8ArrayOrString::String(s) => format!("string:{s}"), + } + } + + #[jsg_method] + pub fn take_point_or_value<'s>(&self, value: PointOrValue<'s>) -> String { + match value { + PointOrValue::Point(p) => format!("point:{:?}", p.x), + PointOrValue::Value(_) => "value".to_owned(), + } + } + + #[jsg_method] + pub fn take_value_or_point<'s>(&self, value: ValueOrPoint<'s>) -> String { + match value { + ValueOrPoint::Value(_) => "value".to_owned(), + ValueOrPoint::Point(p) => format!("point:{:?}", p.x), + } + } + + #[jsg_method] + pub fn take_number_or_nested_handle<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: NumberOrNestedHandle<'s>, + ) -> Result { + Ok(match value { + NumberOrNestedHandle::Number(n) => format!("number:{n}"), + NumberOrNestedHandle::Nested(StringHandleOrBool::String(s)) => { + format!("handle:{}", String::from_js(lock, s.into())?) + } + NumberOrNestedHandle::Nested(StringHandleOrBool::Bool(b)) => format!("bool:{b}"), + }) + } +} + +fn with_context(f: impl for<'s, 'c> FnOnce(&mut jsg::Lock<'s>, &crate::EvalContext<'c>)) { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let cx = jsg::Rc::new(VariantContext).to_js(lock)?; + ctx.set_global(lock, "cx", cx); + f(lock, ctx); + Ok(()) + }); +} + +fn expect_eval(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str, expected: &str) { + let result: String = ctx.eval(lock, code).unwrap(); + assert_eq!(result, expected, "{code}"); +} + +fn expect_type_error(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str) -> String { + let err = ctx + .eval::(lock, code) + .unwrap_err() + .unwrap_jsg_err(lock); + assert_eq!(err.name, ExceptionType::TypeError, "{code}"); + err.message +} + +// ============================================================================= +// Integers and i64 +// ============================================================================= + +/// An integer is a numeric type: it takes a number in the exact-number pass, and anything else +/// only after the string pass. An error from converting a number ends the conversion, as +/// `JSG_REQUIRE` in C++ JSG's integer conversion does, rather than trying the string variant. +#[test] +fn integer_variant_is_numeric() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeU32OrString(5)", "U32(5)"); + expect_eval(lock, ctx, "cx.takeU32OrString('5')", "String(\"5\")"); + expect_eval(lock, ctx, "cx.takeU32OrString(true)", "String(\"true\")"); + let message = expect_type_error(lock, ctx, "cx.takeU32OrString(-1)"); + assert!(message.contains("negative"), "{message}"); + }); +} + +/// `i64`, like `int64_t`, takes a `BigInt` in the exact-number pass too. +#[test] +fn i64_variant_takes_big_int() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeI64OrString(5n)", "I64(5)"); + expect_eval(lock, ctx, "cx.takeI64OrString(5)", "I64(5)"); + expect_eval(lock, ctx, "cx.takeI64OrString('5')", "String(\"5\")"); + }); +} + +// ============================================================================= +// SystemTime +// ============================================================================= + +/// `kj::Date` is in none of Web IDL's categories, so it is tried with the types that aren't +/// strings, numbers or booleans, before any numeric variant, and there takes a number of +/// milliseconds as well as a `Date`. +#[test] +fn date_variant_is_tried_before_numbers() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeDateOrNumber(new Date(1000))", + "date:1000", + ); + expect_eval(lock, ctx, "cx.takeDateOrNumber(5)", "date:5"); + expect_eval(lock, ctx, "cx.takeDateOrNumber('5')", "number:5"); + expect_eval( + lock, + ctx, + "String(cx.returnDateOrNumber(true) instanceof Date && \ + cx.returnDateOrNumber(true).getTime())", + "1000", + ); + expect_eval(lock, ctx, "typeof cx.returnDateOrNumber(false)", "number"); + }); +} + +// ============================================================================= +// Nullable +// ============================================================================= + +/// A nullable variant takes `null` and `undefined`. Like `kj::Maybe`, it is not itself a +/// string, number or boolean type, so it is tried before them, and converts any other value as +/// its inner type would, coercion included. +#[test] +fn nullable_variant_is_tried_before_primitives() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeNullableStringOrNumber(null)", + "String(Null)", + ); + expect_eval( + lock, + ctx, + "cx.takeNullableStringOrNumber(undefined)", + "String(Null)", + ); + expect_eval( + lock, + ctx, + "cx.takeNullableStringOrNumber(5)", + "String(Some(\"5\"))", + ); + expect_eval(lock, ctx, "cx.takeNullableU32OrString(null)", "U32(Null)"); + expect_eval(lock, ctx, "cx.takeNullableU32OrString(5)", "U32(Some(5))"); + }); +} + +// ============================================================================= +// Dict +// ============================================================================= + +/// A record takes an object other than an array, which a sequence takes. +#[test] +fn dict_variant_takes_objects_but_not_arrays() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeDictSequenceOrString({ a: 1, b: 2 })", + "dict:a=1,b=2", + ); + expect_eval( + lock, + ctx, + "cx.takeDictSequenceOrString(['x', 'y'])", + "sequence:x,y", + ); + expect_eval(lock, ctx, "cx.takeDictSequenceOrString('z')", "string:z"); + expect_eval( + lock, + ctx, + "JSON.stringify(cx.returnDictOrSequence(true))", + "{\"a\":1}", + ); + expect_eval( + lock, + ctx, + "JSON.stringify(cx.returnDictOrSequence(false))", + "[\"x\"]", + ); + }); +} + +// ============================================================================= +// Handles +// ============================================================================= + +/// A string handle is a string type, but takes only a string, as C++ JSG's `v8::Local` +/// does, so a boolean is coerced by the numeric variant instead. +#[test] +fn string_handle_variant_takes_only_strings() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringHandleOrNumber('a')", "handle:a"); + expect_eval(lock, ctx, "cx.takeStringHandleOrNumber(5)", "number:5"); + expect_eval(lock, ctx, "cx.takeStringHandleOrNumber(true)", "number:1"); + }); +} + +#[test] +fn object_handle_variant_takes_objects() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeObjectOrString({})", "object"); + expect_eval(lock, ctx, "cx.takeObjectOrString([])", "object"); + expect_eval(lock, ctx, "cx.takeObjectOrString(5)", "string:5"); + expect_eval( + lock, + ctx, + "const o = {}; String(cx.echoObjectOrString(o) === o)", + "true", + ); + expect_eval(lock, ctx, "cx.echoObjectOrString(5)", "5"); + }); +} + +#[test] +fn function_handle_variant_takes_functions() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeFunctionOrNumber(() => 'called')", + "function:called", + ); + expect_eval(lock, ctx, "cx.takeFunctionOrNumber('5')", "number:5"); + }); +} + +/// A `BigInt` handle is a numeric type, as C++ JSG's `v8::Local` is, but takes only a +/// `BigInt`, so a number becomes a string. +#[test] +fn big_int_handle_variant_takes_only_big_ints() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeBigIntOrString(5n)", "bigint:5"); + expect_eval(lock, ctx, "cx.takeBigIntOrString(5)", "string:5"); + }); +} + +#[test] +fn typed_array_handle_variant_takes_its_typed_array() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeUint8ArrayOrString(new Uint8Array(3))", + "bytes:3", + ); + expect_eval( + lock, + ctx, + "cx.takeUint8ArrayOrString(new Uint16Array(3))", + "string:0,0,0", + ); + }); +} + +/// A value handle takes anything, in the pass for types that aren't strings, numbers or +/// booleans, so within that pass declaration order decides. +#[test] +fn declaration_order_decides_within_a_pass() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takePointOrValue({ x: 1 })", "point:Some(1)"); + expect_eval(lock, ctx, "cx.takePointOrValue(null)", "point:None"); + expect_eval(lock, ctx, "cx.takePointOrValue(5)", "value"); + expect_eval(lock, ctx, "cx.takeValueOrPoint({ x: 1 })", "value"); + }); +} + +#[test] +fn nested_union_with_handles_is_flattened() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle('a')", "handle:a"); + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle(true)", "bool:true"); + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle(5)", "number:5"); + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle({})", "number:NaN"); + }); +} diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index f127c0e74cc..527fc2c8c7d 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -20,6 +20,7 @@ mod harness; mod integer; mod isolates; mod jsg_oneof; +mod jsg_oneof_variants; mod jsg_struct; mod local_cast; mod local_handle; diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 84fb2010534..79d6f984d6b 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -400,6 +400,13 @@ let name: v8::Local = v8::Local::::try_from(str_value).unw assert!(v8::Local::::try_from(str_value).is_err()); ``` +A method parameter can be a handle of the types that C++ JSG's `v8::Local` parameters +take: `Local` takes any value, and `Local`, `Local`, +`Local`, `Local`, `Local`, `Local`, +`Local` and the typed arrays take a value of exactly that type, with no +coercion, and throw a `TypeError` for any other. `Local` takes any value, as a +promise resolved with it. + ### `Global` A persistent handle that outlives `HandleScope`s, for handles that Rust code owns outside @@ -670,18 +677,51 @@ trying another variant. A `NonCoercible` variant, where `T` is `String`, `boo exactly that type. A variant that is itself a union takes part in each pass with its own variants, as a nested `kj::OneOf` does. -The union traces the variant it holds, so a union with a `jsg::Rc` variant can be a -resource field. It implements `jsg::ToJS` when every variant does. +Variants can be any type that converts from JavaScript and that a `kj::OneOf` can hold: +strings, numbers, the integer types, booleans, `SystemTime`, resources, structs, +`Vec`, `Dict`, `Nullable`, `NonCoercible`, other unions, and +handles (`v8::Local<'s, T>`). Each is tried in the passes of its C++ counterpart, so a few +behave as they do in C++ rather than as their position suggests: + +- `SystemTime`, like `kj::Date`, is not a numeric type, so it is tried in pass 2, where it + also takes a number of milliseconds, before any numeric variant does. +- `Nullable`, like `kj::Maybe`, is tried in pass 2 whatever `T` is: it takes `null` + and `undefined`, and converts any other value as `T` would, coercion included. A value + that `T` does not take is not taken as `null`, as in C++ JSG with the + `dont_substitute_null_on_type_error` compatibility flag, which Rust JSG always follows. +- A handle takes only a value of its type, as C++ JSG's `v8::Local` does: + `v8::Local<'s, v8::String>` is a string type, but never coerces, and + `v8::Local<'s, v8::BigInt>` is a numeric type that takes only a `BigInt`. + `v8::Local<'s, v8::Value>` takes any value, in pass 2. + +A union that holds handles takes their scope as its one lifetime parameter, and can't +outlive it: -Two differences from `kj::OneOf<>`: +```rust +#[jsg_oneof] +enum ObjectOrString<'s> { + Object(jsg::v8::Local<'s, jsg::v8::Object>), + String(String), +} +``` -- Variants must implement `jsg::Type`, which the integer types, `i64`, `SystemTime` and - `Nullable` don't, so they can't be variants yet. -- C++ checks at compile time that a union follows Web IDL's rules, for example at most - one string, numeric, boolean, dictionary-like or sequence-like type, and no duplicate - types (`webidl::UnionTypeValidator` in `src/workerd/jsg/web-idl.h`). Rust doesn't - check them; in a union that breaks them, the first variant that converts the value, in - pass and declaration order, wins. +Like C++ JSG's `webidl::UnionTypeValidator` (`src/workerd/jsg/web-idl.h`), `#[jsg_oneof]` +rejects at compile time a union that breaks Web IDL's rules for unions, counting the +variants of nested unions and the inner types of nullable types: at most one boolean, +numeric, string, dictionary-like (struct or `Dict`) and sequence-like type; at most one +nullable or struct type in all; an object handle (`v8::Local<'s, v8::Object>`) only +without resources, dictionary-like or sequence-like types; no `Option` or +`Lenient`, which mark optional parameters and fields; and no type twice, counting a +`Nullable` as `T`. A union may have several resource types, as Web IDL allows. One +difference from C++: the check for a type that appears twice covers the union's own +variants, seeing through `Nullable<…>` as written but not through a type alias of one, +and not the variants of a nested union. So a type that no at-most-one rule counts, such +as a resource, a typed array handle, `SystemTime` or `v8::Local<'s, v8::Value>`, isn't +caught when it is in both a union and a union nested in it. + +The union traces the variant it holds, so a union with a `jsg::Rc` variant can be a +resource field; one that holds handles can't. A union implements `jsg::ToJS` when every +variant does. ## Feature Flags (Compatibility Flags) diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 72cfc3e7784..3db45797696 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -17,6 +17,7 @@ pub mod lenient; pub mod macros; pub mod modules; pub mod nullable; +mod oneof; mod promise; pub mod resource; mod scope; @@ -31,6 +32,14 @@ pub use feature_flags::FeatureFlags; use isolate_state::IsolateState; pub use lenient::Lenient; pub use nullable::Nullable; +#[doc(hidden)] +pub use oneof::UnionMember; +#[doc(hidden)] +pub use oneof::UnionTypes; +#[doc(hidden)] +pub use oneof::UnionVariant; +#[doc(hidden)] +pub use oneof::try_union_from_js; pub use resource::NestedType; pub use resource::Rc; pub use resource::Resource; @@ -71,10 +80,6 @@ pub use v8::ffi::ExceptionType; pub use wrappable::FromJS; pub use wrappable::ToJS; pub use wrappable::Traced; -#[doc(hidden)] -pub use wrappable::UnionMember; -#[doc(hidden)] -pub use wrappable::try_union_from_js; #[cxx::bridge(namespace = "workerd::rust::jsg")] #[expect(unsafe_code, reason = "the cxx bridge expands to unsafe FFI glue")] @@ -1097,6 +1102,11 @@ pub trait Type: Sized { /// types it can only check the JavaScript type, without a lock: a resource's accepts any /// object, and a union's any value that one of its variants' accepts. fn is_exact(value: v8::Local<'_, v8::Value>) -> bool; + + /// The type's Web IDL categories, which a `#[jsg_oneof]` union with it as a variant checks + /// at compile time; see [`UnionTypes`]. + #[doc(hidden)] + const UNION_TYPES: UnionTypes = UnionTypes::OTHER; } /// Represents a constant value that can be exposed to JavaScript. diff --git a/src/rust/jsg/oneof-test.rs b/src/rust/jsg/oneof-test.rs new file mode 100644 index 00000000000..9d66692485c --- /dev/null +++ b/src/rust/jsg/oneof-test.rs @@ -0,0 +1,165 @@ +// 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 + +use std::panic::AssertUnwindSafe; +use std::time::SystemTime; + +use super::*; +use crate::Dict; +use crate::FromJS; +use crate::NonCoercible; +use crate::Nullable; +use crate::Number; + +/// The message `validate` panics with for `types`, or `None` if they are valid. +fn rejection(types: UnionTypes) -> Option { + let payload = std::panic::catch_unwind(AssertUnwindSafe(|| types.validate())).err()?; + payload + .downcast_ref::<&str>() + .map(|message| (*message).to_owned()) + .or_else(|| payload.downcast_ref::().cloned()) +} + +/// The types of a union of the types in `members`. +fn union(members: &[UnionTypes]) -> UnionTypes { + members + .iter() + .fold(UnionTypes::OTHER, |sum, &member| sum.plus(member)) +} + +#[test] +fn valid_unions_pass() { + for members in [ + vec![UnionTypes::STRING, UnionTypes::NUMERIC, UnionTypes::BOOLEAN], + vec![ + UnionTypes::INTERFACE_LIKE, + UnionTypes::INTERFACE_LIKE, + UnionTypes::DICTIONARY, + UnionTypes::SEQUENCE_LIKE, + UnionTypes::STRING, + ], + vec![UnionTypes::OBJECT, UnionTypes::STRING, UnionTypes::OTHER], + vec![UnionTypes::RECORD, UnionTypes::STRING.nullable()], + vec![UnionTypes::OTHER, UnionTypes::OTHER], + ] { + assert_eq!(rejection(union(&members)), None, "{members:?}"); + } +} + +/// Each rule of C++ JSG's `webidl::UnionTypeValidator`, broken by the smallest union. +#[test] +fn each_web_idl_rule_rejects_a_union() { + let cases = [ + ( + vec![UnionTypes::DICTIONARY, UnionTypes::NUMERIC.nullable()], + "at most one nullable or dictionary type", + ), + ( + vec![ + UnionTypes::STRING.nullable(), + UnionTypes::NUMERIC.nullable(), + ], + "at most one nullable or dictionary type", + ), + ( + vec![UnionTypes::BOOLEAN, UnionTypes::BOOLEAN], + "at most one boolean type", + ), + ( + vec![UnionTypes::NUMERIC, UnionTypes::NUMERIC], + "at most one numeric type", + ), + ( + vec![UnionTypes::STRING, UnionTypes::STRING], + "at most one string type", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::OBJECT], + "at most one object type", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::INTERFACE_LIKE], + "an object type only if", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::RECORD], + "an object type only if", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::SEQUENCE_LIKE], + "an object type only if", + ), + ( + vec![UnionTypes::DICTIONARY, UnionTypes::RECORD], + "at most one dictionary-like type", + ), + ( + vec![UnionTypes::SEQUENCE_LIKE, UnionTypes::SEQUENCE_LIKE], + "at most one sequence-like type", + ), + ( + vec![UnionTypes::STRING.optional()], + "may not contain Option or Lenient", + ), + ]; + for (members, rule) in cases { + let message = rejection(union(&members)).unwrap_or_default(); + assert!(message.contains(rule), "{members:?}: {message:?}"); + } +} + +/// Web IDL flattens a nullable type to its inner type, and counts the nullable type as well. +#[test] +fn nullable_counts_its_inner_type() { + let types = UnionTypes::STRING.nullable(); + assert_eq!(types.string, 1); + assert_eq!(types.nullable, 1); + assert!(rejection(union(&[types, UnionTypes::STRING])).is_some()); +} + +/// Asserts that `T`'s pass in the union conversion and its Web IDL categories are `member` and +/// `types`. The two are separate constants, `FromJS::UNION_MEMBER` and +/// `UnionVariant::UNION_TYPES`, so this table keeps them consistent with each other and with C++ +/// JSG's predicates (`IsResourceType`, `IsFallibleType` and the like in jsg/value.h, and the +/// categories in jsg/web-idl.h). +fn assert_kind>(member: UnionMember, types: UnionTypes) { + let name = std::any::type_name::(); + assert_eq!(T::UNION_MEMBER, member, "{name}"); + assert_eq!(::UNION_TYPES, types, "{name}"); +} + +#[test] +fn each_type_has_the_kind_of_its_cpp_counterpart() { + use UnionMember::Boolean; + use UnionMember::Fallible; + use UnionMember::Numeric; + + assert_kind::(Boolean, UnionTypes::BOOLEAN); + assert_kind::>(Boolean, UnionTypes::BOOLEAN); + assert_kind::(UnionMember::String, UnionTypes::STRING); + assert_kind::>(UnionMember::String, UnionTypes::STRING); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::SEQUENCE_LIKE); + assert_kind::>(Fallible, UnionTypes::SEQUENCE_LIKE); + assert_kind::>(Fallible, UnionTypes::RECORD); + assert_kind::>(Fallible, UnionTypes::STRING.nullable()); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(UnionMember::String, UnionTypes::STRING); + assert_kind::>(Numeric, UnionTypes::NUMERIC); + assert_kind::>(Fallible, UnionTypes::OBJECT); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); +} diff --git a/src/rust/jsg/oneof.rs b/src/rust/jsg/oneof.rs new file mode 100644 index 00000000000..7ad8c340260 --- /dev/null +++ b/src/rust/jsg/oneof.rs @@ -0,0 +1,315 @@ +// 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 support code of `#[jsg_oneof]` unions, the Rust counterpart of C++ JSG's `kj::OneOf`. +//! +//! A union converts a value as C++ JSG's `OneOfWrapper::tryUnwrap()` (jsg/value.h) does, in the +//! passes that [`UnionMember`] describes, and checks at compile time that its variants follow +//! Web IDL's rules for unions, as C++ JSG's `webidl::UnionTypeValidator` (jsg/web-idl.h) does; +//! see [`UnionTypes`]. Everything here is used only by the code the macro generates. + +use crate::Error; +use crate::Lenient; +use crate::Lock; +use crate::Type; +use crate::v8; + +/// The kind of a `#[jsg_oneof]` variant, which decides the passes in which the +/// variant is tried. +/// +/// A union converts a value as C++ JSG's `OneOfWrapper::tryUnwrap()` +/// (jsg/value.h) does, following Web IDL's union conversion: it makes a series +/// of passes over its variants and takes the first one that converts, trying +/// in order +/// +/// 1. resource types, +/// 2. every type that is not a string, numeric or boolean type, +/// 3. boolean types, if the value is a boolean, +/// 4. numeric types, if the value is a number or a `BigInt`, +/// 5. string types, +/// 6. numeric types, +/// 7. boolean types. +/// +/// Passes 5 to 7 coerce, so a value becomes a string only if no other variant +/// takes it, and becomes a number or boolean only if no string variant +/// converts it either. An error from a variant's conversion, such as a +/// `Symbol` that cannot become a string, ends the conversion with that error +/// rather than trying another variant. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnionMember { + /// A resource type (`jsg::Rc`). + Resource, + /// Any other type that is not a string, numeric or boolean type, such as + /// an array, a typed array or a struct (C++ JSG's `IsFallibleType`). + Fallible, + /// A string type. + String, + /// A numeric type. + Numeric, + /// A boolean type. + Boolean, +} + +impl UnionMember { + /// Whether a variant of this kind is tried in `pass`. + pub(crate) const fn takes_part_in(self, pass: Self) -> bool { + matches!( + (self, pass), + (Self::Resource, Self::Resource | Self::Fallible) + | (Self::Fallible, Self::Fallible) + | (Self::String, Self::String) + | (Self::Numeric, Self::Numeric) + | (Self::Boolean, Self::Boolean) + ) + } +} + +/// Converts `value` to a union by running `try_pass` for each pass of the +/// union conversion algorithm (see [`UnionMember`]) until one converts it. +#[doc(hidden)] +pub fn try_union_from_js<'s, R>( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + try_pass: impl Fn(&mut Lock<'s>, v8::Local<'s, v8::Value>, UnionMember) -> Result, Error>, +) -> Result, Error> { + let passes = [ + (true, UnionMember::Resource), + (true, UnionMember::Fallible), + (value.is_boolean(), UnionMember::Boolean), + ( + value.is_number() || value.is_big_int(), + UnionMember::Numeric, + ), + (true, UnionMember::String), + (true, UnionMember::Numeric), + (true, UnionMember::Boolean), + ]; + for (applies, pass) in passes { + if applies && let Some(result) = try_pass(lock, value, pass)? { + return Ok(Some(result)); + } + } + Ok(None) +} + +/// How many types of each Web IDL category a union, or a type that may be one of its variants, +/// contains, as C++ JSG's `webidl::FlattenedTypeTraits` (jsg/web-idl.h) counts them. +/// +/// Web IDL flattens unions: a variant that is itself a union, or a `Nullable`, counts with the +/// types it contains, as a nested `kj::OneOf` or a `kj::Maybe` does. [`UnionTypes::validate`] +/// then applies `webidl::UnionTypeValidator`'s rules to the sum. A type that falls in no category, +/// such as `jsg::v8::Local<'s, jsg::v8::Value>` or `SystemTime`, is [`UnionTypes::OTHER`]; C++ JSG +/// does not reject such types either. Rust has no symbol or callback function types in C++ JSG's +/// sense, so their rules never apply. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnionTypes { + /// Dictionary types: `#[jsg_struct]`s (C++ JSG's `JSG_STRUCT`s). + pub dictionary: u32, + /// Boolean types. + pub boolean: u32, + /// Numeric types. + pub numeric: u32, + /// String types. + pub string: u32, + /// The object type, `jsg::v8::Local<'s, jsg::v8::Object>`. + pub object: u32, + /// Interface-like types: resources. C++ JSG's buffer sources, such as `kj::Array`, + /// have no counterpart: `Vec` is a sequence, and a typed array handle is in no category, + /// as C++ JSG's `v8::Local` is. + pub interface_like: u32, + /// Dictionary-like types: dictionaries and records (`jsg::Dict`). + pub dictionary_like: u32, + /// Sequence-like types: `Vec` and `&[T]`. + pub sequence_like: u32, + /// Nullable types: `Nullable`. + pub nullable: u32, + /// Optional types, `Option` and `Lenient`, which may not be variants. + pub optional: u32, +} + +impl UnionTypes { + /// A type in no category. + pub const OTHER: Self = Self { + dictionary: 0, + boolean: 0, + numeric: 0, + string: 0, + object: 0, + interface_like: 0, + dictionary_like: 0, + sequence_like: 0, + nullable: 0, + optional: 0, + }; + /// A boolean type. + pub const BOOLEAN: Self = Self { + boolean: 1, + ..Self::OTHER + }; + /// A numeric type. + pub const NUMERIC: Self = Self { + numeric: 1, + ..Self::OTHER + }; + /// A string type. + pub const STRING: Self = Self { + string: 1, + ..Self::OTHER + }; + /// The object type. + pub const OBJECT: Self = Self { + object: 1, + ..Self::OTHER + }; + /// An interface-like type. + pub const INTERFACE_LIKE: Self = Self { + interface_like: 1, + ..Self::OTHER + }; + /// A dictionary type, which is also dictionary-like. + pub const DICTIONARY: Self = Self { + dictionary: 1, + dictionary_like: 1, + ..Self::OTHER + }; + /// A record type, which is dictionary-like. + pub const RECORD: Self = Self { + dictionary_like: 1, + ..Self::OTHER + }; + /// A sequence-like type. + pub const SEQUENCE_LIKE: Self = Self { + sequence_like: 1, + ..Self::OTHER + }; + + /// The types of a union of `self`'s and `other`'s types. + #[must_use] + pub const fn plus(self, other: Self) -> Self { + Self { + dictionary: self.dictionary + other.dictionary, + boolean: self.boolean + other.boolean, + numeric: self.numeric + other.numeric, + string: self.string + other.string, + object: self.object + other.object, + interface_like: self.interface_like + other.interface_like, + dictionary_like: self.dictionary_like + other.dictionary_like, + sequence_like: self.sequence_like + other.sequence_like, + nullable: self.nullable + other.nullable, + optional: self.optional + other.optional, + } + } + + /// The types of a nullable type whose inner type has `self`'s types. + #[must_use] + pub const fn nullable(self) -> Self { + Self { + nullable: self.nullable + 1, + ..self + } + } + + /// The types of an optional type whose inner type has `self`'s types. + #[must_use] + pub const fn optional(self) -> Self { + Self { + optional: self.optional + 1, + ..self + } + } + + /// Panics, at compile time when evaluated in a constant, unless a union with these types + /// follows Web IDL's rules for unions, as `webidl::UnionTypeValidator` checks them. + /// + /// # Panics + /// + /// Panics with the rule the union breaks. + pub const fn validate(self) { + assert!( + self.nullable + self.dictionary <= 1, + "A #[jsg_oneof] union may contain at most one nullable or dictionary type." + ); + assert!( + self.boolean <= 1, + "A #[jsg_oneof] union may contain at most one boolean type." + ); + assert!( + self.numeric <= 1, + "A #[jsg_oneof] union may contain at most one numeric type." + ); + assert!( + self.string <= 1, + "A #[jsg_oneof] union may contain at most one string type." + ); + assert!( + self.object <= 1, + "A #[jsg_oneof] union may contain at most one object type." + ); + assert!( + self.object == 0 + || self.interface_like + self.dictionary_like + self.sequence_like == 0, + "A #[jsg_oneof] union may contain an object type only if it also contains no \ + interface-like, dictionary-like or sequence-like types." + ); + assert!( + self.dictionary_like <= 1, + "A #[jsg_oneof] union may contain at most one dictionary-like type." + ); + assert!( + self.sequence_like <= 1, + "A #[jsg_oneof] union may contain at most one sequence-like type." + ); + assert!( + self.optional == 0, + "A #[jsg_oneof] union may not contain Option or Lenient, which mark optional \ + parameters and fields. Use Nullable for a nullable type." + ); + } +} + +/// A type that can be a `#[jsg_oneof]` variant: every [`Type`], and the optional types, which +/// are not `Type`s and which a union rejects with an explanation rather than with a missing +/// impl. +#[doc(hidden)] +#[diagnostic::on_unimplemented( + message = "`{Self}` cannot be a `#[jsg_oneof]` variant", + note = "a variant must implement `jsg::Type`" +)] +pub trait UnionVariant { + /// The variant's types, flattened; see [`UnionTypes`]. + const UNION_TYPES: UnionTypes; + + /// Whether `value` is exactly of the variant's type; see [`Type::is_exact`]. + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool; +} + +impl UnionVariant for T { + const UNION_TYPES: UnionTypes = T::UNION_TYPES; + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + T::is_exact(value) + } +} + +impl UnionVariant for Option { + const UNION_TYPES: UnionTypes = T::UNION_TYPES.optional(); + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_undefined() || T::is_exact(value) + } +} + +impl UnionVariant for Lenient { + const UNION_TYPES: UnionTypes = T::UNION_TYPES.optional(); + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_null_or_undefined() || T::is_exact(value) + } +} + +#[cfg(test)] +#[path = "oneof-test.rs"] +mod tests; diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index ef268be9194..23e81329bbf 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -27,6 +27,7 @@ use crate::ToJS; use crate::Traced; use crate::Type; use crate::UnionMember; +use crate::UnionTypes; use crate::feature_flags::compatibility_flags; use crate::isolate_state::IsolateState; use crate::v8; @@ -182,6 +183,9 @@ impl<'s, R: Resource + 'static> ToJS<'s> for Rc { } impl Type for Rc { + // A Web IDL non-callback interface type. + const UNION_TYPES: UnionTypes = UnionTypes::INTERFACE_LIKE; + fn class_name() -> &'static str { R::class_name() } diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index 67ad79118e4..1a4ea5ebcdf 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -79,6 +79,8 @@ use crate::NonCoercible; use crate::Nullable; use crate::Number; use crate::Type; +use crate::UnionMember; +use crate::UnionTypes; use crate::v8; // ============================================================================= @@ -457,95 +459,14 @@ pub trait FromJS<'s>: Sized { } } -// ============================================================================= -// Unions (`#[jsg_oneof]`) -// ============================================================================= - -/// The kind of a `#[jsg_oneof]` variant, which decides the passes in which the -/// variant is tried. -/// -/// A union converts a value as C++ JSG's `OneOfWrapper::tryUnwrap()` -/// (jsg/value.h) does, following Web IDL's union conversion: it makes a series -/// of passes over its variants and takes the first one that converts, trying -/// in order -/// -/// 1. resource types, -/// 2. every type that is not a string, numeric or boolean type, -/// 3. boolean types, if the value is a boolean, -/// 4. numeric types, if the value is a number or a `BigInt`, -/// 5. string types, -/// 6. numeric types, -/// 7. boolean types. -/// -/// Passes 5 to 7 coerce, so a value becomes a string only if no other variant -/// takes it, and becomes a number or boolean only if no string variant -/// converts it either. An error from a variant's conversion, such as a -/// `Symbol` that cannot become a string, ends the conversion with that error -/// rather than trying another variant. -#[doc(hidden)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum UnionMember { - /// A resource type (`jsg::Rc`). - Resource, - /// Any other type that is not a string, numeric or boolean type, such as - /// an array, a typed array or a struct (C++ JSG's `IsFallibleType`). - Fallible, - /// A string type. - String, - /// A numeric type. - Numeric, - /// A boolean type. - Boolean, -} - -impl UnionMember { - /// Whether a variant of this kind is tried in `pass`. - const fn takes_part_in(self, pass: Self) -> bool { - matches!( - (self, pass), - (Self::Resource, Self::Resource | Self::Fallible) - | (Self::Fallible, Self::Fallible) - | (Self::String, Self::String) - | (Self::Numeric, Self::Numeric) - | (Self::Boolean, Self::Boolean) - ) - } -} - -/// Converts `value` to a union by running `try_pass` for each pass of the -/// union conversion algorithm (see [`UnionMember`]) until one converts it. -#[doc(hidden)] -pub fn try_union_from_js<'s, R>( - lock: &mut Lock<'s>, - value: v8::Local<'s, v8::Value>, - try_pass: impl Fn(&mut Lock<'s>, v8::Local<'s, v8::Value>, UnionMember) -> Result, Error>, -) -> Result, Error> { - let passes = [ - (true, UnionMember::Resource), - (true, UnionMember::Fallible), - (value.is_boolean(), UnionMember::Boolean), - ( - value.is_number() || value.is_big_int(), - UnionMember::Numeric, - ), - (true, UnionMember::String), - (true, UnionMember::Numeric), - (true, UnionMember::Boolean), - ]; - for (applies, pass) in passes { - if applies && let Some(result) = try_pass(lock, value, pass)? { - return Ok(Some(result)); - } - } - Ok(None) -} - // ============================================================================= // Primitive type implementations // ============================================================================= // Boolean implementation for JavaScript booleans. impl Type for bool { + const UNION_TYPES: UnionTypes = UnionTypes::BOOLEAN; + fn class_name() -> &'static str { "boolean" } @@ -583,6 +504,8 @@ impl<'s> FromJS<'s> for bool { } impl Type for std::string::String { + const UNION_TYPES: UnionTypes = UnionTypes::STRING; + fn class_name() -> &'static str { "string" } @@ -627,6 +550,8 @@ impl<'s> FromJS<'s> for std::string::String { // Number implementation for JavaScript numbers impl Type for Number { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "number" } @@ -684,6 +609,8 @@ impl<'s> FromJS<'s> for Number { // by converting JavaScript strings to owned Strings, then borrowing. // The macro handles passing &arg instead of arg for reference types. impl Type for &str { + const UNION_TYPES: UnionTypes = UnionTypes::STRING; + fn class_name() -> &'static str { "string" } @@ -745,6 +672,8 @@ impl<'s, T: FromJS<'s, ResultType = T>> FromJS<'s> for &T { // Slice type - allows functions to accept &[T] parameters. // JavaScript arrays are converted to Vec, then borrowed as &[T] by the macro. impl Type for &[T] { + const UNION_TYPES: UnionTypes = UnionTypes::SEQUENCE_LIKE; + fn class_name() -> &'static str { "Array" } @@ -791,6 +720,8 @@ macro_rules! impl_integer { ($($type:ty => $name:literal, $non_finite:expr, $range:expr);* $(;)?) => { $( impl Type for $type { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "number" } @@ -899,6 +830,8 @@ impl_integer!( ); impl Type for i64 { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "bigint" } @@ -953,6 +886,8 @@ impl<'s> FromJS<'s> for i64 { } impl Type for u64 { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "bigint" } @@ -975,6 +910,8 @@ impl<'s> ToJS<'s> for u64 { impl<'s> FromJS<'s> for u64 { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { "bigint".to_owned() } @@ -1012,6 +949,18 @@ impl<'s> FromJS<'s> for u64 { } } +// Like C++ JSG's `kj::Date`, which is in none of Web IDL's categories, so a union tries it in +// the pass for types that aren't strings, numbers or booleans, where it also takes a number. +impl Type for std::time::SystemTime { + fn class_name() -> &'static str { + "Date" + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_date() + } +} + // Like C++ JSG's kj::Date (jsg/value.h), a Date or a number of milliseconds since the epoch, which // must be finite and within the range of `kj::Date`, `i64` nanoseconds either side of the epoch. // Fractions of a millisecond are dropped. @@ -1169,6 +1118,8 @@ impl<'s, T: FromJS<'s>> FromJS<'s> for Option { } impl Type for NonCoercible { + const UNION_TYPES: UnionTypes = T::UNION_TYPES; + fn class_name() -> &'static str { T::class_name() } @@ -1215,6 +1166,19 @@ impl<'s, T: Coercible + FromJS<'s>> FromJS<'s> for NonCoercible { } } +// Like C++ JSG's `kj::Maybe`: a union with it as a variant counts it as nullable, and as `T`. +impl Type for Nullable { + const UNION_TYPES: UnionTypes = T::UNION_TYPES.nullable(); + + fn class_name() -> &'static str { + T::class_name() + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_null_or_undefined() || T::is_exact(value) + } +} + // Like C++ `kj::Maybe`, Web IDL nullable conversion reads `undefined` as `null`. // Nullable arguments remain required; only an outer `Option` permits omission. impl<'s, T: FromJS<'s>> FromJS<'s> for Nullable { @@ -1280,6 +1244,8 @@ impl<'s, T: FromJS<'s>> FromJS<'s> for Lenient { // ============================================================================= impl Type for Vec { + const UNION_TYPES: UnionTypes = UnionTypes::SEQUENCE_LIKE; + fn class_name() -> &'static str { "Array" } @@ -1342,6 +1308,8 @@ impl<'s, T: Type + FromJS<'s, ResultType = T>> FromJS<'s> for Vec { // Like C++ JSG's DictWrapper (jsg/value.h): any object other than an array. impl Type for Dict { + const UNION_TYPES: UnionTypes = UnionTypes::RECORD; + fn class_name() -> &'static str { "object" } @@ -1463,7 +1431,7 @@ impl_typed_array_type!( // ArrayBuffer, ArrayBufferView, SharedArrayBuffer // ============================================================================= // -// These types only implement `Type` (used by `NonCoercible` for type checking); their +// These markers implement `Type` so that their handles can be `#[jsg_oneof]` variants; their // conversions are those of their handles, in `v8.rs`. impl Type for v8::ArrayBuffer { @@ -1510,6 +1478,99 @@ impl<'s> FromJS<'s> for v8::Local<'s, v8::Value> { } } +// A handle is the type its marker is, as C++ JSG's `v8::Local` is. +impl Type for v8::Local<'_, M> { + const UNION_TYPES: UnionTypes = M::UNION_TYPES; + + fn class_name() -> &'static str { + M::class_name() + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + M::is_exact(value) + } +} + +/// Implements `Type` for handle markers. Their Web IDL categories are those that C++ JSG's +/// `webidl` traits (jsg/web-idl.h) give the corresponding `v8::Local`. +macro_rules! impl_marker_type { + ($($marker:ident: $name:literal, $is_exact:ident, $types:expr;)*) => { + $(impl Type for v8::$marker { + const UNION_TYPES: UnionTypes = $types; + + fn class_name() -> &'static str { + $name + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.$is_exact() + } + })* + }; +} + +impl_marker_type! { + String: "String", is_string, UnionTypes::STRING; + BigInt: "BigInt", is_big_int, UnionTypes::NUMERIC; + Object: "Object", is_object, UnionTypes::OBJECT; + Function: "Function", is_function, UnionTypes::OTHER; + Promise: "Promise", is_promise, UnionTypes::OTHER; + TypedArray: "TypedArray", is_typed_array, UnionTypes::OTHER; +} + +// Any value is a `v8::Value`. +impl Type for v8::Value { + fn class_name() -> &'static str { + "Value" + } + + fn is_exact(_value: v8::Local<'_, v8::Value>) -> bool { + true + } +} + +/// Implements `FromJS` for handles of markers that C++ JSG converts a value to without coercion +/// (`V8HandleWrapper` in jsg/type-wrapper.h): a value of the marker's type, and nothing else. +macro_rules! impl_handle_from_js { + ($($marker:ident: $member:expr;)*) => { + $(impl<'s> FromJS<'s> for v8::Local<'s, v8::$marker> { + type ResultType = Self; + + const UNION_MEMBER: UnionMember = $member; + + fn type_name() -> String { + // C++ JSG names `v8::Local` after `T`. + ::class_name().to_owned() + } + + fn from_js(lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>) -> Result { + let type_name = value.type_of(lock); + Self::try_from_js(lock, value)?.ok_or_else(|| { + Error::new_type_error(format!( + "Expected {} but got {type_name}", + >::type_name() + )) + }) + } + + fn try_from_js( + _lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + ) -> Result, Error> { + Ok(Self::try_from(value).ok()) + } + })* + }; +} + +impl_handle_from_js! { + String: UnionMember::String; + BigInt: UnionMember::Numeric; + Object: UnionMember::Fallible; + Function: UnionMember::Fallible; + TypedArray: UnionMember::Fallible; +} + #[cfg(test)] #[path = "wrappable-test.rs"] mod tests; From ec5c442f9dbdaf707db36f1f0cfa089233e7b6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 29 Oct 2026 11:00:28 +0100 Subject: [PATCH 22/23] Address review of Rust JSG unions * A v8::Local<'s, v8::Uint8ClampedArray> can be a union variant, as kj::OneOf can hold one. * Test the last pass of union conversion, where a boolean variant coerces a value no string or numeric variant takes, with StringHandleOrBool on its own. * Reject a resource, handle or SystemTime that appears in both a union and a union nested in it, as C++ JSG's FlattenedTypeTraits does. The macro can't see into a nested union and stable Rust can't compare types in a constant, so jsg::Type has a hidden UNION_NAMES that flattens as UNION_TYPES does, and the union checks it for a name that appears twice. --- src/rust/jsg-compile-tests/ok-oneof.rs | 16 +++- .../oneof-duplicate-types.rs | 8 +- .../oneof-nested-duplicate-types.rs | 74 +++++++++++++++++ src/rust/jsg-macros/oneof.rs | 23 +++++- src/rust/jsg-macros/resource.rs | 3 + src/rust/jsg-test/tests/jsg_oneof_variants.rs | 82 ++++++++++++++++++- src/rust/jsg/README.md | 9 +- src/rust/jsg/lib.rs | 16 ++++ src/rust/jsg/oneof-test.rs | 44 +++++++++- src/rust/jsg/oneof.rs | 71 ++++++++++++++++ src/rust/jsg/resource.rs | 2 + src/rust/jsg/wrappable.rs | 14 ++++ 12 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 src/rust/jsg-compile-tests/oneof-nested-duplicate-types.rs diff --git a/src/rust/jsg-compile-tests/ok-oneof.rs b/src/rust/jsg-compile-tests/ok-oneof.rs index e9265a4f53f..2cce97ead74 100644 --- a/src/rust/jsg-compile-tests/ok-oneof.rs +++ b/src/rust/jsg-compile-tests/ok-oneof.rs @@ -3,7 +3,8 @@ // https://opensource.org/licenses/Apache-2.0 // The unions that Web IDL allows compile, including several resources, a handle of any type with -// a dictionary, and an object handle with primitives. +// a dictionary, an object handle with primitives, and different resources and typed array handles +// in a union and a union nested in it. use jsg::Nullable; use jsg::v8; @@ -54,3 +55,16 @@ pub enum AnyOrPoint<'s> { Any(v8::Local<'s, v8::Value>), Point(Point), } + +#[jsg_oneof] +pub enum SecondOrClamped<'s> { + Second(jsg::Rc), + Clamped(v8::Local<'s, v8::Uint8ClampedArray>), +} + +#[jsg_oneof] +pub enum FirstOrNested<'s> { + First(jsg::Rc), + Bytes(v8::Local<'s, v8::Uint8Array>), + Nested(SecondOrClamped<'s>), +} diff --git a/src/rust/jsg-compile-tests/oneof-duplicate-types.rs b/src/rust/jsg-compile-tests/oneof-duplicate-types.rs index 6b4e57e32b5..99a145b1dd8 100644 --- a/src/rust/jsg-compile-tests/oneof-duplicate-types.rs +++ b/src/rust/jsg-compile-tests/oneof-duplicate-types.rs @@ -4,7 +4,9 @@ // A #[jsg_oneof] union may not contain the same type twice, as C++ JSG's // webidl::UnionTypeValidator requires of a kj::OneOf, even of a kind it may have more than one -// of, such as resources. As there, a nullable type counts as its inner type. +// of, such as resources. As there, a nullable type counts as its inner type. Each union is +// rejected twice: by conflicting trait impls, which point at the second variant, and because +// the union's names contain the resource's twice. use jsg::Nullable; use jsg_macros::jsg_oneof; @@ -18,13 +20,13 @@ impl Thing {} type SameThing = jsg::Rc; -#[jsg_oneof] +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice pub enum SameResourceTwice { First(jsg::Rc), Second(SameThing), //~ ERROR E0119 } -#[jsg_oneof] +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice pub enum ResourceAndNullableResource { Thing(jsg::Rc), Nullable(Nullable>), //~ ERROR E0119 diff --git a/src/rust/jsg-compile-tests/oneof-nested-duplicate-types.rs b/src/rust/jsg-compile-tests/oneof-nested-duplicate-types.rs new file mode 100644 index 00000000000..df62dcf7587 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-nested-duplicate-types.rs @@ -0,0 +1,74 @@ +// 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 #[jsg_oneof] union may not contain a type that a union nested in it also contains, as C++ +// JSG's webidl::FlattenedTypeTraits flattens a nested kj::OneOf before checking for duplicate +// types. These types are in no Web IDL category with a limit, so only their names catch them. + +use std::time::SystemTime; + +use jsg::Nullable; +use jsg::v8; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +#[jsg_oneof] +pub enum ThingOrString { + Thing(jsg::Rc), + String(String), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum ResourceInNestedUnion { + Thing(jsg::Rc), + Nested(ThingOrString), +} + +#[jsg_oneof] +pub enum NullableThingOrString { + Thing(Nullable>), + String(String), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum ResourceInNullableInNestedUnion { + Thing(jsg::Rc), + Nested(NullableThingOrString), +} + +#[jsg_oneof] +pub enum BytesOrString<'s> { + Bytes(v8::Local<'s, v8::Uint8Array>), + String(String), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum HandleInNestedUnion<'s> { + Bytes(v8::Local<'s, v8::Uint8Array>), + Nested(BytesOrString<'s>), +} + +#[jsg_oneof] +pub enum DateOrString { + Date(SystemTime), + String(String), +} + +#[jsg_oneof] +pub enum DateOrBool { + Date(SystemTime), + Bool(bool), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum DateInTwoNestedUnions { + First(DateOrString), + Second(DateOrBool), +} diff --git a/src/rust/jsg-macros/oneof.rs b/src/rust/jsg-macros/oneof.rs index 416f8594ad3..35a210a251b 100644 --- a/src/rust/jsg-macros/oneof.rs +++ b/src/rust/jsg-macros/oneof.rs @@ -142,8 +142,11 @@ impl<'a> Union<'a> { /// `webidl::UnionTypeValidator` checks a `kj::OneOf`. The rules on the variants' categories /// are checked through their types' `jsg::UnionVariant::UNION_TYPES`, which flatten nested /// unions and nullable types. A variant type that appears twice has two conflicting - /// implementations of a local trait; a `Nullable` variant implements it for `T`, as - /// `webidl::FlattenedTypeTraits` flattens `kj::Maybe` to `T`. + /// implementations of a local trait, whose error points at the variant; a `Nullable` + /// variant implements it for `T`, as `webidl::FlattenedTypeTraits` flattens `kj::Maybe` to + /// `T`. That can't see into a nested union, so the names in `jsg::Type::UNION_NAMES`, which + /// flatten as the categories do, catch a resource, handle or `SystemTime` that appears in + /// both a union and a union nested in it. fn generate_checks(&self, input: &DeriveInput) -> proc_macro2::TokenStream { let self_type = self.with_static_lifetime(self.self_type(input)); // Spanned on the variant, so that a duplicate's error points at it. @@ -155,6 +158,7 @@ impl<'a> Union<'a> { }); quote! { const _: () = <#self_type as jsg::Type>::UNION_TYPES.validate(); + const _: () = jsg::validate_union_names(<#self_type as jsg::Type>::UNION_NAMES); const _: () = { /// A `#[jsg_oneof]` union may not contain duplicate types; a type that has two @@ -172,12 +176,27 @@ impl<'a> Union<'a> { let self_type = self.self_type(input); let lifetime = self.lifetime.into_iter(); let inner_types = &self.inner_types; + // A constant's type can't use the enum's lifetime, so the names are those of the + // `'static` variant types; a lifetime doesn't change a type's name. + let static_types: Vec<_> = inner_types + .iter() + .map(|ty| self.with_static_lifetime(ty)) + .collect(); quote! { #[automatically_derived] impl<#(#lifetime)*> jsg::Type for #self_type { const UNION_TYPES: jsg::UnionTypes = jsg::UnionTypes::OTHER #(.plus(<#inner_types as jsg::UnionVariant>::UNION_TYPES))*; + const UNION_NAMES: &'static [&'static str] = { + const LEN: usize = + 0 #(+ <#static_types as jsg::UnionVariant>::UNION_NAMES.len())*; + const NAMES: [&str; LEN] = jsg::concat_union_names( + &[#(<#static_types as jsg::UnionVariant>::UNION_NAMES),*], + ); + &NAMES + }; + fn class_name() -> &'static str { stringify!(#name) } diff --git a/src/rust/jsg-macros/resource.rs b/src/rust/jsg-macros/resource.rs index 45d4c396e9f..25a2caf4a23 100644 --- a/src/rust/jsg-macros/resource.rs +++ b/src/rust/jsg-macros/resource.rs @@ -101,6 +101,9 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> #[automatically_derived] impl jsg::Type for #name { + const UNION_NAMES: &'static [&'static str] = + &[concat!(module_path!(), "::", #name_str)]; + fn class_name() -> &'static str { #class_name } fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { diff --git a/src/rust/jsg-test/tests/jsg_oneof_variants.rs b/src/rust/jsg-test/tests/jsg_oneof_variants.rs index f626b49413d..e74c214910d 100644 --- a/src/rust/jsg-test/tests/jsg_oneof_variants.rs +++ b/src/rust/jsg-test/tests/jsg_oneof_variants.rs @@ -113,6 +113,13 @@ enum Uint8ArrayOrString<'s> { String(String), } +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +enum Uint8ClampedArrayOrString<'s> { + Clamped(v8::Local<'s, v8::Uint8ClampedArray>), + String(String), +} + /// A dictionary and a value of any type, in both orders: both are tried in the same pass, in /// declaration order. #[jsg_oneof] @@ -276,6 +283,17 @@ impl VariantContext { } } + #[jsg_method] + pub fn take_uint8_clamped_array_or_string<'s>( + &self, + value: Uint8ClampedArrayOrString<'s>, + ) -> String { + match value { + Uint8ClampedArrayOrString::Clamped(bytes) => format!("clamped:{}", bytes.len()), + Uint8ClampedArrayOrString::String(s) => format!("string:{s}"), + } + } + #[jsg_method] pub fn take_point_or_value<'s>(&self, value: PointOrValue<'s>) -> String { match value { @@ -300,12 +318,28 @@ impl VariantContext { ) -> Result { Ok(match value { NumberOrNestedHandle::Number(n) => format!("number:{n}"), - NumberOrNestedHandle::Nested(StringHandleOrBool::String(s)) => { - format!("handle:{}", String::from_js(lock, s.into())?) - } - NumberOrNestedHandle::Nested(StringHandleOrBool::Bool(b)) => format!("bool:{b}"), + NumberOrNestedHandle::Nested(nested) => describe_string_handle_or_bool(lock, &nested)?, }) } + + #[jsg_method] + pub fn take_string_handle_or_bool<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: StringHandleOrBool<'s>, + ) -> Result { + describe_string_handle_or_bool(lock, &value) + } +} + +fn describe_string_handle_or_bool<'s>( + lock: &mut jsg::Lock<'s>, + value: &StringHandleOrBool<'s>, +) -> Result { + Ok(match value { + StringHandleOrBool::String(s) => format!("handle:{}", String::from_js(lock, (*s).into())?), + StringHandleOrBool::Bool(b) => format!("bool:{b}"), + }) } fn with_context(f: impl for<'s, 'c> FnOnce(&mut jsg::Lock<'s>, &crate::EvalContext<'c>)) { @@ -530,6 +564,26 @@ fn typed_array_handle_variant_takes_its_typed_array() { }); } +/// A `Uint8ClampedArray` handle takes only a `Uint8ClampedArray`, not a `Uint8Array` with the same +/// elements, as C++ JSG's `v8::Local` does. +#[test] +fn uint8_clamped_array_handle_variant_takes_only_its_typed_array() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeUint8ClampedArrayOrString(new Uint8ClampedArray(3))", + "clamped:3", + ); + expect_eval( + lock, + ctx, + "cx.takeUint8ClampedArrayOrString(new Uint8Array(3))", + "string:0,0,0", + ); + }); +} + /// A value handle takes anything, in the pass for types that aren't strings, numbers or /// booleans, so within that pass declaration order decides. #[test] @@ -551,3 +605,23 @@ fn nested_union_with_handles_is_flattened() { expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle({})", "number:NaN"); }); } + +/// A string handle takes only a string, so with no numeric variant a value that is neither a +/// string nor a boolean reaches the last pass, where the boolean variant coerces it, as in +/// `kj::OneOf, bool>`. +#[test] +fn boolean_variant_coerces_in_the_last_pass() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringHandleOrBool('a')", "handle:a"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool(true)", "bool:true"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool(0)", "bool:false"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool(1)", "bool:true"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool({})", "bool:true"); + expect_eval( + lock, + ctx, + "cx.takeStringHandleOrBool(undefined)", + "bool:false", + ); + }); +} diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index c2cf76fdd62..acbacca6bbc 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -727,12 +727,9 @@ numeric, string, dictionary-like (struct or `Dict`) and sequence-like type; a nullable or struct type in all; an object handle (`v8::Local<'s, v8::Object>`) only without resources, dictionary-like or sequence-like types; no `Option` or `Lenient`, which mark optional parameters and fields; and no type twice, counting a -`Nullable` as `T`. A union may have several resource types, as Web IDL allows. One -difference from C++: the check for a type that appears twice covers the union's own -variants, seeing through `Nullable<…>` as written but not through a type alias of one, -and not the variants of a nested union. So a type that no at-most-one rule counts, such -as a resource, a typed array handle, `SystemTime` or `v8::Local<'s, v8::Value>`, isn't -caught when it is in both a union and a union nested in it. +`Nullable` as `T` and the variants of a nested union as the union's own. A union may +have several resource types, as Web IDL allows, but not the same one in both a union and +a union nested in it. The union traces the variant it holds, so a union with a `jsg::Rc` variant can be a resource field; one that holds handles can't. A union implements `jsg::ToJS` when every diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index e3718c775a5..aa7ac1ceadb 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -39,7 +39,11 @@ pub use oneof::UnionTypes; #[doc(hidden)] pub use oneof::UnionVariant; #[doc(hidden)] +pub use oneof::concat_union_names; +#[doc(hidden)] pub use oneof::try_union_from_js; +#[doc(hidden)] +pub use oneof::validate_union_names; pub use resource::NestedType; pub use resource::Rc; pub use resource::Resource; @@ -952,6 +956,18 @@ pub trait Type: Sized { /// at compile time; see [`UnionTypes`]. #[doc(hidden)] const UNION_TYPES: UnionTypes = UnionTypes::OTHER; + + /// Names that identify the type in a `#[jsg_oneof]` union, flattened as + /// [`Self::UNION_TYPES`] is: a union's are those of its variants, and a `Nullable`'s are + /// `T`'s. A union may not contain a name twice, as C++ JSG's `webidl::FlattenedTypeTraits` + /// rejects a type that appears twice in a flattened `kj::OneOf`. + /// + /// Resources, handles and `SystemTime` have a name, since most of them are in no category, or + /// in one with no limit, so [`UnionTypes::validate`] can't tell that one appears twice. The + /// other types that `jsg` implements `Type` for need none: each is in a category that a union + /// may hold at most one type of. + #[doc(hidden)] + const UNION_NAMES: &'static [&'static str] = &[]; } /// Represents a constant value that can be exposed to JavaScript. diff --git a/src/rust/jsg/oneof-test.rs b/src/rust/jsg/oneof-test.rs index 436a295f637..bb6966b9573 100644 --- a/src/rust/jsg/oneof-test.rs +++ b/src/rust/jsg/oneof-test.rs @@ -11,15 +11,20 @@ use crate::FromJS; use crate::NonCoercible; use crate::Nullable; -/// The message `validate` panics with for `types`, or `None` if they are valid. -fn rejection(types: UnionTypes) -> Option { - let payload = std::panic::catch_unwind(AssertUnwindSafe(|| types.validate())).err()?; +/// The message `check` panics with, or `None` if it returns. +fn panic_message(check: impl FnOnce()) -> Option { + let payload = std::panic::catch_unwind(AssertUnwindSafe(check)).err()?; payload .downcast_ref::<&str>() .map(|message| (*message).to_owned()) .or_else(|| payload.downcast_ref::().cloned()) } +/// The message `validate` panics with for `types`, or `None` if they are valid. +fn rejection(types: UnionTypes) -> Option { + panic_message(|| types.validate()) +} + /// The types of a union of the types in `members`. fn union(members: &[UnionTypes]) -> UnionTypes { members @@ -159,6 +164,39 @@ fn each_type_has_the_kind_of_its_cpp_counterpart() { assert_kind::>(Fallible, UnionTypes::OTHER); assert_kind::>(Fallible, UnionTypes::OTHER); assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); assert_kind::>(Fallible, UnionTypes::OTHER); assert_kind::>(Fallible, UnionTypes::OTHER); } + +#[test] +fn union_names_concatenate_in_order() { + let names: [&str; 3] = concat_union_names(&[&["a"], &[], &["b", "c"]]); + assert_eq!(names, ["a", "b", "c"]); +} + +#[test] +fn a_name_twice_is_rejected() { + assert_eq!( + panic_message(|| validate_union_names(&["a", "b", "ab"])), + None + ); + let message = panic_message(|| validate_union_names(&["a", "b", "a"])).unwrap_or_default(); + assert!( + message.contains("may not contain the same type twice"), + "{message:?}" + ); +} + +/// Handles, nullable types and `SystemTime` have the names of the types they hold or are; other +/// types are kept from appearing twice by their categories, and have none. +#[test] +fn names_flatten_as_categories_do() { + let names = as UnionVariant>::UNION_NAMES; + assert_eq!(names, ["v8::Uint8ClampedArray"]); + let names = > as UnionVariant>::UNION_NAMES; + assert_eq!(names, ["v8::Value"]); + assert_eq!(::UNION_NAMES, ["SystemTime"]); + assert!(::UNION_NAMES.is_empty()); + assert!(> as UnionVariant>::UNION_NAMES.is_empty()); +} diff --git a/src/rust/jsg/oneof.rs b/src/rust/jsg/oneof.rs index 7ad8c340260..ccbbae44a7c 100644 --- a/src/rust/jsg/oneof.rs +++ b/src/rust/jsg/oneof.rs @@ -270,6 +270,71 @@ impl UnionTypes { } } +/// The names of a union's types: `parts`, its variants' names, one after another. `N` is their +/// total number. +/// +/// # Panics +/// +/// Panics, at compile time when evaluated in a constant, if `N` is not that number. +#[doc(hidden)] +#[must_use] +pub const fn concat_union_names(parts: &[&[&'static str]]) -> [&'static str; N] { + let mut names = [""; N]; + let mut len = 0; + let mut part = 0; + while part < parts.len() { + let mut i = 0; + while i < parts[part].len() { + names[len] = parts[part][i]; + len += 1; + i += 1; + } + part += 1; + } + assert!(len == N, "`N` is not the number of names in `parts`"); + names +} + +/// Panics, at compile time when evaluated in a constant, if a name appears twice in `names`, +/// the names of a union's types, as `webidl::UnionTypeValidator` rejects a `kj::OneOf` whose +/// flattened types contain a type twice. +/// +/// # Panics +/// +/// Panics if `names` contains a name twice. +#[doc(hidden)] +pub const fn validate_union_names(names: &[&str]) { + let mut i = 0; + while i < names.len() { + let mut j = i + 1; + while j < names.len() { + assert!( + !str_eq(names[i], names[j]), + "A #[jsg_oneof] union may not contain the same type twice, counting the variants \ + of nested unions and the inner types of nullable types." + ); + j += 1; + } + i += 1; + } +} + +/// Whether `a` and `b` are equal, in a constant: `PartialEq` for `str` is not `const`. +const fn str_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + /// A type that can be a `#[jsg_oneof]` variant: every [`Type`], and the optional types, which /// are not `Type`s and which a union rejects with an explanation rather than with a missing /// impl. @@ -282,12 +347,16 @@ pub trait UnionVariant { /// The variant's types, flattened; see [`UnionTypes`]. const UNION_TYPES: UnionTypes; + /// The names of the variant's types, flattened; see [`Type::UNION_NAMES`]. + const UNION_NAMES: &'static [&'static str]; + /// Whether `value` is exactly of the variant's type; see [`Type::is_exact`]. fn is_exact(value: v8::Local<'_, v8::Value>) -> bool; } impl UnionVariant for T { const UNION_TYPES: UnionTypes = T::UNION_TYPES; + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { T::is_exact(value) @@ -296,6 +365,7 @@ impl UnionVariant for T { impl UnionVariant for Option { const UNION_TYPES: UnionTypes = T::UNION_TYPES.optional(); + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { value.is_undefined() || T::is_exact(value) @@ -304,6 +374,7 @@ impl UnionVariant for Option { impl UnionVariant for Lenient { const UNION_TYPES: UnionTypes = T::UNION_TYPES.optional(); + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { value.is_null_or_undefined() || T::is_exact(value) diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index 8317f0a85a0..e147cb59d7d 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -185,6 +185,8 @@ impl<'s, R: Resource + 'static> ToJS<'s> for Rc { impl Type for Rc { // A Web IDL non-callback interface type. const UNION_TYPES: UnionTypes = UnionTypes::INTERFACE_LIKE; + // The resource's, which `#[jsg_resource]` makes its module path and name. + const UNION_NAMES: &'static [&'static str] = R::UNION_NAMES; fn class_name() -> &'static str { R::class_name() diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index 27757268d08..ea1c755300f 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -943,6 +943,8 @@ impl<'s> FromJS<'s> for u64 { // Like C++ JSG's `kj::Date`, which is in none of Web IDL's categories, so a union tries it in // the pass for types that aren't strings, numbers or booleans, where it also takes a number. impl Type for std::time::SystemTime { + const UNION_NAMES: &'static [&'static str] = &["SystemTime"]; + fn class_name() -> &'static str { "Date" } @@ -1160,6 +1162,7 @@ impl<'s, T: Coercible + FromJS<'s>> FromJS<'s> for NonCoercible { // Like C++ JSG's `kj::Maybe`: a union with it as a variant counts it as nullable, and as `T`. impl Type for Nullable { const UNION_TYPES: UnionTypes = T::UNION_TYPES.nullable(); + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; fn class_name() -> &'static str { T::class_name() @@ -1394,6 +1397,8 @@ macro_rules! impl_typed_array_type { ($($marker:ident => $is_check:ident),* $(,)?) => { $( impl Type for v8::$marker { + const UNION_NAMES: &'static [&'static str] = &[concat!("v8::", stringify!($marker))]; + fn class_name() -> &'static str { stringify!($marker) } @@ -1407,6 +1412,7 @@ macro_rules! impl_typed_array_type { impl_typed_array_type!( Uint8Array => is_uint8_array, + Uint8ClampedArray => is_uint8clamped_array, Uint16Array => is_uint16_array, Uint32Array => is_uint32_array, Int8Array => is_int8_array, @@ -1426,6 +1432,8 @@ impl_typed_array_type!( // conversions are those of their handles, in `v8.rs`. impl Type for v8::ArrayBuffer { + const UNION_NAMES: &'static [&'static str] = &["v8::ArrayBuffer"]; + fn class_name() -> &'static str { "ArrayBuffer" } @@ -1435,6 +1443,8 @@ impl Type for v8::ArrayBuffer { } impl Type for v8::ArrayBufferView { + const UNION_NAMES: &'static [&'static str] = &["v8::ArrayBufferView"]; + fn class_name() -> &'static str { "ArrayBufferView" } @@ -1472,6 +1482,7 @@ impl<'s> FromJS<'s> for v8::Local<'s, v8::Value> { // A handle is the type its marker is, as C++ JSG's `v8::Local` is. impl Type for v8::Local<'_, M> { const UNION_TYPES: UnionTypes = M::UNION_TYPES; + const UNION_NAMES: &'static [&'static str] = M::UNION_NAMES; fn class_name() -> &'static str { M::class_name() @@ -1488,6 +1499,7 @@ macro_rules! impl_marker_type { ($($marker:ident: $name:literal, $is_exact:ident, $types:expr;)*) => { $(impl Type for v8::$marker { const UNION_TYPES: UnionTypes = $types; + const UNION_NAMES: &'static [&'static str] = &[concat!("v8::", stringify!($marker))]; fn class_name() -> &'static str { $name @@ -1511,6 +1523,8 @@ impl_marker_type! { // Any value is a `v8::Value`. impl Type for v8::Value { + const UNION_NAMES: &'static [&'static str] = &["v8::Value"]; + fn class_name() -> &'static str { "Value" } From d3fbf0ced80a96390fae1621fcafc6444f991aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 29 Oct 2026 12:10:50 +0100 Subject: [PATCH 23/23] Match both compilers' error in a Rust JSG compile test Some compilers report the rejected `for<'s> FromJS<'s>` bound as an impl that is not general enough, others as an unsatisfied bound (E0277), so owned-fromjs-bound-rejects-local matches the `FromJS` both name. --- .../jsg-compile-tests/owned-fromjs-bound-rejects-local.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs b/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs index 5fe877b2946..0c9a771cb6f 100644 --- a/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs +++ b/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs @@ -3,12 +3,13 @@ // https://opensource.org/licenses/Apache-2.0 // `T: for<'s> FromJS<'s, ResultType = T>` (used where a value must outlive the scope) excludes -// handle types. +// handle types. Compilers word the error differently: some report that the `FromJS` impl is not +// general enough, others that the bound `for<'s> FromJS<'s>` is not satisfied (E0277). use jsg::FromJS; use jsg::v8; pub fn owned FromJS<'s, ResultType = T>>() {} pub fn t() { - owned::>(); //~ ERROR implementation of `FromJS` is not general enough + owned::>(); //~ ERROR FromJS }