/* -*- 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 Services = require("Services"); const { BreakpointActor, setBreakpointAtEntryPoints } = require("devtools/server/actors/breakpoint"); const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); const { createValueGrip } = require("devtools/server/actors/object"); const { ActorClassWithSpec, Arg, RetVal, method } = require("devtools/shared/protocol"); const DevToolsUtils = require("devtools/shared/DevToolsUtils"); const { assert, fetch } = DevToolsUtils; const { joinURI } = require("devtools/shared/path"); const promise = require("promise"); const { defer, resolve, reject, all } = promise; const { sourceSpec } = require("devtools/shared/specs/source"); 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(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 Boolean isInlineSource * Optional. True if this is an inline source from a HTML or XUL page. * @param String contentType * Optional. The content type of this source, if immediately available. */ let SourceActor = ActorClassWithSpec(sourceSpec, { typeName: "source", initialize: function ({ 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.prettyPrint( this.threadActor.sources.prettyPrintIndent(this.url) ).then(null, error => { DevToolsUtils.reportException("SourceActor", error); }); } else { this._init = 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 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, generatedUrl: this.generatedSource ? this.generatedSource.url : null, 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), isSourceMapped: this.isSourceMapped, sourceMapURL: source ? source.sourceMapURL : null, 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); if (!localURI) { return; } let id = mapURIToAddonID(localURI); if (!id) { return; } this._addonID = id; 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 or it is synthesized // wasm. 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 || this._contentType === "text/wasm")) { 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 loadFromCache = this.isInlineSource; // Fetch the sources with the same principal as the original document let win = this.threadActor._parent.window; let principal, cacheKey; // On xpcshell, we don't have a window but a Sandbox if (!isWorker && win instanceof Ci.nsIDOMWindow) { let webNav = win.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebNavigation); let channel = webNav.currentDocumentChannel; principal = channel.loadInfo.loadingPrincipal; // Retrieve the cacheKey in order to load POST requests from cache // Note that chrome:// URLs don't support this interface. if (loadFromCache && webNav.currentDocumentChannel instanceof Ci.nsICacheInfoChannel) { cacheKey = webNav.currentDocumentChannel.cacheKey; assert( cacheKey, "Could not fetch the cacheKey from the related document." ); } } let sourceFetched = fetch(this.url, { principal, cacheKey, loadFromCache }); // 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 () { 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); } } return sortLines(lines); }); } let lines = this.getExecutableOffsets(this.source, true); return sortLines(lines); }, /** * 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.dbg.findScripts({ 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 { source: createValueGrip(content, this.threadActor.threadLifetimePool, this.threadActor.objectGrip), contentType: contentType }; }) .then(null, aError => { reportError(aError, "Got an exception during SA_onSource: "); throw new Error("Could not load the source for " + this.url + ".\n" + DevToolsUtils.safeErrorString(aError)); }); }, /** * Handler for the "prettyPrint" packet. */ prettyPrint: 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.disablePrettyPrint(); throw new Error(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. */ disablePrettyPrint: 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. */ blackbox: function () { this.threadActor.sources.blackBox(this.url); if (this.threadActor.state == "paused" && this.threadActor.youngestFrame && this.threadActor.youngestFrame.script.url == this.url) { return true; } return false; }, /** * Handler for the "unblackbox" packet. */ unblackbox: function () { this.threadActor.sources.unblackBox(this.url); }, /** * Handle a request to set a breakpoint. * * @param Number line * Line to break on. * @param Number column * Column to break on. * @param String condition * A condition which must be true for breakpoint to be hit. * @param Boolean noSliding * If true, disables breakpoint sliding. * * @returns Promise * A promise that resolves to a JSON object representing the * response. */ setBreakpoint: function (line, column, condition, noSliding) { if (this.threadActor.state !== "paused") { throw { error: "wrongState", message: "Cannot set breakpoint while debuggee is running." }; } let location = new OriginalLocation(this, line, column); return this._getOrCreateBreakpointActor( location, condition, noSliding ).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. * @param Boolean noSliding * If true, disables breakpoint sliding. * * @returns BreakpointActor * A BreakpointActor representing the breakpoint. */ _getOrCreateBreakpointActor: function (originalLocation, condition, noSliding) { 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, noSliding); }, /* * 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. * @param Boolean noSliding * If true, disables breakpoint sliding. * * @returns A Promise that resolves to the given BreakpointActor. */ _setBreakpoint: function (actor, noSliding) { const { originalLocation } = actor; const { originalLine, originalSourceActor } = originalLocation; if (!this.isSourceMapped) { const generatedLocation = GeneratedLocation.fromOriginalLocation(originalLocation); if (!this._setBreakpointAtGeneratedLocation(actor, generatedLocation) && !noSliding) { const query = { line: originalLine }; // For most cases, we have a real source to query for. The // only time we don't is for HTML pages. In that case we want // to query for scripts in an HTML page based on its URL, as // there could be several sources within an HTML page. if (this.source) { query.source = this.source; } else { query.url = this.url; } const scripts = this.dbg.findScripts(query); // 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. const query = { line: generatedLine }; if (generatedSourceActor.source) { query.source = generatedSourceActor.source; } else { query.url = generatedSourceActor.url; } let scripts = this.dbg.findScripts(query); 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; } }); exports.SourceActor = SourceActor;