Conversation
0f0adb0 to
fa9ad80
Compare
This comment was marked as off-topic.
This comment was marked as off-topic.
08b1698 to
1644d88
Compare
|
The generated output of |
72acd75 to
88c43a0
Compare
41fcd6f to
7dcb51c
Compare
d44716f to
a105fa2
Compare
|
I'm taking one step back and prepare an internal document to make the best decision. |
9e8fa59 to
67b803e
Compare
| size_t result; | ||
| auto ptr_void = reinterpret_cast<void*>(&result); | ||
| new (ptr_void) v8::TracedReference<T>(kj::mv(value)); | ||
| return TracedReference{result}; |
There was a problem hiding this comment.
Note that v8::TracedReference has .... nuances.... that make it critical to handle correctly. I know that @kentonv found a lot of the edges when he was working up the current handling in workerd/jsg. I strongly recommend that you double check everything here with either him, @erikcorry or @dcarney-cf as some of the edges can be pretty rough.
guybedford
left a comment
There was a problem hiding this comment.
Great to see the tracing approach in action! Left some comments with respect to the approach further, let me know if you want to discuss.
b881782 to
df977b1
Compare
81cbaef to
cc5611d
Compare
cc5611d to
ded08e2
Compare
ded08e2 to
e6a4cf1
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5638 +/- ##
==========================================
- Coverage 70.73% 70.62% -0.12%
==========================================
Files 409 411 +2
Lines 109253 110174 +921
Branches 18007 18010 +3
==========================================
+ Hits 77276 77806 +530
- Misses 21171 21555 +384
- Partials 10806 10813 +7 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
jasnell
left a comment
There was a problem hiding this comment.
AI-generated review (claude-opus-4-6 via opencode). These comments are automated suggestions and may not be perfect — please use your judgment.
Overall this is an impressive piece of work that brings cppgc integration to the Rust JSG layer. The architecture is well thought out and the test coverage is good. I have several findings ranging from a critical correctness issue to minor suggestions.
|
|
||
| if let Some(traced) = instance.traced_reference() { | ||
| let local = traced.get(lock); | ||
| if local.has_value() { |
There was a problem hiding this comment.
[CRITICAL] JS wrappers don't keep cppgc resources alive
wrap_resource() calls v8::Object::SetAlignedPointerInInternalField() to stash the Instance<R> pointer in the JS wrapper, but there's no v8::Object::Wrap(resource) call to connect the JS object to the cppgc RustResource.
In the C++ JSG layer, Wrappable::jsgGetMembraneWrapperRef() stores the wrapper as a TracedReference and calls wrapper->Wrap(CppgcShim*) to establish a bidirectional link — cppgc keeps the shim alive while JS holds the wrapper.
Without this link, if all Rust Refs transition to Traced mode (dropping their Persistent handles) while JavaScript still holds the wrapper object, the RustResource has no cppgc root keeping it alive. V8's GC can collect it, but the JS wrapper's internal field still points at the freed memory → use-after-free when JS later calls a method on that wrapper.
The fix should mirror what C++ does: call v8::Object::Wrap(rust_resource_ptr) after SetAlignedPointerInInternalField, so that the JS wrapper keeps the cppgc object alive through V8's wrapper tracing infrastructure (HeapTracer::TracePrologue).
|
|
||
| /// Reconstructs a mutable reference from the stored fat pointer. | ||
| /// | ||
| /// # Safety |
There was a problem hiding this comment.
[HIGH] TraitObjectPtr relies on unstable fat pointer layout
The transmute between *mut dyn GarbageCollected and [NonNull<()>; 2] assumes Rust's fat pointer layout is [data_ptr, vtable_ptr]. While this is the de facto layout used by rustc, it is not guaranteed by the Rust reference. The layout of trait object pointers is explicitly unspecified.
This is a known pattern in the Rust ecosystem and unlikely to break in practice, but it's worth documenting prominently. Consider:
- Adding a compile-time assertion that
size_of::<*mut dyn GarbageCollected>() == 2 * size_of::<usize>() - Adding a runtime test that verifies round-tripping through
TraitObjectPtrpreserves identity - Commenting that this relies on the de facto (but unstable) fat pointer ABI
Alternatively, once ptr_metadata stabilizes (RFC 2580), this could use std::ptr::metadata() instead of transmute.
| let data_ptr = unsafe { ffi::cppgc_rust_resource_data(resource) }; | ||
| unsafe { &mut *data_ptr.cast::<TraitObjectPtr>() } | ||
| } | ||
|
|
There was a problem hiding this comment.
[HIGH] get_trait_object_ptr returns &'static — unsound lifetime
unsafe fn get_trait_object_ptr(resource: *const ffi::RustResource) -> &'static TraitObjectPtr {This returns a 'static reference, but the TraitObjectPtr is stored inside a cppgc-managed RustResource that can be collected. The returned reference outlives the resource's actual lifetime.
While callers currently use it briefly and don't store it, the 'static lifetime is a soundness hole — any caller could legally store the reference and use it after GC collects the resource.
Suggestion: Use a generic lifetime parameter instead:
unsafe fn get_trait_object_ptr<'a>(resource: *const ffi::RustResource) -> &'a TraitObjectPtr {Same for get_trait_object_ptr_mut.
| #[expect(clippy::needless_pass_by_value)] | ||
| pub fn wrap<'a, R: Resource + 'static>( | ||
| lock: &mut Lock, | ||
| resource: Ref<R>, |
There was a problem hiding this comment.
[HIGH] Unsafe borrow bypass creates aliased &mut Lock
let constructor = unsafe {
let lock_ptr = lock as *mut Lock;
let resources = (*lock_ptr).realm().get_resources::<R>();
resources.get_constructor(&mut *lock_ptr)
};This creates two &mut Lock references simultaneously (one from (*lock_ptr).realm() and one from &mut *lock_ptr). Under Rust's aliasing rules this is undefined behavior, even if the mutations don't actually overlap in practice.
Consider restructuring to avoid the overlap. For instance, split the operation into two steps where the first borrow is released before the second begins, or refactor the API so get_resources() returns data without needing &mut Lock.
| ) | ||
| }; | ||
|
|
||
| assert!(!pointer.is_null(), "cppgc allocation failed"); |
There was a problem hiding this comment.
[MEDIUM] cppgc::Member stored off-heap in Box — write barriers won't fire
MemberInner wraps cppgc::Member in a Box<UnsafeCell<[usize; 1]>>, which means the cppgc::Member lives on the regular heap rather than inside the cppgc-managed object.
Write barriers for cppgc::Member are designed for on-heap → on-heap references. When a Member is stored off-heap (in a Box), the write barrier that fires on assignment may not correctly inform the GC about the reference, which can lead to premature collection in incremental/concurrent marking scenarios.
This is currently safe because workerd configures cppgc::Heap::MarkingType::kAtomic and SweepingType::kAtomic (see setup.c++), which means stop-the-world GC with no incremental marking. But this is a fragile invariant — if the marking mode ever changes, this will silently break.
Consider:
- Documenting this dependency prominently
- Adding an assertion that verifies the heap uses atomic marking
- Long-term: storing the
Memberinline in the cppgc object (inAdditionalBytes) rather than in a separateBox
| pub fn create_resource_constructor<R: Resource>( | ||
| lock: &mut Lock, | ||
| ) -> v8::Global<v8::FunctionTemplate> { | ||
| // SAFETY: Lock guarantees the isolate is valid and locked |
There was a problem hiding this comment.
[MEDIUM] Realm::drop() no longer performs deterministic cleanup of leaked resources
The previous implementation (on main) had Realm::drop() iterate through all registered resources and call their weak callbacks to clean up leaked Rcs. The new implementation just asserts the lock is held:
impl Drop for Realm {
fn drop(&mut self) {
debug_assert!(unsafe { v8::ffi::isolate_is_locked(self.isolate.as_ptr()) });
}
}With cppgc, the GC handles cleanup — but there's a question of deterministic destruction order. In C++, maybeDeferDestruction() handles cross-thread cleanup and destruction ordering. Does the Rust side need any equivalent? What happens if a RustResource destructor accesses another RustResource that was already collected in the same GC cycle?
This may be fine if Rust resources don't have cross-resource destructor dependencies, but it should be documented.
| auto* heap = isolate->GetCppHeap(); | ||
| KJ_ASSERT(heap != nullptr, "CppHeap not available on isolate"); | ||
| KJ_ASSERT(alignment <= 16, "Alignment {} exceeds maximum of 16", alignment); | ||
|
|
There was a problem hiding this comment.
[MEDIUM] cppgc_make_garbage_collected — alignment handling could be clearer
The alignment branch at 8 bytes is correct but the comment could explain why we need a separate RustResourceAlign16 class. The reason is that cppgc::MakeGarbageCollected uses alignof(T) to determine the allocation alignment, so we need a C++ type with alignas(16) to get 16-byte aligned allocations.
Also, the KJ_ASSERT(alignment <= 16) will crash the process if a Rust type has >16-byte alignment. Consider returning an error or using KJ_REQUIRE with a user-facing message instead, since this could be triggered by user code defining a resource with exotic alignment.
| /// | ||
| /// This mirrors the behavior of `Wrappable::visitRef` in C++. | ||
| pub fn visit_ref<R: crate::Resource>(&mut self, r: &crate::Ref<R>) { | ||
| let instance = r.visit(self); |
There was a problem hiding this comment.
[MEDIUM] GcVisitor::visit_ref — recursive tracing without wrapper could stack overflow
When a resource has no JS wrapper, visit_ref traces transitively through its children:
} else {
let mut child_visitor = self.with_parent(instance);
instance.resource.trace(&mut child_visitor);
}If there's a cycle of resources none of which have wrappers, this could recurse indefinitely (or until stack overflow). The C++ side handles this via CppgcShim::Trace which is called by the GC's own worklist-based traversal — it doesn't recurse through visitRef in the same way.
Consider whether cycles without wrappers are possible, and if so, whether an iterative worklist approach is needed.
|
|
||
| // Wrappers | ||
| Local wrap_resource(Isolate* isolate, size_t resource, const Global& tmpl, size_t drop_callback) { | ||
| Local wrap_resource(Isolate* isolate, size_t resource, const Global& tmpl) { |
There was a problem hiding this comment.
[LOW] wrap_resource — signature changed but internal implementation still uses SetAlignedPointerInInternalField
The drop_callback parameter was removed (good — cppgc handles destruction now), but the function still sets the raw pointer via SetAlignedPointerInInternalField. As noted in the critical comment on resource.rs, this needs a corresponding v8::Object::Wrap() call to connect the JS wrapper to the cppgc object for proper liveness tracking.
| use jsg::v8::TracedReference; | ||
| use jsg_macros::jsg_method; | ||
| use jsg_macros::jsg_resource; | ||
|
|
There was a problem hiding this comment.
[LOW] Static AtomicUsize counters could cause flaky tests under parallel execution
static INSTANCE_COUNT: AtomicUsize = AtomicUsize::new(0);
static DROP_COUNT: AtomicUsize = AtomicUsize::new(0);If multiple tests run in the same process concurrently (which Rust's test harness does by default), these global counters could be incremented/checked by different tests simultaneously, leading to flaky assertions.
I see the tests reset counts at the start, but there's still a window for races. Consider either:
- Using
#[serial_test::serial]to serialize tests that use shared state - Using thread-local counters instead of globals
- Ensuring
just testruns these with--test-threads=1
This PR adds garbage collection support to Rust-owned JSG resources, enabling proper cleanup when V8 collects JavaScript wrapper objects.
Implements Oilpan integration to our Rust/JSG integration. Compared to C++ GC implementation, Rust use cppgc heap for allocation of resources rather than kj::heap. We eventually need to do the same thing in our C++ implementation as well.
Used AI to help me write comments.