Repository navigation
Add jsg-cpp: interop between Rust JSG and C++ JSG resources - #8086
Conversation
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.
d00cd49 to
d7eb075
Compare
guybedford
left a comment
There was a problem hiding this comment.
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:
- The interop tier admits more than the README describes. Exemptions apply per construct group, so
jsg::Dataalso coversJsValue/JsRef/JsObject/JsString, andjsg::Refalso coversPromise/Function/Sequence.jsg::throwalso matchesjsg::throwTunneledException, which sidesteps thecreateTunneledExceptionban. Appendingjsg::throwTunneledException(...),jsg::Promise<int>,jsg::JsValueandjsg::Function<void()>tojsg-cpp.hstill passescpp-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. - "Only
src/rust/jsg-cppfiles belong in the tier" isn't enforced. Any path can take aninteropline. A prefix check inread_allowlistwould make this a rule rather than a convention. - 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. - A
Ref<T>moved out of a traced field stays weak. Unlike C++jsg::Ref's move constructor, moving theKjOwn<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 inDeref/as_cppthat the inner ref is strong or parented could catch misuse. - Nit: the
CppRef(void*, Ref<Object>)constructor is public forkj::heap, which allows building a mismatched pointer/ref pair. Making it private with akj::heapfriend would close that off.
|
@bartlomieju heads-up on the interaction with #8009 (pointed out by @guybedford): #8009 has landed, so if (::workerd::rust::jsg::exceptionPending(isolate)) return false;
I've added it in 1b0e09f, on top of a merge of main into this branch (which brings in |
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.
|
@guybedford thanks for the review. Addressed in d6bb6ed and 657885a:
|
There was a problem hiding this comment.
💬 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 theexceptionPending()check and never throws while execution is terminating. Its outer handlers calljsg::throwInternalError, which schedules the exception and returns instead of throwingJsExceptionThrown, so nothing escapes thenoexceptfunction. Its structure matchesliftKj.cppRefWrapandRef::to_js: both go throughcallCpp. When an exception is pending or execution is terminating, they return an emptyMaybeLocal, which becomesError::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/, staleinteroplines, and count lines for constructs the tier already allows, andcpp-jsg-allowlist-checker-test.pytests each case. - Tests: they cover subclass unwrap, type rejection, the lazy property, propagation of both
kj::Exceptionand 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
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 untypedv8::Localplus a manualffi_ischeck, which handles no subclasses and isn't traced by GCacross the boundary.
What's added
jsg_cpp::Ref<T: CppResource>holds a C++ resource from Rust: a resource field, a methodparameter or a return value. It unwraps and wraps through the isolate's
TypeHandler<jsg::Ref<T>>, so subclasses (JSG_INHERIT), wrapper reuse and type errorsbehave as in C++ JSG. It dereferences to the cxx opaque
T, and tracing visits it as ajsg::Reffield.callCpp()runs the per-method shims through which Rust calls C++ methods:kj::Exceptioninto its JS error asliftKjdoes, and passes JS exceptionsthrough. Rust gets a pending
jsg::Error.RustRef<Tag>holds a Rust resource from C++. It keeps a tracedV8Refto the wrapper,so identity is preserved and cycles across the two heaps are collected.
RustResourceWrapper, aTypeWrapperExtension, makesRustRef<Tag>usable in methodsignatures,
jsg::Optionalandkj::OneOf.cppRefTryUnwrap<T>,cppRefWrap<T>, ...) live with theC++ class they bind, as ordinary C++ API code.
jsgexposesError::pending()andGcVisitor::as_ffi(), whichjsg-cppneeds to report anexception that C++ left pending and to visit a C++
jsg::Refwhile tracing.Relaxing "Safety rules", rule 3
Rust JSG's C++ shims must not use C++ JSG. This adds one bounded exception, the interop tier:
src/rust/jsg-cpp/jsg-cpp.handffi.c++may usejsg::Lock,jsg::Ref/Optional/Object,jsg::V8Ref,JsExceptionThrownandjsg::throwInternalError. Everything else stays forbidden there too:JSG_TRY/tryCatch,createTunneledException,makeInternalError,jsg::check,liftKj,JSG_REQUIRE, theother
jsg::throw*functions, JS value handles (jsg::Data,jsg::JsValue, ...) andjsg::Promise/Function/Sequence.jsg-cppdepends onjsg, never the reverse. The shims that bind aparticular 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.
cpp-jsg-allowlist-testaccepts a<path> interopline, only for files undersrc/rust/jsg-cpp/, and skips counting only the tier's constructs for that file. It alsocounts
jsg-cppas a construct, so other shims can't reach C++ JSG through the interopheader.
cpp-jsg-allowlist-checker-testchecks what the tier admits and rejects.