Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/rust/jsg-test/ffi.c++
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ void TestHarness::run_in_context(
});
}

void cancel_termination(Isolate* isolate) {
isolate->CancelTerminateExecution();
}

void request_gc(Isolate* isolate, GcType gc_type) {
switch (gc_type) {
case GcType::Full:
Expand Down
1 change: 1 addition & 0 deletions src/rust/jsg-test/ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ kj::Own<TestHarness> create_test_harness();
enum class GcType : uint8_t;

// Triggers garbage collection for testing purposes.
void cancel_termination(Isolate* isolate);
void request_gc(Isolate* isolate, GcType gc_type);

// Creates a V8 object with the C++ WORKERD_WRAPPABLE_TAG set in its internal fields.
Expand Down
10 changes: 10 additions & 0 deletions src/rust/jsg-test/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ mod ffi {
/// current HandleScope.
#[expect(clippy::allow_attributes)] // Only used in tests, but #[expect(dead_code)] fails during test builds
#[allow(dead_code)]
pub unsafe fn cancel_termination(isolate: *mut Isolate);
pub unsafe fn request_gc(isolate: *mut Isolate, gc_type: GcType);

/// Creates a V8 object with the C++ `WORKERD_WRAPPABLE_TAG` in its internal fields.
Expand Down Expand Up @@ -215,6 +216,15 @@ impl Harness {
}
}

/// Cancels a pending `terminate_execution()` so the harness can keep using
/// the isolate after a test deliberately terminates it. Debug V8 DCHECKs
/// (`!isolate_->has_exception()`) on API entry if the termination exception
/// is still scheduled when the test returns control to the harness.
pub fn cancel_termination(lock: &mut jsg::Lock) {
// SAFETY: isolate is valid and locked (guaranteed by Lock).
unsafe { ffi::cancel_termination(lock.isolate().as_ffi()) };
}

pub fn request_gc(lock: &mut jsg::Lock) {
// SAFETY: isolate is valid and locked (guaranteed by Lock).
unsafe { ffi::request_gc(lock.isolate().as_ffi(), ffi::GcType::Full) };
Expand Down
141 changes: 140 additions & 1 deletion src/rust/jsg-test/tests/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

//! Tests for `Local<Function>::call()`, `As<T>` trait, and `impl_local_cast!` conversions.
//! Tests for `Local<Function>::call()`, `jsg::Function`, `As<T>` trait, and
//! `impl_local_cast!` conversions.

use jsg::ExceptionType;
use jsg::FromJS;
use jsg::Function;
use jsg::Number;
use jsg::ToJS;
use jsg::v8;
Expand Down Expand Up @@ -154,6 +158,141 @@ fn call_with_object_receiver_via_into() {
});
}

/// A throwing callee surfaces as an `Err` preserving the JS error type and
/// message — it must not unwind across the FFI boundary.
#[test]
fn call_throw_returns_error() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx
.eval_raw("(() => { throw new TypeError('boom') })")
.unwrap();
let func = value.try_as::<v8::Function>().unwrap();
let err = func
.call::<Number, _>(lock, None::<v8::Local<v8::Value>>, &[])
.unwrap_err();
assert_eq!(err.name, ExceptionType::TypeError);
assert_eq!(err.message, "boom");
Ok(())
});
}

/// A non-Error throw (e.g. a string) still comes back as an `Err`.
#[test]
fn call_throw_non_error_value() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx.eval_raw("(() => { throw 'plain string' })").unwrap();
let func = value.try_as::<v8::Function>().unwrap();
let result = func.call::<Number, _>(lock, None::<v8::Local<v8::Value>>, &[]);
assert!(result.is_err());
Ok(())
});
}

/// `jsg::Function` converts from a JS function value and calls with typed args.
#[test]
fn typed_function_from_js_and_call() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx.eval_raw("((a, b) => a + b)").unwrap();
let func: Function<(Number, Number), Number> = Function::from_js(lock, value)?;
let result = func.call(lock, (Number::new(10.0), Number::new(32.0)))?;
assert!((result.value() - 42.0).abs() < f64::EPSILON);
Ok(())
});
}

/// `jsg::Function<(), ()>` — no arguments, result discarded.
#[test]
fn typed_function_unit_signature() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx
.eval_raw("(() => { globalThis.sideEffect = 7; })")
.unwrap();
let func: Function = Function::from_js(lock, value)?;
func.call(lock, ())?;
let observed: Number = ctx.eval(lock, "globalThis.sideEffect").unwrap();
assert!((observed.value() - 7.0).abs() < f64::EPSILON);
Ok(())
});
}

