Skip to content

fix: allow hitting breakpoints early in webassembly #2102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This changelog records changes to stable releases since 1.50.2. "TBA" changes he

## Nightly (only)

- fix: allow hitting breakpoints early in webassembly ([vscode#230875](https://github.com/microsoft/vscode/issues/230875))
- fix: only autofill "debug link" input if the hostname resolves ([vscode#228950](https://github.com/microsoft/vscode/issues/228950))
- fix: support ANSI colorization in stdout logged strings ([vscode#230441](https://github.com/microsoft/vscode/issues/230441))
- fix: disable entrypoint breakpoint at first pause in script ([vscode#230201](https://github.com/microsoft/vscode/issues/230201))
Expand Down
27 changes: 27 additions & 0 deletions src/adapter/templates/breakOnWasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------*/

import { makeInternalSourceUrl, templateFunction } from '.';

/* eslint-disable @typescript-eslint/no-explicit-any */

export const breakOnWasmSourceUrl = makeInternalSourceUrl(); // randomized

export const breakOnWasmInit = templateFunction(function() {
const fns = [
'instantiate',
'instantiateStreaming',
'compile',
'compileStreaming',
] satisfies (keyof typeof WebAssembly)[];
for (const fn of fns) {
const original = (WebAssembly as any)[fn];
WebAssembly[fn] = function(...args) {
return original.apply(this, args).then((r: unknown) => {
debugger;
return r as any;
});
};
}
}, breakOnWasmSourceUrl);
30 changes: 21 additions & 9 deletions src/adapter/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { SourceConstants } from '../../common/sourceUtils';
/**
* Gets the suffix containing the `sourceURL` to mark a script as internal.
*/
export const getSourceSuffix = () =>
`\n//# sourceURL=eval-${randomBytes(4).toString('hex')}${SourceConstants.InternalExtension}\n`;
export const getSourceSuffix = (url = makeInternalSourceUrl()) => `\n//# sourceURL=${url}\n`;

export const makeInternalSourceUrl = () =>
`eval-${randomBytes(4).toString('hex')}${SourceConstants.InternalExtension}`;

export type TemplateFunction<A extends unknown[]> = {
expr: (...args: A) => string;
Expand Down Expand Up @@ -48,6 +50,7 @@ export type TemplateFunction<A extends unknown[]> = {
* })();
* ```
*/
export function templateFunction(fn: () => void, sourceURL?: string): TemplateFunction<[]>;
export function templateFunction<A>(fn: (a: A) => void): TemplateFunction<[string]>;
export function templateFunction<A, B>(
fn: (a: A, b: B) => void,
Expand All @@ -58,11 +61,15 @@ export function templateFunction<A, B, C>(
export function templateFunction<Args extends unknown[]>(fn: string): TemplateFunction<Args>;
export function templateFunction<Args extends unknown[]>(
fn: string | ((...args: Args) => void),
sourceURL?: string,
): TemplateFunction<string[]> {
return templateFunctionStr('' + fn);
return templateFunctionStr('' + fn, sourceURL);
}

function templateFunctionStr<Args extends string[]>(stringified: string): TemplateFunction<Args> {
function templateFunctionStr<Args extends string[]>(
stringified: string,
sourceURL?: string,
): TemplateFunction<Args> {
const decl = parseExpressionAt(stringified, 0, {
ecmaVersion: 'latest',
locations: true,
Expand All @@ -86,8 +93,9 @@ function templateFunctionStr<Args extends string[]>(stringified: string): Templa
${stringified.slice(start + 1, end - 1)}
`;
return {
expr: (...args: Args) => `(()=>{${inner(args)}})();\n${getSourceSuffix()}`,
decl: (...args: Args) => `function(...runtimeArgs){${inner(args)};\n${getSourceSuffix()}}`,
expr: (...args: Args) => `(()=>{${inner(args)}})();\n${getSourceSuffix(sourceURL)}`,
decl: (...args: Args) =>
`function(...runtimeArgs){${inner(args)};\n${getSourceSuffix(sourceURL)}}`,
};
}

Expand Down Expand Up @@ -116,10 +124,14 @@ export class RemoteObjectId {
* that takes the CDP and arguments with which to invoke the function. The
* arguments should be simple objects.
*/
export function remoteFunction<Args extends unknown[], R>(fn: string | ((...args: Args) => R)) {
export function remoteFunction<Args extends unknown[], R>(
fn: string | ((...args: Args) => R),
sourceURL?: string,
) {
let stringified = '' + fn;
const endIndex = stringified.lastIndexOf('}');
stringified = stringified.slice(0, endIndex) + getSourceSuffix() + stringified.slice(endIndex);
stringified = stringified.slice(0, endIndex) + getSourceSuffix(sourceURL)
+ stringified.slice(endIndex);

// Some ugly typing here, but it gets us type safety. Mainly we want to:
// 1. Have args that extend the function arg and omit the args we provide (easy)
Expand All @@ -130,7 +142,7 @@ export function remoteFunction<Args extends unknown[], R>(fn: string | ((...args
args,
...options
}:
& { cdp: Cdp.Api; args: Args | RemoteObjectId[] }
& { cdp: Cdp.Api; sourceURL?: string; args: Args | RemoteObjectId[] }
& Omit<
Cdp.Runtime.CallFunctionOnParams,
'functionDeclaration' | 'arguments' | 'returnByValue'
Expand Down
57 changes: 43 additions & 14 deletions src/adapter/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from './source';
import { IPreferredUiLocation, SourceContainer } from './sourceContainer';
import { InlinedFrame, isStackFrameElement, StackFrame, StackTrace } from './stackTrace';
import { breakOnWasmInit, breakOnWasmSourceUrl } from './templates/breakOnWasm';
import {
serializeForClipboard,
serializeForClipboardTmpl,
Expand All @@ -56,6 +57,8 @@ import { IVariableStoreLocationProvider, VariableStore } from './variableStore';
export class ExecutionContext {
public readonly sourceMapLoads = new Map<string, Promise<IUiLocation[]>>();
public readonly scripts: Script[] = [];
/** Script ID paused in after WASM is initialized, from {@link breakOnWasmInit} */
public breakOnWasmScriptId?: string;

constructor(public readonly description: Cdp.Runtime.ExecutionContextDescription) {}

Expand Down Expand Up @@ -186,6 +189,8 @@ export class Thread implements IVariableStoreLocationProvider {
private _expectedPauseReason?: ExpectedPauseReason;
private _excludedCallers: readonly Dap.ExcludedCaller[] = [];
private _enabledCustomBreakpoints?: ReadonlySet<string>;
/** Last parsed WASM script ID. Set immediately before breaking in {@link _breakOnWasmScriptId} */
private _lastParsedWasmScriptIds?: string[];
private readonly stateQueue = new StateQueue();
private readonly _onPausedEmitter = new EventEmitter<IPausedDetails>();
private readonly _dap: DeferredContainer<Dap.Api>;
Expand Down Expand Up @@ -917,6 +922,26 @@ export class Thread implements IVariableStoreLocationProvider {
private _executionContextCreated(description: Cdp.Runtime.ExecutionContextDescription) {
const context = new ExecutionContext(description);
this._executionContexts.set(description.id, context);

// 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]',
// }),
//
// For now, overwrite WebAssembly methods in the runtime to get the same effect. This needs to be run in event new execution context:
this._cdp.Runtime.evaluate({
expression: breakOnWasmInit.expr(),
silent: true,
contextId: description.id,
});
}

async _executionContextDestroyed(contextId: number) {
Expand Down Expand Up @@ -969,10 +994,13 @@ export class Thread implements IVariableStoreLocationProvider {
|| event.reason === 'debugCommand';
const location = event.callFrames[0]?.location as Cdp.Debugger.Location | undefined;
const scriptId = (event.data as IInstrumentationPauseAuxData)?.scriptId || location?.scriptId;
const isWasmBreak = scriptId
&& this._sourceContainer.getSourceScriptById(scriptId)?.url === breakOnWasmSourceUrl;
const isSourceMapPause = scriptId
&& (event.reason === 'instrumentation'
|| this._breakpointManager.isEntrypointBreak(hitBreakpoints, scriptId)
|| hitBreakpoints.some(bp => this._pauseOnSourceMapBreakpointIds?.includes(bp)));
|| hitBreakpoints.some(bp => this._pauseOnSourceMapBreakpointIds?.includes(bp))
|| isWasmBreak);
this.evaluator.setReturnedValue(event.callFrames[0]?.returnValue);

if (isSourceMapPause) {
Expand All @@ -988,7 +1016,15 @@ export class Thread implements IVariableStoreLocationProvider {
}

const expectedPauseReason = this._expectedPauseReason;
if (scriptId && (await this._handleSourceMapPause(scriptId, location))) {
if (isWasmBreak && this._lastParsedWasmScriptIds) {
// Resolve all pending WASM symbols when we've just initialized something
const ids = this._lastParsedWasmScriptIds;
this._lastParsedWasmScriptIds = undefined;
await Promise.all(
ids.map(id => this._handleSourceMapPause(id, location)),
);
return this.resume();
} else if (scriptId && (await this._handleSourceMapPause(scriptId, location))) {
// Pause if we just resolved a breakpoint that's on this
// location; this won't have existed before now.
} else if (isInspectBrk) {
Expand Down Expand Up @@ -1663,6 +1699,11 @@ export class Thread implements IVariableStoreLocationProvider {
}
}

if (event.scriptLanguage === 'WebAssembly') {
this._lastParsedWasmScriptIds ??= [];
this._lastParsedWasmScriptIds.push(event.scriptId);
}

const source = await this._sourceContainer.addSource(
event,
contentGetter,
Expand Down Expand Up @@ -1942,18 +1983,6 @@ export class Thread implements IVariableStoreLocationProvider {
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) {
Expand Down
18 changes: 17 additions & 1 deletion src/test/wasm/wasm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,26 @@ describe('webassembly', () => {
await p.dap.setBreakpoints(bp);

await p.dap.once('breakpoint', bp => bp.breakpoint.verified);
await p.cdp.Page.reload({});
p.cdp.Page.reload({});
// wait for the reload to start:
await p.dap.once('loadedSource', e => e.reason === 'removed');
return p;
};

itIntegrates('can break immediately', async ({ r }) => {
const p = await r.launchUrl(`dwarf/fibonacci.html`);
await p.dap.setBreakpoints({
source: { path: p.workspacePath('web/dwarf/fibonacci.c') },
breakpoints: [{ line: 6 }],
});
p.load();

const { threadId } = p.log(await p.dap.once('stopped'));
await p.logger.logStackTrace(threadId, 2);

r.assertLog();
});

itIntegrates('scopes and variables', async ({ r, context }) => {
const p = await prepare(r, context, 'fibonacci', {
source: { path: 'web/dwarf/fibonacci.c' },
Expand Down
42 changes: 42 additions & 0 deletions src/test/wasm/webassembly-dwarf-can-break-immediately.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
allThreadsStopped : false
description : Paused on breakpoint
reason : breakpoint
threadId : <number>
}

fib @ ${workspaceFolder}/web/dwarf/fibonacci.c:6:9
> scope #0: Locals
a: 0
b: 0
c: 1
i: 1
scope #1: Parameters [expensive]

main @ ${workspaceFolder}/web/dwarf/fibonacci.c:14:11
> scope #0: Locals
a: 0
b: 0

Window.$main @ localhost꞉8001/dwarf/fibonacci.wat:230:1

<anonymous> @ ${workspaceFolder}/web/dwarf/fibonacci.js:723:14

Window.callMain @ ${workspaceFolder}/web/dwarf/fibonacci.js:1580:15

Window.doRun @ ${workspaceFolder}/web/dwarf/fibonacci.js:1630:23

<anonymous> @ ${workspaceFolder}/web/dwarf/fibonacci.js:1641:7

----setTimeout----
run @ ${workspaceFolder}/web/dwarf/fibonacci.js:1637:5
runCaller @ ${workspaceFolder}/web/dwarf/fibonacci.js:1565:19
removeRunDependency @ ${workspaceFolder}/web/dwarf/fibonacci.js:641:7
receiveInstance @ ${workspaceFolder}/web/dwarf/fibonacci.js:860:5
receiveInstantiationResult @ ${workspaceFolder}/web/dwarf/fibonacci.js:878:5
----Promise.then----
<anonymous> @ ${workspaceFolder}/web/dwarf/fibonacci.js:813:21
----Promise.then----
instantiateAsync @ ${workspaceFolder}/web/dwarf/fibonacci.js:805:62
createWasm @ ${workspaceFolder}/web/dwarf/fibonacci.js:897:3
<anonymous> @ ${workspaceFolder}/web/dwarf/fibonacci.js:1253:19
Loading