feat: support heap profiling in contentTracing (#53349)

* feat: support heap profiling in contentTracing

Integrate Chromium's trace-scoped native heap profiler with contentTracing.startRecording() and emit protobuf traces when heap profiling is enabled.

This adapts the related Chromium changes:

https://chromium-review.googlesource.com/c/chromium/src/+/8163651

https: //chromium-review.googlesource.com/c/chromium/src/+/8263276
Co-Authored-By: GitHub Copilot (GPT-5.3-Codex) <175728472+Copilot@users.noreply.github.com>

* ci: suppress by-design PoissonAllocationSampler leak under LSan

The heap-profiling contentTracing specs are the first tests to engage
the native sampling heap profiler, whose sampled-address hash set
deliberately leaks its previous map on growth (see
PoissonAllocationSampler::BalanceAddressesHashSet), tripping the
linux-x64-asan job at shutdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ntRtbDdSvAdYJ9qRJHfJu
Signed-off-by: John Kleinschmidt <jkleinsc@electronjs.org>

* chore: apply review suggestions

* chore: rename sampling_interval_ms to dump_interval_ms for clarity

* chore: don't use setTimeout because it can cause flakes

* chore: fixup build failure

* chore: update patch after rebase

* chore: update patch after rebase

* chore: update to match current patch

* chore: update yarn.lock after rebase

* chore: move all of content tracing to Perfetto protobuf

* chore: update patch after rebase

* chore: remove upstreamed patch

* chore: update native heap profiler test for CI contention

* chore: address review feedback

* chore: address additional feedback

---------

Signed-off-by: John Kleinschmidt <jkleinsc@electronjs.org>
Co-authored-by: GitHub Copilot (GPT-5.3-Codex) <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 17:03:38 -04:00
1 parent 37fa0d5eb8
commit 3da8468c48
12 files changed
+531 -76

No files matched your search

+2
View File
@@ -554,6 +554,7 @@ source_set("electron_lib") {
"//services/device/public/cpp/hid",
"//services/device/public/mojom",
"//services/proxy_resolver:lib",
"//services/tracing/public/cpp",
"//services/video_capture/public/mojom:constants",
"//services/viz/privileged/mojom/compositing",
"//services/viz/public/mojom",
@@ -572,6 +573,7 @@ source_set("electron_lib") {
"//third_party/inspector_protocol:crdtp",
"//third_party/leveldatabase",
"//third_party/libyuv",
"//third_party/perfetto/protos/perfetto/config:cpp",
"//third_party/webrtc_overrides:webrtc_component",
"//third_party/widevine/cdm:headers",
"//third_party/zlib/google:zip",
+9
View File
@@ -12,3 +12,12 @@ leak:third_party/fontconfig
# GLib/GTK process-lifetime state (type system, quarks, theme and settings
# caches) that is never freed by design.
leak:libglib-2.0.so
# base::PoissonAllocationSampler deliberately leaks its previous
# sampled-address hash set when the set grows (see
# PoissonAllocationSampler::BalanceAddressesHashSet), since lock-free
# readers may still hold pointers to the old map. The heap-profiling
# contentTracing specs are the first tests to actually engage the
# sampler, so the initial 64-bucket set gets swapped out and reported.
# One-time and bounded (~1KB per growth step).
leak:base/sampling_heap_profiler/
+22 -4
View File
@@ -4,8 +4,9 @@
Process: [Main](../glossary.md#main-process)
This module does not include a web interface. To view recorded traces, use
[trace viewer][], available at `chrome://tracing` in Chrome.
This module does not include a web interface. Traces are recorded in the
[Perfetto protobuf format][perfetto trace format]; to view them, open them in
the [Perfetto UI][].
> [!NOTE]
> You should not use this module until the `ready` event of the app
@@ -59,6 +60,8 @@ changes:
- pr-url: https://github.com/electron/electron/pull/16584
description: "This function now returns a callback`Promise<void>`."
breaking-changes-header: api-changed-callback-based-versions-of-promisified-apis
- pr-url: https://github.com/electron/electron/pull/53349
description: "Added the `nativeHeapProfiling` option to enable native heap profiling."
```
-->
@@ -72,7 +75,10 @@ Recording begins immediately locally and asynchronously on child processes
as soon as they receive the EnableRecording request.
If a recording is already running, the promise will be immediately resolved, as
only one trace operation can be in progress at a time.
only one trace operation can be in progress at a time. If `options.nativeHeapProfiling`
is set while a recording is already running, the promise is rejected instead,
since the already-running recording's options can't be changed to turn on heap
profiling.
### `contentTracing.stopRecording([resultFilePath])`
@@ -84,6 +90,9 @@ changes:
breaking-changes-header: api-changed-callback-based-versions-of-promisified-apis
- pr-url: https://github.com/electron/electron/pull/18411
description: "The `resultFilePath` parameter is now optional."
- pr-url: https://github.com/electron/electron/pull/53349
description: "Trace data is now written in the Perfetto protobuf format. The legacy JSON format is no longer supported."
breaking-changes-header: behavior-changed-contenttracing-recordings-use-the-perfetto-protobuf-format
```
-->
@@ -103,6 +112,14 @@ Trace data will be written into `resultFilePath`. If `resultFilePath` is empty
or not provided, trace data will be written to a temporary file, and the path
will be returned in the promise.
Trace data is written in the [Perfetto protobuf format][perfetto trace format].
Heap profiling samples record instruction addresses and module build IDs rather
than function names. To symbolize them, use the symbols published for the
matching Electron release with Chromium's
[`symbolize_trace`](https://chromium.googlesource.com/chromium/src/+/main/tools/tracing/README.md)
tool.
### `contentTracing.getTraceBufferUsage()`
<!--
@@ -122,4 +139,5 @@ Returns `Promise<Object>` - Resolves with an object containing the `value` and `
Get the maximum usage across processes of trace buffer as a percentage of the
full state.
[trace viewer]: https://chromium.googlesource.com/catapult/+/HEAD/tracing/README.md
[Perfetto UI]: https://ui.perfetto.dev
[perfetto trace format]: https://perfetto.dev/docs/reference/trace-packet-proto
@@ -0,0 +1,11 @@
# NativeHeapProfilerOptions Object
* `dumpInterval` number (optional) - The interval in milliseconds at
which samples are written to the trace. Each write is a full snapshot of
every sampled allocation that is still live, so short intervals can fill the
trace buffer quickly in long recordings. Defaults to `0`, which writes a
single snapshot when tracing stops.
* `samplingInterval` number (optional) - The average number of bytes
allocated between samples. Lower values give a more precise profile at a
higher performance cost. Must be greater than `0`. Defaults to Chromium's
default, currently `131072` (128 KiB).
@@ -16,3 +16,11 @@
The trace option will first be reset to the default option (`record_mode` set
to `record-until-full`, `enable_sampling` and `enable_systrace`
set to `false`) before options parsed from `traceOptions` are applied on it.
* `nativeHeapProfiling` [NativeHeapProfilerOptions](native-heap-profiler-options.md) (optional) _Experimental_ -
**Electron extension; not part of Chromium's TraceCategoriesAndOptions schema.**
Enables native (C/C++) heap allocation profiling for this recording. This is
distinct from JavaScript heap profiling (see
[`contents.takeHeapSnapshot()`](../web-contents.md#contentstakeheapsnapshotfilepath));
it samples allocations made by Chromium and native modules. Unlike `TraceConfig`,
this object has no `included_process_ids` field, so every traced process is
profiled.
+9
View File
@@ -23,6 +23,15 @@
`disabled-by-default-memory-infra` category is enabled, this contains
optional additional configuration for data collection. See the
[Chromium memory-infra docs][memory-infra docs] for more information.
* `nativeHeapProfiling` [NativeHeapProfilerOptions](native-heap-profiler-options.md) (optional) _Experimental_ -
**Electron extension; not part of Chromium's TraceConfig schema.** Enables
native (C/C++) heap allocation profiling for this recording. This is
distinct from JavaScript heap profiling (see
[`process.takeHeapSnapshot()`](../process.md#processtakeheapsnapshotfilepath) or
[`contents.takeHeapSnapshot()`](../web-contents.md#contentstakeheapsnapshotfilepath));
it samples allocations made by Chromium and native modules.
`included_process_ids` above also limits which processes are profiled when
this is enabled.
An example TraceConfig that roughly matches what Chrome DevTools records:
+31 -2
View File
@@ -113,8 +113,37 @@ provided.
The experimental `contentTracing.enableHeapProfiling()` API has been removed.
Chromium removed the memlog implementation that backed this API and replaced it
with a Perfetto heap-profiling data source. Heap profiling through Electron's
`contentTracing` API is unavailable until that data source is integrated.
with a Perfetto heap-profiling data source. Pass a `nativeHeapProfiling` option
to `contentTracing.startRecording()` to enable heap profiling for the duration
of a trace. Like all recordings, heap-enabled recordings use the
[Perfetto protobuf format](https://perfetto.dev/docs/reference/trace-packet-proto).
The old options map to the new ones as follows:
* `samplingRate` is now `nativeHeapProfiling.samplingInterval`.
* `mode` has no direct equivalent. Every traced process is profiled; use
`included_process_ids` in a `TraceConfig` to limit which processes are traced.
* `stackMode` has no equivalent.
```js
await contentTracing.startRecording({
nativeHeapProfiling: {
samplingInterval: 128 * 1024
}
})
```
### Behavior Changed: `contentTracing` recordings use the Perfetto protobuf format
`contentTracing.stopRecording()` used to write traces in the legacy JSON trace
format. It now always writes the
[Perfetto protobuf format](https://perfetto.dev/docs/reference/trace-packet-proto),
and the JSON format is no longer available. Open recordings in the
[Perfetto UI](https://ui.perfetto.dev), or query them with Perfetto's
[Trace Processor](https://perfetto.dev/docs/analysis/trace-processor). If you
still need the legacy JSON format, Perfetto's
[`traceconv`](https://perfetto.dev/docs/quickstart/traceconv) tool can convert
a recording to it.
### Behavior Changed: screen capture requests are reported as `display-capture` in `setPermissionRequestHandler`
+1
View File
@@ -128,6 +128,7 @@ auto_filenames = {
"docs/api/structures/mime-typed-buffer.md",
"docs/api/structures/mouse-input-event.md",
"docs/api/structures/mouse-wheel-input-event.md",
"docs/api/structures/native-heap-profiler-options.md",
"docs/api/structures/navigation-entry.md",
"docs/api/structures/notification-action.md",
"docs/api/structures/notification-response.md",
@@ -9,12 +9,18 @@
#include <utility>
#include "base/files/file_util.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/task/bind_post_task.h"
#include "base/task/thread_pool.h"
#include "base/threading/thread_restrictions.h"
#include "base/trace_event/trace_config.h"
#include "content/browser/tracing/tracing_controller_impl.h" // nogncheck
#include "content/public/browser/tracing_controller.h"
#include "services/tracing/public/cpp/perfetto/perfetto_config.h"
#include "services/tracing/public/cpp/perfetto/perfetto_data_source_names.h"
#include "shell/browser/browser.h"
#include "shell/browser/javascript_environment.h"
#include "shell/common/gin_converters/callback_converter.h"
@@ -23,12 +29,48 @@
#include "shell/common/gin_helper/dictionary.h"
#include "shell/common/gin_helper/promise.h"
#include "shell/common/node_includes.h"
#include "third_party/perfetto/include/perfetto/tracing/core/trace_config.h"
#include "third_party/perfetto/protos/perfetto/config/chrome/sampling_heap_profiler.gen.h"
#include "third_party/perfetto/protos/perfetto/config/trace_config.gen.h"
using content::TracingController;
using namespace std::literals;
namespace {
struct NativeHeapProfilerOptions {
// 0 writes one snapshot when tracing stops, matching Chromium's default.
uint32_t dump_interval_ms = 0;
// 0 uses Chromium's default sampling interval
// (base::PoissonAllocationSampler::kDefaultSamplingIntervalBytes); the data
// source itself treats 0 this way, so there's no need to duplicate the
// constant here and risk drifting from it.
uint32_t sampling_interval_bytes = 0;
};
} // namespace
namespace gin {
template <>
struct Converter<NativeHeapProfilerOptions> {
static bool FromV8(v8::Isolate* isolate,
v8::Local<v8::Value> val,
NativeHeapProfilerOptions* out) {
gin_helper::Dictionary options;
if (!ConvertFromV8(isolate, val, &options))
return false;
if (options.Has("samplingInterval") &&
(!options.Get("samplingInterval", &out->sampling_interval_bytes) ||
out->sampling_interval_bytes == 0)) {
return false;
}
return !options.Has("dumpInterval") ||
options.Get("dumpInterval", &out->dump_interval_ms);
}
};
template <>
struct Converter<base::trace_event::TraceConfig> {
static bool FromV8(v8::Isolate* isolate,
@@ -62,7 +104,28 @@ struct Converter<base::trace_event::TraceConfig> {
namespace {
using CompletionCallback = base::OnceCallback<void(const base::FilePath&)>;
void AddHeapProfilingDataSource(
const base::trace_event::TraceConfig& trace_config,
const NativeHeapProfilerOptions& heap_profiler_options,
perfetto::TraceConfig* perfetto_config) {
auto* heap_data_source = perfetto_config->add_data_sources();
auto* data_source = heap_data_source->mutable_config();
data_source->set_name(tracing::kNativeHeapProfilerSourceName);
data_source->set_target_buffer(0);
for (const auto& included_pid :
trace_config.process_filter_config().included_process_ids()) {
*heap_data_source->add_producer_name_filter() = base::StrCat(
{tracing::kPerfettoProducerNamePrefix,
base::NumberToString(static_cast<uint32_t>(included_pid))});
}
perfetto::protos::gen::ChromiumSamplingHeapProfilerConfig heap_config;
heap_config.set_sampling_interval_bytes(
heap_profiler_options.sampling_interval_bytes);
heap_config.set_sampling_interval_ms(heap_profiler_options.dump_interval_ms);
data_source->set_chromium_sampling_heap_profiler_raw(
heap_config.SerializeAsString());
}
std::optional<base::FilePath> CreateTemporaryFileOnIO() {
base::FilePath temp_file_path;
@@ -148,9 +211,8 @@ v8::Local<v8::Promise> GetCategories(v8::Isolate* isolate) {
return handle;
}
v8::Local<v8::Promise> StartTracing(
v8::Isolate* isolate,
const base::trace_event::TraceConfig& trace_config) {
v8::Local<v8::Promise> StartTracing(gin::Arguments* const args) {
v8::Isolate* const isolate = args->isolate();
gin_helper::Promise<void> promise(isolate);
v8::Local<v8::Promise> handle = promise.GetHandle();
@@ -160,13 +222,67 @@ v8::Local<v8::Promise> StartTracing(
return handle;
}
if (!TracingController::GetInstance()->StartTracing(
trace_config,
gin_helper::Dictionary options;
if (!args->GetNext(&options)) {
args->ThrowTypeError(
"contentTracing.startRecording() requires a TraceConfig or "
"TraceCategoriesAndOptions object as its first argument");
return v8::Local<v8::Promise>();
}
// nativeHeapProfiling is an Electron-only addition to the Chromium-schema
// TraceConfig/TraceCategoriesAndOptions object -- pull it out before
// converting the rest, since the TraceConfig converter below doesn't know
// about it (and safely ignores it as just another unrecognized key).
std::optional<NativeHeapProfilerOptions> heap_profiler_options;
if (options.Has("nativeHeapProfiling")) {
NativeHeapProfilerOptions native_heap_profiling;
if (!options.Get("nativeHeapProfiling", &native_heap_profiling)) {
args->ThrowTypeError(
"contentTracing.startRecording()'s nativeHeapProfiling option is "
"invalid");
return v8::Local<v8::Promise>();
}
heap_profiler_options = native_heap_profiling;
}
base::trace_event::TraceConfig trace_config;
if (!gin::ConvertFromV8(isolate, options.GetHandle(), &trace_config)) {
args->ThrowTypeError(
"contentTracing.startRecording() requires a TraceConfig or "
"TraceCategoriesAndOptions object as its first argument");
return v8::Local<v8::Promise>();
}
auto* instance = content::TracingControllerImpl::GetInstance();
if (instance->IsTracing()) {
if (heap_profiler_options) {
// Silently resolving here would mislead the caller into believing heap
// profiling is now on, when the already-running recording's options
// are left untouched.
promise.RejectWithErrorMessage(
"Cannot start heap profiling while a recording is in progress");
return handle;
}
return gin_helper::Promise<void>::ResolvedPromise(isolate);
}
// Recordings always use the Perfetto protobuf format.
perfetto::TraceConfig perfetto_config = tracing::GetDefaultPerfettoConfig(
trace_config, /*privacy_filtering_enabled=*/false,
/*convert_to_legacy_json=*/false);
if (heap_profiler_options) {
AddHeapProfilingDataSource(trace_config, *heap_profiler_options,
&perfetto_config);
}
if (!instance->StartTracingWithPerfettoConfig(
perfetto_config,
base::BindOnce(gin_helper::Promise<void>::ResolvePromise,
std::move(promise)))) {
// If StartTracing returns false, that means it didn't invoke its callback.
// Return an already-resolved promise and abandon the previous promise (it
// was std::move()d into the StartTracing callback and has been deleted by
// If StartTracingWithPerfettoConfig returns false, it didn't invoke its
// callback. Return an already-resolved promise and abandon the previous
// promise (it was std::move()d into the callback and has been deleted by
// this point).
return gin_helper::Promise<void>::ResolvedPromise(isolate);
}
+295 -61
View File
@@ -1,6 +1,7 @@
import { app, contentTracing, type TraceConfig, type TraceCategoriesAndOptions } from 'electron/main';
import { expect } from 'chai';
import * as protobuf from 'protobufjs';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
@@ -11,6 +12,149 @@ import * as vm from 'node:vm';
import { ifdescribe } from './lib/spec-helpers.ts';
// Test jobs do not include Chromium's source tree, so define the subset of the
// Perfetto schema these tests read. Field numbers match
// third_party/perfetto/protos/perfetto/trace/.
const perfettoTraceType = protobuf
.parse(`
syntax = "proto2";
package perfetto.protos;
message StackSample {}
message EventCategory {
optional uint64 iid = 1;
optional string name = 2;
}
message EventName {
optional uint64 iid = 1;
optional string name = 2;
}
message InternedData {
repeated EventCategory event_categories = 1;
repeated EventName event_names = 2;
}
message LegacyEvent {
optional int32 phase = 2;
}
message TrackEvent {
repeated uint64 category_iids = 3;
optional LegacyEvent legacy_event = 6;
optional int32 type = 9;
optional uint64 name_iid = 10;
optional uint64 track_uuid = 11;
repeated string categories = 22;
optional string name = 23;
}
message ChromeMetadata {
optional string name = 1;
optional string string_value = 2;
}
message ChromeEventBundle {
repeated ChromeMetadata metadata = 2;
}
message TracePacket {
optional ChromeEventBundle chrome_events = 5;
optional uint32 trusted_packet_sequence_id = 10;
optional TrackEvent track_event = 11;
optional InternedData interned_data = 12;
optional uint32 sequence_flags = 13;
optional StackSample stack_sample = 135;
}
message Trace {
repeated TracePacket packet = 1;
}
`)
.root.lookupType('perfetto.protos.Trace');
interface InternedEntry {
iid?: string;
name?: string;
}
interface PerfettoPacket {
chromeEvents?: { metadata?: Array<{ name?: string; stringValue?: string }> };
trustedPacketSequenceId?: number;
trackEvent?: {
categoryIids?: string[];
legacyEvent?: { phase?: number };
type?: number;
nameIid?: string;
trackUuid?: string;
categories?: string[];
name?: string;
};
internedData?: { eventCategories?: InternedEntry[]; eventNames?: InternedEntry[] };
sequenceFlags?: number;
stackSample?: unknown;
}
const readPerfettoTrace = (filePath: string) =>
perfettoTraceType.toObject(perfettoTraceType.decode(fs.readFileSync(filePath)), { longs: String }) as {
packet?: PerfettoPacket[];
};
// TrackEvent.Type values.
const TYPE_SLICE_BEGIN = 1;
const TYPE_SLICE_END = 2;
const TYPE_INSTANT = 3;
// TracePacket.SequenceFlags.SEQ_INCREMENTAL_STATE_CLEARED.
const SEQ_INCREMENTAL_STATE_CLEARED = 1;
interface TraceEvent {
categories: string[];
name?: string;
type?: number;
phase?: string;
trackUuid?: string;
}
// Flattens the track events in a trace, resolving interned category and event
// names. Interning is scoped to a packet sequence and is reset whenever a
// packet clears the sequence's incremental state.
const getTraceEvents = (filePath: string): TraceEvent[] => {
const interned = new Map<number, { categories: Map<string, string>; names: Map<string, string> }>();
const events: TraceEvent[] = [];
for (const packet of readPerfettoTrace(filePath).packet ?? []) {
const sequenceId = packet.trustedPacketSequenceId ?? 0;
if (!interned.has(sequenceId) || (packet.sequenceFlags ?? 0) & SEQ_INCREMENTAL_STATE_CLEARED) {
interned.set(sequenceId, { categories: new Map(), names: new Map() });
}
const state = interned.get(sequenceId)!;
for (const { iid, name } of packet.internedData?.eventCategories ?? []) {
if (iid !== undefined && name !== undefined) state.categories.set(iid, name);
}
for (const { iid, name } of packet.internedData?.eventNames ?? []) {
if (iid !== undefined && name !== undefined) state.names.set(iid, name);
}
const event = packet.trackEvent;
if (!event) continue;
// Category groups such as "node,node.environment" are split into their
// individual categories.
const categories = [
...(event.categories ?? []),
...(event.categoryIids ?? []).map((iid) => state.categories.get(iid) ?? '')
].flatMap((category) => category.split(','));
const phase = event.legacyEvent?.phase;
events.push({
categories,
name: event.name ?? (event.nameIid !== undefined ? state.names.get(event.nameIid) : undefined),
type: event.type,
phase: phase !== undefined ? String.fromCharCode(phase) : undefined,
trackUuid: event.trackUuid
});
}
return events;
};
const hasCategory = (event: TraceEvent, category: string) => event.categories.includes(category);
const getTraceMetadata = (filePath: string) =>
new Map(
(readPerfettoTrace(filePath).packet ?? [])
.flatMap((packet) => packet.chromeEvents?.metadata ?? [])
.map(({ name, stringValue }) => [name, stringValue] as const)
);
// FIXME: The tests are skipped on linux arm64
ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTracing', () => {
let pendingRecording: Promise<string> | undefined;
@@ -36,7 +180,7 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
// path race on the final rename.
let outputFilePath: string;
beforeEach(() => {
outputFilePath = path.join(app.getPath('temp'), `electron-content-tracing-${randomUUID()}.json`);
outputFilePath = path.join(app.getPath('temp'), `electron-content-tracing-${randomUUID()}.pftrace`);
});
afterEach(async () => {
try {
@@ -80,8 +224,57 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
// If the `excluded_categories` param above is not respected, categories
// like `node,node.environment` will be included in the output.
const content = fs.readFileSync(outputFilePath).toString();
expect(content.includes('"cat":"node,node.environment"')).to.be.false();
const events = getTraceEvents(outputFilePath);
expect(events.some((event) => hasCategory(event, 'node.environment'))).to.be.false();
});
it('records the Perfetto protobuf format', async () => {
await record({}, outputFilePath);
expect(fs.readFileSync(outputFilePath)[0]).to.not.equal('{'.charCodeAt(0), 'output looks like JSON');
expect(readPerfettoTrace(outputFilePath).packet).to.be.an('array').that.is.not.empty('trace has no packets');
});
it('rejects invalid heap profiler options', () => {
expect(() =>
contentTracing.startRecording({
nativeHeapProfiling: {
samplingInterval: 0
}
})
).to.throw();
expect(() =>
contentTracing.startRecording({
nativeHeapProfiling: {
samplingInterval: -1
}
})
).to.throw();
expect(() =>
contentTracing.startRecording({
nativeHeapProfiling: {
dumpInterval: 1.5
}
})
).to.throw();
expect(() => contentTracing.startRecording({ nativeHeapProfiling: 'invalid' } as any)).to.throw();
expect(() =>
contentTracing.startRecording({
nativeHeapProfiling: {
dumpInterval: 2 ** 32
}
})
).to.throw();
});
it('rejects a second stop while heap tracing is stopping', async () => {
await contentTracing.startRecording({ nativeHeapProfiling: {} });
const firstStop = contentTracing.stopRecording(outputFilePath);
await expect(contentTracing.stopRecording(`${outputFilePath}.second`)).to.eventually.be.rejectedWith(
'Failed to stop tracing'
);
await firstStop;
});
it('accepts "categoryFilter" and "traceOptions" as a config', async () => {
@@ -95,19 +288,19 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
expect(fs.existsSync(outputFilePath)).to.be.true('output exists');
// If the `categoryFilter` param above is not respected the file will
// contain actual trace events and be far larger. When the filter is
// respected the file only contains metadata, whose size grows slowly as
// Chromium adds fields, so keep generous headroom above that baseline.
const fileSizeInKiloBytes = getFileSizeInKiloBytes(outputFilePath);
const expectedMaximumFileSize = 100; // Depends on a platform.
expect(fileSizeInKiloBytes).to.be.above(0, `the trace output file is empty, check "${outputFilePath}"`);
expect(fileSizeInKiloBytes).to.be.below(
expectedMaximumFileSize,
`the trace output file is suspiciously large (${fileSizeInKiloBytes}KB),
check "${outputFilePath}"`
// If the `categoryFilter` param above is not respected the trace will
// contain events from other categories. Metadata events use the
// always-enabled `__metadata` category.
const expectedCategories = new Set(['__ThisIsANonexistentCategory__', '__metadata']);
const unexpectedCategories = new Set(
getTraceEvents(outputFilePath)
.flatMap((event) => event.categories)
.filter((category) => !expectedCategories.has(category))
);
expect([...unexpectedCategories]).to.be.empty(`unexpected trace event categories, check "${outputFilePath}"`);
});
});
@@ -175,9 +368,59 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
expect(result).to.have.property('percentage').that.is.a('number');
expect(result.percentage).to.equal(0);
});
it('settles concurrent requests during heap profiling', async () => {
await app.whenReady();
await contentTracing.startRecording({ nativeHeapProfiling: {} });
const results = await Promise.all([contentTracing.getTraceBufferUsage(), contentTracing.getTraceBufferUsage()]);
for (const result of results) {
expect(result).to.have.property('percentage').that.is.a('number');
expect(result).to.have.property('value').that.is.a('number');
}
await contentTracing.stopRecording();
});
});
describe('captured events', () => {
it('include native heap profiler stack samples', async function () {
this.timeout(60000);
await app.whenReady();
// getTraceBufferUsage() going non-zero only means *some* data has been
// buffered -- routine trace metadata satisfies that almost instantly,
// well before the heap profiler's periodic timer has necessarily fired
// even once. Under CI's heavier CPU contention (several spec workers
// sharing a handful of cores) that timer can be delayed well past a
// single short burst of allocations, so retry with progressively
// longer recordings instead of trusting a single buffer-usage check.
let hasStackSample = false;
for (let attempt = 0; !hasStackSample && attempt < 5; attempt++) {
await contentTracing.startRecording({
nativeHeapProfiling: {
dumpInterval: 10,
samplingInterval: 1024
}
});
const allocations: Buffer[] = [];
const deadline = Date.now() + 100 * 2 ** attempt;
while (Date.now() < deadline) {
for (let index = 0; index < 1000; index++) {
allocations.push(Buffer.alloc(4096));
}
await setTimeout(10);
}
await contentTracing.stopRecording(outputFilePath);
const trace = readPerfettoTrace(outputFilePath);
hasStackSample = trace.packet?.some((packet) => packet.stackSample !== undefined) ?? false;
}
expect(hasStackSample).to.be.true('no heap profiler stack samples were captured after multiple attempts');
});
it('include V8 samples from the main process', async function () {
this.timeout(60000);
await contentTracing.startRecording({
@@ -195,11 +438,10 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
}
}
const path = await contentTracing.stopRecording();
const data = fs.readFileSync(path, 'utf8');
const parsed = JSON.parse(data);
const events = getTraceEvents(path);
expect(
parsed.traceEvents.some(
(x: any) => x.cat === 'disabled-by-default-v8.cpu_profiler' && x.name === 'ProfileChunk'
events.some(
(event) => hasCategory(event, 'disabled-by-default-v8.cpu_profiler') && event.name === 'ProfileChunk'
)
).to.be.true();
});
@@ -214,14 +456,14 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
performance.mark('test-trace-mark');
const resultPath = await contentTracing.stopRecording();
const data = fs.readFileSync(resultPath, 'utf8');
const parsed = JSON.parse(data);
const markEvents = parsed.traceEvents.filter(
(x: any) => x.cat === 'node.perf.usertiming' && x.name === 'test-trace-mark'
const markEvents = getTraceEvents(resultPath).filter(
(event) => hasCategory(event, 'node.perf.usertiming') && event.name === 'test-trace-mark'
);
expect(markEvents).to.have.lengthOf.at.least(1, 'should have node.perf.usertiming events for performance.mark()');
expect(markEvents[0].ph).to.equal('I', 'performance.mark() should emit instant (I) phase events');
// Instants are typed events, or legacy events with the 'I' phase.
expect(markEvents[0].type === TYPE_INSTANT || markEvents[0].phase === 'I').to.be.true(
'performance.mark() should emit instant events'
);
});
it('captures performance.measure() as nestable async begin/end trace events', async function () {
@@ -235,14 +477,24 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
performance.measure('test-trace-measure', 'trace-measure-start', 'trace-measure-end');
const resultPath = await contentTracing.stopRecording();
const data = fs.readFileSync(resultPath, 'utf8');
const parsed = JSON.parse(data);
const events = getTraceEvents(resultPath);
const measureEvents = parsed.traceEvents.filter(
(x: any) => x.cat === 'node.perf.usertiming' && x.name === 'test-trace-measure'
// Nestable async slices are typed begin/end events on an async track, or
// legacy events with the 'b'/'e' phases. Typed end events carry no name,
// so they are matched to the begin event by track.
const begin = events.find(
(event) =>
hasCategory(event, 'node.perf.usertiming') &&
event.name === 'test-trace-measure' &&
(event.type === TYPE_SLICE_BEGIN || event.phase === 'b')
);
expect(measureEvents.some((x: any) => x.ph === 'b')).to.be.true('should have nestable async begin (b) event');
expect(measureEvents.some((x: any) => x.ph === 'e')).to.be.true('should have nestable async end (e) event');
expect(begin).to.not.be.undefined('should have a nestable async begin event');
const hasEnd = events.some(
(event) =>
(event.type === TYPE_SLICE_END && event.trackUuid === begin!.trackUuid) ||
(event.phase === 'e' && event.name === 'test-trace-measure')
);
expect(hasEnd).to.be.true('should have a nestable async end event');
});
it('captures node.fs.sync trace events for file operations', async function () {
@@ -253,12 +505,7 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
fs.readFileSync(import.meta.filename, 'utf8');
const resultPath = await contentTracing.stopRecording();
const data = fs.readFileSync(resultPath, 'utf8');
const parsed = JSON.parse(data);
const fsEvents = parsed.traceEvents.filter(
(x: any) => typeof x.cat === 'string' && x.cat.includes('node.fs.sync')
);
const fsEvents = getTraceEvents(resultPath).filter((event) => hasCategory(event, 'node.fs.sync'));
expect(fsEvents).to.have.lengthOf.at.least(1, 'should have node.fs.sync trace events');
});
@@ -271,15 +518,10 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
await fs.promises.readFile(import.meta.filename, 'utf8');
const resultPath = await contentTracing.stopRecording();
const data = fs.readFileSync(resultPath, 'utf8');
const parsed = JSON.parse(data);
const events = getTraceEvents(resultPath);
const asyncHooksEvents = parsed.traceEvents.filter(
(x: any) => typeof x.cat === 'string' && x.cat.includes('node.async_hooks')
);
const vmEvents = parsed.traceEvents.filter(
(x: any) => typeof x.cat === 'string' && x.cat.includes('node.vm.script')
);
const asyncHooksEvents = events.filter((event) => hasCategory(event, 'node.async_hooks'));
const vmEvents = events.filter((event) => hasCategory(event, 'node.vm.script'));
expect(asyncHooksEvents).to.have.lengthOf.at.least(1, 'should have node.async_hooks events');
expect(vmEvents).to.have.lengthOf.at.least(1, 'should have node.vm.script events');
});
@@ -293,36 +535,28 @@ ifdescribe(process.arch !== 'arm64' || process.platform !== 'linux')('contentTra
await fs.promises.readFile(import.meta.filename, 'utf8');
const resultPath = await contentTracing.stopRecording();
const data = fs.readFileSync(resultPath, 'utf8');
const parsed = JSON.parse(data);
const events = getTraceEvents(resultPath);
const syncEvents = parsed.traceEvents.filter(
(x: any) => typeof x.cat === 'string' && x.cat.includes('node.fs.sync')
);
const asyncEvents = parsed.traceEvents.filter(
(x: any) => typeof x.cat === 'string' && x.cat.includes('node.fs.async')
);
const syncEvents = events.filter((event) => hasCategory(event, 'node.fs.sync'));
const asyncEvents = events.filter((event) => hasCategory(event, 'node.fs.async'));
expect(syncEvents).to.have.lengthOf.at.least(1, 'should have node.fs.sync events from wildcard pattern');
expect(asyncEvents).to.have.lengthOf.at.least(1, 'should have node.fs.async events from wildcard pattern');
});
});
describe('trace metadata', () => {
// These are necessary to be able to symbolicate heap dumps with third_party/catapult/tracing/bin/symbolize_trace.
it('includes product version and OS arch metadata in JSON output', async () => {
// These are necessary to be able to symbolicate heap profiles with Chromium's tools/tracing/symbolize_trace.
it('includes product version and OS arch metadata', async () => {
const config = {
excluded_categories: ['*']
};
await record(config, outputFilePath);
const content = fs.readFileSync(outputFilePath).toString();
const parsed = JSON.parse(content);
expect(parsed.metadata).to.be.an('object');
expect(parsed.metadata['product-version']).to.be.a('string');
expect(parsed.metadata['product-version'].startsWith(process.versions.chrome)).to.be.true();
expect(parsed.metadata['os-arch']).to.be.a('string');
expect(parsed.metadata['os-arch']).to.not.be.empty();
const metadata = getTraceMetadata(outputFilePath);
const productVersion = metadata.get('product-version');
expect(productVersion).to.be.a('string');
expect(productVersion!.startsWith(process.versions.chrome)).to.be.true();
expect(metadata.get('os-arch')).to.be.a('string').that.is.not.empty();
});
});
});
+1
View File
@@ -32,6 +32,7 @@
"graceful-fs": "^4.1.15",
"pdfjs-dist": "4.10.38",
"plist": "^3.1.0",
"protobufjs": "^8.7.2",
"ps-list": "^7.0.0",
"resedit": "^2.0.3",
"send": "^1.2.0",
+17
View File
@@ -2527,6 +2527,7 @@ __metadata:
graceful-fs: "npm:^4.1.15"
pdfjs-dist: "npm:4.10.38"
plist: "npm:^3.1.0"
protobufjs: "npm:^8.7.2"
ps-list: "npm:^7.0.0"
resedit: "npm:^2.0.3"
send: "npm:^1.2.0"
@@ -3317,6 +3318,13 @@ __metadata:
languageName: node
linkType: hard
"long@npm:^5.3.2":
version: 5.3.2
resolution: "long@npm:5.3.2"
checksum: 10c0/7130fe1cbce2dca06734b35b70d380ca3f70271c7f8852c922a7c62c86c4e35f0c39290565eca7133c625908d40e126ac57c02b1b1a4636b9457d77e1e60b981
languageName: node
linkType: hard
"loupe@npm:^2.3.6":
version: 2.3.7
resolution: "loupe@npm:2.3.7"
@@ -4327,6 +4335,15 @@ __metadata:
languageName: node
linkType: hard
"protobufjs@npm:^8.7.2":
version: 8.8.0
resolution: "protobufjs@npm:8.8.0"
dependencies:
long: "npm:^5.3.2"
checksum: 10c0/2aa7fc766dce41858ce45ad750d9bfb2d30e9121f8c4f581ee3e5deda21440cae6bb23f41ec599d33b34963164e85f38eace687ba025d804fe926ab057c05703
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"