From 1f5725d98240ee89cb44a65894884aaeec7a8c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 2 Nov 2026 12:06:00 +0100 Subject: [PATCH 1/3] Let jsg::MemoryTracker track objects that are not C++ types 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. --- src/workerd/jsg/AGENTS.md | 2 +- src/workerd/jsg/memory.c++ | 28 ++++++++++++++++++++++++++++ src/workerd/jsg/memory.h | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/workerd/jsg/AGENTS.md b/src/workerd/jsg/AGENTS.md index b4f0796cb9c..abb378b48e8 100644 --- a/src/workerd/jsg/AGENTS.md +++ b/src/workerd/jsg/AGENTS.md @@ -21,7 +21,7 @@ Macro-driven C++/V8 binding layer: declares C++ types as JS-visible resources/st | `setup.h` | `V8System`, `IsolateBase`, `JsgConfig`; process-level V8 init; `JSG_DECLARE_ISOLATE_TYPE` | | `sandbox.{h,c++}` | Cage-relative byte views for ArrayBuffer-backed native objects | | `function.h` | `jsg::Function` wrapping C++ callables ↔ JS functions | -| `memory.h` | `MemoryTracker`, `JSG_MEMORY_INFO` macro; heap snapshot support | +| `memory.h` | `MemoryTracker`, `JSG_MEMORY_INFO` macro; heap snapshot support; `pushRetainer` (deduplicated by address and type)/`pushContainer`/`popNode` track objects that are not C++ types (Rust JSG's `jsg::MemoryTracker`) | | `rtti.capnp` | Cap'n Proto schema for type introspection; consumed by `types/` for TS generation | | `rtti.h` | C++ RTTI builder: walks JSG type graph → `rtti.capnp` structures | | `jsvalue.h` | `JsValue`, `JsObject`, `JsString`, etc. — typed wrappers over `v8::Value` | diff --git a/src/workerd/jsg/memory.c++ b/src/workerd/jsg/memory.c++ index 00a0458f3e1..2067f262907 100644 --- a/src/workerd/jsg/memory.c++ +++ b/src/workerd/jsg/memory.c++ @@ -164,6 +164,34 @@ MemoryRetainerNode* MemoryTracker::pushNode( return n; } +bool MemoryTracker::pushRetainer(const void* address, + const void* type, + kj::StringPtr name, + size_t size, + kj::StringPtr edgeName) { + if (address != nullptr) { + RetainerKey key{address, type}; + KJ_IF_SOME(found, seenRetainers_.find(key)) { + addEdge(found, edgeName); + return false; + } + MemoryRetainerNode* n = pushNode(name, size, edgeName); + seenRetainers_.insert(key, n); + } else { + pushNode(name, size, edgeName); + } + return true; +} + +void MemoryTracker::pushContainer(kj::StringPtr name, size_t size, kj::StringPtr edgeName) { + pushNode(name, size, edgeName); +} + +void MemoryTracker::popNode() { + KJ_DASSERT(!nodeStack_.empty(), "popNode() without a node pushed"); + if (!nodeStack_.empty()) nodeStack_.pop(); +} + void MemoryTracker::decCurrentNodeSize(size_t size) { KJ_IF_SOME(currentNode, getCurrentNode(nodeStack_)) { currentNode.size_ -= size; diff --git a/src/workerd/jsg/memory.h b/src/workerd/jsg/memory.h index 3a1471ac273..4e46b8a8b61 100644 --- a/src/workerd/jsg/memory.h +++ b/src/workerd/jsg/memory.h @@ -317,6 +317,30 @@ class MemoryTracker final { return isolate_; } + // The following track objects that are not C++ types, such as Rust JSG's, whose fields are + // reported between a push and the matching popNode(). V8 reads `name` and `edgeName` after the + // BuildEmbedderGraph callback returns, so they must outlive it, as string literals do. An + // empty `edgeName` is the name `trackField` gives the elements of a container. + + // Tracks a retainer that `address` and `type` identify together, so that a retainer and one + // of its fields at the same address are two nodes. If the retainer was tracked already, only + // adds an edge to its node and returns false: there is nothing to report and nothing to pop. + // Otherwise adds a node for it, makes the node the one that fields are reported to, and + // returns true. A null `address` identifies nothing: the retainer always gets a new node. + bool pushRetainer(const void* address, + const void* type, + kj::StringPtr name, + size_t size, + kj::StringPtr edgeName); + + // Adds a node for a container, as `trackField` does for a C++ container, and makes it the one + // that the elements are reported to. + void pushContainer(kj::StringPtr name, size_t size, kj::StringPtr edgeName); + + // Ends the node of the most recent push that has not been popped and needs a pop. Release + // builds ignore a pop without such a push; debug builds assert. + void popNode(); + KJ_DISALLOW_COPY_AND_MOVE(MemoryTracker); private: @@ -324,6 +348,20 @@ class MemoryTracker final { v8::EmbedderGraph* graph_; std::stack nodeStack_; kj::HashMap seen_; + + // The retainers that pushRetainer() tracked, by address and type. + struct RetainerKey { + const void* address; + const void* type; + + bool operator==(const RetainerKey& other) const { + return address == other.address && type == other.type; + } + kj::uint hashCode() const { + return kj::hashCode(address, type); + } + }; + kj::HashMap seenRetainers_; KJ_DISALLOW_AS_COROUTINE_PARAM; explicit MemoryTracker(v8::Isolate* isolate, v8::EmbedderGraph* graph); From 92f694bbd1f9ca35e16f001165ac0fcb6e33bd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 2 Nov 2026 12:06:56 +0100 Subject: [PATCH 2/3] Report Rust JSG resources to heap snapshots 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 is the C++ resource's node, and C++ RustRef 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. --- src/rust/AGENTS.md | 8 +- .../memory-info-names-are-static.rs | 24 + ...memory-info-needs-visit-for-memory-info.rs | 15 + .../ok-memory-info-with-other-flags.rs | 69 ++ src/rust/jsg-cpp/ffi.c++ | 5 + src/rust/jsg-cpp/ffi.rs | 26 + src/rust/jsg-cpp/jsg-cpp.h | 13 + src/rust/jsg-macros/README.md | 74 +- src/rust/jsg-macros/resource.rs | 23 +- src/rust/jsg-macros/utils.rs | 16 +- src/rust/jsg/README.md | 1 + src/rust/jsg/cpp-jsg-allowlist-test.py | 4 +- src/rust/jsg/dict.rs | 5 + src/rust/jsg/ffi.c++ | 56 +- src/rust/jsg/ffi.h | 34 + src/rust/jsg/lib.rs | 33 +- src/rust/jsg/memory.rs | 785 ++++++++++++++++++ src/rust/jsg/resource.rs | 15 + src/rust/jsg/v8.rs | 118 +++ src/workerd/jsg/AGENTS.md | 2 +- 20 files changed, 1304 insertions(+), 22 deletions(-) create mode 100644 src/rust/jsg-compile-tests/memory-info-names-are-static.rs create mode 100644 src/rust/jsg-compile-tests/memory-info-needs-visit-for-memory-info.rs create mode 100644 src/rust/jsg-compile-tests/ok-memory-info-with-other-flags.rs create mode 100644 src/rust/jsg/memory.rs diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index 89a70f321d0..aea61923325 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -41,9 +41,15 @@ _Snapshot — the set drifts as crates come and go; `bazel query //src/rust/...` - **JSG properties**: three property macros on `#[jsg_resource]` impl blocks — `#[jsg_property(prototype|instance|lazy_instance [, name = "..."] [, readonly])]` (`prototype` registers an accessor on the prototype, equivalent to `JSG_PROTOTYPE_PROPERTY`; `instance` registers an own data property via V8 `SetNativeDataProperty`, equivalent to `JSG_INSTANCE_PROPERTY`; `lazy_instance` registers an own data property via V8 `SetLazyDataProperty` whose getter runs once, on first access, equivalent to `JSG_LAZY_INSTANCE_PROPERTY`: it has no setter, is writable unless `readonly` (`JSG_LAZY_READONLY_INSTANCE_PROPERTY`); declaration order sets `Object.keys()` order for automatic registration; `readonly` is a compile-time check preventing a paired setter; `name = "..."` overrides the JS name; prefer `prototype` in almost all cases), `#[jsg_inspect_property]` (registered under a unique symbol, invisible to normal enumeration and string-key lookup, surfaced by `node:util` `inspect()`, equivalent to `JSG_INSPECT_PROPERTY`), and `#[jsg_wildcard_property]` (answers string-named properties the object and its prototypes lack, at most one per type, equivalent to `JSG_WILDCARD_PROPERTY`; see `jsg/README.md`); setter auto-detected from `set_` prefix; read-only when no setter present; getter/setter `.length` and `.name` are set correctly when `spec_compliant_property_attributes` compat flag is enabled - **`jsg::v8::Global` / `TracedGlobal`**: persistent handles. A `Global` is for handles Rust owns outside resources: GC never visits it, so it is always strong, holds its handle inline (no box), has no `Traced` impl (it cannot be a resource field), and is `Send` and `Sync`. A `TracedGlobal` is for resource fields, which GC traces to collect cycles; it is `Send` (not `Sync`). Either may be dropped on any thread. Under the isolate lock its strong handle is reset at once; without the lock it is queued and reset at the next `IsolateBase::Lock`, through `IsolateBase::applyDeferredActions()`; once the `Realm` is dropped (under the lock, before `Dispose`) the handles are left to `Dispose`. See `jsg/isolate_state.rs`. Hand-written FFI that resets a V8 handle directly must hold the lock. Rust never resets a `TracedGlobal`'s traced reference: every GC visit lets go of it and makes the strong handle strong again (`ClearWeak`) before C++ installs a new one if needed, and a drop just lets go of it; V8 frees untraced nodes at the next full GC. See `GcVisitor::visit_traced_global`. A `TracedGlobal` keeps its handles in a `Box`, since V8 holds the address of a weak handle's slot; the slot of a `TracedGlobal` dropped after the `Realm` is kept until the isolate is disposed - **JSG parameters**: function `.length` is the Web IDL required-argument count under `spec_compliant_property_attributes` (`this: jsg::Rc` and a leading `&mut Lock` are not counted; counting stops at the first type whose `FromJS::is_optional()` returns true), so put optional parameters last -- **`Traced`**: core tracing trait in `jsg::wrappable`; built-ins include no-op impls for primitives/value types and delegating impls for wrappers/collections (`Option`, `Nullable`, `NullOrUndefined`, `Lenient`, `Vec`, maps/sets, `Cell`, `jsg::Rc`, `jsg::Weak`, `jsg::v8::TracedGlobal`); `jsg::v8::Global` deliberately has none +- **`Traced`**: core tracing trait in `jsg::wrappable`; built-ins include no-op impls for primitives/value types and delegating impls for wrappers/collections (`Option`, `Nullable`, `NullOrUndefined`, `Lenient`, `Vec`, maps/sets, `Cell`, `jsg::Rc`, `jsg::Weak`, `jsg::v8::TracedGlobal`); `jsg::v8::Global` deliberately has none. `Traced for Cell` reads the `T` in place through `Cell::as_ptr()`, so a `trace` impl **MUST NOT** write into a cell (the same rule as for heap snapshot reporting, below) - **Sequences to JavaScript**: `Vec` converts to an `Array`, numbers included (`vec![1u8, 2]` makes `[1, 2]`). A typed array or an `ArrayBuffer` is an explicit conversion of a slice: `to_js_typed_array(lock)` makes the typed array of the element type, and `to_js_array_buffer(lock)` makes an `ArrayBuffer` of bytes (`[u8]` only), as C++ JSG returns for a `kj::Array`. See `jsg/README.md` §Arrays, typed arrays and `ArrayBuffer`s - **`#[jsg_resource(custom_trace)]`**: suppresses the auto-generated `Traced` impl so the user can write their own; `GarbageCollected` (`memory_name`), `jsg::Type`, `jsg::ToJS`, and `jsg::FromJS` are still generated +- **Heap snapshots**: `#[jsg_resource(memory_info)]` + `impl jsg::VisitForMemoryInfo` (C++ `visitForMemoryInfo`); helper nodes implement `jsg::MemoryRetainer` and register with `jsg::jsg_memory_retainer!` (C++ `JSG_MEMORY_INFO`); names are `&'static CStr`. See `jsg-macros/README.md` §Heap Snapshots and `jsg/memory.rs`. Reporting code (`visit_for_memory_info`, `MemoryRetainer::memory_info`, any `MemoryInfo` impl) **MUST NOT**: + - **write into anything it can reach, above all a `Cell`** (no `Cell::set`/`replace`/`take`, no `RefCell::borrow_mut`, no counters or caches updated during a snapshot). `MemoryInfo for Cell` reads the `T` in place through `Cell::as_ptr()`, as `Traced for Cell` does, so a write into a cell whose value is being reported drops that value under a live reference: use-after-free from safe code. The compiler does not catch it, so review every new `memory_info`/`visit_for_memory_info` and `trace` impl for writes, including writes made through an `std::rc::Rc` back-reference; + - panic (it runs in a callback from C++, so a panic aborts the process); + - call into JavaScript. + + A type in a cycle through `std::rc::Rc`/`Arc` must be a `MemoryRetainer`: only a retainer's deduplication ends the cycle, and any other type recurses until the stack overflows. When a collection or `Box` holds retainers inline (directly or in an `Option`/`Cell`/other inline wrapper), `MemoryInfo::self_bytes_reported` leaves their bytes to their own nodes; a hand-written impl for an inline wrapper must forward it - **Rust JSG safety rules** (full text and examples in `src/rust/jsg/README.md` §Safety rules): 1. Never call a V8 API that takes a `v8::Local` while an exception or a termination is pending: V8 either clears the exception on entry, so the script runs on, or fails a debug check and in a release build runs on with it pending. Every C++ shim that passes a context to V8 first checks `exceptionPending()` (`jsg/ffi.h`) and fails with a pending error. Never throw while execution is terminating, and never discard a `jsg::Error` from an operation that can fail with a pending error (a call, `Object::get`/`set`, a coercion, `ToJS::to_js`); pass it on with `?`. Creating strings, numbers, objects, arrays, error objects and templates is fine at any time. 2. Never put traced state (`TracedGlobal`, `jsg::Rc`, or a type holding them) behind `std::rc::Rc`. To give a callback or another party access, give it a `jsg::Rc` or `jsg::Weak` to the resource that owns the state (a method gets one as `this: jsg::Rc`). Clone a `jsg::Rc` right after moving it out of a traced field (`take`, `replace`, `pop`), since the moved one is neither strong nor traced ([#7976](https://github.com/cloudflare/workerd/issues/7976)). Don't put a `Global` in anything GC traces. diff --git a/src/rust/jsg-compile-tests/memory-info-names-are-static.rs b/src/rust/jsg-compile-tests/memory-info-names-are-static.rs new file mode 100644 index 00000000000..70af906c1cc --- /dev/null +++ b/src/rust/jsg-compile-tests/memory-info-names-are-static.rs @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// V8 reads the names of heap snapshot edges after the tracker's callback returns, so an edge name +// is a `&'static CStr`, and one built at runtime is rejected. +use std::ffi::CString; + +use jsg_macros::jsg_resource; + +#[jsg_resource(memory_info)] +struct Holder { + name: String, +} + +#[jsg_resource] +impl Holder {} + +impl jsg::VisitForMemoryInfo for Holder { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + let edge_name = CString::new("name").unwrap(); + tracker.track_field(&edge_name, &self.name); //~ ERROR E0597 + } +} diff --git a/src/rust/jsg-compile-tests/memory-info-needs-visit-for-memory-info.rs b/src/rust/jsg-compile-tests/memory-info-needs-visit-for-memory-info.rs new file mode 100644 index 00000000000..28682a7a943 --- /dev/null +++ b/src/rust/jsg-compile-tests/memory-info-needs-visit-for-memory-info.rs @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// `#[jsg_resource(memory_info)]` reports the resource's fields through its +// `jsg::VisitForMemoryInfo` impl, so the resource must have one. +use jsg_macros::jsg_resource; + +#[jsg_resource(memory_info)] //~ ERROR E0277 +struct Holder { + name: String, +} + +#[jsg_resource] +impl Holder {} diff --git a/src/rust/jsg-compile-tests/ok-memory-info-with-other-flags.rs b/src/rust/jsg-compile-tests/ok-memory-info-with-other-flags.rs new file mode 100644 index 00000000000..69e3b6041d9 --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-memory-info-with-other-flags.rs @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// `memory_info` combines with the other flags of `#[jsg_resource]`, in any order. +#![deny(warnings)] + +use std::ffi::CStr; + +use jsg_macros::jsg_resource; + +#[jsg_resource(name = "Renamed", memory_info, custom_trace)] +pub struct Holder { + name: String, +} + +#[jsg_resource] +impl Holder {} + +impl jsg::Traced for Holder {} + +impl jsg::VisitForMemoryInfo for Holder { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"name", &self.name); + } +} + +#[jsg_resource(custom_trace, memory_info)] +pub struct Other { + cache: Cache, +} + +#[jsg_resource] +impl Other {} + +impl jsg::Traced for Other {} + +impl jsg::VisitForMemoryInfo for Other { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"cache", &self.cache); + } +} + +pub struct Cache { + entries: Vec, +} + +impl jsg::MemoryRetainer for Cache { + fn memory_name(&self) -> &'static CStr { + c"Cache" + } + + fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"entries", &self.entries); + } +} + +jsg::jsg_memory_retainer!(Cache); + +pub fn t(name: String) -> (jsg::Rc, jsg::Rc) { + ( + jsg::Rc::new(Holder { name }), + jsg::Rc::new(Other { + cache: Cache { + entries: Vec::new(), + }, + }), + ) +} diff --git a/src/rust/jsg-cpp/ffi.c++ b/src/rust/jsg-cpp/ffi.c++ index 4a2efb4da00..a7f325a439a 100644 --- a/src/rust/jsg-cpp/ffi.c++ +++ b/src/rust/jsg-cpp/ffi.c++ @@ -19,4 +19,9 @@ void cpp_ref_visit(GcVisitor* visitor, const CppRef& ref) { ref.visitForGc(*::workerd::rust::jsg::gc_visitor_from_ffi(visitor)); } +void cpp_ref_track( + ::workerd::rust::jsg::MemoryTracker& tracker, const char* edge_name, const CppRef& ref) { + ref.trackField(tracker, ::workerd::rust::jsg::memoryTrackerEdgeName(edge_name)); +} + } // namespace workerd::rust::jsg_cpp diff --git a/src/rust/jsg-cpp/ffi.rs b/src/rust/jsg-cpp/ffi.rs index 0cfc580c1a5..4f6ad4f105b 100644 --- a/src/rust/jsg-cpp/ffi.rs +++ b/src/rust/jsg-cpp/ffi.rs @@ -28,6 +28,7 @@ pub mod bridge { unsafe extern "C++" { include!("workerd/rust/jsg/ffi.h"); type GcVisitor = jsg::v8::ffi::GcVisitor; + type MemoryTracker = jsg::v8::ffi::MemoryTracker; } unsafe extern "C++" { @@ -47,6 +48,18 @@ pub mod bridge { /// /// `visitor` is the visitor of a visit in progress. pub unsafe fn cpp_ref_visit(visitor: *mut GcVisitor, cpp_ref: &CppRef); + /// Reports the reference to a heap snapshot as C++ reports a `jsg::Ref` field, under + /// the edge `edge_name`. + /// + /// # Safety + /// + /// `edge_name` is a NUL-terminated string literal, or null for none: V8 reads it after + /// the snapshot's BuildEmbedderGraph callback returns. + pub unsafe fn cpp_ref_track( + tracker: Pin<&mut MemoryTracker>, + edge_name: *const c_char, + cpp_ref: &CppRef, + ); } impl KjOwn {} @@ -209,6 +222,19 @@ impl jsg::Traced for Ref { } } +/// The C++ resource's node, as C++ reports a `jsg::Ref` field. +impl jsg::MemoryInfo for Ref { + fn track_as_field( + &self, + tracker: &mut jsg::MemoryTracker<'_>, + edge_name: Option<&'static std::ffi::CStr>, + ) { + let edge_name = edge_name.map_or(std::ptr::null(), std::ffi::CStr::as_ptr); + // SAFETY: the name is a literal or null. + unsafe { bridge::cpp_ref_track(tracker.as_ffi(), edge_name, &self.inner) }; + } +} + impl jsg::Type for Ref { fn class_name() -> &'static str { T::class_name() diff --git a/src/rust/jsg-cpp/jsg-cpp.h b/src/rust/jsg-cpp/jsg-cpp.h index 1df3d88d667..2d5c2c95d4b 100644 --- a/src/rust/jsg-cpp/jsg-cpp.h +++ b/src/rust/jsg-cpp/jsg-cpp.h @@ -68,6 +68,10 @@ class CppRef final { visitor.visit(ref); } + void trackField(::workerd::jsg::MemoryTracker& tracker, kj::StringPtr edgeName) const { + tracker.trackField(edgeName, ref); + } + // For clone(): only CppRef can make the badge, so no one else can pair an object with a // reference to a different one. Public only so that kj::heap can call it. CppRef(kj::Badge, void* object, ::workerd::jsg::Ref<::workerd::jsg::Object> ref) @@ -85,6 +89,9 @@ class CppRef final { kj::Own cpp_ref_clone(const CppRef& ref); size_t cpp_ref_object(const CppRef& ref); void cpp_ref_visit(GcVisitor* visitor, const CppRef& ref); +// A null `edge_name` is the empty name C++ gives the elements of a container. +void cpp_ref_track( + ::workerd::rust::jsg::MemoryTracker& tracker, const char* edge_name, const CppRef& ref); // Runs `func(jsg::Lock&)` the way a JSG method callback runs C++ API code (jsg::liftKj()), and // returns whether it completed. A `jsg::JsExceptionThrown` leaves its exception pending. A @@ -228,6 +235,12 @@ class RustRef { visitor.visit(wrapper); } + // Reports the reference to a heap snapshot as an edge to the wrapper, whose + // `javascript_to_native` edge leads to the Rust resource's node. + void trackField(::workerd::jsg::MemoryTracker& tracker, kj::StringPtr edgeName) const { + tracker.trackField(edgeName, wrapper); + } + private: explicit RustRef(::workerd::jsg::V8Ref wrapper): wrapper(kj::mv(wrapper)) {} diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index f8b471d0d6c..8be474e4223 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -465,7 +465,9 @@ impl MyResource { `#[jsg_resource]` on a struct synthesises: - `impl jsg::Traced for MyType { fn trace(&self, visitor) { ... } }` -- `impl jsg::GarbageCollected for MyType { fn memory_name(&self) -> ... }` +- `impl jsg::GarbageCollected for MyType { fn memory_name(&self) -> ... }`, which with + `#[jsg_resource(memory_info)]` also reports the fields to heap snapshots (see + [Heap Snapshots](#heap-snapshots)) The generated `Traced::trace` body simply calls `Traced::trace` on **every named field**: @@ -590,3 +592,73 @@ impl jsg::Traced for DynamicResource { ``` `custom_trace` can be combined with `name`: `#[jsg_resource(name = "MyName", custom_trace)]`. + +## Heap Snapshots + +Every resource is a node in a V8 heap snapshot, as a C++ resource is: it is named after the +struct (`workerd / MyType`), sized as the struct plus its C++ `Wrappable`, and linked to its +JavaScript wrapper. To report its fields as well, as C++ `visitForMemoryInfo` does, add +`memory_info` to the struct's `#[jsg_resource]` and implement `jsg::VisitForMemoryInfo`. The +generated `GarbageCollected::memory_info` calls it; without `memory_info` a resource reports no +fields, and with it but without the impl the resource does not compile. + +```rust +#[jsg_resource(memory_info)] +pub struct EventRouter { + handlers: HashMap>, + on_error: RefCell>>, + name: String, +} + +impl jsg::VisitForMemoryInfo for EventRouter { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"handlers", &self.handlers); + tracker.track_field(c"on_error", &self.on_error); + tracker.track_field(c"name", &self.name); + } +} +``` + +`tracker.track_field` takes any `jsg::MemoryInfo`, the counterpart of C++ +`MemoryTracker::trackField`'s overloads: strings and collections are nodes of the memory they +allocate, a `jsg::Rc` is an edge to the resource's node, a `TracedGlobal` an edge into the V8 heap, +and a helper type that implements `jsg::MemoryRetainer` (C++ `JSG_MEMORY_INFO`) is a node of its +own. Edge and node names are `c"..."` literals, since V8 reads them after the tracker's callback +returns. See `src/rust/jsg/memory.rs` for the full list. + +A helper type registers its `MemoryRetainer` impl with `jsg::jsg_memory_retainer!`, which +implements `jsg::MemoryInfo` for it, so that `track_field` takes it: + +```rust +struct Cache { + entries: HashMap, +} + +impl jsg::MemoryRetainer for Cache { + fn memory_name(&self) -> &'static CStr { + c"Cache" + } + + fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"entries", &self.entries); + } +} + +jsg::jsg_memory_retainer!(Cache); +``` + +A retainer's node counts its own bytes, so a collection of retainers counts only its spare +capacity. A retainer reached twice, as through two `Rc`s, is one node, so a type in a cycle through +`std::rc::Rc` or `std::sync::Arc` must be a retainer, or reporting never ends. A retainer type is +`'static`. + +The reporting code (`visit_for_memory_info`, `memory_info` and any `MemoryInfo` impl) follows the +rules in `jsg::MemoryInfo`'s docs: + +- It must not panic. It runs in a callback from C++, where a panic aborts the process. +- It must not write into anything it can reach, a `Cell` above all. `Cell` is reported in + place, through `Cell::as_ptr`, so a `set` or `replace` of a cell while its value is reported drops + the value under the reference. The compiler does not check this. +- It must not call into JavaScript. + +`memory_info` can be combined with `name` and `custom_trace`. diff --git a/src/rust/jsg-macros/resource.rs b/src/rust/jsg-macros/resource.rs index 25a2caf4a23..65950e00f88 100644 --- a/src/rust/jsg-macros/resource.rs +++ b/src/rust/jsg-macros/resource.rs @@ -21,6 +21,7 @@ use crate::utils::elide_named_lifetimes; use crate::utils::extract_name_attribute; use crate::utils::extract_named_fields; use crate::utils::has_custom_trace_flag; +use crate::utils::has_memory_info_flag; use crate::utils::is_attr; use crate::utils::is_lock_ref; use crate::utils::is_result_type; @@ -45,8 +46,10 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> return err.to_compile_error().into(); } - // Check for `#[jsg_resource(custom_trace)]` before consuming `attr`. + // Check for `#[jsg_resource(custom_trace)]` and `#[jsg_resource(memory_info)]` before + // consuming `attr`. let custom_trace = has_custom_trace_flag(&attr); + let memory_info = has_memory_info_flag(&attr); // Clone the name before mutating `input` so we can borrow freely later. let name = input.ident.clone(); @@ -81,6 +84,18 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> } }; + // `memory_info` reports the fields through the user's `VisitForMemoryInfo` impl, as C++ + // `JSG_RESOURCE_TYPE` calls a resource's `visitForMemoryInfo`. + let memory_info_fn = if memory_info { + quote! { + fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + ::visit_for_memory_info(self, tracker); + } + } + } else { + quote! {} + }; + let gc_impl = quote! { #[automatically_derived] impl jsg::GarbageCollected for #name { @@ -93,6 +108,8 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> ::std::ffi::CStr::from_bytes_with_nul(concat!(#name_str, "\0").as_bytes()) .unwrap() } + + #memory_info_fn } }; @@ -481,8 +498,8 @@ fn parse_impl_args(attr: proc_macro2::TokenStream) -> syn::Result { use syn::punctuated::Punctuated; const EXPECTED: &str = "expected `custom_members` or `nested_types(...)` on a \ - #[jsg_resource] impl block (`name` and `custom_trace` belong on \ - the struct)"; + #[jsg_resource] impl block (`name`, `custom_trace` and \ + `memory_info` belong on the struct)"; let args = Punctuated::::parse_terminated.parse2(attr)?; let mut custom_members = None; diff --git a/src/rust/jsg-macros/utils.rs b/src/rust/jsg-macros/utils.rs index 99966bc38e6..c93145d7059 100644 --- a/src/rust/jsg-macros/utils.rs +++ b/src/rust/jsg-macros/utils.rs @@ -44,6 +44,20 @@ pub fn is_attr(attr: &syn::Attribute, name: &str) -> bool { /// When set, `#[jsg_resource]` on a struct suppresses the auto-generated `Traced` /// impl, letting the user write their own. pub fn has_custom_trace_flag(attr: &TokenStream) -> bool { + has_flag(attr, "custom_trace") +} + +/// Returns `true` if the `memory_info` bare word is present in the attribute token stream. +/// +/// When set, the `GarbageCollected::memory_info` that `#[jsg_resource]` generates on a struct +/// calls the user's `jsg::VisitForMemoryInfo` impl. +pub fn has_memory_info_flag(attr: &TokenStream) -> bool { + has_flag(attr, "memory_info") +} + +/// Returns `true` if the bare word `flag` is present in the comma-separated attribute token +/// stream. +fn has_flag(attr: &TokenStream, flag: &str) -> bool { use syn::Meta; use syn::punctuated::Punctuated; @@ -56,7 +70,7 @@ pub fn has_custom_trace_flag(attr: &TokenStream) -> bool { parsed .iter() - .any(|meta| matches!(meta, Meta::Path(path) if path.is_ident("custom_trace"))) + .any(|meta| matches!(meta, Meta::Path(path) if path.is_ident(flag))) } /// Emits a `compile_error!` token stream anchored to `tokens` with message `msg`. diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index c4f11e5ef42..017c7ff93a0 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -359,6 +359,7 @@ does for `&self` methods. A method that takes `this` has no Fast API callback. - **`Traced` drives field tracing**: `#[jsg_resource]` now traces every named field via `Traced::trace(&self.field, visitor)`. Types with no GC edges use no-op `Traced` impls; wrappers/collections delegate recursively. - **Nested wrappers are supported by composition**: `Option>>` works as long as each layer implements `Traced`. - **`#[jsg_resource(custom_trace)]`**: suppresses the generated `Traced` impl so you can write your own. `GarbageCollected` (`memory_name`), `Type`, `ToJS`, and `FromJS` are still generated. +- **Heap snapshots**: every resource is a node of its own, linked to its JavaScript wrapper, as a C++ resource is. `#[jsg_resource(memory_info)]` has it also report its fields through its `jsg::VisitForMemoryInfo` impl, the counterpart of C++ `visitForMemoryInfo`, with `tracker.track_field(c"name", &self.field)` for any `jsg::MemoryInfo`; a helper type implements `jsg::MemoryRetainer`, the counterpart of `JSG_MEMORY_INFO`, and registers it with `jsg::jsg_memory_retainer!`. See `memory.rs` and [`jsg-macros/README.md`](../jsg-macros/README.md#heap-snapshots). - **`jsg::v8::TracedGlobal` cycle collection**: Uses the same strong↔traced dual-mode as C++ `jsg::V8Ref`. While the parent resource has strong Rust refs the JS handle stays strong. Once all Rust `Rc`s are dropped, `visit_traced_global` downgrades the handle to a `v8::TracedReference` that cppgc can follow — allowing cycles (e.g. a resource holding a callback that captures its own wrapper) to be detected and collected. - **Circular references** through `jsg::Rc` are **not** collected, matching C++ `jsg::Rc` behavior. diff --git a/src/rust/jsg/cpp-jsg-allowlist-test.py b/src/rust/jsg/cpp-jsg-allowlist-test.py index 31f01990867..c933421f3c1 100644 --- a/src/rust/jsg/cpp-jsg-allowlist-test.py +++ b/src/rust/jsg/cpp-jsg-allowlist-test.py @@ -25,8 +25,8 @@ import sys from pathlib import Path -# Allowlist name -> pattern. `jsg::Wrappable`, `jsg::GcVisitor` and the module registries are not -# listed, because Rust JSG still shares them with C++ JSG. +# Allowlist name -> pattern. `jsg::Wrappable`, `jsg::GcVisitor`, `jsg::MemoryTracker` and the +# module registries are not listed, because Rust JSG still shares them with C++ JSG. CONSTRUCTS = { "jsg::Lock": r"\bjsg::Lock\b", "JSG_TRY": r"\bJSG_(?:TRY|CATCH)\b", diff --git a/src/rust/jsg/dict.rs b/src/rust/jsg/dict.rs index b55cc410ba0..88dcb7d44ce 100644 --- a/src/rust/jsg/dict.rs +++ b/src/rust/jsg/dict.rs @@ -61,6 +61,11 @@ impl Dict { pub fn is_empty(&self) -> bool { self.entries.is_empty() } + + /// The entries, for heap snapshots, which report the memory they allocate. + pub(crate) fn entries(&self) -> &Vec<(String, V)> { + &self.entries + } } impl Default for Dict { diff --git a/src/rust/jsg/ffi.c++ b/src/rust/jsg/ffi.c++ index cb7ab211d95..87b1683a78a 100644 --- a/src/rust/jsg/ffi.c++ +++ b/src/rust/jsg/ffi.c++ @@ -108,7 +108,12 @@ kj::StringPtr Wrappable::jsgGetMemoryName() const { } size_t Wrappable::jsgGetMemorySelfSize() const { - return sizeof(Wrappable); + return sizeof(Wrappable) + wrappable_invoke_get_memory_self_size(*this); +} + +void Wrappable::jsgGetMemoryInfo(::workerd::jsg::MemoryTracker& tracker) const { + ::workerd::jsg::Wrappable::jsgGetMemoryInfo(tracker); + wrappable_invoke_get_memory_info(*this, tracker); } // Local @@ -1133,6 +1138,55 @@ void global_clear_weak(uintptr_t* global) { } } +// MemoryTracker +Isolate* memory_tracker_isolate(MemoryTracker& tracker) { + return tracker.isolate(); +} + +void memory_tracker_track_field_with_size( + MemoryTracker& tracker, const char* edge_name, size_t size, const char* node_name) { + if (node_name == nullptr) { + tracker.trackFieldWithSize(memoryTrackerEdgeName(edge_name), size); + } else { + tracker.trackFieldWithSize(memoryTrackerEdgeName(edge_name), size, kj::StringPtr(node_name)); + } +} + +void memory_tracker_track_wrappable( + MemoryTracker& tracker, const char* edge_name, const Wrappable& wrappable) { + // As the base class, so that the node is the one HeapTracer adds for the same Wrappable. + tracker.trackField( + memoryTrackerEdgeName(edge_name), static_cast(&wrappable)); +} + +void memory_tracker_track_global( + MemoryTracker& tracker, const char* edge_name, const Global& global) { + if (global.ptr == 0) return; + auto* isolate = tracker.isolate(); + v8::HandleScope scope(isolate); + tracker.trackField(memoryTrackerEdgeName(edge_name), + v8::Local::New(isolate, global_as_ref_from_ffi(global))); +} + +bool memory_tracker_push_retainer(MemoryTracker& tracker, + size_t address, + size_t type, + const char* name, + size_t size, + const char* edge_name) { + return tracker.pushRetainer(reinterpret_cast(address), + reinterpret_cast(type), name, size, memoryTrackerEdgeName(edge_name)); +} + +void memory_tracker_push_container( + MemoryTracker& tracker, const char* name, size_t size, const char* edge_name) { + tracker.pushContainer(name, size, memoryTrackerEdgeName(edge_name)); +} + +void memory_tracker_pop_node(MemoryTracker& tracker) { + tracker.popNode(); +} + void wrappable_visit_ref( Wrappable& wrappable, uintptr_t* ref_parent, bool* ref_strong, GcVisitor* visitor) { auto* gcVisitor = gc_visitor_from_ffi(visitor); diff --git a/src/rust/jsg/ffi.h b/src/rust/jsg/ffi.h index cda13a8705c..05812daa923 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include @@ -68,6 +69,7 @@ class Wrappable: public ::workerd::jsg::Wrappable { void jsgVisitForGc(::workerd::jsg::GcVisitor& visitor) override; kj::StringPtr jsgGetMemoryName() const override; size_t jsgGetMemorySelfSize() const override; + void jsgGetMemoryInfo(::workerd::jsg::MemoryTracker& tracker) const override; // Returns a new kj::Rc reference to this Wrappable. kj::Rc toRc() { @@ -108,6 +110,38 @@ Isolate* gc_visitor_v8_isolate(GcVisitor* visitor); /// field of the `ffi::Global`. void global_clear_weak(uintptr_t* global); +// MemoryTracker +// Heap snapshots: what a Rust value reports to `jsg::MemoryTracker`. A null `edge_name` is the +// empty name C++ gives the elements of a container; a null `node_name` is none. V8 reads the names +// after the snapshot's BuildEmbedderGraph callback returns, so they are string literals; see +// `pushRetainer()` in workerd/jsg/memory.h. +using MemoryTracker = ::workerd::jsg::MemoryTracker; +// The edge name of a `memory_tracker_*` shim's `edge_name`: a null one is the empty name. +inline kj::StringPtr memoryTrackerEdgeName(const char* edgeName) { + if (edgeName == nullptr) return nullptr; + return kj::StringPtr(edgeName); +} +// The isolate whose heap the snapshot is of. +Isolate* memory_tracker_isolate(MemoryTracker& tracker); +void memory_tracker_track_field_with_size( + MemoryTracker& tracker, const char* edge_name, size_t size, const char* node_name); +// Tracks a Wrappable, as `trackField` tracks a `jsg::Ref` field. +void memory_tracker_track_wrappable( + MemoryTracker& tracker, const char* edge_name, const Wrappable& wrappable); +// Adds an edge to the value of `global`, a handle of the tracker's isolate, unless it is empty. +void memory_tracker_track_global( + MemoryTracker& tracker, const char* edge_name, const Global& global); +// `address` and `type` identify the retainer; a null `address` identifies nothing. +bool memory_tracker_push_retainer(MemoryTracker& tracker, + size_t address, + size_t type, + const char* name, + size_t size, + const char* edge_name); +void memory_tracker_push_container( + MemoryTracker& tracker, const char* name, size_t size, const char* edge_name); +void memory_tracker_pop_node(MemoryTracker& tracker); + // Rules for the shims below (details in src/rust/jsg/README.md, "Safety rules"): // - Call V8 directly, not C++ JSG (`jsg::Lock`, `JSG_TRY`, `jsg::check`, // `createTunneledException`, `makeInternalError`, `jsg::Data`, ...). cpp-jsg-allowlist-test diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 84a7e0f1db3..da8dcceee78 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -15,6 +15,7 @@ pub mod feature_flags; mod isolate_state; pub mod lenient; pub mod macros; +mod memory; pub mod modules; pub mod null_or_undefined; pub mod nullable; @@ -34,6 +35,10 @@ pub use dict::Dict; pub use feature_flags::FeatureFlags; use isolate_state::IsolateState; pub use lenient::Lenient; +pub use memory::MemoryInfo; +pub use memory::MemoryRetainer; +pub use memory::MemoryTracker; +pub use memory::VisitForMemoryInfo; pub use null_or_undefined::NullOrUndefined; pub use nullable::Nullable; #[doc(hidden)] @@ -932,20 +937,13 @@ impl<'s> Lock<'s> { /// Provides metadata about Rust types exposed to JavaScript. /// -/// This trait provides type information used for error messages, memory tracking, -/// and type validation (for `NonCoercible`). The actual conversion logic is in -/// `ToJS` (Rust → JS) and `FromJS` (JS → Rust). -/// -/// TODO: Implement `memory_info(jsg::MemoryTracker)` +/// This trait provides type information used for error messages and type validation (for +/// `NonCoercible`). The actual conversion logic is in `ToJS` (Rust → JS) and `FromJS` +/// (JS → Rust). What a resource reports to heap snapshots is in [`GarbageCollected`]. pub trait Type: Sized { /// The JavaScript class name for this type (used in error messages). fn class_name() -> &'static str; - /// Same as jsgGetMemorySelfSize - fn memory_self_size() -> usize { - std::mem::size_of::() - } - /// Returns true if the V8 value is exactly this type (no coercion). /// Used by `NonCoercible` to reject values that would require coercion. /// @@ -1115,11 +1113,12 @@ pub enum Member { /// Trait for types that participate in V8 garbage collection as tracked resources. /// /// Extends [`Traced`] (which provides the `trace` method for visiting nested -/// GC-visible references) with a class name for heap snapshot tooling. +/// GC-visible references) with what the resource reports to heap snapshots. /// /// `#[jsg_resource]` auto-derives both `Traced` and `GarbageCollected`. /// Use `#[jsg_resource(custom_trace)]` to suppress the generated `Traced` -/// impl and provide your own. +/// impl and provide your own, and `#[jsg_resource(memory_info)]` to have +/// `memory_info` call the resource's [`VisitForMemoryInfo`] impl. pub trait GarbageCollected: Traced { /// Class name for heap snapshots / debugging. /// @@ -1127,6 +1126,16 @@ pub trait GarbageCollected: Traced { /// construct a `kj::StringPtr` directly from the NUL-terminated pointer without any /// allocation or caching. Implementations must not access `self`. fn memory_name(&self) -> &'static std::ffi::CStr; + + /// The size of the resource in a heap snapshot, without what it allocates. C++ adds it to + /// the size of the resource's `Wrappable`. + fn memory_self_size(&self) -> usize { + std::mem::size_of_val(self) + } + + /// Reports the resource's fields to a heap snapshot: the C++ `jsgGetMemoryInfo` of a + /// resource. Reports nothing by default. + fn memory_info(&self, _tracker: &mut MemoryTracker<'_>) {} } /// Rust types that are deep-copied into JavaScript as value types. diff --git a/src/rust/jsg/memory.rs b/src/rust/jsg/memory.rs new file mode 100644 index 00000000000..52ea356b8ee --- /dev/null +++ b/src/rust/jsg/memory.rs @@ -0,0 +1,785 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +#![allow( + unsafe_code, + reason = "reports to the C++ `jsg::MemoryTracker` through the FFI" +)] + +//! Heap snapshot support: what Rust values report to C++ `jsg::MemoryTracker` +//! (`workerd/jsg/memory.h`), which adds them to the graph of embedder objects in a V8 heap +//! snapshot. +//! +//! - [`MemoryTracker`] is the tracker, as C++ code receives it in `jsgGetMemoryInfo`. +//! - [`MemoryInfo`] says how a value is reported when it is a field of a tracked object, as the +//! overloads of C++ `MemoryTracker::trackField` do. +//! - [`MemoryRetainer`] makes a type a node of its own, as C++ `JSG_MEMORY_INFO` does; +//! [`jsg_memory_retainer!`](crate::jsg_memory_retainer) gives it its `MemoryInfo` impl. +//! - [`VisitForMemoryInfo`] reports the fields of a resource, as C++ `visitForMemoryInfo` does. +//! `#[jsg_resource(memory_info)]` on the struct opts the resource in. +//! +//! Every resource is a node, named after its type and sized as the resource plus its C++ +//! `Wrappable`, with an edge to its JavaScript wrapper if it has one, whether it reports its +//! fields or not. +//! +//! Names are `&'static CStr`, written as `c"..."` literals: V8 reads the names of nodes and +//! edges after the tracker's callback returns. +//! +//! The code that reports values follows the rules in +//! [`MemoryInfo`](MemoryInfo#rules-for-reporting-code). +//! +//! # Sizes +//! +//! A node's size is the bytes it accounts for. A resource or retainer counts its own bytes +//! (`size_of_val`), which include its fields' inline bytes, and a string or collection counts the +//! memory it allocates. A retainer held inline in another, as a field, is therefore counted in +//! both nodes, as a C++ member with `JSG_MEMORY_INFO` is. A collection leaves the bytes of the +//! retainers it holds inline, directly or in an `Option`, `Cell` or other inline wrapper, to their +//! own nodes (see [`MemoryInfo::self_bytes_reported`]). A value shared through an `std::rc::Rc` +//! or `std::sync::Arc` is reported once for each holder, unless it is a retainer, which is one +//! node. +//! +//! The size of a collection is that of its elements' slots: `capacity` slots for `Vec`, +//! `VecDeque`, `HashMap`, `HashSet` and `Dict`, `len` for `BTreeMap`, `BTreeSet` and boxed +//! slices. It leaves out the allocator's and the collection's own bookkeeping, such as a hash +//! table's control bytes and a B-tree's nodes, so it is an estimate, and a low one. + +use std::any::TypeId; +use std::ffi::CStr; +use std::ffi::c_char; +use std::hash::DefaultHasher; +use std::hash::Hash; +use std::hash::Hasher; +use std::ops::Deref; +use std::ops::DerefMut; +use std::pin::Pin; + +use crate::Dict; +use crate::Lenient; +use crate::NonCoercible; +use crate::NullOrUndefined; +use crate::Nullable; +use crate::isolate_state::IsolateState; +use crate::v8::ffi; + +/// The C++ `jsg::MemoryTracker` of a heap snapshot in progress, to which a value reports what +/// it retains. A heap snapshot is taken under the isolate lock, outside any `IoContext`. +pub struct MemoryTracker<'a> { + inner: Pin<&'a mut ffi::MemoryTracker>, + /// The isolate whose heap the snapshot is of. + isolate: *mut ffi::Isolate, +} + +fn name_ptr(name: Option<&'static CStr>) -> *const c_char { + name.map_or(std::ptr::null(), CStr::as_ptr) +} + +/// The tag that tells retainers of type `R` from those of other types at the same address: a +/// hash of its `TypeId`, which is the same wherever `R` is reported from. Two types share a tag +/// only by a hash collision, which would make the retainer reported second an edge to the node of +/// the first. +#[expect( + clippy::cast_possible_truncation, + reason = "a tag needs no more bits than a pointer has" +)] +fn type_tag() -> usize { + let mut hasher = DefaultHasher::new(); + TypeId::of::().hash(&mut hasher); + hasher.finish() as usize +} + +/// Pops the node that a push made when it is dropped, so that the tracker's stack of nodes stays +/// balanced however the reporting in between ends. +struct PushedNode<'t, 'a> { + tracker: &'t mut MemoryTracker<'a>, +} + +impl<'a> Deref for PushedNode<'_, 'a> { + type Target = MemoryTracker<'a>; + + fn deref(&self) -> &Self::Target { + self.tracker + } +} + +impl DerefMut for PushedNode<'_, '_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.tracker + } +} + +impl Drop for PushedNode<'_, '_> { + fn drop(&mut self) { + // SAFETY: pops the node that the push that made this guard pushed. + unsafe { ffi::memory_tracker_pop_node(self.tracker.inner.as_mut()) }; + } +} + +impl<'a> MemoryTracker<'a> { + pub(crate) fn from_ffi(mut inner: Pin<&'a mut ffi::MemoryTracker>) -> Self { + // SAFETY: the tracker is valid for the duration of the snapshot. + let isolate = unsafe { ffi::memory_tracker_isolate(inner.as_mut()) }; + Self { inner, isolate } + } + + /// The C++ `jsg::MemoryTracker`, for a [`MemoryInfo`] impl in another crate that reports a + /// value Rust JSG doesn't know, such as a C++ JSG `jsg::Ref`, through its own FFI. + pub fn as_ffi(&mut self) -> Pin<&mut ffi::MemoryTracker> { + self.inner.as_mut() + } + + /// Reports `value`, a field of the object being tracked, under the edge `edge_name`: C++ + /// `MemoryTracker::trackField`. + pub fn track_field(&mut self, edge_name: &'static CStr, value: &T) { + value.track_as_field(self, Some(edge_name)); + } + + /// Adds a node of `size` bytes named `node_name`, or `edge_name` if it has none, with an + /// edge to it from the object being tracked; nothing if `size` is 0. C++ + /// `MemoryTracker::trackFieldWithSize`. An `edge_name` of `None` is the empty name of a + /// collection's element. + pub fn track_field_with_size( + &mut self, + edge_name: Option<&'static CStr>, + size: usize, + node_name: Option<&'static CStr>, + ) { + // SAFETY: the names are literals or null. + unsafe { + ffi::memory_tracker_track_field_with_size( + self.inner.as_mut(), + name_ptr(edge_name), + size, + name_ptr(node_name), + ); + } + } + + /// Reports `retainer` as a node of its own, the first time; later reports of the same + /// retainer, which its address and type identify, only add an edge to that node, as for a C++ + /// retainer. A zero-sized retainer has no address of its own, so each report is a new node. + /// + /// Report the fields of the object being tracked, not values computed from them: two + /// temporaries of the same type in the same stack slot would be taken for one retainer. + pub fn track_retainer( + &mut self, + edge_name: Option<&'static CStr>, + retainer: &R, + ) { + let address = if size_of_val(retainer) == 0 { + 0 + } else { + std::ptr::from_ref(retainer).cast::<()>() as usize + }; + let type_tag = type_tag::(); + // SAFETY: the names are literals or null. + let pushed = unsafe { + ffi::memory_tracker_push_retainer( + self.inner.as_mut(), + address, + type_tag, + retainer.memory_name().as_ptr(), + retainer.memory_self_size(), + name_ptr(edge_name), + ) + }; + if pushed { + let mut node = PushedNode { tracker: self }; + retainer.memory_info(&mut node); + } + } + + /// Adds a node of `size` bytes named `name` for a container, with an edge to it from the + /// object being tracked, and has `elements` report the container's elements to it, with + /// `None` as their edge names. + pub fn track_container( + &mut self, + edge_name: Option<&'static CStr>, + name: &'static CStr, + size: usize, + elements: impl FnOnce(&mut Self), + ) { + // SAFETY: the names are literals or null. + unsafe { + ffi::memory_tracker_push_container( + self.inner.as_mut(), + name.as_ptr(), + size, + name_ptr(edge_name), + ); + } + let mut node = PushedNode { tracker: self }; + elements(&mut node); + } + + /// Reports a Rust resource's Wrappable, as C++ reports a `jsg::Ref` field. + pub(crate) fn track_wrappable( + &mut self, + edge_name: Option<&'static CStr>, + wrappable: &ffi::Wrappable, + ) { + // SAFETY: the name is a literal or null. + unsafe { + ffi::memory_tracker_track_wrappable( + self.inner.as_mut(), + name_ptr(edge_name), + wrappable, + ); + } + } + + /// Whether a persistent handle of the isolate of `state` can be reported: it belongs to the + /// snapshot's isolate, which is not being disposed. Debug builds assert that it belongs to + /// the snapshot's isolate, since a handle belongs in objects of its own isolate. + pub(crate) fn can_track_handle_of(&self, state: &IsolateState) -> bool { + let own = state.belongs_to(self.isolate); + debug_assert!( + own, + "a heap snapshot found a Global or TracedGlobal of another isolate" + ); + own && !state.is_closed() + } + + /// Adds an edge to the value of `global`, unless it is empty. + /// + /// # Safety + /// + /// `global` is a handle of the tracker's isolate, or empty; see + /// [`can_track_handle_of`](Self::can_track_handle_of). + pub(crate) unsafe fn track_global( + &mut self, + edge_name: Option<&'static CStr>, + global: &ffi::Global, + ) { + // SAFETY: the name is a literal or null; the caller guarantees the handle's isolate. + unsafe { + ffi::memory_tracker_track_global(self.inner.as_mut(), name_ptr(edge_name), global); + } + } +} + +/// How a value is reported to a heap snapshot when it is a field of a tracked object. +/// +/// It is the counterpart of the overloads of C++ `MemoryTracker::trackField`. +/// [`MemoryTracker::track_field`] takes any type that implements it. A type that is a node of its +/// own implements [`MemoryRetainer`] and gets this impl from +/// [`jsg_memory_retainer!`](crate::jsg_memory_retainer). +/// +/// # Rules for reporting code +/// +/// The code that reports a value, [`VisitForMemoryInfo::visit_for_memory_info`], +/// [`MemoryRetainer::memory_info`] and every impl of this trait: +/// +/// - must not panic: the tracker is called from C++, and a panic there aborts the process, as an +/// exception thrown while C++ builds the graph does; +/// - must not write into anything it can reach, a `Cell` above all: the impl for `Cell` +/// reports the `T` in place, through `Cell::as_ptr`, as `Traced` traces it, and a `Cell::set` +/// or `Cell::replace` while it does would drop the `T` under it. Nothing checks this rule; +/// - must not call into JavaScript, and should allocate nothing. +/// +/// A type that is part of a cycle through `std::rc::Rc` or `std::sync::Arc` must be a +/// [`MemoryRetainer`]: a retainer reached again is only an edge to its node, which ends the +/// cycle, while any other value is reported again each time it is reached, without end. +/// +/// # Built-in implementations +/// +/// - **Primitives** (`bool`, `char`, integers, floats, `()`, `SystemTime`, `Duration`, `&str`) +/// report nothing: they are part of the size of the object that holds them. +/// - **`String`** and **`Box`** are a node of the memory they allocate, and +/// **`v8::BackingStore`** one of its byte length. +/// - **`Option`**, **`Nullable`**, **`NullOrUndefined`**, **`Lenient`**, +/// **`NonCoercible`**, **`Cell`**, **`RefCell`**, **`std::rc::Rc`**, +/// **`std::sync::Arc`** and **`[T; N]`** report what they hold, if anything. A mutably +/// borrowed `RefCell` reports nothing. `Cell` reports the `T` in place; see the rules for +/// reporting code above. +/// - **`Box`** reports a retainer as its node, plain data as a node of its size, and anything +/// else as a node of the bytes that what it holds leaves uncounted, which what it holds +/// reports to. +/// - **Collections** (`Vec`, `Box<[T]>`, `VecDeque`, `Dict`, `HashMap`, +/// `BTreeMap`, `HashSet`, `BTreeSet`) are a node of the memory they allocate, less +/// the bytes their elements' nodes count, which their elements report to; maps report their +/// entries under the edges `key` and `value`. A collection of plain data (see +/// [`Self::PLAIN_DATA`]) is one node, as C++ reports a `kj::Array` of numbers. An empty +/// collection reports nothing. A size counts the elements' slots, and leaves out bookkeeping +/// such as a hash table's control bytes or a B-tree's nodes, so it is a low estimate. +/// - **`jsg::Rc`** reports the resource's node, as C++ reports a `jsg::Ref`. +/// - **`jsg::Weak`** reports nothing. +/// - **`v8::Global`** and **`v8::TracedGlobal`** are an edge to the value in the V8 heap, +/// as C++ reports a `jsg::V8Ref`. +pub trait MemoryInfo { + /// Whether a value of this type retains no memory beyond its own bytes, so that a + /// collection of such values is reported as one node of the collection's size. + const PLAIN_DATA: bool = false; + + /// How many of the bytes of `self` itself, out of its `size_of_val`, the nodes that `self` + /// reports count, so that the collection or `Box` that holds `self` leaves them to those + /// nodes. A [`MemoryRetainer`], whose node counts its own bytes, returns them; a wrapper that + /// holds its value inline, such as `Option` or `Cell`, returns what the value returns; a + /// value that holds what it reports out of line, such as a `Box` or `std::rc::Rc`, returns + /// 0, the default. It follows the [rules for reporting code](Self#rules-for-reporting-code). + fn self_bytes_reported(&self) -> usize { + 0 + } + + /// Reports `self` to `tracker`, under the edge `edge_name` from the object being tracked; + /// `None` is the empty name of a collection's element. It follows the + /// [rules for reporting code](Self#rules-for-reporting-code). + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>); +} + +/// A type that is a node of its own in a heap snapshot. +/// +/// It is the Rust counterpart of a C++ type with `JSG_MEMORY_INFO`. A retainer is reported with +/// [`MemoryTracker::track_retainer`]; [`jsg_memory_retainer!`](crate::jsg_memory_retainer) +/// implements [`MemoryInfo`] that way, so that [`MemoryTracker::track_field`] takes the type. +/// +/// ```ignore +/// struct Cache { +/// entries: HashMap, +/// } +/// +/// impl jsg::MemoryRetainer for Cache { +/// fn memory_name(&self) -> &'static CStr { +/// c"Cache" +/// } +/// +/// fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { +/// tracker.track_field(c"entries", &self.entries); +/// } +/// } +/// +/// jsg::jsg_memory_retainer!(Cache); +/// ``` +pub trait MemoryRetainer { + /// The name of the node, which the snapshot shows as `workerd / `. + fn memory_name(&self) -> &'static CStr; + + /// The size of the node: the value's own bytes, without what it allocates. + fn memory_self_size(&self) -> usize { + std::mem::size_of_val(self) + } + + /// Reports the fields that retain memory, with [`MemoryTracker::track_field`]. It follows the + /// [rules for reporting code](MemoryInfo#rules-for-reporting-code): it must not panic, must + /// not write into anything it can reach, a `Cell` above all, and must not call into + /// JavaScript. + fn memory_info(&self, tracker: &mut MemoryTracker<'_>); +} + +/// Implements [`MemoryInfo`] for [`MemoryRetainer`] types, so that each value is a node of its +/// own: `jsg::jsg_memory_retainer!(Cache, Entry);`. +/// +/// A retainer is `'static`, since its type tells it from a retainer of another type at the same +/// address. +/// +/// A generic type writes the same impl itself: `track_as_field` calls +/// [`MemoryTracker::track_retainer`], and +/// [`self_bytes_reported`](MemoryInfo::self_bytes_reported) returns the bytes of `self` that its +/// node counts, the lesser of `size_of_val(self)` and `memory_self_size()`. +#[macro_export] +macro_rules! jsg_memory_retainer { + ($($t:ty),* $(,)?) => { + $(impl $crate::MemoryInfo for $t { + fn self_bytes_reported(&self) -> usize { + ::std::cmp::min( + ::std::mem::size_of_val(self), + $crate::MemoryRetainer::memory_self_size(self), + ) + } + + fn track_as_field( + &self, + tracker: &mut $crate::MemoryTracker<'_>, + edge_name: ::std::option::Option<&'static ::std::ffi::CStr>, + ) { + tracker.track_retainer(edge_name, self); + } + })* + }; +} + +/// The fields that a resource reports to heap snapshots. +/// +/// It is the Rust counterpart of C++ `visitForMemoryInfo`. `#[jsg_resource(memory_info)]` on the +/// resource's struct makes its node call this; without it a resource reports no fields. +/// +/// ```ignore +/// #[jsg_resource(memory_info)] +/// struct Foo { +/// name: String, +/// child: Option>, +/// } +/// +/// impl jsg::VisitForMemoryInfo for Foo { +/// fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { +/// tracker.track_field(c"name", &self.name); +/// tracker.track_field(c"child", &self.child); +/// } +/// } +/// ``` +pub trait VisitForMemoryInfo { + /// Reports the fields that retain memory, with [`MemoryTracker::track_field`]. It follows the + /// [rules for reporting code](MemoryInfo#rules-for-reporting-code): it must not panic, must + /// not write into anything it can reach, a `Cell` above all, and must not call into + /// JavaScript. + fn visit_for_memory_info(&self, tracker: &mut MemoryTracker<'_>); +} + +// ============================================================================= +// Built-in implementations +// ============================================================================= + +/// Generates `MemoryInfo` impls for plain data, which reports nothing. +macro_rules! impl_memory_info_plain { + ($($t:ty),* $(,)?) => { + $(impl MemoryInfo for $t { + const PLAIN_DATA: bool = true; + + fn track_as_field(&self, _: &mut MemoryTracker<'_>, _: Option<&'static CStr>) {} + })* + }; +} + +impl_memory_info_plain!( + (), + bool, + char, + u8, + u16, + u32, + u64, + u128, + usize, + i8, + i16, + i32, + i64, + i128, + isize, + f32, + f64, + std::time::SystemTime, + std::time::Duration, + &str, +); + +impl MemoryInfo for String { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + tracker.track_field_with_size(edge_name, self.capacity(), Some(c"String")); + } +} + +impl MemoryInfo for Box { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + tracker.track_field_with_size(edge_name, self.len(), Some(c"Box")); + } +} + +impl MemoryInfo for crate::v8::BackingStore { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + tracker.track_field_with_size(edge_name, self.byte_length(), Some(c"BackingStore")); + } +} + +impl MemoryInfo for Option { + const PLAIN_DATA: bool = T::PLAIN_DATA; + + fn self_bytes_reported(&self) -> usize { + self.as_ref().map_or(0, T::self_bytes_reported) + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + if let Some(inner) = self { + inner.track_as_field(tracker, edge_name); + } + } +} + +impl MemoryInfo for Nullable { + const PLAIN_DATA: bool = T::PLAIN_DATA; + + fn self_bytes_reported(&self) -> usize { + match self { + Self::Some(inner) => inner.self_bytes_reported(), + Self::Null => 0, + } + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + if let Self::Some(inner) = self { + inner.track_as_field(tracker, edge_name); + } + } +} + +impl MemoryInfo for NullOrUndefined { + const PLAIN_DATA: bool = T::PLAIN_DATA; + + fn self_bytes_reported(&self) -> usize { + match self { + Self::Some(inner) => inner.self_bytes_reported(), + Self::Null | Self::Undefined => 0, + } + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + if let Self::Some(inner) = self { + inner.track_as_field(tracker, edge_name); + } + } +} + +impl MemoryInfo for Lenient { + fn self_bytes_reported(&self) -> usize { + match self { + Self::Some(inner) => inner.self_bytes_reported(), + Self::Unconvertable(inner) => inner.self_bytes_reported(), + Self::Null | Self::Undefined => 0, + } + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + match self { + Self::Some(inner) => inner.track_as_field(tracker, edge_name), + Self::Unconvertable(inner) => inner.track_as_field(tracker, edge_name), + Self::Null | Self::Undefined => {} + } + } +} + +impl MemoryInfo for NonCoercible { + const PLAIN_DATA: bool = T::PLAIN_DATA; + + fn self_bytes_reported(&self) -> usize { + self.as_ref().self_bytes_reported() + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + self.as_ref().track_as_field(tracker, edge_name); + } +} + +impl MemoryInfo for std::cell::Cell { + const PLAIN_DATA: bool = T::PLAIN_DATA; + + fn self_bytes_reported(&self) -> usize { + // SAFETY: as in `track_as_field`. + unsafe { (*self.as_ptr()).self_bytes_reported() } + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + // SAFETY: a heap snapshot runs on the thread holding the isolate lock, and reporting code + // writes into nothing it can reach and runs no JavaScript (see the rules for reporting + // code on `MemoryInfo`), so nothing writes into the cell while the reference lives. + // Nothing enforces that rule, as nothing does for `Traced` on `Cell`, which reads in + // place in the same way. Reading in place gives a retainer the cell's own address. + unsafe { (*self.as_ptr()).track_as_field(tracker, edge_name) }; + } +} + +impl MemoryInfo for std::cell::RefCell { + const PLAIN_DATA: bool = T::PLAIN_DATA; + + fn self_bytes_reported(&self) -> usize { + self.try_borrow() + .map_or(0, |inner| inner.self_bytes_reported()) + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + // A heap snapshot can start while code holds a mutable borrow, if that code takes one. + // The contents cannot be read then. + if let Ok(inner) = self.try_borrow() { + inner.track_as_field(tracker, edge_name); + } + } +} + +impl MemoryInfo for std::rc::Rc { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + (**self).track_as_field(tracker, edge_name); + } +} + +impl MemoryInfo for std::sync::Arc { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + (**self).track_as_field(tracker, edge_name); + } +} + +impl MemoryInfo for Box { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let inner: &T = self; + if T::PLAIN_DATA { + tracker.track_field_with_size(edge_name, size_of::(), Some(c"Box")); + return; + } + let uncounted = size_of::().saturating_sub(inner.self_bytes_reported()); + if uncounted == 0 { + // A retainer's node counts the whole allocation. + inner.track_as_field(tracker, edge_name); + } else { + tracker.track_container(edge_name, c"Box", uncounted, |tracker| { + inner.track_as_field(tracker, None); + }); + } + } +} + +impl MemoryInfo for [T; N] { + const PLAIN_DATA: bool = T::PLAIN_DATA; + + fn self_bytes_reported(&self) -> usize { + self.iter().map(T::self_bytes_reported).sum() + } + + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + // The elements are inline, in the object being tracked, so each is a field of it. + for element in self { + element.track_as_field(tracker, edge_name); + } + } +} + +/// Reports a collection of `len` elements of type `T` in an allocation of `size` bytes. +fn track_collection<'e, T: MemoryInfo + 'e>( + tracker: &mut MemoryTracker<'_>, + edge_name: Option<&'static CStr>, + name: &'static CStr, + size: usize, + len: usize, + elements: impl IntoIterator + Clone, +) { + if len == 0 { + return; + } + if T::PLAIN_DATA { + tracker.track_field_with_size(edge_name, size, Some(name)); + return; + } + // The elements' nodes count some of the elements' bytes, such as a retainer's. + let reported: usize = elements + .clone() + .into_iter() + .map(T::self_bytes_reported) + .sum(); + tracker.track_container(edge_name, name, size.saturating_sub(reported), |tracker| { + for element in elements { + element.track_as_field(tracker, None); + } + }); +} + +/// Reports a map of `len` entries of types `K` and `V` in an allocation of `size` bytes. +fn track_map<'e, K: MemoryInfo + 'e, V: MemoryInfo + 'e>( + tracker: &mut MemoryTracker<'_>, + edge_name: Option<&'static CStr>, + name: &'static CStr, + size: usize, + len: usize, + entries: impl IntoIterator + Clone, +) { + if len == 0 { + return; + } + if K::PLAIN_DATA && V::PLAIN_DATA { + tracker.track_field_with_size(edge_name, size, Some(name)); + return; + } + let reported: usize = entries + .clone() + .into_iter() + .map(|(key, value)| key.self_bytes_reported() + value.self_bytes_reported()) + .sum(); + tracker.track_container(edge_name, name, size.saturating_sub(reported), |tracker| { + for (key, value) in entries { + key.track_as_field(tracker, Some(c"key")); + value.track_as_field(tracker, Some(c"value")); + } + }); +} + +impl MemoryInfo for Vec { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let size = self.capacity() * size_of::(); + track_collection(tracker, edge_name, c"Vec", size, self.len(), self); + } +} + +impl MemoryInfo for Box<[T]> { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let size = self.len() * size_of::(); + track_collection( + tracker, + edge_name, + c"Box<[T]>", + size, + self.len(), + self.iter(), + ); + } +} + +impl MemoryInfo for std::collections::VecDeque { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let size = self.capacity() * size_of::(); + track_collection(tracker, edge_name, c"VecDeque", size, self.len(), self); + } +} + +impl MemoryInfo for Dict { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let entries = self.entries(); + if entries.is_empty() { + return; + } + let size = entries.capacity() * size_of::<(String, V)>(); + if V::PLAIN_DATA { + // The keys are the only other memory, so the whole is one node. + let keys: usize = entries.iter().map(|(key, _)| key.capacity()).sum(); + tracker.track_field_with_size(edge_name, size + keys, Some(c"Dict")); + return; + } + let reported: usize = entries + .iter() + .map(|(_, value)| value.self_bytes_reported()) + .sum(); + let size = size.saturating_sub(reported); + tracker.track_container(edge_name, c"Dict", size, |tracker| { + for (key, value) in entries { + key.track_as_field(tracker, Some(c"key")); + value.track_as_field(tracker, Some(c"value")); + } + }); + } +} + +impl MemoryInfo + for std::collections::HashMap +{ + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let size = self.capacity() * size_of::<(K, V)>(); + track_map(tracker, edge_name, c"HashMap", size, self.len(), self); + } +} + +impl MemoryInfo for std::collections::BTreeMap { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let size = self.len() * size_of::<(K, V)>(); + track_map(tracker, edge_name, c"BTreeMap", size, self.len(), self); + } +} + +impl MemoryInfo for std::collections::HashSet { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let size = self.capacity() * size_of::(); + track_collection(tracker, edge_name, c"HashSet", size, self.len(), self); + } +} + +impl MemoryInfo for std::collections::BTreeSet { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + let size = self.len() * size_of::(); + track_collection(tracker, edge_name, c"BTreeSet", size, self.len(), self); + } +} diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index 82e3e19d1dd..7aa2af74a3c 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -10,6 +10,7 @@ use std::any::TypeId; use std::cell::Cell; use std::collections::HashMap; +use std::ffi::CStr; use std::fmt; use std::ops::Deref; use std::ptr::NonNull; @@ -23,6 +24,8 @@ use crate::FromJS; use crate::GarbageCollected; use crate::Lock; use crate::Member; +use crate::MemoryInfo; +use crate::MemoryTracker; use crate::ToJS; use crate::Traced; use crate::Type; @@ -365,6 +368,18 @@ impl Traced for Weak { } } +/// The resource's node, as C++ reports a `jsg::Ref`. +impl MemoryInfo for Rc { + fn track_as_field(&self, tracker: &mut MemoryTracker<'_>, edge_name: Option<&'static CStr>) { + self.wrappable.track(tracker, edge_name); + } +} + +/// A weak reference retains nothing. +impl MemoryInfo for Weak { + fn track_as_field(&self, _: &mut MemoryTracker<'_>, _: Option<&'static CStr>) {} +} + impl GarbageCollected for Weak { fn memory_name(&self) -> &'static std::ffi::CStr { // jsgGetMemoryName is only called on live Wrappables, never on Weak. diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 9f1e2f790ef..c782e0b4907 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -155,6 +155,17 @@ pub mod ffi { /// into a `'static` string literal. The C++ side constructs a `kj::StringPtr` /// directly from `.data()` / `.size()` — no allocation needed. unsafe fn wrappable_invoke_get_name(wrappable: &Wrappable) -> &'static str; + + /// Called from C++ `Wrappable::jsgGetMemorySelfSize`: the size of the Rust resource, + /// which C++ adds to the Wrappable's own. + unsafe fn wrappable_invoke_get_memory_self_size(wrappable: &Wrappable) -> usize; + + /// Called from C++ `Wrappable::jsgGetMemoryInfo` while a heap snapshot is taken, to + /// report the resource's fields to `tracker`. + unsafe fn wrappable_invoke_get_memory_info( + wrappable: &Wrappable, + tracker: Pin<&mut MemoryTracker>, + ); } unsafe extern "C++" { @@ -167,6 +178,9 @@ pub mod ffi { type PropertySetterCallbackInfo; type PropertyQueryCallbackInfo; type Wrappable; + /// C++ `jsg::MemoryTracker`, which builds the graph of embedder objects in a heap + /// snapshot. + type MemoryTracker; // BackingStore pub unsafe fn backing_store_drop(store: usize); @@ -588,6 +602,46 @@ pub mod ffi { /// points to the `ptr` field of the `ffi::Global`. pub unsafe fn global_clear_weak(global: *mut usize); + // MemoryTracker + // + // Every name is a NUL-terminated string literal, or null: V8 reads the names after the + // heap snapshot's BuildEmbedderGraph callback returns. A null edge name is the empty + // name of a container's element, and a null node name is none. + pub unsafe fn memory_tracker_isolate(tracker: Pin<&mut MemoryTracker>) -> *mut Isolate; + pub unsafe fn memory_tracker_track_field_with_size( + tracker: Pin<&mut MemoryTracker>, + edge_name: *const c_char, + size: usize, + node_name: *const c_char, + ); + pub unsafe fn memory_tracker_track_wrappable( + tracker: Pin<&mut MemoryTracker>, + edge_name: *const c_char, + wrappable: &Wrappable, + ); + /// `global` is a handle of the tracker's isolate, or empty. + pub unsafe fn memory_tracker_track_global( + tracker: Pin<&mut MemoryTracker>, + edge_name: *const c_char, + global: &Global, + ); + /// `address` and `type_tag` identify the retainer; an `address` of 0 identifies nothing. + pub unsafe fn memory_tracker_push_retainer( + tracker: Pin<&mut MemoryTracker>, + address: usize, + type_tag: usize, + name: *const c_char, + size: usize, + edge_name: *const c_char, + ) -> bool; + pub unsafe fn memory_tracker_push_container( + tracker: Pin<&mut MemoryTracker>, + name: *const c_char, + size: usize, + edge_name: *const c_char, + ); + pub unsafe fn memory_tracker_pop_node(tracker: Pin<&mut MemoryTracker>); + // Unwrappers // // Shims that can throw a C++ exception (building rust::String/Vec from V8 @@ -3950,6 +4004,23 @@ impl crate::Traced for TracedGlobal { } } +/// An edge to the value in the V8 heap, as C++ reports a `jsg::V8Ref`. +impl crate::MemoryInfo for TracedGlobal { + fn track_as_field( + &self, + tracker: &mut crate::MemoryTracker<'_>, + edge_name: Option<&'static std::ffi::CStr>, + ) { + if !tracker.can_track_handle_of(&self.state) { + return; + } + // SAFETY: the handle belongs to the snapshot's isolate, as checked above, whose lock the + // snapshot holds, so reading the slot races with no GC. The handle is empty if it was + // weak and its value died. + unsafe { tracker.track_global(edge_name, &self.strong_handle()) }; + } +} + // Note: TracedGlobal intentionally does NOT implement the std Clone trait. // Cloning a V8 persistent handle requires an isolate pointer to create an // independent handle via v8::Global::New(isolate, original). The Clone trait @@ -4096,6 +4167,21 @@ impl Drop for Global { } } +/// An edge to the value in the V8 heap, as C++ reports a strong `v8::Global`. +impl crate::MemoryInfo for Global { + fn track_as_field( + &self, + tracker: &mut crate::MemoryTracker<'_>, + edge_name: Option<&'static std::ffi::CStr>, + ) { + if !tracker.can_track_handle_of(&self.state) { + return; + } + // SAFETY: the handle belongs to the snapshot's isolate, as checked above. + unsafe { tracker.track_global(edge_name, &self.handle) }; + } +} + /// Moves the handle of a `Global` into the slot of a new `TracedGlobal`, which GC may visit. impl From> for TracedGlobal { fn from(global: Global) -> Self { @@ -4545,6 +4631,28 @@ unsafe fn wrappable_invoke_get_name(wrappable: &ffi::Wrappable) -> &'static str } } +unsafe fn wrappable_invoke_get_memory_self_size(wrappable: &ffi::Wrappable) -> usize { + // SAFETY: wrappable is valid, and a trait object that is not cleared is alive. + unsafe { + ffi::TraitObjectPtr::from_wrappable(wrappable) + .map_or(0, |trait_ptr| trait_ptr.as_gc_ref().memory_self_size()) + } +} + +unsafe fn wrappable_invoke_get_memory_info( + wrappable: &ffi::Wrappable, + tracker: Pin<&mut ffi::MemoryTracker>, +) { + // SAFETY: wrappable is valid, and a trait object that is not cleared is alive. + unsafe { + let Some(trait_ptr) = ffi::TraitObjectPtr::from_wrappable(wrappable) else { + return; + }; + let mut tracker = crate::MemoryTracker::from_ffi(tracker); + trait_ptr.as_gc_ref().memory_info(&mut tracker); + } +} + /// Visitor for garbage collection tracing. /// /// `GcVisitor` wraps a C++ `jsg::GcVisitor` pointer. All GC visitation logic @@ -4948,6 +5056,16 @@ impl WrappableRc { unsafe { NonNull::new_unchecked(self.handle.get().cast_mut()) } } + /// Reports the Wrappable to a heap snapshot, as C++ reports a `jsg::Ref`. + pub(crate) fn track( + &self, + tracker: &mut crate::MemoryTracker<'_>, + edge_name: Option<&'static std::ffi::CStr>, + ) { + // SAFETY: the KjRc keeps the Wrappable alive. + tracker.track_wrappable(edge_name, unsafe { &*self.handle.get() }); + } + /// Increments the strong reference count on the underlying Wrappable. /// /// Called when a new `Rc` is created (clone, unwrap) to inform the GC diff --git a/src/workerd/jsg/AGENTS.md b/src/workerd/jsg/AGENTS.md index abb378b48e8..e363be72000 100644 --- a/src/workerd/jsg/AGENTS.md +++ b/src/workerd/jsg/AGENTS.md @@ -21,7 +21,7 @@ Macro-driven C++/V8 binding layer: declares C++ types as JS-visible resources/st | `setup.h` | `V8System`, `IsolateBase`, `JsgConfig`; process-level V8 init; `JSG_DECLARE_ISOLATE_TYPE` | | `sandbox.{h,c++}` | Cage-relative byte views for ArrayBuffer-backed native objects | | `function.h` | `jsg::Function` wrapping C++ callables ↔ JS functions | -| `memory.h` | `MemoryTracker`, `JSG_MEMORY_INFO` macro; heap snapshot support; `pushRetainer` (deduplicated by address and type)/`pushContainer`/`popNode` track objects that are not C++ types (Rust JSG's `jsg::MemoryTracker`) | +| `memory.h` | `MemoryTracker`, `JSG_MEMORY_INFO` macro; heap snapshot support; `pushRetainer`/`pushContainer`/`popNode` for non-C++ objects (Rust JSG) | | `rtti.capnp` | Cap'n Proto schema for type introspection; consumed by `types/` for TS generation | | `rtti.h` | C++ RTTI builder: walks JSG type graph → `rtti.capnp` structures | | `jsvalue.h` | `JsValue`, `JsObject`, `JsString`, etc. — typed wrappers over `v8::Value` | From 95810864ddab28bcb0a40d6847b97f709d51ae27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 2 Nov 2026 12:07:04 +0100 Subject: [PATCH 3/3] Test Rust JSG heap snapshots 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. --- src/rust/jsg-test/BUILD.bazel | 1 + src/rust/jsg-test/cpp-interop.h | 7 + src/rust/jsg-test/cpp_interop.rs | 9 +- src/rust/jsg-test/ffi.c++ | 20 + src/rust/jsg-test/ffi.h | 6 + src/rust/jsg-test/lib.rs | 26 + src/rust/jsg-test/tests/cpp_interop.rs | 40 + src/rust/jsg-test/tests/memory_info.rs | 1000 ++++++++++++++++++++++++ src/rust/jsg-test/tests/mod.rs | 1 + 9 files changed, 1109 insertions(+), 1 deletion(-) create mode 100644 src/rust/jsg-test/tests/memory_info.rs diff --git a/src/rust/jsg-test/BUILD.bazel b/src/rust/jsg-test/BUILD.bazel index d6b89587162..69bb3dceff0 100644 --- a/src/rust/jsg-test/BUILD.bazel +++ b/src/rust/jsg-test/BUILD.bazel @@ -57,5 +57,6 @@ wd_rust_crate( "//src/rust/jsg", "//src/rust/jsg-cpp", "@crates_vendor//:capnp", + "@crates_vendor//:serde_json", ], ) diff --git a/src/rust/jsg-test/cpp-interop.h b/src/rust/jsg-test/cpp-interop.h index e6bc00f051c..016ca98ea63 100644 --- a/src/rust/jsg-test/cpp-interop.h +++ b/src/rust/jsg-test/cpp-interop.h @@ -101,6 +101,13 @@ class TestCppHolder final: public jsg::Object { void visitForGc(jsg::GcVisitor& visitor) { visitor.visit(held); } + + public: + void visitForMemoryInfo(jsg::MemoryTracker& tracker) const { + KJ_IF_SOME(host, held) { + host.trackField(tracker, "held"_kjc); + } + } }; // How many TestCppHolders have been destroyed. diff --git a/src/rust/jsg-test/cpp_interop.rs b/src/rust/jsg-test/cpp_interop.rs index 52b25fd51b9..a6407bba42f 100644 --- a/src/rust/jsg-test/cpp_interop.rs +++ b/src/rust/jsg-test/cpp_interop.rs @@ -234,7 +234,7 @@ thread_local! { } /// A Rust resource that uses C++ resources, as a ported `ExecutionContext` would. -#[jsg_resource] +#[jsg_resource(memory_info)] pub struct Host { pub label: String, /// A C++ resource, traced as a `jsg::Ref` field of a C++ resource is. @@ -247,6 +247,13 @@ impl Drop for Host { } } +impl jsg::VisitForMemoryInfo for Host { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"label", &self.label); + tracker.track_field(c"held", &self.held); + } +} + #[jsg_resource] impl Host { /// Takes a C++ resource and calls one of its methods, as `mapVirtualHost(fetcher, port)` does. diff --git a/src/rust/jsg-test/ffi.c++ b/src/rust/jsg-test/ffi.c++ index 3e132c64fb0..c6977c00478 100644 --- a/src/rust/jsg-test/ffi.c++ +++ b/src/rust/jsg-test/ffi.c++ @@ -266,6 +266,26 @@ size_t used_global_handles_size(Isolate* isolate) { return stats.used_global_handles_size(); } +::rust::String take_heap_snapshot(Isolate* isolate) { + kj::Vector serialized; + ::workerd::jsg::HeapSnapshotActivity activity([](auto, auto) { return true; }); + ::workerd::jsg::HeapSnapshotWriter writer([&](kj::Maybe> maybeChunk) { + KJ_IF_SOME(chunk, maybeChunk) { + serialized.addAll(chunk); + } + return true; + }); + ::workerd::jsg::HeapSnapshotDeleter deleter; + auto snapshot = kj::Own( + isolate->GetHeapProfiler()->TakeHeapSnapshot(&activity, nullptr, true, true), deleter); + snapshot->Serialize(&writer, v8::HeapSnapshot::kJSON); + return ::rust::String(serialized.begin(), serialized.size()); +} + +size_t rust_wrappable_size() { + return sizeof(::workerd::rust::jsg::Wrappable); +} + ::workerd::rust::jsg::Local create_cpp_tagged_object(Isolate* isolate) { auto tmpl = v8::ObjectTemplate::New(isolate); tmpl->SetInternalFieldCount(::workerd::jsg::Wrappable::INTERNAL_FIELD_COUNT); diff --git a/src/rust/jsg-test/ffi.h b/src/rust/jsg-test/ffi.h index 62c6ede1014..2594fc51a86 100644 --- a/src/rust/jsg-test/ffi.h +++ b/src/rust/jsg-test/ffi.h @@ -94,6 +94,12 @@ void run_unlocked(Isolate* isolate, size_t data, ::rust::Fn callba // v8::HeapStatistics::used_global_handles_size() of the isolate. size_t used_global_handles_size(Isolate* isolate); +// Takes a heap snapshot of the isolate and returns it serialized as JSON. +::rust::String take_heap_snapshot(Isolate* isolate); + +// sizeof(::workerd::rust::jsg::Wrappable). +size_t rust_wrappable_size(); + // Creates a V8 object with the C++ WORKERD_WRAPPABLE_TAG set in its internal fields. // Used to test that Rust unwrap correctly rejects non-Rust wrappable objects. ::workerd::rust::jsg::Local create_cpp_tagged_object(Isolate* isolate); diff --git a/src/rust/jsg-test/lib.rs b/src/rust/jsg-test/lib.rs index 75f9031dc32..0e68f5e29c7 100644 --- a/src/rust/jsg-test/lib.rs +++ b/src/rust/jsg-test/lib.rs @@ -120,6 +120,13 @@ mod ffi { #[allow(dead_code)] pub unsafe fn used_global_handles_size(isolate: *mut Isolate) -> usize; + /// Takes a heap snapshot of the isolate, which includes what `jsg::MemoryTracker` + /// reports, and returns it serialized as JSON. + pub unsafe fn take_heap_snapshot(isolate: *mut Isolate) -> Result; + + /// `sizeof` the C++ `Wrappable` of a Rust resource, which its heap snapshot node counts. + pub fn rust_wrappable_size() -> usize; + /// Creates a V8 object with the C++ `WORKERD_WRAPPABLE_TAG` in its internal fields. /// Used to test that Rust unwrap correctly rejects non-Rust wrappable objects. #[expect(clippy::allow_attributes)] @@ -599,6 +606,25 @@ impl Harness { unsafe { ffi::used_global_handles_size(lock.isolate().as_ffi()) } } + /// Takes a heap snapshot, as the inspector's `HeapProfiler.takeHeapSnapshot` does, and + /// returns it in V8's JSON format. + /// + /// # Panics + /// + /// If V8 fails to take the snapshot. + pub fn take_heap_snapshot(lock: &mut jsg::Lock<'_>) -> String { + // SAFETY: isolate is valid and locked (guaranteed by Lock). + unsafe { ffi::take_heap_snapshot(lock.isolate().as_ffi()) } + .expect("V8 takes a heap snapshot") + } + + /// The size of the C++ `Wrappable` of a Rust resource, which the resource's heap snapshot + /// node adds to the resource's own. + #[must_use] + pub fn rust_wrappable_size() -> usize { + ffi::rust_wrappable_size() + } + /// Creates a V8 object tagged with the C++ `WORKERD_WRAPPABLE_TAG`. /// Used to test that Rust unwrap rejects non-Rust wrappable objects. pub fn create_cpp_tagged_object<'s>(lock: &mut jsg::Lock<'s>) -> v8::Local<'s, v8::Value> { diff --git a/src/rust/jsg-test/tests/cpp_interop.rs b/src/rust/jsg-test/tests/cpp_interop.rs index 4d52a95559e..75473997b57 100644 --- a/src/rust/jsg-test/tests/cpp_interop.rs +++ b/src/rust/jsg-test/tests/cpp_interop.rs @@ -294,3 +294,43 @@ fn held_resources_survive_gc() { Ok(()) }); } + +#[test] +fn heap_snapshot_follows_a_held_cpp_resource() { + Harness::new().run_in_context(|lock, ctx| { + set_globals(lock, ctx, new_host()); + ctx.eval_raw(lock, "h.hold(holder)").unwrap(); + + let snapshot = crate::tests::memory_info::Snapshot::take(lock); + let host = snapshot.find("Host"); + let held = snapshot.target(host, "held"); + assert_eq!(snapshot.nodes[held].name, "workerd / TestCppHolder"); + // The C++ resource has a wrapper, so the snapshot also reaches it through the isolate's + // list of wrapped objects, and that is the same node. + assert_eq!(snapshot.find_all("TestCppHolder"), [held]); + Ok(()) + }); +} + +#[test] +fn heap_snapshot_follows_a_held_rust_resource() { + Harness::new().run_in_context(|lock, ctx| { + set_globals(lock, ctx, new_host()); + ctx.eval_raw(lock, "holder.hold(h)").unwrap(); + + let snapshot = crate::tests::memory_info::Snapshot::take(lock); + let holder = snapshot.find("TestCppHolder"); + let host = snapshot.find("Host"); + // C++ holds a Rust resource by its wrapper, so the edge is to the wrapper, which leads on + // to the resource's node. + let wrapper = snapshot.target(holder, "held"); + // V8 shows the wrapper as one node with the cppgc object it holds. + assert!(!snapshot.nodes[wrapper].name.starts_with("workerd /")); + assert!( + snapshot + .targets(wrapper, "javascript_to_native") + .contains(&host) + ); + Ok(()) + }); +} diff --git a/src/rust/jsg-test/tests/memory_info.rs b/src/rust/jsg-test/tests/memory_info.rs new file mode 100644 index 00000000000..5dcafa21cf7 --- /dev/null +++ b/src/rust/jsg-test/tests/memory_info.rs @@ -0,0 +1,1000 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! What Rust resources report to heap snapshots, through `jsg::MemoryTracker`. + +use std::cell::Cell; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::ffi::CStr; + +use jsg::FromJS; +use jsg::ToJS; +use jsg::v8; +use jsg_macros::jsg_resource; + +// ============================================================================= +// Snapshot parsing +// ============================================================================= + +/// A heap snapshot, parsed from V8's JSON format. +pub struct Snapshot { + pub nodes: Vec, +} + +pub struct Node { + pub kind: String, + pub name: String, + pub self_size: u64, + /// `(edge name, index of the node it points to)`. + pub edges: Vec<(String, usize)>, + /// The type of each edge, in the order of `edges`: `internal`, `element`, ... + pub edge_types: Vec, +} + +impl Snapshot { + pub fn take(lock: &mut jsg::Lock<'_>) -> Self { + Self::parse(&crate::Harness::take_heap_snapshot(lock)) + } + + fn parse(json: &str) -> Self { + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + let meta = &value["snapshot"]["meta"]; + let field_index = |fields: &serde_json::Value, name: &str| { + fields + .as_array() + .unwrap() + .iter() + .position(|field| field == name) + .unwrap() + }; + let node_fields = &meta["node_fields"]; + let edge_fields = &meta["edge_fields"]; + let node_field_count = node_fields.as_array().unwrap().len(); + let edge_field_count = edge_fields.as_array().unwrap().len(); + let node_type_field = field_index(node_fields, "type"); + let node_name_field = field_index(node_fields, "name"); + let node_size_field = field_index(node_fields, "self_size"); + let node_edge_count_field = field_index(node_fields, "edge_count"); + let edge_type_field = field_index(edge_fields, "type"); + let edge_name_field = field_index(edge_fields, "name_or_index"); + let edge_to_field = field_index(edge_fields, "to_node"); + let node_types = meta["node_types"][node_type_field].as_array().unwrap(); + let edge_types = meta["edge_types"][edge_type_field].as_array().unwrap(); + + let strings: Vec<&str> = value["strings"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap()) + .collect(); + let numbers = |key: &str| -> Vec { + value[key] + .as_array() + .unwrap() + .iter() + .map(|n| n.as_u64().unwrap()) + .collect() + }; + let raw_nodes = numbers("nodes"); + let raw_edges = numbers("edges"); + let to_usize = |n: u64| usize::try_from(n).unwrap(); + + let mut nodes = Vec::new(); + let mut edges = raw_edges.chunks(edge_field_count); + for node in raw_nodes.chunks(node_field_count) { + let edge_count = to_usize(node[node_edge_count_field]); + let mut node_edge_types = Vec::new(); + let node_edges = edges + .by_ref() + .take(edge_count) + .map(|edge| { + let edge_type = edge_types[to_usize(edge[edge_type_field])] + .as_str() + .unwrap(); + node_edge_types.push(edge_type.to_owned()); + let name_or_index = edge[edge_name_field]; + let name = if edge_type == "element" || edge_type == "hidden" { + name_or_index.to_string() + } else { + strings[to_usize(name_or_index)].to_owned() + }; + (name, to_usize(edge[edge_to_field]) / node_field_count) + }) + .collect(); + nodes.push(Node { + kind: node_types[to_usize(node[node_type_field])] + .as_str() + .unwrap() + .to_owned(), + name: strings[to_usize(node[node_name_field])].to_owned(), + self_size: node[node_size_field], + edges: node_edges, + edge_types: node_edge_types, + }); + } + Self { nodes } + } + + /// The indices of the nodes that Rust or C++ reported as `name`. + pub fn find_all(&self, name: &str) -> Vec { + let name = format!("workerd / {name}"); + (0..self.nodes.len()) + .filter(|&i| self.nodes[i].name == name) + .collect() + } + + /// The index of the one node reported as `name`. + pub fn find(&self, name: &str) -> usize { + let found = self.find_all(name); + assert_eq!(found.len(), 1, "expected one node named {name}"); + found[0] + } + + /// The nodes that the edges named `edge` of node `from` point to. + pub fn targets(&self, from: usize, edge: &str) -> Vec { + self.nodes[from] + .edges + .iter() + .filter(|(name, _)| name == edge) + .map(|&(_, to)| to) + .collect() + } + + /// The node that the one edge named `edge` of node `from` points to. + pub fn target(&self, from: usize, edge: &str) -> usize { + let targets = self.targets(from, edge); + assert_eq!( + targets.len(), + 1, + "expected one edge {edge} from {}", + self.nodes[from].name + ); + targets[0] + } + + pub fn edge_names(&self, from: usize) -> Vec<&str> { + self.nodes[from] + .edges + .iter() + .map(|(name, _)| name.as_str()) + .collect() + } + + /// The `(type, name)` of each edge of node `from`. + pub fn edges(&self, from: usize) -> Vec<(&str, &str)> { + let node = &self.nodes[from]; + node.edge_types + .iter() + .zip(&node.edges) + .map(|(edge_type, (name, _))| (edge_type.as_str(), name.as_str())) + .collect() + } + + /// The names of the nodes that node `from` has edges to, in order. + pub fn target_names(&self, from: usize) -> Vec<&str> { + self.nodes[from] + .edges + .iter() + .map(|&(_, to)| self.nodes[to].name.as_str()) + .collect() + } + + fn name(&self, node: usize) -> &str { + &self.nodes[node].name + } + + fn size(&self, node: usize) -> usize { + usize::try_from(self.nodes[node].self_size).unwrap() + } +} + +// ============================================================================= +// Resources +// ============================================================================= + +#[jsg_resource(memory_info)] +struct Child { + pub parent: RefCell>>, +} + +#[jsg_resource] +impl Child {} + +impl jsg::VisitForMemoryInfo for Child { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"parent", &self.parent); + } +} + +fn new_child() -> jsg::Rc { + jsg::Rc::new(Child { + parent: RefCell::new(None), + }) +} + +/// A helper that is a node of its own, like a C++ type with `JSG_MEMORY_INFO`. +struct Cache { + entries: HashMap, +} + +impl jsg::MemoryRetainer for Cache { + fn memory_name(&self) -> &'static CStr { + c"Cache" + } + + fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"entries", &self.entries); + } +} + +jsg::jsg_memory_retainer!(Cache); + +impl jsg::Traced for Cache {} + +fn new_cache() -> Cache { + Cache { + entries: HashMap::from([("key".to_owned(), "value".to_owned())]), + } +} + +#[jsg_resource(memory_info)] +struct Parent { + pub name: String, + pub child: jsg::Rc, + pub children: Vec>, + pub numbers: Vec, + pub cache: Cache, + pub object: v8::TracedGlobal, + pub missing: Option>, + pub unreported: String, +} + +#[jsg_resource] +impl Parent {} + +impl jsg::VisitForMemoryInfo for Parent { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"name", &self.name); + tracker.track_field(c"child", &self.child); + tracker.track_field(c"children", &self.children); + tracker.track_field(c"numbers", &self.numbers); + tracker.track_field(c"cache", &self.cache); + tracker.track_field(c"object", &self.object); + tracker.track_field(c"missing", &self.missing); + } +} + +/// A resource without `memory_info`, which reports no fields. +#[jsg_resource] +struct Silent { + pub name: String, +} + +#[jsg_resource] +impl Silent {} + +fn parent(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>) -> jsg::Rc { + parent_of(lock, ctx, new_child()) +} + +fn parent_of( + lock: &mut jsg::Lock<'_>, + ctx: &crate::EvalContext<'_>, + child: jsg::Rc, +) -> jsg::Rc { + let object = ctx.eval_raw(lock, "({ marker: 1 })").unwrap(); + jsg::Rc::new(Parent { + name: "a name".to_owned(), + child, + children: vec![new_child(), new_child()], + numbers: vec![1, 2, 3], + cache: new_cache(), + object: v8::TracedGlobal::new(lock, object), + missing: None, + unreported: "unreported".to_owned(), + }) +} + +/// Wraps `resource` and stores the wrapper as the global `name`, so that the snapshot reaches it +/// through the isolate's list of wrapped objects. +fn expose( + lock: &mut jsg::Lock<'_>, + ctx: &crate::EvalContext<'_>, + name: &str, + resource: &jsg::Rc, +) -> Result<(), jsg::Error> { + let wrapper = resource.clone().to_js(lock)?; + ctx.set_global(lock, name, wrapper); + Ok(()) +} + +// ============================================================================= +// Resource nodes +// ============================================================================= + +#[test] +fn resource_node_reports_its_fields() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = parent(lock, ctx); + expose(lock, ctx, "parent", &parent)?; + + let snapshot = Snapshot::take(lock); + let node = snapshot.find("Parent"); + + // The node is sized as the resource plus its Wrappable. + assert_eq!( + snapshot.size(node), + size_of::() + crate::Harness::rust_wrappable_size() + ); + + let name = snapshot.target(node, "name"); + assert_eq!(snapshot.name(name), "workerd / String"); + assert_eq!(snapshot.size(name), parent.name.capacity()); + + let child = snapshot.target(node, "child"); + assert_eq!(snapshot.name(child), "workerd / Child"); + + let children = snapshot.target(node, "children"); + assert_eq!(snapshot.name(children), "workerd / Vec"); + assert_eq!( + snapshot.target_names(children), + ["workerd / Child", "workerd / Child"] + ); + + // Plain data is one node of the allocation's size. + let numbers = snapshot.target(node, "numbers"); + assert_eq!(snapshot.name(numbers), "workerd / Vec"); + assert!(snapshot.nodes[numbers].edges.is_empty()); + assert_eq!( + snapshot.size(numbers), + parent.numbers.capacity() * size_of::() + ); + + let cache = snapshot.target(node, "cache"); + assert_eq!(snapshot.name(cache), "workerd / Cache"); + assert_eq!(snapshot.size(cache), size_of::()); + let entries = snapshot.target(cache, "entries"); + assert_eq!(snapshot.name(entries), "workerd / HashMap"); + assert_eq!( + snapshot.name(snapshot.target(entries, "key")), + "workerd / String" + ); + assert_eq!( + snapshot.name(snapshot.target(entries, "value")), + "workerd / String" + ); + + // A handle is an edge into the V8 heap. + let object = snapshot.target(node, "object"); + assert_eq!(snapshot.nodes[object].kind, "object"); + + // A field that holds nothing and a field that is not reported have no edge. + assert!(snapshot.targets(node, "missing").is_empty()); + assert!(snapshot.targets(node, "unreported").is_empty()); + + // The node is linked to its wrapper, as for a C++ resource. V8 shows the wrapper as one + // node with the cppgc object it holds. + let wrapper = snapshot.target(node, "native_to_javascript"); + assert!(!snapshot.name(wrapper).starts_with("workerd /")); + assert!( + snapshot + .targets(wrapper, "javascript_to_native") + .contains(&node) + ); + Ok(()) + }); +} + +#[test] +fn resource_without_memory_info_reports_no_fields() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let silent = jsg::Rc::new(Silent { + name: "a name".to_owned(), + }); + expose(lock, ctx, "silent", &silent)?; + + let snapshot = Snapshot::take(lock); + let node = snapshot.find("Silent"); + let mut edges = snapshot.edge_names(node); + edges.sort_unstable(); + assert_eq!(edges, ["cppgcshim", "native_to_javascript"]); + assert_eq!( + snapshot.size(node), + size_of::() + crate::Harness::rust_wrappable_size() + ); + Ok(()) + }); +} + +#[test] +fn shared_resource_is_one_node() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let shared = new_child(); + let first = parent_of(lock, ctx, shared.clone()); + let second = parent_of(lock, ctx, shared.clone()); + expose(lock, ctx, "first", &first)?; + expose(lock, ctx, "second", &second)?; + expose(lock, ctx, "shared", &shared)?; + + let snapshot = Snapshot::take(lock); + let parents = snapshot.find_all("Parent"); + assert_eq!(parents.len(), 2); + let first_child = snapshot.target(parents[0], "child"); + let second_child = snapshot.target(parents[1], "child"); + assert_eq!(first_child, second_child); + Ok(()) + }); +} + +#[test] +fn cycle_of_resources_is_tracked_once() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = parent(lock, ctx); + *parent.child.parent.borrow_mut() = Some(parent.clone()); + expose(lock, ctx, "parent", &parent)?; + + let snapshot = Snapshot::take(lock); + let node = snapshot.find("Parent"); + let child = snapshot.target(node, "child"); + assert_eq!(snapshot.name(child), "workerd / Child"); + assert_eq!(snapshot.target(child, "parent"), node); + + // Break the cycle, which GC cannot collect while Rust holds a strong reference. + parent.child.parent.borrow_mut().take(); + Ok(()) + }); +} + +#[test] +fn function_data_is_a_node() { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let function = v8::Function::new(lock, "data".to_owned(), |lock, data, _args| { + data.clone().to_js(lock) + })?; + ctx.set_global(lock, "f", v8::Local::::from(function)); + + let snapshot = Snapshot::take(lock); + let node = snapshot.find("WrappableFunction"); + assert!(snapshot.size(node) > crate::Harness::rust_wrappable_size()); + Ok(()) + }); +} + +// ============================================================================= +// Field types +// ============================================================================= + +/// A retainer with a retainer at its start, which shares its address. +#[repr(C)] +struct Outer { + inner: Inner, + label: String, +} + +struct Inner { + name: String, +} + +impl jsg::MemoryRetainer for Outer { + fn memory_name(&self) -> &'static CStr { + c"Outer" + } + + fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"inner", &self.inner); + tracker.track_field(c"label", &self.label); + } +} + +impl jsg::MemoryRetainer for Inner { + fn memory_name(&self) -> &'static CStr { + c"Inner" + } + + fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"name", &self.name); + } +} + +/// A retainer with no bytes, which has no address of its own. +struct Empty; + +impl jsg::MemoryRetainer for Empty { + fn memory_name(&self) -> &'static CStr { + c"Empty" + } + + fn memory_info(&self, _tracker: &mut jsg::MemoryTracker<'_>) {} +} + +/// A retainer in a cycle through `std::rc::Rc`, which only its deduplication ends. +struct Link { + name: String, + next: RefCell>>, +} + +impl jsg::MemoryRetainer for Link { + fn memory_name(&self) -> &'static CStr { + c"Link" + } + + fn memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"name", &self.name); + tracker.track_field(c"next", &self.next); + } +} + +jsg::jsg_memory_retainer!(Outer, Inner, Empty, Link); + +/// Two links, each the other's `next`. +fn new_ring() -> std::rc::Rc { + let first = std::rc::Rc::new(Link { + name: "first".to_owned(), + next: RefCell::new(None), + }); + let second = std::rc::Rc::new(Link { + name: "second".to_owned(), + next: RefCell::new(Some(first.clone())), + }); + *first.next.borrow_mut() = Some(second); + first +} + +fn new_inner(name: &str) -> Inner { + Inner { + name: name.to_owned(), + } +} + +/// Holds one field of every built-in kind that tests below look at. Some of them have no `Traced` +/// impl, so it traces its fields itself. +#[jsg_resource(memory_info, custom_trace)] +struct Fields { + pub outer: Outer, + pub empty_a: Empty, + pub empty_b: Empty, + pub cell_a: Cell, + pub cell_b: Cell, + pub inners: Vec, + pub optional_inners: Vec>, + pub boxed_inner: Box, + pub boxed_optional_inner: Box>, + pub shared_a: std::rc::Rc, + pub shared_b: std::rc::Rc, + pub ring: std::rc::Rc, + pub boxed_child: Box>>, + pub boxed_number: Box, + pub boxed_str: Box, + pub boxed_slice: Box<[jsg::Rc]>, + pub nested: Vec>>, + pub optional_numbers: Vec>, + pub deque: VecDeque, + pub array: [jsg::Rc; 2], + pub set: HashSet>, + pub tree: BTreeMap, + pub plain_dict: jsg::Dict, + pub dict: jsg::Dict>, + pub weak: jsg::Weak, + pub lenient: jsg::Lenient, + pub global: v8::Global, + pub store: v8::BackingStore, + pub borrowed: RefCell, +} + +#[jsg_resource] +impl Fields {} + +impl jsg::VisitForMemoryInfo for Fields { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"outer", &self.outer); + tracker.track_field(c"empty_a", &self.empty_a); + tracker.track_field(c"empty_b", &self.empty_b); + tracker.track_field(c"cell_a", &self.cell_a); + tracker.track_field(c"cell_b", &self.cell_b); + tracker.track_field(c"inners", &self.inners); + tracker.track_field(c"optional_inners", &self.optional_inners); + tracker.track_field(c"boxed_inner", &self.boxed_inner); + tracker.track_field(c"boxed_optional_inner", &self.boxed_optional_inner); + tracker.track_field(c"shared_a", &self.shared_a); + tracker.track_field(c"shared_b", &self.shared_b); + tracker.track_field(c"ring", &self.ring); + tracker.track_field(c"boxed_child", &self.boxed_child); + tracker.track_field(c"boxed_number", &self.boxed_number); + tracker.track_field(c"boxed_str", &self.boxed_str); + tracker.track_field(c"boxed_slice", &self.boxed_slice); + tracker.track_field(c"nested", &self.nested); + tracker.track_field(c"optional_numbers", &self.optional_numbers); + tracker.track_field(c"deque", &self.deque); + tracker.track_field(c"array", &self.array); + tracker.track_field(c"set", &self.set); + tracker.track_field(c"tree", &self.tree); + tracker.track_field(c"plain_dict", &self.plain_dict); + tracker.track_field(c"dict", &self.dict); + tracker.track_field(c"weak", &self.weak); + tracker.track_field(c"lenient", &self.lenient); + tracker.track_field(c"global", &self.global); + tracker.track_field(c"store", &self.store); + tracker.track_field(c"borrowed", &self.borrowed); + } +} + +impl jsg::Traced for Fields { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.boxed_child.trace(visitor); + for child in &*self.boxed_slice { + child.trace(visitor); + } + self.nested.trace(visitor); + for child in &self.array { + child.trace(visitor); + } + self.set.trace(visitor); + self.dict.trace(visitor); + self.lenient.trace(visitor); + // `global` is a strong handle, which GC never visits. + } +} + +/// Runs `check` on a snapshot of a `Fields` resource, with its node. +fn with_fields(check: impl FnOnce(&Snapshot, usize, &Fields)) { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let weak_target = new_child(); + let unconvertable = ctx.eval_raw(lock, "({ unconvertable: 1 })").unwrap(); + let global = ctx.eval_raw(lock, "({ global: 1 })").unwrap(); + let (c1, c2) = (new_child(), new_child()); + ctx.set_global(lock, "c1", c1); + ctx.set_global(lock, "c2", c2); + let dict = ctx.eval_raw(lock, "({ a: c1, b: c2 })").unwrap(); + let dict = jsg::Dict::>::from_js(lock, dict)?; + let plain_dict = ctx.eval_raw(lock, "({ a: 1, bb: 2 })").unwrap(); + let plain_dict = jsg::Dict::::from_js(lock, plain_dict)?; + let shared = std::rc::Rc::new(new_cache()); + let fields = jsg::Rc::new(Fields { + outer: Outer { + inner: new_inner("inner"), + label: "label".to_owned(), + }, + empty_a: Empty, + empty_b: Empty, + cell_a: Cell::new(new_inner("a")), + cell_b: Cell::new(new_inner("b")), + inners: { + let mut inners = Vec::with_capacity(5); + inners.extend([new_inner("x"), new_inner("y")]); + inners + }, + optional_inners: { + let mut inners = Vec::with_capacity(4); + inners.extend([Some(new_inner("some")), None]); + inners + }, + boxed_inner: Box::new(new_inner("boxed")), + boxed_optional_inner: Box::new(Some(new_inner("boxed some"))), + shared_a: shared.clone(), + shared_b: shared, + ring: new_ring(), + boxed_child: Box::new(Some(new_child())), + boxed_number: Box::new(7), + boxed_str: "boxed str".into(), + boxed_slice: vec![new_child(), new_child()].into_boxed_slice(), + nested: vec![vec![new_child()], vec![new_child(), new_child()]], + optional_numbers: vec![Some(1), None], + deque: VecDeque::from(["d".to_owned()]), + array: [new_child(), new_child()], + set: HashSet::from([new_child()]), + tree: BTreeMap::from([(1, "one".to_owned())]), + plain_dict, + dict, + weak: weak_target.downgrade(), + lenient: jsg::Lenient::Unconvertable(v8::TracedGlobal::new(lock, unconvertable)), + global: global.to_global(lock), + store: v8::ArrayBuffer::new(lock, &[1u8, 2, 3, 4]).backing_store(), + borrowed: RefCell::new("borrowed".to_owned()), + }); + expose(lock, ctx, "fields", &fields)?; + expose(lock, ctx, "weakTarget", &weak_target)?; + + // A snapshot taken while a cell is mutably borrowed skips the cell. Taking a snapshot runs + // a GC first, so the cell holds no GC references, which the GC could not visit. + let borrow = fields.borrowed.borrow_mut(); + let snapshot = Snapshot::take(lock); + drop(borrow); + let node = snapshot.find("Fields"); + check(&snapshot, node, &fields); + // Break the cycle of links, which would leak. + fields.ring.next.borrow_mut().take(); + Ok(()) + }); +} + +#[test] +fn retainer_and_its_first_field_are_two_nodes() { + with_fields(|snapshot, node, _| { + let outer = snapshot.target(node, "outer"); + let inner = snapshot.target(outer, "inner"); + assert_eq!(snapshot.name(outer), "workerd / Outer"); + assert_eq!(snapshot.name(inner), "workerd / Inner"); + assert_eq!( + snapshot.name(snapshot.target(inner, "name")), + "workerd / String" + ); + assert_eq!( + snapshot.name(snapshot.target(outer, "label")), + "workerd / String" + ); + }); +} + +#[test] +fn zero_sized_retainers_are_separate_nodes() { + with_fields(|snapshot, node, _| { + let a = snapshot.target(node, "empty_a"); + let b = snapshot.target(node, "empty_b"); + assert_eq!(snapshot.name(a), "workerd / Empty"); + assert_ne!(a, b); + }); +} + +#[test] +fn retainers_in_cells_are_separate_nodes() { + with_fields(|snapshot, node, _| { + let a = snapshot.target(node, "cell_a"); + let b = snapshot.target(node, "cell_b"); + assert_eq!(snapshot.name(a), "workerd / Inner"); + assert_ne!(a, b); + }); +} + +#[test] +fn collection_of_retainers_leaves_their_bytes_to_them() { + with_fields(|snapshot, node, fields| { + let inners = snapshot.target(node, "inners"); + // Only the spare capacity is the Vec's own. + assert!(fields.inners.capacity() > 2); + assert_eq!( + snapshot.size(inners), + (fields.inners.capacity() - 2) * size_of::() + ); + assert_eq!( + snapshot.target_names(inners), + ["workerd / Inner", "workerd / Inner"] + ); + for element in snapshot.targets(inners, "") { + assert_eq!(snapshot.size(element), size_of::()); + } + }); +} + +#[test] +fn collection_leaves_bytes_of_retainers_in_wrappers_to_them() { + with_fields(|snapshot, node, fields| { + let inners = snapshot.target(node, "optional_inners"); + // One element is a retainer, whose node counts its bytes; the `None` counts in the Vec. + assert_eq!( + snapshot.size(inners), + fields.optional_inners.capacity() * size_of::>() - size_of::() + ); + assert_eq!(snapshot.target_names(inners), ["workerd / Inner"]); + }); +} + +#[test] +fn boxed_retainer_in_wrapper_is_the_retainers_node() { + // `Option` takes its tag from a niche of `Inner`, so the retainer's node counts all + // of the allocation. + assert_eq!(size_of::>(), size_of::()); + with_fields(|snapshot, node, _| { + let inner = snapshot.target(node, "boxed_optional_inner"); + assert_eq!(snapshot.name(inner), "workerd / Inner"); + assert_eq!(snapshot.size(inner), size_of::()); + }); +} + +#[test] +fn retainer_shared_through_rc_is_one_node() { + with_fields(|snapshot, node, _| { + let a = snapshot.target(node, "shared_a"); + let b = snapshot.target(node, "shared_b"); + assert_eq!(snapshot.name(a), "workerd / Cache"); + assert_eq!(a, b); + assert_eq!(snapshot.find_all("Cache"), [a]); + }); +} + +#[test] +fn cycle_of_retainers_through_rc_is_tracked_once() { + with_fields(|snapshot, node, _| { + let first = snapshot.target(node, "ring"); + let second = snapshot.target(first, "next"); + assert_ne!(first, second); + assert_eq!(snapshot.target(second, "next"), first); + assert_eq!(snapshot.find_all("Link").len(), 2); + }); +} + +#[test] +fn elements_have_empty_edge_names() { + with_fields(|snapshot, node, _| { + let resources = snapshot.target(node, "nested"); + let retainers = snapshot.target(node, "inners"); + let element_edges = snapshot + .edges(resources) + .into_iter() + .chain(snapshot.edges(retainers)) + .collect::>(); + assert_eq!(element_edges.len(), 4); + for edge in element_edges { + assert_eq!(edge, ("internal", "")); + } + }); +} + +#[test] +fn boxes_report_what_they_hold() { + with_fields(|snapshot, node, _| { + // A boxed retainer is the retainer's node, which counts the allocation. + let inner = snapshot.target(node, "boxed_inner"); + assert_eq!(snapshot.name(inner), "workerd / Inner"); + assert_eq!(snapshot.size(inner), size_of::()); + + let child = snapshot.target(node, "boxed_child"); + assert_eq!(snapshot.name(child), "workerd / Box"); + assert_eq!(snapshot.size(child), size_of::>>()); + assert_eq!(snapshot.target_names(child), ["workerd / Child"]); + + let number = snapshot.target(node, "boxed_number"); + assert_eq!(snapshot.name(number), "workerd / Box"); + assert_eq!(snapshot.size(number), size_of::()); + + let string = snapshot.target(node, "boxed_str"); + assert_eq!(snapshot.name(string), "workerd / Box"); + assert_eq!(snapshot.size(string), "boxed str".len()); + + let slice = snapshot.target(node, "boxed_slice"); + assert_eq!(snapshot.name(slice), "workerd / Box<[T]>"); + assert_eq!(snapshot.target_names(slice).len(), 2); + }); +} + +#[test] +fn nested_collections_report_each_level() { + with_fields(|snapshot, node, _| { + let outer = snapshot.target(node, "nested"); + assert_eq!( + snapshot.target_names(outer), + ["workerd / Vec", "workerd / Vec"] + ); + let counts: Vec<_> = snapshot + .targets(outer, "") + .into_iter() + .map(|inner| snapshot.target_names(inner).len()) + .collect(); + assert_eq!(counts, [1, 2]); + }); +} + +#[test] +fn other_collections() { + with_fields(|snapshot, node, fields| { + // Options of plain data are plain data. + let optional = snapshot.target(node, "optional_numbers"); + assert!(snapshot.nodes[optional].edges.is_empty()); + assert_eq!( + snapshot.size(optional), + fields.optional_numbers.capacity() * size_of::>() + ); + + let deque = snapshot.target(node, "deque"); + assert_eq!(snapshot.name(deque), "workerd / VecDeque"); + assert_eq!(snapshot.target_names(deque), ["workerd / String"]); + + // An array is inline, so each element is a field of the resource. + assert_eq!(snapshot.targets(node, "array").len(), 2); + + let set = snapshot.target(node, "set"); + assert_eq!(snapshot.name(set), "workerd / HashSet"); + assert_eq!(snapshot.target_names(set), ["workerd / Child"]); + + let tree = snapshot.target(node, "tree"); + assert_eq!(snapshot.name(tree), "workerd / BTreeMap"); + assert_eq!(snapshot.edge_names(tree), ["value"]); + + // A dict of plain data is one node, keys included. + let plain_dict = snapshot.target(node, "plain_dict"); + assert!(snapshot.nodes[plain_dict].edges.is_empty()); + assert!(snapshot.size(plain_dict) >= 2 * size_of::<(String, u32)>() + "abb".len()); + + let dict = snapshot.target(node, "dict"); + assert_eq!(snapshot.name(dict), "workerd / Dict"); + let mut edges = snapshot.edge_names(dict); + edges.sort_unstable(); + assert_eq!(edges, ["key", "key", "value", "value"]); + }); +} + +#[test] +fn handles_and_buffers() { + with_fields(|snapshot, node, _| { + assert!(snapshot.targets(node, "weak").is_empty()); + + let lenient = snapshot.target(node, "lenient"); + assert_eq!(snapshot.nodes[lenient].kind, "object"); + + let global = snapshot.target(node, "global"); + assert_eq!(snapshot.nodes[global].kind, "object"); + + let store = snapshot.target(node, "store"); + assert_eq!(snapshot.name(store), "workerd / BackingStore"); + assert_eq!(snapshot.size(store), 4); + + // The cell was mutably borrowed while the snapshot was taken. + assert!(snapshot.targets(node, "borrowed").is_empty()); + }); +} + +/// A heap snapshot of one isolate leaves alone a handle of another that a resource of the first +/// holds. Debug builds assert instead, in a callback from C++, where a panic aborts. +#[test] +#[cfg(not(debug_assertions))] +fn handle_of_another_isolate_is_not_reported() { + // A `Global` has no `Traced` impl, and is never visited. + #[jsg_resource(memory_info, custom_trace)] + struct Holder { + handle: Cell>>, + } + + impl jsg::Traced for Holder {} + + #[jsg_resource] + impl Holder {} + + impl jsg::VisitForMemoryInfo for Holder { + fn visit_for_memory_info(&self, tracker: &mut jsg::MemoryTracker<'_>) { + tracker.track_field(c"handle", &self.handle); + } + } + + let a = crate::Harness::new(); + let b = crate::Harness::new(); + let mut global = None; + a.run_in_context(|lock, _ctx| { + let object: v8::Local<'_, v8::Value> = lock.new_object().into(); + global = Some(object.to_global(lock)); + Ok(()) + }); + let holder = jsg::Rc::new(Holder { + handle: Cell::new(global), + }); + + b.run_in_context(|lock, ctx| { + expose(lock, ctx, "holder", &holder)?; + let snapshot = Snapshot::take(lock); + assert!( + snapshot + .targets(snapshot.find("Holder"), "handle") + .is_empty() + ); + Ok(()) + }); + + // Each handle is released under the lock of its own isolate. + a.run_in_context(|_lock, _ctx| { + drop(holder.handle.take().expect("set above")); + Ok(()) + }); + b.run_in_context(move |_lock, _ctx| { + drop(holder); + Ok(()) + }); +} diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index cad41914046..cc250e2fc6c 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -30,6 +30,7 @@ mod local_cast; mod local_handle; mod local_value; mod lock_errors; +mod memory_info; mod name; mod nested_type; mod non_coercible;