mirror of
https://github.com/electron/electron.git
synced 2026-10-28 11:44:46 +08:00
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:
12 files changed
+531
-76
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user