Skip to content

Add jsg-cpp: interop between Rust JSG and C++ JSG resources - #8086

Merged
bartlomieju merged 9 commits into
mainfrom
biwanczuk/rust-jsg-cpp-interop
Oct 31, 2026
Merged

bartlomieju merged 9 commits into
mainfrom
biwanczuk/rust-jsg-cpp-interop

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Lets Rust JSG resources take, return and hold C++ JSG resources, and lets C++ JSG signatures
take, return and hold Rust ones. Porting a type that C++ APIs consume, such as
ExecutionContext, needs both directions. Until now the only option was an untyped
v8::Local plus a manual ffi_is check, which handles no subclasses and isn't traced by GC
across the boundary.

What's added

  • jsg_cpp::Ref<T: CppResource> holds a C++ resource from Rust: a resource field, a method
    parameter or a return value. It unwraps and wraps through the isolate's
    TypeHandler<jsg::Ref<T>>, so subclasses (JSG_INHERIT), wrapper reuse and type errors
    behave as in C++ JSG. It dereferences to the cxx opaque T, and tracing visits it as a
    jsg::Ref field.
  • callCpp() runs the per-method shims through which Rust calls C++ methods:
    • It turns a kj::Exception into its JS error as liftKj does, and passes JS exceptions
      through. Rust gets a pending jsg::Error.
    • It never throws while execution is terminating.
    • It fails without running any C++ while an exception is pending, so C++ JSG can't clear it.
  • RustRef<Tag> holds a Rust resource from C++. It keeps a traced V8Ref to the wrapper,
    so identity is preserved and cycles across the two heaps are collected.
    RustResourceWrapper, a TypeWrapperExtension, makes RustRef<Tag> usable in method
    signatures, jsg::Optional and kj::OneOf.
  • The per-type and per-method shims (cppRefTryUnwrap<T>, cppRefWrap<T>, ...) live with the
    C++ class they bind, as ordinary C++ API code.
  • jsg exposes Error::pending() and GcVisitor::as_ffi(), which jsg-cpp needs to report an
    exception that C++ left pending and to visit a C++ jsg::Ref while tracing.

Relaxing "Safety rules", rule 3

Rust JSG's C++ shims must not use C++ JSG. This adds one bounded exception, the interop tier:

  • What: only src/rust/jsg-cpp/jsg-cpp.h and ffi.c++ may use jsg::Lock,
    jsg::Ref/Optional/Object, jsg::V8Ref, JsExceptionThrown and
    jsg::throwInternalError. Everything else stays forbidden there too: JSG_TRY/tryCatch,
    createTunneledException, makeInternalError, jsg::check, liftKj, JSG_REQUIRE, the
    other jsg::throw* functions, JS value handles (jsg::Data, jsg::JsValue, ...) and
    jsg::Promise/Function/Sequence.
  • Containment: jsg-cpp depends on jsg, never the reverse. The shims that bind a
    particular C++ class live with that class and use C++ JSG unchecked. Porting an API therefore
    moves its C++ JSG use rather than removing it, as the README states.
  • Enforcement: cpp-jsg-allowlist-test accepts a <path> interop line, only for files under
    src/rust/jsg-cpp/, and skips counting only the tier's constructs for that file. It also
    counts jsg-cpp as a construct, so other shims can't reach C++ JSG through the interop
    header. cpp-jsg-allowlist-checker-test checks what the tier admits and rejects.

A crate that binds C++ through its own shims needs to report an
exception the C++ left pending, and to visit references Rust JSG
doesn't know, such as a C++ JSG jsg::Ref, during GC tracing.
jsg_cpp::Ref<T> lets Rust hold, receive and return C++ JSG resources:
it unwraps and wraps them through the isolate's TypeHandlers, so
subclasses and wrapper identity work as in C++, and it traces as a
jsg::Ref field does. call_cpp() runs a per-method shim in callCpp(),
which turns a C++ exception into a pending JavaScript one. RustRef<Tag>
and RustResourceWrapper let C++ JSG signatures take, return and hold a
Rust resource, held by its wrapper and traced, so cycles across the two
heaps are collected.

The crate's shims are the one place Rust JSG's C++ may use C++ JSG. The
allowlist test gets an interop tier for them, which permits only what
wrapping, unwrapping and exception conversion need, and counts jsg_cpp
as a construct so the other shims can't use it.
A Rust resource takes C++ resources as arguments (including a subclass),
returns one from a lazy property that depends on the current request,
and holds one; a C++ resource takes, returns, calls and holds the Rust
resource, and tells it from a C++ one in a kj::OneOf. Tests cover type
checks, wrapper identity, exceptions from C++ (kj::Exception and
JavaScript), and that a cycle across the two heaps is collected while a
held one survives GC.
@bartlomieju
bartlomieju force-pushed the biwanczuk/rust-jsg-cpp-interop branch from d00cd49 to d7eb075 Compare October 29, 2026 14:59

