Skip to content

Commit ffa4a37

Browse files
authored
test(test-utils): Add MemoryProfiler for heap snapshot testing via CDP (#20555)
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: ```ts 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](https://playwright.dev/docs/api/class-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.
1 parent a5f6198 commit ffa4a37

6 files changed

Lines changed: 669 additions & 2 deletions

File tree

dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ if (!testEnv) {
66
}
77

88
const APP_PORT = 38787;
9+
export const INSPECTOR_PORT = 9230;
910

1011
const config = getPlaywrightConfig(
1112
{
12-
startCommand: `pnpm dev --port ${APP_PORT}`,
13+
startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`,
1314
port: APP_PORT,
1415
},
1516
{
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { MemoryProfiler } from '@sentry-internal/test-utils';
2+
import { expect, test } from '@playwright/test';
3+
import { INSPECTOR_PORT } from '../playwright.config';
4+
5+
test.describe('Worker V8 isolate memory tests', () => {
6+
test('worker memory is reclaimed after GC', async ({ baseURL }) => {
7+
const profiler = new MemoryProfiler({ port: INSPECTOR_PORT });
8+
9+
// Warm up: make initial requests and let the runtime settle
10+
for (let i = 0; i < 5; i++) {
11+
await fetch(baseURL!);
12+
}
13+
14+
await profiler.connect();
15+
16+
const baselineSnapshot = await profiler.takeHeapSnapshot();
17+
18+
for (let i = 0; i < 50; i++) {
19+
const res = await fetch(baseURL!);
20+
expect(res.status).toBe(200);
21+
await res.text();
22+
}
23+
24+
const finalSnapshot = await profiler.takeHeapSnapshot();
25+
const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot);
26+
27+
expect(result.nodeGrowthPercent).toBeLessThan(1);
28+
29+
await profiler.close();
30+
});
31+
});

