Skip to content

test(test-utils): Add MemoryProfiler for heap snapshot testing via CDP#20555

Open
JPeer264 wants to merge 2 commits intodevelopfrom
jp/e2e-test-memory-profiler
Open

test(test-utils): Add MemoryProfiler for heap snapshot testing via CDP#20555
JPeer264 wants to merge 2 commits intodevelopfrom
jp/e2e-test-memory-profiler

Conversation

@JPeer264
Copy link
Copy Markdown
Member

@JPeer264 JPeer264 commented Apr 28, 2026

Adds CDPClient and MemoryProfiler to test-utils for V8 heap profiling. This PR prevents #20407 entirely by comparing heap snapshots.

Within a Playwright test following can now be used:

const profiler = new MemoryProfiler({ port: INSPECTOR_PORT });

await profiler.connect();

// ... make initial requests to let the runtime settle ...

const baselineSnapshot = await profiler.takeHeapSnapshot();

// ... run some operations that might leak memory ...

const finalSnapshot = await profiler.takeHeapSnapshot();
const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot);

expect(result.nodeGrowthPercent).toBeLessThan(1);

await profiler.close();

This works by using the Chrome Developer Protocol (CDP). There is also a CDPSession API available from Playwright, but that would only work for sessions which run in the browser. Theoretically, this could also work in integration tests, but the idea is that this could in the future also be extended to use the CDPSession from Playwright for browser tests.

@JPeer264 JPeer264 self-assigned this Apr 28, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

size-limit report 📦

Path Size % Change Change
@sentry/browser 26.16 kB - -
@sentry/browser - with treeshaking flags 24.63 kB - -
@sentry/browser (incl. Tracing) 44.13 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 46.34 kB - -
@sentry/browser (incl. Tracing, Profiling) 49.08 kB - -
@sentry/browser (incl. Tracing, Replay) 83.48 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 72.96 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 88.15 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 100.8 kB - -
@sentry/browser (incl. Feedback) 43.4 kB - -
@sentry/browser (incl. sendFeedback) 30.96 kB - -
@sentry/browser (incl. FeedbackAsync) 36.14 kB - -
@sentry/browser (incl. Metrics) 27.44 kB - -
@sentry/browser (incl. Logs) 27.59 kB - -
@sentry/browser (incl. Metrics & Logs) 28.28 kB - -
@sentry/react 27.9 kB - -
@sentry/react (incl. Tracing) 46.36 kB - -
@sentry/vue 31.03 kB - -
@sentry/vue (incl. Tracing) 45.96 kB - -
@sentry/svelte 26.18 kB - -
CDN Bundle 28.85 kB - -
CDN Bundle (incl. Tracing) 46.91 kB - -
CDN Bundle (incl. Logs, Metrics) 30.27 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 48.03 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 69.35 kB - -
CDN Bundle (incl. Tracing, Replay) 84.07 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 85.14 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 89.86 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 90.96 kB - -
CDN Bundle - uncompressed 84.55 kB - -
CDN Bundle (incl. Tracing) - uncompressed 140.16 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 88.75 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 143.62 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 212.71 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 257.96 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 261.41 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 271.66 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 275.1 kB - -
@sentry/nextjs (client) 48.85 kB - -
@sentry/sveltekit (client) 44.58 kB - -
@sentry/node-core 59.06 kB +0.02% +10 B 🔺
@sentry/node 170.35 kB +0.01% +13 B 🔺
@sentry/node - without tracing 96.92 kB +0.02% +10 B 🔺
@sentry/aws-serverless 113.78 kB +0.03% +30 B 🔺
@sentry/cloudflare (withSentry) - minified 164.96 kB - -
@sentry/cloudflare (withSentry) 417.1 kB - -

View base workflow run

@JPeer264 JPeer264 force-pushed the jp/e2e-test-memory-profiler branch 8 times, most recently from 1a0c0e3 to 55510e9 Compare April 29, 2026 09:45
@JPeer264 JPeer264 marked this pull request as ready for review April 29, 2026 11:19
Comment thread dev-packages/test-utils/src/memory-profiler.ts
Comment thread dev-packages/test-utils/src/cdp-client.ts
Comment thread dev-packages/test-utils/src/memory-profiler.ts
Comment thread dev-packages/test-utils/src/memory-profiler.ts
Comment thread dev-packages/test-utils/src/cdp-client.ts
Comment thread dev-packages/test-utils/src/cdp-client.ts Outdated
this._connected = false;
});

