diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..3c032078a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b2f5324c..5c9000b5f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,5 +28,5 @@ "source.organizeImports": true }, "editor.formatOnSave": true, - "python.formatting.provider": "black" + "typescript.tsserver.experimental.enableProjectDiagnostics": true, } diff --git a/gulpfile.js b/gulpfile.js index 9d4ef8f6c..a1ea013be 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -24,6 +24,7 @@ const pipelineAsync = util.promisify(stream.pipeline); const dirname = 'js-debug'; const sources = ['src/**/*.{ts,tsx}']; +const externalModules = ['@vscode/dwarf-debugging']; const allPackages = []; const srcDir = 'src'; @@ -232,7 +233,7 @@ async function compileTs({ resolveExtensions: isInVsCode ? ['.extensionOnly.ts', ...resolveDefaultExts] : resolveDefaultExts, - external: isInVsCode ? ['vscode'] : [], + external: isInVsCode ? ['vscode', ...externalModules] : externalModules, sourcemap: !!sourcemap, sourcesContent: false, packages: nodePackages, diff --git a/package-lock.json b/package-lock.json index d733c9f3a..13d2cfc86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "^5.17.0", "@typescript-eslint/parser": "^5.17.0", + "@vscode/dwarf-debugging": "^0.0.2", "@vscode/test-electron": "^2.2.3", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", @@ -2280,6 +2281,15 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vscode/dwarf-debugging": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@vscode/dwarf-debugging/-/dwarf-debugging-0.0.2.tgz", + "integrity": "sha512-u/sQV5SBYOzAFE9Wy0N9oH+FbpZ/KJCl9ESv+3I6G7IAQXvmzFOdkA+BCTFLgZl89viT28SoHmZk4ZPwjQhIkA==", + "dev": true, + "dependencies": { + "ws": "^8.14.1" + } + }, "node_modules/@vscode/js-debug-browsers": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@vscode/js-debug-browsers/-/js-debug-browsers-1.0.8.tgz", @@ -14816,15 +14826,15 @@ } }, "node_modules/ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -16644,6 +16654,15 @@ "eslint-visitor-keys": "^3.0.0" } }, + "@vscode/dwarf-debugging": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@vscode/dwarf-debugging/-/dwarf-debugging-0.0.2.tgz", + "integrity": "sha512-u/sQV5SBYOzAFE9Wy0N9oH+FbpZ/KJCl9ESv+3I6G7IAQXvmzFOdkA+BCTFLgZl89viT28SoHmZk4ZPwjQhIkA==", + "dev": true, + "requires": { + "ws": "^8.14.1" + } + }, "@vscode/js-debug-browsers": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@vscode/js-debug-browsers/-/js-debug-browsers-1.0.8.tgz", @@ -26393,9 +26412,9 @@ } }, "ws": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", "requires": {} }, "xdg-default-browser": { diff --git a/package.json b/package.json index 5632c6fee..22a0b516f 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "@types/ws": "^8.5.3", "@typescript-eslint/eslint-plugin": "^5.17.0", "@typescript-eslint/parser": "^5.17.0", + "@vscode/dwarf-debugging": "^0.0.2", "@vscode/test-electron": "^2.2.3", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", diff --git a/src/adapter/breakpoints.ts b/src/adapter/breakpoints.ts index 597c191ec..f2c631119 100644 --- a/src/adapter/breakpoints.ts +++ b/src/adapter/breakpoints.ts @@ -5,7 +5,7 @@ import { inject, injectable } from 'inversify'; import Cdp from '../cdp/api'; import { ILogger, LogTag } from '../common/logging'; -import { bisectArray, flatten } from '../common/objUtils'; +import { bisectArrayAsync, flatten } from '../common/objUtils'; import { IPosition } from '../common/positions'; import { delay } from '../common/promiseUtil'; import { SourceMap } from '../common/sourceMaps/sourceMap'; @@ -25,15 +25,8 @@ import { NeverResolvedBreakpoint } from './breakpoints/neverResolvedBreakpoint'; import { PatternEntryBreakpoint } from './breakpoints/patternEntrypointBreakpoint'; import { UserDefinedBreakpoint } from './breakpoints/userDefinedBreakpoint'; import { DiagnosticToolSuggester } from './diagnosticToolSuggester'; -import { - base0To1, - base1To0, - ISourceWithMap, - isSourceWithMap, - IUiLocation, - Source, - SourceContainer, -} from './sources'; +import { ISourceWithMap, IUiLocation, Source, base0To1, base1To0, isSourceWithMap } from './source'; +import { SourceContainer } from './sourceContainer'; import { ScriptWithSourceMapHandler, Thread } from './threads'; /** @@ -226,23 +219,30 @@ export class BreakpointManager { * location in the `toSource`, using the provided source map. Breakpoints * are don't have a corresponding location won't be moved. */ - public moveBreakpoints(fromSource: Source, sourceMap: SourceMap, toSource: Source) { + public async moveBreakpoints( + thread: Thread, + fromSource: Source, + sourceMap: SourceMap, + toSource: Source, + ) { const tryUpdateLocations = (breakpoints: UserDefinedBreakpoint[]) => - bisectArray(breakpoints, bp => { - const gen = this._sourceContainer.getOptiminalOriginalPosition( + bisectArrayAsync(breakpoints, async bp => { + const gen = await this._sourceContainer.getOptiminalOriginalPosition( sourceMap, bp.originalPosition, ); - if (gen.column === null || gen.line === null) { + if (!gen) { return false; } + const base1 = gen.position.base1; bp.updateSourceLocation( + thread, { path: toSource.absolutePath, sourceReference: toSource.sourceReference, }, - { lineNumber: gen.line, columnNumber: gen.column + 1, source: toSource }, + { lineNumber: base1.lineNumber, columnNumber: base1.columnNumber, source: toSource }, ); return false; }); @@ -251,14 +251,14 @@ export class BreakpointManager { const toPath = toSource.absolutePath; const byPath = fromPath ? this._byPath.get(fromPath) : undefined; if (byPath && toPath) { - const [remaining, moved] = tryUpdateLocations(byPath); + const [remaining, moved] = await tryUpdateLocations(byPath); this._byPath.set(fromPath, remaining); this._byPath.set(toPath, moved); } const byRef = this._byRef.get(fromSource.sourceReference); if (byRef) { - const [remaining, moved] = tryUpdateLocations(byRef); + const [remaining, moved] = await tryUpdateLocations(byRef); this._byRef.set(fromSource.sourceReference, remaining); this._byRef.set(toSource.sourceReference, moved); } @@ -317,18 +317,19 @@ export class BreakpointManager { end: IPosition, ) { const start1 = start.base1; - const startLocations = this._sourceContainer.currentSiblingUiLocations({ - source, - lineNumber: start1.lineNumber, - columnNumber: start1.columnNumber, - }); - const end1 = end.base1; - const endLocations = this._sourceContainer.currentSiblingUiLocations({ - source, - lineNumber: end1.lineNumber, - columnNumber: end1.columnNumber, - }); + const [startLocations, endLocations] = await Promise.all([ + this._sourceContainer.currentSiblingUiLocations({ + source, + lineNumber: start1.lineNumber, + columnNumber: start1.columnNumber, + }), + this._sourceContainer.currentSiblingUiLocations({ + source, + lineNumber: end1.lineNumber, + columnNumber: end1.columnNumber, + }), + ]); // As far as I know the number of start and end locations should be the // same, log if this is not the case. @@ -343,7 +344,7 @@ export class BreakpointManager { // For each viable location, attempt to identify its script ID and then ask // Chrome for the breakpoints in the given range. For almost all scripts // we'll only every find one viable location with a script. - const todo: Promise[] = []; + const todo: Promise[] = []; const result: IPossibleBreakLocation[] = []; for (let i = 0; i < startLocations.length; i++) { const start = startLocations[i]; @@ -383,15 +384,17 @@ export class BreakpointManager { // Discard any that map outside of the source we're interested in, // which is possible (e.g. if a section of code from one source is // inlined amongst the range we request). - for (const breakLocation of r.locations) { - const { lineNumber, columnNumber = 0 } = breakLocation; - const uiLocations = this._sourceContainer.currentSiblingUiLocations({ - source: lsrc, - ...lsrc.offsetScriptToSource(base0To1({ lineNumber, columnNumber })), - }); - - result.push({ breakLocation, uiLocations }); - } + return Promise.all( + r.locations.map(async breakLocation => { + const { lineNumber, columnNumber = 0 } = breakLocation; + const uiLocations = await this._sourceContainer.currentSiblingUiLocations({ + source: lsrc, + ...lsrc.offsetScriptToSource(base0To1({ lineNumber, columnNumber })), + }); + + result.push({ breakLocation, uiLocations }); + }), + ); }), ); } @@ -671,7 +674,7 @@ export class BreakpointManager { } /** - * Rreturns whether any of the given breakpoints are an entrypoint breakpoint. + * Returns whether any of the given breakpoints are an entrypoint breakpoint. */ public isEntrypointBreak( hitBreakpointIds: ReadonlyArray, diff --git a/src/adapter/breakpoints/breakpointBase.ts b/src/adapter/breakpoints/breakpointBase.ts index 19290528a..196bb9b68 100644 --- a/src/adapter/breakpoints/breakpointBase.ts +++ b/src/adapter/breakpoints/breakpointBase.ts @@ -4,17 +4,11 @@ import Cdp from '../../cdp/api'; import { LogTag } from '../../common/logging'; -import { absolutePathToFileUrl, urlToRegex } from '../../common/urlUtils'; +import { IPosition } from '../../common/positions'; +import { absolutePathToFileUrl } from '../../common/urlUtils'; import Dap from '../../dap/api'; import { BreakpointManager } from '../breakpoints'; -import { - base1To0, - ISourceScript, - IUiLocation, - Source, - SourceFromMap, - WasmSource, -} from '../sources'; +import { ISourceScript, IUiLocation, Source, SourceFromMap, base1To0 } from '../source'; import { Script, Thread } from '../threads'; export type LineColumn = { lineNumber: number; columnNumber: number }; // 1-based @@ -147,20 +141,18 @@ export abstract class Breakpoint { * the breakpoints when we pretty print a source. This is dangerous with * sharp edges, use with caution. */ - public async updateSourceLocation(source: Dap.Source, uiLocation: IUiLocation) { + public async updateSourceLocation(thread: Thread, source: Dap.Source, uiLocation: IUiLocation) { this._source = source; this._originalPosition = uiLocation; - this.updateCdpRefs(list => - list.map(bp => - bp.state === CdpReferenceState.Applied - ? { - ...bp, - uiLocations: this._manager._sourceContainer.currentSiblingUiLocations(uiLocation), - } - : bp, - ), - ); + const todo: Promise[] = []; + for (const ref of this.cdpBreakpoints) { + if (ref.state === CdpReferenceState.Applied) { + todo.push(this.updateUiLocations(thread, ref.cdpId, ref.locations)); + } + } + + await Promise.all(todo); } /** @@ -205,7 +197,7 @@ export abstract class Breakpoint { // double check still enabled to avoid racing if (source && this.isEnabled) { - const uiLocations = this._manager._sourceContainer.currentSiblingUiLocations({ + const uiLocations = await this._manager._sourceContainer.currentSiblingUiLocations({ lineNumber: this.originalPosition.lineNumber, columnNumber: this.originalPosition.columnNumber, source, @@ -250,8 +242,9 @@ export abstract class Breakpoint { return; } + const locations = await this._manager._sourceContainer.currentSiblingUiLocations(uiLocation); + this.updateExistingCdpRef(cdpId, bp => { - const locations = this._manager._sourceContainer.currentSiblingUiLocations(uiLocation); const inPreferredSource = locations.filter(l => l.source === source); return { ...bp, @@ -305,12 +298,8 @@ export abstract class Breakpoint { return []; } - if (source instanceof WasmSource) { - await source.offsetsAssembled; - } - // Find all locations for this breakpoint in the new script. - const uiLocations = this._manager._sourceContainer.currentSiblingUiLocations( + const uiLocations = await this._manager._sourceContainer.currentSiblingUiLocations( { lineNumber: this.originalPosition.lineNumber, columnNumber: this.originalPosition.columnNumber, @@ -325,7 +314,7 @@ export abstract class Breakpoint { const promises: Promise[] = []; for (const uiLocation of uiLocations) { - promises.push(this._setByScriptId(thread, script, source.offsetSourceToScript(uiLocation))); + promises.push(this._setForSpecific(thread, script, source.offsetSourceToScript(uiLocation))); } // If we get a source map that references this script exact URL, then @@ -374,6 +363,24 @@ export abstract class Breakpoint { this.updateCdpRefs(l => l.filter(bp => isSetByUrl(bp.args))); } + /** + * Gets whether this breakpoint has resolved to the given position. + */ + public hasResolvedAt(scriptId: string, position: IPosition) { + const { lineNumber, columnNumber } = position.base0; + + return this.cdpBreakpoints.some( + bp => + bp.state === CdpReferenceState.Applied && + bp.locations.some( + l => + l.scriptId === scriptId && + l.lineNumber === lineNumber && + (l.columnNumber === undefined || l.columnNumber === columnNumber), + ), + ); + } + /** * Gets whether the breakpoint was set in the source by URL. Also checks * the rebased remote paths, since Sources are always normalized to the @@ -473,7 +480,7 @@ export abstract class Breakpoint { private async _setByUiLocation(thread: Thread, uiLocation: IUiLocation): Promise { await Promise.all( - uiLocation.source.scripts.map(script => this._setByScriptId(thread, script, uiLocation)), + uiLocation.source.scripts.map(script => this._setForSpecific(thread, script, uiLocation)), ); } @@ -532,7 +539,9 @@ export abstract class Breakpoint { lcEqual(bp.args.location, lineColumn)) || (script.url && isSetByUrl(bp.args) && - new RegExp(bp.args.urlRegex ?? '').test(script.url) && + (bp.args.urlRegex + ? new RegExp(bp.args.urlRegex).test(script.url) + : script.url === bp.args.url) && lcEqual(bp.args, lineColumn)), ); } @@ -542,23 +551,60 @@ export abstract class Breakpoint { * at the provided script by url regexp already. This is used to deduplicate breakpoint * requests to avoid triggering any logpoint breakpoints multiple times. */ - protected hasSetOnLocationByRegexp(urlRegexp: string, lineColumn: LineColumn) { + protected hasSetOnLocationByUrl(kind: 're' | 'url', input: string, lineColumn: LineColumn) { return this.cdpBreakpoints.find(bp => { if (isSetByUrl(bp.args)) { - return bp.args.urlRegex === urlRegexp && lcEqual(bp.args, lineColumn); + if (!lcEqual(bp.args, lineColumn)) { + return false; + } + + if (kind === 'url') { + return bp.args.urlRegex + ? new RegExp(bp.args.urlRegex).test(input) + : bp.args.url === input; + } else { + return kind === 're' && bp.args.urlRegex === input; + } } const script = this._manager._sourceContainer.getScriptById(bp.args.location.scriptId); if (script) { - return lcEqual(bp.args.location, lineColumn) && new RegExp(urlRegexp).test(script.url); + return lcEqual(bp.args.location, lineColumn) && kind === 're' + ? new RegExp(input).test(script.url) + : script.url === input; } return undefined; }); } + protected async _setForSpecific(thread: Thread, script: ISourceScript, lineColumn: LineColumn) { + // prefer to set on script URL for non-anonymous scripts, since url breakpoints + // will survive and be hit on reload. + if (script.url) { + return this._setByUrl(thread, script.url, lineColumn); + } else { + return this._setByScriptId(thread, script, lineColumn); + } + } + protected async _setByUrl(thread: Thread, url: string, lineColumn: LineColumn): Promise { - return this._setByUrlRegexp(thread, urlToRegex(url), lineColumn); + lineColumn = base1To0(lineColumn); + + const previous = this.hasSetOnLocationByUrl('url', url, lineColumn); + if (previous) { + if (previous.state === CdpReferenceState.Pending) { + await previous.done; + } + + return; + } + + return this._setAny(thread, { + url, + condition: this.getBreakCondition(), + ...lineColumn, + }); } protected async _setByUrlRegexp( @@ -568,7 +614,7 @@ export abstract class Breakpoint { ): Promise { lineColumn = base1To0(lineColumn); - const previous = this.hasSetOnLocationByRegexp(urlRegex, lineColumn); + const previous = this.hasSetOnLocationByUrl('re', urlRegex, lineColumn); if (previous) { if (previous.state === CdpReferenceState.Pending) { await previous.done; diff --git a/src/adapter/console/textualMessage.ts b/src/adapter/console/textualMessage.ts index d2fb00770..fddc70aed 100644 --- a/src/adapter/console/textualMessage.ts +++ b/src/adapter/console/textualMessage.ts @@ -10,7 +10,7 @@ import Dap from '../../dap/api'; import { formatMessage } from '../messageFormat'; import { messageFormatters, previewAsObject } from '../objectPreview'; import { AnyObject } from '../objectPreview/betterTypes'; -import { IUiLocation } from '../sources'; +import { IUiLocation } from '../source'; import { StackFrame, StackTrace } from '../stackTrace'; import { Thread } from '../threads'; import { IConsoleMessage } from './consoleMessage'; diff --git a/src/adapter/debugAdapter.ts b/src/adapter/debugAdapter.ts index 25b463372..bdeab9367 100644 --- a/src/adapter/debugAdapter.ts +++ b/src/adapter/debugAdapter.ts @@ -36,7 +36,8 @@ import { BasicCpuProfiler } from './profiling/basicCpuProfiler'; import { ScriptSkipper } from './scriptSkipper/implementation'; import { IScriptSkipper } from './scriptSkipper/scriptSkipper'; import { SmartStepper } from './smartStepping'; -import { ISourceWithMap, SourceContainer, SourceFromMap } from './sources'; +import { ISourceWithMap, SourceFromMap } from './source'; +import { SourceContainer } from './sourceContainer'; import { Thread } from './threads'; import { VariableStore } from './variableStore'; @@ -509,7 +510,7 @@ export class DebugAdapter implements IDisposable { async _prettyPrintSource( params: Dap.PrettyPrintSourceParams, ): Promise { - if (!params.source) { + if (!params.source || !this._thread) { return { canPrettyPrint: false }; } @@ -526,7 +527,7 @@ export class DebugAdapter implements IDisposable { const { map: sourceMap, source: generated } = prettified; - this.breakpointManager.moveBreakpoints(source, sourceMap, generated); + await this.breakpointManager.moveBreakpoints(this._thread, source, sourceMap, generated); this.sourceContainer.clearDisabledSourceMaps(source as ISourceWithMap); await this._refreshStackTrace(); diff --git a/src/adapter/diagnosics.ts b/src/adapter/diagnosics.ts index 1cb0e3332..94beed373 100644 --- a/src/adapter/diagnosics.ts +++ b/src/adapter/diagnosics.ts @@ -18,7 +18,8 @@ import { IBreakpointCdpReferenceApplied, IBreakpointCdpReferencePending, } from './breakpoints/breakpointBase'; -import { IUiLocation, SourceContainer, SourceFromMap } from './sources'; +import { IUiLocation, SourceFromMap, isSourceWithSourceMap } from './source'; +import { SourceContainer } from './sourceContainer'; export interface IDiagnosticSource { uniqueId: number; @@ -148,14 +149,16 @@ export class Diagnostics { ([k, v]) => [k.sourceReference, v] as [number, string], ) : undefined, - sourceMap: source.sourceMap && { - url: source.sourceMap.url, - metadata: source.sourceMap.metadata, - sources: mapValues( - Object.fromEntries(source.sourceMap.sourceByUrl), - v => v.sourceReference, - ), - }, + sourceMap: isSourceWithSourceMap(source) + ? { + url: source.sourceMap.metadata.sourceMapUrl, + metadata: source.sourceMap.metadata, + sources: mapValues( + Object.fromEntries(source.sourceMap.sourceByUrl), + v => v.sourceReference, + ), + } + : undefined, }))(), ); } diff --git a/src/adapter/dwarf/dwarfModuleProvider.ts b/src/adapter/dwarf/dwarfModuleProvider.ts new file mode 100644 index 000000000..0332aeccb --- /dev/null +++ b/src/adapter/dwarf/dwarfModuleProvider.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +export const IDwarfModuleProvider = Symbol('IDwarfModuleProvider'); + +export interface IDwarfModuleProvider { + /** + * Loads the dwarf module if it exists. + */ + load(): Promise; + + /** + * Prompts the user to install the dwarf module (called if the module is + * not installed.) + */ + prompt(): void; +} diff --git a/src/adapter/dwarf/dwarfModuleProviderImpl.ts b/src/adapter/dwarf/dwarfModuleProviderImpl.ts new file mode 100644 index 000000000..2b8aaa376 --- /dev/null +++ b/src/adapter/dwarf/dwarfModuleProviderImpl.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import type * as dwf from '@vscode/dwarf-debugging'; +import * as l10n from '@vscode/l10n'; +import { inject, injectable } from 'inversify'; +import Dap from '../../dap/api'; +import { IDapApi } from '../../dap/connection'; +import { IDwarfModuleProvider } from './dwarfModuleProvider'; + +const name = '@vscode/dwarf-debugging'; + +@injectable() +export class DwarfModuleProvider implements IDwarfModuleProvider { + private didPrompt = false; + + constructor(@inject(IDapApi) private readonly dap: Dap.Api) {} + + public async load(): Promise { + try { + return await import(name); + } catch { + return undefined; + } + } + + public prompt() { + if (!this.didPrompt) { + this.didPrompt = true; + this.dap.output({ + output: l10n.t( + 'You may install the `{}` module via npm for enhanced WebAssembly debugging', + name, + ), + category: 'console', + }); + } + } +} diff --git a/src/adapter/dwarf/wasmSymbolProvider.ts b/src/adapter/dwarf/wasmSymbolProvider.ts new file mode 100644 index 000000000..ca2bbfbde --- /dev/null +++ b/src/adapter/dwarf/wasmSymbolProvider.ts @@ -0,0 +1,572 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import type { IWasmWorker, MethodReturn } from '@vscode/dwarf-debugging'; +import { Chrome } from '@vscode/dwarf-debugging/chrome-cxx/mnt/extension-api'; +import { randomUUID } from 'crypto'; +import { inject, injectable } from 'inversify'; +import Cdp from '../../cdp/api'; +import { ICdpApi } from '../../cdp/connection'; +import { binarySearch } from '../../common/arrayUtils'; +import { IDisposable } from '../../common/disposable'; +import { ILogger, LogTag } from '../../common/logging'; +import { flatten, once } from '../../common/objUtils'; +import { Base0Position, IPosition, Range } from '../../common/positions'; +import { StepDirection } from '../pause'; +import { getSourceSuffix } from '../templates'; +import { IDwarfModuleProvider } from './dwarfModuleProvider'; + +export const IWasmSymbolProvider = Symbol('IWasmSymbolProvider'); + +export interface IWasmSymbolProvider { + /** Loads WebAssembly symbols for the given wasm script, returning symbol information if it exists. */ + loadWasmSymbols(script: Cdp.Debugger.ScriptParsedEvent): Promise; +} + +@injectable() +export class WasmSymbolProvider implements IWasmSymbolProvider, IDisposable { + /** Running worker, `null` signals that the dwarf module was not available */ + private worker?: IWasmWorker | null; + + private readonly doPrompt = once(() => this.dwarf.prompt()); + + constructor( + @inject(IDwarfModuleProvider) private readonly dwarf: IDwarfModuleProvider, + @inject(ICdpApi) private readonly cdp: Cdp.Api, + @inject(ILogger) private readonly logger: ILogger, + ) {} + + public async loadWasmSymbols(script: Cdp.Debugger.ScriptParsedEvent): Promise { + const rpc = await this.getWorker(); + if (!rpc) { + const syms = new DecompiledWasmSymbols(script, this.cdp, []); + // disassembly is a good signal for a prompt, since that means a user + // will have stepped into and be looking at webassembly code. + syms.onDidDisassemble = this.doPrompt; + return syms; + } + + const moduleId = randomUUID(); + + const symbolsUrl = script.debugSymbols?.externalURL; + let result: MethodReturn<'addRawModule'>; + try { + result = await rpc.sendMessage('addRawModule', moduleId, symbolsUrl, { + url: script.url, + code: + !symbolsUrl && script.url.startsWith('wasm://') + ? await this.getBytecode(script.scriptId) + : undefined, + }); + } catch { + return new DecompiledWasmSymbols(script, this.cdp, []); + } + + if (!(result instanceof Array) || result.length === 0) { + rpc.sendMessage('removeRawModule', moduleId); // no await necessary + return new DecompiledWasmSymbols(script, this.cdp, []); + } + + this.logger.info(LogTag.SourceMapParsing, 'parsed files from wasm', { files: result }); + + return new WasmSymbols(script, this.cdp, moduleId, rpc, result); + } + + /** @inheritdoc */ + public dispose(): void { + this.worker?.dispose(); + this.worker = undefined; + } + + private async getBytecode(scriptId: string) { + const source = await this.cdp.Debugger.getScriptSource({ scriptId }); + const bytecode = source?.bytecode; + return bytecode ? Buffer.from(bytecode, 'base64').buffer : undefined; + } + + private async getWorker() { + if (this.worker !== undefined) { + return this.worker?.rpc; + } + + const dwarf = await this.dwarf.load(); + if (!dwarf) { + this.worker = null; + return undefined; + } + + this.worker = dwarf.spawn({ + getWasmGlobal: (index, stopId) => this.loadWasmValue(`globals[${index}]`, stopId), + getWasmLocal: (index, stopId) => this.loadWasmValue(`locals[${index}]`, stopId), + getWasmOp: (index, stopId) => this.loadWasmValue(`stack[${index}]`, stopId), + getWasmLinearMemory: (offset, length, stopId) => + this.loadWasmValue( + `[].slice.call(new Uint8Array(memories[0].buffer, ${+offset}, ${+length}))`, + stopId, + ).then((v: number[]) => new Uint8Array(v).buffer), + }); + + this.worker.rpc.sendMessage('hello', [], false); + + return this.worker.rpc; + } + + private async loadWasmValue(expression: string, stopId: unknown) { + const callFrameId = stopId as string; + const result = await this.cdp.Debugger.evaluateOnCallFrame({ + callFrameId, + expression: expression + getSourceSuffix(), + silent: true, + returnByValue: true, + throwOnSideEffect: true, + }); + + if (!result || result.exceptionDetails) { + throw new Error(`evaluate failed: ${result?.exceptionDetails?.text || 'unknown'}`); + } + + return result.result.value; + } +} + +export interface IWasmVariableEvaluation { + type: string; + description: string | undefined; + linearMemoryAddress?: number; + linearMemorySize?: number; + getChildren?: () => Promise<{ name: string; value: IWasmVariableEvaluation }[]>; +} + +export const enum WasmScope { + Local = 'LOCAL', + Global = 'GLOBAL', + Parameter = 'PARAMETER', +} + +export interface IWasmVariable { + scope: WasmScope; + name: string; + type: string; + evaluate: () => Promise; +} + +export interface IWasmSymbols extends IDisposable { + /** + * URL in `files` that refers to the dissembled version of the WASM. This + * is used as a fallback for locations that don't better map to a known symbol. + */ + readonly decompiledUrl: string; + + /** + * Files contained in the WASM symbols. + */ + readonly files: readonly string[]; + + /** + * Returns disassembled wasm lines. + */ + getDisassembly(): Promise; + + /** + * Gets the source position for the given position in compiled code. + * + * Following CDP semantics, it assumes the column is being the byte offset + * in webassembly. However, we encode the inline frame index in the line. + */ + originalPositionFor( + compiledPosition: IPosition, + ): Promise<{ url: string; position: IPosition } | undefined>; + + /** + * Gets the compiled position for the given position in source code. + * + * Following CDP semantics, it assumes the position is line 0 with the column + * offset being the byte offset in webassembly. + */ + compiledPositionFor(sourceUrl: string, sourcePosition: IPosition): Promise; + + /** + * Gets variables in the program scope at the given position. If not + * implemented, the variable store should use its default behavior. + * + * Following CDP semantics, it assumes the column is being the byte offset + * in webassembly. However, we encode the inline frame index in the line. + */ + getVariablesInScope?(callFrameId: string, position: IPosition): Promise; + + /** + * Gets the stack of WASM functions at the given position. Generally this will + * return an element with a single item containing the function name. However, + * inlined functions may return multiple functions for a position. + * + * It may return an empty array if function information is not available. + * + * @see https://github.com/ChromeDevTools/devtools-frontend/blob/c9f204731633fd2e2b6999a2543e99b7cc489b4b/docs/language_extension_api.md#dealing-with-inlined-functions + */ + getFunctionStack?(position: IPosition): Promise<{ name: string }[]>; + + /** + * Gets ranges that should be stepped for the given step kind and location. + * + * Following CDP semantics, it assumes the column is being the byte offset + * in webassembly. However, we encode the inline frame index in the line. + */ + getStepSkipList?( + direction: StepDirection, + position: IPosition, + sourceUrl?: string, + mappedPosition?: IPosition, + ): Promise; +} + +class DecompiledWasmSymbols implements IWasmSymbols { + /** @inheritdoc */ + public readonly decompiledUrl: string; + + /** @inheritdoc */ + public readonly files: readonly string[]; + + /** Called whenever disassembly is requested for a source/ */ + public onDidDisassemble?: () => void; + + constructor( + protected readonly event: Cdp.Debugger.ScriptParsedEvent, + protected readonly cdp: Cdp.Api, + files: string[], + ) { + files.push((this.decompiledUrl = event.url.replace('.wasm', '.wat'))); + this.files = files; + } + + /** @inheritdoc */ + public async getDisassembly(): Promise { + const { lines } = await this.doDisassemble(); + this.onDidDisassemble?.(); + return lines.join('\n'); + } + + /** @inheritdoc */ + public async originalPositionFor( + compiledPosition: IPosition, + ): Promise<{ url: string; position: IPosition } | undefined> { + const { byteOffsetsOfLines } = await this.doDisassemble(); + const lineNumber = binarySearch( + byteOffsetsOfLines, + compiledPosition.base0.columnNumber, + (a, b) => a - b, + ); + + if (lineNumber === byteOffsetsOfLines.length) { + return undefined; + } + + return { + url: this.decompiledUrl, + position: new Base0Position(lineNumber, 0), + }; + } + + /** @inheritdoc */ + public async compiledPositionFor( + sourceUrl: string, + sourcePosition: IPosition, + ): Promise { + if (sourceUrl !== this.decompiledUrl) { + return []; + } + + const { byteOffsetsOfLines } = await this.doDisassemble(); + const { lineNumber } = sourcePosition.base0; + if (lineNumber >= byteOffsetsOfLines.length) { + return []; + } + + const columnNumber = byteOffsetsOfLines[sourcePosition.base0.lineNumber]; + return [new Base0Position(0, columnNumber)]; + } + + public dispose(): void { + // no-op + } + + /** + * Memoized disassembly. Returns two things: + * + * 1. byteOffsetsOfLines: Mapping of bytecode offsets where line numbers + * begin. For example, line 42 begins at `byteOffsetsOfLines[42]`. + * 2. lines: disassembled WAT lines. + */ + private readonly doDisassemble = once(async () => { + let lines: string[] = []; + let byteOffsetsOfLines: Uint32Array | undefined; + + for await (const chunk of this.getDisassembledStream()) { + lines = lines.concat(chunk.lines); + + let start: number; + if (byteOffsetsOfLines) { + const newOffsets = new Uint32Array(byteOffsetsOfLines.length + chunk.lines.length); + start = byteOffsetsOfLines.length; + newOffsets.set(byteOffsetsOfLines); + byteOffsetsOfLines = newOffsets; + } else { + byteOffsetsOfLines = new Uint32Array(chunk.lines.length); + start = 0; + } + + for (let i = 0; i < chunk.lines.length; i++) { + byteOffsetsOfLines[start + i] = chunk.bytecodeOffsets[i]; + } + } + + byteOffsetsOfLines ??= new Uint32Array(0); + + return { lines, byteOffsetsOfLines }; + }); + + private async *getDisassembledStream() { + const { scriptId } = this.event; + const r = await this.cdp.Debugger.disassembleWasmModule({ scriptId }); + if (!r) { + return; + } + + yield r.chunk; + + while (r.streamId) { + const r2 = await this.cdp.Debugger.nextWasmDisassemblyChunk({ streamId: r.streamId }); + if (!r2) { + return; + } + yield r2.chunk; + } + } +} + +class WasmSymbols extends DecompiledWasmSymbols { + private readonly mappedLines = new Map>(); + private get codeOffset() { + return this.event.codeOffset || 0; + } + + constructor( + event: Cdp.Debugger.ScriptParsedEvent, + cdp: Cdp.Api, + private readonly moduleId: string, + private readonly rpc: IWasmWorker['rpc'], + files: string[], + ) { + super(event, cdp, files); + } + + /** @inheritdoc */ + public override async originalPositionFor( + compiledPosition: IPosition, + ): Promise<{ url: string; position: IPosition } | undefined> { + const locations = await this.rpc.sendMessage('rawLocationToSourceLocation', { + codeOffset: compiledPosition.base0.columnNumber - this.codeOffset, + inlineFrameIndex: compiledPosition.base0.lineNumber, + rawModuleId: this.moduleId, + }); + + if (!locations.length) { + return super.originalPositionFor(compiledPosition); + } + + return { + position: new Base0Position(locations[0].lineNumber, locations[0].columnNumber), + url: locations[0].sourceFileURL, + }; + } + + /** @inheritdoc */ + public override async compiledPositionFor( + sourceUrl: string, + sourcePosition: IPosition, + ): Promise { + const { lineNumber, columnNumber } = sourcePosition.base0; + const locations = await this.rpc.sendMessage('sourceLocationToRawLocation', { + lineNumber, + columnNumber: columnNumber === 0 ? -1 : columnNumber, + rawModuleId: this.moduleId, + sourceFileURL: sourceUrl, + }); + + // special case: unlike sourcemaps, if we resolve a location on a line + // with nothing on it, sourceLocationToRawLocation returns undefined. + // If we think this might have happened, verify it and then get + // the next mapped line and use that location. + if (columnNumber === 0 && locations.length === 0) { + const mappedLines = await this.getMappedLines(sourceUrl); + const next = mappedLines.find(l => l > lineNumber); + if (!mappedLines.includes(lineNumber) && next /* always > 0 */) { + return this.compiledPositionFor(sourceUrl, new Base0Position(next, 0)); + } + } + + // todo@connor4312: will there ever be a location in another module? + return locations + .filter(l => l.rawModuleId === this.moduleId) + .map(l => new Base0Position(0, this.codeOffset + l.startOffset)); + } + + /** @inheritdoc */ + public override dispose() { + return this.rpc.sendMessage('removeRawModule', this.moduleId); + } + + /** @inheritdoc */ + public async getVariablesInScope( + callFrameId: string, + position: IPosition, + ): Promise { + const location = { + codeOffset: position.base0.columnNumber - this.codeOffset, + inlineFrameIndex: position.base0.lineNumber, + rawModuleId: this.moduleId, + }; + + const variables = await this.rpc.sendMessage('listVariablesInScope', location); + + return variables.map( + (v): IWasmVariable => ({ + name: v.name, + scope: v.scope as WasmScope, + type: v.type, + evaluate: async () => { + const result = await this.rpc.sendMessage('evaluate', v.name, location, callFrameId); + return result ? new WasmVariableEvaluation(result, this.rpc) : nullType; + }, + }), + ); + } + + /** @inheritdoc */ + public async getFunctionStack(position: IPosition): Promise<{ name: string }[]> { + const info = await this.rpc.sendMessage('getFunctionInfo', { + codeOffset: position.base0.columnNumber - this.codeOffset, + inlineFrameIndex: position.base0.lineNumber, + rawModuleId: this.moduleId, + }); + + return 'frames' in info ? info.frames : []; + } + + /** @inheritdoc */ + public async getStepSkipList( + direction: StepDirection, + position: IPosition, + sourceUrl?: string, + mappedPosition?: IPosition, + ): Promise { + const thisLocation = { + codeOffset: position.base0.columnNumber - this.codeOffset, + inlineFrameIndex: position.base0.lineNumber, + rawModuleId: this.moduleId, + }; + + const getOwnLineRanges = () => { + if (!(mappedPosition && sourceUrl)) { + return []; + } + return this.rpc.sendMessage('sourceLocationToRawLocation', { + lineNumber: mappedPosition.base0.lineNumber, + columnNumber: -1, + rawModuleId: this.moduleId, + sourceFileURL: sourceUrl, + }); + }; + + let rawRanges: Chrome.DevTools.RawLocationRange[]; + switch (direction) { + case StepDirection.Out: { + // Step out should step out of inline functions. + rawRanges = await this.rpc.sendMessage('getInlinedFunctionRanges', thisLocation); + break; + } + case StepDirection.Over: { + // step over should both step over inline functions and any + // intermediary statements on this line, which may exist + // in WAT assembly but not in source code. + const ranges = await Promise.all([ + this.rpc.sendMessage('getInlinedCalleesRanges', thisLocation), + getOwnLineRanges(), + ]); + rawRanges = flatten(ranges); + break; + } + case StepDirection.In: + // Step in should skip over any intermediary statements on this line + rawRanges = await getOwnLineRanges(); + break; + default: + rawRanges = []; + break; + } + + return rawRanges.map( + r => + new Range( + new Base0Position(0, r.startOffset + this.codeOffset), + new Base0Position(0, r.endOffset + this.codeOffset), + ), + ); + } + + private getMappedLines(sourceURL: string) { + const prev = this.mappedLines.get(sourceURL); + if (prev) { + return prev; + } + + const value = (async () => { + try { + const lines = await this.rpc.sendMessage('getMappedLines', this.moduleId, sourceURL); + return new Uint32Array(lines?.sort((a, b) => a - b) || []); + } catch { + return new Uint32Array(); + } + })(); + + this.mappedLines.set(sourceURL, value); + return value; + } +} + +const nullType: IWasmVariableEvaluation = { + type: 'null', + description: 'no properties', +}; + +class WasmVariableEvaluation implements IWasmVariableEvaluation { + public readonly type: string; + public readonly description: string | undefined; + public readonly linearMemoryAddress: number | undefined; + public readonly linearMemorySize: number | undefined; + + public readonly getChildren?: () => Promise<{ name: string; value: IWasmVariableEvaluation }[]>; + + constructor(evaluation: NonNullable>, rpc: IWasmWorker['rpc']) { + this.type = evaluation.type; + this.description = evaluation.description; + this.linearMemoryAddress = evaluation.linearMemoryAddress; + this.linearMemorySize = evaluation.linearMemoryAddress; + + if (evaluation.objectId && evaluation.hasChildren) { + const oid = evaluation.objectId; + this.getChildren = once(() => this._getChildren(rpc, oid)); + } + } + + private async _getChildren( + rpc: IWasmWorker['rpc'], + objectId: string, + ): Promise<{ name: string; value: IWasmVariableEvaluation }[]> { + const vars = await rpc.sendMessage('getProperties', objectId); + return vars.map(v => ({ + name: v.name, + value: new WasmVariableEvaluation(v.value, rpc), + })); + } +} diff --git a/src/adapter/exceptionPauseService.test.ts b/src/adapter/exceptionPauseService.test.ts index 1d9807044..b76199beb 100644 --- a/src/adapter/exceptionPauseService.test.ts +++ b/src/adapter/exceptionPauseService.test.ts @@ -5,16 +5,16 @@ import { expect } from 'chai'; import { SinonStub, stub } from 'sinon'; import Cdp from '../cdp/api'; -import { stubbedCdpApi, StubCdpApi } from '../cdp/stubbedApi'; +import { StubCdpApi, stubbedCdpApi } from '../cdp/stubbedApi'; import { upcastPartial } from '../common/objUtils'; import { AnyLaunchConfiguration } from '../configuration'; import Dap from '../dap/api'; -import { stubbedDapApi, StubDapApi } from '../dap/stubbedApi'; +import { StubDapApi, stubbedDapApi } from '../dap/stubbedApi'; import { assertNotResolved, assertResolved } from '../test/asserts'; import { IEvaluator } from './evaluator'; import { ExceptionPauseService, PauseOnExceptionsState } from './exceptionPauseService'; import { ScriptSkipper } from './scriptSkipper/implementation'; -import { SourceContainer } from './sources'; +import { SourceContainer } from './sourceContainer'; describe('ExceptionPauseService', () => { let prepareEval: SinonStub; diff --git a/src/adapter/exceptionPauseService.ts b/src/adapter/exceptionPauseService.ts index 442e362db..d370aec6b 100644 --- a/src/adapter/exceptionPauseService.ts +++ b/src/adapter/exceptionPauseService.ts @@ -15,7 +15,7 @@ import { ProtocolError } from '../dap/protocolError'; import { wrapBreakCondition } from './breakpoints/conditions/expression'; import { IEvaluator, PreparedCallFrameExpr } from './evaluator'; import { IScriptSkipper } from './scriptSkipper/scriptSkipper'; -import { SourceContainer } from './sources'; +import { SourceContainer } from './sourceContainer'; export interface IExceptionPauseService { readonly launchBlocker: Promise; diff --git a/src/adapter/pause.ts b/src/adapter/pause.ts new file mode 100644 index 000000000..1eea0b6cf --- /dev/null +++ b/src/adapter/pause.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import Cdp from '../cdp/api'; +import { IPossibleBreakLocation } from './breakpoints'; +import { StackTrace } from './stackTrace'; +import { Thread } from './threads'; + +export type PausedReason = + | 'step' + | 'breakpoint' + | 'exception' + | 'pause' + | 'entry' + | 'goto' + | 'function breakpoint' + | 'data breakpoint' + | 'frame_entry'; + +export const enum StepDirection { + In, + Over, + Out, +} + +export type ExpectedPauseReason = + | { reason: Exclude; description?: string } + | { reason: 'step'; description?: string; direction: StepDirection }; + +export interface IPausedDetails { + thread: Thread; + reason: PausedReason; + event: Cdp.Debugger.PausedEvent; + description: string; + stackTrace: StackTrace; + stepInTargets?: IPossibleBreakLocation[]; + hitBreakpoints?: string[]; + text?: string; + exception?: Cdp.Runtime.RemoteObject; +} diff --git a/src/adapter/profiling/basicCpuProfiler.ts b/src/adapter/profiling/basicCpuProfiler.ts index 7f1ed8759..da7536a81 100644 --- a/src/adapter/profiling/basicCpuProfiler.ts +++ b/src/adapter/profiling/basicCpuProfiler.ts @@ -13,7 +13,7 @@ import { AnyLaunchConfiguration } from '../../configuration'; import { profileCaptureError } from '../../dap/errors'; import { ProtocolError } from '../../dap/protocolError'; import { FS, FsPromises } from '../../ioc-extras'; -import { SourceContainer } from '../sources'; +import { SourceContainer } from '../sourceContainer'; import { SourceAnnotationHelper } from './sourceAnnotationHelper'; export interface IBasicProfileParams { diff --git a/src/adapter/profiling/basicHeapProfiler.ts b/src/adapter/profiling/basicHeapProfiler.ts index d56bc703a..329e8145b 100644 --- a/src/adapter/profiling/basicHeapProfiler.ts +++ b/src/adapter/profiling/basicHeapProfiler.ts @@ -12,7 +12,7 @@ import { AnyLaunchConfiguration } from '../../configuration'; import { profileCaptureError } from '../../dap/errors'; import { ProtocolError } from '../../dap/protocolError'; import { FS, FsPromises } from '../../ioc-extras'; -import { SourceContainer } from '../sources'; +import { SourceContainer } from '../sourceContainer'; import { SourceAnnotationHelper } from './sourceAnnotationHelper'; /** diff --git a/src/adapter/profiling/sourceAnnotationHelper.ts b/src/adapter/profiling/sourceAnnotationHelper.ts index 2aa84caa1..ad1cb40ba 100644 --- a/src/adapter/profiling/sourceAnnotationHelper.ts +++ b/src/adapter/profiling/sourceAnnotationHelper.ts @@ -4,7 +4,7 @@ import Cdp from '../../cdp/api'; import Dap from '../../dap/api'; -import { SourceContainer } from '../sources'; +import { SourceContainer } from '../sourceContainer'; interface IEmbeddedLocation { lineNumber: number; @@ -45,14 +45,14 @@ export class SourceAnnotationHelper { return []; } + const locations = await this.sources.currentSiblingUiLocations({ + lineNumber: callFrame.lineNumber + 1, + columnNumber: callFrame.columnNumber + 1, + source, + }); + return Promise.all( - this.sources - .currentSiblingUiLocations({ - lineNumber: callFrame.lineNumber + 1, - columnNumber: callFrame.columnNumber + 1, - source, - }) - .map(async loc => ({ ...loc, source: await loc.source.toDapShallow() })), + locations.map(async loc => ({ ...loc, source: await loc.source.toDapShallow() })), ); })(), }); diff --git a/src/adapter/scriptSkipper/implementation.ts b/src/adapter/scriptSkipper/implementation.ts index 2cd6e0146..406af2d22 100644 --- a/src/adapter/scriptSkipper/implementation.ts +++ b/src/adapter/scriptSkipper/implementation.ts @@ -19,14 +19,8 @@ import * as urlUtils from '../../common/urlUtils'; import { AnyLaunchConfiguration } from '../../configuration'; import Dap from '../../dap/api'; import { ITarget } from '../../targets/targets'; -import { - ISourceScript, - ISourceWithMap, - Source, - SourceContainer, - SourceFromMap, - isSourceWithMap, -} from '../sources'; +import { ISourceScript, ISourceWithMap, Source, SourceFromMap, isSourceWithMap } from '../source'; +import { SourceContainer } from '../sourceContainer'; import { getSourceSuffix } from '../templates'; import { simpleGlobsToRe } from './simpleGlobToRe'; @@ -212,7 +206,7 @@ export class ScriptSkipper { const parentIsSkipped = this.isScriptSkipped(source.url); const skipRanges: Cdp.Debugger.ScriptPosition[] = []; let inSkipRange = parentIsSkipped; - Array.from(source.sourceMap.sourceByUrl.values()).forEach(authoredSource => { + for (const authoredSource of source.sourceMap.sourceByUrl.values()) { let isSkippedSource = this.isScriptSkipped(authoredSource.url); if (typeof isSkippedSource === 'undefined') { // If not toggled or specified in launch config, inherit the parent's status @@ -220,7 +214,7 @@ export class ScriptSkipper { } if (isSkippedSource !== inSkipRange) { - const locations = this._sourceContainer.currentSiblingUiLocations( + const locations = await this._sourceContainer.currentSiblingUiLocations( { source: authoredSource, lineNumber: 1, columnNumber: 1 }, source, ); @@ -237,7 +231,7 @@ export class ScriptSkipper { ); } } - }); + } let targets = scripts; if (!skipRanges.length) { diff --git a/src/adapter/smartStepping.ts b/src/adapter/smartStepping.ts index e10405dc5..87295c4c2 100644 --- a/src/adapter/smartStepping.ts +++ b/src/adapter/smartStepping.ts @@ -6,9 +6,10 @@ import { inject, injectable } from 'inversify'; import { ILogger, LogTag } from '../common/logging'; import { isInstanceOf } from '../common/objUtils'; import { AnyLaunchConfiguration } from '../configuration'; -import { isSourceWithMap, UnmappedReason } from './sources'; +import { ExpectedPauseReason, IPausedDetails, PausedReason, StepDirection } from './pause'; +import { isSourceWithMap } from './source'; +import { UnmappedReason } from './sourceContainer'; import { StackFrame } from './stackTrace'; -import { ExpectedPauseReason, IPausedDetails, PausedReason, StepDirection } from './threads'; export const enum StackFrameStepOverReason { NotStepped, diff --git a/src/adapter/source.ts b/src/adapter/source.ts new file mode 100644 index 000000000..6dfabfe46 --- /dev/null +++ b/src/adapter/source.ts @@ -0,0 +1,559 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { relative } from 'path'; +import { URL } from 'url'; +import Cdp from '../cdp/api'; +import { checkContentHash } from '../common/hash/checkContentHash'; +import { once } from '../common/objUtils'; +import { forceForwardSlashes, isSubdirectoryOf } from '../common/pathUtils'; +import { IDeferred, delay, getDeferred } from '../common/promiseUtil'; +import { ISourceMapMetadata, SourceMap } from '../common/sourceMaps/sourceMap'; +import { InlineScriptOffset } from '../common/sourcePathResolver'; +import * as sourceUtils from '../common/sourceUtils'; +import { prettyPrintAsSourceMap } from '../common/sourceUtils'; +import * as utils from '../common/urlUtils'; +import Dap from '../dap/api'; +import { IWasmSymbols } from './dwarf/wasmSymbolProvider'; +import type { SourceContainer } from './sourceContainer'; + +// Represents a text source visible to the user. +// +// Source maps flow (start with compiled1 and compiled2). Two different compiled sources +// reference to the same source map, and produce two different resolved urls leading +// to different source map sources. This is a corner case, usually there is a single +// resolved url and a single source map source per each sourceUrl in the source map. +// +// ------> sourceMapUrl -> SourceContainer._sourceMaps -> SourceMapData -> map +// | | | +// | compiled1 - - - - - - - source1 <-- resolvedUrl1 <-- sourceUrl <---- +// | | +// compiled2 - - - - - - - - - - source2 <-- resolvedUrl2 <-- sourceUrl <---- +// +// compiled1 and source1 are connected (same goes for compiled2 and source2): +// compiled1._sourceMapSourceByUrl.get(sourceUrl) === source1 +// source1._compiledToSourceUrl.get(compiled1) === sourceUrl +// + +export class Source { + public readonly sourceReference: number; + private readonly _name: string; + private readonly _fqname: string; + + /** + * Function to retrieve the content of the source. + */ + private readonly _contentGetter: ContentGetter; + + private readonly _container: SourceContainer; + + /** + * Hypothesized absolute path for the source. May or may not actually exist. + */ + public readonly absolutePath: string; + + public sourceMap?: SourceLocationProvider; + + // This is the same as |_absolutePath|, but additionally checks that file exists to + // avoid errors when page refers to non-existing paths/urls. + private readonly _existingAbsolutePath: Promise; + private _scripts: ISourceScript[] = []; + + /** + * @param inlineScriptOffset Offset of the start location of the script in + * its source file. This is used on scripts in HTML pages, where the script + * is nested in the content. + * @param contentHash Optional hash of the file contents. This is used to + * check whether the script we get is the same one as what's on disk. This + * can be used to detect in-place transpilation. + * @param runtimeScriptOffset Offset of the start location of the script + * in the runtime *only*. This differs from the inlineScriptOffset, as the + * inline offset of also reflected in the file. This is used to deal with + * the runtime wrapping the source and offsetting locations which should + * not be shown to the user. + */ + constructor( + container: SourceContainer, + public readonly url: string, + absolutePath: string | undefined, + contentGetter: ContentGetter, + sourceMapUrl?: string, + public readonly inlineScriptOffset?: InlineScriptOffset, + public readonly runtimeScriptOffset?: InlineScriptOffset, + public readonly contentHash?: string, + ) { + this.sourceReference = container.getSourceReference(url); + this._contentGetter = once(contentGetter); + this._container = container; + this.absolutePath = absolutePath || ''; + this._fqname = this._fullyQualifiedName(); + this._name = this._humanName(); + this.setSourceMapUrl(sourceMapUrl); + + this._existingAbsolutePath = this.checkContentHash(contentHash); + } + + /** Returns the absolute path if the conten hash matches. */ + protected checkContentHash(contentHash?: string) { + return checkContentHash( + this.absolutePath, + // Inline scripts will never match content of the html file. We skip the content check. + this.inlineScriptOffset || this.runtimeScriptOffset ? undefined : contentHash, + this._container._fileContentOverridesForTest.get(this.absolutePath), + ); + } + + /** Offsets a location that came from the runtime script, to where it appears in source code */ + public offsetScriptToSource(obj: T): T { + if (this.runtimeScriptOffset) { + return { + ...obj, + lineNumber: obj.lineNumber - this.runtimeScriptOffset.lineOffset, + columnNumber: obj.columnNumber - this.runtimeScriptOffset.columnOffset, + }; + } + + return obj; + } + /** Offsets a location that came from source code, to where it appears in the runtime script */ + public offsetSourceToScript(obj: T): T { + if (this.runtimeScriptOffset) { + return { + ...obj, + lineNumber: obj.lineNumber + this.runtimeScriptOffset.lineOffset, + columnNumber: obj.columnNumber + this.runtimeScriptOffset.columnOffset, + }; + } + + return obj; + } + + private setSourceMapUrl(sourceMapUrl?: string) { + if (!sourceMapUrl) { + this.sourceMap = undefined; + return; + } + + this.sourceMap = { + type: SourceLocationType.SourceMap, + sourceByUrl: new Map(), + value: getDeferred(), + metadata: { + sourceMapUrl, + compiledPath: this.absolutePath || this.url, + }, + }; + } + + /** + * Associated a script with this source. This is only valid for a source + * from the runtime, not a {@link SourceFromMap}. + */ + addScript(script: ISourceScript): void { + this._scripts.push(script); + } + + /** + * Filters scripts from a source, done when an execution context is removed. + */ + filterScripts(fn: (s: ISourceScript) => boolean): void { + this._scripts = this._scripts.filter(fn); + } + + /** + * Gets scripts associated with this source. + */ + get scripts(): ReadonlyArray { + return this._scripts; + } + + /** + * Gets a suggested mimetype for the source. + */ + get getSuggestedMimeType(): string | undefined { + if (this.url.endsWith('.wat')) { + return 'text/wat'; // does not seem to be any standard mime type for WAT + } + + // only return an explicit mimetype if the file has no extension (such as + // with node internals) or a query path. Otherwise, let the editor guess. + if (!/\.[^/]+$/.test(this.url) || this.url.includes('?')) { + return 'text/javascript'; + } + } + + async content(): Promise { + let content = await this._contentGetter(); + + // pad for the inline source offset, see + // https://github.com/microsoft/vscode-js-debug/issues/736 + if (this.inlineScriptOffset?.lineOffset) { + content = '\n'.repeat(this.inlineScriptOffset.lineOffset) + content; + } + + return content; + } + + /** + * Pretty-prints the source. Generates a beauitified source map if possible + * and it hasn't already been done, and returns the created map and created + * ephemeral source. Returns undefined if the source can't be beautified. + */ + public async prettyPrint(): Promise<{ map: SourceMap; source: Source } | undefined> { + if (!this._container) { + return undefined; + } + + if ( + isSourceWithSourceMap(this) && + this.sourceMap.metadata.sourceMapUrl.endsWith('-pretty.map') + ) { + const map = this.sourceMap.value.settledValue; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map && { map, source: [...this.sourceMap.sourceByUrl!.values()][0] }; + } + + const content = await this.content(); + if (!content) { + return undefined; + } + + // Eval'd scripts have empty urls, give them a temporary one for the purpose + // of the sourcemap. See #929 + const baseUrl = this.url || `eval://${this.sourceReference}.js`; + const sourceMapUrl = baseUrl + '-pretty.map'; + const basename = baseUrl.split(/[\/\\]/).pop() as string; + const fileName = basename + '-pretty.js'; + const map = await prettyPrintAsSourceMap(fileName, content, baseUrl, sourceMapUrl); + if (!map) { + return undefined; + } + + // Note: this overwrites existing source map. + this.setSourceMapUrl(sourceMapUrl); + const asCompiled = this as ISourceWithMap; + this.sourceMap = { + type: SourceLocationType.SourceMap, + metadata: { compiledPath: this.absolutePath, sourceMapUrl: '' }, + sourceByUrl: new Map(), + value: getDeferred(), + }; + this.sourceMap.value.resolve(map); + + await this._container._addSourceMapSources(asCompiled, map); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return { map, source: [...asCompiled.sourceMap.sourceByUrl.values()][0] }; + } + + /** + * Returns a DAP representation of the source. + */ + async toDap(): Promise { + return this.toDapShallow(); + } + + /** + * Returns a DAP representation without including any nested sources. + */ + public async toDapShallow(): Promise { + const existingAbsolutePath = await this._existingAbsolutePath; + const dap: Dap.Source = { + name: this._name, + path: this._fqname, + sourceReference: this.sourceReference, + presentationHint: this.blackboxed() ? 'deemphasize' : undefined, + origin: this.blackboxed() ? l10n.t('Skipped by skipFiles') : undefined, + }; + + if (existingAbsolutePath) { + dap.sourceReference = 0; + dap.path = existingAbsolutePath; + } + + return dap; + } + + existingAbsolutePath(): Promise { + return this._existingAbsolutePath; + } + + async prettyName(): Promise { + const path = await this._existingAbsolutePath; + if (path) return path; + return this._fqname; + } + + /** + * Gets the human-readable name of the source. + */ + private _humanName() { + if (utils.isAbsolute(this._fqname)) { + for (const root of this._container.rootPaths) { + if (isSubdirectoryOf(root, this._fqname)) { + return forceForwardSlashes(relative(root, this._fqname)); + } + } + } + + return this._fqname; + } + + /** + * Returns a pretty name for the script. This is the name displayed in + * stack traces and returned through DAP if the file does not verifiably + * exist on disk. + */ + private _fullyQualifiedName(): string { + if (!this.url) { + return '/VM' + this.sourceReference; + } + + if (this.url.endsWith(sourceUtils.SourceConstants.ReplExtension)) { + return 'repl'; + } + + if (this.absolutePath.startsWith('')) { + return this.absolutePath; + } + + if (utils.isAbsolute(this.url)) { + return this.url; + } + + const parsedAbsolute = utils.fileUrlToAbsolutePath(this.url); + if (parsedAbsolute) { + return parsedAbsolute; + } + + let fqname = this.url; + try { + const tokens: string[] = []; + const url = new URL(this.url); + if (url.protocol === 'data:') { + return '/VM' + this.sourceReference; + } + + if (url.hostname) { + tokens.push(url.hostname); + } + + if (url.port) { + tokens.push('\uA789' + url.port); // : in unicode + } + + if (url.pathname) { + tokens.push(/^\/[a-z]:/.test(url.pathname) ? url.pathname.slice(1) : url.pathname); + } + + const searchParams = url.searchParams?.toString(); + if (searchParams) { + tokens.push('?' + searchParams); + } + + fqname = tokens.join(''); + } catch (e) { + // ignored + } + + if (fqname.endsWith('/')) { + fqname += '(index)'; + } + + if (this.inlineScriptOffset) { + fqname += `\uA789${this.inlineScriptOffset.lineOffset + 1}:${ + this.inlineScriptOffset.columnOffset + 1 + }`; + } + return fqname; + } + + /** + * Gets whether this script is blackboxed (part of the skipfiles). + */ + public blackboxed(): boolean { + return this._container.isSourceSkipped(this.url); + } +} + +export interface IWasmLocationProvider extends ISourceLocationProvider { + type: SourceLocationType.WasmSymbols; + value: IDeferred; +} +export interface ISourceScript { + executionContextId: Cdp.Runtime.ExecutionContextId; + scriptId: Cdp.Runtime.ScriptId; + url: string; +} + +export const enum SourceLocationType { + SourceMap, + WasmSymbols, +} + +export interface ISourceLocationProvider { + sourceByUrl: Map; +} + +export interface ISourceMapLocationProvider extends ISourceLocationProvider { + type: SourceLocationType.SourceMap; + /** Metadata from the source map. */ + metadata: ISourceMapMetadata; + /** The loaded sourcemap, or undefined if loading it failed. */ + value: IDeferred; +} + +export type SourceLocationProvider = ISourceMapLocationProvider | IWasmLocationProvider; +export namespace SourceLocationProvider { + /** Waits for the sourcemap or wasm symbols to be loaded. */ + export async function waitForValue( + p: SourceLocationProvider, + ): Promise { + return p.value.promise; + } + + /** Waits for the sourcemap or wasm symbols to be loaded. */ + export function waitForValueWithTimeout( + p: SourceLocationProvider, + timeout: number, + ): Promise { + if (p.type === SourceLocationType.SourceMap && p.value.settledValue) { + return Promise.resolve(p.value.settledValue); + } + + return Promise.race([waitForValue(p), delay(timeout) as Promise]); + } + + /** Waits for the location to be available before returning {@link ISourceLocationProvider.sourceByUrl} */ + export async function waitForSources(p: SourceLocationProvider) { + await waitForValue(p); + return p.sourceByUrl; + } +} +/** + * A Source that has an associated sourcemap. + */ + +export interface ISourceWithMap + extends Source { + sourceMap: T; +} +/** + * A Source generated from a sourcemap. For example, a TypeScript input file + * discovered from its compiled JavaScript code. + */ + +export class SourceFromMap extends Source { + // Sources generated from the source map are referenced by some compiled sources + // (through a source map). This map holds the original |sourceUrl| as written in the + // source map, which was used to produce this source for each compiled. + public readonly compiledToSourceUrl = new Map(); +} + +export class WasmSource extends Source implements ISourceWithMap { + public readonly sourceMap: IWasmLocationProvider; + + constructor( + container: SourceContainer, + public readonly event: Cdp.Debugger.ScriptParsedEvent, + absolutePath: string | undefined, + ) { + super( + container, + event.url, + absolutePath, + () => Promise.resolve('Binary content not shown, see the decompiled WAT file'), + undefined, + undefined, + undefined, + undefined, + ); + + this.sourceMap = { + type: SourceLocationType.WasmSymbols, + value: getDeferred(), + // todo: popular sourceByUrl when wasm symbols load + sourceByUrl: new Map(), + }; + } + + protected override checkContentHash(): Promise { + // We translate wasm to wat, so we should never use the original disk version: + return Promise.resolve(undefined); + } + + /** Offsets a location that came from the runtime script, to where it appears in source code. (Base 1 locations) */ + public override offsetScriptToSource( + obj: T, + ): T { + return obj; + } + /** Offsets a location that came from source code, to where it appears in the runtime script. (Base 1 locations) */ + public override offsetSourceToScript( + obj: T, + ): T { + return obj; + } +} + +export const isSourceWithMap = (source: unknown): source is ISourceWithMap => + !!source && source instanceof Source && !!source.sourceMap; + +export const isSourceWithSourceMap = ( + source: unknown, +): source is ISourceWithMap => + isSourceWithMap(source) && source.sourceMap.type === SourceLocationType.SourceMap; + +export const isSourceWithWasm = ( + source: unknown, +): source is ISourceWithMap => + isSourceWithMap(source) && source.sourceMap.type === SourceLocationType.WasmSymbols; + +export type ContentGetter = () => Promise; +export type LineColumn = { lineNumber: number; columnNumber: number }; // 1-based + +export function uiToRawOffset(lc: T, offset?: InlineScriptOffset): T { + if (!offset) { + return lc; + } + + let { lineNumber, columnNumber } = lc; + if (offset) { + lineNumber += offset.lineOffset; + if (lineNumber <= 1) columnNumber += offset.columnOffset; + } + + return { ...lc, lineNumber, columnNumber }; +} + +export function rawToUiOffset(lc: T, offset?: InlineScriptOffset): T { + if (!offset) { + return lc; + } + + let { lineNumber, columnNumber } = lc; + if (offset) { + lineNumber = Math.max(1, lineNumber - offset.lineOffset); + if (lineNumber <= 1) columnNumber = Math.max(1, columnNumber - offset.columnOffset); + } + + return { ...lc, lineNumber, columnNumber }; +} + +export const base0To1 = (lc: LineColumn) => ({ + lineNumber: lc.lineNumber + 1, + columnNumber: lc.columnNumber + 1, +}); + +export const base1To0 = (lc: LineColumn) => ({ + lineNumber: lc.lineNumber - 1, + columnNumber: lc.columnNumber - 1, +}); // This is a ui location which corresponds to a position in the document user can see (Source, Dap.Source). + +/** @todo make this use IPosition's instead */ +export interface IUiLocation { + lineNumber: number; // 1-based + columnNumber: number; // 1-based + source: Source; +} diff --git a/src/adapter/sources.ts b/src/adapter/sourceContainer.ts similarity index 50% rename from src/adapter/sources.ts rename to src/adapter/sourceContainer.ts index 476019729..bb158b589 100644 --- a/src/adapter/sources.ts +++ b/src/adapter/sourceContainer.ts @@ -2,27 +2,19 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ -import * as l10n from '@vscode/l10n'; import { inject, injectable } from 'inversify'; import { xxHash32 } from 'js-xxhash'; -import { relative } from 'path'; import { NullableMappedPosition, SourceMapConsumer } from 'source-map'; -import { URL } from 'url'; import Cdp from '../cdp/api'; -import { ICdpApi } from '../cdp/connection'; -import { binarySearch } from '../common/arrayUtils'; import { MapUsingProjection } from '../common/datastructure/mapUsingProjection'; import { EventEmitter } from '../common/events'; -import { checkContentHash } from '../common/hash/checkContentHash'; import { ILogger, LogTag } from '../common/logging'; -import { once } from '../common/objUtils'; -import { forceForwardSlashes, isSubdirectoryOf, properResolve } from '../common/pathUtils'; -import { delay, getDeferred } from '../common/promiseUtil'; +import { properResolve } from '../common/pathUtils'; +import { Base01Position, Base1Position, IPosition } from '../common/positions'; import { ISourceMapMetadata, SourceMap } from '../common/sourceMaps/sourceMap'; import { CachingSourceMapFactory, ISourceMapFactory } from '../common/sourceMaps/sourceMapFactory'; -import { InlineScriptOffset, ISourcePathResolver } from '../common/sourcePathResolver'; +import { ISourcePathResolver, InlineScriptOffset } from '../common/sourcePathResolver'; import * as sourceUtils from '../common/sourceUtils'; -import { prettyPrintAsSourceMap } from '../common/sourceUtils'; import * as utils from '../common/urlUtils'; import { AnyLaunchConfiguration } from '../configuration'; import Dap from '../dap/api'; @@ -31,18 +23,28 @@ import { sourceMapParseFailed } from '../dap/errors'; import { IInitializeParams } from '../ioc-extras'; import { IStatistics } from '../telemetry/classification'; import { extractErrorDetails } from '../telemetry/dapTelemetryReporter'; +import { IWasmSymbolProvider, IWasmSymbols } from './dwarf/wasmSymbolProvider'; import { IResourceProvider } from './resourceProvider'; import { ScriptSkipper } from './scriptSkipper/implementation'; import { IScriptSkipper } from './scriptSkipper/scriptSkipper'; +import { + ContentGetter, + ISourceMapLocationProvider, + ISourceWithMap, + IUiLocation, + LineColumn, + Source, + SourceFromMap, + SourceLocationProvider, + WasmSource, + isSourceWithMap, + isSourceWithSourceMap, + isSourceWithWasm, + rawToUiOffset, + uiToRawOffset, +} from './source'; import { Script } from './threads'; -// This is a ui location which corresponds to a position in the document user can see (Source, Dap.Source). -export interface IUiLocation { - lineNumber: number; // 1-based - columnNumber: number; // 1-based - source: Source; -} - function isUiLocation(loc: unknown): loc is IUiLocation { return ( typeof (loc as IUiLocation).lineNumber === 'number' && @@ -60,10 +62,6 @@ const getFallbackPosition = () => ({ isSourceMapLoadFailure: true, }); -type ContentGetter = () => Promise; - -// Each source map has a number of compiled sources referncing it. -type SourceMapData = { compiled: Set; map?: SourceMap; loaded: Promise }; export type SourceMapTimeouts = { // This is a source map loading delay used for testing. load: number; @@ -100,524 +98,6 @@ const defaultTimeouts: SourceMapTimeouts = { sourceMapCumulativePause: 10000, }; -export interface ISourceScript { - executionContextId: Cdp.Runtime.ExecutionContextId; - scriptId: Cdp.Runtime.ScriptId; - url: string; -} - -// Represents a text source visible to the user. -// -// Source maps flow (start with compiled1 and compiled2). Two different compiled sources -// reference to the same source map, and produce two different resolved urls leading -// to different source map sources. This is a corner case, usually there is a single -// resolved url and a single source map source per each sourceUrl in the source map. -// -// ------> sourceMapUrl -> SourceContainer._sourceMaps -> SourceMapData -> map -// | | | -// | compiled1 - - - - - - - source1 <-- resolvedUrl1 <-- sourceUrl <---- -// | | -// compiled2 - - - - - - - - - - source2 <-- resolvedUrl2 <-- sourceUrl <---- -// -// compiled1 and source1 are connected (same goes for compiled2 and source2): -// compiled1._sourceMapSourceByUrl.get(sourceUrl) === source1 -// source1._compiledToSourceUrl.get(compiled1) === sourceUrl -// -export class Source { - public readonly sourceReference: number; - private readonly _name: string; - private readonly _fqname: string; - - /** - * Function to retrieve the content of the source. - */ - private readonly _contentGetter: ContentGetter; - - private readonly _container: SourceContainer; - - /** - * Hypothesized absolute path for the source. May or may not actually exist. - */ - public readonly absolutePath: string; - - public sourceMap?: ISourceWithMap['sourceMap']; - - // This is the same as |_absolutePath|, but additionally checks that file exists to - // avoid errors when page refers to non-existing paths/urls. - private readonly _existingAbsolutePath: Promise; - private _scripts: ISourceScript[] = []; - - /** - * @param inlineScriptOffset Offset of the start location of the script in - * its source file. This is used on scripts in HTML pages, where the script - * is nested in the content. - * @param contentHash Optional hash of the file contents. This is used to - * check whether the script we get is the same one as what's on disk. This - * can be used to detect in-place transpilation. - * @param runtimeScriptOffset Offset of the start location of the script - * in the runtime *only*. This differs from the inlineScriptOffset, as the - * inline offset of also reflected in the file. This is used to deal with - * the runtime wrapping the source and offsetting locations which should - * not be shown to the user. - */ - constructor( - container: SourceContainer, - public readonly url: string, - absolutePath: string | undefined, - contentGetter: ContentGetter, - sourceMapUrl?: string, - public readonly inlineScriptOffset?: InlineScriptOffset, - public readonly runtimeScriptOffset?: InlineScriptOffset, - public readonly contentHash?: string, - ) { - this.sourceReference = container.getSourceReference(url); - this._contentGetter = once(contentGetter); - this._container = container; - this.absolutePath = absolutePath || ''; - this._fqname = this._fullyQualifiedName(); - this._name = this._humanName(); - this.setSourceMapUrl(sourceMapUrl); - - this._existingAbsolutePath = this.checkContentHash(contentHash); - } - - /** Returns the absolute path if the conten hash matches. */ - protected checkContentHash(contentHash?: string) { - return checkContentHash( - this.absolutePath, - // Inline scripts will never match content of the html file. We skip the content check. - this.inlineScriptOffset || this.runtimeScriptOffset ? undefined : contentHash, - this._container._fileContentOverridesForTest.get(this.absolutePath), - ); - } - - /** Offsets a location that came from the runtime script, to where it appears in source code */ - public offsetScriptToSource(obj: T): T { - if (this.runtimeScriptOffset) { - return { - ...obj, - lineNumber: obj.lineNumber - this.runtimeScriptOffset.lineOffset, - columnNumber: obj.columnNumber - this.runtimeScriptOffset.columnOffset, - }; - } - - return obj; - } - /** Offsets a location that came from source code, to where it appears in the runtime script */ - public offsetSourceToScript(obj: T): T { - if (this.runtimeScriptOffset) { - return { - ...obj, - lineNumber: obj.lineNumber + this.runtimeScriptOffset.lineOffset, - columnNumber: obj.columnNumber + this.runtimeScriptOffset.columnOffset, - }; - } - - return obj; - } - - private setSourceMapUrl(sourceMapUrl?: string) { - if (!sourceMapUrl) { - this.sourceMap = undefined; - return; - } - - this.sourceMap = { - url: sourceMapUrl, - sourceByUrl: new Map(), - metadata: { - sourceMapUrl, - compiledPath: this.absolutePath || this.url, - }, - }; - } - - /** - * Associated a script with this source. This is only valid for a source - * from the runtime, not a {@link SourceFromMap}. - */ - addScript(script: ISourceScript): void { - this._scripts.push(script); - } - - /** - * Filters scripts from a source, done when an execution context is removed. - */ - filterScripts(fn: (s: ISourceScript) => boolean): void { - this._scripts = this._scripts.filter(fn); - } - - /** - * Gets scripts associated with this source. - */ - get scripts(): ReadonlyArray { - return this._scripts; - } - - /** - * Gets a suggested mimetype for the source. - */ - get getSuggestedMimeType(): string | undefined { - // only return an explicit mimetype if the file has no extension (such as - // with node internals) or a query path. Otherwise, let the editor guess. - if (!/\.[^/]+$/.test(this.url) || this.url.includes('?')) { - return 'text/javascript'; - } - } - - async content(): Promise { - let content = await this._contentGetter(); - - // pad for the inline source offset, see - // https://github.com/microsoft/vscode-js-debug/issues/736 - if (this.inlineScriptOffset?.lineOffset) { - content = '\n'.repeat(this.inlineScriptOffset.lineOffset) + content; - } - - return content; - } - - /** - * Pretty-prints the source. Generates a beauitified source map if possible - * and it hasn't already been done, and returns the created map and created - * ephemeral source. Returns undefined if the source can't be beautified. - */ - public async prettyPrint(): Promise<{ map: SourceMap; source: Source } | undefined> { - if (!this._container) { - return undefined; - } - - if (isSourceWithMap(this) && this.sourceMap.url.endsWith('-pretty.map')) { - const map = this._container._sourceMaps.get(this.sourceMap?.url)?.map; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return map && { map, source: [...this.sourceMap.sourceByUrl!.values()][0] }; - } - - const content = await this.content(); - if (!content) { - return undefined; - } - - // Eval'd scripts have empty urls, give them a temporary one for the purpose - // of the sourcemap. See #929 - const baseUrl = this.url || `eval://${this.sourceReference}.js`; - const sourceMapUrl = baseUrl + '-pretty.map'; - const basename = baseUrl.split(/[\/\\]/).pop() as string; - const fileName = basename + '-pretty.js'; - const map = await prettyPrintAsSourceMap(fileName, content, baseUrl, sourceMapUrl); - if (!map) { - return undefined; - } - - // Note: this overwrites existing source map. - this.setSourceMapUrl(sourceMapUrl); - const asCompiled = this as ISourceWithMap; - const sourceMap: SourceMapData = { - compiled: new Set([asCompiled]), - map, - loaded: Promise.resolve(), - }; - this._container._sourceMaps.set(sourceMapUrl, sourceMap); - await this._container._addSourceMapSources(asCompiled, map); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return { map, source: [...asCompiled.sourceMap.sourceByUrl.values()][0] }; - } - - /** - * Returns a DAP representation of the source. - */ - async toDap(): Promise { - return this.toDapShallow(); - } - - /** - * Returns a DAP representation without including any nested sources. - */ - public async toDapShallow(): Promise { - const existingAbsolutePath = await this._existingAbsolutePath; - const dap: Dap.Source = { - name: this._name, - path: this._fqname, - sourceReference: this.sourceReference, - presentationHint: this.blackboxed() ? 'deemphasize' : undefined, - origin: this.blackboxed() ? l10n.t('Skipped by skipFiles') : undefined, - }; - - if (existingAbsolutePath) { - dap.sourceReference = 0; - dap.path = existingAbsolutePath; - } - - return dap; - } - - existingAbsolutePath(): Promise { - return this._existingAbsolutePath; - } - - async prettyName(): Promise { - const path = await this._existingAbsolutePath; - if (path) return path; - return this._fqname; - } - - /** - * Gets the human-readable name of the source. - */ - private _humanName() { - if (utils.isAbsolute(this._fqname)) { - for (const root of this._container.rootPaths) { - if (isSubdirectoryOf(root, this._fqname)) { - return forceForwardSlashes(relative(root, this._fqname)); - } - } - } - - return this._fqname; - } - - /** - * Returns a pretty name for the script. This is the name displayed in - * stack traces and returned through DAP if the file does not verifiably - * exist on disk. - */ - private _fullyQualifiedName(): string { - if (!this.url) { - return '/VM' + this.sourceReference; - } - - if (this.url.endsWith(sourceUtils.SourceConstants.ReplExtension)) { - return 'repl'; - } - - if (this.absolutePath.startsWith('')) { - return this.absolutePath; - } - - if (utils.isAbsolute(this.url)) { - return this.url; - } - - const parsedAbsolute = utils.fileUrlToAbsolutePath(this.url); - if (parsedAbsolute) { - return parsedAbsolute; - } - - let fqname = this.url; - try { - const tokens: string[] = []; - const url = new URL(this.url); - if (url.protocol === 'data:') { - return '/VM' + this.sourceReference; - } - - if (url.hostname) { - tokens.push(url.hostname); - } - - if (url.port) { - tokens.push('\uA789' + url.port); // : in unicode - } - - if (url.pathname) { - tokens.push(/^\/[a-z]:/.test(url.pathname) ? url.pathname.slice(1) : url.pathname); - } - - const searchParams = url.searchParams?.toString(); - if (searchParams) { - tokens.push('?' + searchParams); - } - - fqname = tokens.join(''); - } catch (e) { - // ignored - } - - if (fqname.endsWith('/')) { - fqname += '(index)'; - } - - if (this.inlineScriptOffset) { - fqname += `\uA789${this.inlineScriptOffset.lineOffset + 1}:${ - this.inlineScriptOffset.columnOffset + 1 - }`; - } - return fqname; - } - - /** - * Gets whether this script is blackboxed (part of the skipfiles). - */ - public blackboxed(): boolean { - return this._container.isSourceSkipped(this.url); - } -} - -/** - * A Source that has an associated sourcemap. - */ -export interface ISourceWithMap extends Source { - readonly sourceMap: { - url: string; - metadata: ISourceMapMetadata; - // When compiled source references a source map, we'll generate source map sources. - // This map |sourceUrl| as written in the source map itself to the Source. - // Only present on compiled sources, exclusive with |_origin|. - sourceByUrl: Map; - }; -} - -/** - * A Source generated from a sourcemap. For example, a TypeScript input file - * discovered from its compiled JavaScript code. - */ -export class SourceFromMap extends Source { - // Sources generated from the source map are referenced by some compiled sources - // (through a source map). This map holds the original |sourceUrl| as written in the - // source map, which was used to produce this source for each compiled. - public readonly compiledToSourceUrl = new Map(); -} - -export class WasmSource extends Source { - private readonly _offsetsAssembled = getDeferred(); - - /** - * Mapping of bytecode offsets where line numbers begin. For example, line - * 42 begins at `byteOffsetsOfLines[42]`. - */ - private byteOffsetsOfLines?: Uint32Array; - - /** - * Promise that resolves when the WASM's source offsets have been loaded. - */ - public readonly offsetsAssembled = this._offsetsAssembled.promise; - - constructor( - container: SourceContainer, - public readonly url: string, - absolutePath: string | undefined, - private readonly cdp: Cdp.Api, - ) { - super( - container, - url, - absolutePath, - once(() => this.getContent()), - undefined, - undefined, - undefined, - undefined, - ); - } - - protected override checkContentHash(): Promise { - // We translate wasm to wat, so we should never use the original disk version: - return Promise.resolve(undefined); - } - - /** - * Gets a suggested mimetype for the source. - */ - public override get getSuggestedMimeType(): string | undefined { - return 'text/wat'; // does not seem to be any standard mime type for WAT - } - - public override addScript(script: ISourceScript): void { - const hadScripts = this.scripts.length; - super.addScript(script); - - // this is a little racey, but we don't want to block the debugger while - // assembling offsets for wasm files. Downside is breakpoints hit immediately - // when wasm files load might not initially have correct positions. - if (!hadScripts) { - this.assembleOffsets().finally(() => this._offsetsAssembled.resolve()); - } - } - - /** Offsets a location that came from the runtime script, to where it appears in source code. (Base 1 locations) */ - public override offsetScriptToSource( - obj: T, - ): T { - if (this.byteOffsetsOfLines) { - // CDP sets locations in wasm as line = 0 and column = byte offset. - return { - ...obj, - columnNumber: 1, - lineNumber: binarySearch(this.byteOffsetsOfLines, obj.columnNumber, (a, b) => a - b), - }; - } - - return obj; - } - /** Offsets a location that came from source code, to where it appears in the runtime script. (Base 1 locations) */ - public override offsetSourceToScript( - obj: T, - ): T { - if (this.byteOffsetsOfLines) { - return { - ...obj, - lineNumber: 1, - columnNumber: this.byteOffsetsOfLines[obj.lineNumber - 1] || 1, - }; - } - - return obj; - } - - private async assembleOffsets() { - for await (const chunk of this.getDisassembledStream()) { - let start: number; - if (this.byteOffsetsOfLines) { - const newOffsets = new Uint32Array(this.byteOffsetsOfLines.length + chunk.lines.length); - start = this.byteOffsetsOfLines.length; - newOffsets.set(this.byteOffsetsOfLines); - this.byteOffsetsOfLines = newOffsets; - } else { - this.byteOffsetsOfLines = new Uint32Array(chunk.lines.length); - start = 0; - } - - for (let i = 0; i < chunk.lines.length; i++) { - this.byteOffsetsOfLines[start + i] = chunk.bytecodeOffsets[i]; - } - } - } - - private async getContent() { - let lines = ''; - for await (const chunk of this.getDisassembledStream()) { - lines += chunk.lines.join('\n'); - } - - return lines; - } - - private async *getDisassembledStream() { - if (!this.scripts.length) { - return; - } - - const { scriptId } = this.scripts[0]; - const r = await this.cdp.Debugger.disassembleWasmModule({ scriptId }); - if (!r) { - return; - } - - yield r.chunk; - - while (r.streamId) { - const r2 = await this.cdp.Debugger.nextWasmDisassemblyChunk({ streamId: r.streamId }); - if (!r2) { - return; - } - yield r2.chunk; - } - } -} - -export const isSourceWithMap = (source: unknown): source is ISourceWithMap => - !!source && source instanceof Source && !!source.sourceMap; - const isOriginalSourceOf = (compiled: Source, original: Source) => original instanceof SourceFromMap && original.compiledToSourceUrl.has(compiled as ISourceWithMap); @@ -668,8 +148,6 @@ export class SourceContainer { private _sourceMapSourcesByUrl: Map = new Map(); private _sourceByAbsolutePath: Map = utils.caseNormalizedMap(); - // All source maps by url. - _sourceMaps: Map = new Map(); private _sourceMapTimeouts: SourceMapTimeouts = defaultTimeouts; // Test support. @@ -735,7 +213,6 @@ export class SourceContainer { constructor( @inject(IDapApi) dap: Dap.Api, - @inject(ICdpApi) private readonly cdp: Cdp.Api, @inject(ISourceMapFactory) private readonly sourceMapFactory: ISourceMapFactory, @inject(ILogger) private readonly logger: ILogger, @inject(AnyLaunchConfiguration) private readonly launchConfig: AnyLaunchConfiguration, @@ -743,6 +220,7 @@ export class SourceContainer { @inject(ISourcePathResolver) public readonly sourcePathResolver: ISourcePathResolver, @inject(IScriptSkipper) public readonly scriptSkipper: ScriptSkipper, @inject(IResourceProvider) private readonly resourceProvider: IResourceProvider, + @inject(IWasmSymbolProvider) private readonly wasmSymbols: IWasmSymbolProvider, ) { this._dap = dap; @@ -864,19 +342,12 @@ export class SourceContainer { break; } - const sourceMap = this._sourceMaps.get(uiLocation.source.sourceMap.url); - if ( - !this.logger.assert( - sourceMap, - `Expected to have sourcemap for loaded source ${uiLocation.source.sourceMap.url}`, - ) - ) { - break; - } - - await Promise.race([sourceMap.loaded, delay(this._sourceMapTimeouts.resolveLocation)]); - if (!sourceMap.map) return { ...uiLocation, isMapped, unmappedReason }; - const sourceMapped = this._sourceMappedUiLocation(uiLocation, sourceMap.map); + const map = await SourceLocationProvider.waitForValueWithTimeout( + uiLocation.source.sourceMap, + this._sourceMapTimeouts.resolveLocation, + ); + if (!map) return { ...uiLocation, isMapped, unmappedReason }; + const sourceMapped = await this._sourceMappedUiLocation(uiLocation, map); if (!isUiLocation(sourceMapped)) { unmappedReason = isMapped ? undefined : sourceMapped; break; @@ -897,16 +368,19 @@ export class SourceContainer { * the location in source map source. This method does not wait for the * source map to be loaded. */ - currentSiblingUiLocations(uiLocation: IUiLocation, inSource?: Source): IUiLocation[] { - return this._uiLocations(uiLocation).filter( - uiLocation => !inSource || uiLocation.source === inSource, - ); + public async currentSiblingUiLocations( + uiLocation: IUiLocation, + inSource?: Source, + ): Promise { + const locations = await this._uiLocations(uiLocation); + return inSource ? locations.filter(ui => ui.source === inSource) : locations; } /** * Clears all sources in the container. + * @param silent If true, does not send DAP events to remove the source; used during shutdown */ - clear(silent: boolean) { + public clear(silent: boolean) { this.scriptsById.clear(); for (const source of this._sourceByReference.values()) { this.removeSource(source, silent); @@ -922,33 +396,38 @@ export class SourceContainer { * Returns all the possible locations the given location can map to or from, * taking into account source maps. */ - private _uiLocations(uiLocation: IUiLocation): IUiLocation[] { - return [ - ...this.getSourceMapUiLocations(uiLocation), - uiLocation, - ...this.getCompiledLocations(uiLocation), - ]; + private async _uiLocations(uiLocation: IUiLocation): Promise { + const [original, source] = await Promise.all([ + this.getSourceMapUiLocations(uiLocation), + this.getCompiledLocations(uiLocation), + ]); + + return [...original, uiLocation, ...source]; } /** * Returns all UI locations the given location maps to. */ - private getSourceMapUiLocations(uiLocation: IUiLocation): IUiLocation[] { + private async getSourceMapUiLocations(uiLocation: IUiLocation): Promise { if (!isSourceWithMap(uiLocation.source) || !this._doSourceMappedStepping) return []; - const map = this._sourceMaps.get(uiLocation.source.sourceMap.url)?.map; + const map = await SourceLocationProvider.waitForValueWithTimeout( + uiLocation.source.sourceMap, + this._sourceMapTimeouts.resolveLocation, + ); + if (!map) return []; - const sourceMapUiLocation = this._sourceMappedUiLocation(uiLocation, map); + const sourceMapUiLocation = await this._sourceMappedUiLocation(uiLocation, map); if (!isUiLocation(sourceMapUiLocation)) return []; - const r = this.getSourceMapUiLocations(sourceMapUiLocation); + const r = await this.getSourceMapUiLocations(sourceMapUiLocation); r.push(sourceMapUiLocation); return r; } - private _sourceMappedUiLocation( + private async _sourceMappedUiLocation( uiLocation: IUiLocation, - map: SourceMap, - ): IUiLocation | UnmappedReason { + map: SourceMap | IWasmSymbols, + ): Promise { const compiled = uiLocation.source; if (!isSourceWithMap(compiled)) { return UnmappedReason.HasNoMap; @@ -961,69 +440,83 @@ export class SourceContainer { return UnmappedReason.MapDisabled; } - const entry = this.getOptiminalOriginalPosition( + const entry = await this.getOptiminalOriginalPosition( map, rawToUiOffset(uiLocation, compiled.inlineScriptOffset), ); - if ('isSourceMapLoadFailure' in entry) { - return UnmappedReason.MapLoadingFailed; - } - - if (!entry.source) { + if (!entry) { return UnmappedReason.MapPositionMissing; } - const source = compiled.sourceMap.sourceByUrl.get(entry.source); + const source = compiled.sourceMap.sourceByUrl.get(entry.url); if (!source) { return UnmappedReason.MapPositionMissing; } + const base1 = entry.position.base1; return { - lineNumber: entry.line || 1, - columnNumber: entry.column ? entry.column + 1 : 1, // adjust for 0-based columns + lineNumber: base1.lineNumber, + columnNumber: base1.columnNumber, // adjust for 0-based columns source: source, }; } - private getCompiledLocations(uiLocation: IUiLocation): IUiLocation[] { + private async getCompiledLocations(uiLocation: IUiLocation): Promise { if (!(uiLocation.source instanceof SourceFromMap)) { return []; } let output: IUiLocation[] = []; for (const [compiled, sourceUrl] of uiLocation.source.compiledToSourceUrl) { - const sourceMap = this._sourceMaps.get(compiled.sourceMap.url); - if (!sourceMap || !sourceMap.map) { - continue; - } - - const entry = this.sourceMapFactory.guardSourceMapFn( - sourceMap.map, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - () => sourceUtils.getOptimalCompiledPosition(sourceUrl, uiLocation, sourceMap.map!), - getFallbackPosition, + const value = await SourceLocationProvider.waitForValueWithTimeout( + compiled.sourceMap, + this._sourceMapTimeouts.resolveLocation, ); - if (!entry) { + if (!value) { continue; } - const { lineNumber, columnNumber } = uiToRawOffset( - { - lineNumber: entry.line || 1, - columnNumber: (entry.column || 0) + 1, // correct for 0 index - }, - compiled.inlineScriptOffset, - ); + let locations: IUiLocation[]; + if ('decompiledUrl' in value) { + const entry = await value.compiledPositionFor( + sourceUrl, + new Base1Position(uiLocation.lineNumber, uiLocation.columnNumber), + ); + if (!entry) { + continue; + } + locations = entry.map(l => ({ + lineNumber: l.base1.lineNumber, + columnNumber: l.base1.columnNumber, + source: compiled, + })); + } else { + const entry = this.sourceMapFactory.guardSourceMapFn( + value, + () => sourceUtils.getOptimalCompiledPosition(sourceUrl, uiLocation, value), + getFallbackPosition, + ); - const compiledUiLocation: IUiLocation = { - lineNumber, - columnNumber, - source: compiled, - }; + if (!entry) { + continue; + } + + const { lineNumber, columnNumber } = uiToRawOffset( + { + lineNumber: entry.line || 1, + columnNumber: (entry.column || 0) + 1, // correct for 0 index + }, + compiled.inlineScriptOffset, + ); + + // recurse for nested sourcemaps: + const location = { lineNumber, columnNumber, source: compiled }; + locations = [location, ...(await this.getCompiledLocations(location))]; + } - output = output.concat(compiledUiLocation, this.getCompiledLocations(compiledUiLocation)); + output = output.concat(locations); } return output; @@ -1032,8 +525,16 @@ export class SourceContainer { /** * Gets the best original position for the location in the source map. */ - public getOptiminalOriginalPosition(sourceMap: SourceMap, uiLocation: LineColumn) { - return this.sourceMapFactory.guardSourceMapFn( + public async getOptiminalOriginalPosition( + sourceMap: SourceMap | IWasmSymbols, + uiLocation: LineColumn, + ): Promise<{ url: string; position: IPosition } | undefined> { + if ('decompiledUrl' in sourceMap) { + return await sourceMap.originalPositionFor( + new Base1Position(uiLocation.lineNumber, uiLocation.columnNumber), + ); + } + const value = this.sourceMapFactory.guardSourceMapFn( sourceMap, () => { const glb = sourceMap.originalPositionFor({ @@ -1054,6 +555,15 @@ export class SourceContainer { }, getFallbackPosition, ); + + if (value.column === null || value.line === null || value.source === null) { + return undefined; + } + + return { + position: new Base01Position(value.line, value.column), + url: value.source, + }; } /** @@ -1076,7 +586,7 @@ export class SourceContainer { let source: Source; if (event.scriptLanguage === 'WebAssembly') { - source = new WasmSource(this, event.url, absolutePath, this.cdp); + source = new WasmSource(this, event, absolutePath); } else { source = new Source( this, @@ -1131,27 +641,52 @@ export class SourceContainer { this.scriptSkipper.initializeSkippingValueForSource(source); source.toDap().then(dap => this._dap.loadedSource({ reason: 'new', source: dap })); - if (!isSourceWithMap(source)) { - return; + if (isSourceWithSourceMap(source)) { + this._finishAddSourceWithSourceMap(source); + } else if (isSourceWithWasm(source)) { + this._finishAddSourceWithWasm(source as WasmSource); } + } - const existingSourceMap = this._sourceMaps.get(source.sourceMap.url); - if (existingSourceMap) { - existingSourceMap.compiled.add(source); - if (existingSourceMap.map) { - // If source map has been already loaded, we add sources here. - // Otheriwse, we'll add sources for all compiled after loading the map. - await this._addSourceMapSources(source, existingSourceMap.map); - } - return; + private async _finishAddSourceWithWasm(compiled: WasmSource) { + const symbols = await this.wasmSymbols.loadWasmSymbols(compiled.event); + + const todo: Promise[] = []; + for (const url of symbols.files) { + const absolutePath = await this.sourcePathResolver.urlToAbsolutePath({ url }); + const resolvedUrl = absolutePath ? utils.absolutePathToFileUrl(absolutePath) : url; + + this.logger.verbose(LogTag.RuntimeSourceCreate, 'Creating wasm source from source map', { + inputUrl: url, + absolutePath, + resolvedUrl, + }); + + const fileUrl = absolutePath && utils.absolutePathToFileUrl(absolutePath); + const contentGetter = + url === symbols.decompiledUrl + ? () => symbols.getDisassembly() + : () => + fileUrl + ? this.resourceProvider.fetch(fileUrl).then(r => r.body) + : Promise.resolve(`Could not read ${url}`); + + const source = new SourceFromMap(this, resolvedUrl, absolutePath, contentGetter); + source.compiledToSourceUrl.set(compiled, url); + compiled.sourceMap.sourceByUrl.set(url, source); + todo.push(this._addSource(source)); } - const deferred = getDeferred(); - const sourceMap: SourceMapData = { compiled: new Set([source]), loaded: deferred.promise }; - this._sourceMaps.set(source.sourceMap.url, sourceMap); + await Promise.all(todo); + + compiled.sourceMap.value.resolve(symbols); + } + private async _finishAddSourceWithSourceMap(source: ISourceWithMap) { + const deferred = source.sourceMap.value; + let value: SourceMap | undefined; try { - sourceMap.map = await this.sourceMapFactory.load(source.sourceMap.metadata); + value = await this.sourceMapFactory.load(source.sourceMap.metadata); } catch (urlError) { if (this.initializeConfig.clientID === 'visualstudio') { // On VS we want to support loading source-maps from storage if the web-server doesn't serve them @@ -1166,7 +701,7 @@ export class SourceContainer { utils.absolutePathToFileUrl(sourceMapAbsolutePath); } - sourceMap.map = await this.sourceMapFactory.load(source.sourceMap.metadata); + value = await this.sourceMapFactory.load(source.sourceMap.metadata); this._statistics.fallbackSourceMapCount++; this.logger.info( @@ -1181,36 +716,36 @@ export class SourceContainer { } catch {} } - if (!sourceMap.map) { + if (value === undefined) { this._dap.output({ output: sourceMapParseFailed(source.url, urlError.message).error.format + '\n', category: 'stderr', }); - return deferred.resolve(); + return deferred.resolve(undefined); } } // Source map could have been detached while loading. - if (this._sourceMaps.get(source.sourceMap.url) !== sourceMap) { - return deferred.resolve(); + if (this._sourceByReference.get(source.sourceReference) !== source) { + return deferred.resolve(undefined); } this.logger.verbose(LogTag.SourceMapParsing, 'Creating sources from source map', { - sourceMapId: sourceMap.map.id, - metadata: sourceMap.map.metadata, + sourceMapId: value.id, + metadata: value.metadata, }); - const todo: Promise[] = []; - for (const compiled of sourceMap.compiled) { - todo.push(this._addSourceMapSources(compiled, sourceMap.map)); + try { + await this._addSourceMapSources(source, value); + } finally { + // important to not resolve the sourcemap until after sources are available, + // or dependent code that's waiting for the sources will fail + deferred.resolve(value); } - await Promise.all(todo); - // re-initialize after loading source mapped sources this.scriptSkipper.initializeSkippingValueForSource(source); - deferred.resolve(); } public removeSource(source: Source, silent = false) { @@ -1247,30 +782,14 @@ export class SourceContainer { source.toDap().then(dap => this._dap.loadedSource({ reason: 'removed', source: dap })); } - if (!isSourceWithMap(source)) return; - - const sourceMap = this._sourceMaps.get(source.sourceMap.url); - if ( - !this.logger.assert( - sourceMap, - `Source map missing for ${source.sourceMap.url} in removeSource()`, - ) - ) { - return; + if (isSourceWithSourceMap(source)) { + source.sourceMap.value.settledValue?.destroy(); + } else if (isSourceWithWasm(source)) { + source.sourceMap.value.promise.then(w => w?.dispose()); } - this.logger.assert( - sourceMap.compiled.has(source), - `Source map ${source.sourceMap.url} does not contain source ${source.url}`, - ); - sourceMap.compiled.delete(source); - if (!sourceMap.compiled.size) { - if (sourceMap.map) sourceMap.map.destroy(); - this._sourceMaps.delete(source.sourceMap.url); - } - // Source map could still be loading, or failed to load. - if (sourceMap.map) { - this._removeSourceMapSources(source, sourceMap.map, silent); + if (isSourceWithMap(source)) { + this._removeSourceMapSources(source, silent); } } @@ -1366,8 +885,8 @@ export class SourceContainer { await Promise.all(todo); } - private _removeSourceMapSources(compiled: ISourceWithMap, map: SourceMap, silent: boolean) { - for (const url of map.sources) { + private _removeSourceMapSources(compiled: ISourceWithMap, silent: boolean) { + for (const url of compiled.sourceMap.sourceByUrl.keys()) { const source = compiled.sourceMap.sourceByUrl.get(url); if (!source) { // Previously, we would have always expected the source to exist here. @@ -1389,15 +908,8 @@ export class SourceContainer { return []; } - const sourceMap = this._sourceMaps.get(source.sourceMap.url); - if ( - !this.logger.assert(sourceMap, 'Unrecognized source map url in waitForSourceMapSources()') - ) { - return []; - } - - await sourceMap.loaded; - return [...source.sourceMap.sourceByUrl.values()]; + const sourcesByUrl = await SourceLocationProvider.waitForSources(source.sourceMap); + return [...sourcesByUrl.values()]; } /** @@ -1434,43 +946,3 @@ export class SourceContainer { } } } - -type LineColumn = { lineNumber: number; columnNumber: number }; // 1-based - -export function uiToRawOffset(lc: T, offset?: InlineScriptOffset): T { - if (!offset) { - return lc; - } - - let { lineNumber, columnNumber } = lc; - if (offset) { - lineNumber += offset.lineOffset; - if (lineNumber <= 1) columnNumber += offset.columnOffset; - } - - return { ...lc, lineNumber, columnNumber }; -} - -export function rawToUiOffset(lc: T, offset?: InlineScriptOffset): T { - if (!offset) { - return lc; - } - - let { lineNumber, columnNumber } = lc; - if (offset) { - lineNumber = Math.max(1, lineNumber - offset.lineOffset); - if (lineNumber <= 1) columnNumber = Math.max(1, columnNumber - offset.columnOffset); - } - - return { ...lc, lineNumber, columnNumber }; -} - -export const base0To1 = (lc: LineColumn) => ({ - lineNumber: lc.lineNumber + 1, - columnNumber: lc.columnNumber + 1, -}); - -export const base1To0 = (lc: LineColumn) => ({ - lineNumber: lc.lineNumber - 1, - columnNumber: lc.columnNumber - 1, -}); diff --git a/src/adapter/stackTrace.ts b/src/adapter/stackTrace.ts index f923c8bfd..a961eeb17 100644 --- a/src/adapter/stackTrace.ts +++ b/src/adapter/stackTrace.ts @@ -4,21 +4,26 @@ import * as l10n from '@vscode/l10n'; import Cdp from '../cdp/api'; +import { groupBy } from '../common/arrayUtils'; import { once, posInt32Counter, truthy } from '../common/objUtils'; -import { Base0Position } from '../common/positions'; +import { Base0Position, Base1Position, IPosition, Range } from '../common/positions'; import { SourceConstants } from '../common/sourceUtils'; import Dap from '../dap/api'; import { asyncScopesNotAvailable } from '../dap/errors'; import { ProtocolError } from '../dap/protocolError'; +import { WasmScope } from './dwarf/wasmSymbolProvider'; +import { PreviewContextType } from './objectPreview/contexts'; +import { StepDirection } from './pause'; import { StackFrameStepOverReason, shouldStepOverStackFrame } from './smartStepping'; -import { IPreferredUiLocation } from './sources'; +import { ISourceWithMap, IWasmLocationProvider, SourceFromMap, isSourceWithWasm } from './source'; +import { IPreferredUiLocation } from './sourceContainer'; import { getToStringIfCustom } from './templates/getStringyProps'; import { RawLocation, Thread } from './threads'; import { IExtraProperty, IScopeRef, IVariableContainer } from './variableStore'; export interface IFrameElement { /** DAP stack frame ID */ - frameId: number; + readonly frameId: number; /** Formats the stack element as V8 would format it */ formatAsNative(): Promise; /** Pretty formats the stack element as text */ @@ -27,11 +32,38 @@ export interface IFrameElement { toDap(format?: Dap.StackFrameFormat): Promise; } -type FrameElement = StackFrame | AsyncSeparator; +export interface IStackFrameElement extends IFrameElement { + /** Stack frame that contains this one. Usually == this, except for inline stack frames */ + readonly root: StackFrame; + + /** UI location for the frame. */ + uiLocation(): Promise | IPreferredUiLocation | undefined; + + /** + * Gets variable scopes on this frame. All scope variables should be added + * to the paused {@link VariablesStore} when this resolves. + */ + scopes(): Promise; + + /** + * Gets ranges that should be stepped for the given step kind and location. + */ + getStepSkipList(direction: StepDirection, position: IPosition): Promise; +} + +type FrameElement = StackFrame | InlinedFrame | AsyncSeparator; + +export const isStackFrameElement = (element: IFrameElement): element is IStackFrameElement => + typeof (element as IStackFrameElement).uiLocation === 'function'; export class StackTrace { public readonly frames: FrameElement[] = []; - private _frameById: Map = new Map(); + private _frameById: Map = new Map(); + /** + * Frame index that was last checked for inline expansion. + * @see https://github.com/ChromeDevTools/devtools-frontend/blob/c9f204731633fd2e2b6999a2543e99b7cc489b4b/docs/language_extension_api.md#dealing-with-inlined-functions + */ + private _lastInlineWasmExpanded = Promise.resolve(0); private _asyncStackTraceId?: Cdp.Runtime.StackTraceId; private _lastFrameThread?: Thread; @@ -53,33 +85,6 @@ export class StackTrace { return result; } - public static async fromRuntimeWithPredicate( - thread: Thread, - stack: Cdp.Runtime.StackTrace, - predicate: (frame: StackFrame) => Promise, - frameLimit = Infinity, - ): Promise { - const result = new StackTrace(thread); - for (let frameNo = 0; frameNo < stack.callFrames.length && frameLimit > 0; frameNo++) { - if (!stack.callFrames[frameNo].url.endsWith(SourceConstants.InternalExtension)) { - const frame = StackFrame.fromRuntime(thread, stack.callFrames[frameNo], false); - if (await predicate(frame)) { - result.frames.push(); - frameLimit--; - } - } - } - - if (stack.parentId) { - result._asyncStackTraceId = stack.parentId; - console.assert(!stack.parent); - } else { - result._appendStackTrace(thread, stack.parent); - } - - return result; - } - public static fromDebugger( thread: Thread, frames: Cdp.Debugger.CallFrame[], @@ -97,37 +102,88 @@ export class StackTrace { return result; } - constructor(thread: Thread) { + constructor(private readonly thread: Thread) { this._lastFrameThread = thread; } - async loadFrames(limit: number, noFuncEval?: boolean): Promise { + public async loadFrames(limit: number, noFuncEval?: boolean): Promise { + await this.expandAsyncStack(limit, noFuncEval); + await this.expandWasmFrames(); + return this.frames; + } + + private async expandAsyncStack(limit: number, noFuncEval?: boolean) { while (this.frames.length < limit && this._asyncStackTraceId) { - if (this._asyncStackTraceId.debuggerId) + if (this._asyncStackTraceId.debuggerId) { this._lastFrameThread = Thread.threadForDebuggerId(this._asyncStackTraceId.debuggerId); + } + if (!this._lastFrameThread) { this._asyncStackTraceId = undefined; break; } - if (noFuncEval) + + if (noFuncEval) { this._lastFrameThread .cdp() .DotnetDebugger.setEvaluationOptions({ options: { noFuncEval }, type: 'stackFrame' }); + } const response = await this._lastFrameThread .cdp() .Debugger.getStackTrace({ stackTraceId: this._asyncStackTraceId }); this._asyncStackTraceId = undefined; - if (response) this._appendStackTrace(this._lastFrameThread, response.stackTrace); + if (response) { + this._appendStackTrace(this._lastFrameThread, response.stackTrace); + } } - return this.frames; } - frame(frameId: number): StackFrame | undefined { + private expandWasmFrames() { + return (this._lastInlineWasmExpanded = this._lastInlineWasmExpanded.then(async last => { + for (; last < this.frames.length; last++) { + const frame = this.frames[last]; + if (!(frame instanceof StackFrame)) { + continue; + } + + const source = frame.scriptSource?.resolvedSource; + if (!isSourceWithWasm(source)) { + continue; + } + + const symbols = await source.sourceMap.value.promise; + if (!symbols.getFunctionStack) { + continue; + } + + const stack = await symbols.getFunctionStack(frame.rawPosition); + if (stack.length === 0) { + continue; + } + + for (let i = 0; i < stack.length; i++) { + const inlinedFrame = new InlinedFrame({ + source, + thread: this.thread, + inlineFrameIndex: i, + name: stack[i].name, + root: frame, + }); + + this._appendFrame(inlinedFrame, last++); + } + } + + return last; + })); + } + + public frame(frameId: number): StackFrame | InlinedFrame | undefined { return this._frameById.get(frameId); } - _appendStackTrace(thread: Thread, stackTrace: Cdp.Runtime.StackTrace | undefined) { + private _appendStackTrace(thread: Thread, stackTrace: Cdp.Runtime.StackTrace | undefined) { console.assert(!stackTrace || !this._asyncStackTraceId); while (stackTrace) { @@ -150,18 +206,22 @@ export class StackTrace { } } - _appendFrame(frame: FrameElement) { - this.frames.push(frame); - if (frame instanceof StackFrame) { + private _appendFrame(frame: FrameElement, index?: number) { + if (index !== undefined) { + this.frames.splice(index, 0, frame); + } else { + this.frames.push(frame); + } + if (!(frame instanceof AsyncSeparator)) { this._frameById.set(frame.frameId, frame); } } - async formatAsNative(): Promise { + public async formatAsNative(): Promise { return await this.formatWithMapper(frame => frame.formatAsNative()); } - async format(): Promise { + public async format(): Promise { return await this.formatWithMapper(frame => frame.format()); } @@ -181,7 +241,7 @@ export class StackTrace { return (await Promise.all(promises)).join('\n') + '\n'; } - async toDap(params: Dap.StackTraceParamsExtended): Promise { + public async toDap(params: Dap.StackTraceParamsExtended): Promise { const from = params.startFrame || 0; let to = (params.levels || 50) + from; const frames = await this.loadFrames(to, params.noFuncEval); @@ -288,10 +348,16 @@ function getDefaultName(callFrame: Cdp.Debugger.CallFrame | Cdp.Runtime.CallFram return callFrame.functionName || fallbackName; } -export class StackFrame implements IFrameElement { +export class StackFrame implements IStackFrameElement { public readonly frameId = frameIdCounter(); + /** Override for the `name` in the DAP representation. */ + public overrideName?: string; + /** @inheritdoc */ + public readonly root = this; private _rawLocation: RawLocation; + + /** @inheritdoc */ public readonly uiLocation: () => | Promise | IPreferredUiLocation @@ -339,6 +405,24 @@ export class StackFrame implements IFrameElement { this.isReplEval = script ? script.url.endsWith(SourceConstants.ReplExtension) : false; } + /** + * Gets this frame's script ID. + */ + public get scriptId() { + return 'scriptId' in this.callFrame + ? this.callFrame.scriptId + : this.callFrame.location.scriptId; + } + + /** + * Gets the source associated with the script ID of the stackframe. This may + * not be where the frame is eventually displayed to the user; + * use {@link uiLocation} for that. + */ + public get scriptSource() { + return this._thread._sourceContainer.getScriptById(this.scriptId); + } + /** * Gets whether the runtime explicitly said this frame can be restarted. */ @@ -362,6 +446,7 @@ export class StackFrame implements IFrameElement { return this._scope ? this._scope.callFrameId : undefined; } + /** @inheritdoc */ async scopes(): Promise { const currentScope = this._scope; if (!currentScope) { @@ -447,19 +532,26 @@ export class StackFrame implements IFrameElement { return { scopes: scopes.filter(truthy) }; } + /** @inheritdoc */ + public getStepSkipList(_direction: StepDirection): Promise { + // Normal JS never has any skip lists -- only web assembly does + return Promise.resolve(undefined); + } + private readonly getLocationInfo = once(async () => { const uiLocation = this.uiLocation(); const isSmartStepped = await shouldStepOverStackFrame(this); // only use the relatively expensive custom tostring lookup for frames // that aren't skipped, to avoid unnecessary work e.g. on node_internals const name = - 'this' in this.callFrame + this.overrideName || + ('this' in this.callFrame ? await getEnhancedName( this._thread, this.callFrame, isSmartStepped === StackFrameStepOverReason.NotStepped, ) - : getDefaultName(this.callFrame); + : getDefaultName(this.callFrame)); return { isSmartStepped, name, uiLocation: await uiLocation }; }); @@ -583,3 +675,134 @@ export class StackFrame implements IFrameElement { return ([] as Dap.CompletionItem[]).concat(...completions); }); } + +const EMPTY_SCOPES: Dap.ScopesResult = { scopes: [] }; + +export class InlinedFrame implements IStackFrameElement { + /** @inheritdoc */ + public readonly root: StackFrame; + + /** @inheritdoc */ + public readonly frameId = frameIdCounter(); + + /** @inheritdoc */ + public readonly uiLocation: () => Promise; + + private readonly name: string; + private readonly thread: Thread; + private readonly inlineFrameIndex: number; + private readonly source: ISourceWithMap; + + constructor(opts: { + thread: Thread; + /** Inline frame index in the function info */ + inlineFrameIndex: number; + /** Display name of the call frame */ + name: string; + /** Original WASM source */ + source: ISourceWithMap; + /** Original stack frame this was derived from */ + root: StackFrame; + }) { + this.name = opts.name; + this.root = opts.root; + this.thread = opts.thread; + this.source = opts.source; + this.inlineFrameIndex = opts.inlineFrameIndex; + this.uiLocation = once(() => + opts.thread._sourceContainer.preferredUiLocation({ + columnNumber: opts.root.rawPosition.base1.columnNumber, + lineNumber: opts.inlineFrameIndex + 1, + source: opts.source, + }), + ); + } + + /** @inheritdoc */ + public async formatAsNative(): Promise { + const { columnNumber, lineNumber, source } = await this.uiLocation(); + return ` at ${this.name} (${source.url}:${lineNumber}:${columnNumber})`; + } + + /** @inheritdoc */ + public async format(): Promise { + const { columnNumber, lineNumber, source } = await this.uiLocation(); + const prettyName = (await source.prettyName()) || ''; + return `${this.name} @ ${prettyName}:${lineNumber}:${columnNumber}`; + } + + /** @inheritdoc */ + public async toDap(): Promise { + const { columnNumber, lineNumber, source } = await this.uiLocation(); + return Promise.resolve({ + id: this.frameId, + name: this.name, + column: columnNumber, + line: lineNumber, + source: await source.toDapShallow(), + }); + } + + /** @inheritdoc */ + public async getStepSkipList(direction: StepDirection): Promise { + const sm = this.source.sourceMap.value.settledValue; + if (!sm?.getStepSkipList) { + return; + } + + const uiLocation = await this.uiLocation(); + if (uiLocation) { + return sm.getStepSkipList( + direction, + this.root.rawPosition, + (uiLocation.source as SourceFromMap).compiledToSourceUrl.get(this.source), + new Base1Position(uiLocation.lineNumber, uiLocation.columnNumber), + ); + } else { + return sm.getStepSkipList(direction, this.root.rawPosition); + } + } + + /** @inheritdoc */ + public async scopes(): Promise { + const v = this.source.sourceMap.value.settledValue; + const callFrameId = this.root.callFrameId(); + if (!v || !callFrameId) { + return EMPTY_SCOPES; + } + + const variables = await v.getVariablesInScope?.( + callFrameId, + new Base0Position(this.inlineFrameIndex, this.root.rawPosition.base0.columnNumber), + ); + if (!variables) { + return EMPTY_SCOPES; + } + + const paused = this.thread.pausedVariables(); + if (!paused) { + return EMPTY_SCOPES; + } + + const scopeRef: IScopeRef = { + stackFrame: this.root, + callFrameId, + scopeNumber: 0, // this is only used for setting variables, which wasm doesn't support + }; + + return { + scopes: await Promise.all( + [...groupBy(variables, v => v.scope)].map(([key, vars]) => + paused + .createWasmScope(key as WasmScope, vars, scopeRef) + .toDap(PreviewContextType.PropertyValue) + .then(v => ({ + name: v.name, + variablesReference: v.variablesReference, + expensive: key !== WasmScope.Local, + })), + ), + ), + }; + } +} diff --git a/src/adapter/threads.ts b/src/adapter/threads.ts index 7d066ef0c..42e934864 100644 --- a/src/adapter/threads.ts +++ b/src/adapter/threads.ts @@ -10,8 +10,8 @@ import { EventEmitter } from '../common/events'; import { HrTime } from '../common/hrnow'; import { ILogger, LogTag } from '../common/logging'; import { isInstanceOf, truthy } from '../common/objUtils'; -import { Base1Position } from '../common/positions'; -import { delay, getDeferred, IDeferred } from '../common/promiseUtil'; +import { Base1Position, Range } from '../common/positions'; +import { IDeferred, delay, getDeferred } from '../common/promiseUtil'; import { IRenameProvider } from '../common/sourceMaps/renameProvider'; import * as sourceUtils from '../common/sourceUtils'; import { StackTraceParser } from '../common/stackTraceParser'; @@ -25,7 +25,7 @@ import { ProtocolError } from '../dap/protocolError'; import { NodeWorkerTarget } from '../targets/node/nodeWorkerTarget'; import { ITarget } from '../targets/targets'; import { IShutdownParticipants } from '../ui/shutdownParticipants'; -import { BreakpointManager, EntryBreakpointMode, IPossibleBreakLocation } from './breakpoints'; +import { BreakpointManager, EntryBreakpointMode } from './breakpoints'; import { UserDefinedBreakpoint } from './breakpoints/userDefinedBreakpoint'; import { ICompletions } from './completions'; import { ExceptionMessage, IConsole, QueryObjectsMessage } from './console'; @@ -33,56 +33,18 @@ import { CustomBreakpointId, customBreakpoints } from './customBreakpoints'; import { IEvaluator } from './evaluator'; import { IExceptionPauseService } from './exceptionPauseService'; import * as objectPreview from './objectPreview'; -import { getContextForType, PreviewContextType } from './objectPreview/contexts'; +import { PreviewContextType, getContextForType } from './objectPreview/contexts'; +import { ExpectedPauseReason, IPausedDetails, StepDirection } from './pause'; import { SmartStepper } from './smartStepping'; -import { - base1To0, - IPreferredUiLocation, - ISourceWithMap, - IUiLocation, - Source, - SourceContainer, -} from './sources'; -import { StackFrame, StackTrace } from './stackTrace'; +import { ISourceWithMap, IUiLocation, Source, base1To0 } from './source'; +import { IPreferredUiLocation, SourceContainer } from './sourceContainer'; +import { StackFrame, StackTrace, isStackFrameElement } from './stackTrace'; import { serializeForClipboard, serializeForClipboardTmpl, } from './templates/serializeForClipboard'; import { IVariableStoreLocationProvider, VariableStore } from './variableStore'; -export type PausedReason = - | 'step' - | 'breakpoint' - | 'exception' - | 'pause' - | 'entry' - | 'goto' - | 'function breakpoint' - | 'data breakpoint' - | 'frame_entry'; - -export const enum StepDirection { - In, - Over, - Out, -} - -export type ExpectedPauseReason = - | { reason: Exclude; description?: string } - | { reason: 'step'; description?: string; direction: StepDirection }; - -export interface IPausedDetails { - thread: Thread; - reason: PausedReason; - event: Cdp.Debugger.PausedEvent; - description: string; - stackTrace: StackTrace; - stepInTargets?: IPossibleBreakLocation[]; - hitBreakpoints?: string[]; - text?: string; - exception?: Cdp.Runtime.RemoteObject; -} - export class ExecutionContext { public readonly sourceMapLoads = new Map>(); public readonly scripts: Script[] = []; @@ -199,8 +161,8 @@ export class Thread implements IVariableStoreLocationProvider { private _pausedForSourceMapScriptId?: string; private _executionContexts: Map = new Map(); readonly replVariables: VariableStore; - private _sourceContainer: SourceContainer; - private _pauseOnSourceMapBreakpointId?: Cdp.Debugger.BreakpointId; + readonly _sourceContainer: SourceContainer; + private _pauseOnSourceMapBreakpointIds?: Cdp.Debugger.BreakpointId[]; private _selectedContext: ExecutionContext | undefined; static _allThreadsByDebuggerId = new Map(); private _scriptWithSourceMapHandler?: ScriptWithSourceMapHandler; @@ -307,7 +269,8 @@ export class Thread implements IVariableStoreLocationProvider { public stepOver(): Promise { return this.stateQueue.enqueue('stepOver', async () => { this._expectedPauseReason = { reason: 'step', direction: StepDirection.Over }; - if (await this._cdp.Debugger.stepOver({})) { + const skipList = await this.getCurrentSkipList(StepDirection.Over); + if (await this._cdp.Debugger.stepOver({ skipList })) { return {}; } @@ -330,7 +293,8 @@ export class Thread implements IVariableStoreLocationProvider { return {}; } } else { - if (await this._cdp.Debugger.stepInto({ breakOnAsyncCall: true })) { + const skipList = await this.getCurrentSkipList(StepDirection.In); + if (await this._cdp.Debugger.stepInto({ breakOnAsyncCall: true, skipList })) { return {}; } } @@ -351,6 +315,32 @@ export class Thread implements IVariableStoreLocationProvider { }); } + private async getCurrentSkipList(direction: StepDirection) { + if (!this._pausedDetails) { + return; + } + + const [frame] = await this._pausedDetails.stackTrace.loadFrames(1); + if (!frame || !isStackFrameElement(frame)) { + return undefined; + } + + const list = await frame.getStepSkipList(direction); + if (!list) { + return undefined; + } + + // make sure to simplify the range, as V8 is picky about + // wanting the ranges in order and non-overlapping. + return Range.simplify(list).map( + (r): Cdp.Debugger.LocationRange => ({ + start: r.begin.base0, + end: r.end.base0, + scriptId: frame.root.scriptId, + }), + ); + } + _stackFrameNotFoundError(): Dap.Error { return errors.createSilentError(l10n.t('Stack frame not found')); } @@ -360,7 +350,7 @@ export class Thread implements IVariableStoreLocationProvider { } async restartFrame(params: Dap.RestartFrameParams): Promise { - const stackFrame = this._pausedDetails?.stackTrace.frame(params.frameId); + const stackFrame = this._pausedDetails?.stackTrace.frame(params.frameId)?.root; if (!stackFrame) { return this._stackFrameNotFoundError(); } @@ -438,7 +428,7 @@ export class Thread implements IVariableStoreLocationProvider { let stackFrame: StackFrame | undefined; if (params.frameId !== undefined) { stackFrame = this._pausedDetails - ? this._pausedDetails.stackTrace.frame(params.frameId) + ? this._pausedDetails.stackTrace.frame(params.frameId)?.root : undefined; if (!stackFrame) return this._stackFrameNotFoundError(); if (!stackFrame.callFrameId()) return this._evaluateOnAsyncFrameError(); @@ -477,7 +467,7 @@ export class Thread implements IVariableStoreLocationProvider { let stackFrame: StackFrame | undefined; if (args.frameId !== undefined) { stackFrame = this._pausedDetails - ? this._pausedDetails.stackTrace.frame(args.frameId) + ? this._pausedDetails.stackTrace.frame(args.frameId)?.root : undefined; if (!stackFrame) { throw new ProtocolError(this._stackFrameNotFoundError()); @@ -808,7 +798,7 @@ export class Thread implements IVariableStoreLocationProvider { return; } - this._pausedDetails = this._createPausedDetails(this._pausedDetails.event); + this._pausedDetails = await this._createPausedDetails(this._pausedDetails.event); this._onThreadResumed(); await this._onThreadPaused(this._pausedDetails); } @@ -845,9 +835,7 @@ export class Thread implements IVariableStoreLocationProvider { } private async _onPaused(event: Cdp.Debugger.PausedEvent) { - const hitBreakpoints = (event.hitBreakpoints ?? []).filter( - bp => bp !== this._pauseOnSourceMapBreakpointId, - ); + const hitBreakpoints = event.hitBreakpoints ?? []; // "Break on start" is not actually a by-spec reason in CDP, it's added on from Node.js, so cast `as string`: // https://github.com/nodejs/node/blob/9cbf6af5b5ace0cc53c1a1da3234aeca02522ec6/src/node_contextify.cc#L913 // And Deno uses `debugCommand: @@ -857,8 +845,10 @@ export class Thread implements IVariableStoreLocationProvider { const location = event.callFrames[0]?.location as Cdp.Debugger.Location | undefined; const scriptId = (event.data as IInstrumentationPauseAuxData)?.scriptId || location?.scriptId; const isSourceMapPause = - (event.reason === 'instrumentation' && event.data?.scriptId) || - (scriptId && this._breakpointManager.isEntrypointBreak(hitBreakpoints, scriptId)); + scriptId && + (event.reason === 'instrumentation' || + this._breakpointManager.isEntrypointBreak(hitBreakpoints, scriptId) || + hitBreakpoints.some(bp => this._pauseOnSourceMapBreakpointIds?.includes(bp))); this.evaluator.setReturnedValue(event.callFrames[0]?.returnValue); if (isSourceMapPause) { @@ -949,7 +939,7 @@ export class Thread implements IVariableStoreLocationProvider { } // We store pausedDetails in a local variable to avoid race conditions while awaiting this._smartStepper.shouldSmartStep - const pausedDetails = (this._pausedDetails = this._createPausedDetails(event)); + const pausedDetails = (this._pausedDetails = await this._createPausedDetails(event)); if (this._excludedCallers.length) { if (await this._matchesExcludedCaller(this._pausedDetails.stackTrace)) { this.logger.info(LogTag.Runtime, 'Skipping pause due to excluded caller'); @@ -1210,7 +1200,7 @@ export class Thread implements IVariableStoreLocationProvider { await breakpoint.apply(this._cdp, enabled); } - _createPausedDetails(event: Cdp.Debugger.PausedEvent): IPausedDetails { + private async _createPausedDetails(event: Cdp.Debugger.PausedEvent): Promise { // When hitting breakpoint in compiled source, we ignore source maps during the stepping // sequence (or exceptions) until user resumes or hits another breakpoint-alike pause. // TODO: this does not work for async stepping just yet. @@ -1345,14 +1335,15 @@ export class Thread implements IVariableStoreLocationProvider { }); if (entryBreakpointSource !== undefined) { - const entryBreakpointLocations = this._sourceContainer.currentSiblingUiLocations({ - lineNumber: event.callFrames[0].location.lineNumber + 1, - columnNumber: (event.callFrames[0].location.columnNumber || 0) + 1, - source: entryBreakpointSource, - }); + const entryBreakpointLocations = + await this._sourceContainer.currentSiblingUiLocations({ + lineNumber: event.callFrames[0].location.lineNumber + 1, + columnNumber: (event.callFrames[0].location.columnNumber || 0) + 1, + source: entryBreakpointSource, + }); // But if there is a user breakpoint on the same location that the stop on entry breakpoint, then we consider it an user breakpoint - isStopOnEntry = !entryBreakpointLocations.some(location => + isStopOnEntry = !entryBreakpointLocations?.some(location => this._breakpointManager.hasAtLocation(location), ); } @@ -1537,7 +1528,7 @@ export class Thread implements IVariableStoreLocationProvider { this._sourceContainer.addScriptById(script); - if (event.sourceMapURL) { + if (event.sourceMapURL || event.scriptLanguage === 'WebAssembly') { // If we won't pause before executing this script, still try to load source // map and set breakpoints as soon as possible. We pause on the first line // (the "module entry breakpoint") to ensure this resolves. @@ -1775,18 +1766,37 @@ export class Thread implements IVariableStoreLocationProvider { const needsPause = pause && this._sourceContainer.sourceMapTimeouts().sourceMapMinPause && handler; - if (needsPause && !this._pauseOnSourceMapBreakpointId) { - const result = await this._cdp.Debugger.setInstrumentationBreakpoint({ - instrumentation: 'beforeScriptWithSourceMapExecution', - }); - this._pauseOnSourceMapBreakpointId = result ? result.breakpointId : undefined; - } else if (!needsPause && this._pauseOnSourceMapBreakpointId) { - const breakpointId = this._pauseOnSourceMapBreakpointId; - this._pauseOnSourceMapBreakpointId = undefined; - await this._cdp.Debugger.removeBreakpoint({ breakpointId }); + if (needsPause && !this._pauseOnSourceMapBreakpointIds?.length) { + // setting the URL breakpoint for wasm fails if debugger isn't fully initialized + await this.debuggerReady.promise; + + const result = await Promise.all([ + this._cdp.Debugger.setInstrumentationBreakpoint({ + instrumentation: 'beforeScriptWithSourceMapExecution', + }), + // WASM files don't have sourcemaps and so aren't paused in the usual + // instrumentation BP. But we do need to pause, either to figure out the WAT + // lines or by mapping symbolicated files. + // + // todo: this does not actually work yet! I have a thread out with the + // Chromium folks to see if we can make it work, or if there's another workaround. + this._cdp.Debugger.setBreakpointByUrl({ + lineNumber: 0, + columnNumber: 0, + // this is very approximate, but hitting it spurriously is not problematic + urlRegex: '\\.[wW][aA][sS][mM]', + }), + ]); + this._pauseOnSourceMapBreakpointIds = result.map(r => r?.breakpointId).filter(truthy); + } else if (!needsPause && this._pauseOnSourceMapBreakpointIds?.length) { + const breakpointIds = this._pauseOnSourceMapBreakpointIds; + this._pauseOnSourceMapBreakpointIds = undefined; + await Promise.all( + breakpointIds.map(breakpointId => this._cdp.Debugger.removeBreakpoint({ breakpointId })), + ); } - return !!this._pauseOnSourceMapBreakpointId; + return !!this._pauseOnSourceMapBreakpointIds?.length; } /** diff --git a/src/adapter/variableStore.ts b/src/adapter/variableStore.ts index 2cb4e9984..6f73d2a02 100644 --- a/src/adapter/variableStore.ts +++ b/src/adapter/variableStore.ts @@ -15,11 +15,12 @@ import Dap from '../dap/api'; import { IDapApi } from '../dap/connection'; import * as errors from '../dap/errors'; import { ProtocolError } from '../dap/protocolError'; +import { IWasmVariable, IWasmVariableEvaluation, WasmScope } from './dwarf/wasmSymbolProvider'; import * as objectPreview from './objectPreview'; import { MapPreview, SetPreview } from './objectPreview/betterTypes'; import { PreviewContextType } from './objectPreview/contexts'; import { StackFrame, StackTrace } from './stackTrace'; -import { getSourceSuffix, RemoteException, RemoteObjectId } from './templates'; +import { RemoteException, RemoteObjectId, getSourceSuffix } from './templates'; import { getArrayProperties } from './templates/getArrayProperties'; import { getArraySlots } from './templates/getArraySlots'; import { @@ -160,7 +161,7 @@ export interface IStoreSettings { customPropertiesGenerator?: string; } -type VariableCtor = { +type VariableCtor = { new (context: VariableContext, ...rest: TRestArgs): R; }; @@ -177,6 +178,12 @@ interface IContextSettings { descriptionSymbols?: Promise; } +const wasmScopeNames: { [K in WasmScope]: { name: string; sortOrder: number } } = { + [WasmScope.Parameter]: { name: l10n.t('Parameters'), sortOrder: -10 }, + [WasmScope.Local]: { name: l10n.t('Locals'), sortOrder: -9 }, + [WasmScope.Global]: { name: l10n.t('Globals'), sortOrder: -8 }, +}; + class VariableContext { /** When in a Variable, the name that this variable is accessible as from its parent scope or object */ public readonly name: string; @@ -191,11 +198,11 @@ class VariableContext { constructor( public readonly cdp: Cdp.Api, - public readonly parent: undefined | Variable | Scope, + public readonly parent: undefined | IVariable | Scope, ctx: IContextInit, private readonly vars: VariablesMap, public readonly locationProvider: IVariableStoreLocationProvider, - private readonly currentRef: undefined | (() => Variable | Scope), + private readonly currentRef: undefined | (() => IVariable | Scope), private readonly settings: IContextSettings, ) { this.name = ctx.name; @@ -546,14 +553,14 @@ class Variable implements IVariable { */ public get accessor(): string { const { parent, name } = this.context; - if (!parent || parent instanceof Scope) { - return this.context.name; - } - if (parent instanceof AccessorVariable) { return parent.accessor; } + if (!(parent instanceof Variable)) { + return this.context.name; + } + // Maps and sets: const grandparent = parent.context.parent; if (grandparent instanceof Variable) { @@ -1023,6 +1030,131 @@ class GetterVariable extends AccessorVariable { } } +class WasmVariable implements IVariable, IMemoryReadable { + public static presentationHint: Dap.VariablePresentationHint = { + attributes: ['readOnly'], + }; + + /** @inheritdoc */ + public readonly id = getVariableId(); + + /** @inheritdoc */ + public readonly sortOrder = 0; + + constructor( + private readonly context: VariableContext, + private readonly variable: IWasmVariableEvaluation, + private readonly scopeRef: IScopeRef, + ) {} + + public toDap(): Promise { + return Promise.resolve({ + name: this.context.name, + value: this.variable.description || '', + variablesReference: this.variable.getChildren ? this.id : 0, + memoryReference: this.variable.linearMemoryAddress ? String(this.id) : undefined, + presentationHint: this.context.presentationHint, + }); + } + + public async getChildren(): Promise { + const children = (await this.variable.getChildren?.()) || []; + return children.map(c => + this.context.createVariable( + WasmVariable, + { name: c.name, presentationHint: WasmVariable.presentationHint }, + c.value, + this.scopeRef, + ), + ); + } + + /** @inheritdoc */ + public async readMemory(offset: number, count: number): Promise { + const addr = this.variable.linearMemoryAddress; + if (addr === undefined) { + return undefined; + } + + const result = await this.context.cdp.Debugger.evaluateOnCallFrame({ + callFrameId: this.scopeRef.callFrameId, + expression: `(${readMemory.source}).call(memories[0].buffer, ${ + +addr + offset + }, ${+count}) ${getSourceSuffix()}`, + returnByValue: true, + }); + + if (!result) { + throw new RemoteException({ + exceptionId: 0, + text: 'No response from CDP', + lineNumber: 0, + columnNumber: 0, + }); + } + + return Buffer.from(result.result.value, 'hex'); + } + + /** @inheritdoc */ + public async writeMemory(offset: number, memory: Buffer): Promise { + const addr = this.variable.linearMemoryAddress; + if (addr === undefined) { + return 0; + } + + const result = await this.context.cdp.Debugger.evaluateOnCallFrame({ + callFrameId: this.scopeRef.callFrameId, + expression: `(${writeMemory.source}).call(memories[0].buffer, ${ + +addr + offset + }, ${JSON.stringify(memory.toString('hex'))}) ${getSourceSuffix()}`, + returnByValue: true, + }); + + return result?.result.value || 0; + } +} + +class WasmScopeVariable implements IVariable { + /** @inheritdoc */ + public readonly id = getVariableId(); + + /** @inheritdoc */ + public readonly sortOrder = wasmScopeNames[this.kind]?.sortOrder || 0; + + constructor( + private readonly context: VariableContext, + public readonly kind: WasmScope, + private readonly variables: readonly IWasmVariable[], + private readonly scopeRef: IScopeRef, + ) {} + + toDap(): Promise { + return Promise.resolve({ + name: wasmScopeNames[this.kind]?.name || this.kind, + value: '', + variablesReference: this.id, + }); + } + + getChildren(): Promise { + return Promise.all( + this.variables.map(async v => { + const evaluated = await v.evaluate(); + return this.context.createVariable( + WasmVariable, + { + name: v.name, + presentationHint: WasmVariable.presentationHint, + }, + evaluated, + this.scopeRef, + ); + }), + ); + } +} + class Scope implements IVariableContainer { /** @inheritdoc */ public readonly id = getVariableId(); @@ -1035,7 +1167,7 @@ class Scope implements IVariableContainer { private readonly renameProvider: IRenameProvider, ) {} - public async getChildren(_params: Dap.VariablesParams): Promise { + public async getChildren(_params: Dap.VariablesParams): Promise { const variables = await this.context.createObjectPropertyVars(this.remoteObject); const existing = new Set(variables.map(v => v.name)); for (const extraProperty of this.extraProperties) { @@ -1203,6 +1335,32 @@ export class VariableStore { return scope; } + /** Creates a container for a CDP Scope */ + public createWasmScope( + kind: WasmScope, + variables: readonly IWasmVariable[], + scopeRef: IScopeRef, + ): IVariable { + const scope: WasmScopeVariable = new WasmScopeVariable( + new VariableContext( + this.cdp, + undefined, + { name: '' }, + this.vars, + this.locationProvider, + () => scope, + this.contextSettings, + ), + kind, + variables, + scopeRef, + ); + + this.vars.add(scope); + + return scope; + } + /** Gets whether the variablesReference exists in this store */ public hasVariable(variablesReference: number) { return !!this.vars.get(variablesReference); diff --git a/src/common/arrayUtils.ts b/src/common/arrayUtils.ts index 325a486cd..0dd7fcd0a 100644 --- a/src/common/arrayUtils.ts +++ b/src/common/arrayUtils.ts @@ -32,3 +32,26 @@ export function binarySearch( return low; } + +/** + * Groups an array using an accessor function. + */ +export function groupBy(array: T[], accessor: (item: T) => K): Map { + const groups: Map = new Map(); + + for (const item of array) { + const key = accessor(item); + const group = groups.get(key); + if (group) { + group.push(item); + } else { + groups.set(key, [item]); + } + } + + return groups; +} + +export function iteratorFirst(it: IterableIterator): T | undefined { + return it.next().value; +} diff --git a/src/common/objUtils.ts b/src/common/objUtils.ts index 526bda025..b3a03765c 100644 --- a/src/common/objUtils.ts +++ b/src/common/objUtils.ts @@ -338,14 +338,14 @@ export function trailingEdgeThrottle( * Bisets the array by the predicate. The first return value will be the ones * in which the predicate returned true, the second where it returned false. */ -export function bisectArray( +export async function bisectArrayAsync( items: ReadonlyArray, - predicate: (item: T) => boolean, -): [T[], T[]] { + predicate: (item: T) => Promise, +): Promise<[T[], T[]]> { const a: T[] = []; const b: T[] = []; for (const item of items) { - if (predicate(item)) { + if (await predicate(item)) { a.push(item); } else { b.push(item); diff --git a/src/common/positions.test.ts b/src/common/positions.test.ts new file mode 100644 index 000000000..ebd463851 --- /dev/null +++ b/src/common/positions.test.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { expect } from 'chai'; +import { Base0Position, Range } from './positions'; + +describe('Range', () => { + describe('simplify', () => { + it('should merge overlapping ranges', () => { + const range1 = new Range(new Base0Position(0, 0), new Base0Position(0, 5)); + const range2 = new Range(new Base0Position(0, 3), new Base0Position(0, 8)); + const range3 = new Range(new Base0Position(0, 10), new Base0Position(0, 15)); + const range4 = new Range(new Base0Position(0, 12), new Base0Position(0, 20)); + const range5 = new Range(new Base0Position(0, 25), new Base0Position(0, 30)); + const range6 = new Range(new Base0Position(0, 28), new Base0Position(0, 35)); + const mergedRanges = Range.simplify([range1, range2, range3, range4, range5, range6]); + expect(mergedRanges.join(', ')).to.equal( + 'Range[0:0 -> 0:8], Range[0:10 -> 0:20], Range[0:25 -> 0:35]', + ); + }); + + it('should merge adjacent ranges', () => { + const range1 = new Range(new Base0Position(0, 0), new Base0Position(0, 5)); + const range2 = new Range(new Base0Position(0, 5), new Base0Position(0, 8)); + const range3 = new Range(new Base0Position(0, 8), new Base0Position(0, 10)); + const range4 = new Range(new Base0Position(0, 10), new Base0Position(0, 15)); + const range5 = new Range(new Base0Position(0, 15), new Base0Position(0, 20)); + const mergedRanges = Range.simplify([range1, range2, range3, range4, range5]); + expect(mergedRanges.join(', ')).to.equal('Range[0:0 -> 0:20]'); + }); + + it('should not merge non-overlapping ranges', () => { + const range1 = new Range(new Base0Position(0, 0), new Base0Position(0, 5)); + const range2 = new Range(new Base0Position(0, 7), new Base0Position(0, 10)); + const range3 = new Range(new Base0Position(0, 12), new Base0Position(0, 15)); + const mergedRanges = Range.simplify([range1, range2, range3]); + expect(mergedRanges.join(', ')).to.equal( + 'Range[0:0 -> 0:5], Range[0:7 -> 0:10], Range[0:12 -> 0:15]', + ); + }); + + it('should handle empty input', () => { + const mergedRanges = Range.simplify([]); + expect(mergedRanges).to.have.lengthOf(0); + }); + + it('should handle input with a single range', () => { + const range = new Range(new Base0Position(0, 0), new Base0Position(0, 5)); + const mergedRanges = Range.simplify([range]); + expect(mergedRanges.join(', ')).to.equal('Range[0:0 -> 0:5]'); + }); + + it('should handle duplicated range', () => { + const range1 = new Range(new Base0Position(0, 0), new Base0Position(0, 5)); + const range2 = new Range(new Base0Position(0, 0), new Base0Position(0, 5)); + const range3 = new Range(new Base0Position(0, 6), new Base0Position(0, 7)); + const range4 = new Range(new Base0Position(0, 6), new Base0Position(0, 7)); + const mergedRanges = Range.simplify([range1, range2, range3, range4]); + expect(mergedRanges.join(', ')).to.equal('Range[0:0 -> 0:5], Range[0:6 -> 0:7]'); + }); + }); +}); diff --git a/src/common/positions.ts b/src/common/positions.ts index 5c619c70c..bf610001b 100644 --- a/src/common/positions.ts +++ b/src/common/positions.ts @@ -51,7 +51,8 @@ export class Base0Position implements IPosition { } public compare(other: Base0Position) { - return this.lineNumber - other.lineNumber || this.columnNumber - other.columnNumber || 0; + const other0 = other.base0; + return this.lineNumber - other0.lineNumber || this.columnNumber - other0.columnNumber; } } @@ -76,7 +77,8 @@ export class Base1Position implements IPosition { } public compare(other: Base1Position) { - return this.lineNumber - other.lineNumber || this.columnNumber - other.columnNumber || 0; + const other1 = other.base1; + return this.lineNumber - other1.lineNumber || this.columnNumber - other1.columnNumber || 0; } } @@ -101,6 +103,40 @@ export class Base01Position implements IPosition { } public compare(other: Base01Position) { - return this.lineNumber - other.lineNumber || this.columnNumber - other.columnNumber || 0; + const other01 = other.base01; + return this.lineNumber - other01.lineNumber || this.columnNumber - other01.columnNumber; + } +} + +export class Range { + public static simplify(rangeList: readonly Range[]): Range[] { + if (rangeList.length === 0) { + return []; + } + + const sortedRanges = rangeList.slice().sort((a, b) => a.begin.compare(b.begin)); + const mergedRanges: Range[] = []; + + let currentRange = sortedRanges[0]; + for (let i = 1; i < sortedRanges.length; i++) { + const nextRange = sortedRanges[i]; + if (currentRange.end.compare(nextRange.begin) >= 0) { + currentRange = new Range(currentRange.begin, nextRange.end); + } else { + mergedRanges.push(currentRange); + currentRange = nextRange; + } + } + mergedRanges.push(currentRange); + + return mergedRanges; + } + + constructor(public readonly begin: IPosition, public readonly end: IPosition) {} + + public toString() { + const b1 = this.begin.base0; + const e1 = this.end.base0; + return `Range[${b1.lineNumber}:${b1.columnNumber} -> ${e1.lineNumber}:${e1.columnNumber}]`; } } diff --git a/src/common/sourceMaps/renameProvider.ts b/src/common/sourceMaps/renameProvider.ts index 895699fb9..e180045b9 100644 --- a/src/common/sourceMaps/renameProvider.ts +++ b/src/common/sourceMaps/renameProvider.ts @@ -3,9 +3,10 @@ *--------------------------------------------------------*/ import { inject, injectable } from 'inversify'; -import { ISourceWithMap, Source, SourceFromMap } from '../../adapter/sources'; +import { Source, SourceFromMap, isSourceWithSourceMap } from '../../adapter/source'; import { StackFrame } from '../../adapter/stackTrace'; import { AnyLaunchConfiguration } from '../../configuration'; +import { iteratorFirst } from '../arrayUtils'; import { Base01Position, IPosition } from '../positions'; import { PositionToOffset } from '../stringUtils'; import { SourceMap } from './sourceMap'; @@ -73,11 +74,15 @@ export class RenameProvider implements IRenameProvider { return RenameMapping.None; } - const original: ISourceWithMap | undefined = source.compiledToSourceUrl.keys().next().value; + const original = iteratorFirst(source.compiledToSourceUrl.keys()); if (!original) { throw new Error('unreachable'); } + if (!isSourceWithSourceMap(original)) { + return RenameMapping.None; + } + const cached = this.renames.get(original.url); if (cached) { return cached; diff --git a/src/common/urlUtils.ts b/src/common/urlUtils.ts index b3be677b6..8d5e5479f 100644 --- a/src/common/urlUtils.ts +++ b/src/common/urlUtils.ts @@ -4,10 +4,11 @@ import { promises as dns } from 'dns'; import * as path from 'path'; -import { parse as urlParse, URL } from 'url'; +import { URL, parse as urlParse } from 'url'; import Cdp from '../cdp/api'; import { AnyChromiumConfiguration } from '../configuration'; import { BrowserTargetType } from '../targets/browser/browserTargets'; +import { iteratorFirst } from './arrayUtils'; import { MapUsingProjection } from './datastructure/mapUsingProjection'; import { IFsUtils } from './fsUtils'; import { memoize } from './objUtils'; @@ -362,7 +363,7 @@ const createReGroup = (patterns: ReadonlySet): string => { case 0: return ''; case 1: - return patterns.values().next().value; + return iteratorFirst(patterns.values()) as string; default: // Prefer the more compacy [aA] form if we're only matching single // characters, produce a non-capturing group otherwise. diff --git a/src/configuration.ts b/src/configuration.ts index 1b01d98c4..76137b0d4 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -2,7 +2,7 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ -import { SourceMapTimeouts } from './adapter/sources'; +import { SourceMapTimeouts } from './adapter/sourceContainer'; import { DebugType } from './common/contributionUtils'; import { assertNever, filterValues } from './common/objUtils'; import Dap from './dap/api'; diff --git a/src/ioc-extras.ts b/src/ioc-extras.ts index bc8648270..68b0e0a8c 100644 --- a/src/ioc-extras.ts +++ b/src/ioc-extras.ts @@ -2,6 +2,7 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ +import type * as dwf from '@vscode/dwarf-debugging'; import { promises as fsPromises } from 'fs'; import { interfaces } from 'inversify'; import type * as vscode from 'vscode'; @@ -69,6 +70,16 @@ export type FsPromises = typeof fsPromises; */ export const BrowserFinder = Symbol('IBrowserFinder'); +/** + * Symbol for the `@vscode/dwarf-debugging` module, in IDwarfDebugging. + */ +export const DwarfDebugging = Symbol('DwarfDebugging'); + +/** + * Type for {@link DwarfDebugging} + */ +export type DwarfDebugging = () => Promise; + /** * Location the extension is running in. */ diff --git a/src/ioc.ts b/src/ioc.ts index 93b1606e8..ec5809ea2 100644 --- a/src/ioc.ts +++ b/src/ioc.ts @@ -32,6 +32,7 @@ import { IConsole } from './adapter/console'; import { Console } from './adapter/console/console'; import { Diagnostics } from './adapter/diagnosics'; import { DiagnosticToolSuggester } from './adapter/diagnosticToolSuggester'; +import { IWasmSymbolProvider, WasmSymbolProvider } from './adapter/dwarf/wasmSymbolProvider'; import { Evaluator, IEvaluator } from './adapter/evaluator'; import { ExceptionPauseService, IExceptionPauseService } from './adapter/exceptionPauseService'; import { IPerformanceProvider, PerformanceProviderFactory } from './adapter/performance'; @@ -46,7 +47,7 @@ import { StatefulResourceProvider } from './adapter/resourceProvider/statefulRes import { ScriptSkipper } from './adapter/scriptSkipper/implementation'; import { IScriptSkipper } from './adapter/scriptSkipper/scriptSkipper'; import { SmartStepper } from './adapter/smartStepping'; -import { SourceContainer } from './adapter/sources'; +import { SourceContainer } from './adapter/sourceContainer'; import { IVueFileMapper, VueFileMapper } from './adapter/vueFileMapper'; import Cdp from './cdp/api'; import { ICdpApi } from './cdp/connection'; @@ -56,7 +57,7 @@ import { OutFiles, VueComponentPaths } from './common/fileGlobList'; import { IFsUtils, LocalAndRemoteFsUtils, LocalFsUtils } from './common/fsUtils'; import { ILogger } from './common/logging'; import { Logger } from './common/logging/logger'; -import { createMutableLaunchConfig, MutableLaunchConfig } from './common/mutableLaunchConfig'; +import { MutableLaunchConfig, createMutableLaunchConfig } from './common/mutableLaunchConfig'; import { IRenameProvider, RenameProvider } from './common/sourceMaps/renameProvider'; import { CachingSourceMapFactory, @@ -120,6 +121,8 @@ import { NullTelemetryReporter } from './telemetry/nullTelemetryReporter'; import { ITelemetryReporter } from './telemetry/telemetryReporter'; import { IShutdownParticipants, ShutdownParticipants } from './ui/shutdownParticipants'; import { registerTopLevelSessionComponents, registerUiComponents } from './ui/ui-ioc'; +import { IDwarfModuleProvider } from './adapter/dwarf/dwarfModuleProvider'; +import { DwarfModuleProvider } from './adapter/dwarf/dwarfModuleProviderImpl'; /** * Contains IOC container factories for the extension. We use Inverisfy, which @@ -192,6 +195,7 @@ export const createTargetContainer = ( container.bind(IEvaluator).to(Evaluator).inSingletonScope(); container.bind(IConsole).to(Console).inSingletonScope(); container.bind(IShutdownParticipants).to(ShutdownParticipants).inSingletonScope(); + container.bind(IWasmSymbolProvider).to(WasmSymbolProvider).inSingletonScope(); container.bind(BasicCpuProfiler).toSelf(); container.bind(BasicHeapProfiler).toSelf(); @@ -271,6 +275,10 @@ export const createTopLevelSessionContainer = (parent: Container) => { ) .inSingletonScope(); + if (!container.isBound(IDwarfModuleProvider)) { + container.bind(IDwarfModuleProvider).to(DwarfModuleProvider).inSingletonScope(); + } + const browserFinderFactory = (ctor: BrowserFinderCtor) => (ctx: interfaces.Context) => new ctor(ctx.container.get(ProcessEnv), ctx.container.get(FS), ctx.container.get(Execa)); diff --git a/src/targets/node/nodeLauncher.ts b/src/targets/node/nodeLauncher.ts index 2d0e12943..c0dde842c 100644 --- a/src/targets/node/nodeLauncher.ts +++ b/src/targets/node/nodeLauncher.ts @@ -7,20 +7,19 @@ import { extname, resolve } from 'path'; import { IBreakpointsPredictor } from '../../adapter/breakpointPredictor'; import { IPortLeaseTracker } from '../../adapter/portLeaseTracker'; import Cdp from '../../cdp/api'; -import { asArray } from '../../common/arrayUtils'; +import { asArray, iteratorFirst } from '../../common/arrayUtils'; import { DebugType } from '../../common/contributionUtils'; import { IFsUtils, LocalFsUtils } from '../../common/fsUtils'; import { ILogger, LogTag } from '../../common/logging'; import { fixDriveLetterAndSlashes } from '../../common/pathUtils'; import { delay } from '../../common/promiseUtil'; -import { ISourceMapMetadata } from '../../common/sourceMaps/sourceMap'; import { absolutePathToFileUrl, urlToRegex } from '../../common/urlUtils'; import { AnyLaunchConfiguration, INodeLaunchConfiguration } from '../../configuration'; import { fixInspectFlags } from '../../ui/configurationUtils'; import { retryGetNodeEndpoint } from '../browser/spawn/endpoints'; import { ISourcePathResolverFactory } from '../sourcePathResolverFactory'; import { CallbackFile } from './callback-file'; -import { getRunScript, hideDebugInfoFromConsole, INodeBinaryProvider } from './nodeBinaryProvider'; +import { INodeBinaryProvider, getRunScript, hideDebugInfoFromConsole } from './nodeBinaryProvider'; import { IProcessTelemetry, IRunData, NodeLauncherBase } from './nodeLauncherBase'; import { INodeTargetLifecycleHooks } from './nodeTarget'; import { IPackageJsonProvider } from './packageJsonProvider'; @@ -305,7 +304,7 @@ export class NodeLauncher extends NodeLauncherBase { // There can be more than one compile file per source file. Just pick // whichever one in that case. - const entry: ISourceMapMetadata = mapped.values().next().value; + const entry = iteratorFirst(mapped.values()); if (!entry) { return targetProgram; } diff --git a/src/test/benchmark/scriptSkipper.ts b/src/test/benchmark/scriptSkipper.ts index 409d87c3e..1754aa67e 100644 --- a/src/test/benchmark/scriptSkipper.ts +++ b/src/test/benchmark/scriptSkipper.ts @@ -4,7 +4,7 @@ import { IBenchmarkApi } from '@c4312/matcha'; import { ScriptSkipper } from '../../adapter/scriptSkipper/implementation'; -import { Source } from '../../adapter/sources'; +import { Source } from '../../adapter/source'; import Connection from '../../cdp/connection'; import { NullTransport } from '../../cdp/nullTransport'; import { Logger } from '../../common/logging/logger'; diff --git a/src/test/sources/sources-sourcemap-error-handling-logs-lazy-parse-errors.txt b/src/test/sources/sources-sourcemap-error-handling-logs-lazy-parse-errors.txt index 671ba527c..b9c524945 100644 --- a/src/test/sources/sources-sourcemap-error-handling-logs-lazy-parse-errors.txt +++ b/src/test/sources/sources-sourcemap-error-handling-logs-lazy-parse-errors.txt @@ -1,10 +1,3 @@ Evaluating#1: //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXZhbDEuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJldmFsMVNvdXJjZS5qcyJdLCJtYXBwaW5ncyI6IiMsIyMjIzsifQ== stderr> Could not read source map for http://localhost:8001/eval1.js: Error parsing mappings (code 4): invalid base 64 character while parsing a VLQ -{ - allThreadsStopped : false - description : Paused on breakpoint - reason : breakpoint - threadId : -} - @ localhost꞉8001/eval1.js:3:23 diff --git a/src/test/sources/sourcesTest.ts b/src/test/sources/sourcesTest.ts index 8a6384937..481c4b208 100644 --- a/src/test/sources/sourcesTest.ts +++ b/src/test/sources/sourcesTest.ts @@ -262,7 +262,6 @@ describe('sources', () => { `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${contents}\n`, ); await p.logger.logOutput(await output); - await waitForPause(p); await ev; p.assertLog(); }); diff --git a/src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt b/src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt index 0d8374d2e..87ccd8a50 100644 --- a/src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt +++ b/src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt @@ -10,7 +10,7 @@ stopped event{ reason : step threadId : } -Window.$fac @ localhost꞉8001/wasm/hello.wasm:3:1 +Window.$fac @ localhost꞉8001/wasm/hello.wat:3:1 @ ${workspaceFolder}/web/wasm/hello.html:14:19 ----Promise.then---- @ ${workspaceFolder}/web/wasm/hello.html:12:59 @@ -38,7 +38,7 @@ breakpoint stopped event{ reason : breakpoint threadId : } -Window.$fac @ localhost꞉8001/wasm/hello.wasm:10:1 +Window.$fac @ localhost꞉8001/wasm/hello.wat:10:1 @ ${workspaceFolder}/web/wasm/hello.html:14:19 ----Promise.then---- @ ${workspaceFolder}/web/wasm/hello.html:12:59 diff --git a/src/ui/dwarfModuleProviderImpl.ts b/src/ui/dwarfModuleProviderImpl.ts new file mode 100644 index 000000000..320a27df5 --- /dev/null +++ b/src/ui/dwarfModuleProviderImpl.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import type * as dwf from '@vscode/dwarf-debugging'; +import * as l10n from '@vscode/l10n'; +import { inject, injectable } from 'inversify'; +import * as vscode from 'vscode'; +import { IDwarfModuleProvider } from '../adapter/dwarf/dwarfModuleProvider'; +import { ExtensionContext } from '../ioc-extras'; + +const EXT_ID = 'ms-vscode.wasm-dwarf-debugging'; +const NEVER_REMIND = 'dwarf.neverRemind'; + +@injectable() +export class DwarfModuleProvider implements IDwarfModuleProvider { + private didPromptForSession = this.context.workspaceState.get(NEVER_REMIND, false); + + constructor(@inject(ExtensionContext) private readonly context: vscode.ExtensionContext) {} + + /** @inheritdoc */ + public async load(): Promise { + const ext = vscode.extensions.getExtension(EXT_ID); + if (!ext) { + return undefined; + } + if (!ext.isActive) { + await ext.activate(); + } + + return ext.exports; + } + + /** @inheritdoc */ + public async prompt() { + if (this.didPromptForSession) { + return; + } + + this.didPromptForSession = true; + + const yes = l10n.t('Yes'); + const never = l10n.t('Never'); + const response = await vscode.window.showInformationMessage( + l10n.t({ + message: + 'VS Code can provide better debugging experience for WebAssembly via "DWARF Debugging" extension. Would you like to install it?', + comment: '"DWARF Debugging" is the extension name and should not be localized.', + }), + yes, + l10n.t('Not Now'), + never, + ); + + if (response === yes) { + this.install(); + } else if (response === never) { + this.context.workspaceState.update(NEVER_REMIND, true); + } + } + + private async install() { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: l10n.t('Installing the DWARF debugger...'), + }, + async () => { + try { + await vscode.commands.executeCommand('workbench.extensions.installExtension', EXT_ID); + vscode.window.showInformationMessage( + l10n.t( + 'Installation complete! The extension will be used after you restart your debug session.', + ), + ); + } catch (e) { + vscode.window.showErrorMessage(e.message || String(e)); + } + }, + ); + } +} diff --git a/src/ui/profiling/uiProfileManager.ts b/src/ui/profiling/uiProfileManager.ts index 2a1b9ad74..c8d34a4c7 100644 --- a/src/ui/profiling/uiProfileManager.ts +++ b/src/ui/profiling/uiProfileManager.ts @@ -7,7 +7,8 @@ import { inject, injectable, multiInject } from 'inversify'; import { homedir } from 'os'; import { basename, join } from 'path'; import * as vscode from 'vscode'; -import { getDefaultProfileName, ProfilerFactory } from '../../adapter/profiling'; +import { ProfilerFactory, getDefaultProfileName } from '../../adapter/profiling'; +import { iteratorFirst } from '../../common/arrayUtils'; import { Commands, ContextKey, setContextKey } from '../../common/contributionUtils'; import { DisposableList, IDisposable } from '../../common/disposable'; import { moveFile } from '../../common/fsUtils'; @@ -260,8 +261,8 @@ export class UiProfileManager implements IDisposable { setContextKey(vscode.commands, ContextKey.IsProfiling, true); - if (this.activeSessions.size === 1) { - const session: UiProfileSession = this.activeSessions.values().next().value; + const session = iteratorFirst(this.activeSessions.values()); + if (session && this.activeSessions.size === 1) { this.statusBarItem.text = session.status ? l10n.t('{0} Click to Stop Profiling ({1})', '$(loading~spin)', session.status) : l10n.t('{0} Click to Stop Profiling', '$(loading~spin)'); diff --git a/src/ui/ui-ioc.extensionOnly.ts b/src/ui/ui-ioc.extensionOnly.ts index cca072015..634bf0ec0 100644 --- a/src/ui/ui-ioc.extensionOnly.ts +++ b/src/ui/ui-ioc.extensionOnly.ts @@ -3,6 +3,7 @@ *--------------------------------------------------------*/ import { Container } from 'inversify'; +import { IDwarfModuleProvider } from '../adapter/dwarf/dwarfModuleProvider'; import { IRequestOptionsProvider } from '../adapter/resourceProvider/requestOptionsProvider'; import { IExtensionContribution, trackDispose, VSCodeApi } from '../ioc-extras'; import { TerminalNodeLauncher } from '../targets/node/terminalNodeLauncher'; @@ -20,6 +21,7 @@ import { DebugLinkUi } from './debugLinkUI'; import { DebugSessionTracker } from './debugSessionTracker'; import { DiagnosticsUI } from './diagnosticsUI'; import { DisableSourceMapUI } from './disableSourceMapUI'; +import { DwarfModuleProvider } from './dwarfModuleProviderImpl'; import { EdgeDevToolOpener } from './edgeDevToolOpener'; import { ExcludedCallersUI } from './excludedCallersUI'; import { ILinkedBreakpointLocation } from './linkedBreakpointLocation'; @@ -68,6 +70,7 @@ export const registerUiComponents = (container: Container) => { container.bind(UiProfileManager).toSelf().inSingletonScope().onActivation(trackDispose); container.bind(TerminalLinkHandler).toSelf().inSingletonScope(); container.bind(DisableSourceMapUI).toSelf().inSingletonScope(); + container.bind(IDwarfModuleProvider).to(DwarfModuleProvider).inSingletonScope(); container .bind(ITerminationConditionFactory)