/// `jsg::Function::from_js` rejects non-function values with a `TypeError`.
#[test]
fn typed_function_rejects_non_function() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx.eval_raw("42").unwrap();
let err = Function::<(), ()>::from_js(lock, value).unwrap_err();
assert_eq!(err.name, ExceptionType::TypeError);
assert_eq!(err.message, "expected function, got number");
Ok(())
});
}

/// A throwing callee through `jsg::Function` preserves the JS error type.
#[test]
fn typed_function_throw_preserves_error_type() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx
.eval_raw("(() => { throw new RangeError('out of range') })")
.unwrap();
let func: Function = Function::from_js(lock, value)?;
let err = func.call(lock, ()).unwrap_err();
assert_eq!(err.name, ExceptionType::RangeError);
assert_eq!(err.message, "out of range");
Ok(())
});
}

/// `jsg::Function::call_with_receiver` passes `this`.
#[test]
fn typed_function_with_receiver() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let obj = ctx.eval_raw("({x: 100})").unwrap();
let value = ctx.eval_raw("(function() { return this.x; })").unwrap();
let func: Function<(), Number> = Function::from_js(lock, value)?;
let result = func.call_with_receiver(lock, Some(obj), ())?;
assert!((result.value() - 100.0).abs() < f64::EPSILON);
Ok(())
});
}

/// Isolate termination surfaces as an `Error` with `is_termination()` set —
/// distinguishable from a JS throw, and not process-fatal.
#[test]
fn call_after_terminate_returns_termination_error() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx.eval_raw("(() => 1)").unwrap();
let func: Function<(), Number> = Function::from_js(lock, value)?;
lock.terminate_execution();
let err = func.call(lock, ()).unwrap_err();
assert!(err.is_termination());
crate::Harness::cancel_termination(lock);
Ok(())
});
}

/// A clone remains callable after the original is dropped (independent
/// persistent handles).
#[test]
fn typed_function_clone_independent() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx.eval_raw("(() => 'alive')").unwrap();
let func: Function<(), String> = Function::from_js(lock, value)?;
let cloned = func.clone(lock);
drop(func);
assert_eq!(cloned.call(lock, ())?, "alive");
Ok(())
});
}

/// `impl_local_cast!` conversions: `Local<Object>` → `Local<Value>` via `Into`.
#[test]
fn local_object_into_value() {
Expand Down
131 changes: 131 additions & 0 deletions src/rust/jsg-test/tests/function_resource.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// 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

//! Tests for `jsg::Function` held in resources: the `#[jsg_method]` parameter
//! pipeline, GC tracing of stored callbacks, and cycle collection.

use std::cell::Cell;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;

use jsg::Number;
use jsg::ToJS;
use jsg::v8;
use jsg_macros::jsg_method;
use jsg_macros::jsg_resource;

static HOLDER_DROPS: AtomicUsize = AtomicUsize::new(0);

#[jsg_resource]
struct CallbackHolder {
pub callback: Cell<Option<jsg::Function<(), Number>>>,
}

impl Drop for CallbackHolder {
fn drop(&mut self) {
HOLDER_DROPS.fetch_add(1, Ordering::SeqCst);
}
}

#[jsg_resource]
impl CallbackHolder {
#[jsg_method]
fn set_callback(&self, callback: jsg::Function<(), Number>) {
self.callback.set(Some(callback));
}

#[jsg_method]
fn invoke(&self, lock: &mut jsg::Lock) -> Result<Number, jsg::Error> {
let callback = self
.callback
.take()
.ok_or_else(|| jsg::Error::new_error("no callback set"))?;
let result = callback.call(lock, ());
self.callback.set(Some(callback));
result
}
}

/// `Function` as a `#[jsg_method]` parameter: JS registers a callback through
/// the macro pipeline and Rust invokes it.
#[test]
fn function_as_method_parameter() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let holder = jsg::Rc::new(CallbackHolder {
callback: Cell::new(None),
});
let wrapped = holder.to_js(lock);
ctx.set_global("holder", wrapped);

ctx.eval_raw("holder.setCallback(() => 42)").unwrap();
let result: Number = ctx.eval(lock, "holder.invoke()").unwrap();
assert!((result.value() - 42.0).abs() < f64::EPSILON);
Ok(())
});
}

/// A stored callback survives a full GC while the resource is only reachable
/// from JS (traced mode): if `Traced` tracing of the inner `Global` were
/// broken, the traced handle would dangle and this would crash or fail.
#[test]
fn stored_callback_survives_gc_in_traced_mode() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let holder = jsg::Rc::new(CallbackHolder {
callback: Cell::new(None),
});
let wrapped = holder.clone().to_js(lock);
ctx.set_global("holder", wrapped);
ctx.eval_raw("holder.setCallback(() => 7)").unwrap();