@guybedford guybedford left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great foundation for the incremental port, with clean reuse of the isolate's TypeHandlers and solid cross-heap GC coverage.

Non-blocking AI review suggestions:

  1. The interop tier admits more than the README describes. Exemptions apply per construct group, so jsg::Data also covers JsValue/JsRef/JsObject/JsString, and jsg::Ref also covers Promise/Function/Sequence. jsg::throw also matches jsg::throwTunneledException, which sidesteps the createTunneledException ban. Appending jsg::throwTunneledException(...), jsg::Promise<int>, jsg::JsValue and jsg::Function<void()> to jsg-cpp.h still passes cpp-jsg-allowlist-test. Either split out narrower constructs for the tier (jsg::Ref|Optional|Object, V8Ref, jsg::throwInternalError) or align the README with what's enforced.
  2. "Only src/rust/jsg-cpp files belong in the tier" isn't enforced. Any path can take an interop line. A prefix check in read_allowlist would make this a rule rather than a convention.
  3. The C++ JSG surface moves rather than shrinks. The per-type/per-method shims live with their C++ classes, outside CPP_SHIMS, so they use C++ JSG freely. That's reasonable, but it may be worth stating explicitly in the README so the real port's footprint is understood.
  4. A Ref<T> moved out of a traced field stays weak. Unlike C++ jsg::Ref's move constructor, moving the KjOwn<CppRef> doesn't restore strength, so a .take() without a clone is a silent use-after-GC hazard. It's documented (rule 2), but a debug assertion in Deref/as_cpp that the inner ref is strong or parented could catch misuse.
  5. Nit: the CppRef(void*, Ref<Object>) constructor is public for kj::heap, which allows building a mismatched pointer/ref pair. Making it private with a kj::heap friend would close that off.

@piscisaureus

piscisaureus commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

@bartlomieju heads-up on the interaction with #8009 (pointed out by @guybedford): #8009 has landed, so callCpp() needs to start with the pending-exception check that #8009 adds to every Rust JSG shim that enters V8:

if (::workerd::rust::jsg::exceptionPending(isolate)) return false;

exceptionPending() (in src/rust/jsg/ffi.h) is true while a termination or an exception is pending. Without the check, a Rust caller with an exception pending can run C++ JSG code that enters V8 with a context, e.g. cppRefWrap → TypeHandler::wrap → ObjectTemplate::NewInstance, which clears the pending exception so that JavaScript runs on. Returning false leaves the exception pending, which Rust already reports as jsg::Error::pending().

I've added it in 1b0e09f, on top of a merge of main into this branch (which brings in exceptionPending()). Feel free to rebase it away; the commit replays cleanly on top of main.

piscisaureus and others added 6 commits October 30, 2026 16:58
Rust JSG shims that enter V8 with a context check exceptionPending()
first. callCpp() runs C++ JSG code that can enter V8 that way, e.g.
cppRefWrap() through TypeHandler::wrap and ObjectTemplate::NewInstance,
which clears a pending exception so that JavaScript runs on. Returning
false leaves the exception pending, which Rust reports as
jsg::Error::pending().
With an exception pending, a call through callCpp() must fail
without running its C++, leaving the exception pending. The test
calls a C++ method that would otherwise succeed.
The tier's exemptions applied per construct group, so allowing
jsg::Ref also allowed jsg::Promise, jsg::Function and jsg::Sequence,
allowing jsg::Data allowed every JS value handle, and allowing
jsg::throw allowed throwTunneledException. The groups are now split,
and the tier gets only jsg::Ref/Optional/Object, jsg::V8Ref and
jsg::throwInternalError besides jsg::Lock and JsExceptionThrown.

The checker also rejects an interop line for a file outside
src/rust/jsg-cpp, and a new test checks what the tier admits and
rejects. The README now says that per-type and per-method shims,
which live with their C++ classes, use C++ JSG unchecked, so porting
an API moves that use rather than removing it.
The constructor taking a void* and a jsg::Ref<Object> was public for
kj::heap, so any code could build a CppRef whose pointer and
reference disagree. It now takes a kj::Badge<CppRef>, which only
clone() can make.
@bartlomieju

Copy link
Copy Markdown
Collaborator Author

