v8: implement v8.queryObjects() for memory leak regression testing

This is similar to the `queryObjects()` console API provided by the
Chromium DevTools console. It can be used to search for objects that
have the matching constructor on its prototype chain in the entire
heap, which can be useful for memory leak regression tests. To avoid
surprising results, users should avoid using this API on constructors
whose implementation they don't control, or on constructors that can
be invoked by other parties in the application.

To avoid accidental leaks, this API does not return raw references to
the objects found. By default, it returns the count of the objects
found. If `options.format` is `'summary'`, it returns an array
containing brief string representations for each object. The visibility
provided in this API is similar to what the heap snapshot provides,
while users can save the cost of serialization and parsing and directly
filer the target objects during the search.

We have been using this API internally for the test suite, which
has been more stable than any other leak regression testing
strategies in the CI. With a public implementation we can now
use the public API instead.

PR-URL: https://github.com/nodejs/node/pull/51927
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
Reviewed-By: Vinícius Lourenço Claro Cardoso <contact@viniciusl.com.br>
Reviewed-By: Gerhard Stöbich <deb2001-github@yahoo.de>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
This commit is contained in:
Joyee Cheung 2024-03-02 23:11:30 +01:00 committed by GitHub
parent 9930f114a0
commit 7f2d61f82a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 359 additions and 51 deletions

View File

