UNPKG

chrome-devtools-frontend

Version:
637 lines (558 loc) • 27.8 kB
// Copyright 2024 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Core from '../core/core.js'; import type * as Lantern from '../types/types.js'; import type {BaseNode, Node} from './BaseNode.js'; import {CPUNode} from './CPUNode.js'; import {NetworkNode} from './NetworkNode.js'; // COMPAT: m71+ We added RunTask to `disabled-by-default-lighthouse` const SCHEDULABLE_TASK_TITLE_LH = 'RunTask'; // m69-70 DoWork is different and we now need RunTask, see https://bugs.chromium.org/p/chromium/issues/detail?id=871204#c11 const SCHEDULABLE_TASK_TITLE_ALT1 = 'ThreadControllerImpl::RunTask'; // In m66-68 refactored to this task title, https://crrev.com/c/883346 const SCHEDULABLE_TASK_TITLE_ALT2 = 'ThreadControllerImpl::DoWork'; // m65 and earlier const SCHEDULABLE_TASK_TITLE_ALT3 = 'TaskQueueManager::ProcessTaskFromWorkQueue'; interface NetworkNodeOutput { nodes: NetworkNode[]; idToNodeMap: Map<string, NetworkNode>; urlToNodeMap: Map<string, NetworkNode[]>; frameIdToNodeMap: Map<string, NetworkNode|null>; } // Shorter tasks have negligible impact on simulation results. const SIGNIFICANT_DUR_THRESHOLD_MS = 10; // TODO: video files tend to be enormous and throw off all graph traversals, move this ignore // into estimation logic when we use the dependency graph for other purposes. const IGNORED_MIME_TYPES_REGEX = /^video/; class PageDependencyGraph { static getNetworkInitiators(request: Lantern.NetworkRequest): string[] { if (!request.initiator) { return []; } if (request.initiator.url) { return [request.initiator.url]; } if (request.initiator.type === 'script') { // Script initiators have the stack of callFrames from all functions that led to this request. // If async stacks are enabled, then the stack will also have the parent functions that asynchronously // led to this request chained in the `parent` property. const scriptURLs = new Set<string>(); let stack = request.initiator.stack; while (stack) { const callFrames = stack.callFrames || []; for (const frame of callFrames) { if (frame.url) { scriptURLs.add(frame.url); } } stack = stack.parent; } return Array.from(scriptURLs); } return []; } static getNetworkNodeOutput(networkRequests: Lantern.NetworkRequest[]): NetworkNodeOutput { const nodes: NetworkNode[] = []; const idToNodeMap = new Map<string, NetworkNode>(); const urlToNodeMap = new Map<string, NetworkNode[]>(); const frameIdToNodeMap = new Map<string, NetworkNode|null>(); networkRequests.forEach(request => { if (IGNORED_MIME_TYPES_REGEX.test(request.mimeType)) { return; } if (request.fromWorker) { return; } // Network requestIds can be duplicated for an unknown reason // Suffix all subsequent requests with `:duplicate` until it's unique // NOTE: This should never happen with modern NetworkRequest library, but old fixtures // might still have this issue. while (idToNodeMap.has(request.requestId)) { request.requestId += ':duplicate'; } const node = new NetworkNode(request); nodes.push(node); const urlList = urlToNodeMap.get(request.url) || []; urlList.push(node); idToNodeMap.set(request.requestId, node); urlToNodeMap.set(request.url, urlList); // If the request was for the root document of an iframe, save an entry in our // map so we can link up the task `args.data.frame` dependencies later in graph creation. if (request.frameId && request.resourceType === 'Document' && request.documentURL === request.url) { // If there's ever any ambiguity, permanently set the value to `false` to avoid loops in the graph. const value = frameIdToNodeMap.has(request.frameId) ? null : node; frameIdToNodeMap.set(request.frameId, value); } }); return {nodes, idToNodeMap, urlToNodeMap, frameIdToNodeMap}; } static isScheduleableTask(evt: Lantern.TraceEvent): boolean { return evt.name === SCHEDULABLE_TASK_TITLE_LH || evt.name === SCHEDULABLE_TASK_TITLE_ALT1 || evt.name === SCHEDULABLE_TASK_TITLE_ALT2 || evt.name === SCHEDULABLE_TASK_TITLE_ALT3; } /** * There should *always* be at least one top level event, having 0 typically means something is * drastically wrong with the trace and we should just give up early and loudly. */ static assertHasToplevelEvents(events: Lantern.TraceEvent[]): void { const hasToplevelTask = events.some(this.isScheduleableTask); if (!hasToplevelTask) { throw new Core.LanternError('Could not find any top level events'); } } static getCPUNodes(mainThreadEvents: Lantern.TraceEvent[]): CPUNode[] { const nodes: CPUNode[] = []; let i = 0; PageDependencyGraph.assertHasToplevelEvents(mainThreadEvents); while (i < mainThreadEvents.length) { const evt = mainThreadEvents[i]; i++; // Skip all trace events that aren't schedulable tasks with sizable duration if (!PageDependencyGraph.isScheduleableTask(evt) || !evt.dur) { continue; } let correctedEndTs: number|undefined = undefined; // Capture all events that occurred within the task const children: Lantern.TraceEvent[] = []; for (const endTime = evt.ts + evt.dur; i < mainThreadEvents.length && mainThreadEvents[i].ts < endTime; i++) { const event = mainThreadEvents[i]; // Temporary fix for a Chrome bug where some RunTask events can be overlapping. // We correct that here be ensuring each RunTask ends at least 1 microsecond before the next // https://github.com/GoogleChrome/lighthouse/issues/15896 // https://issues.chromium.org/issues/329678173 if (PageDependencyGraph.isScheduleableTask(event) && event.dur) { correctedEndTs = event.ts - 1; break; } children.push(event); } nodes.push(new CPUNode(evt, children, correctedEndTs)); } return nodes; } static linkNetworkNodes(rootNode: NetworkNode, networkNodeOutput: NetworkNodeOutput): void { networkNodeOutput.nodes.forEach(node => { const directInitiatorRequest = node.request.initiatorRequest || rootNode.request; const directInitiatorNode = networkNodeOutput.idToNodeMap.get(directInitiatorRequest.requestId) || rootNode; const canDependOnInitiator = !directInitiatorNode.isDependentOn(node) && node.canDependOn(directInitiatorNode); const initiators = PageDependencyGraph.getNetworkInitiators(node.request); if (initiators.length) { initiators.forEach(initiator => { const parentCandidates = networkNodeOutput.urlToNodeMap.get(initiator) || []; // Only add the edge if the parent is unambiguous with valid timing and isn't circular. if (parentCandidates.length === 1 && parentCandidates[0].startTime <= node.startTime && !parentCandidates[0].isDependentOn(node)) { node.addDependency(parentCandidates[0]); } else if (canDependOnInitiator) { directInitiatorNode.addDependent(node); } }); } else if (canDependOnInitiator) { directInitiatorNode.addDependent(node); } // Make sure the nodes are attached to the graph if the initiator information was invalid. if (node !== rootNode && node.getDependencies().length === 0 && node.canDependOn(rootNode)) { node.addDependency(rootNode); } if (!node.request.redirects) { return; } const redirects = [...node.request.redirects, node.request]; for (let i = 1; i < redirects.length; i++) { const redirectNode = networkNodeOutput.idToNodeMap.get(redirects[i - 1].requestId); const actualNode = networkNodeOutput.idToNodeMap.get(redirects[i].requestId); if (actualNode && redirectNode) { actualNode.addDependency(redirectNode); } } }); } static linkCPUNodes(rootNode: Node, networkNodeOutput: NetworkNodeOutput, cpuNodes: CPUNode[]): void { const linkableResourceTypes = new Set<Lantern.ResourceType|undefined>([ 'XHR', 'Fetch', 'Script', ]); function addDependentNetworkRequest(cpuNode: CPUNode, reqId: string): void { const networkNode = networkNodeOutput.idToNodeMap.get(reqId); if (!networkNode || // Ignore all network nodes that started before this CPU task started // A network request that started earlier could not possibly have been started by this task networkNode.startTime <= cpuNode.startTime) { return; } const {request} = networkNode; const resourceType = request.resourceType || request.redirectDestination?.resourceType; if (!linkableResourceTypes.has(resourceType)) { // We only link some resources to CPU nodes because we observe LCP simulation // regressions when including images, etc. return; } cpuNode.addDependent(networkNode); } /** * If the node has an associated frameId, then create a dependency on the root document request * for the frame. The task obviously couldn't have started before the frame was even downloaded. */ function addDependencyOnFrame(cpuNode: CPUNode, frameId: string|undefined): void { if (!frameId) { return; } const networkNode = networkNodeOutput.frameIdToNodeMap.get(frameId); if (!networkNode) { return; } // Ignore all network nodes that started after this CPU task started // A network request that started after could not possibly be required this task if (networkNode.startTime >= cpuNode.startTime) { return; } cpuNode.addDependency(networkNode); } function addDependencyOnUrl(cpuNode: CPUNode, url: string): void { if (!url) { return; } // Allow network requests that end up to 100ms before the task started // Some script evaluations can start before the script finishes downloading const minimumAllowableTimeSinceNetworkNodeEnd = -100 * 1000; const candidates = networkNodeOutput.urlToNodeMap.get(url) || []; let minCandidate = null; let minDistance = Infinity; // Find the closest request that finished before this CPU task started for (const candidate of candidates) { // Explicitly ignore all requests that started after this CPU node // A network request that started after this task started cannot possibly be a dependency if (cpuNode.startTime <= candidate.startTime) { return; } const distance = cpuNode.startTime - candidate.endTime; if (distance >= minimumAllowableTimeSinceNetworkNodeEnd && distance < minDistance) { minCandidate = candidate; minDistance = distance; } } if (!minCandidate) { return; } cpuNode.addDependency(minCandidate); } const timers = new Map<string, CPUNode>(); for (const node of cpuNodes) { for (const evt of node.childEvents) { if (!evt.args.data) { continue; } const argsUrl = evt.args.data.url; const stackTraceUrls = (evt.args.data.stackTrace || []).map(l => l.url).filter(Boolean); switch (evt.name) { case 'TimerInstall': // @ts-expect-error - 'TimerInstall' event means timerId exists. timers.set(evt.args.data.timerId, node); stackTraceUrls.forEach(url => addDependencyOnUrl(node, url)); break; case 'TimerFire': { // @ts-expect-error - 'TimerFire' event means timerId exists. const installer = timers.get(evt.args.data.timerId); if (!installer || installer.endTime > node.startTime) { break; } installer.addDependent(node); break; } case 'InvalidateLayout': case 'ScheduleStyleRecalculation': addDependencyOnFrame(node, evt.args.data.frame); stackTraceUrls.forEach(url => addDependencyOnUrl(node, url)); break; case 'EvaluateScript': addDependencyOnFrame(node, evt.args.data.frame); // @ts-expect-error - 'EvaluateScript' event means argsUrl is defined. addDependencyOnUrl(node, argsUrl); stackTraceUrls.forEach(url => addDependencyOnUrl(node, url)); break; case 'XHRReadyStateChange': // Only create the dependency if the request was completed // 'XHRReadyStateChange' event means readyState is defined. if (evt.args.data.readyState !== 4) { break; } // @ts-expect-error - 'XHRReadyStateChange' event means argsUrl is defined. addDependencyOnUrl(node, argsUrl); stackTraceUrls.forEach(url => addDependencyOnUrl(node, url)); break; case 'FunctionCall': case 'v8.compile': addDependencyOnFrame(node, evt.args.data.frame); // @ts-expect-error - events mean argsUrl is defined. addDependencyOnUrl(node, argsUrl); break; case 'ParseAuthorStyleSheet': addDependencyOnFrame(node, evt.args.data.frame); // @ts-expect-error - 'ParseAuthorStyleSheet' event means styleSheetUrl is defined. addDependencyOnUrl(node, evt.args.data.styleSheetUrl); break; case 'ResourceSendRequest': addDependencyOnFrame(node, evt.args.data.frame); // @ts-expect-error - 'ResourceSendRequest' event means requestId is defined. addDependentNetworkRequest(node, evt.args.data.requestId); stackTraceUrls.forEach(url => addDependencyOnUrl(node, url)); break; } } // Nodes starting before the root node cannot depend on it. if (node.getNumberOfDependencies() === 0 && node.canDependOn(rootNode)) { node.addDependency(rootNode); } } // Second pass to prune the graph of short tasks. const minimumEvtDur = SIGNIFICANT_DUR_THRESHOLD_MS * 1000; let foundFirstLayout = false; let foundFirstPaint = false; let foundFirstParse = false; for (const node of cpuNodes) { // Don't prune if event is the first ParseHTML/Layout/Paint. // See https://github.com/GoogleChrome/lighthouse/issues/9627#issuecomment-526699524 for more. let isFirst = false; if (!foundFirstLayout && node.childEvents.some(evt => evt.name === 'Layout')) { isFirst = foundFirstLayout = true; } if (!foundFirstPaint && node.childEvents.some(evt => evt.name === 'Paint')) { isFirst = foundFirstPaint = true; } if (!foundFirstParse && node.childEvents.some(evt => evt.name === 'ParseHTML')) { isFirst = foundFirstParse = true; } if (isFirst || node.duration >= minimumEvtDur) { // Don't prune this node. The task is long / important so it will impact simulation. continue; } // Prune the node if it isn't highly connected to minimize graph size. Rewiring the graph // here replaces O(M + N) edges with (M * N) edges, which is fine if either M or N is at // most 1. if (node.getNumberOfDependencies() === 1 || node.getNumberOfDependents() <= 1) { PageDependencyGraph.pruneNode(node); } } } /** * Removes the given node from the graph, but retains all paths between its dependencies and * dependents. */ static pruneNode(node: Node): void { const dependencies = node.getDependencies(); const dependents = node.getDependents(); for (const dependency of dependencies) { node.removeDependency(dependency); for (const dependent of dependents) { dependency.addDependent(dependent); } } for (const dependent of dependents) { node.removeDependent(dependent); } } /** * TODO: remove when CDT backend in Lighthouse is gone. Until then, this is a useful debugging tool * to find delta between using CDP or the trace to create the network requests. * * When a test fails using the trace backend, I enabled this debug method and copied the network * requests when CDP was used, then when trace is used, and diff'd them. This method helped * remove non-logical differences from the comparison (order of properties, slight rounding * discrepancies, removing object cycles, etc). * * When using for a unit test, make sure to do `.only` so you are getting what you expect. */ static debugNormalizeRequests(lanternRequests: Lantern.NetworkRequest[]): void { for (const request of lanternRequests) { request.rendererStartTime = Math.round(request.rendererStartTime * 1000) / 1000; request.networkRequestTime = Math.round(request.networkRequestTime * 1000) / 1000; request.responseHeadersEndTime = Math.round(request.responseHeadersEndTime * 1000) / 1000; request.networkEndTime = Math.round(request.networkEndTime * 1000) / 1000; } for (const r of lanternRequests) { delete r.rawRequest; if (r.initiatorRequest) { // @ts-expect-error r.initiatorRequest = {id: r.initiatorRequest.requestId}; } if (r.redirectDestination) { // @ts-expect-error r.redirectDestination = {id: r.redirectDestination.requestId}; } if (r.redirectSource) { // @ts-expect-error r.redirectSource = {id: r.redirectSource.requestId}; } if (r.redirects) { // @ts-expect-error r.redirects = r.redirects.map(r2 => r2.requestId); } } const requests: Lantern.NetworkRequest[] = lanternRequests .map(r => ({ requestId: r.requestId, connectionId: r.connectionId, connectionReused: r.connectionReused, url: r.url, protocol: r.protocol, parsedURL: r.parsedURL, documentURL: r.documentURL, rendererStartTime: r.rendererStartTime, networkRequestTime: r.networkRequestTime, responseHeadersEndTime: r.responseHeadersEndTime, networkEndTime: r.networkEndTime, transferSize: r.transferSize, resourceSize: r.resourceSize, fromDiskCache: r.fromDiskCache, fromMemoryCache: r.fromMemoryCache, finished: r.finished, statusCode: r.statusCode, redirectSource: r.redirectSource, redirectDestination: r.redirectDestination, redirects: r.redirects, failed: r.failed, initiator: r.initiator, timing: r.timing ? { requestTime: r.timing.requestTime, proxyStart: r.timing.proxyStart, proxyEnd: r.timing.proxyEnd, dnsStart: r.timing.dnsStart, dnsEnd: r.timing.dnsEnd, connectStart: r.timing.connectStart, connectEnd: r.timing.connectEnd, sslStart: r.timing.sslStart, sslEnd: r.timing.sslEnd, workerStart: r.timing.workerStart, workerReady: r.timing.workerReady, workerFetchStart: r.timing.workerFetchStart, workerRespondWithSettled: r.timing.workerRespondWithSettled, sendStart: r.timing.sendStart, sendEnd: r.timing.sendEnd, pushStart: r.timing.pushStart, pushEnd: r.timing.pushEnd, receiveHeadersStart: r.timing.receiveHeadersStart, receiveHeadersEnd: r.timing.receiveHeadersEnd, } : r.timing, resourceType: r.resourceType, mimeType: r.mimeType, priority: r.priority, initiatorRequest: r.initiatorRequest, frameId: r.frameId, fromWorker: r.fromWorker, isLinkPreload: r.isLinkPreload, serverResponseTime: r.serverResponseTime, })) .filter(r => !r.fromWorker); const debug = requests; // Set breakpoint here. // Copy `debug` and compare with https://www.diffchecker.com/text-compare/ // eslint-disable-next-line no-console console.log(debug); } static createGraph( mainThreadEvents: Lantern.TraceEvent[], networkRequests: Lantern.NetworkRequest[], url: Lantern.Simulation.URL): Node { // This is for debugging trace/devtoolslog network records. // const debug = PageDependencyGraph.debugNormalizeRequests(networkRequests); const networkNodeOutput = PageDependencyGraph.getNetworkNodeOutput(networkRequests); const cpuNodes = PageDependencyGraph.getCPUNodes(mainThreadEvents); const {requestedUrl, mainDocumentUrl} = url; if (!requestedUrl) { throw new Core.LanternError('requestedUrl is required to get the root request'); } if (!mainDocumentUrl) { throw new Core.LanternError('mainDocumentUrl is required to get the main resource'); } const rootRequest = Core.NetworkAnalyzer.findResourceForUrl(networkRequests, requestedUrl); if (!rootRequest) { throw new Core.LanternError('rootRequest not found'); } const rootNode = networkNodeOutput.idToNodeMap.get(rootRequest.requestId); if (!rootNode) { throw new Core.LanternError('rootNode not found'); } const mainDocumentRequest = Core.NetworkAnalyzer.findLastDocumentForUrl(networkRequests, mainDocumentUrl); if (!mainDocumentRequest) { throw new Core.LanternError('mainDocumentRequest not found'); } const mainDocumentNode = networkNodeOutput.idToNodeMap.get(mainDocumentRequest.requestId); if (!mainDocumentNode) { throw new Core.LanternError('mainDocumentNode not found'); } PageDependencyGraph.linkNetworkNodes(rootNode, networkNodeOutput); PageDependencyGraph.linkCPUNodes(rootNode, networkNodeOutput, cpuNodes); mainDocumentNode.setIsMainDocument(true); if (NetworkNode.findCycle(rootNode)) { // Uncomment the following if you are debugging cycles. // this.printGraph(rootNode); throw new Core.LanternError('Invalid dependency graph created, cycle detected'); } return rootNode; } // Unused, but useful for debugging. static printGraph(rootNode: Node, widthInCharacters = 80): void { function padRight(str: string, target: number, padChar = ' '): string { return str + padChar.repeat(Math.max(target - str.length, 0)); } const nodes: Node[] = []; rootNode.traverse(node => nodes.push(node)); nodes.sort((a, b) => a.startTime - b.startTime); // Assign labels (A, B, C, ..., Z, Z1, Z2, ...) for each node. const nodeToLabel = new Map<BaseNode, string>(); rootNode.traverse(node => { const ascii = 65 + nodeToLabel.size; let label; if (ascii > 90) { label = `Z${ascii - 90}`; } else { label = String.fromCharCode(ascii); } nodeToLabel.set(node, label); }); const min = nodes[0].startTime; const max = nodes.reduce((max, node) => Math.max(max, node.endTime), 0); const totalTime = max - min; const timePerCharacter = totalTime / widthInCharacters; nodes.forEach(node => { const offset = Math.round((node.startTime - min) / timePerCharacter); const length = Math.ceil((node.endTime - node.startTime) / timePerCharacter); const bar = padRight('', offset) + padRight('', length, '='); // @ts-expect-error -- disambiguate displayName from across possible Node types. const displayName = node.request ? node.request.url : node.type; // eslint-disable-next-line console.log(padRight(bar, widthInCharacters), `| ${displayName.slice(0, 50)}`); }); // Print labels for each node. // eslint-disable-next-line console.log(); // Print dependencies. nodes.forEach(node => { // @ts-expect-error -- disambiguate displayName from across possible Node types. const displayName = node.request ? node.request.url : node.type; // eslint-disable-next-line console.log(nodeToLabel.get(node), displayName.slice(0, widthInCharacters - 5)); for (const child of node.dependents) { // @ts-expect-error -- disambiguate displayName from across possible Node types. const displayName = child.request ? child.request.url : child.type; // eslint-disable-next-line console.log(' ->', nodeToLabel.get(child), displayName.slice(0, widthInCharacters - 10)); } // eslint-disable-next-line console.log(); }); // Show cycle. const cyclePath = NetworkNode.findCycle(rootNode); // eslint-disable-next-line console.log('Cycle?', cyclePath ? 'yes' : 'no'); if (cyclePath) { const path = [...cyclePath]; path.push(path[0]); // eslint-disable-next-line console.log(path.map(node => nodeToLabel.get(node)).join(' -> ')); } } } export {PageDependencyGraph};