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-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; 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 b4f0796cb9c..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 | +| `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` | 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);