@ -242,6 +242,89 @@ buffers and external strings.
}
```
## `v8.queryObjects(ctor[, options])`
<!-- YAML
added: REPLACEME
-->
> Stability: 1.1 - Active development
* `ctor` {Function} The constructor that can be used to search on the
prototype chain in order to filter target objects in the heap.
* `options` {undefined|Object}
* `format` {string} If it's `'count'`, the count of matched objects
is returned. If it's `'summary'`, an array with summary strings
of the matched objects is returned.
* Returns: {number|Array<string>}
This is similar to the [`queryObjects()` console API][] provided by the
Chromium DevTools console. It can be used to search for objects that
have the matching constructor on its prototype chain in the heap after
a full garbage collection, which can be useful for memory leak
regression tests. To avoid surprising results, users should avoid using
this API on constructors whose implementation they don't control, or on
constructors that can be invoked by other parties in the application.
To avoid accidental leaks, this API does not return raw references to
the objects found. By default, it returns the count of the objects
found. If `options.format` is `'summary'`, it returns an array
containing brief string representations for each object. The visibility
provided in this API is similar to what the heap snapshot provides,
while users can save the cost of serialization and parsing and directly
filter the target objects during the search.
Only objects created in the current execution context are included in the
results.
```cjs
const { queryObjects } = require('node:v8');
class A { foo = 'bar'; }
console.log(queryObjects(A)); // 0
const a = new A();
console.log(queryObjects(A)); // 1
// [ "A { foo: 'bar' }" ]
console.log(queryObjects(A, { format: 'summary' }));
class B extends A { bar = 'qux'; }
const b = new B();
console.log(queryObjects(B)); // 1
// [ "B { foo: 'bar', bar: 'qux' }" ]
console.log(queryObjects(B, { format: 'summary' }));
// Note that, when there are child classes inheriting from a constructor,
// the constructor also shows up in the prototype chain of the child
// classes's prototoype, so the child classes's prototoype would also be
// included in the result.
console.log(queryObjects(A)); // 3
// [ "B { foo: 'bar', bar: 'qux' }", 'A {}', "A { foo: 'bar' }" ]
console.log(queryObjects(A, { format: 'summary' }));
```
```mjs
import { queryObjects } from 'node:v8';
class A { foo = 'bar'; }
console.log(queryObjects(A)); // 0
const a = new A();
console.log(queryObjects(A)); // 1
// [ "A { foo: 'bar' }" ]
console.log(queryObjects(A, { format: 'summary' }));
class B extends A { bar = 'qux'; }
const b = new B();
console.log(queryObjects(B)); // 1
// [ "B { foo: 'bar', bar: 'qux' }" ]
console.log(queryObjects(B, { format: 'summary' }));
// Note that, when there are child classes inheriting from a constructor,
// the constructor also shows up in the prototype chain of the child
// classes's prototoype, so the child classes's prototoype would also be
// included in the result.
console.log(queryObjects(A)); // 3
// [ "B { foo: 'bar', bar: 'qux' }", 'A {}', "A { foo: 'bar' }" ]
console.log(queryObjects(A, { format: 'summary' }));
```
## `v8.setFlagsFromString(flags)`
<!-- YAML
@ -1212,6 +1295,7 @@ setTimeout(() => {
[`deserializer._readHostObject()`]: #deserializer_readhostobject
[`deserializer.transferArrayBuffer()`]: #deserializertransferarraybufferid-arraybuffer
[`init` callback]: #initpromise-parent
[`queryObjects()` console API]: https://developer.chrome.com/docs/devtools/console/utilities#queryObjects-function
[`serialize()`]: #v8serializevalue
[`serializer._getSharedArrayBufferId()`]: #serializer_getsharedarraybufferidsharedarraybuffer
[`serializer._writeHostObject()`]: #serializer_writehostobjectobject

View File

@ -2,6 +2,7 @@
const {
Symbol,
Uint8Array,
ArrayPrototypeMap,
} = primordials;
const {
kUpdateTimer,
@ -9,9 +10,23 @@ const {
} = require('internal/stream_base_commons');
const { owner_symbol } = require('internal/async_hooks').symbols;
const { Readable } = require('stream');
const { validateObject, validateBoolean } = require('internal/validators');
const { kEmptyObject } = require('internal/util');
const {
validateObject,
validateBoolean,
validateFunction,
} = require('internal/validators');
const {
codes: {
ERR_INVALID_ARG_VALUE,
},
} = require('internal/errors');
const { kEmptyObject, emitExperimentalWarning } = require('internal/util');
const {
queryObjects: _queryObjects,
} = internalBinding('internal_only_v8');
const {
inspect,
} = require('internal/util/inspect');
const kHandle = Symbol('kHandle');
function getHeapSnapshotOptions(options = kEmptyObject) {
@ -50,7 +65,31 @@ class HeapSnapshotStream extends Readable {
}
}
const inspectOptions = {
__proto__: null,
depth: 0,
};
function queryObjects(ctor, options = kEmptyObject) {
validateFunction(ctor, 'constructor');
if (options !== kEmptyObject) {
validateObject(options, 'options');
}
const format = options.format || 'count';
if (format !== 'count' && format !== 'summary') {
throw new ERR_INVALID_ARG_VALUE('options.format', format);
}
emitExperimentalWarning('v8.queryObjects()');
// Matching the console API behavior - just access the .prototype.
const objects = _queryObjects(ctor.prototype);
if (format === 'count') {
return objects.length;
}
// options.format is 'summary'.
return ArrayPrototypeMap(objects, (object) => inspect(object, inspectOptions));
}
module.exports = {
getHeapSnapshotOptions,
HeapSnapshotStream,
queryObjects,
};

View File

@ -1,6 +1,8 @@
'use strict';
const {
Error,
StringPrototypeStartsWith,
globalThis,
} = primordials;
@ -8,9 +10,24 @@ process.emitWarning(
'These APIs are for internal testing only. Do not use them.',
'internal/test/binding');
function filteredInternalBinding(id) {
// Disallows internal bindings with names that start with 'internal_only'
// which means it should not be exposed to users even with
// --expose-internals.
if (StringPrototypeStartsWith(id, 'internal_only')) {
// This code is only intended for internal errors and is not documented.
// Do not use the normal error system.
// eslint-disable-next-line no-restricted-syntax
const error = new Error(`No such binding: ${id}`);
error.code = 'ERR_INVALID_MODULE';
throw error;
}
return internalBinding(id);
}
if (module.isPreloading) {
globalThis.internalBinding = internalBinding;
globalThis.internalBinding = filteredInternalBinding;
globalThis.primordials = primordials;
}
module.exports = { internalBinding, primordials };
module.exports = { internalBinding: filteredInternalBinding, primordials };

View File

@ -60,6 +60,7 @@ const {
const {
HeapSnapshotStream,
getHeapSnapshotOptions,
queryObjects,
} = require('internal/heap_utils');
const promiseHooks = require('internal/promise_hooks');
const { getOptionValue } = require('internal/options');
@ -437,6 +438,7 @@ module.exports = {
serialize,
writeHeapSnapshot,
promiseHooks,
queryObjects,
startupSnapshot,
setHeapSnapshotNearHeapLimit,
GCProfiler,

View File

@ -80,6 +80,7 @@
'src/handle_wrap.cc',
'src/heap_utils.cc',
'src/histogram.cc',
'src/internal_only_v8.cc',
'src/js_native_api.h',
'src/js_native_api_types.h',
'src/js_native_api_v8.cc',

View File

@ -474,39 +474,6 @@ void TriggerHeapSnapshot(const FunctionCallbackInfo<Value>& args) {
return args.GetReturnValue().Set(filename_v);
}
class PrototypeChainHas : public v8::QueryObjectPredicate {
public:
PrototypeChainHas(Local<Context> context, Local<Object> search)
: context_(context), search_(search) {}
// What we can do in the filter can be quite limited, but looking up
// the prototype chain is something that the inspector console API
// queryObject() does so it is supported.
bool Filter(Local<Object> object) override {
for (Local<Value> proto = object->GetPrototype(); proto->IsObject();
proto = proto.As<Object>()->GetPrototype()) {
if (search_ == proto) return true;
}
return false;
}
private:
Local<Context> context_;
Local<Object> search_;
};
void CountObjectsWithPrototype(const FunctionCallbackInfo<Value>& args) {
CHECK_EQ(args.Length(), 1);
CHECK(args[0]->IsObject());
Local<Object> proto = args[0].As<Object>();
Isolate* isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
PrototypeChainHas prototype_chain_has(context, proto);
std::vector<Global<Object>> out;
isolate->GetHeapProfiler()->QueryObjects(context, &prototype_chain_has, &out);
args.GetReturnValue().Set(static_cast<uint32_t>(out.size()));
}
void Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
@ -515,15 +482,12 @@ void Initialize(Local<Object> target,
SetMethod(context, target, "triggerHeapSnapshot", TriggerHeapSnapshot);
SetMethod(
context, target, "createHeapSnapshotStream", CreateHeapSnapshotStream);
SetMethod(
context, target, "countObjectsWithPrototype", CountObjectsWithPrototype);
}
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(BuildEmbedderGraph);
registry->Register(TriggerHeapSnapshot);
registry->Register(CreateHeapSnapshotStream);
registry->Register(CountObjectsWithPrototype);
}
} // namespace heap

85
src/internal_only_v8.cc Normal file
View File

@ -0,0 +1,85 @@
#include "node_binding.h"
#include "node_external_reference.h"
#include "util-inl.h"
#include "v8-profiler.h"
#include "v8.h"
using v8::Array;
using v8::Context;
using v8::FunctionCallbackInfo;
using v8::Global;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::Value;
namespace node {
namespace internal_only_v8 {
class PrototypeChainHas : public v8::QueryObjectPredicate {
public:
PrototypeChainHas(Local<Context> context, Local<Object> search)
: context_(context), search_(search) {}
// What we can do in the filter can be quite limited, but looking up
// the prototype chain is something that the inspector console API
// queryObject() does so it is supported.
bool Filter(Local<Object> object) override {
Local<Context> creation_context;
if (!object->GetCreationContext().ToLocal(&creation_context)) {
return false;
}
if (creation_context != context_) {
return false;
}
for (Local<Value> proto = object->GetPrototype(); proto->IsObject();
proto = proto.As<Object>()->GetPrototype()) {
if (search_ == proto) return true;
}
return false;
}
private:
Local<Context> context_;
Local<Object> search_;
};
void QueryObjects(const FunctionCallbackInfo<Value>& args) {
CHECK_EQ(args.Length(), 1);
Isolate* isolate = args.GetIsolate();
if (!args[0]->IsObject()) {
args.GetReturnValue().Set(Array::New(isolate));
return;
}
Local<Object> proto = args[0].As<Object>();
Local<Context> context = isolate->GetCurrentContext();
PrototypeChainHas prototype_chain_has(context, proto.As<Object>());
std::vector<Global<Object>> out;
isolate->GetHeapProfiler()->QueryObjects(context, &prototype_chain_has, &out);
std::vector<Local<Value>> result;
result.reserve(out.size());
for (size_t i = 0; i < out.size(); ++i) {
result.push_back(out[i].Get(isolate));
}
args.GetReturnValue().Set(Array::New(isolate, result.data(), result.size()));
}
void Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
SetMethod(context, target, "queryObjects", QueryObjects);
}
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(QueryObjects);
}
} // namespace internal_only_v8
} // namespace node
NODE_BINDING_CONTEXT_AWARE_INTERNAL(internal_only_v8,
node::internal_only_v8::Initialize)
NODE_BINDING_EXTERNAL_REFERENCE(
internal_only_v8, node::internal_only_v8::RegisterExternalReferences)

View File

@ -26,6 +26,8 @@
// function. This helps the built-in bindings are loaded properly when
// node is built as static library. No need to depend on the
// __attribute__((constructor)) like mechanism in GCC.
// The binding IDs that start with 'internal_only' are not exposed to the user
// land even from internal/test/binding module under --expose-internals.
#define NODE_BUILTIN_STANDARD_BINDINGS(V) \
V(async_wrap) \
V(blob) \
@ -46,6 +48,7 @@
V(http2) \
V(http_parser) \
V(inspector) \
V(internal_only_v8) \
V(js_stream) \
V(js_udp_wrap) \
V(messaging) \

View File

@ -109,6 +109,7 @@ class ExternalReferenceRegistry {
V(fs_event_wrap) \
V(handle_wrap) \
V(heap_utils) \
V(internal_only_v8) \
V(messaging) \
V(mksnapshot) \
V(module_wrap) \

View File

@ -83,15 +83,14 @@ async function runAndBreathe(fn, repeat, waitTime = 20) {
* @param {(i: number) => number} fn The factory receiving iteration count
* and returning number of objects created. The return value should be
* precise otherwise false negatives can be produced.
* @param {Function} klass The class whose object is used to count the objects
* @param {Function} ctor The constructor of the objects being counted.
* @param {number} count Number of iterations that this check should be done
* @param {number} waitTime Optional breathing time for GC.
*/
async function checkIfCollectableByCounting(fn, klass, count, waitTime = 20) {
const { internalBinding } = require('internal/test/binding');
const { countObjectsWithPrototype } = internalBinding('heap_utils');
const { prototype, name } = klass;
const initialCount = countObjectsWithPrototype(prototype);
async function checkIfCollectableByCounting(fn, ctor, count, waitTime = 20) {
const { queryObjects } = require('v8');
const { name } = ctor;
const initialCount = queryObjects(ctor, { format: 'count' });
console.log(`Initial count of ${name}: ${initialCount}`);
let totalCreated = 0;
for (let i = 0; i < count; ++i) {
@ -99,7 +98,7 @@ async function checkIfCollectableByCounting(fn, klass, count, waitTime = 20) {
totalCreated += created;
console.log(`#${i}: created ${created} ${name}, total ${totalCreated}`);
await wait(waitTime); // give GC some breathing room.
const currentCount = countObjectsWithPrototype(prototype);
const currentCount = queryObjects(ctor, { format: 'count' });
const collected = totalCreated - (currentCount - initialCount);
console.log(`#${i}: counted ${currentCount} ${name}, collected ${collected}`);
if (collected > 0) {
@ -109,7 +108,7 @@ async function checkIfCollectableByCounting(fn, klass, count, waitTime = 20) {
}
await wait(waitTime); // give GC some breathing room.
const currentCount = countObjectsWithPrototype(prototype);
const currentCount = queryObjects(ctor, { format: 'count' });
const collected = totalCreated - (currentCount - initialCount);
console.log(`Last count: counted ${currentCount} ${name}, collected ${collected}`);
// Some objects with the prototype can be collected.

View File

@ -1,4 +1,4 @@
// Flags: --expose-internals --experimental-vm-modules --max-old-space-size=16 --trace-gc
// Flags: --experimental-vm-modules --max-old-space-size=16 --trace-gc
'use strict';
// This tests that vm.SourceTextModule() does not leak.

View File

@ -1,4 +1,4 @@
// Flags: --expose-internals --max-old-space-size=16
// Flags: --max-old-space-size=16
'use strict';
// This test ensures that diagnostic channel references aren't leaked.

View File

@ -0,0 +1,9 @@
'use strict';
// Flags: --expose-internals
require('../common');
const assert = require('assert');
const { internalBinding } = require('internal/test/binding');
assert.throws(() => internalBinding('internal_only_v8'), {
code: 'ERR_INVALID_MODULE'
});

View File

@ -0,0 +1,104 @@
'use strict';
// This tests the v8.queryObjects() API.
const common = require('../common');
const v8 = require('v8');
const assert = require('assert');
const { inspect } = require('util');
function format(obj) {
return inspect(obj, { depth: 0 });
}
common.expectWarning(
'ExperimentalWarning',
'v8.queryObjects() is an experimental feature and might change at any time',
);
{
for (const invalid of [undefined, 1, null, false, {}, 'foo']) {
assert.throws(() => v8.queryObjects(invalid), { code: 'ERR_INVALID_ARG_TYPE' });
}
for (const invalid of [1, null, false, 'foo']) {
assert.throws(() => v8.queryObjects(() => {}, invalid), { code: 'ERR_INVALID_ARG_TYPE' });
}
assert.throws(() => v8.queryObjects(() => {}, { format: 'invalid' }), { code: 'ERR_INVALID_ARG_VALUE' });
}
{
class TestV8QueryObjectsClass {}
// By default, returns count of objects with the constructor on the prototype.
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass), 0);
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass, { format: 'count' }), 0);
// 'summary' format returns an array.
assert.deepStrictEqual(v8.queryObjects(TestV8QueryObjectsClass, { format: 'summary' }), []);
// Create an instance and check that it shows up in the results.
const obj = new TestV8QueryObjectsClass();
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass), 1);
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass, { format: 'count' }), 1);
assert.deepStrictEqual(
v8.queryObjects(TestV8QueryObjectsClass, { format: 'summary' }),
[ format(obj)]
);
}
{
// ES6 class inheritance.
class TestV8QueryObjectsBaseClass {}
class TestV8QueryObjectsChildClass extends TestV8QueryObjectsBaseClass {}
const summary = v8.queryObjects(TestV8QueryObjectsBaseClass, { format: 'summary' });
// TestV8QueryObjectsChildClass's prototype's [[Prototype]] slot is
// TestV8QueryObjectsBaseClass's prototoype so it shows up in the query.
assert.deepStrictEqual(summary, [
format(TestV8QueryObjectsChildClass.prototype),
]);
const obj = new TestV8QueryObjectsChildClass();
assert.deepStrictEqual(
v8.queryObjects(TestV8QueryObjectsBaseClass, { format: 'summary' }).sort(),
[
format(TestV8QueryObjectsChildClass.prototype),
format(obj),
].sort()
);
assert.deepStrictEqual(
v8.queryObjects(TestV8QueryObjectsChildClass, { format: 'summary' }),
[ format(obj) ],
);
}
{
function TestV8QueryObjectsCtor() {}
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor), 0);
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor, { format: 'count' }), 0);
assert.deepStrictEqual(v8.queryObjects(TestV8QueryObjectsCtor, { format: 'summary' }), []);
// Create an instance and check that it shows up in the results.
const obj = new TestV8QueryObjectsCtor();
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor), 1);
assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor, { format: 'count' }), 1);
assert.deepStrictEqual(
v8.queryObjects(TestV8QueryObjectsCtor, { format: 'summary' }),
[ format(obj)]
);
}
{
// Classic inheritance.
function TestV8QueryObjectsBaseCtor() {}
function TestV8QueryObjectsChildCtor() {}
Object.setPrototypeOf(TestV8QueryObjectsChildCtor.prototype, TestV8QueryObjectsBaseCtor.prototype);
Object.setPrototypeOf(TestV8QueryObjectsChildCtor, TestV8QueryObjectsBaseCtor);
const summary = v8.queryObjects(TestV8QueryObjectsBaseCtor, { format: 'summary' });
assert.deepStrictEqual(summary, [
format(TestV8QueryObjectsChildCtor.prototype),
]);
const obj = new TestV8QueryObjectsChildCtor();
assert.deepStrictEqual(
v8.queryObjects(TestV8QueryObjectsChildCtor, { format: 'summary' }),
[ format(obj) ],
);
}