From ba06ad3e2d4170516a01f304e200d35f184ab2d6 Mon Sep 17 00:00:00 2001 From: Mark McCulloh Date: Sun, 14 Apr 2024 17:46:39 -0400 Subject: [PATCH] fix: unable to hit most inflight breakpoints while debugging (#6217) Fixes #5988 Fixes #2546 Fixes #6036 Fixes #5986 #5988 was a symptom of a deeper issue. In `wing it` or any other command, breakpoints would only work sometimes and depended on the order of inflights in a file and how many there were. The underlying issue is that the sourcemaps are too slow and may not finish loading in time to help the debugger attach breakpoints. At first I made a change in this PR to fix a problem with how esbuild bundles our sourcemaps. On it's own this change made many scenarios work that previously didn't. That wasn't enough though, so I went through many other options. As part of this, I actually added debugger settings to vscode. In the end I needed to add a hack where we simply have a small sleep before running inflight code. TODO: - [x] Check Windows sourcemaps aren't borked - [x] Ensure minimal benchmark regression (might skip this process if debugger is not needed) - [x] A few tests for the remapping logic - [x] Test locally with a winglib - [ ] Try to figure out better options than a sleep *By submitting this pull request, I confirm that my contribution is made under the terms of the [Wing Cloud Contribution License](https://github.com/winglang/wing/blob/main/CONTRIBUTION_LICENSE.md)*. --- apps/vscode-wing/.projenrc.ts | 45 +++++++- apps/vscode-wing/package.json | 46 +++++++- apps/vscode-wing/src/extension.ts | 67 +++++++++++- apps/vscode-wing/src/project/vscode_types.ts | 6 ++ docs/docs/06-tools/03-debugging.md | 10 +- libs/wingsdk/.projen/deps.json | 4 + libs/wingsdk/.projenrc.ts | 1 + libs/wingsdk/package.json | 2 + libs/wingsdk/src/shared/bundling.ts | 108 +++++++++++++++++-- libs/wingsdk/src/shared/sandbox.ts | 14 ++- libs/wingsdk/test/shared/bundling.test.ts | 72 +++++++++++++ pnpm-lock.yaml | 7 ++ 12 files changed, 361 insertions(+), 21 deletions(-) create mode 100644 libs/wingsdk/test/shared/bundling.test.ts diff --git a/apps/vscode-wing/.projenrc.ts b/apps/vscode-wing/.projenrc.ts index bfa1bc0a95f..49cf8f0e4e7 100644 --- a/apps/vscode-wing/.projenrc.ts +++ b/apps/vscode-wing/.projenrc.ts @@ -108,6 +108,49 @@ const contributes: VSCodeExtensionContributions = { }, }, ], + debuggers: [ + { + type: "wing", + label: "Wing Debug", + program: "", + configurationAttributes: { + launch: { + entrypoint: { + type: "string", + description: "The entrypoint to run", + default: "${file}", + }, + arguments: { + type: "string", + description: "Wing CLI arguments", + default: "test", + }, + }, + }, + initialConfigurations: [ + { + label: "Wing Debug: Launch", + description: "Launch a Wing program", + body: { + type: "wing", + request: "launch", + name: "Launch", + }, + }, + ], + configurationSnippets: [ + { + label: "Wing Debug: Launch", + description: "Launch a Wing program", + body: { + type: "wing", + request: "launch", + name: "Launch", + }, + }, + ], + }, + ], grammars: [ { language: "wing", @@ -167,7 +210,7 @@ project.addFields({ vscode: `^${VSCODE_BASE_VERSION}`, }, categories: ["Programming Languages"], - activationEvents: ["onLanguage:wing"], + activationEvents: ["onLanguage:wing", "onDebug"], contributes, }); diff --git a/apps/vscode-wing/package.json b/apps/vscode-wing/package.json index 6b051d72b71..5dfb9ff9ca9 100644 --- a/apps/vscode-wing/package.json +++ b/apps/vscode-wing/package.json @@ -84,7 +84,8 @@ "Programming Languages" ], "activationEvents": [ - "onLanguage:wing" + "onLanguage:wing", + "onDebug" ], "contributes": { "breakpoints": [ @@ -110,6 +111,49 @@ } } ], + "debuggers": [ + { + "type": "wing", + "label": "Wing Debug", + "program": "", + "configurationAttributes": { + "launch": { + "entrypoint": { + "type": "string", + "description": "The entrypoint to run", + "default": "${file}" + }, + "arguments": { + "type": "string", + "description": "Wing CLI arguments", + "default": "test" + } + } + }, + "initialConfigurations": [ + { + "label": "Wing Debug: Launch", + "description": "Launch a Wing program", + "body": { + "type": "wing", + "request": "launch", + "name": "Launch" + } + } + ], + "configurationSnippets": [ + { + "label": "Wing Debug: Launch", + "description": "Launch a Wing program", + "body": { + "type": "wing", + "request": "launch", + "name": "Launch" + } + } + ] + } + ], "grammars": [ { "language": "wing", diff --git a/apps/vscode-wing/src/extension.ts b/apps/vscode-wing/src/extension.ts index aa5bb410eb1..3a4e002bd07 100644 --- a/apps/vscode-wing/src/extension.ts +++ b/apps/vscode-wing/src/extension.ts @@ -5,6 +5,8 @@ import { languages, workspace, window, + debug, + DebugConfiguration, } from "vscode"; import { getWingBin, updateStatusBar } from "./bin-helper"; import { CFG_WING, CFG_WING_BIN, COMMAND_OPEN_CONSOLE } from "./constants"; @@ -17,7 +19,6 @@ let languageServerManager: LanguageServerManager | undefined; export async function deactivate() { wingBinWatcher?.close(); await languageServerManager?.stop(); - await wingConsoleContext?.stop(); } export async function activate(context: ExtensionContext) { @@ -29,6 +30,70 @@ export async function activate(context: ExtensionContext) { languageServerManager = new LanguageServerManager(); + debug.registerDebugConfigurationProvider("wing", { + async resolveDebugConfiguration(_, _config: DebugConfiguration) { + Loggers.default.appendLine( + `Resolving debug configuration... ${JSON.stringify(_config)}` + ); + const editor = window.activeTextEditor; + + const currentFilename = editor?.document.fileName; + let chosenFile; + if ( + currentFilename?.endsWith("main.w") || + currentFilename?.endsWith(".test.w") + ) { + chosenFile = currentFilename; + } else { + let uriOptions = await workspace.findFiles( + `**/*.{main,test}.w`, + "**/{node_modules,target}/**" + ); + uriOptions.concat( + await workspace.findFiles(`**/main.w`, "**/{node_modules,target}/**") + ); + + const entrypoint = await window.showQuickPick( + uriOptions.map((f) => f.fsPath), + { + placeHolder: "Choose entrypoint to debug", + } + ); + + if (!entrypoint) { + return; + } + + chosenFile = entrypoint; + } + + const command = await window.showInputBox({ + title: `Debugging ${chosenFile}`, + prompt: "Wing CLI arguments", + value: "test", + }); + + if (!command) { + return; + } + + const currentWingBin = await getWingBin(); + + // Use builtin node debugger + return { + name: `Debug ${chosenFile}`, + request: "launch", + type: "node", + args: [currentWingBin, command, chosenFile], + runtimeSourcemapPausePatterns: [ + "${workspaceFolder}/**/target/**/*.cjs", + ], + autoAttachChildProcesses: true, + pauseForSourceMap: true, + }; + }, + }); + const wingBinChanged = async () => { Loggers.default.appendLine(`Setting up wing bin...`); const currentWingBin = await getWingBin(); diff --git a/apps/vscode-wing/src/project/vscode_types.ts b/apps/vscode-wing/src/project/vscode_types.ts index e62ee2b0e27..d85f170dad4 100644 --- a/apps/vscode-wing/src/project/vscode_types.ts +++ b/apps/vscode-wing/src/project/vscode_types.ts @@ -52,6 +52,12 @@ export interface VSCodeDebugger { readonly label?: string; readonly type: string; readonly runtime?: string; + readonly program?: string; + readonly request?: string; + readonly variables?: string; + readonly configurationAttributes?: any; + readonly initialConfigurations?: any[]; + readonly configurationSnippets?: any[]; } export interface VSCodeGrammar { diff --git a/docs/docs/06-tools/03-debugging.md b/docs/docs/06-tools/03-debugging.md index 631aa9f2133..5e7d9714601 100644 --- a/docs/docs/06-tools/03-debugging.md +++ b/docs/docs/06-tools/03-debugging.md @@ -11,13 +11,9 @@ Internally Wing uses JavaScript to execute preflight and inflight code, so stand ### Local/Simulator Debugging -To start, open your .w file in VS Code and set a breakpoint by clicking in the gutter to the left of the line number. Breakpoints can also be set in extern files. There are several ways to start the debugger, but let's use the "JavaScript Debug Terminal". -Open the command palette and type "Debug: Open JavaScript Debug Terminal". This works for any wing commands like `wing test` and `wing it`, although keep in mind that `wing compile` will only debug preflight code. - -### Limitations - -- ([Issue](https://github.com/winglang/wing/issues/5988)) When using the Wing Console (`wing it`) and attempting to debug inflight code in a `test` or Function, the first execution of the test will not hit a breakpoint and will need to be run again -- ([Issue](https://github.com/winglang/wing/issues/5986)) inflight code by default has a timeout that continues during debugging, so if execution is paused for too long the program is terminate +To start, open your .w file in VS Code and set breakpoints by clicking in the gutter to the left of the line number. Breakpoints can also be set in extern files. +Once set, press F5 or use the "Run and Debug" button in the sidebar to start the debugger. This will use the current file if it's an entrypoint or it will prompt you to select one. Different CLI arguments can be provided as well. +By default, `wing test` will be run with an attached debugger. #### Non-VSCode Support diff --git a/libs/wingsdk/.projen/deps.json b/libs/wingsdk/.projen/deps.json index 0cc97fca607..13a0ec341d8 100644 --- a/libs/wingsdk/.projen/deps.json +++ b/libs/wingsdk/.projen/deps.json @@ -338,6 +338,10 @@ "name": "uuid", "type": "bundled" }, + { + "name": "vlq", + "type": "bundled" + }, { "name": "yaml", "type": "bundled" diff --git a/libs/wingsdk/.projenrc.ts b/libs/wingsdk/.projenrc.ts index f3433a92daa..b66ff083697 100644 --- a/libs/wingsdk/.projenrc.ts +++ b/libs/wingsdk/.projenrc.ts @@ -89,6 +89,7 @@ const project = new cdk.JsiiProject({ // enhanced diagnostics "stacktracey", "ulid", + "vlq", // tunnels "@winglang/wingtunnels@workspace:^", "glob", diff --git a/libs/wingsdk/package.json b/libs/wingsdk/package.json index 989cba997b2..29217b20a2a 100644 --- a/libs/wingsdk/package.json +++ b/libs/wingsdk/package.json @@ -117,6 +117,7 @@ "toml": "^3.0.0", "ulid": "^2.3.0", "uuid": "^8.3.2", + "vlq": "^2.0.4", "yaml": "^2.3.2" }, "bundledDependencies": [ @@ -159,6 +160,7 @@ "toml", "ulid", "uuid", + "vlq", "yaml" ], "engines": { diff --git a/libs/wingsdk/src/shared/bundling.ts b/libs/wingsdk/src/shared/bundling.ts index 20cc47c6203..0d38bc855e8 100644 --- a/libs/wingsdk/src/shared/bundling.ts +++ b/libs/wingsdk/src/shared/bundling.ts @@ -1,6 +1,7 @@ import * as crypto from "crypto"; import { mkdirSync, realpathSync, writeFileSync } from "fs"; import { posix, resolve } from "path"; +import { decode, encode } from "vlq"; import { normalPath } from "./misc"; const SDK_PATH = normalPath(resolve(__dirname, "..", "..")); @@ -74,15 +75,7 @@ export function createBundle( const sourcemapData = JSON.parse( new TextDecoder().decode(esbuild.outputFiles[0].contents) ); - if (sourcemapData.sourceRoot) { - sourcemapData.sourceRoot = normalPath(sourcemapData.sourceRoot); - } - - for (const [idx, source] of Object.entries( - sourcemapData.sources as string[] - )) { - sourcemapData.sources[idx] = normalPath(source); - } + fixSourcemaps(sourcemapData); writeFileSync(outfile, bundleOutput.contents); writeFileSync(outfileMap, JSON.stringify(sourcemapData)); @@ -101,3 +94,100 @@ export function createBundle( sourcemapPath: outfileMap, }; } + +export interface SourceMap { + sourceRoot?: string; + sources: string[]; + sourcesContent: string[]; + mappings: string; +} + +/** + * Takes a bundled sourcemap and does the following fixes: + * - Normalizes paths in sources and sourceRoot + * - Removes duplicate sources and sourcesContent + * - Updates mappings to reflect the new source indices + * + * The duplicate sources come from esbuild's strange handling of multiple files being bundled that point to the same source (e.g. inflights that point to one .w file) + * See https://github.com/evanw/esbuild/issues/933 + */ +export function fixSourcemaps(sourcemapData: SourceMap): void { + // normalize sourceRoot + if (sourcemapData.sourceRoot) { + sourcemapData.sourceRoot = normalPath(sourcemapData.sourceRoot); + } + + // normalize sources and remove duplicates + const sourceSet: string[] = []; + const newSourceContents: string[] = []; + const sourceIndexMap: Record = {}; + let hasSourceDupes = false; + sourcemapData.sources.forEach((source, idx) => { + const newPath = normalPath(source); + sourcemapData.sources[idx] = newPath; + + const existingIndex = sourceSet.indexOf(newPath); + if (existingIndex === -1) { + sourceSet.push(newPath); + newSourceContents.push(sourcemapData.sourcesContent[idx]); + sourceIndexMap[idx] = sourceSet.length - 1; + } else { + hasSourceDupes = true; + sourceIndexMap[idx] = existingIndex; + } + }); + + sourcemapData.sources = sourceSet; + sourcemapData.sourcesContent = newSourceContents; + + // fast path: No source duplicates so no need to update mappings + if (!hasSourceDupes) { + return; + } + + // update mappings + let newMapping = ""; + let characterIndex = 0; + let lastFile = 0; + let lastTrueFile = 0; + while (characterIndex < sourcemapData.mappings.length) { + const char = sourcemapData.mappings[characterIndex]; + // `;` and `,` are separators between the segments of interest + if (char === ";" || char === ",") { + newMapping += char; + characterIndex++; + continue; + } + + // get next slice of segment data + let segment = ""; + let nextChar = char; + while (nextChar !== undefined && nextChar !== "," && nextChar !== ";") { + segment += nextChar; + nextChar = sourcemapData.mappings[++characterIndex]; + } + const decoded = decode(segment); + if (decoded.length === 1) { + newMapping += segment; + continue; + } + + const sourceRelative = decoded[1]; + const originalSource = lastTrueFile + sourceRelative; + const newSourceIndex = sourceIndexMap[originalSource]; + lastTrueFile = originalSource; + + const newRelativeValue = newSourceIndex - lastFile; + lastFile = newSourceIndex; + + if (newRelativeValue === decoded[1]) { + // no change was made, avoid re-encoding + newMapping += segment; + } else { + decoded[1] = newRelativeValue; + newMapping += encode(decoded); + } + } + + sourcemapData.mappings = newMapping; +} diff --git a/libs/wingsdk/src/shared/sandbox.ts b/libs/wingsdk/src/shared/sandbox.ts index 5d82610307b..4500e57e87b 100644 --- a/libs/wingsdk/src/shared/sandbox.ts +++ b/libs/wingsdk/src/shared/sandbox.ts @@ -47,6 +47,15 @@ export class Sandbox { ); } + let debugShim = ""; + if (inspectorUrl?.()) { + // If we're debugging, we need to make sure the debugger has enough time to attach + // to the child process. This gives enough time for the debugger load sourcemaps and set breakpoints. + // See https://github.com/microsoft/vscode-js-debug/issues/1510 + debugShim = + "\n await new Promise((resolve) => setTimeout(resolve, 25));"; + } + // wrap contents with a shim that handles the communication with the parent process // we insert this shim before bundling to ensure source maps are generated correctly contents += ` @@ -54,12 +63,13 @@ process.setUncaughtExceptionCaptureCallback((reason) => { process.send({ type: "reject", reason }); }); -process.on("message", async (message) => { +process.on("message", async (message) => {${debugShim} const { fn, args } = message; const value = await exports[fn](...args); process.send({ type: "resolve", value }); }); `; + const wrappedPath = entrypoint.replace(/\.cjs$/, ".sandbox.cjs"); writeFileSync(wrappedPath, contents); // async fsPromises.writeFile "flush" option is not available in Node 20 const bundle = createBundle(wrappedPath); @@ -232,7 +242,7 @@ process.on("message", async (message) => { reject(new Error(`Process exited with code ${code}, signal ${signal}`)); }; - if (this.options.timeout) { + if (this.options.timeout && !inspectorUrl?.()) { this.timeout = setTimeout(() => { this.debugLog("Killing process after timeout."); this.child?.kill("SIGTERM"); diff --git a/libs/wingsdk/test/shared/bundling.test.ts b/libs/wingsdk/test/shared/bundling.test.ts new file mode 100644 index 00000000000..41e5f18b0ed --- /dev/null +++ b/libs/wingsdk/test/shared/bundling.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { encode, decode } from "vlq"; +import { fixSourcemaps } from "../../src/shared/bundling"; + +describe("fixSourcemaps", () => { + it("should fix sourcemaps", () => { + // THEN + const mappings = [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 2, 2, 0], + [0, -1, 3, 0], + [0, -1, 4, 0], + ]; + const originalMapping = mappings.map((m) => encode(m)).join(";"); + + const sourcemapData = { + sources: ["a/aa", "b", "a/aa", "c"], + sourcesContent: ["1", "2", "1", "3"], + mappings: originalMapping, + }; + + // WHEN + fixSourcemaps(sourcemapData); + + // THEN + expect(sourcemapData.sources).toHaveLength(3); + expect(sourcemapData.sourcesContent).toHaveLength(3); + expect(sourcemapData.mappings).not.toEqual(originalMapping); + + expect(sourcemapData.sources).toMatchInlineSnapshot(` + [ + "a/aa", + "b", + "c", + ] + `); + expect(sourcemapData.sourcesContent).toMatchInlineSnapshot(` + [ + "1", + "2", + "3", + ] + `); + + const decoded = sourcemapData.mappings.split(";").map(decode); + expect(decoded).toHaveLength(5); + // first 2 mappings are unchanged + expect(decoded[0]).toEqual(mappings[0]); + expect(decoded[1]).toEqual(mappings[1]); + // This mapping pointed to [3] which is now at [2], so now it needs to point to [2] + // AKA Shifted by 1 + expect(decoded[2]).toEqual([ + mappings[2][0], + mappings[2][1] - 1, + mappings[2][2], + mappings[2][3], + ]); + expect(decoded[3]).toEqual([ + mappings[3][0], + mappings[3][1] - 1, + mappings[3][2], + mappings[3][3], + ]); + expect(decoded[4]).toEqual([ + mappings[4][0], + mappings[4][1] + 2, + mappings[4][2], + mappings[4][3], + ]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4f1c1bcc81..815dd742ec9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1380,6 +1380,9 @@ importers: uuid: specifier: ^8.3.2 version: 8.3.2 + vlq: + specifier: ^2.0.4 + version: 2.0.4 yaml: specifier: ^2.3.2 version: 2.3.2 @@ -23429,6 +23432,10 @@ packages: - terser dev: true + /vlq@2.0.4: + resolution: {integrity: sha512-aodjPa2wPQFkra1G8CzJBTHXhgk3EVSwxSWXNPr1fgdFLUb8kvLV1iEb6rFgasIsjP82HWI6dsb5Io26DDnasA==} + dev: false + /vscode-jsonrpc@8.1.0: resolution: {integrity: sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw==} engines: {node: '>=14.0.0'}