diff --git a/devtools/server/actors/breakpoint.js b/devtools/server/actors/breakpoint.js index 07ad3e54f7fb..a18bc31457be 100644 --- a/devtools/server/actors/breakpoint.js +++ b/devtools/server/actors/breakpoint.js @@ -8,6 +8,26 @@ const { ActorClass, method } = require("devtools/server/protocol"); +/** + * Set breakpoints on all the given entry points with the given + * BreakpointActor as the handler. + * + * @param BreakpointActor actor + * The actor handling the breakpoint hits. + * @param Array entryPoints + * An array of objects of the form `{ script, offsets }`. + */ +function setBreakpointAtEntryPoints(actor, entryPoints) { + for (let { script, offsets } of entryPoints) { + actor.addScript(script); + for (let offset of offsets) { + script.setBreakpoint(offset, actor); + } + } +} + +exports.setBreakpointAtEntryPoints = setBreakpointAtEntryPoints; + /** * BreakpointActors exist for the lifetime of their containing thread and are * responsible for deleting breakpoints, handling breakpoint hits and diff --git a/devtools/server/actors/moz.build b/devtools/server/actors/moz.build index aa2c7ed26f63..4d2d8ab9fbd0 100644 --- a/devtools/server/actors/moz.build +++ b/devtools/server/actors/moz.build @@ -49,6 +49,7 @@ DevToolsModules( 'root.js', 'script.js', 'settings.js', + 'source.js', 'storage.js', 'string.js', 'styleeditor.js', diff --git a/devtools/server/actors/script.js b/devtools/server/actors/script.js index 3dcd706fc230..c2fe9c6dcb20 100644 --- a/devtools/server/actors/script.js +++ b/devtools/server/actors/script.js @@ -9,14 +9,14 @@ const Services = require("Services"); const { Cc, Ci, Cu, Cr, components, ChromeWorker } = require("chrome"); const { ActorPool, OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); -const { BreakpointActor } = require("devtools/server/actors/breakpoint"); +const { BreakpointActor, setBreakpointAtEntryPoints } = require("devtools/server/actors/breakpoint"); const { EnvironmentActor } = require("devtools/server/actors/environment"); const { FrameActor } = require("devtools/server/actors/frame"); const { ObjectActor, createValueGrip, longStringGrip } = require("devtools/server/actors/object"); +const { SourceActor, getSourceURL } = require("devtools/server/actors/source"); const { DebuggerServer } = require("devtools/server/main"); const DevToolsUtils = require("devtools/shared/DevToolsUtils"); const { assert, dumpn, update, fetch } = DevToolsUtils; -const { dirname, joinURI } = require("devtools/shared/path"); const promise = require("promise"); const PromiseDebugging = require("PromiseDebugging"); const xpcInspector = require("xpcInspector"); @@ -30,8 +30,6 @@ loader.lazyGetter(this, "Debugger", () => { hackDebugger(Debugger); return Debugger; }); -loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); -loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true); loader.lazyRequireGetter(this, "CssLogic", "devtools/shared/inspector/css-logic", true); loader.lazyRequireGetter(this, "events", "sdk/event/core"); loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); @@ -2102,819 +2100,6 @@ PauseScopedActor.prototype = { } }; -/** - * Resolve a URI back to physical file. - * - * Of course, this works only for URIs pointing to local resources. - * - * @param aURI - * URI to resolve - * @return - * resolved nsIURI - */ -function resolveURIToLocalPath(aURI) { - let resolved; - switch (aURI.scheme) { - case "jar": - case "file": - return aURI; - - case "chrome": - resolved = Cc["@mozilla.org/chrome/chrome-registry;1"]. - getService(Ci.nsIChromeRegistry).convertChromeURL(aURI); - return resolveURIToLocalPath(resolved); - - case "resource": - resolved = Cc["@mozilla.org/network/protocol;1?name=resource"]. - getService(Ci.nsIResProtocolHandler).resolveURI(aURI); - aURI = Services.io.newURI(resolved, null, null); - return resolveURIToLocalPath(aURI); - - default: - return null; - } -} - -/** - * A SourceActor provides information about the source of a script. There - * are two kinds of source actors: ones that represent real source objects, - * and ones that represent non-existant "original" sources when the real - * sources are sourcemapped. When a source is sourcemapped, actors are - * created for both the "generated" and "original" sources, and the client will - * only see the original sources. We separate these because there isn't - * a 1:1 mapping of generated to original sources; one generated source - * may represent N original sources, so we need to create N + 1 separate - * actors. - * - * There are 4 different scenarios for sources that you should - * understand: - * - * - A single non-sourcemapped source that is not inlined in HTML - * (separate JS file, eval'ed code, etc) - * - A single sourcemapped source which creates N original sources - * - An HTML page with multiple inline scripts, which are distinct - * sources, but should be represented as a single source - * - A pretty-printed source (which may or may not be an original - * sourcemapped source), which generates a sourcemap for itself - * - * The complexity of `SourceActor` and `ThreadSources` are to handle - * all of thise cases and hopefully internalize the complexities. - * - * @param Debugger.Source source - * The source object we are representing. - * @param ThreadActor thread - * The current thread actor. - * @param String originalUrl - * Optional. For sourcemapped urls, the original url this is representing. - * @param Debugger.Source generatedSource - * Optional, passed in when aSourceMap is also passed in. The generated - * source object that introduced this source. - * @param String contentType - * Optional. The content type of this source, if immediately available. - */ -function SourceActor({ source, thread, originalUrl, generatedSource, - isInlineSource, contentType }) { - this._threadActor = thread; - this._originalUrl = originalUrl; - this._source = source; - this._generatedSource = generatedSource; - this._contentType = contentType; - this._isInlineSource = isInlineSource; - - this.onSource = this.onSource.bind(this); - this._invertSourceMap = this._invertSourceMap.bind(this); - this._encodeAndSetSourceMapURL = this._encodeAndSetSourceMapURL.bind(this); - this._getSourceText = this._getSourceText.bind(this); - - this._mapSourceToAddon(); - - if (this.threadActor.sources.isPrettyPrinted(this.url)) { - this._init = this.onPrettyPrint({ - indent: this.threadActor.sources.prettyPrintIndent(this.url) - }).then(null, error => { - DevToolsUtils.reportException("SourceActor", error); - }); - } else { - this._init = null; - } -} - -SourceActor.prototype = { - constructor: SourceActor, - actorPrefix: "source", - - _oldSourceMap: null, - _init: null, - _addonID: null, - _addonPath: null, - - get isSourceMapped() { - return !this.isInlineSource && ( - this._originalURL || this._generatedSource || - this.threadActor.sources.isPrettyPrinted(this.url) - ); - }, - - get isInlineSource() { - return this._isInlineSource; - }, - - get threadActor() { return this._threadActor; }, - get sources() { return this._threadActor.sources; }, - get dbg() { return this.threadActor.dbg; }, - get scripts() { return this.threadActor.scripts; }, - get source() { return this._source; }, - get generatedSource() { return this._generatedSource; }, - get breakpointActorMap() { return this.threadActor.breakpointActorMap; }, - get url() { - if (this.source) { - return getSourceURL(this.source, this.threadActor._parent.window); - } - return this._originalUrl; - }, - get addonID() { return this._addonID; }, - get addonPath() { return this._addonPath; }, - - get prettyPrintWorker() { - return this.threadActor.prettyPrintWorker; - }, - - form: function () { - let source = this.source || this.generatedSource; - // This might not have a source or a generatedSource because we - // treat HTML pages with inline scripts as a special SourceActor - // that doesn't have either - let introductionUrl = null; - if (source && source.introductionScript) { - introductionUrl = source.introductionScript.source.url; - } - - return { - actor: this.actorID, - url: this.url ? this.url.split(" -> ").pop() : null, - addonID: this._addonID, - addonPath: this._addonPath, - isBlackBoxed: this.threadActor.sources.isBlackBoxed(this.url), - isPrettyPrinted: this.threadActor.sources.isPrettyPrinted(this.url), - introductionUrl: introductionUrl ? introductionUrl.split(" -> ").pop() : null, - introductionType: source ? source.introductionType : null - }; - }, - - disconnect: function () { - if (this.registeredPool && this.registeredPool.sourceActors) { - delete this.registeredPool.sourceActors[this.actorID]; - } - }, - - _mapSourceToAddon: function() { - try { - var nsuri = Services.io.newURI(this.url.split(" -> ").pop(), null, null); - } - catch (e) { - // We can't do anything with an invalid URI - return; - } - - let localURI = resolveURIToLocalPath(nsuri); - - let id = {}; - if (localURI && mapURIToAddonID(localURI, id)) { - this._addonID = id.value; - - if (localURI instanceof Ci.nsIJARURI) { - // The path in the add-on is easy for jar: uris - this._addonPath = localURI.JAREntry; - } - else if (localURI instanceof Ci.nsIFileURL) { - // For file: uris walk up to find the last directory that is part of the - // add-on - let target = localURI.file; - let path = target.leafName; - - // We can assume that the directory containing the source file is part - // of the add-on - let root = target.parent; - let file = root.parent; - while (file && mapURIToAddonID(Services.io.newFileURI(file), {})) { - path = root.leafName + "/" + path; - root = file; - file = file.parent; - } - - if (!file) { - const error = new Error("Could not find the root of the add-on for " + this.url); - DevToolsUtils.reportException("SourceActor.prototype._mapSourceToAddon", error) - return; - } - - this._addonPath = path; - } - } - }, - - _reportLoadSourceError: function (error, map=null) { - try { - DevToolsUtils.reportException("SourceActor", error); - - JSON.stringify(this.form(), null, 4).split(/\n/g) - .forEach(line => console.error("\t", line)); - - if (!map) { - return; - } - - console.error("\t", "source map's sourceRoot =", map.sourceRoot); - - console.error("\t", "source map's sources ="); - map.sources.forEach(s => { - let hasSourceContent = map.sourceContentFor(s, true); - console.error("\t\t", s, "\t", - hasSourceContent ? "has source content" : "no source content"); - }); - - console.error("\t", "source map's sourcesContent ="); - map.sourcesContent.forEach(c => { - if (c.length > 80) { - c = c.slice(0, 77) + "..."; - } - c = c.replace(/\n/g, "\\n"); - console.error("\t\t", c); - }); - } catch (e) { } - }, - - _getSourceText: function () { - let toResolvedContent = t => ({ - content: t, - contentType: this._contentType - }); - - let genSource = this.generatedSource || this.source; - return this.threadActor.sources.fetchSourceMap(genSource).then(map => { - if (map) { - try { - let sourceContent = map.sourceContentFor(this.url); - if (sourceContent) { - return toResolvedContent(sourceContent); - } - } catch (error) { - this._reportLoadSourceError(error, map); - throw error; - } - } - - // Use `source.text` if it exists, is not the "no source" - // string, and the content type of the source is JavaScript. It - // will be "no source" if the Debugger API wasn't able to load - // the source because sources were discarded - // (javascript.options.discardSystemSource == true). Re-fetch - // non-JS sources to get the contentType from the headers. - if (this.source && - this.source.text !== "[no source]" && - this._contentType && - this._contentType.indexOf('javascript') !== -1) { - return toResolvedContent(this.source.text); - } - else { - // Only load the HTML page source from cache (which exists when - // there are inline sources). Otherwise, we can't trust the - // cache because we are most likely here because we are - // fetching the original text for sourcemapped code, and the - // page hasn't requested it before (if it has, it was a - // previous debugging session). - let sourceFetched = fetch(this.url, { loadFromCache: this.isInlineSource }); - - // Record the contentType we just learned during fetching - return sourceFetched - .then(result => { - this._contentType = result.contentType; - return result; - }, error => { - this._reportLoadSourceError(error, map); - throw error; - }); - } - }); - }, - - /** - * Get all executable lines from the current source - * @return Array - Executable lines of the current script - **/ - getExecutableLines: function () { - // Check if the original source is source mapped - let packet = { - from: this.actorID - }; - - function sortLines(lines) { - // Converting the Set into an array - lines = [...lines]; - lines.sort((a, b) => { - return a - b; - }); - return lines; - } - - if (this.generatedSource) { - return this.threadActor.sources.getSourceMap(this.generatedSource).then(sm => { - let lines = new Set(); - - // Position of executable lines in the generated source - let offsets = this.getExecutableOffsets(this.generatedSource, false); - for (let offset of offsets) { - let {line, source: sourceUrl} = sm.originalPositionFor({ - line: offset.lineNumber, - column: offset.columnNumber - }); - - if (sourceUrl === this.url) { - lines.add(line); - } - } - - packet.lines = sortLines(lines); - return packet; - }); - } - - let lines = this.getExecutableOffsets(this.source, true); - packet.lines = sortLines(lines); - return packet; - }, - - /** - * Extract all executable offsets from the given script - * @param String url - extract offsets of the script with this url - * @param Boolean onlyLine - will return only the line number - * @return Set - Executable offsets/lines of the script - **/ - getExecutableOffsets: function (source, onlyLine) { - let offsets = new Set(); - for (let s of this.threadActor.scripts.getScriptsBySource(source)) { - for (let offset of s.getAllColumnOffsets()) { - offsets.add(onlyLine ? offset.lineNumber : offset); - } - } - - return offsets; - }, - - /** - * Handler for the "source" packet. - */ - onSource: function () { - return resolve(this._init) - .then(this._getSourceText) - .then(({ content, contentType }) => { - return { - from: this.actorID, - source: createValueGrip(content, this.threadActor.threadLifetimePool, - this.threadActor.objectGrip), - contentType: contentType - }; - }) - .then(null, aError => { - reportError(aError, "Got an exception during SA_onSource: "); - return { - "from": this.actorID, - "error": this.url, - "message": "Could not load the source for " + this.url + ".\n" - + DevToolsUtils.safeErrorString(aError) - }; - }); - }, - - /** - * Handler for the "prettyPrint" packet. - */ - onPrettyPrint: function ({ indent }) { - this.threadActor.sources.prettyPrint(this.url, indent); - return this._getSourceText() - .then(this._sendToPrettyPrintWorker(indent)) - .then(this._invertSourceMap) - .then(this._encodeAndSetSourceMapURL) - .then(() => { - // We need to reset `_init` now because we have already done the work of - // pretty printing, and don't want onSource to wait forever for - // initialization to complete. - this._init = null; - }) - .then(this.onSource) - .then(null, error => { - this.onDisablePrettyPrint(); - return { - from: this.actorID, - error: "prettyPrintError", - message: DevToolsUtils.safeErrorString(error) - }; - }); - }, - - /** - * Return a function that sends a request to the pretty print worker, waits on - * the worker's response, and then returns the pretty printed code. - * - * @param Number aIndent - * The number of spaces to indent by the code by, when we send the - * request to the pretty print worker. - * @returns Function - * Returns a function which takes an AST, and returns a promise that - * is resolved with `{ code, mappings }` where `code` is the pretty - * printed code, and `mappings` is an array of source mappings. - */ - _sendToPrettyPrintWorker: function (aIndent) { - return ({ content }) => { - return this.prettyPrintWorker.performTask("pretty-print", { - url: this.url, - indent: aIndent, - source: content - }) - }; - }, - - /** - * Invert a source map. So if a source map maps from a to b, return a new - * source map from b to a. We need to do this because the source map we get - * from _generatePrettyCodeAndMap goes the opposite way we want it to for - * debugging. - * - * Note that the source map is modified in place. - */ - _invertSourceMap: function ({ code, mappings }) { - const generator = new SourceMapGenerator({ file: this.url }); - return DevToolsUtils.yieldingEach(mappings._array, m => { - let mapping = { - generated: { - line: m.originalLine, - column: m.originalColumn - } - }; - if (m.source) { - mapping.source = m.source; - mapping.original = { - line: m.generatedLine, - column: m.generatedColumn - }; - mapping.name = m.name; - } - generator.addMapping(mapping); - }).then(() => { - generator.setSourceContent(this.url, code); - let consumer = SourceMapConsumer.fromSourceMap(generator); - - return { - code: code, - map: consumer - }; - }); - }, - - /** - * Save the source map back to our thread's ThreadSources object so that - * stepping, breakpoints, debugger statements, etc can use it. If we are - * pretty printing a source mapped source, we need to compose the existing - * source map with our new one. - */ - _encodeAndSetSourceMapURL: function ({ map: sm }) { - let source = this.generatedSource || this.source; - let sources = this.threadActor.sources; - - return sources.getSourceMap(source).then(prevMap => { - if (prevMap) { - // Compose the source maps - this._oldSourceMapping = { - url: source.sourceMapURL, - map: prevMap - }; - - prevMap = SourceMapGenerator.fromSourceMap(prevMap); - prevMap.applySourceMap(sm, this.url); - sm = SourceMapConsumer.fromSourceMap(prevMap); - } - - let sources = this.threadActor.sources; - sources.clearSourceMapCache(source.sourceMapURL); - sources.setSourceMapHard(source, null, sm); - }); - }, - - /** - * Handler for the "disablePrettyPrint" packet. - */ - onDisablePrettyPrint: function () { - let source = this.generatedSource || this.source; - let sources = this.threadActor.sources; - let sm = sources.getSourceMap(source); - - sources.clearSourceMapCache(source.sourceMapURL, { hard: true }); - - if (this._oldSourceMapping) { - sources.setSourceMapHard(source, - this._oldSourceMapping.url, - this._oldSourceMapping.map); - this._oldSourceMapping = null; - } - - this.threadActor.sources.disablePrettyPrint(this.url); - return this.onSource(); - }, - - /** - * Handler for the "blackbox" packet. - */ - onBlackBox: function (aRequest) { - this.threadActor.sources.blackBox(this.url); - let packet = { - from: this.actorID - }; - if (this.threadActor.state == "paused" - && this.threadActor.youngestFrame - && this.threadActor.youngestFrame.script.url == this.url) { - packet.pausedInSource = true; - } - return packet; - }, - - /** - * Handler for the "unblackbox" packet. - */ - onUnblackBox: function (aRequest) { - this.threadActor.sources.unblackBox(this.url); - return { - from: this.actorID - }; - }, - - /** - * Handle a request to set a breakpoint. - * - * @param JSON request - * A JSON object representing the request. - * - * @returns Promise - * A promise that resolves to a JSON object representing the - * response. - */ - onSetBreakpoint: function (request) { - if (this.threadActor.state !== "paused") { - return { - error: "wrongState", - message: "Cannot set breakpoint while debuggee is running." - }; - } - - let { location: { line, column }, condition } = request; - let location = new OriginalLocation(this, line, column); - return this._getOrCreateBreakpointActor( - location, - condition - ).then((actor) => { - let response = { - actor: actor.actorID, - isPending: actor.isPending - }; - - let actualLocation = actor.originalLocation; - if (!actualLocation.equals(location)) { - response.actualLocation = actualLocation.toJSON(); - } - - return response; - }); - }, - - /** - * Get or create a BreakpointActor for the given location in the original - * source, and ensure it is set as a breakpoint handler on all scripts that - * match the given location. - * - * @param OriginalLocation originalLocation - * An OriginalLocation representing the location of the breakpoint in - * the original source. - * @param String condition - * A string that is evaluated whenever the breakpoint is hit. If the - * string evaluates to false, the breakpoint is ignored. - * - * @returns BreakpointActor - * A BreakpointActor representing the breakpoint. - */ - _getOrCreateBreakpointActor: function (originalLocation, condition) { - let actor = this.breakpointActorMap.getActor(originalLocation); - if (!actor) { - actor = new BreakpointActor(this.threadActor, originalLocation); - this.threadActor.threadLifetimePool.addActor(actor); - this.breakpointActorMap.setActor(originalLocation, actor); - } - - actor.condition = condition; - - return this._setBreakpoint(actor); - }, - - /* - * Ensure the given BreakpointActor is set as a breakpoint handler on all - * scripts that match its location in the original source. - * - * If there are no scripts that match the location of the BreakpointActor, - * we slide its location to the next closest line (for line breakpoints) or - * column (for column breakpoint) that does. - * - * If breakpoint sliding fails, then either there are no scripts that contain - * any code for the given location, or they were all garbage collected before - * the debugger started running. We cannot distinguish between these two - * cases, so we insert the BreakpointActor in the BreakpointActorMap as - * a pending breakpoint. Whenever a new script is introduced, this method is - * called again for each pending breakpoint. - * - * @param BreakpointActor actor - * The BreakpointActor to be set as a breakpoint handler. - * - * @returns A Promise that resolves to the given BreakpointActor. - */ - _setBreakpoint: function (actor) { - const { originalLocation } = actor; - const { originalLine, originalSourceActor } = originalLocation; - - if (!this.isSourceMapped) { - if (!this._setBreakpointAtGeneratedLocation( - actor, - GeneratedLocation.fromOriginalLocation(originalLocation) - )) { - const scripts = this.scripts.getScriptsBySourceActorAndLine( - this, - originalLine - ); - - // Never do breakpoint sliding for column breakpoints. - // Additionally, never do breakpoint sliding if no scripts - // exist on this line. - // - // Sliding can go horribly wrong if we always try to find the - // next line with valid entry points in the entire file. - // Scripts may be completely GCed and we never knew they - // existed, so we end up sliding through whole functions to - // the user's bewilderment. - // - // We can slide reliably if any scripts exist, however, due - // to how scripts are kept alive. A parent Debugger.Script - // keeps all of its children alive, so as long as we have a - // valid script, we can slide through it and know we won't - // slide through any of its child scripts. Additionally, if a - // script gets GCed, that means that all parents scripts are - // GCed as well, and no scripts will exist on those lines - // anymore. We will never slide through a GCed script. - if (originalLocation.originalColumn || scripts.length === 0) { - return promise.resolve(actor); - } - - // Find the script that spans the largest amount of code to - // determine the bounds for sliding. - const largestScript = scripts.reduce((largestScript, script) => { - if (script.lineCount > largestScript.lineCount) { - return script; - } - return largestScript; - }); - const maxLine = largestScript.startLine + largestScript.lineCount - 1; - - let actualLine = originalLine; - for (; actualLine <= maxLine; actualLine++) { - const loc = new GeneratedLocation(this, actualLine); - if (this._setBreakpointAtGeneratedLocation(actor, loc)) { - break; - } - } - - // The above loop should never complete. We only did breakpoint sliding - // because we found scripts on the line we started from, - // which means there must be valid entry points somewhere - // within those scripts. - assert( - actualLine <= maxLine, - "Could not find any entry points to set a breakpoint on, " + - "even though I was told a script existed on the line I started " + - "the search with." - ); - - // Update the actor to use the new location (reusing a - // previous breakpoint if it already exists on that line). - const actualLocation = new OriginalLocation(originalSourceActor, actualLine); - const existingActor = this.breakpointActorMap.getActor(actualLocation); - this.breakpointActorMap.deleteActor(originalLocation); - if (existingActor) { - actor.delete(); - actor = existingActor; - } else { - actor.originalLocation = actualLocation; - this.breakpointActorMap.setActor(actualLocation, actor); - } - } - - return promise.resolve(actor); - } else { - return this.sources.getAllGeneratedLocations(originalLocation).then((generatedLocations) => { - this._setBreakpointAtAllGeneratedLocations( - actor, - generatedLocations - ); - - return actor; - }); - } - }, - - _setBreakpointAtAllGeneratedLocations: function (actor, generatedLocations) { - let success = false; - for (let generatedLocation of generatedLocations) { - if (this._setBreakpointAtGeneratedLocation( - actor, - generatedLocation - )) { - success = true; - } - } - return success; - }, - - /* - * Ensure the given BreakpointActor is set as breakpoint handler on all - * scripts that match the given location in the generated source. - * - * @param BreakpointActor actor - * The BreakpointActor to be set as a breakpoint handler. - * @param GeneratedLocation generatedLocation - * A GeneratedLocation representing the location in the generated - * source for which the given BreakpointActor is to be set as a - * breakpoint handler. - * - * @returns A Boolean that is true if the BreakpointActor was set as a - * breakpoint handler on at least one script, and false otherwise. - */ - _setBreakpointAtGeneratedLocation: function (actor, generatedLocation) { - let { - generatedSourceActor, - generatedLine, - generatedColumn, - generatedLastColumn - } = generatedLocation; - - // Find all scripts that match the given source actor and line number. - let scripts = this.scripts.getScriptsBySourceActorAndLine( - generatedSourceActor, - generatedLine - ); - - scripts = scripts.filter((script) => !actor.hasScript(script)); - - // Find all entry points that correspond to the given location. - let entryPoints = []; - if (generatedColumn === undefined) { - // This is a line breakpoint, so we are interested in all offsets - // that correspond to the given line number. - for (let script of scripts) { - let offsets = script.getLineOffsets(generatedLine); - if (offsets.length > 0) { - entryPoints.push({ script, offsets }); - } - } - } else { - // This is a column breakpoint, so we are interested in all column - // offsets that correspond to the given line *and* column number. - for (let script of scripts) { - let columnToOffsetMap = script.getAllColumnOffsets() - .filter(({ lineNumber }) => { - return lineNumber === generatedLine; - }); - for (let { columnNumber: column, offset } of columnToOffsetMap) { - if (column >= generatedColumn && column <= generatedLastColumn) { - entryPoints.push({ script, offsets: [offset] }); - } - } - } - } - - if (entryPoints.length === 0) { - return false; - } - setBreakpointAtEntryPoints(actor, entryPoints); - return true; - } -}; - -SourceActor.prototype.requestTypes = { - "source": SourceActor.prototype.onSource, - "blackbox": SourceActor.prototype.onBlackBox, - "unblackbox": SourceActor.prototype.onUnblackBox, - "prettyPrint": SourceActor.prototype.onPrettyPrint, - "disablePrettyPrint": SourceActor.prototype.onDisablePrettyPrint, - "getExecutableLines": SourceActor.prototype.getExecutableLines, - "setBreakpoint": SourceActor.prototype.onSetBreakpoint -}; - -exports.SourceActor = SourceActor; - /** * Creates a pause-scoped actor for the specified object. * @see ObjectActor @@ -3107,51 +2292,6 @@ reportError = function(aError, aPrefix="") { dumpn(msg); } -function isEvalSource(source) { - let introType = source.introductionType; - // These are all the sources that are essentially eval-ed (either - // by calling eval or passing a string to one of these functions). - return (introType === 'eval' || - introType === 'Function' || - introType === 'eventHandler' || - introType === 'setTimeout' || - introType === 'setInterval'); -} -exports.isEvalSource = isEvalSource; - -function getSourceURL(source, window) { - if (isEvalSource(source)) { - // Eval sources have no urls, but they might have a `displayURL` - // created with the sourceURL pragma. If the introduction script - // is a non-eval script, generate an full absolute URL relative to it. - - if (source.displayURL && source.introductionScript && - !isEvalSource(source.introductionScript.source)) { - - if (source.introductionScript.source.url === 'debugger eval code') { - if (window) { - // If this is a named eval script created from the console, make it - // relative to the current page. window is only available - // when we care about this. - return joinURI(window.location.href, source.displayURL); - } - } - else { - return joinURI(dirname(source.introductionScript.source.url), - source.displayURL); - } - } - - return source.displayURL; - } - else if (source.url === 'debugger eval code') { - // Treat code evaluated by the console as unnamed eval scripts - return null; - } - return source.url; -} -exports.getSourceURL = getSourceURL; - /** * Find the scripts which contain offsets that are an entry point to the given * line. @@ -3176,24 +2316,6 @@ function findEntryPointsForLine(scripts, line) { return entryPoints; } -/** - * Set breakpoints on all the given entry points with the given - * BreakpointActor as the handler. - * - * @param BreakpointActor actor - * The actor handling the breakpoint hits. - * @param Array entryPoints - * An array of objects of the form `{ script, offsets }`. - */ -function setBreakpointAtEntryPoints(actor, entryPoints) { - for (let { script, offsets } of entryPoints) { - actor.addScript(script); - for (let offset of offsets) { - script.setBreakpoint(offset, actor); - } - } -} - /** * Unwrap a global that is wrapped in a |Debugger.Object|, or if the global has * become a dead object, return |undefined|. diff --git a/devtools/server/actors/source.js b/devtools/server/actors/source.js new file mode 100644 index 000000000000..af382f84a689 --- /dev/null +++ b/devtools/server/actors/source.js @@ -0,0 +1,882 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Cc, Ci } = require("chrome"); +const { BreakpointActor, setBreakpointAtEntryPoints } = require("devtools/server/actors/breakpoint"); +const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { createValueGrip } = require("devtools/server/actors/object"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, fetch } = DevToolsUtils; +const { dirname, joinURI } = require("devtools/shared/path"); +const promise = require("promise"); +const { defer, resolve, reject, all } = promise; + +loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); +loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true); +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); + +function isEvalSource(source) { + let introType = source.introductionType; + // These are all the sources that are essentially eval-ed (either + // by calling eval or passing a string to one of these functions). + return (introType === 'eval' || + introType === 'Function' || + introType === 'eventHandler' || + introType === 'setTimeout' || + introType === 'setInterval'); +} + +exports.isEvalSource = isEvalSource; + +function getSourceURL(source, window) { + if (isEvalSource(source)) { + // Eval sources have no urls, but they might have a `displayURL` + // created with the sourceURL pragma. If the introduction script + // is a non-eval script, generate an full absolute URL relative to it. + + if (source.displayURL && source.introductionScript && + !isEvalSource(source.introductionScript.source)) { + + if (source.introductionScript.source.url === 'debugger eval code') { + if (window) { + // If this is a named eval script created from the console, make it + // relative to the current page. window is only available + // when we care about this. + return joinURI(window.location.href, source.displayURL); + } + } + else { + return joinURI(dirname(source.introductionScript.source.url), + source.displayURL); + } + } + + return source.displayURL; + } + else if (source.url === 'debugger eval code') { + // Treat code evaluated by the console as unnamed eval scripts + return null; + } + return source.url; +} + +exports.getSourceURL = getSourceURL; + +/** + * Resolve a URI back to physical file. + * + * Of course, this works only for URIs pointing to local resources. + * + * @param aURI + * URI to resolve + * @return + * resolved nsIURI + */ +function resolveURIToLocalPath(aURI) { + let resolved; + switch (aURI.scheme) { + case "jar": + case "file": + return aURI; + + case "chrome": + resolved = Cc["@mozilla.org/chrome/chrome-registry;1"]. + getService(Ci.nsIChromeRegistry).convertChromeURL(aURI); + return resolveURIToLocalPath(resolved); + + case "resource": + resolved = Cc["@mozilla.org/network/protocol;1?name=resource"]. + getService(Ci.nsIResProtocolHandler).resolveURI(aURI); + aURI = Services.io.newURI(resolved, null, null); + return resolveURIToLocalPath(aURI); + + default: + return null; + } +} + +/** + * A SourceActor provides information about the source of a script. There + * are two kinds of source actors: ones that represent real source objects, + * and ones that represent non-existant "original" sources when the real + * sources are sourcemapped. When a source is sourcemapped, actors are + * created for both the "generated" and "original" sources, and the client will + * only see the original sources. We separate these because there isn't + * a 1:1 mapping of generated to original sources; one generated source + * may represent N original sources, so we need to create N + 1 separate + * actors. + * + * There are 4 different scenarios for sources that you should + * understand: + * + * - A single non-sourcemapped source that is not inlined in HTML + * (separate JS file, eval'ed code, etc) + * - A single sourcemapped source which creates N original sources + * - An HTML page with multiple inline scripts, which are distinct + * sources, but should be represented as a single source + * - A pretty-printed source (which may or may not be an original + * sourcemapped source), which generates a sourcemap for itself + * + * The complexity of `SourceActor` and `ThreadSources` are to handle + * all of thise cases and hopefully internalize the complexities. + * + * @param Debugger.Source source + * The source object we are representing. + * @param ThreadActor thread + * The current thread actor. + * @param String originalUrl + * Optional. For sourcemapped urls, the original url this is representing. + * @param Debugger.Source generatedSource + * Optional, passed in when aSourceMap is also passed in. The generated + * source object that introduced this source. + * @param String contentType + * Optional. The content type of this source, if immediately available. + */ +function SourceActor({ source, thread, originalUrl, generatedSource, + isInlineSource, contentType }) { + this._threadActor = thread; + this._originalUrl = originalUrl; + this._source = source; + this._generatedSource = generatedSource; + this._contentType = contentType; + this._isInlineSource = isInlineSource; + + this.onSource = this.onSource.bind(this); + this._invertSourceMap = this._invertSourceMap.bind(this); + this._encodeAndSetSourceMapURL = this._encodeAndSetSourceMapURL.bind(this); + this._getSourceText = this._getSourceText.bind(this); + + this._mapSourceToAddon(); + + if (this.threadActor.sources.isPrettyPrinted(this.url)) { + this._init = this.onPrettyPrint({ + indent: this.threadActor.sources.prettyPrintIndent(this.url) + }).then(null, error => { + DevToolsUtils.reportException("SourceActor", error); + }); + } else { + this._init = null; + } +} + +SourceActor.prototype = { + constructor: SourceActor, + actorPrefix: "source", + + _oldSourceMap: null, + _init: null, + _addonID: null, + _addonPath: null, + + get isSourceMapped() { + return !this.isInlineSource && ( + this._originalURL || this._generatedSource || + this.threadActor.sources.isPrettyPrinted(this.url) + ); + }, + + get isInlineSource() { + return this._isInlineSource; + }, + + get threadActor() { return this._threadActor; }, + get sources() { return this._threadActor.sources; }, + get dbg() { return this.threadActor.dbg; }, + get scripts() { return this.threadActor.scripts; }, + get source() { return this._source; }, + get generatedSource() { return this._generatedSource; }, + get breakpointActorMap() { return this.threadActor.breakpointActorMap; }, + get url() { + if (this.source) { + return getSourceURL(this.source, this.threadActor._parent.window); + } + return this._originalUrl; + }, + get addonID() { return this._addonID; }, + get addonPath() { return this._addonPath; }, + + get prettyPrintWorker() { + return this.threadActor.prettyPrintWorker; + }, + + form: function () { + let source = this.source || this.generatedSource; + // This might not have a source or a generatedSource because we + // treat HTML pages with inline scripts as a special SourceActor + // that doesn't have either + let introductionUrl = null; + if (source && source.introductionScript) { + introductionUrl = source.introductionScript.source.url; + } + + return { + actor: this.actorID, + url: this.url ? this.url.split(" -> ").pop() : null, + addonID: this._addonID, + addonPath: this._addonPath, + isBlackBoxed: this.threadActor.sources.isBlackBoxed(this.url), + isPrettyPrinted: this.threadActor.sources.isPrettyPrinted(this.url), + introductionUrl: introductionUrl ? introductionUrl.split(" -> ").pop() : null, + introductionType: source ? source.introductionType : null + }; + }, + + disconnect: function () { + if (this.registeredPool && this.registeredPool.sourceActors) { + delete this.registeredPool.sourceActors[this.actorID]; + } + }, + + _mapSourceToAddon: function() { + try { + var nsuri = Services.io.newURI(this.url.split(" -> ").pop(), null, null); + } + catch (e) { + // We can't do anything with an invalid URI + return; + } + + let localURI = resolveURIToLocalPath(nsuri); + + let id = {}; + if (localURI && mapURIToAddonID(localURI, id)) { + this._addonID = id.value; + + if (localURI instanceof Ci.nsIJARURI) { + // The path in the add-on is easy for jar: uris + this._addonPath = localURI.JAREntry; + } + else if (localURI instanceof Ci.nsIFileURL) { + // For file: uris walk up to find the last directory that is part of the + // add-on + let target = localURI.file; + let path = target.leafName; + + // We can assume that the directory containing the source file is part + // of the add-on + let root = target.parent; + let file = root.parent; + while (file && mapURIToAddonID(Services.io.newFileURI(file), {})) { + path = root.leafName + "/" + path; + root = file; + file = file.parent; + } + + if (!file) { + const error = new Error("Could not find the root of the add-on for " + this.url); + DevToolsUtils.reportException("SourceActor.prototype._mapSourceToAddon", error) + return; + } + + this._addonPath = path; + } + } + }, + + _reportLoadSourceError: function (error, map=null) { + try { + DevToolsUtils.reportException("SourceActor", error); + + JSON.stringify(this.form(), null, 4).split(/\n/g) + .forEach(line => console.error("\t", line)); + + if (!map) { + return; + } + + console.error("\t", "source map's sourceRoot =", map.sourceRoot); + + console.error("\t", "source map's sources ="); + map.sources.forEach(s => { + let hasSourceContent = map.sourceContentFor(s, true); + console.error("\t\t", s, "\t", + hasSourceContent ? "has source content" : "no source content"); + }); + + console.error("\t", "source map's sourcesContent ="); + map.sourcesContent.forEach(c => { + if (c.length > 80) { + c = c.slice(0, 77) + "..."; + } + c = c.replace(/\n/g, "\\n"); + console.error("\t\t", c); + }); + } catch (e) { } + }, + + _getSourceText: function () { + let toResolvedContent = t => ({ + content: t, + contentType: this._contentType + }); + + let genSource = this.generatedSource || this.source; + return this.threadActor.sources.fetchSourceMap(genSource).then(map => { + if (map) { + try { + let sourceContent = map.sourceContentFor(this.url); + if (sourceContent) { + return toResolvedContent(sourceContent); + } + } catch (error) { + this._reportLoadSourceError(error, map); + throw error; + } + } + + // Use `source.text` if it exists, is not the "no source" + // string, and the content type of the source is JavaScript. It + // will be "no source" if the Debugger API wasn't able to load + // the source because sources were discarded + // (javascript.options.discardSystemSource == true). Re-fetch + // non-JS sources to get the contentType from the headers. + if (this.source && + this.source.text !== "[no source]" && + this._contentType && + this._contentType.indexOf('javascript') !== -1) { + return toResolvedContent(this.source.text); + } + else { + // Only load the HTML page source from cache (which exists when + // there are inline sources). Otherwise, we can't trust the + // cache because we are most likely here because we are + // fetching the original text for sourcemapped code, and the + // page hasn't requested it before (if it has, it was a + // previous debugging session). + let sourceFetched = fetch(this.url, { loadFromCache: this.isInlineSource }); + + // Record the contentType we just learned during fetching + return sourceFetched + .then(result => { + this._contentType = result.contentType; + return result; + }, error => { + this._reportLoadSourceError(error, map); + throw error; + }); + } + }); + }, + + /** + * Get all executable lines from the current source + * @return Array - Executable lines of the current script + **/ + getExecutableLines: function () { + // Check if the original source is source mapped + let packet = { + from: this.actorID + }; + + function sortLines(lines) { + // Converting the Set into an array + lines = [...lines]; + lines.sort((a, b) => { + return a - b; + }); + return lines; + } + + if (this.generatedSource) { + return this.threadActor.sources.getSourceMap(this.generatedSource).then(sm => { + let lines = new Set(); + + // Position of executable lines in the generated source + let offsets = this.getExecutableOffsets(this.generatedSource, false); + for (let offset of offsets) { + let {line, source: sourceUrl} = sm.originalPositionFor({ + line: offset.lineNumber, + column: offset.columnNumber + }); + + if (sourceUrl === this.url) { + lines.add(line); + } + } + + packet.lines = sortLines(lines); + return packet; + }); + } + + let lines = this.getExecutableOffsets(this.source, true); + packet.lines = sortLines(lines); + return packet; + }, + + /** + * Extract all executable offsets from the given script + * @param String url - extract offsets of the script with this url + * @param Boolean onlyLine - will return only the line number + * @return Set - Executable offsets/lines of the script + **/ + getExecutableOffsets: function (source, onlyLine) { + let offsets = new Set(); + for (let s of this.threadActor.scripts.getScriptsBySource(source)) { + for (let offset of s.getAllColumnOffsets()) { + offsets.add(onlyLine ? offset.lineNumber : offset); + } + } + + return offsets; + }, + + /** + * Handler for the "source" packet. + */ + onSource: function () { + return resolve(this._init) + .then(this._getSourceText) + .then(({ content, contentType }) => { + return { + from: this.actorID, + source: createValueGrip(content, this.threadActor.threadLifetimePool, + this.threadActor.objectGrip), + contentType: contentType + }; + }) + .then(null, aError => { + reportError(aError, "Got an exception during SA_onSource: "); + return { + "from": this.actorID, + "error": this.url, + "message": "Could not load the source for " + this.url + ".\n" + + DevToolsUtils.safeErrorString(aError) + }; + }); + }, + + /** + * Handler for the "prettyPrint" packet. + */ + onPrettyPrint: function ({ indent }) { + this.threadActor.sources.prettyPrint(this.url, indent); + return this._getSourceText() + .then(this._sendToPrettyPrintWorker(indent)) + .then(this._invertSourceMap) + .then(this._encodeAndSetSourceMapURL) + .then(() => { + // We need to reset `_init` now because we have already done the work of + // pretty printing, and don't want onSource to wait forever for + // initialization to complete. + this._init = null; + }) + .then(this.onSource) + .then(null, error => { + this.onDisablePrettyPrint(); + return { + from: this.actorID, + error: "prettyPrintError", + message: DevToolsUtils.safeErrorString(error) + }; + }); + }, + + /** + * Return a function that sends a request to the pretty print worker, waits on + * the worker's response, and then returns the pretty printed code. + * + * @param Number aIndent + * The number of spaces to indent by the code by, when we send the + * request to the pretty print worker. + * @returns Function + * Returns a function which takes an AST, and returns a promise that + * is resolved with `{ code, mappings }` where `code` is the pretty + * printed code, and `mappings` is an array of source mappings. + */ + _sendToPrettyPrintWorker: function (aIndent) { + return ({ content }) => { + return this.prettyPrintWorker.performTask("pretty-print", { + url: this.url, + indent: aIndent, + source: content + }) + }; + }, + + /** + * Invert a source map. So if a source map maps from a to b, return a new + * source map from b to a. We need to do this because the source map we get + * from _generatePrettyCodeAndMap goes the opposite way we want it to for + * debugging. + * + * Note that the source map is modified in place. + */ + _invertSourceMap: function ({ code, mappings }) { + const generator = new SourceMapGenerator({ file: this.url }); + return DevToolsUtils.yieldingEach(mappings._array, m => { + let mapping = { + generated: { + line: m.originalLine, + column: m.originalColumn + } + }; + if (m.source) { + mapping.source = m.source; + mapping.original = { + line: m.generatedLine, + column: m.generatedColumn + }; + mapping.name = m.name; + } + generator.addMapping(mapping); + }).then(() => { + generator.setSourceContent(this.url, code); + let consumer = SourceMapConsumer.fromSourceMap(generator); + + return { + code: code, + map: consumer + }; + }); + }, + + /** + * Save the source map back to our thread's ThreadSources object so that + * stepping, breakpoints, debugger statements, etc can use it. If we are + * pretty printing a source mapped source, we need to compose the existing + * source map with our new one. + */ + _encodeAndSetSourceMapURL: function ({ map: sm }) { + let source = this.generatedSource || this.source; + let sources = this.threadActor.sources; + + return sources.getSourceMap(source).then(prevMap => { + if (prevMap) { + // Compose the source maps + this._oldSourceMapping = { + url: source.sourceMapURL, + map: prevMap + }; + + prevMap = SourceMapGenerator.fromSourceMap(prevMap); + prevMap.applySourceMap(sm, this.url); + sm = SourceMapConsumer.fromSourceMap(prevMap); + } + + let sources = this.threadActor.sources; + sources.clearSourceMapCache(source.sourceMapURL); + sources.setSourceMapHard(source, null, sm); + }); + }, + + /** + * Handler for the "disablePrettyPrint" packet. + */ + onDisablePrettyPrint: function () { + let source = this.generatedSource || this.source; + let sources = this.threadActor.sources; + let sm = sources.getSourceMap(source); + + sources.clearSourceMapCache(source.sourceMapURL, { hard: true }); + + if (this._oldSourceMapping) { + sources.setSourceMapHard(source, + this._oldSourceMapping.url, + this._oldSourceMapping.map); + this._oldSourceMapping = null; + } + + this.threadActor.sources.disablePrettyPrint(this.url); + return this.onSource(); + }, + + /** + * Handler for the "blackbox" packet. + */ + onBlackBox: function (aRequest) { + this.threadActor.sources.blackBox(this.url); + let packet = { + from: this.actorID + }; + if (this.threadActor.state == "paused" + && this.threadActor.youngestFrame + && this.threadActor.youngestFrame.script.url == this.url) { + packet.pausedInSource = true; + } + return packet; + }, + + /** + * Handler for the "unblackbox" packet. + */ + onUnblackBox: function (aRequest) { + this.threadActor.sources.unblackBox(this.url); + return { + from: this.actorID + }; + }, + + /** + * Handle a request to set a breakpoint. + * + * @param JSON request + * A JSON object representing the request. + * + * @returns Promise + * A promise that resolves to a JSON object representing the + * response. + */ + onSetBreakpoint: function (request) { + if (this.threadActor.state !== "paused") { + return { + error: "wrongState", + message: "Cannot set breakpoint while debuggee is running." + }; + } + + let { location: { line, column }, condition } = request; + let location = new OriginalLocation(this, line, column); + return this._getOrCreateBreakpointActor( + location, + condition + ).then((actor) => { + let response = { + actor: actor.actorID, + isPending: actor.isPending + }; + + let actualLocation = actor.originalLocation; + if (!actualLocation.equals(location)) { + response.actualLocation = actualLocation.toJSON(); + } + + return response; + }); + }, + + /** + * Get or create a BreakpointActor for the given location in the original + * source, and ensure it is set as a breakpoint handler on all scripts that + * match the given location. + * + * @param OriginalLocation originalLocation + * An OriginalLocation representing the location of the breakpoint in + * the original source. + * @param String condition + * A string that is evaluated whenever the breakpoint is hit. If the + * string evaluates to false, the breakpoint is ignored. + * + * @returns BreakpointActor + * A BreakpointActor representing the breakpoint. + */ + _getOrCreateBreakpointActor: function (originalLocation, condition) { + let actor = this.breakpointActorMap.getActor(originalLocation); + if (!actor) { + actor = new BreakpointActor(this.threadActor, originalLocation); + this.threadActor.threadLifetimePool.addActor(actor); + this.breakpointActorMap.setActor(originalLocation, actor); + } + + actor.condition = condition; + + return this._setBreakpoint(actor); + }, + + /* + * Ensure the given BreakpointActor is set as a breakpoint handler on all + * scripts that match its location in the original source. + * + * If there are no scripts that match the location of the BreakpointActor, + * we slide its location to the next closest line (for line breakpoints) or + * column (for column breakpoint) that does. + * + * If breakpoint sliding fails, then either there are no scripts that contain + * any code for the given location, or they were all garbage collected before + * the debugger started running. We cannot distinguish between these two + * cases, so we insert the BreakpointActor in the BreakpointActorMap as + * a pending breakpoint. Whenever a new script is introduced, this method is + * called again for each pending breakpoint. + * + * @param BreakpointActor actor + * The BreakpointActor to be set as a breakpoint handler. + * + * @returns A Promise that resolves to the given BreakpointActor. + */ + _setBreakpoint: function (actor) { + const { originalLocation } = actor; + const { originalLine, originalSourceActor } = originalLocation; + + if (!this.isSourceMapped) { + if (!this._setBreakpointAtGeneratedLocation( + actor, + GeneratedLocation.fromOriginalLocation(originalLocation) + )) { + const scripts = this.scripts.getScriptsBySourceActorAndLine( + this, + originalLine + ); + + // Never do breakpoint sliding for column breakpoints. + // Additionally, never do breakpoint sliding if no scripts + // exist on this line. + // + // Sliding can go horribly wrong if we always try to find the + // next line with valid entry points in the entire file. + // Scripts may be completely GCed and we never knew they + // existed, so we end up sliding through whole functions to + // the user's bewilderment. + // + // We can slide reliably if any scripts exist, however, due + // to how scripts are kept alive. A parent Debugger.Script + // keeps all of its children alive, so as long as we have a + // valid script, we can slide through it and know we won't + // slide through any of its child scripts. Additionally, if a + // script gets GCed, that means that all parents scripts are + // GCed as well, and no scripts will exist on those lines + // anymore. We will never slide through a GCed script. + if (originalLocation.originalColumn || scripts.length === 0) { + return promise.resolve(actor); + } + + // Find the script that spans the largest amount of code to + // determine the bounds for sliding. + const largestScript = scripts.reduce((largestScript, script) => { + if (script.lineCount > largestScript.lineCount) { + return script; + } + return largestScript; + }); + const maxLine = largestScript.startLine + largestScript.lineCount - 1; + + let actualLine = originalLine; + for (; actualLine <= maxLine; actualLine++) { + const loc = new GeneratedLocation(this, actualLine); + if (this._setBreakpointAtGeneratedLocation(actor, loc)) { + break; + } + } + + // The above loop should never complete. We only did breakpoint sliding + // because we found scripts on the line we started from, + // which means there must be valid entry points somewhere + // within those scripts. + assert( + actualLine <= maxLine, + "Could not find any entry points to set a breakpoint on, " + + "even though I was told a script existed on the line I started " + + "the search with." + ); + + // Update the actor to use the new location (reusing a + // previous breakpoint if it already exists on that line). + const actualLocation = new OriginalLocation(originalSourceActor, actualLine); + const existingActor = this.breakpointActorMap.getActor(actualLocation); + this.breakpointActorMap.deleteActor(originalLocation); + if (existingActor) { + actor.delete(); + actor = existingActor; + } else { + actor.originalLocation = actualLocation; + this.breakpointActorMap.setActor(actualLocation, actor); + } + } + + return promise.resolve(actor); + } else { + return this.sources.getAllGeneratedLocations(originalLocation).then((generatedLocations) => { + this._setBreakpointAtAllGeneratedLocations( + actor, + generatedLocations + ); + + return actor; + }); + } + }, + + _setBreakpointAtAllGeneratedLocations: function (actor, generatedLocations) { + let success = false; + for (let generatedLocation of generatedLocations) { + if (this._setBreakpointAtGeneratedLocation( + actor, + generatedLocation + )) { + success = true; + } + } + return success; + }, + + /* + * Ensure the given BreakpointActor is set as breakpoint handler on all + * scripts that match the given location in the generated source. + * + * @param BreakpointActor actor + * The BreakpointActor to be set as a breakpoint handler. + * @param GeneratedLocation generatedLocation + * A GeneratedLocation representing the location in the generated + * source for which the given BreakpointActor is to be set as a + * breakpoint handler. + * + * @returns A Boolean that is true if the BreakpointActor was set as a + * breakpoint handler on at least one script, and false otherwise. + */ + _setBreakpointAtGeneratedLocation: function (actor, generatedLocation) { + let { + generatedSourceActor, + generatedLine, + generatedColumn, + generatedLastColumn + } = generatedLocation; + + // Find all scripts that match the given source actor and line number. + let scripts = this.scripts.getScriptsBySourceActorAndLine( + generatedSourceActor, + generatedLine + ); + + scripts = scripts.filter((script) => !actor.hasScript(script)); + + // Find all entry points that correspond to the given location. + let entryPoints = []; + if (generatedColumn === undefined) { + // This is a line breakpoint, so we are interested in all offsets + // that correspond to the given line number. + for (let script of scripts) { + let offsets = script.getLineOffsets(generatedLine); + if (offsets.length > 0) { + entryPoints.push({ script, offsets }); + } + } + } else { + // This is a column breakpoint, so we are interested in all column + // offsets that correspond to the given line *and* column number. + for (let script of scripts) { + let columnToOffsetMap = script.getAllColumnOffsets() + .filter(({ lineNumber }) => { + return lineNumber === generatedLine; + }); + for (let { columnNumber: column, offset } of columnToOffsetMap) { + if (column >= generatedColumn && column <= generatedLastColumn) { + entryPoints.push({ script, offsets: [offset] }); + } + } + } + } + + if (entryPoints.length === 0) { + return false; + } + setBreakpointAtEntryPoints(actor, entryPoints); + return true; + } +}; + +SourceActor.prototype.requestTypes = { + "source": SourceActor.prototype.onSource, + "blackbox": SourceActor.prototype.onBlackBox, + "unblackbox": SourceActor.prototype.onUnblackBox, + "prettyPrint": SourceActor.prototype.onPrettyPrint, + "disablePrettyPrint": SourceActor.prototype.onDisablePrettyPrint, + "getExecutableLines": SourceActor.prototype.getExecutableLines, + "setBreakpoint": SourceActor.prototype.onSetBreakpoint +}; + +exports.SourceActor = SourceActor; + diff --git a/devtools/server/actors/utils/TabSources.js b/devtools/server/actors/utils/TabSources.js index 65d20129e2e8..23f1709a5443 100644 --- a/devtools/server/actors/utils/TabSources.js +++ b/devtools/server/actors/utils/TabSources.js @@ -12,8 +12,8 @@ const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/ const { resolve } = require("promise"); const URL = require("URL"); -loader.lazyRequireGetter(this, "SourceActor", "devtools/server/actors/script", true); -loader.lazyRequireGetter(this, "isEvalSource", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "SourceActor", "devtools/server/actors/source", true); +loader.lazyRequireGetter(this, "isEvalSource", "devtools/server/actors/source", true); loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true);