Skip to content

Rust JSG: heap snapshot memory info - #8145

Draft
bartlomieju wants to merge 3 commits into
mainfrom
biwanczuk/rust-jsg-memory-info
Draft

bartlomieju wants to merge 3 commits into
mainfrom
biwanczuk/rust-jsg-memory-info

Conversation

@bartlomieju

Copy link
Copy Markdown
Collaborator

A Rust JSG resource shows in a V8 heap snapshot only as its
Wrappable: a node named after the type, the size of the C++
Wrappable, and none of what the resource holds. This ports the C++
heap snapshot support (jsg::MemoryTracker, visitForMemoryInfo,
trackField, JSG_MEMORY_INFO) to Rust JSG.

Usage

Every resource's node is now sized as the Rust struct plus its
Wrappable. To report fields too, a resource opts in, as a C++
resource does by defining visitForMemoryInfo:

#[jsg_resource(memory_info)]
pub struct EventRouter {
    handlers: HashMap<String, jsg::Rc<Handler>>,
    on_error: RefCell<Option<v8::TracedGlobal<v8::Value>>>,
    cache: Cache,
}

impl jsg::VisitForMemoryInfo for EventRouter {
    fn visit_for_memory_info(&self, t: &mut jsg::MemoryTracker<'_>) {
        t.track_field(c"handlers", &self.handlers);
        t.track_field(c"on_error", &self.on_error);
        t.track_field(c"cache", &self.cache);
    }
}

// A helper type that is a node of its own, like a C++ type with
// JSG_MEMORY_INFO.
impl jsg::MemoryRetainer for Cache {
    fn memory_name(&self) -> &'static CStr {
        c"Cache"
    }

    fn memory_info(&self, t: &mut jsg::MemoryTracker<'_>) {
        t.track_field(c"entries", &self.entries);
    }
}
jsg::jsg_memory_retainer!(Cache);

C++ counterparts:

  • visitForMemoryInfo: #[jsg_resource(memory_info)] +
    jsg::VisitForMemoryInfo
  • trackField overloads: jsg::MemoryInfo, via
    tracker.track_field(c"name", &value)
  • JSG_MEMORY_INFO: jsg::MemoryRetainer +
    jsg::jsg_memory_retainer!
  • jsg::Ref<T> field: jsg::Rc<R>, jsg_cpp::Ref<T>
  • jsg::V8Ref<T> field: v8::Global<T>, v8::TracedGlobal<T>

Design notes

  • Opt-in per resource, matching C++. A macro flag rather than
    autoref detection, so a resource with the flag but without the impl
    fails to compile.
  • Names are &'static CStr (c"..." literals): V8 reads node and
    edge names after the BuildEmbedderGraph callback returns. A compile
    test enforces it.
  • No panics. Reporting runs in a callback from C++, where a panic
    aborts the process.
  • Sizes: a resource or retainer counts size_of_val(self); strings
    and collections count what they allocate. A collection of retainers
    counts only its spare capacity, leaving the elements' bytes to their
    own nodes. Collections of plain data are a single node.
  • Deduplication: new MemoryTracker::pushRetainer keys nodes by
    address and type, so a retainer reached twice is one node, while a
    retainer and its first field (same address) are two. Zero-sized
    retainers always get a new node.
  • Cross-isolate handles are skipped; debug builds assert.
  • C++ <-> Rust: jsg_cpp::Ref<T> reports the C++ resource's node;
    C++ RustRef<T>::trackField adds an edge to the Rust resource's
    wrapper, which V8 links to its node.

API changes

  • jsg::Type::memory_self_size() is removed (unused, including in the
    internal repo).
  • jsg::GarbageCollected gains memory_self_size() and
    memory_info(), both with defaults.
  • C++ jsg::MemoryTracker gains pushRetainer, pushContainer and
    popNode.

MemoryTracker builds a heap snapshot's embedder graph from C++ types:
trackField() and JSG_MEMORY_INFO find a type's name, size and fields
at compile time. Rust JSG resources have none of that, so they need a
way to add nodes and edges at run time.

