Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@
"@sentry-internal/test-utils": "link:../../../test-utils",
"typescript": "^5.5.2",
"vitest": "~3.2.0",
"wrangler": "^4.61.0",
"ws": "^8.18.3"
"wrangler": "^4.61.0"
},
"volta": {
"extends": "../../package.json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ if (!testEnv) {
}

const APP_PORT = 38787;
const INSPECTOR_PORT = 9230;

const config = getPlaywrightConfig(
{
startCommand: `pnpm dev --port ${APP_PORT}`,
// Enable inspector port for memory profiling tests via CDP
startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`,
port: APP_PORT,
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { MemoryProfiler } from '@sentry-internal/test-utils';
import { expect, test } from '@playwright/test';

/**
* Memory leak tests for Cloudflare Workers SDK.
*
* These tests verify that the CloudflareClient.dispose() mechanism properly
* cleans up resources to prevent memory leaks.
*
* The test connects directly to the wrangler dev server's V8 inspector via CDP
* (Chrome DevTools Protocol) on ws://127.0.0.1:9230/ws to take heap snapshots
* of the actual worker isolate.
*
* @see https://developers.cloudflare.com/workers/observability/dev-tools/memory-usage/
*/

// Wrangler dev exposes inspector on this port (configured in playwright.config.ts)
const INSPECTOR_PORT = 9230;

/**
* CDP-based heap snapshot test for Cloudflare Workers.
*
* This test connects directly to the wrangler dev inspector at ws://127.0.0.1:9230/ws
* to profile the actual worker's V8 isolate memory, not a browser.
*
* The wrangler dev server must be running with --inspector-port 9230.
* This is configured in playwright.config.ts.
*/
test.describe('Worker V8 isolate memory tests', () => {
test('worker memory is reclaimed after GC', async ({ baseURL }) => {
const profiler = new MemoryProfiler({ port: INSPECTOR_PORT, debug: true });

await profiler.connect();
await profiler.startProfiling();

const numRequests = 50;

for (let i = 0; i < numRequests; i++) {
const res = await fetch(baseURL!);
expect(res.status).toBe(200);
await res.text();
}

const result = await profiler.stopProfiling();

expect(result.growthKB).toBeLessThan(800);

await profiler.close();
});
});
4 changes: 3 additions & 1 deletion dev-packages/test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@
"@playwright/test": "~1.56.0"
},
"dependencies": {
"express": "^4.21.2"
"express": "^4.21.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry/core": "10.39.0",
"@types/ws": "^8.5.10",
"eslint-plugin-regexp": "^1.15.0"
},
"volta": {
Expand Down
272 changes: 272 additions & 0 deletions dev-packages/test-utils/src/cdp-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { WebSocket } from 'ws';

/**
* Configuration options for the CDP client.
*/
export interface CDPClientOptions {
/**
* WebSocket URL to connect to (e.g., 'ws://127.0.0.1:9229/ws').
* Can also use the format 'ws://host:port' without path for standard V8 inspector.
*/
url: string;

/**
* Number of connection retry attempts before giving up.
* @default 5
*/
retries?: number;

/**
* Delay in milliseconds between retry attempts.
* @default 1000
*/
retryDelayMs?: number;

/**
* Connection timeout in milliseconds.
* @default 10000
*/
connectionTimeoutMs?: number;

/**
* Default timeout for CDP method calls in milliseconds.
* @default 30000
*/
defaultTimeoutMs?: number;

/**
* Whether to log debug messages.
* @default false
*/
debug?: boolean;
}

/**
* Response type for CDP heap usage queries.
*/
export interface HeapUsage {
usedSize: number;
totalSize: number;
}

interface CDPResponse {
id?: number;
method?: string;
error?: { message: string };
result?: unknown;
}

interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}

/**
* Low-level CDP client for connecting to V8 inspector endpoints.
*
* For memory profiling, prefer using `MemoryProfiler` which provides a higher-level API.
*
* @example
* ```typescript
* const cdp = new CDPClient({ url: 'ws://127.0.0.1:9229/ws' });
* await cdp.connect();
* await cdp.send('Runtime.enable');
* await cdp.close();
* ```
*/
export class CDPClient {
private _ws: WebSocket | null;
private _messageId: number;
private _pendingRequests: Map<number, PendingRequest>;
private _connected: boolean;
private readonly _options: Required<CDPClientOptions>;

public constructor(options: CDPClientOptions) {
this._ws = null;
this._messageId = 0;
this._pendingRequests = new Map();
this._connected = false;
this._options = {
retries: 5,
retryDelayMs: 1000,
connectionTimeoutMs: 10000,
defaultTimeoutMs: 30000,
debug: false,
...options,
};
}

/**
* Connect to the V8 inspector WebSocket endpoint.
* Will retry according to the configured retry settings.
*/
public async connect(): Promise<void> {
const { retries, retryDelayMs } = this._options;

for (let attempt = 1; attempt <= retries; attempt++) {
try {
await this._tryConnect();
return;
} catch (err) {
this._log(`Connection attempt ${attempt}/${retries} failed:`, (err as Error).message);
if (attempt < retries) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
} else {
throw err;
}
}
}
}

/**
* Send a CDP method call and wait for the response.
*
* @param method - The CDP method name (e.g., 'HeapProfiler.enable')
* @param params - Optional parameters for the method
* @param timeoutMs - Timeout in milliseconds (defaults to configured defaultTimeoutMs)
* @returns The result from the CDP method
*/
public async send<T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T> {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}

const timeout = timeoutMs ?? this._options.defaultTimeoutMs;
const id = ++this._messageId;
const message = JSON.stringify({ id, method, params });

this._log('Sending:', method, params || '');

return new Promise((resolve, reject) => {
this._pendingRequests.set(id, {
resolve: value => resolve(value as T),
reject,
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._ws!.send(message);

setTimeout(() => {
if (this._pendingRequests.has(id)) {
this._pendingRequests.delete(id);
reject(new Error(`CDP request ${method} timed out after ${timeout}ms`));
}
}, timeout);
});
}

/**
* Send a CDP method call without waiting for a response.
* Useful for commands that may not return responses in certain V8 environments.
*
* @param method - The CDP method name
* @param params - Optional parameters for the method
* @param settleDelayMs - Time to wait after sending (default: 100ms)
*/
public async sendFireAndForget(method: string, params?: Record<string, unknown>, settleDelayMs = 100): Promise<void> {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}

const id = ++this._messageId;
const message = JSON.stringify({ id, method, params });

this._log('Sending (fire-and-forget):', method, params || '');

this._ws.send(message);

// Give the command time to execute
await new Promise(resolve => setTimeout(resolve, settleDelayMs));
}

/**
* Check if the client is currently connected.
*/
public isConnected(): boolean {
return this._connected && this._ws?.readyState === WebSocket.OPEN;
}

/**
* Close the WebSocket connection.
*/
public async close(): Promise<void> {
if (this._ws) {
this._ws.close();
this._ws = null;
this._connected = false;
}
}

private _log(...args: unknown[]): void {
if (this._options.debug) {
// eslint-disable-next-line no-console
console.log('[CDPClient]', ...args);
}
}

private async _tryConnect(): Promise<void> {
const { url, connectionTimeoutMs } = this._options;

return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Connection to ${url} timed out after ${connectionTimeoutMs}ms`));
}, connectionTimeoutMs);

