From 72c0276263be7a59ada0366341d3d3a6be5ceaa3 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sun, 27 Aug 2023 12:24:30 -0700 Subject: [PATCH] feat: support basic webassembly debugging (#1791) * feat: support basic webassembly debugging Supports viewing, stepping through, and setting breakpoints in webassembly (decompiled as WAT) in the editor. Includes a basic tmLanguage for WAT. ![](https://memes.peet.io/img/23-08-b7617299-9f8d-41c9-8fe0-ada8a3c57966.png) Unfortunately we eagerly have to decompile WASM in order to get line mappings to show e.g. in breakpoints. Location mapping is currently mostly synchronous and I didn't want to make everything async for webassembly. However, we don't keep the WAT source in memory, instead request it again if it's needed. I opted to do this to reduce memory usage for user applications that just happen to contain WASM where they aren't always interested in debugging it. For #1789 Fixes #1715 on the way * retain wasm-set breakpoints between reloads --- CHANGELOG.md | 1 + gulpfile.js | 29 ++- src/adapter/breakpoints.ts | 53 ++--- src/adapter/breakpoints/breakpointBase.ts | 33 ++- src/adapter/sources.ts | 219 ++++++++++++++++-- src/adapter/threads.ts | 9 +- src/build/generate-contributions.ts | 16 ++ src/common/arrayUtils.test.ts | 30 +++ src/common/arrayUtils.ts | 27 +++ src/configuration.ts | 1 + src/test/wasm/wasm.test.ts | 50 ++++ ...ssembly-basic-stepping-and-breakpoints.txt | 44 ++++ src/ui/basic-wat.tmLanguage.json | 93 ++++++++ testWorkspace/web/wasm/hello.html | 19 ++ testWorkspace/web/wasm/hello.wasm | Bin 0 -> 97 bytes 15 files changed, 548 insertions(+), 76 deletions(-) create mode 100644 src/common/arrayUtils.test.ts create mode 100644 src/test/wasm/wasm.test.ts create mode 100644 src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt create mode 100644 src/ui/basic-wat.tmLanguage.json create mode 100644 testWorkspace/web/wasm/hello.html create mode 100644 testWorkspace/web/wasm/hello.wasm diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d3492f7..192e6e383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog records changes to stable releases since 1.50.2. "TBA" changes he ## Nightly (only) +- feat: allow basic webassembly debugging ([vscode#102181](https://github.com/microsoft/vscode/issues/102181)) - feat: add `Symbol.for("debug.description")` as a way to generate object descriptions ([vscode#102181](https://github.com/microsoft/vscode/issues/102181)) - feat: adopt supportTerminateDebuggee for browsers and node ([#1733](https://github.com/microsoft/vscode-js-debug/issues/1733)) - fix: child processes from extension host not getting spawned during debug diff --git a/gulpfile.js b/gulpfile.js index d5457dab6..9d4ef8f6c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -167,6 +167,7 @@ gulp.task('compile:static', () => 'README.md', 'package.nls.json', 'src/**/*.sh', + 'src/ui/basic-wat.tmLanguage.json', '.vscodeignore', ], { @@ -340,19 +341,25 @@ gulp.task( ); /** Prepares the package and then hoists it to the root directory. Destructive. */ -gulp.task('package:hoist', gulp.series('package:prepare', async () => { - const srcFiles = await fs.promises.readdir(buildDir); - const ignoredFiles = new Set(await fs.promises.readdir(__dirname)); +gulp.task( + 'package:hoist', + gulp.series('package:prepare', async () => { + const srcFiles = await fs.promises.readdir(buildDir); + const ignoredFiles = new Set(await fs.promises.readdir(__dirname)); - ignoredFiles.delete('l10n-extract'); // special case: made in the pipeline + ignoredFiles.delete('l10n-extract'); // special case: made in the pipeline - for (const file of srcFiles) { - ignoredFiles.delete(file); - await fs.promises.rm(path.join(__dirname, file), { force: true, recursive: true }); - await fs.promises.rename(path.join(buildDir, file), path.join(__dirname, file)); - } - await fs.promises.appendFile(path.join(__dirname, '.vscodeignore'), [...ignoredFiles].join('\n')); -})); + for (const file of srcFiles) { + ignoredFiles.delete(file); + await fs.promises.rm(path.join(__dirname, file), { force: true, recursive: true }); + await fs.promises.rename(path.join(buildDir, file), path.join(__dirname, file)); + } + await fs.promises.appendFile( + path.join(__dirname, '.vscodeignore'), + [...ignoredFiles].join('\n'), + ); + }), +); gulp.task('package', gulp.series('package:prepare', 'package:createVSIX')); diff --git a/src/adapter/breakpoints.ts b/src/adapter/breakpoints.ts index 45ba4e71d..597c191ec 100644 --- a/src/adapter/breakpoints.ts +++ b/src/adapter/breakpoints.ts @@ -26,15 +26,13 @@ import { PatternEntryBreakpoint } from './breakpoints/patternEntrypointBreakpoin import { UserDefinedBreakpoint } from './breakpoints/userDefinedBreakpoint'; import { DiagnosticToolSuggester } from './diagnosticToolSuggester'; import { + base0To1, + base1To0, ISourceWithMap, + isSourceWithMap, IUiLocation, Source, SourceContainer, - base0To1, - base1To0, - isSourceWithMap, - rawToUiOffset, - uiToRawOffset, } from './sources'; import { ScriptWithSourceMapHandler, Thread } from './threads'; @@ -153,6 +151,17 @@ export class BreakpointManager { _breakpointsPredictor?.onLongParse(() => dap.longPrediction({})); + sourceContainer.onScript(script => { + script.source.then(source => { + const thread = this._thread; + if (thread) { + this._byRef + .get(source.sourceReference) + ?.forEach(bp => bp.updateForNewLocations(thread, script)); + } + }); + }); + sourceContainer.onSourceMappedSteppingChange(() => { if (this._thread) { for (const bp of this._byDapId.values()) { @@ -182,10 +191,10 @@ export class BreakpointManager { const path = source.absolutePath; const byPath = path ? this._byPath.get(path) : undefined; for (const breakpoint of byPath || []) - todo.push(breakpoint.updateForSourceMap(this._thread, script)); + todo.push(breakpoint.updateForNewLocations(this._thread, script)); const byRef = this._byRef.get(source.sourceReference); for (const breakpoint of byRef || []) - todo.push(breakpoint.updateForSourceMap(this._thread, script)); + todo.push(breakpoint.updateForNewLocations(this._thread, script)); if (source.sourceMap) { queue.push(source.sourceMap.sourceByUrl.values()); @@ -362,8 +371,8 @@ export class BreakpointManager { .cdp() .Debugger.getPossibleBreakpoints({ restrictToFunction: false, - start: { scriptId, ...uiToRawOffset(base1To0(start), lsrc.runtimeScriptOffset) }, - end: { scriptId, ...uiToRawOffset(base1To0(end), lsrc.runtimeScriptOffset) }, + start: { scriptId, ...lsrc.offsetSourceToScript(base1To0(start)) }, + end: { scriptId, ...lsrc.offsetSourceToScript(base1To0(end)) }, }) .then(r => { if (!r) { @@ -378,7 +387,7 @@ export class BreakpointManager { const { lineNumber, columnNumber = 0 } = breakLocation; const uiLocations = this._sourceContainer.currentSiblingUiLocations({ source: lsrc, - ...rawToUiOffset(base0To1({ lineNumber, columnNumber }), lsrc.runtimeScriptOffset), + ...lsrc.offsetScriptToSource(base0To1({ lineNumber, columnNumber })), }); result.push({ breakLocation, uiLocations }); @@ -510,19 +519,7 @@ export class BreakpointManager { const wasEntryBpSet = await this._sourceMapHandlerInstalled?.entryBpSet; params.source.path = urlUtils.platformPathToPreferredCase(params.source.path); - - // If we see we want to set breakpoints in file by source reference ID but - // it doesn't exist, they were probably from a previous section. The - // references for scripts just auto-increment per session and are entirely - // ephemeral. Remove the reference so that we fall back to a path if possible. const containedSource = this._sourceContainer.source(params.source); - if ( - params.source.sourceReference /* not (undefined or 0=on disk) */ && - params.source.path && - !containedSource - ) { - params.source.sourceReference = undefined; - } // Wait until the breakpoint predictor finishes to be sure that we // can place correctly in breakpoint.set(), if: @@ -589,17 +586,17 @@ export class BreakpointManager { }; const getCurrent = () => - params.source.path - ? this._byPath.get(params.source.path) - : params.source.sourceReference + params.source.sourceReference ? this._byRef.get(params.source.sourceReference) + : params.source.path + ? this._byPath.get(params.source.path) : undefined; const result = mergeInto(getCurrent() ?? []); - if (params.source.path) { - this._byPath.set(params.source.path, result.list); - } else if (params.source.sourceReference) { + if (params.source.sourceReference) { this._byRef.set(params.source.sourceReference, result.list); + } else if (params.source.path) { + this._byPath.set(params.source.path, result.list); } else { return { breakpoints: [] }; } diff --git a/src/adapter/breakpoints/breakpointBase.ts b/src/adapter/breakpoints/breakpointBase.ts index 08baed8bf..19290528a 100644 --- a/src/adapter/breakpoints/breakpointBase.ts +++ b/src/adapter/breakpoints/breakpointBase.ts @@ -13,7 +13,7 @@ import { IUiLocation, Source, SourceFromMap, - uiToRawOffset, + WasmSource, } from '../sources'; import { Script, Thread } from '../threads'; @@ -194,7 +194,10 @@ export abstract class Breakpoint { // a source map source. To make them work, we always set by url to not miss compiled. // Additionally, if we have two sources with the same url, but different path (or no path), // this will make breakpoint work in all of them. - this._setByPath(thread, uiToRawOffset(this.originalPosition, source?.runtimeScriptOffset)), + this._setByPath( + thread, + source?.offsetSourceToScript(this.originalPosition) || this.originalPosition, + ), ); } @@ -210,7 +213,7 @@ export abstract class Breakpoint { await Promise.all( uiLocations.map(uiLocation => - this._setByUiLocation(thread, uiToRawOffset(uiLocation, source.runtimeScriptOffset)), + this._setByUiLocation(thread, source.offsetSourceToScript(uiLocation)), ), ); } @@ -287,21 +290,33 @@ export abstract class Breakpoint { await Promise.all(promises); } - public async updateForSourceMap(thread: Thread, script: Script) { + /** + * Updates breakpoint placements in the debugee in responce to a new script + * getting parsed. This is useful in two cases: + * + * 1. Where the source was sourcemapped, in which case a new sourcemap tells + * us scripts to set BPs in. + * 2. Where a source was set by script ID, which happens for sourceReferenced + * sources. + */ + public async updateForNewLocations(thread: Thread, script: Script) { const source = this._manager._sourceContainer.source(this.source); if (!source) { return []; } + if (source instanceof WasmSource) { + await source.offsetsAssembled; + } + // Find all locations for this breakpoint in the new script. - const scriptSource = await script.source; const uiLocations = this._manager._sourceContainer.currentSiblingUiLocations( { lineNumber: this.originalPosition.lineNumber, columnNumber: this.originalPosition.columnNumber, source, }, - scriptSource, + await script.source, ); if (!uiLocations.length) { @@ -310,9 +325,7 @@ export abstract class Breakpoint { const promises: Promise[] = []; for (const uiLocation of uiLocations) { - promises.push( - this._setByScriptId(thread, script, uiToRawOffset(uiLocation, source.runtimeScriptOffset)), - ); + promises.push(this._setByScriptId(thread, script, source.offsetSourceToScript(uiLocation))); } // If we get a source map that references this script exact URL, then @@ -323,7 +336,7 @@ export abstract class Breakpoint { continue; } - if (!this.breakpointIsForSource(bp.args, scriptSource)) { + if (!this.breakpointIsForSource(bp.args, source)) { continue; } diff --git a/src/adapter/sources.ts b/src/adapter/sources.ts index 071358b4f..476019729 100644 --- a/src/adapter/sources.ts +++ b/src/adapter/sources.ts @@ -9,6 +9,8 @@ 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'; @@ -176,14 +178,44 @@ export class Source { this._name = this._humanName(); this.setSourceMapUrl(sourceMapUrl); - this._existingAbsolutePath = checkContentHash( + 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. - inlineScriptOffset || runtimeScriptOffset ? undefined : contentHash, - container._fileContentOverridesForTest.get(this.absolutePath), + 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; @@ -446,6 +478,143 @@ export class SourceFromMap extends Source { 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; @@ -566,6 +735,7 @@ 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, @@ -890,36 +1060,41 @@ export class SourceContainer { * Adds a new source to the collection. */ public async addSource( - url: string, + event: Cdp.Debugger.ScriptParsedEvent, contentGetter: ContentGetter, sourceMapUrl?: string, inlineSourceRange?: InlineScriptOffset, runtimeScriptOffset?: InlineScriptOffset, contentHash?: string, ): Promise { - const absolutePath = await this.sourcePathResolver.urlToAbsolutePath({ url }); + const absolutePath = await this.sourcePathResolver.urlToAbsolutePath({ url: event.url }); this.logger.verbose(LogTag.RuntimeSourceCreate, 'Creating source from url', { - inputUrl: url, + inputUrl: event.url, absolutePath, }); - const source = new Source( - this, - url, - absolutePath, - contentGetter, - sourceMapUrl && - this.sourcePathResolver.shouldResolveSourceMap({ - sourceMapUrl, - compiledPath: absolutePath || url, - }) - ? sourceMapUrl - : undefined, - inlineSourceRange, - runtimeScriptOffset, - contentHash, - ); + let source: Source; + if (event.scriptLanguage === 'WebAssembly') { + source = new WasmSource(this, event.url, absolutePath, this.cdp); + } else { + source = new Source( + this, + event.url, + absolutePath, + contentGetter, + sourceMapUrl && + this.sourcePathResolver.shouldResolveSourceMap({ + sourceMapUrl, + compiledPath: absolutePath || event.url, + }) + ? sourceMapUrl + : undefined, + inlineSourceRange, + runtimeScriptOffset, + contentHash, + ); + } this._addSource(source); return source; diff --git a/src/adapter/threads.ts b/src/adapter/threads.ts index 210c52fac..7d066ef0c 100644 --- a/src/adapter/threads.ts +++ b/src/adapter/threads.ts @@ -40,7 +40,6 @@ import { IPreferredUiLocation, ISourceWithMap, IUiLocation, - rawToUiOffset, Source, SourceContainer, } from './sources'; @@ -1132,13 +1131,13 @@ export class Thread implements IVariableStoreLocationProvider { if (script.resolvedSource) { return this._sourceContainer.preferredUiLocation({ - ...rawToUiOffset(rawLocation, script.resolvedSource.runtimeScriptOffset), + ...script.resolvedSource.offsetScriptToSource(rawLocation), source: script.resolvedSource, }); } else { return script.source.then(source => this._sourceContainer.preferredUiLocation({ - ...rawToUiOffset(rawLocation, source.runtimeScriptOffset), + ...source.offsetSourceToScript(rawLocation), source, }), ); @@ -1157,7 +1156,7 @@ export class Thread implements IVariableStoreLocationProvider { const source = await script.source; return this._sourceContainer.preferredUiLocation({ - ...rawToUiOffset(rawLocation, source.runtimeScriptOffset), + ...source.offsetScriptToSource(rawLocation), source, }); } @@ -1506,7 +1505,7 @@ export class Thread implements IVariableStoreLocationProvider { } const source = await this._sourceContainer.addSource( - event.url, + event, contentGetter, resolvedSourceMapUrl, inlineSourceOffset, diff --git a/src/build/generate-contributions.ts b/src/build/generate-contributions.ts index 1b9d66676..c93b6a09d 100644 --- a/src/build/generate-contributions.ts +++ b/src/build/generate-contributions.ts @@ -1586,6 +1586,22 @@ if (require.main === module) { title: 'JavaScript Debugger', properties: configurationSchema, }, + grammars: [ + { + language: 'wat', + scopeName: 'text.wat', + path: './src/ui/basic-wat.tmLanguage.json', + }, + ], + languages: [ + { + id: 'wat', + extensions: ['.wat', '.wasm'], + aliases: ['WebAssembly Text Format'], + firstLine: '^\\(module', + mimetypes: ['text/wat'], + }, + ], terminal: { profiles: [ { diff --git a/src/common/arrayUtils.test.ts b/src/common/arrayUtils.test.ts new file mode 100644 index 000000000..882bd78df --- /dev/null +++ b/src/common/arrayUtils.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { expect } from 'chai'; +import { binarySearch } from './arrayUtils'; + +describe('arrayUtils', () => { + describe('binarySearch', () => { + it('should return negative position for element not in array', () => { + expect(binarySearch([], 1, (a, b) => a - b)).to.equal(0); + expect(binarySearch([1, 2, 3], 4, (a, b) => a - b)).to.equal(3); + expect(binarySearch([1, 2, 3], 1.5, (a, b) => a - b)).to.equal(1); + expect(binarySearch([1, 2, 3], 0, (a, b) => a - b)).to.equal(0); + }); + + it('should return index of key in array', () => { + expect(binarySearch([1, 2, 3], 2, (a, b) => a - b)).to.equal(1); + }); + + it('should return index of key in array with duplicates', () => { + expect(binarySearch([1, 2, 2, 3], 2, (a, b) => a - b)).to.equal(1); + }); + + it('should return index of key in array with custom comparator', () => { + const arr = [{ id: 1 }, { id: 2 }, { id: 3 }]; + expect(binarySearch(arr, { id: 2 }, (a, b) => a.id - b.id)).to.equal(1); + }); + }); +}); diff --git a/src/common/arrayUtils.ts b/src/common/arrayUtils.ts index 338743164..325a486cd 100644 --- a/src/common/arrayUtils.ts +++ b/src/common/arrayUtils.ts @@ -5,3 +5,30 @@ export function asArray(thing: T | readonly T[]): readonly T[] { return thing instanceof Array ? thing : [thing]; } + +/** + * Runs a binary search on the array. Returns the index of the key if it were + * to be inserted into the array to retain order. + */ +export function binarySearch( + array: ArrayLike, + key: T, + comparator: (a: T, b: T) => number, +): number { + let low = 0; + let high = array.length - 1; + + while (low <= high) { + const mid = ((low + high) / 2) | 0; + const comp = comparator(array[mid], key); + if (comp < 0) { + low = mid + 1; + } else if (comp > 0) { + high = mid - 1; + } else { + return mid; + } + } + + return low; +} diff --git a/src/configuration.ts b/src/configuration.ts index baa704fef..1b01d98c4 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1148,6 +1148,7 @@ export const breakpointLanguages: ReadonlyArray = [ 'javascriptreact', 'fsharp', 'html', + 'wat', ]; declare const EXTENSION_NAME: string; diff --git a/src/test/wasm/wasm.test.ts b/src/test/wasm/wasm.test.ts new file mode 100644 index 000000000..8fea3377d --- /dev/null +++ b/src/test/wasm/wasm.test.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { itIntegrates } from '../testIntegrationUtils'; + +describe('webassembly', () => { + itIntegrates('basic stepping and breakpoints', async ({ r }) => { + const p = await r.launchUrl('wasm/hello.html'); + await p.dap.setBreakpoints({ + source: { + path: p.workspacePath('web/wasm/hello.html'), + }, + breakpoints: [{ line: 14 }], + }); + + p.load(); + + { + const { threadId } = p.log(await p.dap.once('stopped')); + await p.dap.stepIn({ threadId }); + } + + { + const { threadId } = p.log(await p.dap.once('stopped'), 'stopped event'); + const stacktrace = await p.logger.logStackTrace(threadId); + const content = await p.dap.source({ + sourceReference: stacktrace[0].source!.sourceReference!, + source: stacktrace[0].source, + }); + + p.log(content.mimeType, 'source mime type'); + p.log(content.content, 'source content'); + + await p.dap.setBreakpoints({ + source: stacktrace[0].source!, + breakpoints: [{ line: 10 }], + }); + + await p.dap.continue({ threadId }); + } + + { + const { threadId } = p.log(await p.dap.once('stopped'), 'breakpoint stopped event'); + await p.logger.logStackTrace(threadId); + } + + p.assertLog(); + }); +}); diff --git a/src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt b/src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt new file mode 100644 index 000000000..ba6838674 --- /dev/null +++ b/src/test/wasm/webassembly-basic-stepping-and-breakpoints.txt @@ -0,0 +1,44 @@ +{ + allThreadsStopped : false + description : Paused on breakpoint + reason : breakpoint + threadId : +} +stopped event{ + allThreadsStopped : false + description : Paused + reason : step + threadId : +} +$fac @ localhost꞉8001/wasm/hello.wasm:3:1 + @ ${workspaceFolder}/web/wasm/hello.html:14:19 +----Promise.then---- + @ ${workspaceFolder}/web/wasm/hello.html:12:59 +source mime typetext/wat +source content(module + (func $fac (;0;) (export "fac") (param $var0 f64) (result f64) + local.get $var0 + f64.const 1 + f64.lt + if (result f64) + f64.const 1 + else + local.get $var0 + local.get $var0 + f64.const 1 + f64.sub + call $fac + f64.mul + end + ) +) +breakpoint stopped event{ + allThreadsStopped : false + description : Paused on breakpoint + reason : breakpoint + threadId : +} +$fac @ localhost꞉8001/wasm/hello.wasm:10:1 + @ ${workspaceFolder}/web/wasm/hello.html:14:19 +----Promise.then---- + @ ${workspaceFolder}/web/wasm/hello.html:12:59 diff --git a/src/ui/basic-wat.tmLanguage.json b/src/ui/basic-wat.tmLanguage.json new file mode 100644 index 000000000..9f1596312 --- /dev/null +++ b/src/ui/basic-wat.tmLanguage.json @@ -0,0 +1,93 @@ +{ + "name": "WebAssembly Text Format", + "scopeName": "text.wat", + "patterns": [ + { "include": "#block-comment" }, + { "include": "#line-comment" }, + { "include": "#expr" } + ], + "repository": { + "op": { + "match": "[a-zA-Z0-9!#$%&`*+-/:<=>?@\\\\^_|~\\.]+", + "name": "keyword" + }, + "id": { + "match": "\\$[A-Za-z0-9!#$%&`*+-/:<=>?@\\\\^_|~\\.]+", + "name": "variable" + }, + "decimal-number": { + "match": "\\b[+-]?[0-9_]+(.[0-9_]+)?([Ee][+-][0-9_]+)?\\b", + "name": "constant.numeric" + }, + "hexadecimal-number": { + "match": "\\b[+-]?0x[0-9a-fA-F_]+(.[0-9a-fA-F_]+)?([pP][+-][0-9a-fA-F_]+)?\\b", + "name": "constant.numeric" + }, + "number-special": { + "match": "\\b[+-]?(inf|nan(:0x[0-9]+)?)\\b", + "name": "constant.numeric" + }, + "memarg": { + "match": "\\b(offset|align)(=)([0-9_]+)\\b", + "name": "keyword", + "captures": { + "1": { "name": "keyword" }, + "2": { "name": "keyword.operator.arithmetic" }, + "3": { "name": "constant.numeric" } + } + }, + "any-number": { + "patterns": [ + { "include": "#decimal-number" }, + { "include": "#hexadecimal-number" }, + { "include": "#number-special" } + ] + }, + "types": { + "match": "\\b([if](32|64)|v128|funcref|externref|func|extern|func|param|result|mut)\\b", + "name": "keyword" + }, + "string": { + "begin": "\"", + "end": "\"", + "name": "string.quoted", + "patterns": [ + { + "name": "constant.character.escape", + "match": "\\\\[\"\\\\]" + } + ] + }, + "line-comment": { + "match": ";;.*$", + "name": "comment.line.double-semicolon" + }, + "block-comment": { + "begin": "\\(;", + "end": ";\\)", + "name": "comment.block" + }, + "expr": { + "begin": "\\(", + "end": "\\)", + "beginCaptures": { + "0": { "name": "punctuation.paren.open" } + }, + "endCaptures": { + "0": { "name": "punctuation.paren.close" } + }, + "name": "expression.group", + "patterns": [ + { "include": "#block-comment" }, + { "include": "$self" }, + { "include": "#types" }, + { "include": "#line-comment" }, + { "include": "#any-number" }, + { "include": "#memarg" }, + { "include": "#id" }, + { "include": "#string" }, + { "include": "#op" } + ] + } + } +} diff --git a/testWorkspace/web/wasm/hello.html b/testWorkspace/web/wasm/hello.html new file mode 100644 index 000000000..4f6cd4327 --- /dev/null +++ b/testWorkspace/web/wasm/hello.html @@ -0,0 +1,19 @@ + + + + + + + Document + + + + + + + diff --git a/testWorkspace/web/wasm/hello.wasm b/testWorkspace/web/wasm/hello.wasm new file mode 100644 index 0000000000000000000000000000000000000000..6f633f117432d696073aeab96942eab564256dc8 GIT binary patch literal 97 zcmZQbEY4+QU|?WmV@zPIVXR?hVq{=vXJk%GOlDx<(qq(NP+)KY$^*d%`(&0HIGa@g dS!SUC!y;~O1|gQb#N1S%SqvZpn3#bk0|1`#4oCn1 literal 0 HcmV?d00001