UNPKG

vscode-chrome-debug-core

Version:

A library for building VS Code debug adapters for targets that support the Chrome Remote Debug Protocol

566 lines (564 loc) 31.2 kB
"use strict"; /*--------------------------------------------------------- * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const vscode_debugadapter_1 = require("vscode-debugadapter"); const ChromeUtils = require("./chromeUtils"); const internalSourceBreakpoint_1 = require("./internalSourceBreakpoint"); const utils = require("../utils"); const path = require("path"); const nls = require("vscode-nls"); let localize = nls.loadMessageBundle(__filename); /** * Encapsulates all the logic surrounding breakpoints (e.g. set, unset, hit count breakpoints, etc.) */ class Breakpoints { constructor(adapter, _chromeConnection) { this.adapter = adapter; this._chromeConnection = _chromeConnection; this._nextUnboundBreakpointId = 0; // when working with _committedBreakpointsByUrl, we want to keep the url keys canonicalized for consistency // use methods getValueFromCommittedBreakpointsByUrl and setValueForCommittedBreakpointsByUrl this._committedBreakpointsByUrl = new Map(); this._setBreakpointsRequestQ = Promise.resolve(); this._breakpointIdHandles = new utils.ReverseHandles(); this._pendingBreakpointsByUrl = new Map(); this._hitConditionBreakpointsById = new Map(); } get breakpointsQueueDrained() { return this._setBreakpointsRequestQ; } get committedBreakpointsByUrl() { return this._committedBreakpointsByUrl; } getValueFromCommittedBreakpointsByUrl(url) { let canonicalizedUrl = utils.canonicalizeUrl(url); return this._committedBreakpointsByUrl.get(canonicalizedUrl); } setValueForCommittedBreakpointsByUrl(url, value) { let canonicalizedUrl = utils.canonicalizeUrl(url); this._committedBreakpointsByUrl.set(canonicalizedUrl, value); } get chrome() { return this._chromeConnection.api; } reset() { this._committedBreakpointsByUrl = new Map(); this._setBreakpointsRequestQ = Promise.resolve(); } /** * Using the request object from the DAP, set all breakpoints on the target * @param args The setBreakpointRequest arguments from the DAP client * @param scripts The script container associated with this instance of the adapter * @param requestSeq The request sequence number from the DAP * @param ids IDs passed in for previously unverified breakpoints */ setBreakpoints(args, scripts, requestSeq, ids) { if (args.source.path) { args.source.path = this.adapter.displayPathToRealPath(args.source.path); args.source.path = utils.canonicalizeUrl(args.source.path); } return this.validateBreakpointsPath(args) .then(() => { // Deep copy the args that we are going to modify, and keep the original values in originalArgs const originalArgs = args; args = JSON.parse(JSON.stringify(args)); args = this.adapter.lineColTransformer.setBreakpoints(args); const sourceMapTransformerResponse = this.adapter.sourceMapTransformer.setBreakpoints(args, requestSeq, ids); if (sourceMapTransformerResponse && sourceMapTransformerResponse.args) { args = sourceMapTransformerResponse.args; } if (sourceMapTransformerResponse && sourceMapTransformerResponse.ids) { ids = sourceMapTransformerResponse.ids; } args.source = this.adapter.pathTransformer.setBreakpoints(args.source); // Get the target url of the script let targetScriptUrl; if (args.source.sourceReference) { const handle = scripts.getSource(args.source.sourceReference); if ((!handle || !handle.scriptId) && args.source.path) { // A sourcemapped script with inline sources won't have a scriptId here, but the // source.path has been fixed. targetScriptUrl = args.source.path; } else { const targetScript = scripts.getScriptById(handle.scriptId); if (targetScript) { targetScriptUrl = targetScript.url; } } } else if (args.source.path) { targetScriptUrl = args.source.path; } if (targetScriptUrl) { // DebugProtocol sends all current breakpoints for the script. Clear all breakpoints for the script then add all of them const internalBPs = args.breakpoints.map(bp => new internalSourceBreakpoint_1.InternalSourceBreakpoint(bp)); const setBreakpointsPFailOnError = this._setBreakpointsRequestQ .then(() => this.clearAllBreakpoints(targetScriptUrl)) .then(() => this.addBreakpoints(targetScriptUrl, internalBPs, scripts)) .then(responses => ({ breakpoints: this.targetBreakpointResponsesToBreakpointSetResults(targetScriptUrl, responses, internalBPs, ids) })); const setBreakpointsPTimeout = utils.promiseTimeout(setBreakpointsPFailOnError, Breakpoints.SET_BREAKPOINTS_TIMEOUT, localize(0, null)); // Do just one setBreakpointsRequest at a time to avoid interleaving breakpoint removed/breakpoint added requests to Crdp, which causes issues. // Swallow errors in the promise queue chain so it doesn't get blocked, but return the failing promise for error handling. this._setBreakpointsRequestQ = setBreakpointsPTimeout.catch(e => { // Log the timeout, but any other error will be logged elsewhere if (e.message && e.message.indexOf('timed out') >= 0) { vscode_debugadapter_1.logger.error(e.stack); } }); // Return the setBP request, no matter how long it takes. It may take awhile in Node 7.5 - 7.7, see https://github.com/nodejs/node/issues/11589 return setBreakpointsPFailOnError.then(setBpResultBody => { const body = { breakpoints: setBpResultBody.breakpoints.map(setBpResult => setBpResult.breakpoint) }; if (body.breakpoints.every(bp => !bp.verified)) { // We need to send the original args to avoid adjusting the line and column numbers twice here return this.unverifiedBpResponseForBreakpoints(originalArgs, requestSeq, targetScriptUrl, body.breakpoints, localize(1, null)); } body.breakpoints = this.adapter.sourceMapTransformer.setBreakpointsResponse(body.breakpoints, true, requestSeq) || body.breakpoints; this.adapter.lineColTransformer.setBreakpointsResponse(body); return body; }); } else { return Promise.resolve(this.unverifiedBpResponse(args, requestSeq, undefined, localize(2, null))); } }, e => this.unverifiedBpResponse(args, requestSeq, undefined, e.message)); } validateBreakpointsPath(args) { if (!args.source.path || args.source.sourceReference) return Promise.resolve(); // When break on load is active, we don't need to validate the path, so return if (this.adapter.breakOnLoadActive) { return Promise.resolve(); } return this.adapter.sourceMapTransformer.getGeneratedPathFromAuthoredPath(args.source.path).then(mappedPath => { if (!mappedPath) { return utils.errP(localize(3, null)); } const targetPath = this.adapter.pathTransformer.getTargetPathFromClientPath(mappedPath); if (!targetPath) { return utils.errP(localize(4, null)); } return undefined; }); } /** * Makes the actual call to either Debugger.setBreakpoint or Debugger.setBreakpointByUrl, and returns the response. * Responses from setBreakpointByUrl are transformed to look like the response from setBreakpoint, so they can be * handled the same. */ addBreakpoints(url, breakpoints, scripts) { return __awaiter(this, void 0, void 0, function* () { let responsePs; if (ChromeUtils.isEvalScript(url)) { // eval script with no real url - use debugger_setBreakpoint const scriptId = utils.lstrip(url, ChromeUtils.EVAL_NAME_PREFIX); responsePs = breakpoints.map(({ line, column = 0, condition }) => this.chrome.Debugger.setBreakpoint({ location: { scriptId, lineNumber: line, columnNumber: column }, condition })); } else { // script that has a url - use debugger_setBreakpointByUrl so that Chrome will rebind the breakpoint immediately // after refreshing the page. This is the only way to allow hitting breakpoints in code that runs immediately when // the page loads. const script = scripts.getScriptByUrl(url); // If script has been parsed, script object won't be undefined and we would have the mapping file on the disk and we can directly set breakpoint using that if (!this.adapter.breakOnLoadActive || script) { const urlRegex = utils.pathToRegex(url); responsePs = breakpoints.map(({ line, column = 0, condition }) => { return this.addOneBreakpointByUrl(script && script.scriptId, urlRegex, line, column, condition); }); } else { if (this.adapter.breakOnLoadActive) { return yield this.adapter.breakOnLoadHelper.handleAddBreakpoints(url, breakpoints); } } } // Join all setBreakpoint requests to a single promise return Promise.all(responsePs); }); } /** * Adds a single breakpoint in the target using the url for the script * @param scriptId the chrome-devtools script id for the script on which we want to add a breakpoint * @param urlRegex The regular expression string which will be used to find the correct url on which to set the breakpoint * @param lineNumber Line number of the breakpoint * @param columnNumber Column number of the breakpoint * @param condition The (optional) breakpoint condition */ addOneBreakpointByUrl(scriptId, urlRegex, lineNumber, columnNumber, condition) { return __awaiter(this, void 0, void 0, function* () { let bpLocation = { lineNumber, columnNumber }; if (this.adapter.columnBreakpointsEnabled && scriptId) { try { const possibleBpResponse = yield this.chrome.Debugger.getPossibleBreakpoints({ start: { scriptId, lineNumber, columnNumber: 0 }, end: { scriptId, lineNumber: lineNumber + 1, columnNumber: 0 }, restrictToFunction: false }); if (possibleBpResponse.locations.length) { const selectedLocation = ChromeUtils.selectBreakpointLocation(lineNumber, columnNumber, possibleBpResponse.locations); bpLocation = { lineNumber: selectedLocation.lineNumber, columnNumber: selectedLocation.columnNumber || 0 }; } } catch (e) { // getPossibleBPs not supported } } let result; try { result = yield this.chrome.Debugger.setBreakpointByUrl({ urlRegex, lineNumber: bpLocation.lineNumber, columnNumber: bpLocation.columnNumber, condition }); } catch (e) { if (e.message === 'Breakpoint at specified location already exists.') { return { actualLocation: { lineNumber: bpLocation.lineNumber, columnNumber: bpLocation.columnNumber, scriptId } }; } else { throw e; } } // Now convert the response to a SetBreakpointResponse so both response types can be handled the same const locations = result.locations; return { breakpointId: result.breakpointId, actualLocation: locations[0] && { lineNumber: locations[0].lineNumber, columnNumber: locations[0].columnNumber, scriptId } }; }); } /** * Using the request object from the DAP, set all breakpoints on the target * @param args The setBreakpointRequest arguments from the DAP client * @param scripts The script container associated with this instance of the adapter * @param requestSeq The request sequence number from the DAP * @param ids IDs passed in for previously unverified breakpoints */ getBreakpointsLocations(args, scripts, requestSeq) { return __awaiter(this, void 0, void 0, function* () { if (args.source.path) { args.source.path = this.adapter.displayPathToRealPath(args.source.path); args.source.path = utils.canonicalizeUrl(args.source.path); } try { yield this.validateBreakpointsPath(args); } catch (e) { vscode_debugadapter_1.logger.log('breakpointsLocations failed: ' + e.message); return { breakpoints: [] }; } // Deep copy the args that we are going to modify, and keep the original values in originalArgs args = JSON.parse(JSON.stringify(args)); args.endLine = this.adapter.lineColTransformer.convertClientLineToDebugger(typeof args.endLine === 'number' ? args.endLine : args.line + 1); args.endColumn = this.adapter.lineColTransformer.convertClientLineToDebugger(args.endColumn || 1); args.line = this.adapter.lineColTransformer.convertClientLineToDebugger(args.line); args.column = this.adapter.lineColTransformer.convertClientColumnToDebugger(args.column || 1); if (args.source.path) { const source1 = JSON.parse(JSON.stringify(args.source)); const startArgs = this.adapter.sourceMapTransformer.setBreakpoints({ breakpoints: [{ line: args.line, column: args.column }], source: source1 }, requestSeq); args.line = startArgs.args.breakpoints[0].line; args.column = startArgs.args.breakpoints[0].column; const endArgs = this.adapter.sourceMapTransformer.setBreakpoints({ breakpoints: [{ line: args.endLine, column: args.endColumn }], source: args.source }, requestSeq); args.endLine = endArgs.args.breakpoints[0].line; args.endColumn = endArgs.args.breakpoints[0].column; } args.source = this.adapter.pathTransformer.setBreakpoints(args.source); // Get the target url of the script let targetScriptUrl; if (args.source.sourceReference) { const handle = scripts.getSource(args.source.sourceReference); if ((!handle || !handle.scriptId) && args.source.path) { // A sourcemapped script with inline sources won't have a scriptId here, but the // source.path has been fixed. targetScriptUrl = args.source.path; } else { const targetScript = scripts.getScriptById(handle.scriptId); if (targetScript) { targetScriptUrl = targetScript.url; } } } else if (args.source.path) { targetScriptUrl = args.source.path; } if (targetScriptUrl) { const script = scripts.getScriptByUrl(targetScriptUrl); if (script) { const end = typeof args.endLine === 'number' ? { scriptId: script.scriptId, lineNumber: args.endLine, columnNumber: args.endColumn || 0 } : { scriptId: script.scriptId, lineNumber: args.line + 1, columnNumber: 0 }; const possibleBpResponse = yield this.chrome.Debugger.getPossibleBreakpoints({ start: { scriptId: script.scriptId, lineNumber: args.line, columnNumber: args.column || 0 }, end, restrictToFunction: false }); if (possibleBpResponse.locations) { let breakpoints = possibleBpResponse.locations.map(loc => { return { line: loc.lineNumber, column: loc.columnNumber }; }); breakpoints = this.adapter.sourceMapTransformer.setBreakpointsResponse(breakpoints, false, requestSeq); const response = { breakpoints }; this.adapter.lineColTransformer.setBreakpointsResponse(response); return response; } else { return { breakpoints: [] }; } } } return null; }); } /** * Transform breakpoint responses from the chrome-devtools target to the DAP response * @param url The URL of the script for which we are translating breakpoint responses * @param responses The setBreakpoint responses from the chrome-devtools target * @param requestBps The list of requested breakpoints pending a response * @param ids IDs passed in for previously unverified BPs */ targetBreakpointResponsesToBreakpointSetResults(url, responses, requestBps, ids) { // Don't cache errored responses const committedBps = responses .filter(response => response && response.breakpointId); // Cache successfully set breakpoint ids from chrome in committedBreakpoints set this.setValueForCommittedBreakpointsByUrl(url, committedBps); // Map committed breakpoints to DebugProtocol response breakpoints return responses .map((response, i) => { // The output list needs to be the same length as the input list, so map errors to // unverified breakpoints. if (!response) { return { isSet: false, breakpoint: { verified: false } }; } // response.breakpointId is undefined when no target BP is backing this BP, e.g. it's at the same location // as another BP const responseBpId = response.breakpointId || this.generateNextUnboundBreakpointId(); let bpId; if (ids && ids[i]) { // IDs passed in for previously unverified BPs bpId = ids[i]; this._breakpointIdHandles.set(bpId, responseBpId); } else { bpId = this._breakpointIdHandles.lookup(responseBpId) || this._breakpointIdHandles.create(responseBpId); } if (!response.actualLocation) { // If we don't have an actualLocation nor a breakpointId this is a pseudo-breakpoint because we are using break-on-load // so we mark the breakpoint as not set, so i'll be set after we load the actual script that has the breakpoint return { isSet: response.breakpointId !== undefined, breakpoint: { id: bpId, verified: false } }; } const thisBpRequest = requestBps[i]; if (thisBpRequest.hitCondition) { if (!this.addHitConditionBreakpoint(thisBpRequest, response)) { return { isSet: true, breakpoint: { id: bpId, message: localize(5, null, thisBpRequest.hitCondition), verified: false } }; } } return { isSet: true, breakpoint: { id: bpId, verified: true, line: response.actualLocation.lineNumber, column: response.actualLocation.columnNumber } }; }); } addHitConditionBreakpoint(requestBp, response) { const result = Breakpoints.HITCONDITION_MATCHER.exec(requestBp.hitCondition.trim()); if (result && result.length >= 3) { let op = result[1] || '>='; if (op === '=') op = '=='; const value = result[2]; const expr = op === '%' ? `return (numHits % ${value}) === 0;` : `return numHits ${op} ${value};`; // eval safe because of the regex, and this is only a string that the current user will type in /* tslint:disable:no-function-constructor-with-string-args */ const shouldPause = new Function('numHits', expr); /* tslint:enable:no-function-constructor-with-string-args */ this._hitConditionBreakpointsById.set(response.breakpointId, { numHits: 0, shouldPause }); return true; } else { return false; } } clearAllBreakpoints(url) { // We want to canonicalize this url because this._committedBreakpointsByUrl keeps url keys in canonicalized form url = utils.canonicalizeUrl(url); if (!this._committedBreakpointsByUrl.has(url)) { return Promise.resolve(); } // Remove breakpoints one at a time. Seems like it would be ok to send the removes all at once, // but there is a chrome bug where when removing 5+ or so breakpoints at once, it gets into a weird // state where later adds on the same line will fail with 'breakpoint already exists' even though it // does not break there. return this._committedBreakpointsByUrl.get(url).reduce((p, bp) => { return p.then(() => this.chrome.Debugger.removeBreakpoint({ breakpointId: bp.breakpointId })).then(() => { }); }, Promise.resolve()).then(() => { this._committedBreakpointsByUrl.delete(url); }); } onBreakpointResolved(params, scripts) { const script = scripts.getScriptById(params.location.scriptId); const breakpointId = this._breakpointIdHandles.lookup(params.breakpointId); if (!script || !breakpointId) { // Breakpoint resolved for a script we don't know about or a breakpoint we don't know about return; } // If the breakpoint resolved is a stopOnEntry breakpoint, we just return since we don't need to send it to client if (this.adapter.breakOnLoadActive && this.adapter.breakOnLoadHelper.stopOnEntryBreakpointIdToRequestedFileName.has(params.breakpointId)) { return; } // committed breakpoints (this._committedBreakpointsByUrl) should always have url keys in canonicalized form const committedBps = this.getValueFromCommittedBreakpointsByUrl(script.url) || []; if (!committedBps.find(committedBp => committedBp.breakpointId === params.breakpointId)) { committedBps.push({ breakpointId: params.breakpointId, actualLocation: params.location }); } this.setValueForCommittedBreakpointsByUrl(script.url, committedBps); const bp = { id: breakpointId, verified: true, line: params.location.lineNumber, column: params.location.columnNumber }; // need to canonicalize this path because the following maps use paths canonicalized const scriptPath = utils.canonicalizeUrl(this.adapter.pathTransformer.breakpointResolved(bp, script.url)); if (this._pendingBreakpointsByUrl.has(scriptPath)) { // If we set these BPs before the script was loaded, remove from the pending list this._pendingBreakpointsByUrl.delete(scriptPath); } this.adapter.sourceMapTransformer.breakpointResolved(bp, scriptPath); this.adapter.lineColTransformer.breakpointResolved(bp); this.adapter.session.sendEvent(new vscode_debugadapter_1.BreakpointEvent('changed', bp)); } generateNextUnboundBreakpointId() { const unboundBreakpointUniquePrefix = '__::[vscode_chrome_debug_adapter_unbound_breakpoint]::'; return `${unboundBreakpointUniquePrefix}${this._nextUnboundBreakpointId++}`; } unverifiedBpResponse(args, requestSeq, targetScriptUrl, message) { const breakpoints = args.breakpoints.map(bp => { return { verified: false, line: bp.line, column: bp.column, message, id: this._breakpointIdHandles.create(this.generateNextUnboundBreakpointId()) }; }); return this.unverifiedBpResponseForBreakpoints(args, requestSeq, targetScriptUrl, breakpoints, message); } unverifiedBpResponseForBreakpoints(args, requestSeq, targetScriptUrl, breakpoints, defaultMessage) { breakpoints.forEach(bp => { if (!bp.message) { bp.message = defaultMessage; } }); if (args.source.path) { const ids = breakpoints.map(bp => bp.id); // setWithPath: record whether we attempted to set the breakpoint, and if so, with which path. // We can use this to tell when the script is loaded whether we guessed correctly, and predict whether the BP will bind. this._pendingBreakpointsByUrl.set(utils.canonicalizeUrl(args.source.path), { args, ids, requestSeq, setWithPath: this.adapter.breakOnLoadActive ? '' : targetScriptUrl }); // Breakpoints need to be re-set when break-on-load is enabled } return { breakpoints }; } handleScriptParsed(script, scripts, mappedUrl, sources) { return __awaiter(this, void 0, void 0, function* () { if (sources) { const filteredSources = sources.filter(source => source !== mappedUrl); // Tools like babel-register will produce sources with the same path as the generated script for (const filteredSource of filteredSources) { yield this.resolvePendingBPs(filteredSource, scripts); } } if (utils.canonicalizeUrl(script.url) === mappedUrl && this._pendingBreakpointsByUrl.has(mappedUrl) && utils.canonicalizeUrl(this._pendingBreakpointsByUrl.get(mappedUrl).setWithPath) === utils.canonicalizeUrl(mappedUrl)) { // If the pathTransformer had no effect, and we attempted to set the BPs with that path earlier, then assume that they are about // to be resolved in this loaded script, and remove the pendingBP. this._pendingBreakpointsByUrl.delete(mappedUrl); } else { yield this.resolvePendingBPs(mappedUrl, scripts); } }); } resolvePendingBPs(source, scripts) { return __awaiter(this, void 0, void 0, function* () { source = source && utils.canonicalizeUrl(source); const pendingBP = this._pendingBreakpointsByUrl.get(source); if (pendingBP && (!pendingBP.setWithPath || utils.canonicalizeUrl(pendingBP.setWithPath) === source)) { vscode_debugadapter_1.logger.log(`OnScriptParsed.resolvePendingBPs: Resolving pending breakpoints: ${JSON.stringify(pendingBP)}`); yield this.resolvePendingBreakpoint(pendingBP, scripts); this._pendingBreakpointsByUrl.delete(source); } else if (source) { const sourceFileName = path.basename(source).toLowerCase(); if (Array.from(this._pendingBreakpointsByUrl.keys()).find(key => key.toLowerCase().indexOf(sourceFileName) > -1)) { vscode_debugadapter_1.logger.log(`OnScriptParsed.resolvePendingBPs: The following pending breakpoints won't be resolved: ${JSON.stringify(pendingBP)} pendingBreakpointsByUrl = ${JSON.stringify([...this._pendingBreakpointsByUrl])} source = ${source}`); } } }); } resolvePendingBreakpoint(pendingBP, scripts) { return this.setBreakpoints(pendingBP.args, scripts, pendingBP.requestSeq, pendingBP.ids).then(response => { response.breakpoints.forEach((bp, i) => { bp.id = pendingBP.ids[i]; this.adapter.session.sendEvent(new vscode_debugadapter_1.BreakpointEvent('changed', bp)); }); }); } handleHitCountBreakpoints(expectingStopReason, hitBreakpoints) { // Did we hit a hit condition breakpoint? for (let hitBp of hitBreakpoints) { if (this._hitConditionBreakpointsById.has(hitBp)) { // Increment the hit count and check whether to pause const hitConditionBp = this._hitConditionBreakpointsById.get(hitBp); hitConditionBp.numHits++; // Only resume if we didn't break for some user action (step, pause button) if (!expectingStopReason && !hitConditionBp.shouldPause(hitConditionBp.numHits)) { this.chrome.Debugger.resume() .catch(() => { }); return { didPause: false }; } } } return null; } } Breakpoints.SET_BREAKPOINTS_TIMEOUT = 5000; Breakpoints.HITCONDITION_MATCHER = /^(>|>=|=|<|<=|%)?\s*([0-9]+)$/; exports.Breakpoints = Breakpoints; //# sourceMappingURL=breakpoints.js.map