Skip to content

Commit ffd7a57

Browse files
divybotlittledivy
andauthored
fix(ext/node): add v8.queryObjects() and util.queryObjects() (#34159)
Closes denoland/orchid#122 Co-authored-by: divybot <divybot@users.noreply.github.com> Co-authored-by: Divy Srivastava <me@littledivy.com>
1 parent 565302b commit ffd7a57

8 files changed

Lines changed: 227 additions & 3 deletions

File tree

ext/node/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ deno_core::extension!(deno_node,
241241
ops::v8::op_v8_update_heap_space_statistics,
242242
ops::v8::op_v8_get_heap_code_statistics,
243243
ops::v8::op_v8_take_heap_snapshot,
244+
ops::v8::op_v8_query_objects_count,
244245
ops::v8::op_v8_get_wire_format_version,
245246
ops::v8::op_v8_new_deserializer,
246247
ops::v8::op_v8_new_serializer,

ext/node/ops/v8.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,103 @@ pub fn op_v8_take_heap_snapshot(scope: &mut v8::PinScope<'_, '_>) -> Vec<u8> {
7272
buf
7373
}
7474

75+
// Walks the V8 heap snapshot and counts nodes that look like instances of a
76+
// class whose constructor name matches `ctor_name`. Used by `util.queryObjects`
77+
// / `v8.queryObjects` to implement `{ format: 'count' }` without exposing
78+
// `HeapProfiler::QueryObjects` (which the rusty_v8 crate does not bind).
79+
//
80+
// Limitation: matches by the immediate constructor name only, so instances of
81+
// subclasses of `ctor` won't be counted. This is sufficient for Node's leak
82+
// tests (which check direct instances of `Channel`, `SourceTextModule`, ...).
83+
#[op2(nofast)]
84+
#[smi]
85+
pub fn op_v8_query_objects_count(
86+
scope: &mut v8::PinScope<'_, '_>,
87+
#[string] ctor_name: &str,
88+
) -> u32 {
89+
use deno_core::serde_json;
90+
use deno_core::serde_json::Value;
91+
92+
let mut buf = Vec::new();
93+
scope.take_heap_snapshot(|chunk| {
94+
buf.extend_from_slice(chunk);
95+
true
96+
});
97+
if buf.is_empty() {
98+
return 0;
99+
}
100+
101+
let snapshot: Value = match serde_json::from_slice(&buf) {
102+
Ok(v) => v,
103+
Err(_) => return 0,
104+
};
105+
106+
let meta = match snapshot.get("snapshot").and_then(|s| s.get("meta")) {
107+
Some(m) => m,
108+
None => return 0,
109+
};
110+
let node_fields = match meta.get("node_fields").and_then(|f| f.as_array()) {
111+
Some(a) => a,
112+
None => return 0,
113+
};
114+
let node_field_count = node_fields.len();
115+
if node_field_count == 0 {
116+
return 0;
117+
}
118+
let type_field_index = node_fields.iter().position(|f| f == "type");
119+
let name_field_index = node_fields.iter().position(|f| f == "name");
120+
let (Some(type_field_index), Some(name_field_index)) =
121+
(type_field_index, name_field_index)
122+
else {
123+
return 0;
124+
};
125+
126+
// `node_types` is an array where the entry at `type_field_index` is the
127+
// list of named type variants (the rest are scalars like "string"/"number").
128+
let object_type_index = match meta
129+
.get("node_types")
130+
.and_then(|t| t.as_array())
131+
.and_then(|t| t.get(type_field_index))
132+
.and_then(|t| t.as_array())
133+
{
134+
Some(types) => match types.iter().position(|t| t == "object") {
135+
Some(i) => i as u64,
136+
None => return 0,
137+
},
138+
None => return 0,
139+
};
140+
141+
let nodes = match snapshot.get("nodes").and_then(|n| n.as_array()) {
142+
Some(a) => a,
143+
None => return 0,
144+
};
145+
let strings = match snapshot.get("strings").and_then(|s| s.as_array()) {
146+
Some(a) => a,
147+
None => return 0,
148+
};
149+
150+
let mut count: u32 = 0;
151+
for chunk in nodes.chunks_exact(node_field_count) {
152+
let Some(ty) = chunk[type_field_index].as_u64() else {
153+
continue;
154+
};
155+
if ty != object_type_index {
156+
continue;
157+
}
158+
let Some(name_idx) = chunk[name_field_index].as_u64() else {
159+
continue;
160+
};
161+
let Some(name) = strings.get(name_idx as usize).and_then(|s| s.as_str())
162+
else {
163+
continue;
164+
};
165+
if name == ctor_name {
166+
count = count.saturating_add(1);
167+
}
168+
}
169+
count
170+
}
171+
75172
#[op2(fast)]
76173
pub fn op_v8_get_heap_code_statistics(
77174
scope: &mut v8::PinScope<'_, '_>,

ext/node/polyfills/util.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const { default: binding } = core.loadExtScript(
7575
const { validateOneOf } = core.loadExtScript(
7676
"ext:deno_node/internal/validators.mjs",
7777
);
78+
const lazyV8 = core.createLazyLoader("node:v8");
7879
const { os: osConstants } = core.loadExtScript(
7980
"ext:deno_node/internal_binding/constants.ts",
8081
);
@@ -344,6 +345,12 @@ function setTraceSigInt(enabled) {
344345
// facility, but the call should succeed so user code can opt in/out.
345346
}
346347

348+
// https://nodejs.org/api/util.html#utilqueryobjectsconstructor-options
349+
// Mirrors `v8.queryObjects` - see ext/node/polyfills/v8.ts for the limitations.
350+
function queryObjects(ctor, options) {
351+
return lazyV8().queryObjects(ctor, options);
352+
}
353+
347354
function convertProcessSignalToExitCode(signalCode) {
348355
const { signals } = osConstants;
349356
validateOneOf(signalCode, "signalCode", ObjectKeys(signals));
@@ -375,6 +382,7 @@ return {
375382
aborted,
376383
getCallSites,
377384
parseEnv,
385+
queryObjects,
378386
setTraceSigInt,
379387
convertProcessSignalToExitCode,
380388
getSystemErrorMessage,

ext/node/polyfills/v8.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const {
2525
op_v8_read_value,
2626
op_v8_release_buffer,
2727
op_v8_set_treat_array_buffer_views_as_host_objects,
28+
op_v8_query_objects_count,
2829
op_v8_take_heap_snapshot,
2930
op_v8_transfer_array_buffer,
3031
op_v8_transfer_array_buffer_de,
@@ -48,9 +49,10 @@ const { isArrayBufferView } = core.loadExtScript(
4849
const lazyFsUtils = core.createLazyLoader(
4950
"ext:deno_node/internal/fs/utils.mjs",
5051
);
51-
const { validateObject } = core.loadExtScript(
52-
"ext:deno_node/internal/validators.mjs",
53-
);
52+
const { validateFunction, validateObject, validateOneOf } = core
53+
.loadExtScript(
54+
"ext:deno_node/internal/validators.mjs",
55+
);
5456

5557
function cachedDataVersionTag() {
5658
return op_v8_cached_data_version_tag();
@@ -167,6 +169,44 @@ function writeHeapSnapshot(
167169
return filename;
168170
}
169171

172+
// https://nodejs.org/api/v8.html#v8queryobjectsctor-options
173+
//
174+
// Deno currently only supports `{ format: 'count' }`. Returning live instances
175+
// would require V8's `HeapProfiler::QueryObjects`, which isn't exposed in the
176+
// rusty_v8 bindings; the count form is what Node's leak tests rely on.
177+
function queryObjects(
178+
ctor: { name?: string; prototype?: unknown },
179+
options:
180+
| { format?: "count" | "summary" }
181+
| undefined = undefined,
182+
) {
183+
validateFunction(ctor, "constructor");
184+
if (options !== undefined) {
185+
validateObject(options, "options");
186+
if (options.format !== undefined) {
187+
validateOneOf(options.format, "options.format", ["count", "summary"]);
188+
}
189+
}
190+
const format = options?.format;
191+
192+
const name = typeof ctor.name === "string" ? ctor.name : "";
193+
if (name === "") {
194+
return format === "count" ? 0 : [];
195+
}
196+
const count = op_v8_query_objects_count(name);
197+
if (format === "count") {
198+
return count;
199+
}
200+
if (format === "summary") {
201+
if (count === 0) return [];
202+
return [`${count} instance(s) of ${name}`];
203+
}
204+
// Default format returns live object handles, which would require V8's
205+
// `HeapProfiler::QueryObjects` (not exposed in rusty_v8). Returning an
206+
// empty array keeps the signature sensible.
207+
return [];
208+
}
209+
170210
// deno-lint-ignore no-explicit-any
171211
function serialize(value: any) {
172212
const ser = new DefaultSerializer();
@@ -396,6 +436,7 @@ return {
396436
getHeapSnapshot,
397437
getHeapSpaceStatistics,
398438
getHeapStatistics,
439+
queryObjects,
399440
setFlagsFromString,
400441
stopCoverage,
401442
takeCoverage,

ext/node/polyfills/v8_esm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const {
88
getHeapSnapshot,
99
getHeapSpaceStatistics,
1010
getHeapStatistics,
11+
queryObjects,
1112
setFlagsFromString,
1213
stopCoverage,
1314
takeCoverage,

tests/node_compat/config.jsonc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"es-module/test-typescript-transform.mjs": {},
131131
"es-module/test-typescript.mjs": {},
132132
"es-module/test-vm-compile-function-lineoffset.js": {},
133+
"es-module/test-vm-source-text-module-leak.js": {},
133134
"es-module/test-wasm-memory-out-of-bound.js": {},
134135
"es-module/test-wasm-simple.js": {},
135136
"internet/test-dns-ipv4.js": {},
@@ -948,6 +949,7 @@
948949
"parallel/test-diagnostic-channel-http-response-created.js": {},
949950
"parallel/test-diagnostics-channel-bind-store.js": {},
950951
"parallel/test-diagnostics-channel-has-subscribers.js": {},
952+
"parallel/test-diagnostics-channel-memory-leak.js": {},
951953
"parallel/test-diagnostics-channel-http-server-start.js": {},
952954
"parallel/test-diagnostics-channel-http.js": {},
953955
"parallel/test-diagnostics-channel-http2-client-stream-close-error.js": {},

tests/unit_node/util_test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,24 @@ Deno.test("[util] stripVTControlCharacters() removes OSC 8 hyperlinks", () => {
244244
assertEquals(util.stripVTControlCharacters(inputBel), "This is a link hello");
245245
});
246246

247+
Deno.test("[util] queryObjects() counts instances", () => {
248+
class UtilQueryObjectsFixture {}
249+
// util.queryObjects is not declared on the bundled @types/node yet, but the
250+
// runtime exposes it (mirroring v8.queryObjects).
251+
// deno-lint-ignore no-explicit-any
252+
const queryObjects = (util as any).queryObjects as (
253+
ctor: unknown,
254+
options?: { format?: "count" | "summary" },
255+
) => number | string[];
256+
const before = queryObjects(UtilQueryObjectsFixture, { format: "count" });
257+
const refs = [];
258+
for (let i = 0; i < 25; i++) refs.push(new UtilQueryObjectsFixture());
259+
const after = queryObjects(UtilQueryObjectsFixture, { format: "count" });
260+
assertEquals(typeof before, "number");
261+
assertEquals((after as number) - (before as number) >= 25, true);
262+
assertEquals(refs.length, 25);
263+
});
264+
247265
Deno.test("[util] parseEnv()", () => {
248266
const env =
249267
"KEY1=VALUE1\nKEY2='VALUE2'\nKEYÄ3=\"VALUE3\"\nKEY4=VALÜE4\nKEY5='VALUE6'INVALID_LINE\nKEY6=A";

tests/unit_node/v8_test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,59 @@ Deno.test({
6969
}, Deno.errors.NotCapable);
7070
},
7171
});
72+
73+
Deno.test({
74+
name: "queryObjects counts instances by constructor",
75+
fn() {
76+
class QueryObjectsTestFixture {}
77+
const before = v8.queryObjects(QueryObjectsTestFixture, {
78+
format: "count",
79+
});
80+
assertEquals(typeof before, "number");
81+
const instances = [];
82+
for (let i = 0; i < 50; i++) {
83+
instances.push(new QueryObjectsTestFixture());
84+
}
85+
const after = v8.queryObjects(QueryObjectsTestFixture, {
86+
format: "count",
87+
});
88+
assertEquals(after - before >= 50, true);
89+
90+
const summary = v8.queryObjects(QueryObjectsTestFixture, {
91+
format: "summary",
92+
});
93+
assertEquals(Array.isArray(summary), true);
94+
assertEquals((summary as string[]).length, 1);
95+
assertEquals(
96+
(summary as string[])[0].includes("QueryObjectsTestFixture"),
97+
true,
98+
);
99+
100+
// Keep the instances reachable until after the snapshot.
101+
assertEquals(instances.length, 50);
102+
},
103+
});
104+
105+
Deno.test({
106+
name: "queryObjects validates the constructor argument",
107+
fn() {
108+
assertThrows(() => {
109+
// @ts-expect-error testing invalid input
110+
v8.queryObjects("not a function");
111+
});
112+
},
113+
});
114+
115+
Deno.test({
116+
name: "queryObjects validates the format option",
117+
fn() {
118+
class Anything {}
119+
assertThrows(() => {
120+
v8.queryObjects(
121+
Anything,
122+
// @ts-expect-error testing invalid input
123+
{ format: "bogus" },
124+
);
125+
});
126+
},
127+
});

0 commit comments

Comments
 (0)