Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Mar 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member Author

@joyeecheung joyeecheung Feb 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently takes a Function to match the Chromium DevTools console API, though IMO it would also be useful to offer a complementary API that allows users to register an object for filtering, and then return the registered objects in v8.queryObjects - this can be implemented based on e.g. symbols. It's worth noting that the operations allowed in the filter in the V8 API can be quite limited (I've seen crashes before when I tried to use a symbol-based approach when I was working on #50572), so for this PR I think we should just implement what Chromium supports - prototype chain walking as a filter, which is more guaranteed to be safe.

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 @@ -1208,6 +1291,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