this._ws.on('message', (data: Buffer) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This function is a bit high in complexity. Maybe you can refactor that a bit.

private readonly _gcSettleDelayMs: number;
private _initialized: boolean;

readonly #debug: boolean;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The # would be syntax with an actual meaning in JS: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_elements

I would rather remove it from the variable name. But you could use it for the private fields above to have runtime-safe private fields. So instead of private _variable, you would have just #variable.

Suggested change
readonly #debug: boolean;
readonly debug: boolean;

Copy link
Copy Markdown
Member Author

@JPeer264 JPeer264 Apr 29, 2026

Choose a reason for hiding this comment

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

Exactly that was the goal here, it is here on purpose. I'll add it to the others


private async _collectGarbage(): Promise<void> {
// Multiple GC passes to ensure full collection - some V8 inspectors need this
for (let i = 0; i < 3; i++) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why exactly 3?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There are different steps in GCing. Usually one is enough, but I wanted to make sure that everything is really garbage collected and therefore only leaks are getting tested:

https://github.com/thlorenz/v8-perf/blob/eab80c8ba242b7b25a0e2a5a4845f79d181d3d4a/gc.md?plain=1#L110-L111


For that purpose, multiple rounds of work and additional tweaks to the garbage collection process may be needed since some steps (e.g. invocation of weak callbacks) are not designed to be run immediately.

(https://joyeecheung.github.io/blog/2023/12/30/fixing-nodejs-vm-apis-3/)

let totalSize = 0;

if (selfSizeIdx !== -1) {
for (let i = 0; i < snapshot.nodes.length; i += nodeFieldCount) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You could use a for of here as you are iterating over the nodes

Copy link
Copy Markdown
Member Author

@JPeer264 JPeer264 Apr 29, 2026

Choose a reason for hiding this comment

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

Don't think that would work as I don't step directly over snapshot.nodes and don't increment i by one with i++ but with i += nodeFieldCount

@JPeer264 JPeer264 requested a review from s1gr1d April 29, 2026 13:55
Comment thread dev-packages/test-utils/src/memory-profiler.ts
Comment thread dev-packages/test-utils/src/memory-profiler.ts
@JPeer264 JPeer264 force-pushed the jp/e2e-test-memory-profiler branch from ba673f9 to dc1877f Compare April 30, 2026 09:17
Comment on lines +241 to +245
nodeGrowthPercent: (nodeGrowth / baseline.nodeCount) * 100,
edgeGrowth,
edgeGrowthPercent: (edgeGrowth / baseline.edgeCount) * 100,
sizeGrowth,
sizeGrowthPercent: (sizeGrowth / baseline.totalSize) * 100,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The compareSnapshots function may divide by zero if a baseline snapshot has a totalSize, nodeCount, or edgeCount of 0, resulting in NaN values.
Severity: MEDIUM

Suggested Fix

In compareSnapshots, before calculating growth percentages, check if the denominators (baseline.nodeCount, baseline.edgeCount, baseline.totalSize) are zero. If a denominator is zero, the corresponding growth percentage should be set to 0 to avoid division by zero and prevent NaN values.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: dev-packages/test-utils/src/memory-profiler.ts#L241-L245

Potential issue: The `compareSnapshots` function calculates growth percentages by
dividing by metrics from a baseline snapshot, such as `baseline.totalSize`. The logic in
`#parseSnapshotStats` can result in `totalSize` being 0 if the heap snapshot does not
contain a `self_size` field, which is possible across different V8 environments. This
leads to a division-by-zero when calculating `sizeGrowthPercent`, silently producing
`NaN` in the returned result object. The same risk applies to `nodeCount` and
`edgeCount`.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dc1877f. Configure here.

export interface HeapUsage {
usedSize: number;
totalSize: number;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

HeapUsage interface exported but never used anywhere

Low Severity

The HeapUsage interface is defined in cdp-client.ts and re-exported from index.ts, but it is never imported or referenced by any code in the codebase. It appears to be dead code — possibly a leftover from an earlier design that used Runtime.getHeapUsage before switching to the snapshot-based approach.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dc1877f. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants