Skip to content

Commit

Permalink
v8: implement v8.queryObjects() for memory leak regression testing
Browse files Browse the repository at this point in the history
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: #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>
  • Loading branch information
joyeecheung authored Mar 2, 2024
1 parent 9930f11 commit 7f2d61f
Show file tree
Hide file tree
Showing 14 changed files with 359 additions and 51 deletions.
84 changes: 84 additions & 0 deletions doc/api/v8.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 42 additions & 3 deletions lib/internal/heap_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,31 @@
const {
Symbol,
Uint8Array,
ArrayPrototypeMap,
} = primordials;
const {
kUpdateTimer,
onStreamRead,
} = 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) {
Expand Down Expand Up @@ -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,
};
21 changes: 19 additions & 2 deletions lib/internal/test/binding.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
'use strict';

const {
Error,
StringPrototypeStartsWith,
globalThis,
} = primordials;

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 };
2 changes: 2 additions & 0 deletions lib/v8.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const {
const {
HeapSnapshotStream,
getHeapSnapshotOptions,
queryObjects,
} = require('internal/heap_utils');
const promiseHooks = require('internal/promise_hooks');
const { getOptionValue } = require('internal/options');
Expand Down Expand Up @@ -437,6 +438,7 @@ module.exports = {
serialize,
writeHeapSnapshot,
promiseHooks,
queryObjects,
startupSnapshot,
setHeapSnapshotNearHeapLimit,
GCProfiler,
Expand Down
1 change: 1 addition & 0 deletions node.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
36 changes: 0 additions & 36 deletions src/heap_utils.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
85 changes: 85 additions & 0 deletions src/internal_only_v8.cc
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions src/node_binding.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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) \
Expand All @@ -46,6 +48,7 @@
V(http2) \
V(http_parser) \
V(inspector) \
V(internal_only_v8) \
V(js_stream) \
V(js_udp_wrap) \
V(messaging) \
Expand Down
1 change: 1 addition & 0 deletions src/node_external_reference.h
Original file line number Diff line number Diff line change
Expand Up @@ -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) \
Expand Down
Loading

0 comments on commit 7f2d61f

Please sign in to comment.