// Drop the strong Rust ref so the resource (and its stored Global)
// downgrades to traced mode, then force a full GC.
std::mem::drop(holder);
crate::Harness::request_gc(lock);

let result: Number = ctx.eval(lock, "holder.invoke()").unwrap();
assert!((result.value() - 7.0).abs() < f64::EPSILON);
Ok(())
});
}

/// A cycle between a resource and its stored callback (the JS closure captures
/// the resource's own wrapper) is collected once nothing else references it.
#[test]
fn callback_cycle_collected() {
HOLDER_DROPS.store(0, Ordering::SeqCst);

let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let holder = jsg::Rc::new(CallbackHolder {
callback: Cell::new(None),
});
let wrapped = holder.clone().to_js(lock);

// Build the cycle wrapper -> Function -> closure -> wrapper without
// leaving any global variable referencing the holder.
let setter = ctx
.eval_raw("(h => { h.setCallback(() => { h; return 1; }); })")
.unwrap();
setter.try_as::<v8::Function>().unwrap().call::<(), _>(
lock,
None::<v8::Local<'_, v8::Value>>,
&[wrapped],
)?;

std::mem::drop(holder);
crate::Harness::request_gc(lock);
// Still alive: the Local from to_js pins the wrapper for this scope.
assert_eq!(HOLDER_DROPS.load(Ordering::SeqCst), 0);
Ok(())
});

// New context: the old scope's Locals are gone; only the cycle remains.
harness.run_in_context(|lock, _ctx| {
crate::Harness::request_gc(lock);
assert_eq!(HOLDER_DROPS.load(Ordering::SeqCst), 1);
Ok(())
});
}
19 changes: 19 additions & 0 deletions src/rust/jsg-test/tests/jsg_struct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,22 @@ fn type_of_returns_correct_js_types() {
Ok(())
});
}

#[jsg_struct]
struct HandlerOptions {
pub handler: jsg::Function<(), jsg::Number>,
}

/// A `jsg::Function` field converts through `#[jsg_struct]` and remains
/// callable from the unwrapped struct.
#[test]
fn struct_with_function_field() {
let harness = crate::Harness::new();
harness.run_in_context(|lock, ctx| {
let value = ctx.eval_raw("({handler: () => 7})").unwrap();
let options = <HandlerOptions as jsg::FromJS>::from_js(lock, value)?;
let result = options.handler.call(lock, ())?;
assert!((result.value() - 7.0).abs() < f64::EPSILON);
Ok(())
});
}
1 change: 1 addition & 0 deletions src/rust/jsg-test/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod coercion_safety;
mod collections_gc;
mod eval;
mod function;
mod function_resource;
mod gc;
mod jsg_oneof;
mod jsg_struct;
Expand Down
18 changes: 18 additions & 0 deletions src/rust/jsg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,24 @@ impl EventEmitter {

Without tracing, storing a `Global` back to the resource's own wrapper creates an unbreakable reference cycle that leaks until the worker is torn down. With `visit_global` tracing (generated automatically by `#[jsg_resource]`), the cycle is collected by the next full GC after all strong Rust `Rc`s are dropped.

### `Function<Args, R>`

A persistent, GC-traced handle to a JavaScript function with a typed Rust call signature — the counterpart of unwrapping a C++ `jsg::Function<Ret(Args...)>`. `Args` is a tuple of `ToJS` argument types; `R` is the `FromJS` return type (`()` discards the result). It is `FromJS`/`ToJS`/`Traced`, so it can be taken directly as a `#[jsg_method]` parameter and stored in a resource field:

```rust
#[jsg_method]
fn on_event(&self, callback: jsg::Function<(String,), ()>) {
self.callback.set(Some(callback));
}

// later, under a lock:
if let Some(cb) = self.callback.take() {
cb.call(lock, ("ready".to_owned(),))?;
}
```

A JS exception thrown by the callee is returned as a `jsg::Error` preserving the JS error type and message. Isolate termination during the call is returned as a `jsg::Error` with `is_termination()` set; stop calling into JS when you see it — throwing it via `Lock::throw_exception()` re-arms termination rather than scheduling a catchable JS exception. Use `call_with_receiver` to pass an explicit `this`.

## Union Types

To accept JavaScript values that can be one of several types, define an enum with `#[jsg_oneof]`:
Expand Down
Loading
Loading