dev-packages/test-utils/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@
4444
"@playwright/test": "~1.56.0"
4545
},
4646
"dependencies": {
47-
"express": "^4.21.2"
47+
"express": "^4.21.2",
48+
"ws": "^8.20.0"
4849
},
4950
"devDependencies": {
5051
"@playwright/test": "~1.56.0",
5152
"@sentry/core": "10.51.0",
53+
"@types/ws": "^8.18.1",
5254
"eslint-plugin-regexp": "^1.15.0"
5355
},
5456
"volta": {
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { WebSocket } from 'ws';
2+
3+
/**
4+
* Configuration options for the Chrome Developer Protocol (CDP) client.
5+
*/
6+
export interface CDPClientOptions {
7+
/**
8+
* WebSocket URL to connect to (e.g., 'ws://127.0.0.1:9229/ws').
9+
* Can also use the format 'ws://host:port' without path for standard V8 inspector.
10+
*/
11+
url: string;
12+
13+
/**
14+
* Number of connection retry attempts before giving up.
15+
* @default 5
16+
*/
17+
retries?: number;
18+
19+
/**
20+
* Delay in milliseconds between retry attempts.
21+
* @default 1000
22+
*/
23+
retryDelayMs?: number;
24+
25+
/**
26+
* Connection timeout in milliseconds.
27+
* @default 10000
28+
*/
29+
connectionTimeoutMs?: number;
30+
31+
/**
32+
* Default timeout for CDP method calls in milliseconds.
33+
* @default 30000
34+
*/
35+
defaultTimeoutMs?: number;
36+
37+
/**
38+
* Whether to log debug messages.
39+
* @default false
40+
*/
41+
debug?: boolean;
42+
}
43+
44+
interface CDPResponse {
45+
id?: number;
46+
method?: string;
47+
params?: unknown;
48+
error?: { message: string };
49+
result?: unknown;
50+
}
51+
52+
interface PendingRequest {
53+
resolve: (value: unknown) => void;
54+
reject: (error: Error) => void;
55+
}
56+
57+
type EventHandler = (params: unknown) => void;
58+
59+
/**
60+
* Low-level CDP client for connecting to V8 inspector endpoints.
61+
*
62+
* For memory profiling, prefer using `MemoryProfiler` which provides a higher-level API.
63+
*
64+
* @example
65+
* ```typescript
66+
* const cdp = new CDPClient({ url: 'ws://127.0.0.1:9229/ws' });
67+
* await cdp.connect();
68+
* await cdp.send('Runtime.enable');
69+
* await cdp.close();
70+
* ```
71+
*/
72+
export class CDPClient {
73+
#ws: WebSocket | null;
74+
#messageId: number;
75+
#pendingRequests: Map<number, PendingRequest>;
76+
#eventHandlers: Map<string, Set<EventHandler>>;
77+
#connected: boolean;
78+
readonly #options: Required<CDPClientOptions>;
79+
80+
public constructor(options: CDPClientOptions) {
81+
this.#ws = null;
82+
this.#messageId = 0;
83+
this.#pendingRequests = new Map();
84+
this.#eventHandlers = new Map();
85+
this.#connected = false;
86+
this.#options = {
87+
retries: 5,
88+
retryDelayMs: 1000,
89+
connectionTimeoutMs: 10000,
90+
defaultTimeoutMs: 30000,
91+
debug: false,
92+
...options,
93+
};
94+
}
95+
96+
/**
97+
* Connect to the V8 inspector WebSocket endpoint.
98+
* Will retry according to the configured retry settings.
99+
*/
100+
public async connect(): Promise<void> {
101+
const { retries, retryDelayMs } = this.#options;
102+
103+
for (let attempt = 1; attempt <= retries; attempt++) {
104+
try {
105+
await this.#tryConnect();
106+
return;
107+
} catch (err) {
108+
this.#log(`Connection attempt ${attempt}/${retries} failed:`, (err as Error).message);
109+
if (attempt < retries) {
110+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
111+
} else {
112+
throw err;
113+
}
114+
}
115+
}
116+
}
117+
118+
/**
119+
* Send a CDP method call and wait for the response.
120+
*
121+
* @param method - The CDP method name (e.g., 'HeapProfiler.enable')
122+
* @param params - Optional parameters for the method
123+
* @param timeoutMs - Timeout in milliseconds (defaults to configured defaultTimeoutMs)
124+
* @returns The result from the CDP method
125+
*/
126+
public async send<T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T> {
127+
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
128+
throw new Error('WebSocket not connected');
129+
}
130+
131+
const timeout = timeoutMs ?? this.#options.defaultTimeoutMs;
132+
const id = ++this.#messageId;
133+
const message = JSON.stringify({ id, method, params });
134+
135+
this.#log('Sending:', method, params || '');
136+
137+
return new Promise((resolve, reject) => {
138+
this.#pendingRequests.set(id, {
139+
resolve: value => resolve(value as T),
140+
reject,
141+
});
142+
this.#ws!.send(message);
143+
144+
setTimeout(() => {
145+
if (this.#pendingRequests.has(id)) {
146+
this.#pendingRequests.delete(id);
147+
reject(new Error(`CDP request ${method} timed out after ${timeout}ms`));
148+
}
149+
}, timeout);
150+
});
151+
}
152+
153+
/**
154+
* Send a CDP method call without waiting for a response.
155+
* Useful for commands that may not return responses in certain V8 environments.
156+
*
157+
* @param method - The CDP method name
158+
* @param params - Optional parameters for the method
159+
* @param settleDelayMs - Time to wait after sending (default: 100ms)
160+
*/
161+
public async sendFireAndForget(method: string, params?: Record<string, unknown>, settleDelayMs = 100): Promise<void> {
162+
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
163+
throw new Error('WebSocket not connected');
164+
}
165+
166+
const id = ++this.#messageId;
167+
const message = JSON.stringify({ id, method, params });
168+
169+
this.#log('Sending (fire-and-forget):', method, params || '');
170+
171+
this.#ws.send(message);
172+
173+
// Give the command time to execute
174+
await new Promise(resolve => setTimeout(resolve, settleDelayMs));
175+
}
176+
177+
/**
178+
* Register a handler for a CDP event method (e.g., 'HeapProfiler.addHeapSnapshotChunk').
179+
* Returns a function that, when called, removes the handler.
180+
*/
181+
public on(method: string, handler: EventHandler): () => void {
182+
let handlers = this.#eventHandlers.get(method);
183+
if (!handlers) {
184+
handlers = new Set();
185+
this.#eventHandlers.set(method, handlers);
186+
}
187+
handlers.add(handler);
188+
189+
return () => {
190+
handlers.delete(handler);
191+
if (handlers.size === 0) {
192+
this.#eventHandlers.delete(method);
193+
}
194+
};
195+
}
196+
197+
/**
198+
* Check if the client is currently connected.
199+
*/
200+
public isConnected(): boolean {
201+
return this.#connected && this.#ws?.readyState === WebSocket.OPEN;
202+
}
203+
204+
/**
205+
* Close the WebSocket connection.
206+
*/
207+
public async close(): Promise<void> {
208+
if (this.#ws) {
209+
this.#ws.close();
210+
this.#ws = null;
211+
this.#connected = false;
212+
}
213+
}
214+
215+
#log(...args: unknown[]): void {
216+
if (this.#options.debug) {
217+
// eslint-disable-next-line no-console
218+
console.log('[CDPClient]', ...args);
219+
}
220+
}
221+
222+
async #tryConnect(): Promise<void> {
223+
const { url, connectionTimeoutMs } = this.#options;
224+
225+
return new Promise((resolve, reject) => {
226+
this.#ws = new WebSocket(url);
227+
228+
const timeoutId = setTimeout(() => {
229+
// Close the WebSocket to prevent state corruption from orphaned sockets on retry
230+
this.#ws?.close();
231+
reject(new Error(`Connection to ${url} timed out after ${connectionTimeoutMs}ms`));
232+
}, connectionTimeoutMs);
233+
234+
this.#ws.on('open', () => {
235+
clearTimeout(timeoutId);
236+
this.#connected = true;
237+
this.#log('WebSocket connected to', url);
238+
resolve();
239+
});
240+
241+
this.#ws.on('error', (err: Error) => {
242+
clearTimeout(timeoutId);
243+
this.#ws?.close();
244+
reject(new Error(`Failed to connect to inspector at ${url}: ${err.message}`));
245+
});
246+
247+
this.#ws.on('close', () => {
248+
this.#connected = false;
249+
});
250+
251+
this.#setupMessageHandler();
252+
});
253+
}
254+
255+
#setupMessageHandler(): void {
256+
this.#ws?.on('message', (data: Buffer) => {
257+
try {
258+
const rawMessage = data.toString();
259+
this.#log('Received raw message:', rawMessage.slice(0, 500));
260+
261+
const message = JSON.parse(rawMessage) as CDPResponse;
262+
263+
if (message.method) {
264+
this.#handleCdpEvent(message);
265+
return;
266+
}
267+
268+
if (message.id !== undefined) {
269+
this.#handleCdpResponse(message);
270+
}
271+
} catch (e) {
272+
this.#log('Failed to parse CDP message:', e);
273+
}
274+
});
275+
}
276+
277+
#handleCdpEvent(message: CDPResponse): void {
278+
this.#log('CDP event:', message.method);
279+
280+
const handlers = this.#eventHandlers.get(message.method!);
281+
282+
if (handlers) {
283+
for (const handler of handlers) {
284+
try {
285+
handler(message.params);
286+
} catch (err) {
287+
this.#log('Event handler threw:', err);
288+
}
289+
}
290+
}
291+
}
292+
293+
#handleCdpResponse(message: CDPResponse): void {
294+
this.#log('CDP response for id:', message.id, 'error:', message.error, 'has result:', message.result !== undefined);
295+
296+
const pending = this.#pendingRequests.get(message.id!);
297+
298+
if (pending) {
299+
this.#pendingRequests.delete(message.id!);
300+
301+
if (message.error) {
302+
pending.reject(new Error(`CDP error: ${message.error.message}`));
303+
} else {
304+
pending.resolve(message.result);
305+
}
306+
} else {
307+
this.#log('No pending request found for id:', message.id);
308+
}
309+
}
310+
}

dev-packages/test-utils/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ export { createBasicSentryServer, createTestServer } from './server';
2020
export { startMockSentryServer } from './mock-sentry-server';
2121
export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server';
2222
export * from './sourcemap-upload-utils';
23+
24+
export { CDPClient } from './cdp-client';
25+
export type { CDPClientOptions } from './cdp-client';
26+
27+
export { MemoryProfiler } from './memory-profiler';
28+
export type { MemoryProfilerOptions, SnapshotStats, SnapshotComparisonResult } from './memory-profiler';

0 commit comments

Comments
 (0)