@guybedford thanks for the review. Addressed in d6bb6ed and 657885a:

  1. Narrower interop tier. The constructs are now split so the tier gets only jsg::Ref/Optional/Object, jsg::V8Ref, jsg::throwInternalError, jsg::Lock and JsExceptionThrown. JsValue/JsRef/Data, Promise/Function/Sequence and the other jsg::throw* (including throwTunneledException) now fail. cpp-jsg-allowlist-checker-test.py covers each of the cases you appended.
  2. Tier enforced. The checker rejects an interop line for a file outside src/rust/jsg-cpp/.
  3. Surface moves rather than shrinks. The README now says so: per-type/per-method shims live with their C++ class, aren't checked by the allowlist, and use C++ JSG until that class goes away.
  4. Weak Ref<T> after a move. I looked into the debug assertion, but "strong or parented" can't tell the two cases apart. Ref<T> owns a KjOwn<CppRef>, so moving it out of a field moves the pointer, not the CppRef. The inner jsg::Ref keeps its parent and stays weak, which is exactly the state of a ref still in a traced field after Wrappable::visitRef has visited it. Any check on the CppRef alone would fire for every traced ref or for none. Rust has no hook on moves, which is why README rule 2 documents the same hazard for jsg::Rc. So I've kept Ref<T> consistent with that rule and its docs. A real fix (something like a take() that returns a strong clone) would need to cover jsg::Rc too, so I think it belongs with Rust JSG: jsg::Rc moved out of a traced field is neither strong nor traced #7976.
  5. CppRef constructor. The untyped constructor now takes a kj::Badge<CppRef>, so only clone() can call it, and kj::heap still works.

@bartlomieju
bartlomieju marked this pull request as ready for review October 31, 2026 12:52
@bartlomieju
bartlomieju requested review from a team as code owners October 31, 2026 12:52

@clanky-the-clanker clanky-the-clanker Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Approved with comments — No correctness, security or performance issues found. The only comment is that the AGENTS.md safety rules don't yet describe the new jsg-cpp interop exception.

Findings: 1 info

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


📖 AGENTS.md: 💬 1 info

The AGENTS.md safety rules don't mention the new interop exception.

Recommendation: 💬 Optional Nits

  • [INFO] src/rust/AGENTS.md safety rule 3 (and AGENTS.md:304) still say C++ JSG is never allowed in Rust JSG shims and that the existing exception must not be extended. Add the bounded jsg-cpp interop tier documented in src/rust/jsg/README.md. (src/rust/AGENTS.md:50)
View full review

src/rust/AGENTS.md:50 (safety rule 3) says the one reviewed exception is io_context_await_io_and_run() and tells contributors not to extend it. The root AGENTS.md:304 says to NEVER use C++ JSG in Rust JSG's C++ shims. This pull request adds a bounded src/rust/jsg-cpp interop tier, which src/rust/jsg/README.md documents and cpp-jsg-allowlist-test enforces, so both files now contradict the code and the README. A one-line pointer to the README's interop-tier section would keep future contributors and tools from treating jsg-cpp as a violation, or from adding C++ JSG to other shims by copying it.


✅ All other checks passed

🔒 Security: ✅ LGTM

No security issues found.


📋 Code Quality: ✅ LGTM

  • callCpp() (src/rust/jsg-cpp/jsg-cpp.h): it starts with the exceptionPending() check and never throws while execution is terminating. Its outer handlers call jsg::throwInternalError, which schedules the exception and returns instead of throwing JsExceptionThrown, so nothing escapes the noexcept function. Its structure matches liftKj.
  • cppRefWrap and Ref::to_js: both go through callCpp. When an exception is pending or execution is terminating, they return an empty MaybeLocal, which becomes Error::pending().
  • GcVisitor::as_ffi: it passes the right pointer type to the C++ visit shim.
  • Allowlist checker: comments are stripped before constructs are counted. The interop-tier validation rejects paths outside src/rust/jsg-cpp/, stale interop lines, and count lines for constructs the tier already allows, and cpp-jsg-allowlist-checker-test.py tests each case.
  • Tests: they cover subclass unwrap, type rejection, the lazy property, propagation of both kj::Exception and JS exceptions, the pending-exception guard, cross-heap cycle collection, and survival across a full GC.

⚡ Performance: ✅ LGTM

No performance issues found.


📝 Documentation: ✅ LGTM

No documentation issues found.


Reviewed commit: 657885af

@bartlomieju
bartlomieju merged commit 80f7da6 into main Oct 31, 2026
27 of 28 checks passed
@bartlomieju
bartlomieju deleted the biwanczuk/rust-jsg-cpp-interop branch October 31, 2026 15:39
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.

3 participants