pushRetainer() adds a node for an object with fields of its own and
makes it the node its fields are reported to, until popNode().
Retainers are deduplicated by address and type together, so an
object reached twice is one node, while a retainer and its first
field, which share an address, are two. A null address always gets a
new node, for objects with no address of their own. pushContainer()
does the same for a container's elements, without deduplication.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 428 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.16%. Comparing base (6472e6e) to head (e12890a).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
src/rust/jsg/memory.rs 0.00% 280 Missing ⚠️
src/rust/jsg/v8.rs 0.00% 72 Missing ⚠️
src/rust/jsg/ffi.c++ 0.00% 40 Missing ⚠️
src/workerd/jsg/memory.c++ 0.00% 21 Missing ⚠️
src/rust/jsg/lib.rs 0.00% 4 Missing ⚠️
src/rust/jsg/resource.rs 0.00% 4 Missing ⚠️
src/workerd/jsg/memory.h 0.00% 4 Missing ⚠️
src/rust/jsg/dict.rs 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8145      +/-   ##
==========================================
- Coverage   68.31%   68.16%   -0.15%     
==========================================
  Files         726      727       +1     
  Lines      189662   190105     +443     
  Branches    29672    29683      +11     
==========================================
+ Hits       129564   129585      +21     
- Misses      42721    43143     +422     
  Partials    17377    17377              

☔ View full report in Codecov by Harness.
📢 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.

A Rust JSG resource showed in a V8 heap snapshot only as its
Wrappable: a node named after the type, the size of the C++ Wrappable
alone, and none of what the resource holds. This ports the C++ heap
snapshot support (jsg::MemoryTracker, visitForMemoryInfo, trackField
and JSG_MEMORY_INFO) to Rust JSG.

Every resource's node is now sized as the Rust struct plus its
Wrappable. A resource reports its fields once it opts in, as a C++
resource does by defining visitForMemoryInfo:

    #[jsg_resource(memory_info)]
    struct EventRouter { ... }

    impl jsg::VisitForMemoryInfo for EventRouter {
        fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) {
            tracker.track_field(c"handlers", &self.handlers);
        }
    }

The opt-in is a macro flag, so a resource with the flag but without
the impl doesn't compile.

* jsg::MemoryInfo says how a field is reported, as trackField's
  overloads do. Strings and collections are nodes of what they
  allocate, jsg::Rc is an edge to the resource's node, Global and
  TracedGlobal are edges into the V8 heap, jsg::Weak reports nothing.
  Collections of plain data are one node.
* jsg::MemoryRetainer makes a helper type a node of its own, as
  JSG_MEMORY_INFO does; jsg::jsg_memory_retainer! registers it. It is
  reported through MemoryTracker::pushRetainer, keyed by its address
  and a hash of its TypeId, so a retainer reached twice is one node
  and a cycle through std::rc::Rc ends. A collection of retainers
  counts only its spare capacity, leaving the elements' bytes to
  their nodes, also when they are inline in an Option, a Cell or
  another wrapper (MemoryInfo::self_bytes_reported).
* jsg_cpp::Ref<T> is the C++ resource's node, and C++ RustRef<T> gets
  trackField(), an edge to the Rust resource's wrapper.

Names are &'static CStr (c"..." literals), since V8 reads them after
the BuildEmbedderGraph callback returns; a compile test checks it.
Reporting runs in a callback from C++, so it must not panic. A Cell is
reported in place, through Cell::as_ptr() as Traced reads it, so
reporting code must not write into anything it can reach; nothing
enforces that, so MemoryInfo's docs and src/rust/AGENTS.md spell it
out. Handles of another isolate are skipped, and debug builds assert
on them.

API changes: Type::memory_self_size() is removed; nothing used it.
GarbageCollected gains memory_self_size() and memory_info(), with
defaults.
jsg_test::Harness::take_heap_snapshot() takes a heap snapshot as the
inspector does, and tests/memory_info.rs parses it to check what
resources report:

* node names, and sizes exact to the struct plus its Wrappable
* resources without memory_info report no fields
* a resource shared by two holders is one node, and a cycle is
  tracked once; so is a retainer shared through an std::rc::Rc, and a
  cycle of retainers through std::rc::Rc
* a retainer and the retainer at its start are two nodes, as are two
  zero-sized retainers or two retainers in Cells
* a collection or Box leaves the bytes of a retainer in an Option to
  the retainer's node
* collections, maps, boxes, Dict, Weak, Lenient, Global, BackingStore
  and a RefCell that is mutably borrowed during the snapshot
* in release builds, a handle of another isolate is not reported

cpp_interop.rs checks both directions between Rust JSG and C++ JSG: a
Rust resource's edge to a held C++ resource, and a C++ resource's
edge through a Rust resource's wrapper to its node.
@bartlomieju
bartlomieju force-pushed the biwanczuk/rust-jsg-memory-info branch from e12890a to 9581086 Compare November 3, 2026 15:22
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