Bug 1177279 - Create a SourceLocationController to manage the state of updating sources for source mapping. r=jlong,jryans
authorJordan Santell <jsantell@mozilla.com>
Mon, 14 Mar 2016 15:17:38 -0700
changeset 340460 4adc4cef81179df18fe4e7e8463e4240aca3ae95
parent 340279 d6ee82b9a74155b6bfd544166f036fc572ae8c56
child 340461 947c1a6bee31e13736c8a6a6739ccdca3c641397
child 340493 5e14887312d4523ab59c3f6c6c94a679cf42b496
push id12971
push userbmo:bob.silverberg@gmail.com
push dateTue, 15 Mar 2016 10:57:24 +0000
reviewersjlong, jryans
bugs1177279
milestone48.0a1
Bug 1177279 - Create a SourceLocationController to manage the state of updating sources for source mapping. r=jlong,jryans
devtools/client/framework/moz.build
devtools/client/framework/source-location.js
devtools/client/framework/target.js
devtools/client/framework/test/browser.ini
devtools/client/framework/test/browser_source-location-01.js
devtools/client/framework/test/browser_source-location-02.js
devtools/client/framework/test/code_ugly.js
devtools/client/framework/test/shared-head.js
devtools/client/shared/frame-script-utils.js
devtools/server/actors/script.js
devtools/server/actors/source.js
devtools/server/actors/utils/TabSources.js
devtools/server/actors/webbrowser.js
devtools/shared/client/main.js
--- a/devtools/client/framework/moz.build
+++ b/devtools/client/framework/moz.build
@@ -11,15 +11,16 @@ TEST_HARNESS_FILES.xpcshell.devtools.cli
 
 DevToolsModules(
     'attach-thread.js',
     'devtools-browser.js',
     'devtools.js',
     'gDevTools.jsm',
     'selection.js',
     'sidebar.js',
+    'source-location.js',
     'target.js',
     'toolbox-highlighter-utils.js',
     'toolbox-hosts.js',
     'toolbox-options.js',
     'toolbox.js',
     'ToolboxProcess.jsm',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/source-location.js
@@ -0,0 +1,137 @@
+/* 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 { Task } = require("resource://gre/modules/Task.jsm");
+const { assert } = require("devtools/shared/DevToolsUtils");
+
+/**
+ * A manager class that wraps a TabTarget and listens to source changes
+ * from source maps and resolves non-source mapped locations to the source mapped
+ * versions and back and forth, and creating smart elements with a location that
+ * auto-update when the source changes (from pretty printing, source maps loading, etc)
+ *
+ * @param {TabTarget} target
+ */
+function SourceLocationController(target) {
+  this.target = target;
+  this.locations = new Set();
+
+  this._onSourceUpdated = this._onSourceUpdated.bind(this);
+  this.reset = this.reset.bind(this);
+  this.destroy = this.destroy.bind(this);
+
+  target.on("source-updated", this._onSourceUpdated);
+  target.on("navigate", this.reset);
+  target.on("will-navigate", this.reset);
+  target.on("close", this.destroy);
+}
+
+SourceLocationController.prototype.reset = function() {
+  this.locations.clear();
+};
+
+SourceLocationController.prototype.destroy = function() {
+  this.locations.clear();
+  this.target.off("source-updated", this._onSourceUpdated);
+  this.target.off("navigate", this.reset);
+  this.target.off("will-navigate", this.reset);
+  this.target.off("close", this.destroy);
+  this.target = this.locations = null;
+};
+
+/**
+ * Add this `location` to be observed and register a callback
+ * whenever the underlying source is updated.
+ *
+ * @param {Object} location
+ *        An object with a {String} url, {Number} line, and optionally
+ *        a {Number} column.
+ * @param {Function} callback
+ */
+SourceLocationController.prototype.bindLocation = function(location, callback) {
+  assert(location.url, "Location must have a url.");
+  assert(location.line, "Location must have a line.");
+  this.locations.add({ location, callback });
+};
+
+/**
+ * Called when a new source occurs (a normal source, source maps) or an updated
+ * source (pretty print) occurs.
+ *
+ * @param {String} eventName
+ * @param {Object} sourceEvent
+ */
+SourceLocationController.prototype._onSourceUpdated = function(_, sourceEvent) {
+  let { type, source } = sourceEvent;
+  // If we get a new source, and it's not a source map, abort;
+  // we can ahve no actionable updates as this is just a new normal source.
+  // Also abort if there's no `url`, which means it's unsourcemappable anyway,
+  // like an eval script.
+  if (!source.url || type === "newSource" && !source.isSourceMapped) {
+    return;
+  }
+
+  for (let locationItem of this.locations) {
+    if (isSourceRelated(locationItem.location, source)) {
+      this._updateSource(locationItem);
+    }
+  }
+};
+
+SourceLocationController.prototype._updateSource = Task.async(function*(locationItem) {
+  let newLocation = yield resolveLocation(this.target, locationItem.location);
+  if (newLocation) {
+    let previousLocation = Object.assign({}, locationItem.location);
+    Object.assign(locationItem.location, newLocation);
+    locationItem.callback(previousLocation, newLocation);
+  }
+});
+
+/**
+ * Take a TabTarget and a location, containing a `url`, `line`, and `column`, resolve
+ * the location to the latest location (so a source mapped location, or if pretty print
+ * status has been updated)
+ *
+ * @param {TabTarget} target
+ * @param {Object} location
+ * @return {Promise<Object>}
+ */
+function resolveLocation(target, location) {
+  return Task.spawn(function*() {
+    let newLocation = yield target.resolveLocation({
+      url: location.url,
+      line: location.line,
+      column: location.column || Infinity
+    });
+
+    // Source or mapping not found, so don't do anything
+    if (newLocation.error) {
+      return null;
+    }
+
+    return newLocation;
+  });
+}
+
+/**
+ * Takes a serialized SourceActor form and returns a boolean indicating
+ * if this source is related to this location, like if a location is a generated source,
+ * and the source map is loaded subsequently, the new source mapped SourceActor
+ * will be considered related to this location. Same with pretty printing new sources.
+ *
+ * @param {Object} location
+ * @param {Object} source
+ * @return {Boolean}
+ */
+function isSourceRelated(location, source) {
+         // Mapping location to subsequently loaded source map
+  return source.generatedUrl === location.url ||
+         // Mapping source map loc to source map
+         source.url === location.url
+}
+
+exports.SourceLocationController = SourceLocationController;
+exports.resolveLocation = resolveLocation;
+exports.isSourceRelated = isSourceRelated;
--- a/devtools/client/framework/target.js
+++ b/devtools/client/framework/target.js
@@ -392,16 +392,17 @@ TabTarget.prototype = {
     let attachTab = () => {
       this._client.attachTab(this._form.actor, (response, tabClient) => {
         if (!tabClient) {
           this._remote.reject("Unable to attach to the tab");
           return;
         }
         this.activeTab = tabClient;
         this.threadActor = response.threadActor;
+
         attachConsole();
       });
     };
 
     let onConsoleAttached = (response, consoleClient) => {
       if (!consoleClient) {
         this._remote.reject("Unable to attach to the console");
         return;
@@ -493,26 +494,32 @@ TabTarget.prototype = {
       }
     };
     this.client.addListener("tabNavigated", this._onTabNavigated);
 
     this._onFrameUpdate = (aType, aPacket) => {
       this.emit("frame-update", aPacket);
     };
     this.client.addListener("frameUpdate", this._onFrameUpdate);
+
+    this._onSourceUpdated = (event, packet) => this.emit("source-updated", packet);
+    this.client.addListener("newSource", this._onSourceUpdated);
+    this.client.addListener("updatedSource", this._onSourceUpdated);
   },
 
   /**
    * Teardown listeners for remote debugging.
    */
   _teardownRemoteListeners: function() {
     this.client.removeListener("closed", this.destroy);
     this.client.removeListener("tabNavigated", this._onTabNavigated);
     this.client.removeListener("tabDetached", this._onTabDetached);
     this.client.removeListener("frameUpdate", this._onFrameUpdate);
+    this.client.removeListener("newSource", this._onSourceUpdated);
+    this.client.removeListener("updatedSource", this._onSourceUpdated);
   },
 
   /**
    * Handle tabs events.
    */
   handleEvent: function(event) {
     switch (event.type) {
       case "TabClose":
@@ -598,16 +605,30 @@ TabTarget.prototype = {
     this._remote = null;
     this._root = null;
   },
 
   toString: function() {
     let id = this._tab ? this._tab : (this._form && this._form.actor);
     return `TabTarget:${id}`;
   },
+
+  /**
+   * @see TabActor.prototype.onResolveLocation
+   */
+  resolveLocation(loc) {
+    let deferred = promise.defer();
+
+    this.client.request(Object.assign({
+      to: this._form.actor,
+      type: "resolveLocation",
+    }, loc), deferred.resolve);
+
+    return deferred.promise;
+  },
 };
 
 /**
  * WebProgressListener for TabTarget.
  *
  * @param object aTarget
  *        The TabTarget instance to work with.
  */
--- a/devtools/client/framework/test/browser.ini
+++ b/devtools/client/framework/test/browser.ini
@@ -2,16 +2,17 @@
 tags = devtools
 subsuite = devtools
 support-files =
   browser_toolbox_options_disable_js.html
   browser_toolbox_options_disable_js_iframe.html
   browser_toolbox_options_disable_cache.sjs
   browser_toolbox_sidebar_tool.xul
   code_math.js
+  code_ugly.js
   head.js
   shared-head.js
   shared-redux-head.js
   helper_disable_cache.js
   doc_theme.css
   doc_viewsource.html
   browser_toolbox_options_enable_serviceworkers_testing_frame_script.js
   browser_toolbox_options_enable_serviceworkers_testing.html
@@ -21,16 +22,18 @@ support-files =
 [browser_devtools_api.js]
 [browser_devtools_api_destroy.js]
 [browser_dynamic_tool_enabling.js]
 [browser_ignore_toolbox_network_requests.js]
 [browser_keybindings_01.js]
 [browser_keybindings_02.js]
 [browser_keybindings_03.js]
 [browser_new_activation_workflow.js]
+[browser_source-location-01.js]
+[browser_source-location-02.js]
 [browser_target_events.js]
 [browser_target_remote.js]
 [browser_target_support.js]
 [browser_toolbox_custom_host.js]
 [browser_toolbox_dynamic_registration.js]
 [browser_toolbox_getpanelwhenready.js]
 [browser_toolbox_highlight.js]
 [browser_toolbox_hosts.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/test/browser_source-location-01.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the SourceMapController updates generated sources when source maps
+ * are subsequently found. Also checks when no column is provided, and
+ * when tagging an already source mapped location initially.
+ */
+
+const DEBUGGER_ROOT = "http://example.com/browser/devtools/client/debugger/test/mochitest/";
+// Empty page
+const PAGE_URL = `${DEBUGGER_ROOT}doc_empty-tab-01.html`;
+const JS_URL = `${DEBUGGER_ROOT}code_binary_search.js`;
+const COFFEE_URL = `${DEBUGGER_ROOT}code_binary_search.coffee`;
+const { SourceLocationController } = require("devtools/client/framework/source-location");
+
+add_task(function*() {
+  let toolbox = yield openNewTabAndToolbox(PAGE_URL, "jsdebugger");
+
+  let controller = new SourceLocationController(toolbox.target);
+
+  let aggregator = [];
+
+  function onUpdate (oldLoc, newLoc) {
+    if (oldLoc.line === 6) {
+      checkLoc1(oldLoc, newLoc);
+    } else if (oldLoc.line === 8) {
+      checkLoc2(oldLoc, newLoc);
+    } else if (oldLoc.line === 2) {
+      checkLoc3(oldLoc, newLoc);
+    } else {
+      throw new Error(`Unexpected location update: ${JSON.stringify(oldLoc)}`);
+    }
+    aggregator.push(newLoc);
+  }
+
+  let loc1 = { url: JS_URL, line: 6 };
+  let loc2 = { url: JS_URL, line: 8, column: 3 };
+  let loc3 = { url: COFFEE_URL, line: 2, column: 0 };
+
+  controller.bindLocation(loc1, onUpdate);
+  controller.bindLocation(loc2, onUpdate);
+  controller.bindLocation(loc3, onUpdate);
+
+  // Inject JS script
+  yield createScript(JS_URL);
+
+  yield waitUntil(() => aggregator.length === 3);
+
+  ok(aggregator.find(i => i.url === COFFEE_URL && i.line === 4), "found first updated location");
+  ok(aggregator.find(i => i.url === COFFEE_URL && i.line === 6), "found second updated location");
+  ok(aggregator.find(i => i.url === COFFEE_URL && i.line === 2), "found third updated location");
+
+  yield toolbox.destroy();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function checkLoc1 (oldLoc, newLoc) {
+  is(oldLoc.line, 6, "Correct line for JS:6");
+  is(oldLoc.column, null, "Correct column for JS:6");
+  is(oldLoc.url, JS_URL, "Correct url for JS:6");
+  is(newLoc.line, 4, "Correct line for JS:6 -> COFFEE");
+  is(newLoc.column, 2, "Correct column for JS:6 -> COFFEE -- handles falsy column entries");
+  is(newLoc.url, COFFEE_URL, "Correct url for JS:6 -> COFFEE");
+}
+
+function checkLoc2 (oldLoc, newLoc) {
+  is(oldLoc.line, 8, "Correct line for JS:8:3");
+  is(oldLoc.column, 3, "Correct column for JS:8:3");
+  is(oldLoc.url, JS_URL, "Correct url for JS:8:3");
+  is(newLoc.line, 6, "Correct line for JS:8:3 -> COFFEE");
+  is(newLoc.column, 10, "Correct column for JS:8:3 -> COFFEE");
+  is(newLoc.url, COFFEE_URL, "Correct url for JS:8:3 -> COFFEE");
+}
+
+function checkLoc3 (oldLoc, newLoc) {
+  is(oldLoc.line, 2, "Correct line for COFFEE:2:0");
+  is(oldLoc.column, 0, "Correct column for COFFEE:2:0");
+  is(oldLoc.url, COFFEE_URL, "Correct url for COFFEE:2:0");
+  is(newLoc.line, 2, "Correct line for COFFEE:2:0 -> COFFEE");
+  is(newLoc.column, 0, "Correct column for COFFEE:2:0 -> COFFEE");
+  is(newLoc.url, COFFEE_URL, "Correct url for COFFEE:2:0 -> COFFEE");
+}
+
+function createScript (url) {
+  info(`Creating script: ${url}`);
+  let mm = getFrameScript();
+  let command = `
+    let script = document.createElement("script");
+    script.setAttribute("src", "${url}");
+    document.body.appendChild(script);
+    null;
+  `;
+  return evalInDebuggee(mm, command);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/test/browser_source-location-02.js
@@ -0,0 +1,107 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the SourceLocationController updates generated sources when pretty printing
+ * and un pretty printing.
+ */
+
+const DEBUGGER_ROOT = "http://example.com/browser/devtools/client/debugger/test/mochitest/";
+// Empty page
+const PAGE_URL = `${DEBUGGER_ROOT}doc_empty-tab-01.html`;
+const JS_URL = `${URL_ROOT}code_ugly.js`;
+const { SourceLocationController } = require("devtools/client/framework/source-location");
+
+add_task(function*() {
+  let toolbox = yield openNewTabAndToolbox(PAGE_URL, "jsdebugger");
+
+  let controller = new SourceLocationController(toolbox.target);
+
+  let checkedPretty = false;
+  let checkedUnpretty = false;
+
+  function onUpdate (oldLoc, newLoc) {
+    if (oldLoc.line === 3) {
+      checkPrettified(oldLoc, newLoc);
+      checkedPretty = true;
+    } else if (oldLoc.line === 9) {
+      checkUnprettified(oldLoc, newLoc);
+      checkedUnpretty = true;
+    } else {
+      throw new Error(`Unexpected location update: ${JSON.stringify(oldLoc)}`);
+    }
+  }
+
+  controller.bindLocation({ url: JS_URL, line: 3 }, onUpdate);
+
+  // Inject JS script
+  let sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_ugly.js");
+  yield createScript(JS_URL);
+  yield sourceShown;
+
+  let ppButton = toolbox.getCurrentPanel().panelWin.document.getElementById("pretty-print");
+  sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_ugly.js");
+  ppButton.click();
+  yield sourceShown;
+  yield waitUntil(() => checkedPretty);
+
+  // TODO check unprettified change once bug 1177446 fixed
+  /*
+  sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_ugly.js");
+  ppButton.click();
+  yield sourceShown;
+  yield waitUntil(() => checkedUnpretty);
+  */
+
+  yield toolbox.destroy();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function checkPrettified (oldLoc, newLoc) {
+  is(oldLoc.line, 3, "Correct line for JS:3");
+  is(oldLoc.column, null, "Correct column for JS:3");
+  is(oldLoc.url, JS_URL, "Correct url for JS:3");
+  is(newLoc.line, 9, "Correct line for JS:3 -> PRETTY");
+  is(newLoc.column, 0, "Correct column for JS:3 -> PRETTY");
+  is(newLoc.url, JS_URL, "Correct url for JS:3 -> PRETTY");
+}
+
+function checkUnprettified (oldLoc, newLoc) {
+  is(oldLoc.line, 9, "Correct line for JS:3 -> PRETTY");
+  is(oldLoc.column, 0, "Correct column for JS:3 -> PRETTY");
+  is(oldLoc.url, JS_URL, "Correct url for JS:3 -> PRETTY");
+  is(newLoc.line, 3, "Correct line for JS:3 -> UNPRETTIED");
+  is(newLoc.column, null, "Correct column for JS:3 -> UNPRETTIED");
+  is(newLoc.url, JS_URL, "Correct url for JS:3 -> UNPRETTIED");
+}
+
+function createScript (url) {
+  info(`Creating script: ${url}`);
+  let mm = getFrameScript();
+  let command = `
+    let script = document.createElement("script");
+    script.setAttribute("src", "${url}");
+    document.body.appendChild(script);
+  `;
+  return evalInDebuggee(mm, command);
+}
+
+function waitForSourceShown (debuggerPanel, url) {
+  let { panelWin } = debuggerPanel;
+  let deferred = promise.defer();
+
+  info(`Waiting for source ${url} to be shown in the debugger...`);
+  panelWin.on(panelWin.EVENTS.SOURCE_SHOWN, function onSourceShown (_, source) {
+    let sourceUrl = source.url || source.introductionUrl;
+
+    if (sourceUrl.includes(url)) {
+      panelWin.off(panelWin.EVENTS.SOURCE_SHOWN, onSourceShown);
+      info(`Source shown for ${url}`);
+      deferred.resolve(source);
+    }
+  });
+
+  return deferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/test/code_ugly.js
@@ -0,0 +1,3 @@
+function foo() { var a=1; var b=2; bar(a, b); }
+function bar(c, d) { return c - d; }
+foo();
--- a/devtools/client/framework/test/shared-head.js
+++ b/devtools/client/framework/test/shared-head.js
@@ -213,16 +213,17 @@ function waitForTick() {
  * @param {String} hostType Optional. The type of toolbox host to be used.
  * @return {Promise} Resolves with the toolbox, when it has been opened.
  */
 var openToolboxForTab = Task.async(function*(tab, toolId, hostType) {
   info("Opening the toolbox");
 
   let toolbox;
   let target = TargetFactory.forTab(tab);
+  yield target.makeRemote();
 
   // Check if the toolbox is already loaded.
   toolbox = gDevTools.getToolbox(target);
   if (toolbox) {
     if (!toolId || (toolId && toolbox.getPanel(toolId))) {
       info("Toolbox is already opened");
       return toolbox;
     }
@@ -258,8 +259,50 @@ var openNewTabAndToolbox = Task.async(fu
  * @return {Promise} Resolves when the toolbox and tab have been destroyed and
  * closed.
  */
 function closeToolboxAndTab(toolbox) {
   return toolbox.destroy().then(function() {
     gBrowser.removeCurrentTab();
   });
 }
+
+/**
+ * Waits until a predicate returns true.
+ *
+ * @param function predicate
+ *        Invoked once in a while until it returns true.
+ * @param number interval [optional]
+ *        How often the predicate is invoked, in milliseconds.
+ */
+function waitUntil(predicate, interval = 10) {
+  if (predicate()) {
+    return Promise.resolve(true);
+  }
+  return new Promise(resolve => {
+    setTimeout(function() {
+      waitUntil(predicate, interval).then(() => resolve(true));
+    }, interval);
+  });
+}
+
+/**
+ * Takes a string `script` and evaluates it directly in the content
+ * in potentially a different process.
+ */
+let MM_INC_ID = 0;
+function evalInDebuggee (mm, script) {
+  return new Promise(function (resolve, reject) {
+    let id = MM_INC_ID++;
+    mm.sendAsyncMessage("devtools:test:eval", { script, id });
+    mm.addMessageListener("devtools:test:eval:response", handler);
+
+    function handler ({ data }) {
+      if (id !== data.id) {
+        return;
+      }
+
+      info(`Successfully evaled in debuggee: ${script}`);
+      mm.removeMessageListener("devtools:test:eval:response", handler);
+      resolve(data.value);
+    }
+  });
+}
--- a/devtools/client/shared/frame-script-utils.js
+++ b/devtools/client/shared/frame-script-utils.js
@@ -119,18 +119,17 @@ addMessageListener("devtools:test:profil
   let result = nsIProfilerModule[method](...args);
   sendAsyncMessage("devtools:test:profiler:response", {
     data: result,
     id: id
   });
 });
 
 
-// To eval in content, look at `evalInDebuggee` in the head.js of canvasdebugger
-// for an example.
+// To eval in content, look at `evalInDebuggee` in the shared-head.js.
 addMessageListener("devtools:test:eval", function ({ data }) {
   sendAsyncMessage("devtools:test:eval:response", {
     value: content.eval(data.script),
     id: data.id
   });
 });
 
 addEventListener("load", function() {
--- a/devtools/server/actors/script.js
+++ b/devtools/server/actors/script.js
@@ -432,17 +432,17 @@ function ThreadActor(aParent, aGlobal)
   // A map of actorID -> actor for breakpoints created and managed by the
   // server.
   this._hiddenBreakpoints = new Map();
 
   this.global = aGlobal;
 
   this._allEventsListener = this._allEventsListener.bind(this);
   this.onNewGlobal = this.onNewGlobal.bind(this);
-  this.onNewSource = this.onNewSource.bind(this);
+  this.onSourceEvent = this.onSourceEvent.bind(this);
   this.uncaughtExceptionHook = this.uncaughtExceptionHook.bind(this);
   this.onDebuggerStatement = this.onDebuggerStatement.bind(this);
   this.onNewScript = this.onNewScript.bind(this);
   this.objectGrip = this.objectGrip.bind(this);
   this.pauseObjectGrip = this.pauseObjectGrip.bind(this);
   this._onWindowReady = this._onWindowReady.bind(this);
   events.on(this._parent, "window-ready", this._onWindowReady);
   // Set a wrappedJSObject property so |this| can be sent via the observer svc
@@ -578,16 +578,18 @@ ThreadActor.prototype = {
     }
 
     // Blow away our source actor ID store because those IDs are only
     // valid for this connection. This is ok because we never keep
     // things like breakpoints across connections.
     this._sourceActorStore = null;
 
     events.off(this._parent, "window-ready", this._onWindowReady);
+    this.sources.off("newSource", this.onSourceEvent);
+    this.sources.off("updatedSource", this.onSourceEvent);
     this.clearDebuggees();
     this.conn.removeActorPool(this._threadLifetimePool);
     this._threadLifetimePool = null;
 
     if (this._prettyPrintWorker) {
       this._prettyPrintWorker.destroy();
       this._prettyPrintWorker = null;
     }
@@ -618,19 +620,18 @@ ThreadActor.prototype = {
                message: "Current state is " + this.state };
     }
 
     this._state = "attached";
     this._debuggerSourcesSeen = new Set();
 
     update(this._options, aRequest.options || {});
     this.sources.setOptions(this._options);
-    this.sources.on('newSource', (name, source) => {
-      this.onNewSource(source);
-    });
+    this.sources.on("newSource", this.onSourceEvent);
+    this.sources.on("updatedSource", this.onSourceEvent);
 
     // Initialize an event loop stack. This can't be done in the constructor,
     // because this.conn is not yet initialized by the actor pool at that time.
     this._nestedEventLoops = new EventLoopStack({
       hooks: this._parent,
       connection: this.conn,
       thread: this
     });
@@ -1889,22 +1890,39 @@ ThreadActor.prototype = {
    *        The source script that has been loaded into a debuggee compartment.
    * @param aGlobal Debugger.Object
    *        A Debugger.Object instance whose referent is the global object.
    */
   onNewScript: function (aScript, aGlobal) {
     this._addSource(aScript.source);
   },
 
-  onNewSource: function (aSource) {
+  /**
+   * A function called when there's a new or updated source from a thread actor's
+   * sources. Emits `newSource` and `updatedSource` on the tab actor.
+   *
+   * @param {String} name
+   * @param {SourceActor} source
+   */
+  onSourceEvent: function (name, source) {
     this.conn.send({
-      from: this.actorID,
-      type: "newSource",
-      source: aSource.form()
+      from: this._parent.actorID,
+      type: name,
+      source: source.form()
     });
+
+    // For compatibility and debugger still using `newSource` on the thread client,
+    // still emit this event here. Clean up in bug 1247084
+    if (name === "newSource") {
+      this.conn.send({
+        from: this.actorID,
+        type: name,
+        source: source.form()
+      });
+    }
   },
 
   /**
    * Restore any pre-existing breakpoints to the sources that we have access to.
    */
   _restoreBreakpoints: function () {
     if (this.breakpointActorMap.size === 0) {
       return;
@@ -2019,17 +2037,17 @@ ThreadActor.prototype.requestTypes = {
   "resume": ThreadActor.prototype.onResume,
   "clientEvaluate": ThreadActor.prototype.onClientEvaluate,
   "frames": ThreadActor.prototype.onFrames,
   "interrupt": ThreadActor.prototype.onInterrupt,
   "eventListeners": ThreadActor.prototype.onEventListeners,
   "releaseMany": ThreadActor.prototype.onReleaseMany,
   "sources": ThreadActor.prototype.onSources,
   "threadGrips": ThreadActor.prototype.onThreadGrips,
-  "prototypesAndProperties": ThreadActor.prototype.onPrototypesAndProperties
+  "prototypesAndProperties": ThreadActor.prototype.onPrototypesAndProperties,
 };
 
 exports.ThreadActor = ThreadActor;
 
 /**
  * Creates a PauseActor.
  *
  * PauseActors exist for the lifetime of a given debuggee pause.  Used to
--- a/devtools/server/actors/source.js
+++ b/devtools/server/actors/source.js
@@ -164,20 +164,20 @@ let SourceActor = ActorClass({
         DevToolsUtils.reportException("SourceActor", error);
       });
     } else {
       this._init = null;
     }
   },
 
   get isSourceMapped() {
-    return !this.isInlineSource && (
+    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; },
@@ -206,21 +206,23 @@ let SourceActor = ActorClass({
     // 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,
       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];
--- a/devtools/server/actors/utils/TabSources.js
+++ b/devtools/server/actors/utils/TabSources.js
@@ -241,16 +241,17 @@ TabSources.prototype = {
       }
 
       if (url in this._sourceMappedSourceActors) {
         return this._sourceMappedSourceActors[url];
       }
     }
 
     throw new Error('getSourceByURL: could not find source for ' + url);
+    return null;
   },
 
   /**
    * Returns true if the URL likely points to a minified resource, false
    * otherwise.
    *
    * @param String aURL
    *        The URL to test.
@@ -552,16 +553,17 @@ TabSources.prototype = {
       // just make a fake URL and stick the sourcemap there.
       url = "internal://sourcemap" + (this._anonSourceMapId++) + '/';
     }
     aSource.sourceMapURL = url;
 
     // Forcefully set the sourcemap cache. This will be used even if
     // sourcemaps are disabled.
     this._sourceMapCache[url] = resolve(aMap);
+    this.emit("updatedSource", this.getSourceActor(aSource));
   },
 
   /**
    * Return the non-source-mapped location of the given Debugger.Frame. If the
    * frame does not have a script, the location's properties are all null.
    *
    * @param Debugger.Frame aFrame
    *        The frame whose location we are getting.
--- a/devtools/server/actors/webbrowser.js
+++ b/devtools/server/actors/webbrowser.js
@@ -4,17 +4,19 @@
  * 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";
 
 var { Ci, Cu } = require("chrome");
 var Services = require("Services");
 var promise = require("promise");
-var { ActorPool, createExtraActors, appendExtraActors } = require("devtools/server/actors/common");
+var {
+  ActorPool, createExtraActors, appendExtraActors, GeneratedLocation
+} = require("devtools/server/actors/common");
 var { DebuggerServer } = require("devtools/server/main");
 var DevToolsUtils = require("devtools/shared/DevToolsUtils");
 var { assert } = DevToolsUtils;
 var { TabSources } = require("./utils/TabSources");
 var makeDebugger = require("./utils/make-debugger");
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
@@ -1898,31 +1900,69 @@ TabActor.prototype = {
     if (aName in this._extraActors) {
       const actor = this._extraActors[aName];
       if (this._tabActorPool.has(actor)) {
         this._tabActorPool.removeActor(actor);
       }
       delete this._extraActors[aName];
     }
   },
+
+  /**
+   * Takes a packet containing a url, line and column and returns
+   * the updated url, line and column based on the current source mapping
+   * (source mapped files, pretty prints).
+   *
+   * @param {String} request.url
+   * @param {Number} request.line
+   * @param {Number?} request.column
+   * @return {Promise<Object>}
+   */
+  onResolveLocation: function (request) {
+    let { url, line } = request;
+    let column = request.column || 0;
+    let actor;
+
+    if (actor = this.sources.getSourceActorByURL(url)) {
+      // Get the generated source actor if this is source mapped
+      let generatedActor = actor.generatedSource ?
+                           this.sources.createNonSourceMappedActor(actor.generatedSource) :
+                           actor;
+      let generatedLocation = new GeneratedLocation(generatedActor, line, column);
+
+      return this.sources.getOriginalLocation(generatedLocation).then(loc => {
+        // If no map found, return this packet
+        if (loc.originalLine == null) {
+          return { from: this.actorID, type: "resolveLocation", error: "MAP_NOT_FOUND" };
+        }
+
+        loc = loc.toJSON();
+        return { from: this.actorID, url: loc.source.url, column: loc.column, line: loc.line };
+      });
+    }
+
+    // Fall back to this packet when source is not found
+    return promise.resolve({ from: this.actorID, type: "resolveLocation", error: "SOURCE_NOT_FOUND" });
+  },
 };
 
 /**
  * The request types this actor can handle.
  */
 TabActor.prototype.requestTypes = {
   "attach": TabActor.prototype.onAttach,
   "detach": TabActor.prototype.onDetach,
   "focus": TabActor.prototype.onFocus,
   "reload": TabActor.prototype.onReload,
   "navigateTo": TabActor.prototype.onNavigateTo,
   "reconfigure": TabActor.prototype.onReconfigure,
   "switchToFrame": TabActor.prototype.onSwitchToFrame,
   "listFrames": TabActor.prototype.onListFrames,
-  "listWorkers": TabActor.prototype.onListWorkers
+  "listWorkers": TabActor.prototype.onListWorkers,
+  "resolveLocation": TabActor.prototype.onResolveLocation
 };
 
 exports.TabActor = TabActor;
 
 /**
  * Creates a tab actor for handling requests to a single in-process
  * <xul:browser> tab, or <html:iframe>.
  * Most of the implementation comes from TabActor.
--- a/devtools/shared/client/main.js
+++ b/devtools/shared/client/main.js
@@ -173,16 +173,18 @@ const UnsolicitedNotifications = {
   "documentLoad": "documentLoad",
   "enteredFrame": "enteredFrame",
   "exitedFrame": "exitedFrame",
   "appOpen": "appOpen",
   "appClose": "appClose",
   "appInstall": "appInstall",
   "appUninstall": "appUninstall",
   "evaluationResult": "evaluationResult",
+  "newSource": "newSource",
+  "updatedSource": "updatedSource",
 };
 
 /**
  * Set of pause types that are sent by the server and not as an immediate
  * response to a client request.
  */
 const UnsolicitedPauses = {
   "resumeLimit": "resumeLimit",
@@ -242,18 +244,18 @@ const DebuggerClient = exports.DebuggerC
  *        The function to call after the response is received. It is passed the
  *        response, and the return value is considered the new response that
  *        will be passed to the callback. The |this| context is the instance of
  *        the client object we are defining a method for.
  * @return Request
  *         The `Request` object that is a Promise object and resolves once
  *         we receive the response. (See request method for more details)
  */
-DebuggerClient.requester = function (aPacketSkeleton,
-                                     { telemetry, before, after }) {
+DebuggerClient.requester = function (aPacketSkeleton, config={}) {
+  let { telemetry, before, after } = config;
   return DevToolsUtils.makeInfallible(function (...args) {
     let histogram, startTime;
     if (telemetry) {
       let transportType = this._transport.onOutputStreamReady === undefined
         ? "LOCAL_"
         : "REMOTE_";
       let histogramId = "DEVTOOLS_DEBUGGER_RDP_"
         + transportType + telemetry + "_MS";
@@ -1370,17 +1372,30 @@ TabClient.prototype = {
   listWorkers: DebuggerClient.requester({
     type: "listWorkers"
   }, {
     telemetry: "LISTWORKERS"
   }),
 
   attachWorker: function (aWorkerActor, aOnResponse) {
     this.client.attachWorker(aWorkerActor, aOnResponse);
-  }
+  },
+
+  /**
+   * Resolve a location ({ url, line, column }) to its current
+   * source mapping location.
+   *
+   * @param {String} arg[0].url
+   * @param {Number} arg[0].line
+   * @param {Number?} arg[0].column
+   */
+  resolveLocation: DebuggerClient.requester({
+    type: "resolveLocation",
+    location: args(0)
+  }),
 };
 
 eventSource(TabClient.prototype);
 
 function WorkerClient(aClient, aForm) {
   this.client = aClient;
   this._actor = aForm.from;
   this._isClosed = false;