| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type * as Platform from '../../core/platform/platform.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as Bindings from '../bindings/bindings.js'; |
| import * as SourceMapScopes from '../source_map_scopes/source_map_scopes.js'; |
| import * as Trace from '../trace/trace.js'; |
| import * as Workspace from '../workspace/workspace.js'; |
| |
| interface ResolvedCodeLocationData { |
| name: string|null; |
| devtoolsLocation: Workspace.UISourceCode.UILocation|null; |
| script: SDK.Script.Script|null; |
| } |
| export class SourceMappingsUpdated extends Event { |
| static readonly eventName = 'sourcemappingsupdated'; |
| constructor() { |
| super(SourceMappingsUpdated.eventName, {composed: true, bubbles: true}); |
| } |
| } |
| |
| /** The code location key is created as a concatenation of its fields. **/ |
| export const resolvedCodeLocationDataNames = new Map<string, ResolvedCodeLocationData|null>(); |
| |
| export class SourceMapsResolver extends EventTarget { |
| private executionContextNamesByOrigin = new Map<Platform.DevToolsPath.UrlString, string>(); |
| #parsedTrace: Trace.TraceModel.ParsedTrace; |
| #entityMapper: Trace.EntityMapper.EntityMapper|null = null; |
| |
| #isResolving = false; |
| |
| // We need to gather up a list of all the DebuggerModels that we should |
| // listen to for source map attached events. For most pages this will be |
| // the debugger model for the primary page target, but if a trace has |
| // workers, we would also need to gather up the DebuggerModel instances for |
| // those workers too. |
| #debuggerModelsToListen = new Set<SDK.DebuggerModel.DebuggerModel>(); |
| |
| constructor(parsedTrace: Trace.TraceModel.ParsedTrace, entityMapper?: Trace.EntityMapper.EntityMapper) { |
| super(); |
| this.#parsedTrace = parsedTrace; |
| this.#entityMapper = entityMapper ?? null; |
| } |
| |
| static clearResolvedNodeNames(): void { |
| resolvedCodeLocationDataNames.clear(); |
| } |
| static keyForCodeLocation(callFrame: Protocol.Runtime.CallFrame): string { |
| return `${callFrame.url}$$$${callFrame.scriptId}$$$${callFrame.functionName}$$$${callFrame.lineNumber}$$$${ |
| callFrame.columnNumber}`; |
| } |
| |
| /** |
| * For trace events containing a call frame / source location |
| * (f.e. a stack trace), attempts to obtain the resolved source |
| * location based on the those that have been resolved so far from |
| * listened source maps. |
| * |
| * Note that a single deployed URL can map to multiple authored URLs |
| * (f.e. if an app is bundled). Thus, beyond a URL we can use code |
| * location data like line and column numbers to obtain the specific |
| * authored code according to the source mappings. |
| * |
| * TODO(andoli): This can return incorrect scripts if the target page has been reloaded since the trace. |
| */ |
| static resolvedCodeLocationForCallFrame(callFrame: Protocol.Runtime.CallFrame): ResolvedCodeLocationData|null { |
| const codeLocationKey = this.keyForCodeLocation(callFrame); |
| return resolvedCodeLocationDataNames.get(codeLocationKey) ?? null; |
| } |
| |
| static resolvedCodeLocationForEntry(entry: Trace.Types.Events.Event): ResolvedCodeLocationData|null { |
| let callFrame = null; |
| if (Trace.Types.Events.isProfileCall(entry)) { |
| callFrame = entry.callFrame; |
| } else { |
| const topCallFrame = Trace.Helpers.Trace.getStackTraceTopCallFrameInEventPayload(entry); |
| if (!topCallFrame) { |
| return null; |
| } |
| callFrame = topCallFrame; |
| } |
| return SourceMapsResolver.resolvedCodeLocationForCallFrame(callFrame as Protocol.Runtime.CallFrame); |
| } |
| |
| static resolvedURLForEntry(parsedTrace: Trace.TraceModel.ParsedTrace, entry: Trace.Types.Events.Event): |
| Platform.DevToolsPath.UrlString|null { |
| const resolvedCallFrameURL = |
| SourceMapsResolver.resolvedCodeLocationForEntry(entry)?.devtoolsLocation?.uiSourceCode.url(); |
| if (resolvedCallFrameURL) { |
| return resolvedCallFrameURL; |
| } |
| |
| // If no source mapping was found for an entry's URL, then default |
| // to the URL value contained in the event itself, if any. |
| const url = Trace.Handlers.Helpers.getNonResolvedURL(entry, parsedTrace.data); |
| if (url) { |
| return Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url)?.url() ?? url; |
| } |
| return null; |
| } |
| |
| static codeLocationForEntry(parsedTrace: Trace.TraceModel.ParsedTrace, entry: Trace.Types.Events.Event): |
| {url: Platform.DevToolsPath.UrlString, line?: number, column?: number}|null { |
| const uiLocation = SourceMapsResolver.resolvedCodeLocationForEntry(entry)?.devtoolsLocation; |
| if (uiLocation) { |
| return {url: uiLocation.uiSourceCode.url(), line: uiLocation.lineNumber, column: uiLocation.columnNumber}; |
| } |
| |
| // If no source mapping was found for an entry's URL, then default |
| // to the frame contained in the event itself, if any. |
| const rawCallFrame = Trace.Helpers.Trace.rawCallFrameForEntry(entry); |
| if (rawCallFrame) { |
| const line = rawCallFrame.lineNumber >= 0 ? rawCallFrame.lineNumber : undefined; |
| const column = rawCallFrame.columnNumber >= 0 ? rawCallFrame.columnNumber : undefined; |
| return {url: rawCallFrame.url as Platform.DevToolsPath.UrlString, line, column}; |
| } |
| |
| // Lastly, look for just a url. |
| let url = Trace.Handlers.Helpers.getNonResolvedURL(entry, parsedTrace.data); |
| if (url) { |
| url = Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url)?.url() ?? url; |
| } |
| if (url) { |
| return {url}; |
| } |
| |
| return null; |
| } |
| |
| static storeResolvedCodeDataForCallFrame( |
| callFrame: Protocol.Runtime.CallFrame, resolvedCodeLocationData: ResolvedCodeLocationData): void { |
| const keyForCallFrame = this.keyForCodeLocation(callFrame); |
| resolvedCodeLocationDataNames.set(keyForCallFrame, resolvedCodeLocationData); |
| } |
| |
| async install(): Promise<void> { |
| for (const threadToProfileMap of this.#parsedTrace.data.Samples.profilesInProcess.values()) { |
| for (const [tid, profile] of threadToProfileMap) { |
| const nodes = profile.parsedProfile.nodes(); |
| if (!nodes || nodes.length === 0) { |
| continue; |
| } |
| |
| const target = this.#targetForThread(tid); |
| const debuggerModel = target?.model(SDK.DebuggerModel.DebuggerModel); |
| if (!debuggerModel) { |
| continue; |
| } |
| for (const node of nodes) { |
| const script = debuggerModel.scriptForId(String(node.callFrame.scriptId)); |
| const shouldListenToSourceMap = !script || script.sourceMapURL; |
| if (!shouldListenToSourceMap) { |
| continue; |
| } |
| this.#debuggerModelsToListen.add(debuggerModel); |
| } |
| } |
| } |
| |
| for (const debuggerModel of this.#debuggerModelsToListen) { |
| debuggerModel.sourceMapManager().addEventListener( |
| SDK.SourceMapManager.Events.SourceMapAttached, this.#onAttachedSourceMap, this); |
| } |
| |
| this.#updateExtensionNames(); |
| |
| // Although we have added listeners for SourceMapAttached events, we also |
| // immediately try to resolve function names. This ensures we use any |
| // sourcemaps that were attached before we bound our event listener. |
| await this.#resolveMappingsForProfileNodes(); |
| } |
| |
| /** |
| * Removes the event listeners and stops tracking newly added sourcemaps. |
| * Should be called before destroying an instance of this class to avoid leaks |
| * with listeners. |
| */ |
| uninstall(): void { |
| for (const debuggerModel of this.#debuggerModelsToListen) { |
| debuggerModel.sourceMapManager().removeEventListener( |
| SDK.SourceMapManager.Events.SourceMapAttached, this.#onAttachedSourceMap, this); |
| } |
| this.#debuggerModelsToListen.clear(); |
| } |
| |
| async #resolveMappingsForProfileNodes(): Promise<void> { |
| // Used to track if source mappings were updated when a source map |
| // is attach. If not, we do not notify the flamechart that mappings |
| // were updated, since that would trigger a rerender. |
| let updatedMappings = false; |
| for (const [, threadsInProcess] of this.#parsedTrace.data.Samples.profilesInProcess) { |
| for (const [tid, threadProfile] of threadsInProcess) { |
| const nodes = threadProfile.parsedProfile.nodes() ?? []; |
| const target = this.#targetForThread(tid); |
| if (!target) { |
| continue; |
| } |
| for (const node of nodes) { |
| const resolvedFunctionName = |
| await SourceMapScopes.NamesResolver.resolveProfileFrameFunctionName(node.callFrame, target); |
| updatedMappings ||= Boolean(resolvedFunctionName); |
| node.setOriginalFunctionName(resolvedFunctionName); |
| |
| const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); |
| const script = debuggerModel?.scriptForId(node.scriptId) || null; |
| const location = debuggerModel && |
| new SDK.DebuggerModel.Location( |
| debuggerModel, node.callFrame.scriptId, node.callFrame.lineNumber, node.callFrame.columnNumber); |
| const uiLocation = location && |
| await Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().rawLocationToUILocation( |
| location); |
| updatedMappings ||= Boolean(uiLocation); |
| if (uiLocation?.uiSourceCode.url() && this.#entityMapper) { |
| // Update mappings for the related events of the entity. |
| this.#entityMapper.updateSourceMapEntities(node.callFrame, uiLocation.uiSourceCode.url()); |
| } |
| |
| SourceMapsResolver.storeResolvedCodeDataForCallFrame( |
| node.callFrame, {name: resolvedFunctionName, devtoolsLocation: uiLocation, script}); |
| } |
| } |
| } |
| if (!updatedMappings) { |
| return; |
| } |
| this.dispatchEvent(new SourceMappingsUpdated()); |
| } |
| |
| #onAttachedSourceMap(): void { |
| // Exit if we are already resolving so that we batch requests; if pages |
| // have a lot of sourcemaps we can get a lot of events at once. |
| if (this.#isResolving) { |
| return; |
| } |
| |
| this.#isResolving = true; |
| // Resolving names triggers a repaint of the flame chart. Instead of attempting to resolve |
| // names every time a source map is attached, wait for some time once the first source map is |
| // attached. This way we allow for other source maps to be parsed before attempting a name |
| // resolving using the available source maps. Otherwise the UI is blocked when the number |
| // of source maps is particularly large. |
| setTimeout(async () => { |
| this.#isResolving = false; |
| await this.#resolveMappingsForProfileNodes(); |
| }, 500); |
| } |
| |
| // Figure out the target for the node. If it is in a worker thread, |
| // that is the target, otherwise we use the primary page target. |
| #targetForThread(tid: Trace.Types.Events.ThreadID): SDK.Target.Target|null { |
| const maybeWorkerId = this.#parsedTrace.data.Workers.workerIdByThread.get(tid); |
| if (maybeWorkerId) { |
| return SDK.TargetManager.TargetManager.instance().targetById(maybeWorkerId); |
| } |
| return SDK.TargetManager.TargetManager.instance().primaryPageTarget(); |
| } |
| |
| #updateExtensionNames(): void { |
| for (const runtimeModel of SDK.TargetManager.TargetManager.instance().models(SDK.RuntimeModel.RuntimeModel)) { |
| for (const context of runtimeModel.executionContexts()) { |
| this.executionContextNamesByOrigin.set(context.origin, context.name); |
| } |
| } |
| this.#entityMapper?.updateExtensionEntitiesWithName(this.executionContextNamesByOrigin); |
| } |
| } |