diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts new file mode 100644 index 00000000000..f2ca3d73261 --- /dev/null +++ b/.github/scripts/chrome.mts @@ -0,0 +1,784 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import * as util from './utility.mts'; +import type { HeapSnapshotData } from './heap-snapshot-util.mts'; + +type ChromeHandle = { + process: ChildProcessWithoutNullStreams; + port: number; + userDataDir: string; +}; + +export type NetworkRequest = { + requestId: string; + url: string; + method: string; + resourceType: string; + startedAt: number; + documentUrl?: string; + requestHeaders?: Record; + requestBody?: string; + hasRequestBody: boolean; + status?: number; + statusText?: string; + mimeType?: string; + responseHeaders?: Record; + protocol?: string; + remoteIPAddress?: string; + remotePort?: number; + encodedDataLength: number; + decodedBodyLength: number; + fromDiskCache: boolean; + fromServiceWorker: boolean; + finished: boolean; + failed: boolean; + errorText?: string; +}; + +export type WebSocketConnection = { + requestId: string; + url: string; + createdAt: number; + handshakeRequestHeaders?: Record; + handshakeResponseStatus?: number; + handshakeResponseStatusText?: string; + handshakeResponseHeaders?: Record; + closedAt?: number; + sentFrameCount: number; + receivedFrameCount: number; + sentBytes: number; + receivedBytes: number; + errorCount: number; +}; + +export type NetworkSummary = { + requestCount: number; + webSocketConnectionCount: number; + webSocketSentBytes: number; + webSocketReceivedBytes: number; + finishedRequestCount: number; + failedRequestCount: number; + cachedRequestCount: number; + serviceWorkerRequestCount: number; + totalEncodedBytes: number; + totalDecodedBodyBytes: number; + sameOriginEncodedBytes: number; + thirdPartyEncodedBytes: number; + byResourceType: Record; + largestRequests: { + url: string; + method: string; + resourceType: string; + status?: number; + encodedBytes: number; + decodedBodyBytes: number; + }[]; + failedRequests: { + url: string; + method: string; + resourceType: string; + errorText?: string; + status?: number; + }[]; +}; + +export type TabMemory = { + totalBytes: number; +}; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + durationMs: number; + network: NetworkSummary; + performance: { + cdpMetrics: Record; + runtimeHeap?: { + usedSize: number; + totalSize: number; + }; + tabMemory: TabMemory; + webVitals: { + firstPaintMs?: number; + firstContentfulPaintMs?: number; + domContentLoadedEventEndMs?: number; + loadEventEndMs?: number; + longTaskCount: number; + longTaskDurationMs: number; + maxLongTaskDurationMs: number; + resourceEntryCount: number; + domElements: number; + }; + }; + heapSnapshot: HeapSnapshotData; +}; + +async function waitForProcessExit(child: ChildProcessWithoutNullStreams) { + await new Promise(resolvePromise => { + if (child.exitCode != null) { + resolvePromise(); + return; + } + const killTimer = setTimeout(() => { + child.kill('SIGKILL'); + resolvePromise(); + }, 5_000).unref(); + child.once('exit', () => { + clearTimeout(killTimer); + resolvePromise(); + }); + }); +} + +async function fetchJson(url: string, options?: RequestInit) { + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`${url} returned ${response.status}: ${await response.text()}`); + } + return await response.json() as T; +} + +function findChrome() { + const envChrome = process.env.CHROME_BIN ?? process.env.GOOGLE_CHROME_BIN; + if (envChrome != null && envChrome !== '') return envChrome; + + const candidates = process.platform === 'win32' + ? [ + 'chrome.exe', + 'msedge.exe', + ] + : [ + 'google-chrome', + 'google-chrome-stable', + 'chromium', + 'chromium-browser', + ]; + + for (const candidate of candidates) { + const result = spawnSync(candidate, ['--version'], { + stdio: 'ignore', + shell: process.platform === 'win32', + }); + if (result.status === 0) return candidate; + } + + throw new Error('Could not find Chrome or Chromium. Set CHROME_BIN to the browser executable.'); +} + +async function launchChrome(label: string): Promise { + const chrome = findChrome(); + const port = label === 'base' ? 9222 : 9223; + const userDataDir = await mkdtemp(join(tmpdir(), `misskey-browser-metrics-${label}-`)); + const child = spawn(chrome, [ + '--headless=new', + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--metrics-recording-only', + '--no-first-run', + '--no-default-browser-check', + '--no-sandbox', + `--remote-debugging-port=${port}`, + `--user-data-dir=${userDataDir}`, + 'about:blank', + ], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`)); + child.stderr.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`)); + + try { + const startedAt = Date.now(); + while (Date.now() - startedAt < 30_000) { + if (child.exitCode != null) throw new Error(`Chrome exited early with code ${child.exitCode}`); + try { + await fetchJson(`http://127.0.0.1:${port}/json/version`); + return { + process: child, + port, + userDataDir, + }; + } catch { + await util.sleep(250); + } + } + + throw new Error('Timed out waiting for Chrome DevTools Protocol'); + } catch (err) { + await closeChrome({ + process: child, + port, + userDataDir, + }); + throw err; + } +} + +async function closeChrome(handle: ChromeHandle) { + if (handle.process.exitCode == null) { + handle.process.kill(); + } + await waitForProcessExit(handle.process); + await rm(handle.userDataDir, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); +} + +type CdpResponse = { + id?: number; + method?: string; + params?: any; + result?: T; + error?: { + code: number; + message: string; + }; +}; + +function selectorReadyExpression(selector: string, options: { visible?: boolean; enabled?: boolean } = {}) { + return `(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (el == null) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + if (${options.visible === true ? 'true' : 'false'} && (style.visibility === 'hidden' || style.display === 'none' || rect.width === 0 || rect.height === 0)) return false; + if (${options.enabled === true ? 'true' : 'false'} && (el.disabled || el.getAttribute('aria-disabled') === 'true')) return false; + return true; + })()`; +} + +function normalizeHeaders(headers: Record | undefined) { + if (headers == null) return undefined; + const normalized = {} as Record; + for (const [key, value] of Object.entries(headers)) { + normalized[key] = String(value); + } + return normalized; +} + +function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) { + if (frame?.payloadData == null) return 0; + if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8'); + return Buffer.byteLength(frame.payloadData, 'base64'); +} + +class CdpClient { + private nextId = 1; + private callbacks = new Map void; + reject: (error: Error) => void; + }>(); + private eventHandlers = new Map void>>(); + private ws: WebSocket; + + private constructor(ws: WebSocket) { + this.ws = ws; + ws.addEventListener('message', event => { + const message = JSON.parse(String(event.data)) as CdpResponse; + if (message.id != null) { + const callback = this.callbacks.get(message.id); + if (callback == null) return; + this.callbacks.delete(message.id); + if (message.error != null) { + callback.reject(new Error(`${message.error.message} (${message.error.code})`)); + } else { + callback.resolve(message.result); + } + return; + } + + if (message.method != null) { + for (const handler of this.eventHandlers.get(message.method) ?? []) { + handler(message.params); + } + } + }); + + ws.addEventListener('close', () => { + for (const callback of this.callbacks.values()) { + callback.reject(new Error('CDP websocket closed')); + } + this.callbacks.clear(); + }); + } + + static async connect(wsUrl: string) { + const ws = new WebSocket(wsUrl); + await new Promise((resolvePromise, reject) => { + ws.addEventListener('open', () => resolvePromise(), { once: true }); + ws.addEventListener('error', () => reject(new Error(`Failed to connect to ${wsUrl}`)), { once: true }); + }); + return new CdpClient(ws); + } + + on(method: string, handler: (params: any) => void) { + const handlers = this.eventHandlers.get(method) ?? new Set(); + handlers.add(handler); + this.eventHandlers.set(method, handlers); + } + + send(method: string, params: Record = {}): Promise { + const id = this.nextId++; + this.ws.send(JSON.stringify({ id, method, params })); + + return new Promise((resolvePromise, reject) => { + this.callbacks.set(id, { + resolve: resolvePromise, + reject, + }); + }); + } + + close() { + this.ws.close(); + } +} + +type ChromeOptions = { + scenarioTimeoutMs: number; +}; + +export class Chrome { + private handle: ChromeHandle; + public cdp: CdpClient; + public networkRequests: NetworkRequest[] = []; + public webSocketConnections: WebSocketConnection[] = []; + private scenarioTimeoutMs: number; + private pendingNetworkDetailReads: Promise[] = []; + + constructor(handle: ChromeHandle, cdpClient: CdpClient, options: ChromeOptions) { + this.handle = handle; + this.cdp = cdpClient; + this.scenarioTimeoutMs = options.scenarioTimeoutMs; + } + + static async create(label: string, options: ChromeOptions): Promise { + const chromeHandle = await launchChrome(label); + try { + const url = await fetchJson<{ webSocketDebuggerUrl: string }>( + `http://127.0.0.1:${chromeHandle.port}/json/new?${encodeURIComponent('about:blank')}`, + { method: 'PUT' }, + ).catch(async () => await fetchJson<{ webSocketDebuggerUrl: string }>( + `http://127.0.0.1:${chromeHandle.port}/json/new?${encodeURIComponent('about:blank')}`, + )); + const cdpClient = await CdpClient.connect(url.webSocketDebuggerUrl); + return new Chrome(chromeHandle, cdpClient, options); + } catch (err) { + await closeChrome(chromeHandle); + throw err; + } + } + + static async with(label: string, options: ChromeOptions, callback: (chrome: Chrome) => T | Promise): Promise { + const chrome = await Chrome.create(label, options); + try { + return await callback(chrome); + } finally { + await chrome.close(); + } + } + + public async enableNetworkTracking() { + const requests = new Map(); + const webSockets = new Map(); + + const readRequestBody = (row: NetworkRequest) => { + if (!row.hasRequestBody || row.requestBody != null) return; + const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', { + requestId: row.requestId, + }).then(result => { + row.requestBody = result.postData; + }).catch(() => { + // Some requests expose hasPostData but no longer have retrievable body data. + }); + this.pendingNetworkDetailReads.push(pending); + }; + + this.cdp.on('Network.requestWillBeSent', params => { + if (params.request?.url == null) return; + const row: NetworkRequest = { + requestId: params.requestId, + url: params.request.url, + method: params.request.method ?? 'GET', + resourceType: params.type ?? 'Other', + startedAt: params.timestamp ?? 0, + documentUrl: params.documentURL, + requestHeaders: normalizeHeaders(params.request.headers), + requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined, + hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string', + encodedDataLength: 0, + decodedBodyLength: 0, + fromDiskCache: false, + fromServiceWorker: false, + finished: false, + failed: false, + }; + requests.set(params.requestId, row); + this.networkRequests.push(row); + }); + + this.cdp.on('Network.webSocketCreated', params => { + if (params.requestId == null || params.url == null) return; + const row: WebSocketConnection = { + requestId: params.requestId, + url: params.url, + createdAt: params.timestamp ?? 0, + sentFrameCount: 0, + receivedFrameCount: 0, + sentBytes: 0, + receivedBytes: 0, + errorCount: 0, + }; + webSockets.set(params.requestId, row); + this.webSocketConnections.push(row); + }); + + this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers); + }); + + this.cdp.on('Network.webSocketHandshakeResponseReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeResponseStatus = params.response?.status; + row.handshakeResponseStatusText = params.response?.statusText; + row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers); + }); + + this.cdp.on('Network.webSocketFrameSent', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.sentFrameCount += 1; + row.sentBytes += webSocketFramePayloadBytes(params.response); + }); + + this.cdp.on('Network.webSocketFrameReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.receivedFrameCount += 1; + row.receivedBytes += webSocketFramePayloadBytes(params.response); + }); + + this.cdp.on('Network.webSocketFrameError', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.errorCount += 1; + }); + + this.cdp.on('Network.webSocketClosed', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.closedAt = params.timestamp ?? 0; + }); + + this.cdp.on('Network.responseReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.status = params.response?.status; + row.statusText = params.response?.statusText; + row.mimeType = params.response?.mimeType; + row.responseHeaders = normalizeHeaders(params.response?.headers); + row.protocol = params.response?.protocol; + row.remoteIPAddress = params.response?.remoteIPAddress; + row.remotePort = params.response?.remotePort; + row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders); + row.fromDiskCache = params.response?.fromDiskCache === true; + row.fromServiceWorker = params.response?.fromServiceWorker === true; + }); + + this.cdp.on('Network.dataReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.decodedBodyLength += params.dataLength ?? 0; + row.encodedDataLength += params.encodedDataLength ?? 0; + }); + + this.cdp.on('Network.loadingFinished', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.finished = true; + row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0); + readRequestBody(row); + }); + + this.cdp.on('Network.loadingFailed', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.failed = true; + row.finished = true; + row.errorText = params.errorText; + readRequestBody(row); + }); + + await this.cdp.send('Network.enable'); + await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); + await this.cdp.send('Network.setBypassServiceWorker', { bypass: true }); + await this.cdp.send('Page.enable'); + await this.cdp.send('Runtime.enable'); + await this.cdp.send('Performance.enable'); + } + + public async waitForNetworkDetails() { + let settledCount = 0; + while (settledCount < this.pendingNetworkDetailReads.length) { + const pending = this.pendingNetworkDetailReads.slice(settledCount); + settledCount = this.pendingNetworkDetailReads.length; + await Promise.allSettled(pending); + } + } + + public async evaluate(expression: string, timeoutMs = 30_000): Promise { + const result = await this.cdp.send<{ + result: { value: T }; + exceptionDetails?: unknown; + }>('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + timeout: timeoutMs, + }); + + if (result.exceptionDetails != null) { + throw new Error(`Runtime.evaluate failed: ${JSON.stringify(result.exceptionDetails)}`); + } + + return result.result.value; + } + + public async waitForSelector(selector: string, options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) { + const startedAt = Date.now(); + const timeoutMs = options.timeoutMs ?? this.scenarioTimeoutMs; + while (Date.now() - startedAt < timeoutMs) { + const ready = await this.evaluate(selectorReadyExpression(selector, options), 5_000); + if (ready) return true; + await util.sleep(250); + } + return false; + } + + public async waitForAnySelector(selectors: string[], options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) { + const startedAt = Date.now(); + const timeoutMs = options.timeoutMs ?? this.scenarioTimeoutMs; + while (Date.now() - startedAt < timeoutMs) { + for (const selector of selectors) { + const ready = await this.evaluate(selectorReadyExpression(selector, options), 5_000); + if (ready) return selector; + } + await util.sleep(250); + } + return null; + } + + public async click(selector: string) { + const found = await this.waitForSelector(selector, { visible: true, enabled: true }); + if (!found) throw new Error(`Selector was not clickable: ${selector}`); + await this.evaluate(`(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (el == null) throw new Error('Element not found'); + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.click(); + })()`); + } + + public async maybeClick(selector: string, timeoutMs = 3_000) { + if (await this.waitForSelector(selector, { visible: true, enabled: true, timeoutMs })) { + await this.click(selector); + return true; + } + return false; + } + + public async setValue(selector: string, value: string) { + const found = await this.waitForSelector(selector, { visible: true, enabled: true }); + if (!found) throw new Error(`Selector was not editable: ${selector}`); + await this.evaluate(`(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (el == null) throw new Error('Element not found'); + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.focus(); + const proto = Object.getPrototypeOf(el); + const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); + if (descriptor?.set != null) { + descriptor.set.call(el, ${JSON.stringify(value)}); + } else { + el.value = ${JSON.stringify(value)}; + } + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(value)} })); + el.dispatchEvent(new Event('change', { bubbles: true })); + })()`); + } + + public async waitForText(text: string, timeoutMs = this.scenarioTimeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const found = await this.evaluate(`document.body?.innerText?.includes(${JSON.stringify(text)}) === true`, 5_000); + if (found) return true; + await util.sleep(250); + } + return false; + } + + public async collectPerformance(): Promise { + const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics'); + const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value])); + const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined); + const tabMemory = await this.collectTabMemory(); + const webVitals = await this.evaluate(`(() => { + const navigation = performance.getEntriesByType('navigation')[0]; + const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime])); + const longTasks = performance.getEntriesByType('longtask'); + const resourceEntries = performance.getEntriesByType('resource'); + return { + firstPaintMs: paintEntries['first-paint'], + firstContentfulPaintMs: paintEntries['first-contentful-paint'], + domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd, + loadEventEndMs: navigation?.loadEventEnd, + longTaskCount: longTasks.length, + longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), + maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0), + resourceEntryCount: resourceEntries.length, + domElements: document.getElementsByTagName('*').length, + }; + })()`); + + return { + cdpMetrics, + runtimeHeap, + tabMemory, + webVitals, + }; + } + + public async collectTabMemory(): Promise { + const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => { + const result = await performance.measureUserAgentSpecificMemory(); + return { bytes: result.bytes }; + })()`, 10_000); + + const userAgentSpecificBytes = userAgentSpecificMemory?.bytes; + if (!Number.isFinite(userAgentSpecificBytes)) { + throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes'); + } + + return { + totalBytes: userAgentSpecificBytes as number, + }; + } + + public async takeHeapSnapshot(savePath?: string) { + const chunks: string[] = []; + this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => { + chunks.push(params.chunk); + }); + + await this.cdp.send('HeapProfiler.enable'); + await this.cdp.send('HeapProfiler.collectGarbage'); + await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + + const content = chunks.join(''); + if (savePath != null) { + await writeFile(savePath, content); + } + + return JSON.parse(content); + } + + public async close() { + this.cdp.close(); + await closeChrome(this.handle); + } +} + +function isMeasurableRequest(row: NetworkRequest) { + return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:'); +} + +export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary { + const origin = new URL(baseUrl).origin; + const rows = requestRows.filter(isMeasurableRequest); + const byResourceType = {} as NetworkSummary['byResourceType']; + + for (const row of rows) { + const summary = byResourceType[row.resourceType] ?? { + requests: 0, + encodedBytes: 0, + decodedBodyBytes: 0, + }; + summary.requests += 1; + summary.encodedBytes += row.encodedDataLength; + summary.decodedBodyBytes += row.decodedBodyLength; + byResourceType[row.resourceType] = summary; + } + + function isSameOrigin(url: string) { + try { + return new URL(url).origin === origin; + } catch { + return false; + } + } + + return { + requestCount: rows.length, + webSocketConnectionCount: webSocketRows == null + ? rows.filter(row => row.resourceType === 'WebSocket').length + : webSocketRows.length, + webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0, + webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0, + finishedRequestCount: rows.filter(row => row.finished).length, + failedRequestCount: rows.filter(row => row.failed).length, + cachedRequestCount: rows.filter(row => row.fromDiskCache).length, + serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length, + totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0), + totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0), + sameOriginEncodedBytes: rows + .filter(row => isSameOrigin(row.url)) + .reduce((sum, row) => sum + row.encodedDataLength, 0), + thirdPartyEncodedBytes: rows + .filter(row => !isSameOrigin(row.url)) + .reduce((sum, row) => sum + row.encodedDataLength, 0), + byResourceType, + largestRequests: rows + .toSorted((a, b) => b.encodedDataLength - a.encodedDataLength) + .slice(0, 15) + .map(row => ({ + url: row.url, + method: row.method, + resourceType: row.resourceType, + status: row.status, + encodedBytes: row.encodedDataLength, + decodedBodyBytes: row.decodedBodyLength, + })), + failedRequests: rows + .filter(row => row.failed) + .map(row => ({ + url: row.url, + method: row.method, + resourceType: row.resourceType, + errorText: row.errorText, + status: row.status, + })), + }; +} diff --git a/.github/scripts/frontend-browser-detailed-html.mts b/.github/scripts/frontend-browser-detailed-html.mts new file mode 100644 index 00000000000..34240eefc65 --- /dev/null +++ b/.github/scripts/frontend-browser-detailed-html.mts @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import * as util from './utility.mts'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-report.mts'; +import type { NetworkRequest } from './chrome.mts'; + +type DiffDirection = 'added' | 'removed'; + +type RequestDiff = { + direction: DiffDirection; + round: number; + baseCount: number; + headCount: number; + request: NetworkRequest; +}; + +function escapeHtml(value: unknown) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeAttribute(value: unknown) { + return escapeHtml(value); +} + +function isHttpRequest(request: NetworkRequest) { + try { + const { protocol } = new URL(request.url); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + +function requestKey(request: NetworkRequest) { + return [ + request.method, + request.resourceType, + request.url, + ].join('\u0000'); +} + +function groupRequests(requests: NetworkRequest[] | undefined) { + const grouped = new Map(); + for (const request of requests ?? []) { + if (!isHttpRequest(request)) continue; + const key = requestKey(request); + const rows = grouped.get(key) ?? []; + rows.push(request); + grouped.set(key, rows); + } + return grouped; +} + +function byRound(samples: BrowserMeasurementSample[]) { + return new Map(samples.map(sample => [sample.round, sample])); +} + +function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) { + const baseRequests = groupRequests(baseSample?.networkRequests); + const headRequests = groupRequests(headSample?.networkRequests); + const keys = [...new Set([ + ...baseRequests.keys(), + ...headRequests.keys(), + ])].toSorted(); + const diffs: RequestDiff[] = []; + + for (const key of keys) { + const baseRows = baseRequests.get(key) ?? []; + const headRows = headRequests.get(key) ?? []; + if (headRows.length > baseRows.length) { + for (const request of headRows.slice(baseRows.length)) { + diffs.push({ + direction: 'added', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } else if (baseRows.length > headRows.length) { + for (const request of baseRows.slice(headRows.length)) { + diffs.push({ + direction: 'removed', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } + } + + return diffs; +} + +function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const baseSamples = byRound(base.samples); + const headSamples = byRound(head.samples); + const rounds = [...new Set([ + ...baseSamples.keys(), + ...headSamples.keys(), + ])].toSorted((a, b) => a - b); + return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round))); +} + +function formatMaybeJson(value: string | undefined) { + if (value == null || value === '') return null; + try { + return JSON.stringify(JSON.parse(value), null, '\t'); + } catch { + return value; + } +} + +function formatHeaders(headers: Record | undefined) { + if (headers == null || Object.keys(headers).length === 0) return null; + return JSON.stringify(headers, null, '\t'); +} + +function countBy(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) { + const counts = new Map(); + for (const diff of diffs) { + counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1); + } + return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); +} + +function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => ` + + ${escapeHtml(type)} + ${util.formatNumber(count)} + `).join(''); + + return ` +
+
+ Base samples + ${util.formatNumber(base.sampleCount)} +
+
+ Head samples + ${util.formatNumber(head.sampleCount)} +
+
+ Added in Head + ${util.formatNumber(added)} +
+
+ Removed in Head + ${util.formatNumber(removed)} +
+
+ ${typeRows === '' ? '' : ` +
+

Diffs by Resource Type

+ + + ${typeRows} + +
TypeDiff requests
+
`}`; +} + +function renderDetails(title: string, content: string | null, open = false) { + if (content == null || content === '') return ''; + return ` + + ${escapeHtml(title)} +
${escapeHtml(content)}
+ `; +} + +function renderRequest(diff: RequestDiff) { + const { request } = diff; + const requestBody = formatMaybeJson(request.requestBody); + const requestHeaders = formatHeaders(request.requestHeaders); + const responseHeaders = formatHeaders(request.responseHeaders); + const bodyNote = requestBody == null && request.hasRequestBody === true + ? '

Request body was present but could not be retrieved from CDP.

' + : ''; + + return ` +
+
+ ${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'} + ${escapeHtml(request.method)} + ${escapeHtml(request.resourceType)} + ${escapeHtml(request.status ?? '-')} +
+ ${escapeHtml(request.url)} +
+
Round
${util.formatNumber(diff.round)}
+
Base count
${util.formatNumber(diff.baseCount)}
+
Head count
${util.formatNumber(diff.headCount)}
+
Encoded
${util.formatBytes(request.encodedDataLength ?? 0)}
+
Decoded body
${util.formatBytes(request.decodedBodyLength ?? 0)}
+
MIME
${escapeHtml(request.mimeType ?? '-')}
+
Protocol
${escapeHtml(request.protocol ?? '-')}
+
Remote
${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}
+
Failed
${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}
+
+ ${bodyNote} + ${renderDetails('Request body', requestBody, requestBody != null)} + ${renderDetails('Request headers', requestHeaders)} + ${renderDetails('Response headers', responseHeaders)} +
`; +} + +function renderRound(round: number, diffs: RequestDiff[]) { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + return ` +
+

Round ${util.formatNumber(round)}

+

${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed

+
+ ${diffs.map(renderRequest).join('\n')} +
+
`; +} + +function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const diffs = diffReports(base, head); + const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b); + const generatedAt = new Date().toISOString(); + const content = diffs.length === 0 + ? '

No added or removed HTTP(S) requests were found in paired samples.

' + : rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n'); + + return ` + + + + + Frontend Browser Network Request Diff + + + +
+

Frontend Browser Network Request Diff

+

Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.

+ ${renderSummary(base, head, diffs)} + ${content} +
+ + +`; +} + +async function main() { + const [baseFile, headFile, outputFile] = process.argv.slice(2); + if (baseFile == null || headFile == null || outputFile == null) { + throw new Error('Usage: node frontend-browser-detailed-html.mts '); + } + + const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; + await writeFile(outputFile, renderHtml(base, head)); +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-report.mts new file mode 100644 index 00000000000..a140bcf836d --- /dev/null +++ b/.github/scripts/frontend-browser-report.mts @@ -0,0 +1,372 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import * as util from './utility.mts'; +import * as heapSnapshotUtil from './heap-snapshot-util.mts'; +import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts'; +import type { NetworkRequest } from './chrome.mts'; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + durationMs: number; + network: { + requestCount: number; + webSocketConnectionCount: number; + webSocketSentBytes: number; + webSocketReceivedBytes: number; + finishedRequestCount: number; + failedRequestCount: number; + cachedRequestCount: number; + serviceWorkerRequestCount: number; + totalEncodedBytes: number; + totalDecodedBodyBytes: number; + sameOriginEncodedBytes: number; + thirdPartyEncodedBytes: number; + byResourceType: Record; + largestRequests: { + url: string; + method: string; + resourceType: string; + status?: number; + encodedBytes: number; + decodedBodyBytes: number; + }[]; + failedRequests: { + url: string; + method: string; + resourceType: string; + errorText?: string; + status?: number; + }[]; + }; + performance: { + cdpMetrics: Record; + runtimeHeap?: { + usedSize: number; + totalSize: number; + }; + tabMemory: { + totalBytes: number; + }; + webVitals: { + firstPaintMs?: number; + firstContentfulPaintMs?: number; + domContentLoadedEventEndMs?: number; + loadEventEndMs?: number; + longTaskCount: number; + longTaskDurationMs: number; + maxLongTaskDurationMs: number; + resourceEntryCount: number; + domElements: number; + }; + }; + heapSnapshot: HeapSnapshotData; +}; + +export type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + networkRequests?: NetworkRequest[]; +}; + +export type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; + +function escapeCell(value: string) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); +} + +function truncate(value: string, maxLength = 140) { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; +} + +function formatMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`; + return `${util.formatNumber(value)} ms`; +} + +function formatSecondsAsMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + return formatMs(value * 1_000); +} + +function formatDelta(delta: number, formatter: (value: number) => string, colorThreshold = 0) { + if (delta === 0) return formatter(0); + return util.formatColoredDelta(delta, v => formatter(v), colorThreshold); +} + +function finiteValues(values: (number | null | undefined)[]) { + return values.filter(value => Number.isFinite(value)) as number[]; +} + +function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) { + const values = finiteValues(report.samples.map(sample => getValue(sample))); + if (values.length < 2) return null; + + const center = util.median(values); + return util.median(values.map(value => Math.abs(value - center))); +} + +function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) { + const spread = sampleSpread(report, getSampleValue); + if (spread == null) return formatter(value); + return `${formatter(value)}
± ${formatter(spread)}`; +} + +function metricRow( + label: string, + base: BrowserMetricsReport, + head: BrowserMetricsReport, + getSummaryValue: (summary: BrowserMeasurement) => number, + getSampleValue: (sample: BrowserMeasurementSample) => number, + formatter: (value: number) => string, + colorThreshold = 0 +) { + const baseValue = getSummaryValue(base.summary); + const headValue = getSummaryValue(head.summary); + if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null; + + const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); + const percent = baseValue === 0 ? null : summary.median * 100 / baseValue; + //const deltaMedian = `${formatDelta(summary.median, formatter, colorThreshold)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = formatDelta(summary.median, formatter, colorThreshold); + + //return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`; + return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter, colorThreshold)} | ${summary == null ? '-' : formatDelta(summary.max, formatter, colorThreshold)} |`; +} + +function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { + return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0); +} + +function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) { + return resourceTypeBytes(sample, resourceTypes); +} + +function getMetric(report: BrowserMeasurement, key: string) { + return report.performance.cdpMetrics[key]; +} + +function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const rows = [ + //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), + metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber), + //metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber), + metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000), + metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000), + metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000), + metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000), + metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000), + metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000), + metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000), + metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000), + metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000), + //metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs), + //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs), + //metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber), + //metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs), + //metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs), + //metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes), + //metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes), + //metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000), + //metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber), + //metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber), + //metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber), + //metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber), + //metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber), + //metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs), + //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs), + metricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber), + metricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000), + metricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000), + metricRow('Tab memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 100000), + ].filter(row => row != null); + + return [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + ].join('\n'); +} + +function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other']; + const keys = [...new Set([ + ...preferredOrder, + ...Object.keys(base.summary.network.byResourceType), + ...Object.keys(head.summary.network.byResourceType), + ])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null); + + const lines = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + for (const key of keys) { + const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + lines.push(''); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(''); + } + + lines.push(''); + lines.push('
TypeRequestsEncoded bytes
BaseHeadΔBaseHeadΔ
${key}${util.formatNumber(baseRow.requests)}${util.formatNumber(headRow.requests)}${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)}${util.formatBytes(baseRow.encodedBytes)}${util.formatBytes(headRow.encodedBytes)}${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}
'); + + return lines.join('\n'); +} + +function renderLargestRequests(report: BrowserMetricsReport, title: string) { + if (report.summary.network.largestRequests.length === 0) return null; + + const lines = [ + `
${title}`, + '', + '| Resource | Type | Status | Encoded | Decoded |', + '| --- | --- | ---: | ---: | ---: |', + ]; + + for (const request of report.summary.network.largestRequests.slice(0, 10)) { + lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`); + } + + lines.push('', '
'); + return lines.join('\n'); +} + +function renderFailedRequests(report: BrowserMetricsReport, title: string) { + if (report.summary.network.failedRequests.length === 0) return null; + + const lines = [ + `
${title}`, + '', + '| Resource | Type | Status | Error |', + '| --- | --- | ---: | --- |', + ]; + + for (const request of report.summary.network.failedRequests.slice(0, 20)) { + lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`); + } + + lines.push('', '
'); + return lines.join('\n'); +} + +function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport { + return { + summary: report.summary.heapSnapshot, + samples: report.samples.map(sample => ({ + round: sample.round, + data: sample.heapSnapshot, + })), + }; +} + +export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { + headHeapSnapshotUrl?: string; + detailedHtmlUrl?: string; +} = {}) { + const headHeapSnapshotUrl = options.headHeapSnapshotUrl; + const detailedHtmlUrl = options.detailedHtmlUrl; + const sampleSummary = base.sampleCount === head.sampleCount + ? `${base.sampleCount} samples per side` + : `${base.sampleCount} base sample(s), ${head.sampleCount} head sample(s)`; + const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); + const lines = [ + '## 🖥 Frontend Browser Metrics', + '', + renderSummaryTable(base, head), + '', + //`> Measured ${sampleSummary} with fresh headless Chrome profiles, browser cache disabled, service workers bypassed, and forced V8 GC before each heap snapshot. Base/Head values are medians; Δ median is the median of paired Head - Base sample deltas; percent uses Δ median / Base median; ± and Δ MAD are median absolute deviations. Scenario: sign up, dismiss the initial account setup dialog, create the first timeline note, then wait until that note is visible.`, + //'', + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', + '
', + 'Requests by resource type', + '', + renderResourceTypeTable(base, head), + '', + '
', + '', + '
', + 'V8 heap snapshot statistics', + '', + heapSnapshotTable ?? '_No V8 heap snapshot data._', + '', + heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), + '', + `[Download representative head heap snapshot](${headHeapSnapshotUrl})`, + '
', + '', + ]; + + for (const section of [ + //renderLargestRequests(head, 'Largest representative head requests'), + //renderFailedRequests(base, 'Failed representative base requests'), + //renderFailedRequests(head, 'Failed representative head requests'), + ]) { + if (section == null) continue; + lines.push(section, ''); + } + + return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; +} + +async function main() { + const [baseFile, headFile, outputFile] = process.argv.slice(2); + if (baseFile == null || headFile == null || outputFile == null) { + throw new Error('Usage: node frontend-browser-report.mts '); + } + + const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; + await writeFile(outputFile, renderFrontendBrowserReport(base, head, { + headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL, + detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL, + })); +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/heap-snapshot-util.mts b/.github/scripts/heap-snapshot-util.mts index c99ce5f4418..92e36188c1f 100644 --- a/.github/scripts/heap-snapshot-util.mts +++ b/.github/scripts/heap-snapshot-util.mts @@ -32,6 +32,326 @@ export type HeapSnapshotReport = { }[]; }; +export const defaultHeapSnapshotBreakdownTopN = 6; + +export function createEmptyHeapSnapshotData(): HeapSnapshotData { + const categories = {} as HeapSnapshotData['categories']; + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + categories[category] = 0; + nodeCounts[category] = 0; + } + return { + categories, + nodeCounts, + breakdowns: {} as HeapSnapshotData['breakdowns'], + }; +} + +function sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') { + const label = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (label === '') return fallback; + if (label.length <= 80) return label; + return `${label.slice(0, 77)}...`; +} + +function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) { + if (category === 'strings') return type; + + if (category === 'jsArrays') { + if (type === 'array elements') return 'Array elements'; + if (type === 'object' && name === 'Array') return 'Array objects'; + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); + } + + if (category === 'typedArrays') { + if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); + } + + if (category === 'systemObjects') { + if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); + if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); + } + + if (category === 'otherJsObjects') { + if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); + return type; + } + + if (category === 'otherNonJsObjects') { + if (type === 'extra native bytes') return 'Extra native bytes'; + if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); + } + + if (category === 'code') { + const lowerName = name.toLowerCase(); + if (lowerName.includes('bytecode')) return 'bytecode'; + if (lowerName.includes('builtin')) return 'builtins'; + if (lowerName.includes('regexp')) return 'regexp code'; + if (lowerName.includes('stub')) return 'stubs'; + return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); + } + + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); +} + +export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { + const entries = Object.entries(breakdown) + .filter(([, value]) => value > 0) + .toSorted((a, b) => b[1] - a[1]); + + const topEntries = entries.slice(0, topN); + const otherValue = entries + .slice(topN) + .reduce((sum, [, value]) => sum + value, 0); + + const collapsed = Object.fromEntries(topEntries); + if (otherValue > 0) collapsed.Other = otherValue; + return collapsed; +} + +export function collapseHeapSnapshotBreakdowns( + breakdowns: Partial>>, + topN = defaultHeapSnapshotBreakdownTopN, +) { + const collapsed = {} as NonNullable; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category === 'total') continue; + + const categoryBreakdown = breakdowns[category]; + if (categoryBreakdown == null) continue; + + const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); + if (Object.keys(collapsedCategory).length > 0) { + collapsed[category] = collapsedCategory; + } + } + + return collapsed; +} + +// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. +export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { + const meta = snapshot?.snapshot?.meta; + const nodes = snapshot?.nodes; + const edges = snapshot?.edges; + const strings = snapshot?.strings; + if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { + throw new Error('Invalid heap snapshot format'); + } + + const nodeFields = meta.node_fields; + if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); + const edgeFields = meta.edge_fields; + if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); + + const typeOffset = nodeFields.indexOf('type'); + const nameOffset = nodeFields.indexOf('name'); + const selfSizeOffset = nodeFields.indexOf('self_size'); + const edgeCountOffset = nodeFields.indexOf('edge_count'); + if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { + throw new Error('Heap snapshot is missing required node fields'); + } + const edgeTypeOffset = edgeFields.indexOf('type'); + const edgeNameOffset = edgeFields.indexOf('name_or_index'); + const edgeToNodeOffset = edgeFields.indexOf('to_node'); + if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { + throw new Error('Heap snapshot is missing required edge fields'); + } + + const nodeTypeNames = meta.node_types?.[typeOffset]; + if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); + const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; + if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); + + const nodeFieldCount = nodeFields.length; + const edgeFieldCount = edgeFields.length; + const nativeType = nodeTypeNames.indexOf('native'); + const codeType = nodeTypeNames.indexOf('code'); + const hiddenType = nodeTypeNames.indexOf('hidden'); + const stringTypes = new Set([ + nodeTypeNames.indexOf('string'), + nodeTypeNames.indexOf('concatenated string'), + nodeTypeNames.indexOf('sliced string'), + ]); + const internalEdgeType = edgeTypeNames.indexOf('internal'); + const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; + const { categories, nodeCounts } = createEmptyHeapSnapshotData(); + const breakdowns = {} as Record>; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category !== 'total') breakdowns[category] = {}; + } + + function addValue(map: Record, key: string, value: number) { + map[key] = (map[key] ?? 0) + value; + } + + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { + const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } + + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { + if (value <= 0) return; + categories[category] += value; + addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); + if (nodeIndex != null) nodeCounts[category]++; + } + + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; + if (edgeType !== internalEdgeType) continue; + + const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; + if (edgeName !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; + categories.total += selfSize; + nodeCounts.total++; + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); + continue; + } + + if (typeId === nativeType) { + if (name === 'system / JSArrayBufferData') { + addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); + } else { + addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); + } + continue; + } + + if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name, nodeIndex); + continue; + } + + if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name, nodeIndex); + continue; + } + + if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); + addJsArrayElementSize(nodeIndex); + continue; + } + } + + categories.total += extraNativeBytes; + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + if (name === 'Array') continue; + + const type = nodeTypeNames[typeId] ?? 'unknown'; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); + } + + return { + categories, + nodeCounts, + breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), + }; +} + +function finiteMedian(values: (number | null | undefined)[]) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return null; + return util.median(finiteValues); +} + +export function summarizeHeapSnapshotDataSamples( + samples: T[], + getData: (sample: T) => HeapSnapshotData | null | undefined, + options: { breakdownTopN?: number } = {}, +) { + const data = samples.map(getData); + const categories = {} as HeapSnapshotData['categories']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); + if (value != null) categories[category] = value; + } + + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); + if (value != null) nodeCounts[category] = value; + } + + if (Object.keys(categories).length === 0) return null; + + const breakdowns = {} as NonNullable; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category === 'total') continue; + + const childKeys = new Set(); + for (const snapshot of data) { + for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) { + childKeys.add(childKey); + } + } + + const categoryBreakdown = {} as Record; + for (const childKey of childKeys) { + const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey])); + if (value != null) categoryBreakdown[childKey] = value; + } + + const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN); + if (Object.keys(collapsed).length > 0) { + breakdowns[category] = collapsed; + } + } + + return { + categories, + nodeCounts, + ...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}), + }; +} + function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) { return report.summary.categories[category]; } diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/measure-backend-memory-comparison.mts index 3dce74dc0a9..2e7e67fe5af 100644 --- a/.github/scripts/measure-backend-memory-comparison.mts +++ b/.github/scripts/measure-backend-memory-comparison.mts @@ -38,7 +38,7 @@ export type MemoryReport = { const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots'); const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot'); @@ -70,51 +70,6 @@ async function resetState(repoDir: string) { } } -function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) { - const breakdowns = {} as Record>; - - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - if (category === 'total') continue; - - const childKeys = new Set(); - for (const sample of samples) { - for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) { - childKeys.add(childKey); - } - } - - const categoryBreakdown = {} as Record; - for (const childKey of childKeys) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) categoryBreakdown[childKey] = util.median(values); - } - - if (Object.keys(categoryBreakdown).length > 0) { - breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown); - } - } - - return breakdowns; -} - -function collapseHeapSnapshotBreakdown(breakdown: Record) { - const entries = Object.entries(breakdown) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N); - const otherValue = entries - .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N) - .reduce((sum, [, value]) => sum + value, 0); - - const collapsed = Object.fromEntries(topEntries); - if (otherValue > 0) collapsed.Other = otherValue; - return collapsed; -} - function summarizeSamples(samples: MemoryReport['samples']) { const summary = {} as MemoryReport['summary']; @@ -135,33 +90,12 @@ function summarizeSamples(samples: MemoryReport['samples']) { summary[phase].memoryUsage[key] = util.median(values); } - const heapSnapshotCategoryValues = {} as Record; - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.categories?.[category]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values); - } - - const heapSnapshotNodeCountValues = {} as Record; - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values); - } - - if (Object.keys(heapSnapshotCategoryValues).length > 0) { - const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase); - - summary[phase].heapSnapshot = { - categories: heapSnapshotCategoryValues, - nodeCounts: heapSnapshotNodeCountValues, - ...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}), - }; - } + const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + samples, + sample => sample.phases[phase].heapSnapshot, + { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }, + ); + if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot; } return summary; diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts new file mode 100644 index 00000000000..8cbaa85ad98 --- /dev/null +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -0,0 +1,311 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import * as util from './utility.mts'; +import * as heapSnapshotUtil from './heap-snapshot-util.mts'; +import { Chrome, summarizeNetwork } from './chrome.mts'; +import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts'; + +const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2); + +const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812'; +const scenarioTimeoutMs = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SCENARIO_TIMEOUT_MS', 90_000, 1); +const settleMs = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SETTLE_MS', 1_000, 0); +const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1); +const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); +const headHeapSnapshotWorkDir = resolve('frontend-browser-head-heap-snapshots'); + +type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + networkRequests: NetworkRequest[]; +}; + +type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; + +async function runSignupAndPostScenario(chrome: Chrome) { + const noteText = `Frontend browser metrics ${Date.now()}`; + + await chrome.cdp.send('Page.navigate', { url: `${baseUrl}/` }); + const initialSelector = await chrome.waitForAnySelector(['[data-cy-signup]', '[data-cy-open-post-form]'], { visible: true, timeoutMs: scenarioTimeoutMs }); + if (initialSelector == null) throw new Error('Timed out waiting for the signup or timeline entry point'); + + if (await chrome.waitForSelector('[data-cy-signup]', { visible: true, enabled: true, timeoutMs: 5_000 })) { + await chrome.click('[data-cy-signup]'); + + if (await chrome.waitForSelector('[data-cy-signup-rules-continue]', { visible: true, timeoutMs: 5_000 })) { + await chrome.click('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]'); + await chrome.maybeClick('[data-cy-modal-dialog-ok]', 5_000); + await chrome.click('[data-cy-signup-rules-continue]'); + } + + await chrome.setValue('[data-cy-signup-username] input', 'alice'); + await chrome.setValue('[data-cy-signup-password] input', 'alice1234'); + await chrome.setValue('[data-cy-signup-password-retype] input', 'alice1234'); + if (await chrome.waitForSelector('[data-cy-signup-invitation-code] input', { visible: true, enabled: true, timeoutMs: 2_000 })) { + await chrome.setValue('[data-cy-signup-invitation-code] input', 'test-invitation-code'); + } + await chrome.click('[data-cy-signup-submit]'); + } + + const firstReadySelector = await chrome.waitForAnySelector([ + '[data-cy-user-setup] [data-cy-modal-window-close]', + '[data-cy-open-post-form]', + ], { visible: true, enabled: true, timeoutMs: scenarioTimeoutMs }); + if (firstReadySelector == null) throw new Error('Timed out waiting for signed-in home timeline'); + + if (firstReadySelector === '[data-cy-user-setup] [data-cy-modal-window-close]') { + await chrome.click('[data-cy-user-setup] [data-cy-modal-window-close]'); + await chrome.maybeClick('[data-cy-modal-dialog-ok]', 5_000); + } + + await chrome.click('[data-cy-open-post-form]'); + await chrome.setValue('[data-cy-post-form-text]', noteText); + await chrome.click('[data-cy-open-post-form-submit]'); + + if (!await chrome.waitForText(noteText, scenarioTimeoutMs)) { + throw new Error('The first timeline note did not appear'); + } + + await util.sleep(settleMs); +} + +function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return defaultValue; + return util.median(finiteValues); +} + +function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) { + const medianValue = finiteMedian(samples.map(getValue)); + let selected: { sample: BrowserMeasurementSample; distance: number } | null = null; + + for (const sample of samples) { + const value = getValue(sample); + if (!Number.isFinite(value)) continue; + const distance = Math.abs(value - medianValue); + if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) { + selected = { + sample, + distance, + }; + } + } + + return selected?.sample ?? samples[0]; +} + +function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) { + return { + requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)), + encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)), + decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)), + }; +} + +function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary { + const resourceTypes = new Set(); + for (const sample of samples) { + for (const resourceType of Object.keys(sample.network.byResourceType)) { + resourceTypes.add(resourceType); + } + } + + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const byResourceType = {} as NetworkSummary['byResourceType']; + for (const resourceType of resourceTypes) { + byResourceType[resourceType] = summarizeResourceType(samples, resourceType); + } + + return { + requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)), + webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)), + webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)), + webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)), + finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)), + failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)), + cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)), + serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)), + totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)), + totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)), + sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)), + thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)), + byResourceType, + largestRequests: representative.network.largestRequests, + failedRequests: representative.network.failedRequests, + }; +} + +function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] { + const cdpMetricKeys = new Set(); + for (const sample of samples) { + for (const key of Object.keys(sample.performance.cdpMetrics)) { + cdpMetricKeys.add(key); + } + } + + const cdpMetrics = {} as Record; + for (const key of cdpMetricKeys) { + cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key])); + } + + const webVitalKeys = [ + 'firstPaintMs', + 'firstContentfulPaintMs', + 'domContentLoadedEventEndMs', + 'loadEventEndMs', + 'longTaskCount', + 'longTaskDurationMs', + 'maxLongTaskDurationMs', + 'resourceEntryCount', + 'domElements', + ] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[]; + + const webVitals = {} as BrowserMeasurement['performance']['webVitals']; + for (const key of webVitalKeys) { + webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key])); + } + + return { + cdpMetrics, + runtimeHeap: { + usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)), + totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)), + }, + tabMemory: { + totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)), + }, + webVitals, + }; +} + +function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) { + const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + samples, + sample => sample.heapSnapshot, + { breakdownTopN: heapSnapshotBreakdownTopN }, + ); + if (summary == null) throw new Error('No heap snapshot samples'); + return summary; +} + +function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport { + if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`); + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const summary: BrowserMeasurement = { + label, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: representative.scenario, + durationMs: finiteMedian(samples.map(sample => sample.durationMs)), + network: summarizeNetworkSamples(samples), + performance: summarizePerformanceSamples(samples), + heapSnapshot: summarizeHeapSnapshotSamples(samples), + }; + + return { + label, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: representative.scenario, + sampleCount: samples.length, + aggregation: 'median', + summary, + samples, + }; +} + +async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) { + await util.prepareInstance(baseUrl); + + return await Chrome.with(label, { scenarioTimeoutMs }, async chrome => { + await chrome.enableNetworkTracking(); + + const startedAt = Date.now(); + await runSignupAndPostScenario(chrome); + const durationMs = Date.now() - startedAt; + await chrome.waitForNetworkDetails(); + const performance = await chrome.collectPerformance(); + const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath); + const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN }); + const measurement: BrowserMeasurementSample = { + label, + round, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: 'fresh browser signup, first timeline note, after the note becomes visible', + durationMs, + network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections), + networkRequests: chrome.networkRequests, + performance, + heapSnapshot, + }; + + return measurement; + }); +} + +function headHeapSnapshotPath(round: number) { + return join(headHeapSnapshotWorkDir, `round-${round}.heapsnapshot`); +} + +async function saveRepresentativeHeadHeapSnapshot(report: BrowserMetricsReport, outputPath: string) { + const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); + await copyFile(headHeapSnapshotPath(representative.round), outputPath); + process.stderr.write(`[head] Selected round ${representative.round} heap snapshot for artifact\n`); + await rm(headHeapSnapshotWorkDir, { recursive: true, force: true }); +} + +async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) { + let server: ChildProcessWithoutNullStreams | null = null; + + try { + server = util.startServer(label, repoDir); + await util.waitForServer(baseUrl, server); + + if (label === 'head' && heapSnapshotSavePath != null) { + await rm(headHeapSnapshotWorkDir, { recursive: true, force: true }); + await mkdir(headHeapSnapshotWorkDir, { recursive: true }); + } + + const samples: BrowserMeasurementSample[] = []; + for (let round = 1; round <= sampleCount; round++) { + process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`); + samples.push(await measureSample( + label, + round, + label === 'head' && heapSnapshotSavePath != null ? headHeapSnapshotPath(round) : undefined, + )); + } + + const report = summarizeSamples(label, samples); + await writeFile(outputPath, JSON.stringify(report, null, '\t')); + process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); + + if (label === 'head' && heapSnapshotSavePath != null) { + await saveRepresentativeHeadHeapSnapshot(report, heapSnapshotSavePath); + } + } finally { + if (server != null) await util.stopServer(server); + } +} + +async function main() { + await measureRepo('base', resolve(baseDirArg), resolve(baseOutputArg)); + await measureRepo('head', resolve(headDirArg), resolve(headOutputArg), headHeapSnapshotOutputArg == null ? undefined : resolve(headHeapSnapshotOutputArg)); +} + +await main(); diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts index fb65d9191dc..caec5555ef6 100644 --- a/.github/scripts/utility.mts +++ b/.github/scripts/utility.mts @@ -5,10 +5,14 @@ // NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない -import { spawn } from 'node:child_process'; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +export function sleep(ms: number) { + return new Promise(resolvePromise => setTimeout(resolvePromise, ms)); +} + export function median(values: number[]) { const sorted = values.toSorted((a, b) => a - b); const center = Math.floor(sorted.length / 2); @@ -202,3 +206,91 @@ export function run(command: string, args: string[], options: { cwd?: string; en }); }); } + +export function startServer(label: string, repoDir: string) { + process.stderr.write(`[${label}] Starting Misskey test server\n`); + const child = spawn(commandName('pnpm'), ['start:test'], { + cwd: repoDir, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); + child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); + return child; +} + +export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 120_000) { + if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`); + try { + const response = await fetch(`${baseUrl}/`, { redirect: 'manual' }); + if (response.status < 500) return; + } catch { + // retry + } + await sleep(1_000); + } + throw new Error(`Timed out waiting for ${baseUrl}`); +} + +export async function api(baseUrl: string, endpoint: string, body: Record) { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`); + } + if (response.status === 204) return null; + return await response.json(); +} + +export async function prepareInstance(baseUrl: string) { + await api(baseUrl, 'reset-db', {}); + await api(baseUrl, 'admin/accounts/create', { + username: 'admin', + password: 'admin1234', + setupPassword: 'example_password_please_change_this_or_you_will_get_hacked', + }); +} + +export async function stopServer(child: ChildProcessWithoutNullStreams) { + if (child.exitCode != null) return; + + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + } else if (child.pid != null) { + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } + + await new Promise(resolvePromise => { + if (child.exitCode != null) { + resolvePromise(); + return; + } + child.once('exit', () => resolvePromise()); + setTimeout(() => { + if (child.pid != null) { + try { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + child.kill('SIGKILL'); + } + } + resolvePromise(); + }, 10_000).unref(); + }); +} diff --git a/.github/workflows/frontend-browser-metrics-report-comment.yml b/.github/workflows/frontend-browser-metrics-report-comment.yml new file mode 100644 index 00000000000..0312092a5bd --- /dev/null +++ b/.github/workflows/frontend-browser-metrics-report-comment.yml @@ -0,0 +1,44 @@ +name: frontend-browser-metrics-report-comment + +on: + workflow_run: + workflows: + - frontend-browser-metrics-report + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + name: Comment frontend browser metrics report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + concurrency: + group: frontend-browser-metrics-report-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: true + steps: + - name: Download browser metrics report + uses: actions/download-artifact@v8 + with: + name: frontend-browser-metrics-report + path: ${{ runner.temp }}/frontend-browser-metrics-report + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Load PR number + id: load-pr-number + shell: bash + run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-metrics-report/pr-number.txt")" >> "$GITHUB_OUTPUT" + + - name: Comment on pull request + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.load-pr-number.outputs.pr-number }} + comment-tag: frontend_browser_metrics_report + file-path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md diff --git a/.github/workflows/frontend-browser-metrics-report.yml b/.github/workflows/frontend-browser-metrics-report.yml new file mode 100644 index 00000000000..7ea8a273ace --- /dev/null +++ b/.github/workflows/frontend-browser-metrics-report.yml @@ -0,0 +1,194 @@ +name: frontend-browser-metrics-report + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-builder/** + - packages/backend/** + - packages/i18n/** + - packages/icons-subsetter/** + - packages/misskey-js/** + - packages/misskey-reversi/** + - packages/misskey-bubble-game/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .node-version + - .github/misskey/test.yml + - .github/scripts/utility.mts + - .github/scripts/frontend-browser-detailed-html.mts + - .github/scripts/frontend-browser-report.mts + - .github/scripts/heap-snapshot-util.mts + - .github/scripts/measure-frontend-browser-comparison.mts + - .github/workflows/frontend-browser-metrics-report.yml + - .github/workflows/frontend-browser-metrics-report-comment.yml + +permissions: + contents: read + pull-requests: read + +concurrency: + group: frontend-browser-metrics-report-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + report: + name: Measure frontend browser metrics + runs-on: ubuntu-latest + timeout-minutes: 90 + + services: + postgres: + image: postgres:18 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:8 + ports: + - 56312:6379 + + steps: + - name: Checkout base + uses: actions/checkout@v6.0.2 + with: + repository: ${{ github.event.pull_request.base.repo.full_name }} + ref: ${{ github.event.pull_request.base.sha }} + path: before + submodules: true + + - name: Checkout pull request + uses: actions/checkout@v6.0.2 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: after + submodules: true + + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.3 + with: + package_json_file: after/package.json + + - name: Setup Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version-file: after/.node-version + cache: pnpm + cache-dependency-path: | + before/pnpm-lock.yaml + after/pnpm-lock.yaml + + - name: Install dependencies for base + working-directory: before + run: pnpm i --frozen-lockfile + + - name: Configure base + working-directory: before + run: cp .github/misskey/test.yml .config + + - name: Build base + working-directory: before + run: pnpm build + + - name: Install dependencies for pull request + working-directory: after + run: pnpm i --frozen-lockfile + + - name: Configure pull request + working-directory: after + run: cp .github/misskey/test.yml .config + + - name: Build pull request + working-directory: after + run: pnpm build + + - name: Measure frontend browser metrics + shell: bash + env: + FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 + FRONTEND_BROWSER_METRICS_SCENARIO_TIMEOUT_MS: 120000 + FRONTEND_BROWSER_METRICS_SETTLE_MS: 1000 + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + mkdir -p "$REPORT_DIR" + node after/.github/scripts/measure-frontend-browser-comparison.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/head-heap-snapshot.heapsnapshot" + + - name: Upload browser head heap snapshot + id: upload-browser-head-heap-snapshot + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-metrics-head-heap-snapshot + path: ${{ runner.temp }}/frontend-browser-metrics-report/head-heap-snapshot.heapsnapshot + if-no-files-found: error + retention-days: 7 + + - name: Generate browser detailed html + shell: bash + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + test -s "$REPORT_DIR/before-browser.json" + test -s "$REPORT_DIR/after-browser.json" + node after/.github/scripts/frontend-browser-detailed-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html" + + - name: Upload browser detailed html + id: upload-browser-detailed-html + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-metrics-detailed-html + path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-detailed-html.html + if-no-files-found: error + archive: false + retention-days: 7 + + - name: Generate browser metrics report + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }} + FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }} + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + test -s "$REPORT_DIR/before-browser.json" + test -s "$REPORT_DIR/after-browser.json" + node after/.github/scripts/frontend-browser-report.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-metrics-report.md" + printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" + printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" + printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" + printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" + + - name: Check browser metrics report + shell: bash + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + test -s "$REPORT_DIR/frontend-browser-metrics-report.md" + test -s "$REPORT_DIR/frontend-browser-detailed-html.html" + test -s "$REPORT_DIR/pr-number.txt" + test -s "$REPORT_DIR/head-sha.txt" + cat "$REPORT_DIR/frontend-browser-metrics-report.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload browser metrics report + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-metrics-report + path: | + ${{ runner.temp }}/frontend-browser-metrics-report/before-browser.json + ${{ runner.temp }}/frontend-browser-metrics-report/after-browser.json + ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md + ${{ runner.temp }}/frontend-browser-metrics-report/pr-number.txt + ${{ runner.temp }}/frontend-browser-metrics-report/base-sha.txt + ${{ runner.temp }}/frontend-browser-metrics-report/head-sha.txt + ${{ runner.temp }}/frontend-browser-metrics-report/pr-url.txt + if-no-files-found: error + retention-days: 7 diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts index 74b84d998ff..eb7a9c037a8 100644 --- a/packages/backend/scripts/measure-memory.mts +++ b/packages/backend/scripts/measure-memory.mts @@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; //import * as http from 'node:http'; import * as fs from 'node:fs/promises'; -import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; +import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -40,7 +40,7 @@ const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Tim const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0); const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH; const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; @@ -79,246 +79,6 @@ function bytesToKiB(value: number) { return Math.round(value / 1024); } -function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') { - const label = String(value ?? '').replace(/\s+/g, ' ').trim(); - if (label === '') return fallback; - if (label.length <= 80) return label; - return `${label.slice(0, 77)}...`; -} - -function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type, name) { - if (category === 'strings') return type; - - if (category === 'jsArrays') { - if (type === 'array elements') return 'Array elements'; - if (type === 'object' && name === 'Array') return 'Array objects'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'typedArrays') { - if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'systemObjects') { - if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); - if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'otherJsObjects') { - if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); - return type; - } - - if (category === 'otherNonJsObjects') { - if (type === 'extra native bytes') return 'Extra native bytes'; - if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'code') { - const lowerName = name.toLowerCase(); - if (lowerName.includes('bytecode')) return 'bytecode'; - if (lowerName.includes('builtin')) return 'builtins'; - if (lowerName.includes('regexp')) return 'regexp code'; - if (lowerName.includes('stub')) return 'stubs'; - return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); - } - - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); -} - -function collapseHeapSnapshotBreakdown(breakdowns: Record>) { - const collapsed = {} as Record>; - - for (const [category, children] of Object.entries(breakdowns)) { - const entries = Object.entries(children) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N); - const otherValue = entries - .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N) - .reduce((sum, [, value]) => sum + value, 0); - - const categoryBreakdown = Object.fromEntries(topEntries); - if (otherValue > 0) categoryBreakdown.Other = otherValue; - if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown; - } - - return collapsed; -} - -// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. -function analyzeHeapSnapshot(snapshot) { - const meta = snapshot?.snapshot?.meta; - const nodes = snapshot?.nodes; - const edges = snapshot?.edges; - const strings = snapshot?.strings; - if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { - throw new Error('Invalid heap snapshot format'); - } - - const nodeFields = meta.node_fields; - if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); - const edgeFields = meta.edge_fields; - if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); - - const typeOffset = nodeFields.indexOf('type'); - const nameOffset = nodeFields.indexOf('name'); - const selfSizeOffset = nodeFields.indexOf('self_size'); - const edgeCountOffset = nodeFields.indexOf('edge_count'); - if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { - throw new Error('Heap snapshot is missing required node fields'); - } - const edgeTypeOffset = edgeFields.indexOf('type'); - const edgeNameOffset = edgeFields.indexOf('name_or_index'); - const edgeToNodeOffset = edgeFields.indexOf('to_node'); - if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { - throw new Error('Heap snapshot is missing required edge fields'); - } - - const nodeTypeNames = meta.node_types?.[typeOffset]; - if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); - const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; - if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); - - function createEmptyHeapSnapshotCategoryMap() { - return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record; - } - - const nodeFieldCount = nodeFields.length; - const edgeFieldCount = edgeFields.length; - const nativeType = nodeTypeNames.indexOf('native'); - const codeType = nodeTypeNames.indexOf('code'); - const hiddenType = nodeTypeNames.indexOf('hidden'); - const stringTypes = new Set([ - nodeTypeNames.indexOf('string'), - nodeTypeNames.indexOf('concatenated string'), - nodeTypeNames.indexOf('sliced string'), - ]); - const internalEdgeType = edgeTypeNames.indexOf('internal'); - const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; - const categories = createEmptyHeapSnapshotCategoryMap(); - const nodeCounts = createEmptyHeapSnapshotCategoryMap(); - const breakdowns = Object.fromEntries( - (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) - .filter(category => category !== 'total') - .map(category => [category, {}]), - ); - - function addValue(map: Record, key: string, value: number) { - map[key] = (map[key] ?? 0) + value; - } - - const edgeStartIndexes = new Map(); - const retainerCounts = new Map(); - let edgeIndex = 0; - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - edgeStartIndexes.set(nodeIndex, edgeIndex); - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { - const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; - retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); - } - } - - const jsArrayElementNodeIndexes = new Set(); - - function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { - if (value <= 0) return; - categories[category] += value; - addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); - if (nodeIndex != null) nodeCounts[category]++; - } - - function addJsArrayElementSize(nodeIndex: number) { - const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { - const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; - if (edgeType !== internalEdgeType) continue; - - const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; - if (edgeName !== 'elements') continue; - - const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; - if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { - const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); - jsArrayElementNodeIndexes.add(elementsNodeIndex); - } - break; - } - } - - if (extraNativeBytes > 0) { - addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); - } - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - const typeId = nodes[nodeIndex + typeOffset]; - const type = nodeTypeNames[typeId] ?? 'unknown'; - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - categories.total += selfSize; - nodeCounts.total++; - - if (typeId === hiddenType) { - addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); - continue; - } - - if (typeId === nativeType) { - if (name === 'system / JSArrayBufferData') { - addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); - } else { - addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); - } - continue; - } - - if (typeId === codeType) { - addCategoryValue('code', selfSize, type, name, nodeIndex); - continue; - } - - if (stringTypes.has(typeId)) { - addCategoryValue('strings', selfSize, type, name, nodeIndex); - continue; - } - - if (name === 'Array') { - addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); - addJsArrayElementSize(nodeIndex); - continue; - } - } - - categories.total += extraNativeBytes; - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; - - const typeId = nodes[nodeIndex + typeOffset]; - if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; - - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - if (name === 'Array') continue; - - const type = nodeTypeNames[typeId] ?? 'unknown'; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); - } - - return { - categories, - nodeCounts, - breakdowns: collapseHeapSnapshotBreakdown(breakdowns), - }; -} - async function getMemoryUsage(pid: number) { const path = `/proc/${pid}/status`; const status = await fs.readFile(path, 'utf-8'); @@ -417,7 +177,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise { process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);