diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts index 73abbd951b90..5c49d7c8e302 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/playwright.config.ts @@ -6,10 +6,11 @@ if (!testEnv) { } const APP_PORT = 38787; +export const INSPECTOR_PORT = 9230; const config = getPlaywrightConfig( { - startCommand: `pnpm dev --port ${APP_PORT}`, + startCommand: `pnpm dev --port ${APP_PORT} --inspector-port ${INSPECTOR_PORT}`, port: APP_PORT, }, { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts new file mode 100644 index 000000000000..740961b3083f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/memory.test.ts @@ -0,0 +1,31 @@ +import { MemoryProfiler } from '@sentry-internal/test-utils'; +import { expect, test } from '@playwright/test'; +import { INSPECTOR_PORT } from '../playwright.config'; + +test.describe('Worker V8 isolate memory tests', () => { + test('worker memory is reclaimed after GC', async ({ baseURL }) => { + const profiler = new MemoryProfiler({ port: INSPECTOR_PORT }); + + // Warm up: make initial requests and let the runtime settle + for (let i = 0; i < 5; i++) { + await fetch(baseURL!); + } + + await profiler.connect(); + + const baselineSnapshot = await profiler.takeHeapSnapshot(); + + for (let i = 0; i < 50; i++) { + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + await res.text(); + } + + const finalSnapshot = await profiler.takeHeapSnapshot(); + const result = profiler.compareSnapshots(baselineSnapshot, finalSnapshot); + + expect(result.nodeGrowthPercent).toBeLessThan(1); + + await profiler.close(); + }); +}); diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index e66c45403dd8..c304082d87f7 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -44,11 +44,13 @@ "@playwright/test": "~1.56.0" }, "dependencies": { - "express": "^4.21.2" + "express": "^4.21.2", + "ws": "^8.20.0" }, "devDependencies": { "@playwright/test": "~1.56.0", "@sentry/core": "10.51.0", + "@types/ws": "^8.18.1", "eslint-plugin-regexp": "^1.15.0" }, "volta": { diff --git a/dev-packages/test-utils/src/cdp-client.ts b/dev-packages/test-utils/src/cdp-client.ts new file mode 100644 index 000000000000..4dae3d17b892 --- /dev/null +++ b/dev-packages/test-utils/src/cdp-client.ts @@ -0,0 +1,318 @@ +import { WebSocket } from 'ws'; + +/** + * Configuration options for the Chrome Developer Protocol (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; + params?: unknown; + error?: { message: string }; + result?: unknown; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +type EventHandler = (params: unknown) => 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 { + #ws: WebSocket | null; + #messageId: number; + #pendingRequests: Map; + #eventHandlers: Map>; + #connected: boolean; + readonly #options: Required; + + public constructor(options: CDPClientOptions) { + this.#ws = null; + this.#messageId = 0; + this.#pendingRequests = new Map(); + this.#eventHandlers = 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 { + 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(method: string, params?: Record, timeoutMs?: number): Promise { + 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, + }); + 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, settleDelayMs = 100): Promise { + 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)); + } + + /** + * Register a handler for a CDP event method (e.g., 'HeapProfiler.addHeapSnapshotChunk'). + * Returns a function that, when called, removes the handler. + */ + public on(method: string, handler: EventHandler): () => void { + let handlers = this.#eventHandlers.get(method); + if (!handlers) { + handlers = new Set(); + this.#eventHandlers.set(method, handlers); + } + handlers.add(handler); + + return () => { + handlers.delete(handler); + if (handlers.size === 0) { + this.#eventHandlers.delete(method); + } + }; + } + + /** + * 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 { + if (this.#ws) { + this.#ws.close(); + this.#ws = null; + this.#connected = false; + } + } + + #log(...args: unknown[]): void { + if (this.#options.debug) { + // eslint-disable-next-line no-console + console.log('[CDPClient]', ...args); + } + } + + async #tryConnect(): Promise { + const { url, connectionTimeoutMs } = this.#options; + + return new Promise((resolve, reject) => { + this.#ws = new WebSocket(url); + + const timeoutId = setTimeout(() => { + // Close the WebSocket to prevent state corruption from orphaned sockets on retry + this.#ws?.close(); + reject(new Error(`Connection to ${url} timed out after ${connectionTimeoutMs}ms`)); + }, connectionTimeoutMs); + + this.#ws.on('open', () => { + clearTimeout(timeoutId); + this.#connected = true; + this.#log('WebSocket connected to', url); + resolve(); + }); + + this.#ws.on('error', (err: Error) => { + clearTimeout(timeoutId); + this.#ws?.close(); + reject(new Error(`Failed to connect to inspector at ${url}: ${err.message}`)); + }); + + this.#ws.on('close', () => { + this.#connected = false; + }); + + this.#setupMessageHandler(); + }); + } + + #setupMessageHandler(): void { + 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; + + if (message.method) { + this.#handleCdpEvent(message); + return; + } + + if (message.id !== undefined) { + this.#handleCdpResponse(message); + } + } catch (e) { + this.#log('Failed to parse CDP message:', e); + } + }); + } + + #handleCdpEvent(message: CDPResponse): void { + this.#log('CDP event:', message.method); + + const handlers = this.#eventHandlers.get(message.method!); + + if (handlers) { + for (const handler of handlers) { + try { + handler(message.params); + } catch (err) { + this.#log('Event handler threw:', err); + } + } + } + } + + #handleCdpResponse(message: CDPResponse): void { + 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); + } + } +} diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 54e5d11749b4..c62fbfdbe067 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -20,3 +20,9 @@ export { createBasicSentryServer, createTestServer } from './server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; + +export { CDPClient } from './cdp-client'; +export type { CDPClientOptions, HeapUsage } from './cdp-client'; + +export { MemoryProfiler } from './memory-profiler'; +export type { MemoryProfilerOptions, SnapshotStats, SnapshotComparisonResult } from './memory-profiler'; diff --git a/dev-packages/test-utils/src/memory-profiler.ts b/dev-packages/test-utils/src/memory-profiler.ts new file mode 100644 index 000000000000..1d4d60473742 --- /dev/null +++ b/dev-packages/test-utils/src/memory-profiler.ts @@ -0,0 +1,317 @@ +import { mkdir, writeFile } from 'fs/promises'; +import { dirname } from 'path'; +import { CDPClient } from './cdp-client'; + +/** + * Options for creating a MemoryProfiler. + */ +export interface MemoryProfilerOptions { + /** + * Inspector port number. + * @default 9229 + */ + port?: number; + + /** + * WebSocket path (e.g., '/ws' for wrangler, '' for Node.js inspector). + * @default '/ws' + */ + path?: string; + + /** + * Host address. + * @default '127.0.0.1' + */ + host?: string; + + /** + * Number of connection retry attempts. + * @default 10 + */ + retries?: number; + + /** + * Delay between retry attempts in milliseconds. + * @default 2000 + */ + retryDelayMs?: number; + + /** + * Delay after garbage collection in milliseconds. + * This gives V8 time to complete GC before measuring. + * @default 2000 + */ + gcSettleDelayMs?: number; + + /** + * Enable debug logging. + * @default false + */ + debug?: boolean; +} + +/** + * V8 heap snapshot format (partial). + */ +interface V8HeapSnapshot { + snapshot: { + meta: { + node_fields: string[]; + edge_fields: string[]; + }; + }; + nodes: number[]; + edges: number[]; +} + +/** + * Parsed snapshot statistics. + */ +export interface SnapshotStats { + nodeCount: number; + edgeCount: number; + totalSize: number; +} + +/** + * Result from comparing two heap snapshots. + */ +export interface SnapshotComparisonResult { + baseline: SnapshotStats; + final: SnapshotStats; + nodeGrowth: number; + nodeGrowthPercent: number; + edgeGrowth: number; + edgeGrowthPercent: number; + sizeGrowth: number; + sizeGrowthPercent: number; +} + +/** + * High-level memory profiler for V8 inspector endpoints. + * + * Provides a simple API for memory testing via CDP (Chrome DevTools Protocol). + * Works with any V8 inspector endpoint including: + * - Wrangler dev server (Cloudflare Workers) + * - Node.js inspector (--inspect flag) + * + * @example + * ```typescript + * const profiler = new MemoryProfiler({ port: 9229 }); + * await profiler.connect(); + * + * // ... make initial requests to let the runtime settle ... + * + * const baseline = await profiler.takeHeapSnapshot(); + * + * // ... run some operations that might leak memory ... + * + * const final = await profiler.takeHeapSnapshot(); + * + * const result = profiler.compareSnapshots(baseline, final); + * console.log(`Node growth: ${result.nodeGrowthPercent.toFixed(2)}%`); + * + * await profiler.close(); + * ``` + */ +export class MemoryProfiler { + readonly #cdp: CDPClient; + readonly #gcSettleDelayMs: number; + readonly #debug: boolean; + + #initialized: boolean; + + public constructor(options: MemoryProfilerOptions = {}) { + const { + port = 9229, + path = '/ws', + host = '127.0.0.1', + retries = 10, + retryDelayMs = 2000, + gcSettleDelayMs = 3000, + debug = false, + } = options; + + this.#debug = debug; + + this.#cdp = new CDPClient({ + url: `ws://${host}:${port}${path}`, + retries, + retryDelayMs, + debug, + }); + this.#gcSettleDelayMs = gcSettleDelayMs; + this.#initialized = false; + } + + /** + * Connect to the V8 inspector and enable required CDP domains. + */ + public async connect(): Promise { + await this.#cdp.connect(); + await this.#cdp.send('HeapProfiler.enable'); + await this.#cdp.send('Runtime.enable'); + this.#initialized = true; + } + + /** + * Check if the profiler is connected to the inspector. + */ + public isConnected(): boolean { + return this.#cdp.isConnected() && this.#initialized; + } + + /** + * Capture a V8 heap snapshot. If `outputPath` is provided, the snapshot is written there + * as a `.heapsnapshot` file that can be loaded into Chrome DevTools (Memory tab → Load). + * + * Some V8 inspectors (e.g., wrangler) stream chunks via `HeapProfiler.addHeapSnapshotChunk` + * but never send a response to the `takeHeapSnapshot` request. We work around that by + * resolving once chunk events go idle for `chunkIdleMs` (default 2s). + * + * @param outputPath - Optional file path to save the snapshot + * @param chunkIdleMs - How long to wait after the last chunk before considering the snapshot complete + * @param overallTimeoutMs - Maximum time to wait for any chunks before throwing (prevents infinite hang) + * @returns The full snapshot string. + */ + public async takeHeapSnapshot(outputPath?: string, chunkIdleMs = 2000, overallTimeoutMs = 5000): Promise { + this.#ensureConnected(); + await this.#collectGarbage(); + + const chunks: string[] = []; + const startedAt = Date.now(); + let lastChunkAt = Date.now(); + let receivedAny = false; + + const unsubscribe = this.#cdp.on('HeapProfiler.addHeapSnapshotChunk', params => { + const chunk = (params as { chunk?: string }).chunk; + if (typeof chunk === 'string') { + chunks.push(chunk); + lastChunkAt = Date.now(); + receivedAny = true; + } + }); + + try { + await this.#cdp.sendFireAndForget('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + captureNumericValue: false, + }); + + // Poll until chunks stop arriving for `chunkIdleMs`, or we hit the overall timeout + const pollInterval = 200; + + while (!receivedAny || Date.now() - lastChunkAt < chunkIdleMs) { + if (!receivedAny && Date.now() - startedAt > overallTimeoutMs) { + throw new Error(`Heap snapshot timed out after ${overallTimeoutMs}ms: no chunks received from V8 inspector`); + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } finally { + unsubscribe(); + } + + const snapshot = chunks.join(''); + + if (outputPath) { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, snapshot, 'utf8'); + } + + return snapshot; + } + + /** + * Compare two heap snapshots and return growth metrics. + * This is more reliable than `Runtime.getHeapUsage` for leak detection + * as it measures actual retained objects rather than V8 internal metrics. + */ + public compareSnapshots(baselineSnapshot: string, finalSnapshot: string): SnapshotComparisonResult { + const baseline = this.#parseSnapshotStats(baselineSnapshot); + const final = this.#parseSnapshotStats(finalSnapshot); + + const nodeGrowth = final.nodeCount - baseline.nodeCount; + const edgeGrowth = final.edgeCount - baseline.edgeCount; + const sizeGrowth = final.totalSize - baseline.totalSize; + + const result: SnapshotComparisonResult = { + baseline, + final, + nodeGrowth, + nodeGrowthPercent: (nodeGrowth / baseline.nodeCount) * 100, + edgeGrowth, + edgeGrowthPercent: (edgeGrowth / baseline.edgeCount) * 100, + sizeGrowth, + sizeGrowthPercent: (sizeGrowth / baseline.totalSize) * 100, + }; + + if (this.#debug) { + // eslint-disable-next-line no-console + console.log('Snapshot comparison:', { + baselineNodes: baseline.nodeCount, + finalNodes: final.nodeCount, + nodeGrowth, + nodeGrowthPercent: `${result.nodeGrowthPercent.toFixed(2)}%`, + sizeGrowthKB: (sizeGrowth / 1024).toFixed(2), + }); + } + + return result; + } + + /** + * Parse a heap snapshot string and extract statistics. + */ + #parseSnapshotStats(snapshotJson: string): SnapshotStats { + const snapshot = JSON.parse(snapshotJson) as V8HeapSnapshot; + const meta = snapshot.snapshot?.meta; + + if (!meta?.node_fields) { + throw new Error('Invalid heap snapshot format: missing meta.node_fields'); + } + + if (!meta?.edge_fields) { + throw new Error('Invalid heap snapshot format: missing meta.edge_fields'); + } + + const nodeFieldCount = meta.node_fields.length; + const nodeCount = snapshot.nodes.length / nodeFieldCount; + const edgeCount = snapshot.edges.length / meta.edge_fields.length; + + const selfSizeIdx = meta.node_fields.indexOf('self_size'); + let totalSize = 0; + + if (selfSizeIdx !== -1) { + for (let i = 0; i < snapshot.nodes.length; i += nodeFieldCount) { + totalSize += snapshot.nodes[i + selfSizeIdx] ?? 0; + } + } + + return { nodeCount, edgeCount, totalSize }; + } + + /** + * Close the connection to the inspector. + */ + public async close(): Promise { + await this.#cdp.close(); + this.#initialized = false; + } + + #ensureConnected(): void { + if (!this.#initialized) { + throw new Error('MemoryProfiler not connected. Call connect() first.'); + } + } + + async #collectGarbage(): Promise { + // V8 uses generational GC (young/old generations) and incremental marking. + // A single GC call may only collect young generation objects. Multiple passes + // ensure objects are promoted to old generation and fully collected, giving + // more stable heap measurements for leak detection. + for (let i = 0; i < 3; i++) { + await this.#cdp.sendFireAndForget('HeapProfiler.collectGarbage', undefined, 500); + } + await new Promise(resolve => setTimeout(resolve, this.#gcSettleDelayMs)); + } +}