this._ws = new WebSocket(url);

this._ws.on('open', () => {
clearTimeout(timeoutId);
this._connected = true;
this._log('WebSocket connected to', url);
resolve();
});

this._ws.on('error', (err: Error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to connect to inspector at ${url}: ${err.message}`));
});

this._ws.on('close', () => {
this._connected = false;
});

this._ws.on('message', (data: Buffer) => {
try {
const rawMessage = data.toString();
this._log('Received raw message:', rawMessage.slice(0, 500));

const message = JSON.parse(rawMessage) as CDPResponse;

// CDP event (not a response to our request)
if (message.method) {
this._log('CDP event:', message.method);
return;
}

if (message.id !== undefined) {
this._log(
'CDP response for id:',
message.id,
'error:',
message.error,
'has result:',
message.result !== undefined,
);
const pending = this._pendingRequests.get(message.id);
if (pending) {
this._pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(`CDP error: ${message.error.message}`));
} else {
pending.resolve(message.result);
}
} else {
this._log('No pending request found for id:', message.id);
}
}
} catch (e) {
this._log('Failed to parse CDP message:', e);
}
});
});
}
}
6 changes: 6 additions & 0 deletions dev-packages/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ export {

export { getPlaywrightConfig } from './playwright-config';
export { createBasicSentryServer, createTestServer } from './server';

export { CDPClient } from './cdp-client';
export type { CDPClientOptions, HeapUsage } from './cdp-client';

export { MemoryProfiler } from './memory-profiler';
export type { MemoryProfilerOptions, MemoryProfilingResult } from './memory-profiler';
Loading
Loading