Borrow Rust JSG resources shared in method callbacks by piscisaureus · Pull Request #7830 · cloudflare/workerd · GitHub
Skip to content

Borrow Rust JSG resources shared in method callbacks - #7830

Draft
piscisaureus wants to merge 7 commits into
piscisaureus/scopesfrom
piscisaureus/shared-self
Draft

piscisaureus wants to merge 7 commits into
piscisaureus/scopesfrom
piscisaureus/shared-self

Conversation

@piscisaureus

Copy link
Copy Markdown
Contributor

Stacked on #7826; only the last commit is new.

The generated callback of a #[jsg_method] made a &mut Self from
the wrapper's resource pointer for every instance method, &self
methods included. Two things can hold another reference to the same
resource while that &mut is live:

  • A method that calls into JavaScript can be re-entered on the same
    resource, which makes a second &mut Self.
  • GC can run during any allocation in the method and trace the
    resource through Traced::trace(&self).

Callbacks now borrow the resource as &Self, and a receiver other
than &self is a compile error that points to Cell or RefCell
fields, which the README already recommends for state set after
construction. No resource method in the tree took &mut self.

A runtime test re-enters a method three levels deep, and a compile
test checks the error for &mut self and self.

@codecov-commenter

codecov-commenter commented Oct 19, 2026 •

Copy link
Copy Markdown

`wd_rust_compile_test` type-checks one snippet per .rs file against a
set of crates and compares the errors rustc reports with the
snippet's `//~ ERROR` annotations, in the notation of rustc's UI
tests. A snippet without annotations must compile. The check runs as a
cached build action; a mismatch fails the build with rustc's output.

* `build/rust/compile_test_checker.rs` runs rustc and matches errors
  by code, message substring and line
* `build/rust/compile-tests` holds the checker's own tests, including
  snippets that must be reported as mismatches
A `v8::Local` is a pointer to a slot in its `HandleScope` and owns
nothing, but the Rust `Local` and `MaybeLocal` had `Drop` and `Clone`
impls that went through FFI (`local_drop`, `local_clone`) to do
nothing and to copy the pointer. They are now `Copy`, and the FFI
functions are gone.
A `Lock` could be made from a raw isolate or callback pointer at any
time, and APIs such as `ToJS::to_js`, `new_object` and
`Global::as_local` returned a `Local` of any lifetime the caller
picked, `'static` included. Safe code could keep a handle after its
`HandleScope` closed, or hold a slice into an `ArrayBuffer` across a
call into JavaScript that detaches it.

Locks now work as in rusty_v8:

* `Lock<'s>` is branded with its scope and only handed out as
  `&'s mut Lock<'s>`, borrowed from the pinned storage of a
  `CallbackScope`. It has no public constructor. Every handle is a
  `Local<'s, T>`, so it cannot outlive the callback that created it.
* `jsg::function_callback(|lock, args| ...)` turns a closure that
  captures nothing into a V8 function callback. The callback recreates
  the closure from its type (`UnitType`), opens the scope and catches
  panics, so the callbacks `jsg-macros` generates contain no `unsafe`
  for entering V8.
* `jsg::callback_scope!(unsafe let lock, ptr)` is the entry point for
  other Rust code that C++ calls with a scope open. It replaces
  `Lock::from_args` and friends.
* `ToJS<'s>`, `FromJS<'s>` and the generated callbacks carry the
  scope lifetime. Slices into V8 memory borrow the lock, so no
  JavaScript can run while they are alive.
* The test harness hands its callbacks a branded lock, and its
  `eval`/`set_global` take it.

`src/rust/jsg-compile-tests` checks the lifetime rules with snippets
that must not compile.
Lifetimes tie a handle to its scope, not to that scope's isolate, so
a handle of one isolate could still be used with the lock of another
whose scope was open at the same time. So:

* All scopes open on a thread at once must belong to one isolate;
  opening a `CallbackScope` of another isolate panics. A handle
  cannot outlive its scope, so every live handle belongs to the
  isolate of any lock it can be used with. `function_callback` opens
  its scope inside `catch_panic`, so a JS callback entered that way
  throws an internal error instead of unwinding into V8. The `unsafe`
  `jsg::allow_scopes_of_another_isolate()` lifts the rule.
* `v8::Global` records the identity of its isolate, a counter kept in
  the `Realm` that is never reused, and panics when opened or cloned
  with another isolate's lock.
* Wrapping a resource whose JS wrapper lives in another isolate
  panics; `wrap_resource` checks the isolate the C++ `Wrappable`
  records for its wrapper. The `Rc` is leaked in that case, since
  releasing it needs the other isolate's lock.
`jsg::modules::add_builtin` took a `fn(*mut Isolate) -> ffi::Local`,
so every module had to open its own scope with the `unsafe`
`callback_scope!` and convert its exports to a raw handle. It now
takes a closure that captures nothing, of type
`for<'s> Fn(&mut Lock<'s>) -> Local<'s, Value>`, and registers a
trampoline monomorphized for it, which opens the scope and recreates
the closure from its type, as `function_callback` does.
The builtin module trampoline and the node-exceptions FFI functions
opened their scope with `callback_scope!` and let a panic, including
the one for a scope of another isolate, unwind into C++, which aborts.

* `jsg::catch_panic` is public and returns the closure's result, or
  `None` after throwing an internal error and terminating execution.
* Both entry points run through it and return an empty `MaybeLocal`
  on panic. Their C++ callers rethrow it as `jsg::JsExceptionThrown`.
The generated callback of a `#[jsg_method]` made a `&mut Self` from
the wrapper's resource pointer for every instance method, `&self` ones
included. A method that calls into JavaScript can be re-entered on the
same resource, and GC can trace the resource through `&self` during
any allocation, so that `&mut` could alias.

* Callbacks borrow the resource as `&Self`.
* A receiver other than `&self` is a compile error that points to
  `Cell` or `RefCell` fields. No resource method used one.
@piscisaureus
piscisaureus force-pushed the piscisaureus/shared-self branch from 6deccb9 to 8624b47 Compare October 19, 2026 21:14
@piscisaureus
piscisaureus force-pushed the piscisaureus/scopes branch 2 times, most recently from 628f200 to f16bc9b Compare October 19, 2026 22:21
@piscisaureus
piscisaureus force-pushed the piscisaureus/scopes branch 4 times, most recently from 5a1e7cc to b012fc5 Compare October 20, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants