Skip to content

Comments

add gc support to Rust owned objects#5638

Draft
anonrig wants to merge 1 commit intomainfrom
yagiz/rust-gc
Draft

add gc support to Rust owned objects#5638
anonrig wants to merge 1 commit intomainfrom
yagiz/rust-gc

Conversation

@anonrig
Copy link
Member

@anonrig anonrig commented Dec 3, 2025

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.

@codspeed-hq

This comment was marked as off-topic.

Copy link
Contributor

@guybedford guybedford left a comment

Choose a reason for hiding this comment

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

Lovely work!

@anonrig anonrig force-pushed the yagiz/rust-gc branch 3 times, most recently from 08b1698 to 1644d88 Compare December 12, 2025 17:14
@github-actions
Copy link

github-actions bot commented Dec 12, 2025

The generated output of @cloudflare/workers-types matches the snapshot in types/generated-snapshot 🎉

@anonrig anonrig force-pushed the yagiz/rust-gc branch 3 times, most recently from 72acd75 to 88c43a0 Compare December 12, 2025 22:29
@anonrig anonrig requested a review from guybedford December 12, 2025 22:59
@anonrig anonrig force-pushed the yagiz/rust-gc branch 2 times, most recently from d44716f to a105fa2 Compare December 15, 2025 22:03
@anonrig
Copy link
Member Author

anonrig commented Dec 15, 2025

I'm taking one step back and prepare an internal document to make the best decision.

size_t result;
auto ptr_void = reinterpret_cast<void*>(&result);
new (ptr_void) v8::TracedReference<T>(kj::mv(value));
return TracedReference{result};
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

@anonrig anonrig marked this pull request as draft December 17, 2025 01:08
Copy link
Contributor

@guybedford guybedford left a comment

Choose a reason for hiding this comment

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

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.

@jasnell jasnell added the rust Pull requests that update rust code label Dec 17, 2025
@anonrig anonrig force-pushed the yagiz/rust-gc branch 2 times, most recently from 81cbaef to cc5611d Compare January 8, 2026 21:52
@codecov-commenter
Copy link

Codecov Report

❌ Patch coverage is 67.15385% with 427 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.62%. Comparing base (02c8669) to head (e6a4cf1).

Files with missing lines Patch % Lines
src/rust/jsg-macros/lib.rs 0.00% 156 Missing ⚠️
src/rust/jsg/v8.rs 69.10% 131 Missing ⚠️
src/rust/jsg/ffi.c++ 26.22% 87 Missing and 3 partials ⚠️
src/rust/jsg/resource.rs 79.53% 44 Missing ⚠️
src/rust/jsg/lib.rs 78.57% 6 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Collaborator

@jasnell jasnell left a comment

Choose a reason for hiding this comment

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

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() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

[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
Copy link
Collaborator

Choose a reason for hiding this comment

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

[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:

  1. Adding a compile-time assertion that size_of::<*mut dyn GarbageCollected>() == 2 * size_of::<usize>()
  2. Adding a runtime test that verifies round-tripping through TraitObjectPtr preserves identity
  3. 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>() }
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

[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>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

[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");
Copy link
Collaborator

Choose a reason for hiding this comment

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

[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:

  1. Documenting this dependency prominently
  2. Adding an assertion that verifies the heap uses atomic marking
  3. Long-term: storing the Member inline in the cppgc object (in AdditionalBytes) rather than in a separate Box

pub fn create_resource_constructor<R: Resource>(
lock: &mut Lock,
) -> v8::Global<v8::FunctionTemplate> {
// SAFETY: Lock guarantees the isolate is valid and locked
Copy link
Collaborator

Choose a reason for hiding this comment

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

[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);

Copy link
Collaborator

Choose a reason for hiding this comment

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

[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);
Copy link
Collaborator

Choose a reason for hiding this comment

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

[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) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

[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;

Copy link
Collaborator

Choose a reason for hiding this comment

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

[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:

  1. Using #[serial_test::serial] to serialize tests that use shared state
  2. Using thread-local counters instead of globals
  3. Ensuring just test runs these with --test-threads=1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants