Merge fx-team to m-c a=merge CLOSED TREE
authorWes Kocher <wkocher@mozilla.com>
Tue, 24 Mar 2015 18:12:58 -0700
changeset 265740 cc0950b7a3696f925e2f0c917649aa537f59aa27
parent 265700 515084cb28c0cb0131f02e30b052e5ebb4b3ab13 (current diff)
parent 265739 6dd5b23f5558daf996798b2a3983a7fd9d4359b6 (diff)
child 265782 43ef69c1f6b544f98cb8b7b6082fb6b0ce9308d2
child 265846 1b09c8d75388103be1dc7097ef231277d05733d8
child 265883 771b34ea01539f728959037997d1af6c3f4a7055
push id830
push userraliiev@mozilla.com
push dateFri, 19 Jun 2015 19:24:37 +0000
treeherdermozilla-release@932614382a68 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone39.0a1
first release with
nightly linux32
cc0950b7a369 / 39.0a1 / 20150325030206 / files
nightly linux64
cc0950b7a369 / 39.0a1 / 20150325030206 / files
nightly mac
cc0950b7a369 / 39.0a1 / 20150325030206 / files
nightly win32
cc0950b7a369 / 39.0a1 / 20150325030206 / files
nightly win64
cc0950b7a369 / 39.0a1 / 20150325030206 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge fx-team to m-c a=merge CLOSED TREE
toolkit/components/telemetry/Histograms.json
--- a/addon-sdk/source/app-extension/bootstrap.js
+++ b/addon-sdk/source/app-extension/bootstrap.js
@@ -349,15 +349,14 @@ function nukeModules() {
   }
   loader = null;
 
   // both `toolkit/loader` and `system/xul-app` are loaded as JSM's via
   // `cuddlefish.js`, and needs to be unloaded to avoid memory leaks, when
   // the addon is unload.
 
   unloadSandbox(cuddlefishSandbox.loaderSandbox);
-  unloadSandbox(cuddlefishSandbox.xulappSandbox);
 
   // Bug 764840: We need to unload cuddlefish otherwise it will stay alive
   // and keep a reference to this compartment.
   unloadSandbox(cuddlefishSandbox);
   cuddlefishSandbox = null;
 }
--- a/addon-sdk/source/bin/jpm-test.js
+++ b/addon-sdk/source/bin/jpm-test.js
@@ -1,27 +1,29 @@
 /* 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";
 
-var readParam = require("./node-scripts/utils").readParam;
-var path = require("path");
+var Promise = require("promise");
 var Mocha = require("mocha");
 var mocha = new Mocha({
   ui: "bdd",
   reporter: "spec",
   timeout: 900000
 });
 
-var type = readParam("type");
+exports.run = function(type) {
+  return new Promise(function(resolve) {
+    type = type || "";
+    [
+      (/^(modules)?$/.test(type)) && require.resolve("../bin/node-scripts/test.modules"),
+      (/^(addons)?$/.test(type)) && require.resolve("../bin/node-scripts/test.addons"),
+      (/^(examples)?$/.test(type)) && require.resolve("../bin/node-scripts/test.examples"),
+    ].sort().forEach(function(filepath) {
+      filepath && mocha.addFile(filepath);
+    })
 
-[
-  (!type || type == "modules") && require.resolve("../bin/node-scripts/test.modules"),
-  (!type || type == "addons") && require.resolve("../bin/node-scripts/test.addons"),
-  (!type || type == "examples") && require.resolve("../bin/node-scripts/test.examples"),
-].sort().forEach(function(filepath) {
-  filepath && mocha.addFile(filepath);
-})
-
-mocha.run(function (failures) {
-  process.exit(failures);
-});
+    mocha.run(function(failures) {
+      resolve(failures);
+    });
+  });
+}
--- a/addon-sdk/source/examples/debug-client/data/client.js
+++ b/addon-sdk/source/examples/debug-client/data/client.js
@@ -197,17 +197,17 @@ var Connection = Class({
   addPool: function(pool) {
     this.pools.add(pool);
   },
   removePool: function(pool) {
     this.pools.delete(pool);
   },
   poolFor: function(id) {
     for (let pool of this.pools.values()) {
-      if (pool.has(id))
+      if pool.has(id)
         return pool;
     }
   },
   get: function(id) {
     var pool = this.poolFor(id);
     return pool && pool.get(id);
   },
   disconnect: function() {
@@ -792,17 +792,17 @@ var Tab = Client.from({
     "canvasActor": "canvas",
     "webglActor": "webgl",
     "webaudioActor": "webaudio",
     "styleSheetsActor": "stylesheets",
     "styleEditorActor": "styleeditor",
     "storageActor": "storage",
     "gcliActor": "gcli",
     "memoryActor": "memory",
-    "eventLoopLag": "eventLoopLag",
+    "eventLoopLag": "eventLoopLag"
 
     "trace": "trace", // missing
   }
 });
 
 var tablist = Client.from({
   "category": "dict",
   "typeName": "tablist",
new file mode 100644
--- /dev/null
+++ b/addon-sdk/source/gulpfile.js
@@ -0,0 +1,23 @@
+/* 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";
+
+var gulp = require('gulp');
+
+gulp.task('test', function(done) {
+  require("./bin/jpm-test").run().then(done);
+});
+
+gulp.task('test:addons', function(done) {
+  require("./bin/jpm-test").run("addons").then(done);
+});
+
+gulp.task('test:examples', function(done) {
+  require("./bin/jpm-test").run("examples").then(done);
+});
+
+gulp.task('test:modules', function(done) {
+  require("./bin/jpm-test").run("modules").then(done);
+});
+
--- a/addon-sdk/source/lib/sdk/content/l10n-html.js
+++ b/addon-sdk/source/lib/sdk/content/l10n-html.js
@@ -11,32 +11,59 @@ const { Ci } = require("chrome");
 const core = require("../l10n/core");
 const { loadSheet, removeSheet } = require("../stylesheet/utils");
 const { process, frames } = require("../remote/child");
 
 const assetsURI = require('../self').data.url();
 
 const hideSheetUri = "data:text/css,:root {visibility: hidden !important;}";
 
+function translateElementAttributes(element) {
+  // Translateable attributes
+  const attrList = ['title', 'accesskey', 'alt', 'label', 'placeholder'];
+  const ariaAttrMap = {
+          'ariaLabel': 'aria-label',
+          'ariaValueText': 'aria-valuetext',
+          'ariaMozHint': 'aria-moz-hint'
+        };
+  const attrSeparator = '.';
+  
+  // Try to translate each of the attributes
+  for (let attribute of attrList) {
+    const data = core.get(element.dataset.l10nId + attrSeparator + attribute);
+    if (data)
+      element.setAttribute(attribute, data);
+  }
+  
+  // Look for the aria attribute translations that match fxOS's aliases
+  for (let attrAlias in ariaAttrMap) {
+    const data = core.get(element.dataset.l10nId + attrSeparator + attrAlias);
+    if (data)
+      element.setAttribute(ariaAttrMap[attrAlias], data);
+  }
+}
+
 // Taken from Gaia:
 // https://github.com/andreasgal/gaia/blob/04fde2640a7f40314643016a5a6c98bf3755f5fd/webapi.js#L1470
 function translateElement(element) {
   element = element || document;
 
   // check all translatable children (= w/ a `data-l10n-id' attribute)
   var children = element.querySelectorAll('*[data-l10n-id]');
   var elementCount = children.length;
   for (var i = 0; i < elementCount; i++) {
     var child = children[i];
 
     // translate the child
     var key = child.dataset.l10nId;
     var data = core.get(key);
     if (data)
       child.textContent = data;
+
+    translateElementAttributes(child);
   }
 }
 exports.translateElement = translateElement;
 
 function onDocumentReady2Translate(event) {
   let document = event.target;
   document.removeEventListener("DOMContentLoaded", onDocumentReady2Translate,
                                false);
--- a/addon-sdk/source/lib/sdk/deprecated/unit-test.js
+++ b/addon-sdk/source/lib/sdk/deprecated/unit-test.js
@@ -315,16 +315,18 @@ TestRunner.prototype = {
           win.removeEventListener("DOMContentLoaded", onLoad, false);
           resolve();
         }, false);
       }
       return promise;
     });
 
     PromiseDebugging.flushUncaughtErrors();
+    PromiseDebugging.removeUncaughtErrorObserver(this._uncaughtErrorObserver);
+
 
     return all(winPromises).then(() => {
       let browserWins = wins.filter(isBrowser);
       let tabs = browserWins.reduce((tabs, window) => tabs.concat(getTabs(window)), []);
       let newTabID = getTabId(getSelectedTab(wins[0]));
       let oldTabID = runnerTabs.get(this);
       let hasMoreTabsOpen = browserWins.length && tabs.length != 1;
       let failure = false;
@@ -532,17 +534,18 @@ TestRunner.prototype = {
 
   start: function start(options) {
     this.test = options.test;
     this.test.passed = 0;
     this.test.failed = 0;
     this.test.errors = {};
     this.test.last = 'START';
     PromiseDebugging.clearUncaughtErrorObservers();
-    PromiseDebugging.addUncaughtErrorObserver(this._uncaughtErrorObserver.bind(this));
+    this._uncaughtErrorObserver = this._uncaughtErrorObserver.bind(this);
+    PromiseDebugging.addUncaughtErrorObserver(this._uncaughtErrorObserver);
 
     this.isDone = false;
     this.onDone = function(self) {
       if (cfxArgs.parseable)
         self.console.print("TEST-END | " + self.test.name + "\n");
       options.onDone(self);
     }
     this.waitTimeout = null;
--- a/addon-sdk/source/lib/sdk/event/dom.js
+++ b/addon-sdk/source/lib/sdk/event/dom.js
@@ -16,16 +16,20 @@ let listeners = new Map();
 
 let getWindowFrom = x =>
                     x instanceof Ci.nsIDOMWindow ? x :
                     x instanceof Ci.nsIDOMDocument ? x.defaultView :
                     x instanceof Ci.nsIDOMNode ? x.ownerDocument.defaultView :
                     null;
 
 function removeFromListeners() {
+  this.removeEventListener("DOMWindowClose", removeFromListeners);
+  for (let cleaner of listeners.get(this))
+    cleaner();
+
   listeners.delete(this);
 }
 
 // Simple utility function takes event target, event type and optional
 // `options.capture` and returns node style event stream that emits "data"
 // events every time event of that type occurs on the given `target`.
 function open(target, type, options) {
   let output = {};
@@ -40,31 +44,30 @@ function open(target, type, options) {
 
   // If we're not able to get a `window` from `target`, there is something
   // wrong. We cannot add listeners that can leak later, or results in
   // "dead object" exception.
   // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1001833
   if (!window)
     throw new Error("Unable to obtain the owner window from the target given.");
 
-  let cleaners = listeners.get(window) || [];
-  cleaners.push(() => target.removeEventListener(type, listener, capture));
-
-  listeners.set(window, cleaners);
+  let cleaners = listeners.get(window);
+  if (!cleaners) {
+    cleaners = [];
+    listeners.set(window, cleaners);
 
-  // We need to remove from our map the `window` once is closed, to prevent
-  // memory leak
-  window.addEventListener("DOMWindowClose", removeFromListeners);
+    // We need to remove from our map the `window` once is closed, to prevent
+    // memory leak
+    window.addEventListener("DOMWindowClose", removeFromListeners);
+  }
 
+  cleaners.push(() => target.removeEventListener(type, listener, capture));
   target.addEventListener(type, listener, capture);
 
   return output;
 }
 
 unload(() => {
-  for (let [window, cleaners] of listeners) {
-    cleaners.forEach(callback => callback())
-  }
-
-  listeners.clear();
+  for (let window of listeners.keys())
+    removeFromListeners.call(window);
 });
 
 exports.open = open;
--- a/addon-sdk/source/lib/sdk/io/fs.js
+++ b/addon-sdk/source/lib/sdk/io/fs.js
@@ -687,18 +687,16 @@ exports.open = open;
  */
 function writeSync(fd, buffer, offset, length, position) {
   if (length + offset > buffer.length) {
     throw Error("Length is extends beyond buffer");
   }
   else if (length + offset !== buffer.length) {
     buffer = buffer.slice(offset, offset + length);
   }
-  let writeStream = new WriteStream(fd, { position: position,
-                                          length: length });
 
   let output = BinaryOutputStream(nsIFileOutputStream(fd));
   nsIBinaryOutputStream(fd, output);
   // We write content as a byte array as this will avoid any transcoding
   // if content was a buffer.
   output.writeByteArray(buffer.valueOf(), buffer.length);
   output.flush();
 };
--- a/addon-sdk/source/lib/sdk/places/events.js
+++ b/addon-sdk/source/lib/sdk/places/events.js
@@ -18,16 +18,17 @@ const { Class } = require('../core/herit
 const { merge } = require('../util/object');
 const bookmarkService = Cc['@mozilla.org/browser/nav-bookmarks-service;1']
                         .getService(Ci.nsINavBookmarksService);
 const historyService = Cc['@mozilla.org/browser/nav-history-service;1']
                        .getService(Ci.nsINavHistoryService);
 const { mapBookmarkItemType } = require('./utils');
 const { EventTarget } = require('../event/target');
 const { emit } = require('../event/core');
+const { when } = require('../system/unload');
 
 const emitter = EventTarget();
 
 let HISTORY_ARGS = {
   onBeginUpdateBatch: [],
   onEndUpdateBatch: [],
   onClearHistory: [],
   onDeleteURI: ['url'],
@@ -114,9 +115,14 @@ function formatValue (type, data) {
 }
 
 let historyObserver = createObserverInstance(HISTORY_EVENTS, HISTORY_ARGS);
 historyService.addObserver(historyObserver, false);
 
 let bookmarkObserver = createObserverInstance(BOOKMARK_EVENTS, BOOKMARK_ARGS);
 bookmarkService.addObserver(bookmarkObserver, false);
 
+when(() => {
+  historyService.removeObserver(historyObserver);
+  bookmarkService.removeObserver(bookmarkObserver);
+});
+
 exports.events = emitter;
--- a/addon-sdk/source/lib/toolkit/loader.js
+++ b/addon-sdk/source/lib/toolkit/loader.js
@@ -164,16 +164,25 @@ function serializeStack(frames) {
            frame.lineNumber + ":" +
            frame.columnNumber + "\n" +
            stack;
   }, "");
 }
 Loader.serializeStack = serializeStack;
 
 function readURI(uri) {
+  let nsURI = NetUtil.newURI(uri);
+  if (nsURI.scheme == "resource") {
+    // Resolve to a real URI, this will catch any obvious bad paths without
+    // logging assertions in debug builds, see bug 1135219
+    let proto = Cc["@mozilla.org/network/protocol;1?name=resource"].
+                getService(Ci.nsIResProtocolHandler);
+    uri = proto.resolveURI(nsURI);
+  }
+
   let stream = NetUtil.newChannel2(uri,
                                    'UTF-8',
                                    null,
                                    null,      // aLoadingNode
                                    systemPrincipal,
                                    null,      // aTriggeringPrincipal
                                    Ci.nsILoadInfo.SEC_NORMAL,
                                    Ci.nsIContentPolicy.TYPE_OTHER).open();
@@ -415,30 +424,39 @@ Loader.resolve = resolve;
 // Takes an id and path and attempts to load a file using node's resolving
 // algorithm.
 // `id` should already be resolved relatively at this point.
 // http://nodejs.org/api/modules.html#modules_all_together
 const nodeResolve = iced(function nodeResolve(id, requirer, { rootURI }) {
   // Resolve again
   id = Loader.resolve(id, requirer);
 
+  // If this is already an absolute URI then there is no resolution to do
+  if (isAbsoluteURI(id))
+    return void 0;
+
   // we assume that extensions are correct, i.e., a directory doesnt't have '.js'
   // and a js file isn't named 'file.json.js'
   let fullId = join(rootURI, id);
   let resolvedPath;
 
   if ((resolvedPath = loadAsFile(fullId)))
     return stripBase(rootURI, resolvedPath);
 
   if ((resolvedPath = loadAsDirectory(fullId)))
     return stripBase(rootURI, resolvedPath);
 
+  // If the requirer is an absolute URI then the node module resolution below
+  // won't work correctly as we prefix everything with rootURI
+  if (isAbsoluteURI(requirer))
+    return void 0;
+
   // If manifest has dependencies, attempt to look up node modules
   // in the `dependencies` list
-  let dirs = getNodeModulePaths(dirname(join(rootURI, requirer))).map(dir => join(dir, id));
+  let dirs = getNodeModulePaths(dirname(requirer)).map(dir => join(rootURI, dir, id));
   for (let i = 0; i < dirs.length; i++) {
     if ((resolvedPath = loadAsFile(dirs[i])))
       return stripBase(rootURI, resolvedPath);
 
     if ((resolvedPath = loadAsDirectory(dirs[i])))
       return stripBase(rootURI, resolvedPath);
   }
 
@@ -504,16 +522,17 @@ function getNodeModulePaths (start) {
 
   let parts = start.split('/');
   let dirs = [];
   for (let i = parts.length - 1; i >= 0; i--) {
     if (parts[i] === moduleDir) continue;
     let dir = join(parts.slice(0, i + 1).join('/'), moduleDir);
     dirs.push(dir);
   }
+  dirs.push(moduleDir);
   return dirs;
 }
 
 
 function addTrailingSlash (path) {
   return !path ? null : !path.endsWith('/') ? path + '/' : path;
 }
 
--- a/addon-sdk/source/package.json
+++ b/addon-sdk/source/package.json
@@ -3,33 +3,31 @@
   "description": "Add-on development made easy.",
   "keywords": [
     "javascript", "engine", "addon", "extension",
     "xulrunner", "firefox", "browser"
   ],
   "license": "MPL 2.0",
   "unpack": true,
   "scripts": {
-    "test": "node ./bin/jpm-test.js",
-    "modules": "node ./bin/jpm-test.js --type modules",
-    "addons": "node ./bin/jpm-test.js --type addons",
-    "examples": "node ./bin/jpm-test.js --type examples"
+    "test": "gulp test"
   },
   "homepage": "https://github.com/mozilla/addon-sdk",
   "repository": {
     "type": "git",
     "url": "git://github.com/mozilla/addon-sdk.git"
   },
   "version": "0.1.18",
   "main": "./lib/index.js",
   "loader": "lib/sdk/loader/cuddlefish.js",
   "devDependencies": {
     "async": "0.9.0",
     "chai": "2.1.1",
     "glob": "4.4.2",
+    "gulp": "3.8.11",
     "jpm": "0.0.29",
     "lodash": "3.3.1",
     "mocha": "2.1.0",
     "promise": "6.1.0",
     "rimraf": "2.3.1",
     "unzip": "0.1.11",
     "xmldom": "0.1.19"
   }
--- a/addon-sdk/source/python-lib/cuddlefish/prefs.py
+++ b/addon-sdk/source/python-lib/cuddlefish/prefs.py
@@ -51,16 +51,17 @@ DEFAULT_NO_CONNECTIONS_PREFS = {
     'media.gmp-gmpopenh264.autoupdate' : False,
     'media.gmp-manager.cert.checkAttributes' : False,
     'media.gmp-manager.cert.requireBuiltIn' : False,
     'media.gmp-manager.url' : 'http://localhost/media-dummy/gmpmanager',
     'media.gmp-manager.url.override': 'http://localhost/dummy-gmp-manager.xml',
     'browser.aboutHomeSnippets.updateUrl': 'https://localhost/snippet-dummy',
     'browser.newtab.url' : 'about:blank',
     'browser.search.update': False,
+    'browser.search.suggest.enabled' : False,
     'browser.safebrowsing.enabled' : False,
     'browser.safebrowsing.updateURL': 'http://localhost/safebrowsing-dummy/update',
     'browser.safebrowsing.gethashURL': 'http://localhost/safebrowsing-dummy/gethash',
     'browser.safebrowsing.reportURL': 'http://localhost/safebrowsing-dummy/report',
     'browser.safebrowsing.malware.reportURL': 'http://localhost/safebrowsing-dummy/malwarereport',
     'browser.selfsupport.url': 'http://localhost/repair-dummy',
     'browser.trackingprotection.gethashURL': 'http://localhost/safebrowsing-dummy/gethash',
     'browser.trackingprotection.updateURL': 'http://localhost/safebrowsing-dummy/update',
--- a/addon-sdk/source/test/addons/e10s-l10n/data/test-localization.html
+++ b/addon-sdk/source/test/addons/e10s-l10n/data/test-localization.html
@@ -15,10 +15,15 @@
         Elements with data-l10n-id attribute whose parent element is translated
         will be replaced by the content of the translation.
       </li>
     </ul>
     <div data-l10n-id="text-content">No</div>
     <div data-l10n-id="Translated">
       A data-l10n-id value can be used in multiple elements
     </div>
+    <a data-l10n-id="link-attributes" title="Certain whitelisted attributes get translated too" alt="No" accesskey="A"></a>
+    <input data-l10n-id="input" type="text" placeholder="Form placeholders are translateable">
+    <menu>
+      <menuitem data-l10n-id="contextitem" label="Labels of select options and context menus are translateable">
+    </menu>
   </body>
 </html
--- a/addon-sdk/source/test/addons/e10s-l10n/locale/en.properties
+++ b/addon-sdk/source/test/addons/e10s-l10n/locale/en.properties
@@ -21,8 +21,18 @@ explicitPlural[other]=other
 unicodeEscape = \u0020\u0040\u0020
 # this string equals to " @ "
 
 # bug 1033309 plurals with multiple placeholders
 first_identifier[one]=first entry is %s and the second one is %s.
 first_identifier=the entries are %s and %s.
 second_identifier[other]=first entry is %s and the second one is %s.
 third_identifier=first entry is %s and the second one is %s.
+
+# bug 824489 allow translation of element attributes
+link-attributes.title=Yes
+link-attributes.alt=Yes
+link-attributes.accesskey=B
+input.placeholder=Yes
+contextitem.label=Yes
+link-attributes.ariaLabel=Yes
+link-attributes.ariaValueText=Value
+link-attributes.ariaMozHint=Hint
--- a/addon-sdk/source/test/addons/e10s-l10n/main.js
+++ b/addon-sdk/source/test/addons/e10s-l10n/main.js
@@ -100,32 +100,53 @@ exports.testHtmlLocalizationPageWorker =
   let uri = require("sdk/self").data.url("test-localization.html");
   let worker = loader.require("sdk/page-worker").Page({
     contentURL: uri,
     contentScript: "new " + function ContentScriptScope() {
       let nodes = document.body.querySelectorAll("*[data-l10n-id]");
       self.postMessage([nodes[0].innerHTML,
                         nodes[1].innerHTML,
                         nodes[2].innerHTML,
-                        nodes[3].innerHTML]);
+                        nodes[3].innerHTML,
+                        nodes[4].title,
+                        nodes[4].getAttribute("alt"),
+                        nodes[4].getAttribute("accesskey"),
+                        nodes[4].getAttribute("aria-label"),
+                        nodes[4].getAttribute("aria-valuetext"),
+                        nodes[4].getAttribute("aria-moz-hint"),
+                        nodes[5].placeholder,
+                        nodes[6].label]);
     },
     onMessage: function (data) {
       assert.equal(
         data[0],
         "Kept as-is",
         "Nodes with unknown id in .properties are kept 'as-is'"
       );
       assert.equal(data[1], "Yes", "HTML is translated");
       assert.equal(
         data[2],
         "no &lt;b&gt;HTML&lt;/b&gt; injection",
         "Content from .properties is text content; HTML can't be injected."
       );
       assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted.");
 
+      // Attribute translation tests
+      assert.equal(data[4], "Yes", "Title attributes gets translated.");
+      assert.equal(data[5], "Yes", "Alt attributes gets translated.");
+      assert.equal(data[6], "B", "Accesskey gets translated.");
+      
+      assert.equal(data[7], "Yes", "Aria-Label gets translated.");
+      assert.equal(data[8], "Value", "Aria-valuetext gets translated.");
+      assert.equal(data[9], "Hint", "Aria-moz-hint gets translated.");
+      
+      assert.equal(data[10], "Yes", "Form placeholders are translateable.");
+      
+      assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable.");
+
       done();
     }
   });
 });
 
 exports.testHtmlLocalization = createTest("en-GB", function(assert, loader, done) {
   // Ensure initing html component that watch document creations
   // Note that this module is automatically initialized in
@@ -139,32 +160,53 @@ exports.testHtmlLocalization = createTes
     onReady: function(tab) {
       tab.attach({
         contentURL: uri,
         contentScript: "new " + function ContentScriptScope() {
           let nodes = document.body.querySelectorAll("*[data-l10n-id]");
           self.postMessage([nodes[0].innerHTML,
                             nodes[1].innerHTML,
                             nodes[2].innerHTML,
-                            nodes[3].innerHTML]);
+                            nodes[3].innerHTML,
+                            nodes[4].title,
+                            nodes[4].getAttribute("alt"),
+                            nodes[4].getAttribute("accesskey"),
+                            nodes[4].getAttribute("aria-label"),
+                            nodes[4].getAttribute("aria-valuetext"),
+                            nodes[4].getAttribute("aria-moz-hint"),
+                            nodes[5].placeholder,
+                            nodes[6].label]);
         },
         onMessage: function (data) {
           assert.equal(
             data[0],
             "Kept as-is",
             "Nodes with unknown id in .properties are kept 'as-is'"
           );
           assert.equal(data[1], "Yes", "HTML is translated");
           assert.equal(
             data[2],
             "no &lt;b&gt;HTML&lt;/b&gt; injection",
             "Content from .properties is text content; HTML can't be injected."
           );
           assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted.");
 
+          // Attribute translation tests
+          assert.equal(data[4], "Yes", "Title attributes gets translated.");
+          assert.equal(data[5], "Yes", "Alt attributes gets translated.");
+          assert.equal(data[6], "B", "Accesskey gets translated.");
+      
+          assert.equal(data[7], "Yes", "Aria-Label gets translated.");
+          assert.equal(data[8], "Value", "Aria-valuetext gets translated.");
+          assert.equal(data[9], "Hint", "Aria-moz-hint gets translated.");
+      
+          assert.equal(data[10], "Yes", "Form placeholders are translateable.");
+      
+          assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable.");
+
           tab.close(done);
         }
       });
     }
   });
 });
 
 exports.testEnUsLocaleName = createTest("en-US", function(assert, loader, done) {
--- a/addon-sdk/source/test/addons/e10s-remote/main.js
+++ b/addon-sdk/source/test/addons/e10s-remote/main.js
@@ -1,31 +1,44 @@
 /* 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 LOCAL_URI = "about:robots";
-const REMOTE_URI = "about:home";
+const REMOTE_URI = "data:text/html;charset=utf-8,remote";
 
 const { Loader } = require('sdk/test/loader');
 const { getTabs, openTab, closeTab, setTabURL, getBrowserForTab, getURI } = require('sdk/tabs/utils');
 const { getMostRecentBrowserWindow } = require('sdk/window/utils');
 const { cleanUI } = require("sdk/test/utils");
 const { setTimeout } = require("sdk/timers");
 const { promiseEvent, promiseDOMEvent, promiseEventOnItemAndContainer,
         waitForProcesses, getChildFrameCount, isE10S } = require("./utils");
 const { after } = require('sdk/test/utils');
 const { processID } = require('sdk/system/runtime');
 
 const { set } = require('sdk/preferences/service');
 // The hidden preload browser messes up our frame counts
 set('browser.newtab.preload', false);
 
+function promiseTabFrameAttach(frames) {
+  return new Promise(resolve => {
+    let listener = function(frame, ...args) {
+      if (!frame.isTab)
+        return;
+      frames.off("attach", listener);
+      resolve([frame, ...args]);
+    }
+
+    frames.on("attach", listener);
+  });
+}
+
 // Check that we see a process stop and start
 exports["test process restart"] = function*(assert) {
   if (!isE10S) {
     assert.pass("Skipping test in non-e10s mode");
     return;
   }
 
   let window = getMostRecentBrowserWindow();
@@ -39,30 +52,30 @@ exports["test process restart"] = functi
 
   let remoteProcess = Array.filter(processes, p => p.isRemote)[0];
   let localProcess = Array.filter(processes, p => !p.isRemote)[0];
   let remoteFrame = Array.filter(frames, f => f.process == remoteProcess)[0];
 
   // Switch the remote tab to a local URI which should kill the remote process
 
   let frameDetach = promiseEventOnItemAndContainer(assert, remoteFrame, frames, 'detach');
-  let frameAttach = promiseEvent(frames, 'attach');
+  let frameAttach = promiseTabFrameAttach(frames);
   let processDetach = promiseEventOnItemAndContainer(assert, remoteProcess, processes, 'detach');
   setTabURL(tab, LOCAL_URI);
   // The load should kill the remote frame
   yield frameDetach;
   // And create a new frame in the local process
   let [newFrame] = yield frameAttach;
   assert.equal(newFrame.process, localProcess, "New frame should be in the local process");
   // And kill the process
   yield processDetach;
 
   frameDetach = promiseEventOnItemAndContainer(assert, newFrame, frames, 'detach');
   let processAttach = promiseEvent(processes, 'attach');
-  frameAttach = promiseEvent(frames, 'attach');
+  frameAttach = promiseTabFrameAttach(frames);
   setTabURL(tab, REMOTE_URI);
   // The load should kill the remote frame
   yield frameDetach;
   // And create a new remote process
   [remoteProcess] = yield processAttach;
   assert.ok(remoteProcess.isRemote, "Process should be remote");
   // And create a new frame in the remote process
   [newFrame] = yield frameAttach;
@@ -144,26 +157,26 @@ exports["test frame list"] = function*(a
   assert.equal(tabs.length, 1, "Should have just the one tab to start with");
 
   let loader = new Loader(module);
   let { processes, frames } = yield waitForProcesses(loader);
 
   assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames.");
   assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames");
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab1 = openTab(window, LOCAL_URI);
   let [frame1] = yield promise;
   assert.ok(!!frame1, "Should have seen the new frame");
   assert.ok(!frame1.process.isRemote, "Frame should not be remote");
 
   assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames.");
   assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames");
 
-  promise = promiseEvent(frames, 'attach');
+  promise = promiseTabFrameAttach(frames);
   let tab2 = openTab(window, REMOTE_URI);
   let [frame2] = yield promise;
   assert.ok(!!frame2, "Should have seen the new frame");
   if (isE10S)
     assert.ok(frame2.process.isRemote, "Frame should be remote");
   else
     assert.ok(!frame2.process.isRemote, "Frame should not be remote");
 
@@ -251,17 +264,17 @@ exports["test new loader"] = function*(a
 };
 
 // Test that unloading the loader unloads the child instances
 exports["test unload"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:,<html/>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   promise = promiseDOMEvent(browser, 'hashchange');
   frame.port.emit('sdk/test/testunload');
@@ -275,17 +288,17 @@ exports["test unload"] = function*(asser
 }
 
 // Test that unloading the loader causes the child to see frame detach events
 exports["test frame detach on unload"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:,<html/>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   promise = promiseDOMEvent(browser, 'hashchange');
   frame.port.emit('sdk/test/testdetachonunload');
@@ -299,17 +312,17 @@ exports["test frame detach on unload"] =
 }
 
 // Test that DOM event listener on the frame object works
 exports["test frame event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframeevent');
   promise = Promise.all([
@@ -334,17 +347,17 @@ exports["test frame event listeners"] = 
 }
 
 // Test that DOM event listener on the frames object works
 exports["test frames event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframesevent');
   promise = Promise.all([
@@ -372,18 +385,18 @@ exports["test frames event listeners"] =
 exports["test unload removes frame event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
   let loader2 = new Loader(module);
   let { frames: frames2 } = yield waitForProcesses(loader2);
 
-  let promise = promiseEvent(frames, 'attach');
-  let promise2 = promiseEvent(frames2, 'attach');
+  let promise = promiseTabFrameAttach(frames);
+  let promise2 = promiseTabFrameAttach(frames2);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   let [frame2] = yield promise2;
   assert.ok(!!frame && !!frame2, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframeevent');
@@ -413,18 +426,18 @@ exports["test unload removes frame event
 exports["test unload removes frames event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
   let loader2 = new Loader(module);
   let { frames: frames2 } = yield waitForProcesses(loader2);
 
-  let promise = promiseEvent(frames, 'attach');
-  let promise2 = promiseEvent(frames2, 'attach');
+  let promise = promiseTabFrameAttach(frames);
+  let promise2 = promiseTabFrameAttach(frames2);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   let [frame2] = yield promise2;
   assert.ok(!!frame && !!frame2, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframesevent');
--- a/addon-sdk/source/test/addons/l10n/data/test-localization.html
+++ b/addon-sdk/source/test/addons/l10n/data/test-localization.html
@@ -15,10 +15,15 @@
         Elements with data-l10n-id attribute whose parent element is translated
         will be replaced by the content of the translation.
       </li>
     </ul>
     <div data-l10n-id="text-content">No</div>
     <div data-l10n-id="Translated">
       A data-l10n-id value can be used in multiple elements
     </div>
+    <a data-l10n-id="link-attributes" title="Certain whitelisted attributes get translated too" alt="No" accesskey="A"></a>
+    <input data-l10n-id="input" type="text" placeholder="Form placeholders are translateable">
+    <menu>
+      <menuitem data-l10n-id="contextitem" label="Labels of select options and context menus are translateable">
+    </menu>
   </body>
 </html
--- a/addon-sdk/source/test/addons/l10n/locale/en.properties
+++ b/addon-sdk/source/test/addons/l10n/locale/en.properties
@@ -21,8 +21,18 @@ explicitPlural[other]=other
 unicodeEscape = \u0020\u0040\u0020
 # this string equals to " @ "
 
 # bug 1033309 plurals with multiple placeholders
 first_identifier[one]=first entry is %s and the second one is %s.
 first_identifier=the entries are %s and %s.
 second_identifier[other]=first entry is %s and the second one is %s.
 third_identifier=first entry is %s and the second one is %s.
+
+# bug 824489 allow translation of element attributes
+link-attributes.title=Yes
+link-attributes.alt=Yes
+link-attributes.accesskey=B
+input.placeholder=Yes
+contextitem.label=Yes
+link-attributes.ariaLabel=Yes
+link-attributes.ariaValueText=Value
+link-attributes.ariaMozHint=Hint
--- a/addon-sdk/source/test/addons/l10n/main.js
+++ b/addon-sdk/source/test/addons/l10n/main.js
@@ -100,31 +100,52 @@ exports.testHtmlLocalizationPageWorker =
   let uri = require("sdk/self").data.url("test-localization.html");
   let worker = loader.require("sdk/page-worker").Page({
     contentURL: uri,
     contentScript: "new " + function ContentScriptScope() {
       let nodes = document.body.querySelectorAll("*[data-l10n-id]");
       self.postMessage([nodes[0].innerHTML,
                         nodes[1].innerHTML,
                         nodes[2].innerHTML,
-                        nodes[3].innerHTML]);
+                        nodes[3].innerHTML,
+                        nodes[4].title,
+                        nodes[4].getAttribute("alt"),
+                        nodes[4].getAttribute("accesskey"),
+                        nodes[4].getAttribute("aria-label"),
+                        nodes[4].getAttribute("aria-valuetext"),
+                        nodes[4].getAttribute("aria-moz-hint"),
+                        nodes[5].placeholder,
+                        nodes[6].label]);
     },
     onMessage: function (data) {
       assert.equal(
         data[0],
         "Kept as-is",
         "Nodes with unknown id in .properties are kept 'as-is'"
       );
       assert.equal(data[1], "Yes", "HTML is translated");
       assert.equal(
         data[2],
         "no &lt;b&gt;HTML&lt;/b&gt; injection",
         "Content from .properties is text content; HTML can't be injected."
       );
       assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted.");
+      
+      // Attribute translation tests
+      assert.equal(data[4], "Yes", "Title attributes gets translated.");
+      assert.equal(data[5], "Yes", "Alt attributes gets translated.");
+      assert.equal(data[6], "B", "Accesskey gets translated.");
+      
+      assert.equal(data[7], "Yes", "Aria-Label gets translated.");
+      assert.equal(data[8], "Value", "Aria-valuetext gets translated.");
+      assert.equal(data[9], "Hint", "Aria-moz-hint gets translated.");
+      
+      assert.equal(data[10], "Yes", "Form placeholders are translateable.");
+      
+      assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable.");
 
       done();
     }
   });
 });
 
 exports.testHtmlLocalization = createTest("en-GB", function(assert, loader, done) {
   // Ensure initing html component that watch document creations
@@ -139,32 +160,53 @@ exports.testHtmlLocalization = createTes
     onReady: function(tab) {
       tab.attach({
         contentURL: uri,
         contentScript: "new " + function ContentScriptScope() {
           let nodes = document.body.querySelectorAll("*[data-l10n-id]");
           self.postMessage([nodes[0].innerHTML,
                             nodes[1].innerHTML,
                             nodes[2].innerHTML,
-                            nodes[3].innerHTML]);
+                            nodes[3].innerHTML,
+                            nodes[4].title,
+                            nodes[4].getAttribute("alt"),
+                            nodes[4].getAttribute("accesskey"),
+                            nodes[4].getAttribute("aria-label"),
+                            nodes[4].getAttribute("aria-valuetext"),
+                            nodes[4].getAttribute("aria-moz-hint"),
+                            nodes[5].placeholder,
+                            nodes[6].label]);
         },
         onMessage: function (data) {
           assert.equal(
             data[0],
             "Kept as-is",
             "Nodes with unknown id in .properties are kept 'as-is'"
           );
           assert.equal(data[1], "Yes", "HTML is translated");
           assert.equal(
             data[2],
             "no &lt;b&gt;HTML&lt;/b&gt; injection",
             "Content from .properties is text content; HTML can't be injected."
           );
           assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted.");
 
+          // Attribute translation tests
+          assert.equal(data[4], "Yes", "Title attributes gets translated.");
+          assert.equal(data[5], "Yes", "Alt attributes gets translated.");
+          assert.equal(data[6], "B", "Accesskey gets translated.");
+          
+          assert.equal(data[7], "Yes", "Aria-Label gets translated.");
+          assert.equal(data[8], "Value", "Aria-valuetext gets translated.");
+          assert.equal(data[9], "Hint", "Aria-moz-hint gets translated.");
+          
+          assert.equal(data[10], "Yes", "Form placeholders are translateable.");
+          
+          assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable.");
+
           tab.close(done);
         }
       });
     }
   });
 });
 
 exports.testEnUsLocaleName = createTest("en-US", function(assert, loader, done) {
--- a/addon-sdk/source/test/addons/remote/main.js
+++ b/addon-sdk/source/test/addons/remote/main.js
@@ -1,31 +1,44 @@
 /* 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 LOCAL_URI = "about:robots";
-const REMOTE_URI = "about:home";
+const REMOTE_URI = "data:text/html;charset=utf-8,remote";
 
 const { Loader } = require('sdk/test/loader');
 const { getTabs, openTab, closeTab, setTabURL, getBrowserForTab, getURI } = require('sdk/tabs/utils');
 const { getMostRecentBrowserWindow } = require('sdk/window/utils');
 const { cleanUI } = require("sdk/test/utils");
 const { setTimeout } = require("sdk/timers");
 const { promiseEvent, promiseDOMEvent, promiseEventOnItemAndContainer,
         waitForProcesses, getChildFrameCount, isE10S } = require("./utils");
 const { after } = require('sdk/test/utils');
 const { processID } = require('sdk/system/runtime');
 
 const { set } = require('sdk/preferences/service');
 // The hidden preload browser messes up our frame counts
 set('browser.newtab.preload', false);
 
+function promiseTabFrameAttach(frames) {
+  return new Promise(resolve => {
+    let listener = function(frame, ...args) {
+      if (!frame.isTab)
+        return;
+      frames.off("attach", listener);
+      resolve([frame, ...args]);
+    }
+
+    frames.on("attach", listener);
+  });
+}
+
 // Check that we see a process stop and start
 exports["test process restart"] = function*(assert) {
   if (!isE10S) {
     assert.pass("Skipping test in non-e10s mode");
     return;
   }
 
   let window = getMostRecentBrowserWindow();
@@ -39,30 +52,30 @@ exports["test process restart"] = functi
 
   let remoteProcess = Array.filter(processes, p => p.isRemote)[0];
   let localProcess = Array.filter(processes, p => !p.isRemote)[0];
   let remoteFrame = Array.filter(frames, f => f.process == remoteProcess)[0];
 
   // Switch the remote tab to a local URI which should kill the remote process
 
   let frameDetach = promiseEventOnItemAndContainer(assert, remoteFrame, frames, 'detach');
-  let frameAttach = promiseEvent(frames, 'attach');
+  let frameAttach = promiseTabFrameAttach(frames);
   let processDetach = promiseEventOnItemAndContainer(assert, remoteProcess, processes, 'detach');
   setTabURL(tab, LOCAL_URI);
   // The load should kill the remote frame
   yield frameDetach;
   // And create a new frame in the local process
   let [newFrame] = yield frameAttach;
   assert.equal(newFrame.process, localProcess, "New frame should be in the local process");
   // And kill the process
   yield processDetach;
 
   frameDetach = promiseEventOnItemAndContainer(assert, newFrame, frames, 'detach');
   let processAttach = promiseEvent(processes, 'attach');
-  frameAttach = promiseEvent(frames, 'attach');
+  frameAttach = promiseTabFrameAttach(frames);
   setTabURL(tab, REMOTE_URI);
   // The load should kill the remote frame
   yield frameDetach;
   // And create a new remote process
   [remoteProcess] = yield processAttach;
   assert.ok(remoteProcess.isRemote, "Process should be remote");
   // And create a new frame in the remote process
   [newFrame] = yield frameAttach;
@@ -144,26 +157,26 @@ exports["test frame list"] = function*(a
   assert.equal(tabs.length, 1, "Should have just the one tab to start with");
 
   let loader = new Loader(module);
   let { processes, frames } = yield waitForProcesses(loader);
 
   assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames.");
   assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames");
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab1 = openTab(window, LOCAL_URI);
   let [frame1] = yield promise;
   assert.ok(!!frame1, "Should have seen the new frame");
   assert.ok(!frame1.process.isRemote, "Frame should not be remote");
 
   assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames.");
   assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames");
 
-  promise = promiseEvent(frames, 'attach');
+  promise = promiseTabFrameAttach(frames);
   let tab2 = openTab(window, REMOTE_URI);
   let [frame2] = yield promise;
   assert.ok(!!frame2, "Should have seen the new frame");
   if (isE10S)
     assert.ok(frame2.process.isRemote, "Frame should be remote");
   else
     assert.ok(!frame2.process.isRemote, "Frame should not be remote");
 
@@ -251,17 +264,17 @@ exports["test new loader"] = function*(a
 };
 
 // Test that unloading the loader unloads the child instances
 exports["test unload"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:,<html/>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   promise = promiseDOMEvent(browser, 'hashchange');
   frame.port.emit('sdk/test/testunload');
@@ -275,17 +288,17 @@ exports["test unload"] = function*(asser
 }
 
 // Test that unloading the loader causes the child to see frame detach events
 exports["test frame detach on unload"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:,<html/>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   promise = promiseDOMEvent(browser, 'hashchange');
   frame.port.emit('sdk/test/testdetachonunload');
@@ -299,17 +312,17 @@ exports["test frame detach on unload"] =
 }
 
 // Test that DOM event listener on the frame object works
 exports["test frame event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframeevent');
   promise = Promise.all([
@@ -334,17 +347,17 @@ exports["test frame event listeners"] = 
 }
 
 // Test that DOM event listener on the frames object works
 exports["test frames event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
-  let promise = promiseEvent(frames, 'attach');
+  let promise = promiseTabFrameAttach(frames);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   assert.ok(!!frame, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframesevent');
   promise = Promise.all([
@@ -372,18 +385,18 @@ exports["test frames event listeners"] =
 exports["test unload removes frame event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
   let loader2 = new Loader(module);
   let { frames: frames2 } = yield waitForProcesses(loader2);
 
-  let promise = promiseEvent(frames, 'attach');
-  let promise2 = promiseEvent(frames2, 'attach');
+  let promise = promiseTabFrameAttach(frames);
+  let promise2 = promiseTabFrameAttach(frames2);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   let [frame2] = yield promise2;
   assert.ok(!!frame && !!frame2, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframeevent');
@@ -413,18 +426,18 @@ exports["test unload removes frame event
 exports["test unload removes frames event listeners"] = function*(assert) {
   let window = getMostRecentBrowserWindow();
   let loader = new Loader(module);
   let { frames } = yield waitForProcesses(loader);
 
   let loader2 = new Loader(module);
   let { frames: frames2 } = yield waitForProcesses(loader2);
 
-  let promise = promiseEvent(frames, 'attach');
-  let promise2 = promiseEvent(frames2, 'attach');
+  let promise = promiseTabFrameAttach(frames);
+  let promise2 = promiseTabFrameAttach(frames2);
   let tab = openTab(window, "data:text/html,<html></html>");
   let browser = getBrowserForTab(tab);
   yield promiseDOMEvent(browser, "load", true);
   let [frame] = yield promise;
   let [frame2] = yield promise2;
   assert.ok(!!frame && !!frame2, "Should have seen the new frame");
 
   frame.port.emit('sdk/test/registerframesevent');
--- a/addon-sdk/source/test/jetpack-package.ini
+++ b/addon-sdk/source/test/jetpack-package.ini
@@ -57,16 +57,17 @@ skip-if = true
 [test-dev-panel.js]
 [test-diffpatcher.js]
 [test-dispatcher.js]
 [test-disposable.js]
 [test-dom.js]
 [test-environment.js]
 [test-errors.js]
 [test-event-core.js]
+[test-event-dom.js]
 [test-event-target.js]
 [test-event-utils.js]
 [test-events.js]
 [test-file.js]
 [test-frame-utils.js]
 [test-framescript-manager.js]
 [test-framescript-util.js]
 [test-fs.js]
--- a/addon-sdk/source/test/preferences/no-connections.json
+++ b/addon-sdk/source/test/preferences/no-connections.json
@@ -8,16 +8,17 @@
   "media.gmp-gmpopenh264.autoupdate": false,
   "media.gmp-manager.cert.checkAttributes": false,
   "media.gmp-manager.cert.requireBuiltIn": false,
   "media.gmp-manager.url": "http://localhost/media-dummy/gmpmanager",
   "media.gmp-manager.url.override": "http://localhost/dummy-gmp-manager.xml",
   "browser.aboutHomeSnippets.updateUrl": "https://localhost/snippet-dummy",
   "browser.newtab.url": "about:blank",
   "browser.search.update": false,
+  "browser.search.suggest.enabled": false,
   "browser.safebrowsing.enabled": false,
   "browser.safebrowsing.updateURL": "http://localhost/safebrowsing-dummy/update",
   "browser.safebrowsing.gethashURL": "http://localhost/safebrowsing-dummy/gethash",
   "browser.safebrowsing.reportURL": "http://localhost/safebrowsing-dummy/report",
   "browser.safebrowsing.malware.reportURL": "http://localhost/safebrowsing-dummy/malwarereport",
   "browser.selfsupport.url": "http://localhost/repair-dummy",
   "browser.trackingprotection.gethashURL": "http://localhost/safebrowsing-dummy/gethash",
   "browser.trackingprotection.updateURL": "http://localhost/safebrowsing-dummy/update",
--- a/addon-sdk/source/test/private-browsing/windows.js
+++ b/addon-sdk/source/test/private-browsing/windows.js
@@ -5,16 +5,17 @@
 
 const { onFocus, openDialog, open } = require('sdk/window/utils');
 const { open: openPromise, close, focus, promise } = require('sdk/window/helpers');
 const { isPrivate } = require('sdk/private-browsing');
 const { getMode } = require('sdk/private-browsing/utils');
 const { browserWindows: windows } = require('sdk/windows');
 const { defer } = require('sdk/core/promise');
 const tabs = require('sdk/tabs');
+const { getMostRecentBrowserWindow } = require('sdk/window/utils');
 
 // test openDialog() from window/utils with private option
 // test isActive state in pwpb case
 // test isPrivate on ChromeWindow
 exports.testPerWindowPrivateBrowsingGetter = function*(assert) {
   let win = openDialog({ private: true });
 
   yield promise(win, 'DOMContentLoaded');
@@ -75,37 +76,32 @@ exports.testIsPrivateOnWindowOpenFromPri
       });
 
       return promise;
     }).then(close).
        then(done, assert.fail);
 };
 
 exports.testOpenTabWithPrivateWindow = function*(assert) {
-  let { promise, resolve } = defer();
+  let window = getMostRecentBrowserWindow().OpenBrowserWindow({ private: true });
 
-  let window = yield openPromise(null, {
-    features: {
-      private: true,
-      toolbar: true
-    }
-  });
-  yield focus(window);
+  assert.pass("loading new private window");
+
+  yield promise(window, 'load').then(focus);
 
   assert.equal(isPrivate(window), true, 'the focused window is private');
 
-  tabs.open({
+  yield new Promise(resolve => tabs.open({
     url: 'about:blank',
     onOpen: (tab) => {
       assert.equal(isPrivate(tab), false, 'the opened tab is not private');
       tab.close(resolve);
     }
-  });
+  }));
 
-  yield promise;
   yield close(window);
 };
 
 exports.testIsPrivateOnWindowOff = function(assert, done) {
   windows.open({
     onOpen: function(window) {
       assert.equal(isPrivate(window), false, 'isPrivate for a window is false when it should be');
       assert.equal(isPrivate(window.tabs[0]), false, 'isPrivate for a tab is false when it should be');
new file mode 100644
--- /dev/null
+++ b/addon-sdk/source/test/tabs/utils.js
@@ -0,0 +1,24 @@
+/* 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 { openTab: makeTab, getTabContentWindow } = require("sdk/tabs/utils");
+
+function openTab(rawWindow, url) {
+  return new Promise(resolve => {
+    let tab = makeTab(rawWindow, url);
+    let window = getTabContentWindow(tab);
+    if (window.document.readyState == "complete") {
+      return resolve();
+    }
+
+    window.addEventListener("load", function onLoad() {
+      window.removeEventListener("load", onLoad, true);
+      resolve();
+    }, true);
+
+    return null;
+  })
+}
+exports.openTab = openTab;
new file mode 100644
--- /dev/null
+++ b/addon-sdk/source/test/test-event-dom.js
@@ -0,0 +1,92 @@
+/* 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 { openWindow, closeWindow } = require('./util');
+const { Loader } = require('sdk/test/loader');
+const { getMostRecentBrowserWindow } = require('sdk/window/utils');
+const { Cc, Ci } = require('chrome');
+const els = Cc["@mozilla.org/eventlistenerservice;1"].
+            getService(Ci.nsIEventListenerService);
+
+function countListeners(target, type) {
+  let listeners = els.getListenerInfoFor(target, {});
+  return listeners.filter(listener => listener.type == type).length;
+}
+
+exports['test window close clears listeners'] = function(assert) {
+  let window = yield openWindow();
+  let loader = Loader(module);
+
+  // Any element will do here
+  let gBrowser = window.gBrowser;
+
+  // Other parts of the app may be listening for this
+  let windowListeners = countListeners(window, "DOMWindowClose");
+
+  // We can assume we're the only ones using the test events
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 0, "Should be no listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 0, "Should be no listener for test event 2");
+
+  let { open } = loader.require('sdk/event/dom');
+
+  open(gBrowser, "TestEvent1");
+  assert.equal(countListeners(window, "DOMWindowClose"), windowListeners + 1,
+               "Should have added a DOMWindowClose listener");
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 1, "Should be a listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 0, "Should be no listener for test event 2");
+
+  open(gBrowser, "TestEvent2");
+  assert.equal(countListeners(window, "DOMWindowClose"), windowListeners + 1,
+               "Should not have added another DOMWindowClose listener");
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 1, "Should be a listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 1, "Should be a listener for test event 2");
+
+  window = yield closeWindow(window);
+
+  assert.equal(countListeners(window, "DOMWindowClose"), windowListeners,
+               "Should have removed a DOMWindowClose listener");
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 0, "Should be no listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 0, "Should be no listener for test event 2");
+
+  loader.unload();
+};
+
+exports['test unload clears listeners'] = function(assert) {
+  let window = getMostRecentBrowserWindow();
+  let loader = Loader(module);
+
+  // Any element will do here
+  let gBrowser = window.gBrowser;
+
+  // Other parts of the app may be listening for this
+  let windowListeners = countListeners(window, "DOMWindowClose");
+
+  // We can assume we're the only ones using the test events
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 0, "Should be no listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 0, "Should be no listener for test event 2");
+
+  let { open } = loader.require('sdk/event/dom');
+
+  open(gBrowser, "TestEvent1");
+  assert.equal(countListeners(window, "DOMWindowClose"), windowListeners + 1,
+               "Should have added a DOMWindowClose listener");
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 1, "Should be a listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 0, "Should be no listener for test event 2");
+
+  open(gBrowser, "TestEvent2");
+  assert.equal(countListeners(window, "DOMWindowClose"), windowListeners + 1,
+               "Should not have added another DOMWindowClose listener");
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 1, "Should be a listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 1, "Should be a listener for test event 2");
+
+  loader.unload();
+
+  assert.equal(countListeners(window, "DOMWindowClose"), windowListeners,
+               "Should have removed a DOMWindowClose listener");
+  assert.equal(countListeners(gBrowser, "TestEvent1"), 0, "Should be no listener for test event 1");
+  assert.equal(countListeners(gBrowser, "TestEvent2"), 0, "Should be no listener for test event 2");
+};
+
+require('sdk/test').run(exports);
--- a/addon-sdk/source/test/test-native-loader.js
+++ b/addon-sdk/source/test/test-native-loader.js
@@ -43,16 +43,24 @@ exports['test nodeResolve'] = function (
     'correctly ignores SDK references in paths');
   resolveTest('fs', './index.js', undefined,
     'correctly ignores built in node modules in paths');
 
   resolveTest('test-add', './node_modules/test-math/index.js',
     './node_modules/test-math/node_modules/test-add/index.js',
     'Dependencies\' dependencies can be found');
 
+  resolveTest('resource://gre/modules/commonjs/sdk/tabs.js', './index.js', undefined,
+              'correctly ignores absolute URIs.');
+
+  resolveTest('../tabs', 'resource://gre/modules/commonjs/sdk/addon/bootstrap.js', undefined,
+              'correctly ignores attempts to resolve from a module at an absolute URI.');
+
+  resolveTest('sdk/tabs', 'resource://gre/modules/commonjs/sdk/addon/bootstrap.js', undefined,
+              'correctly ignores attempts to resolve from a module at an absolute URI.');
 
   function resolveTest (id, requirer, expected, msg) {
     let result = nodeResolve(id, requirer, { manifest: manifest, rootURI: rootURI });
     assert.equal(result, expected, 'nodeResolve ' + id + ' from ' + requirer + ' ' +msg);
   }
 }
 
 /*
--- a/addon-sdk/source/test/test-ui-sidebar.js
+++ b/addon-sdk/source/test/test-ui-sidebar.js
@@ -1486,44 +1486,54 @@ exports.testAttachDoesNotEmitWhenShown =
   sidebar.off('show', onShow);
   sidebar.destroy();
 }
 
 exports.testShowHideRawWindowArg = function*(assert) {
   const { Sidebar } = require('sdk/ui/sidebar');
 
   let testName = 'testShowHideRawWindowArg';
+
+  assert.pass("Creating sidebar");
+
   let sidebar = Sidebar({
     id: testName,
     title: testName,
     url: 'data:text/html;charset=utf-8,' + testName
   });
 
+  assert.pass("Created sidebar");
+
   let mainWindow = getMostRecentBrowserWindow();
   let newWindow = yield windowPromise(mainWindow.OpenBrowserWindow(), 'load');
   assert.pass("Created the new window");
 
   yield focus(newWindow);
   assert.pass("Focused the new window");
 
-  yield focus(mainWindow);
-  assert.pass("Focused the old window");
+  let newWindow2 = yield windowPromise(mainWindow.OpenBrowserWindow(), 'load');
+  assert.pass("Created the second new window");
+
+  yield focus(newWindow2);
+  assert.pass("Focused the second new window");
 
   yield sidebar.show(newWindow);
 
   assert.pass('the sidebar was shown');
   assert.equal(isSidebarShowing(mainWindow), false, 'sidebar is not showing in main window');
+  assert.equal(isSidebarShowing(newWindow2), false, 'sidebar is not showing in second window');
   assert.equal(isSidebarShowing(newWindow), true, 'sidebar is showing in new window');
 
-  assert.ok(isFocused(mainWindow), 'main window is still focused');
+  assert.ok(isFocused(newWindow2), 'main window is still focused');
 
   yield sidebar.hide(newWindow);
 
-  assert.equal(isFocused(mainWindow), true, 'main window is still focused');
+  assert.equal(isFocused(newWindow2), true, 'second window is still focused');
   assert.equal(isSidebarShowing(mainWindow), false, 'sidebar is not showing in main window');
+  assert.equal(isSidebarShowing(newWindow2), false, 'sidebar is not showing in second window');
   assert.equal(isSidebarShowing(newWindow), false, 'sidebar is not showing in new window');
 
   sidebar.destroy();
 }
 
 exports.testShowHideSDKWindowArg = function*(assert) {
   const { Sidebar } = require('sdk/ui/sidebar');
 
--- a/addon-sdk/source/test/util.js
+++ b/addon-sdk/source/test/util.js
@@ -26,17 +26,17 @@ const openWindow = () => {
         }
       }
     }, "browser-delayed-startup-finished", false);
   });
 };
 exports.openWindow = openWindow;
 
 const closeWindow = (window) => {
-  const closed = when(window, "unload", true).then(_target);
+  const closed = when(window, "unload", true).then(_ => window);
   window.close();
   return closed;
 };
 exports.closeWindow = closeWindow;
 
 const openTab = (url, window=getMostRecentBrowserWindow()) => {
   const tab = tabUtils.openTab(window, url);
   const browser = tabUtils.getBrowserForTab(tab);
--- a/addon-sdk/source/test/windows/test-firefox-windows.js
+++ b/addon-sdk/source/test/windows/test-firefox-windows.js
@@ -2,26 +2,29 @@
  * 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 { setTimeout } = require('sdk/timers');
 const { Loader } = require('sdk/test/loader');
 const { onFocus, getMostRecentWindow, windows, isBrowser, getWindowTitle, isFocused } = require('sdk/window/utils');
-const { open, close, focus } = require('sdk/window/helpers');
+const { open, close, focus, promise: windowPromise } = require('sdk/window/helpers');
 const { browserWindows } = require("sdk/windows");
 const tabs = require("sdk/tabs");
 const winUtils = require("sdk/deprecated/window-utils");
 const { isPrivate } = require('sdk/private-browsing');
 const { isWindowPBSupported } = require('sdk/private-browsing/utils');
 const { viewFor } = require("sdk/view/core");
 const { defer } = require("sdk/lang/functional");
 const { cleanUI } = require("sdk/test/utils");
 const { after } = require("sdk/test/utils");
+const { merge } = require("sdk/util/object");
+const self = require("sdk/self");
+const { openTab } = require("../tabs/utils");
 
 // TEST: open & close window
 exports.testOpenAndCloseWindow = function(assert, done) {
   assert.equal(browserWindows.length, 1, "Only one window open");
   let title = 'testOpenAndCloseWindow';
 
   browserWindows.open({
     url: "data:text/html;charset=utf-8,<title>" + title + "</title>",
@@ -54,40 +57,51 @@ exports.testNeWindowIsFocused = function
         assert.ok(isFocused(browserWindows.activeWindow), 'the active window is focused');
         assert.ok(!isFocused(mainWindow), 'the main window is not focused');
         done();
       })
     }
   });
 }
 
-exports.testOpenRelativePathWindow = function(assert, done) {
+exports.testOpenRelativePathWindow = function*(assert) {
   assert.equal(browserWindows.length, 1, "Only one window open");
 
-  const { merge } = require("sdk/util/object");
-  const self = require("sdk/self");
-
   let loader = Loader(module, null, null, {
     modules: {
       "sdk/self": merge({}, self, {
         data: merge({}, self.data, require("./../fixtures"))
       })
     }
   });
+  assert.pass("Created a new loader");
 
-  loader.require("sdk/windows").browserWindows.open({
-    url: "./test.html",
-    onOpen: (window) => {
-      window.tabs.activeTab.once("ready", (tab) => {
-        assert.equal(tab.title, "foo",
-          "tab opened a document with relative path");
-        done();
-      });
-    }
-  })
+  let tabReady = new Promise(resolve => {
+    loader.require("sdk/tabs").on("ready", (tab) => {
+      if (!/test\.html$/.test(tab.url))
+        return;
+      assert.equal(tab.title, "foo",
+        "tab opened a document with relative path");
+      resolve();
+    });
+  });
+
+
+  yield new Promise(resolve => {
+    loader.require("sdk/windows").browserWindows.open({
+      url: "./test.html",
+      onOpen: (window) => {
+        assert.pass("Created a new window");
+        resolve();
+      }
+    })
+  });
+
+  yield tabReady;
+  loader.unload();
 }
 
 exports.testAutomaticDestroy = function(assert, done) {
   let windows = browserWindows;
 
   // Create a second windows instance that we will unload
   let called = false;
   let loader = Loader(module);
@@ -213,83 +227,59 @@ exports.testOnOpenOnCloseListeners = fun
         done();
       });
     }
   });
 };
 
 exports.testActiveWindow = function*(assert) {
   let windows = browserWindows;
-
-  // API window objects
-  let window2, window3;
+  let window = getMostRecentWindow();
 
   // Raw window objects
-  let rawWindow2, rawWindow3;
-
-  yield new Promise(resolve => {
-    windows.open({
-      url: "data:text/html;charset=utf-8,<title>window 2</title>",
-      onOpen: (window) => {
-        assert.pass('window 2 open');
+  let rawWindow2 =  yield windowPromise(window.OpenBrowserWindow(), "load").then(focus);
+  assert.pass("Window 2 was created");
 
-        window.tabs.activeTab.once('ready', () => {
-          assert.pass('window 2 tab activated');
+  // open a tab in window 2
+  yield openTab(rawWindow2, "data:text/html;charset=utf-8,<title>window 2</title>");
 
-          window2 = window;
-          rawWindow2 = viewFor(window);
-
-          assert.equal(rawWindow2.content.document.title, "window 2", "Got correct raw window 2");
-          assert.equal(rawWindow2.document.title, window2.title, "Saw correct title on window 2");
+  assert.equal(rawWindow2.content.document.title, "window 2", "Got correct raw window 2");
+  assert.equal(rawWindow2.document.title, windows[1].title, "Saw correct title on window 2");
 
-          windows.open({
-            url: "data:text/html;charset=utf-8,<title>window 3</title>",
-            onOpen: (window) => {
-              assert.pass('window 3 open');
-
-              window.tabs.activeTab.once('ready', () => {
-                assert.pass('window 3 tab activated');
-
-                window3 = window;
-                rawWindow3 = viewFor(window);
+  let rawWindow3 =  yield windowPromise(window.OpenBrowserWindow(), "load").then(focus);;
+  assert.pass("Window 3 was created");
 
-                assert.equal(rawWindow3.content.document.title, "window 3", "Got correct raw window 3");
-                assert.equal(rawWindow3.document.title, window3.title, "Saw correct title on window 3");
+  // open a tab in window 3
+  yield openTab(rawWindow3, "data:text/html;charset=utf-8,<title>window 3</title>");
 
-                resolve();
-              });
-            }
-          });
-        });
-      }
-    });
-  });
-
-  yield focus(rawWindow3);
+  assert.equal(rawWindow3.content.document.title, "window 3", "Got correct raw window 3");
+  assert.equal(rawWindow3.document.title, windows[2].title, "Saw correct title on window 3");
 
   assert.equal(windows.length, 3, "Correct number of browser windows");
 
   let count = 0;
   for (let window in windows) {
     count++;
   }
   assert.equal(count, 3, "Correct number of windows returned by iterator");
-  assert.equal(windows.activeWindow.title, window3.title, "Correct active window title - 3");
+  assert.equal(windows.activeWindow.title, windows[2].title, "Correct active window title - 3");
+  let window3 = windows[2];
 
   yield focus(rawWindow2);
 
-  assert.equal(windows.activeWindow.title, window2.title, "Correct active window title - 2");
+  assert.equal(windows.activeWindow.title, windows[1].title, "Correct active window title - 2");
+  let window2 = windows[1];
 
   yield new Promise(resolve => {
     onFocus(rawWindow2).then(resolve);
     window2.activate();
     assert.pass("activating window2");
   });
 
-  assert.equal(windows.activeWindow.title, window2.title, "Correct active window - 2");
+  assert.equal(windows.activeWindow.title, windows[1].title, "Correct active window - 2");
 
   yield new Promise(resolve => {
     onFocus(rawWindow3).then(resolve);
     window3.activate();
     assert.pass("activating window3");
   });
 
   assert.equal(windows.activeWindow.title, window3.title, "Correct active window - 3");
@@ -386,31 +376,42 @@ exports.testTrackWindows = function(asse
         actions.push("deactivate " + index)
       }
     }));
   }
   openWindow();
 }
 
 // test that it is not possible to open a private window by default
-exports.testWindowOpenPrivateDefault = function(assert, done) {
-  browserWindows.open({
-    url: 'about:mozilla',
+exports.testWindowOpenPrivateDefault = function*(assert) {
+  const TITLE = "yo";
+  const URL = "data:text/html,<title>" + TITLE + "</title>";
+
+  let tabReady = new Promise(resolve => {
+    tabs.on('ready', function onTabReady(tab) {
+      if (tab.url != URL)
+        return;
+
+      tabs.removeListener('ready', onTabReady);
+      assert.equal(tab.title, TITLE, 'opened correct tab');
+      assert.equal(isPrivate(tab), false, 'tab is not private');
+      resolve();
+    });
+  })
+
+  yield new Promise(resolve => browserWindows.open({
+    url: URL,
     isPrivate: true,
     onOpen: function(window) {
-      let tab = window.tabs[0];
+      assert.pass("the new window was opened");
+      resolve();
+    }
+  }));
 
-      tab.once('ready', function() {
-        assert.equal(tab.url, 'about:mozilla', 'opened correct tab');
-        assert.equal(isPrivate(tab), false, 'tab is not private');
-
-        done();
-      });
-    }
-  });
+  yield tabReady;
 }
 
 // test that it is not possible to find a private window in
 // windows module's iterator
 exports.testWindowIteratorPrivateDefault = function*(assert) {
   assert.equal(browserWindows.length, 1, 'only one window open');
 
   let window = yield open('chrome://browser/content/browser.xul', {
--- a/browser/base/content/browser-loop.js
+++ b/browser/base/content/browser-loop.js
@@ -485,17 +485,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
     handleEvent: function(event) {
       // We only should get "select" events.
       if (event.type != "TabSelect") {
         return;
       }
 
       let wasVisible = false;
       // Hide the infobar from the previous tab.
-      if (event.detail.previousTabfromTab) {
+      if (event.detail.previousTab) {
         wasVisible = this._hideBrowserSharingInfoBar(false, event.detail.previousTab.linkedBrowser);
       }
 
       // We've changed the tab, so get the new window id.
       for (let listener of this._tabChangeListeners) {
         try {
           listener(null, gBrowser.selectedBrowser.outerWindowID);
         } catch (ex) {
--- a/browser/base/content/popup-notifications.inc
+++ b/browser/base/content/popup-notifications.inc
@@ -57,17 +57,17 @@
       <popupnotificationcontent orient="vertical" align="start">
         <separator class="thin"/>
         <label id="pointerLock-cancel">&pointerLock.notification.message;</label>
       </popupnotificationcontent>
     </popupnotification>
 
     <popupnotification id="password-notification" hidden="true">
       <popupnotificationcontent orient="vertical">
-        <textbox id="password-notification-username" disabled="true"/>
+        <textbox id="password-notification-username"/>
         <textbox id="password-notification-password" type="password"
                  disabled="true"/>
       </popupnotificationcontent>
     </popupnotification>
 
 #ifdef E10S_TESTING_ONLY
     <popupnotification id="enable-e10s-notification" hidden="true">
       <popupnotificationcontent orient="vertical"/>
--- a/browser/base/content/test/referrer/browser_referrer_middle_click.js
+++ b/browser/base/content/test/referrer/browser_referrer_middle_click.js
@@ -9,11 +9,11 @@ function startMiddleClickTestCase(aTestN
     checkReferrerAndStartNextTest(aTestNumber, null, aNewTab,
                                   startMiddleClickTestCase);
   });
 
   clickTheLink(gTestWindow, "testlink", {button: 1});
 }
 
 function test() {
-  requestLongerTimeout(5);  // slowwww shutdown on e10s
+  requestLongerTimeout(10);  // slowwww shutdown on e10s
   startReferrerTest(startMiddleClickTestCase);
 }
--- a/browser/base/content/test/referrer/browser_referrer_open_link_in_private.js
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_private.js
@@ -12,11 +12,11 @@ function startNewPrivateWindowTestCase(a
       });
     });
 
     doContextMenuCommand(gTestWindow, aContextMenu, "context-openlinkprivate");
   });
 }
 
 function test() {
-  requestLongerTimeout(5);  // slowwww shutdown on e10s
+  requestLongerTimeout(10);  // slowwww shutdown on e10s
   startReferrerTest(startNewPrivateWindowTestCase);
 }
--- a/browser/base/content/test/referrer/browser_referrer_open_link_in_tab.js
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_tab.js
@@ -11,11 +11,11 @@ function startNewTabTestCase(aTestNumber
                                     startNewTabTestCase);
     });
 
     doContextMenuCommand(gTestWindow, aContextMenu, "context-openlinkintab");
   });
 }
 
 function test() {
-  requestLongerTimeout(5);  // slowwww shutdown on e10s
+  requestLongerTimeout(10);  // slowwww shutdown on e10s
   startReferrerTest(startNewTabTestCase);
 }
--- a/browser/base/content/test/referrer/browser_referrer_open_link_in_window.js
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_window.js
@@ -12,11 +12,11 @@ function startNewWindowTestCase(aTestNum
       });
     });
 
     doContextMenuCommand(gTestWindow, aContextMenu, "context-openlink");
   });
 }
 
 function test() {
-  requestLongerTimeout(5);  // slowwww shutdown on e10s
+  requestLongerTimeout(10);  // slowwww shutdown on e10s
   startReferrerTest(startNewWindowTestCase);
 }
--- a/browser/base/content/test/referrer/browser_referrer_simple_click.js
+++ b/browser/base/content/test/referrer/browser_referrer_simple_click.js
@@ -8,11 +8,11 @@ function startSimpleClickTestCase(aTestN
     checkReferrerAndStartNextTest(aTestNumber, null, null,
                                   startSimpleClickTestCase);
   });
 
   clickTheLink(gTestWindow, "testlink", {});
 };
 
 function test() {
-  requestLongerTimeout(5);  // slowwww shutdown on e10s
+  requestLongerTimeout(10);  // slowwww shutdown on e10s
   startReferrerTest(startSimpleClickTestCase);
 }
--- a/browser/components/preferences/in-content/content.xul
+++ b/browser/components/preferences/in-content/content.xul
@@ -85,17 +85,17 @@
 </groupbox>
 
 <!-- Fonts and Colors -->
 <groupbox id="fontsGroup" data-category="paneContent" hidden="true">
   <caption><label>&fontsAndColors.label;</label></caption>
   <hbox align="center">
     <label control="defaultFont" accesskey="&defaultFont.accesskey;">&defaultFont.label;</label>
     <menulist id="defaultFont" flex="1"/>
-    <label control="defaultFontSize" accesskey="&defaultSize.accesskey;">&defaultSize.label;</label>
+    <label id="defaultFontSizeLabel" control="defaultFontSize" accesskey="&defaultSize.accesskey;">&defaultSize.label;</label>
     <menulist id="defaultFontSize">
       <menupopup>
         <menuitem value="9" label="9"/>
         <menuitem value="10" label="10"/>
         <menuitem value="11" label="11"/>
         <menuitem value="12" label="12"/>
         <menuitem value="13" label="13"/>
         <menuitem value="14" label="14"/>
--- a/browser/components/sessionstore/SessionMigration.jsm
+++ b/browser/components/sessionstore/SessionMigration.jsm
@@ -67,24 +67,19 @@ let SessionMigrationInternal = {
             win.extData[k] = oldWin.extData[k];
           }
         }
       }
       win.selected = oldWin.selected;
       win._closedTabs = [];
       return win;
     });
-    let wrappedState = {
-      url: "about:welcomeback",
-      formdata: {
-        id: {"sessionData": state},
-        xpath: {}
-      }
-    };
-    return {windows: [{tabs: [{entries: [wrappedState]}]}]};
+    let url = "about:welcomeback";
+    let formdata = {id: {sessionData: state}, url};
+    return {windows: [{tabs: [{entries: [{url}], formdata}]}]};
   },
   /**
    * Asynchronously read session restore state (JSON) from a path
    */
   readState: function(aPath) {
     return Task.spawn(function() {
       let bytes = yield OS.File.read(aPath);
       let text = gDecoder.decode(bytes);
--- a/browser/components/sessionstore/SessionStore.jsm
+++ b/browser/components/sessionstore/SessionStore.jsm
@@ -465,24 +465,19 @@ let SessionStoreInternal = {
           LastSession.setState(state.lastSessionState);
 
           if (ss.previousSessionCrashed) {
             this._recentCrashes = (state.session &&
                                    state.session.recentCrashes || 0) + 1;
 
             if (this._needsRestorePage(state, this._recentCrashes)) {
               // replace the crashed session with a restore-page-only session
-              let pageData = {
-                url: "about:sessionrestore",
-                formdata: {
-                  id: { "sessionData": state },
-                  xpath: {}
-                }
-              };
-              state = { windows: [{ tabs: [{ entries: [pageData] }] }] };
+              let url = "about:sessionrestore";
+              let formdata = {id: {sessionData: state}, url};
+              state = { windows: [{ tabs: [{ entries: [{url}], formdata }] }] };
             } else if (this._hasSingleTabWithURL(state.windows,
                                                  "about:welcomeback")) {
               // On a single about:welcomeback URL that crashed, replace about:welcomeback
               // with about:sessionrestore, to make clear to the user that we crashed.
               state.windows[0].tabs[0].entries[0].url = "about:sessionrestore";
             }
           }
 
--- a/browser/components/uitour/test/browser_no_tabs.js
+++ b/browser/components/uitour/test/browser_no_tabs.js
@@ -1,62 +1,54 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
+let HiddenFrame = Cu.import("resource:///modules/HiddenFrame.jsm", {}).HiddenFrame;
+
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 
 /**
  * Create a frame in the |hiddenDOMWindow| to host a |browser|, then load the URL in the
  * latter.
  *
  * @param aURL
  *        The URL to open in the browser.
  **/
 function createHiddenBrowser(aURL) {
-  let deferred = Promise.defer();
-  let hiddenDoc = Services.appShell.hiddenDOMWindow.document;
-
-  // Create a HTML iframe with a chrome URL, then this can host the browser.
-  let iframe = hiddenDoc.createElementNS(HTML_NS, "iframe");
-  iframe.setAttribute("src", "chrome://global/content/mozilla.xhtml");
-  iframe.addEventListener("load", function onLoad() {
-    iframe.removeEventListener("load", onLoad, true);
+  let frame = new HiddenFrame();
+  return new Promise(resolve =>
+    frame.get().then(aFrame => {
+      let doc = aFrame.document;
+      let browser = doc.createElementNS(XUL_NS, "browser");
+      browser.setAttribute("type", "content");
+      browser.setAttribute("disableglobalhistory", "true");
+      browser.setAttribute("src", aURL);
 
-    let browser = iframe.contentDocument.createElementNS(XUL_NS, "browser");
-    browser.setAttribute("type", "content");
-    browser.setAttribute("disableglobalhistory", "true");
-    browser.setAttribute("src", aURL);
-
-    iframe.contentDocument.documentElement.appendChild(browser);
-    deferred.resolve({frame: iframe, browser: browser});
-  }, true);
-
-  hiddenDoc.documentElement.appendChild(iframe);
-  return deferred.promise;
-};
+      doc.documentElement.appendChild(browser);
+      resolve({frame: frame, browser: browser});
+    }));
+}
 
 /**
- * Remove the browser and the iframe.
+ * Remove the browser and the HiddenFrame.
  *
  * @param aFrame
- *        The iframe to dismiss.
+ *        The HiddenFrame to dismiss.
  * @param aBrowser
  *        The browser to dismiss.
  */
 function destroyHiddenBrowser(aFrame, aBrowser) {
   // Dispose of the hidden browser.
   aBrowser.remove();
 
   // Take care of the frame holding our invisible browser.
-  if (!Cu.isDeadWrapper(aFrame)) {
-    aFrame.remove();
-  }
+  aFrame.destroy();
 };
 
 /**
  * Test that UITour works when called when no tabs are available (e.g., when using windowless
  * browsers).
  */
 add_task(function* test_windowless_UITour(){
   // Get the URL for the test page.
--- a/browser/devtools/debugger/debugger-controller.js
+++ b/browser/devtools/debugger/debugger-controller.js
@@ -491,20 +491,32 @@ ThreadState.prototype = {
 
   /**
    * Update the UI after a thread state change.
    */
   _update: function(aEvent, aPacket) {
     // Ignore "interrupted" events, to avoid UI flicker. These are generated
     // by the slow script dialog and internal events such as setting
     // breakpoints. Pressing the resume button does need to be shown, though.
-    if (aEvent == "paused" &&
-        aPacket.why.type == "interrupted" &&
-        !this.interruptedByResumeButton) {
-      return;
+    if (aEvent == "paused") {
+      if (aPacket.why.type == "interrupted" &&
+          !this.interruptedByResumeButton) {
+        return;
+      } else if (aPacket.why.type == "breakpointConditionThrown" && aPacket.why.message) {
+        let where = aPacket.frame.where;
+        let aLocation = {
+          line: where.line,
+          column: where.column,
+          actor: where.source ? where.source.actor : null
+        };
+        DebuggerView.Sources.showBreakpointConditionThrownMessage(
+          aLocation,
+          aPacket.why.message
+        );
+      }
     }
 
     this.interruptedByResumeButton = false;
     DebuggerView.Toolbar.toggleResumeButtonState(this.activeThread.state);
 
     if (gTarget && (aEvent == "paused" || aEvent == "resumed")) {
       gTarget.emit("thread-" + aEvent);
     }
@@ -585,16 +597,20 @@ StackFrames.prototype = {
    *        The response packet.
    */
   _onPaused: function(aEvent, aPacket) {
     switch (aPacket.why.type) {
       // If paused by a breakpoint, store the breakpoint location.
       case "breakpoint":
         this._currentBreakpointLocation = aPacket.frame.where;
         break;
+      case "breakpointConditionThrown":
+        this._currentBreakpointLocation = aPacket.frame.where;
+        this._conditionThrowMessage = aPacket.why.message;
+        break;
       // If paused by a client evaluation, store the evaluated value.
       case "clientEvaluated":
         this._currentEvaluation = aPacket.why.frameFinished;
         break;
       // If paused by an exception, store the exception value.
       case "exception":
         this._currentException = aPacket.why.exception;
         break;
--- a/browser/devtools/debugger/debugger-panes.js
+++ b/browser/devtools/debugger/debugger-panes.js
@@ -446,16 +446,29 @@ SourcesView.prototype = Heritage.extend(
    * Unhighlights the current breakpoint in this sources container.
    */
   unhighlightBreakpoint: function() {
     this._hideConditionalPopup();
     this._unselectBreakpoint();
   },
 
   /**
+   * Display the message thrown on breakpoint condition
+   */
+  showBreakpointConditionThrownMessage: function(aLocation, aMessage = "") {
+    let breakpointItem = this.getBreakpoint(aLocation);
+    if (!breakpointItem) {
+      return;
+    }
+    let attachment = breakpointItem.attachment;
+    attachment.view.container.classList.add("dbg-breakpoint-condition-thrown");
+    attachment.view.message.setAttribute("value", aMessage);
+  },
+
+  /**
    * Update the checked/unchecked and enabled/disabled states of the buttons in
    * the sources toolbar based on the currently selected source's state.
    */
   updateToolbarButtonsState: function() {
     const { source } = this.selectedItem.attachment;
     const sourceClient = gThreadClient.source(source);
 
     if (sourceClient.isBlackBoxed) {
@@ -684,22 +697,23 @@ SourcesView.prototype = Heritage.extend(
   /**
    * Customization function for creating a breakpoint item's UI.
    *
    * @param object aOptions
    *        A couple of options or flags supported by this operation:
    *          - location: the breakpoint's source location and line number
    *          - disabled: the breakpoint's disabled state, boolean
    *          - text: the breakpoint's line text to be displayed
+   *          - message: thrown string when the breakpoint condition throws,
    * @return object
    *         An object containing the breakpoint container, checkbox,
    *         line number and line text nodes.
    */
   _createBreakpointView: function(aOptions) {
-    let { location, disabled, text } = aOptions;
+    let { location, disabled, text, message } = aOptions;
     let identifier = DebuggerController.Breakpoints.getIdentifier(location);
 
     let checkbox = document.createElement("checkbox");
     checkbox.setAttribute("checked", !disabled);
     checkbox.className = "dbg-breakpoint-checkbox";
 
     let lineNumberNode = document.createElement("label");
     lineNumberNode.className = "plain dbg-breakpoint-line";
@@ -709,35 +723,55 @@ SourcesView.prototype = Heritage.extend(
     lineTextNode.className = "plain dbg-breakpoint-text";
     lineTextNode.setAttribute("value", text);
     lineTextNode.setAttribute("crop", "end");
     lineTextNode.setAttribute("flex", "1");
 
     let tooltip = text ? text.substr(0, BREAKPOINT_LINE_TOOLTIP_MAX_LENGTH) : "";
     lineTextNode.setAttribute("tooltiptext", tooltip);
 
+    let thrownNode = document.createElement("label");
+    thrownNode.className = "plain dbg-breakpoint-condition-thrown-message dbg-breakpoint-text";
+    thrownNode.setAttribute("value", message);
+    thrownNode.setAttribute("crop", "end");
+    thrownNode.setAttribute("flex", "1");
+
+    let bpLineContainer = document.createElement("hbox");
+    bpLineContainer.className = "plain dbg-breakpoint-line-container";
+    bpLineContainer.setAttribute("flex", "1");
+
+    bpLineContainer.appendChild(lineNumberNode);
+    bpLineContainer.appendChild(lineTextNode);
+
+    let bpDetailContainer = document.createElement("vbox");
+    bpDetailContainer.className = "plain dbg-breakpoint-detail-container";
+    bpDetailContainer.setAttribute("flex", "1");
+
+    bpDetailContainer.appendChild(bpLineContainer);
+    bpDetailContainer.appendChild(thrownNode);
+
     let container = document.createElement("hbox");
     container.id = "breakpoint-" + identifier;
     container.className = "dbg-breakpoint side-menu-widget-item-other";
     container.classList.add("devtools-monospace");
     container.setAttribute("align", "center");
     container.setAttribute("flex", "1");
 
     container.addEventListener("click", this._onBreakpointClick, false);
     checkbox.addEventListener("click", this._onBreakpointCheckboxClick, false);
 
     container.appendChild(checkbox);
-    container.appendChild(lineNumberNode);
-    container.appendChild(lineTextNode);
+    container.appendChild(bpDetailContainer);
 
     return {
       container: container,
       checkbox: checkbox,
       lineNumber: lineNumberNode,
-      lineText: lineTextNode
+      lineText: lineTextNode,
+      message: thrownNode
     };
   },
 
   /**
    * Creates a context menu for a breakpoint element.
    *
    * @param object aOptions
    *        A couple of options or flags supported by this operation:
--- a/browser/devtools/debugger/debugger.css
+++ b/browser/devtools/debugger/debugger.css
@@ -43,8 +43,18 @@
 #body[layout=horizontal] #vertical-layout-splitter,
 #body[layout=horizontal] #vertical-layout-panes-container {
   display: none;
 }
 
 #body[layout=vertical] #stackframes {
   visibility: hidden;
 }
+
+#source-progress-container {
+  display: flex;
+  flex-flow: column;
+  justify-content: center;
+}
+
+#source-progress {
+  flex: none;
+}
--- a/browser/devtools/debugger/debugger.xul
+++ b/browser/devtools/debugger/debugger.xul
@@ -10,16 +10,17 @@
 <?xml-stylesheet href="chrome://browser/skin/devtools/debugger.css" type="text/css"?>
 <!DOCTYPE window [
   <!ENTITY % debuggerDTD SYSTEM "chrome://browser/locale/devtools/debugger.dtd">
   %debuggerDTD;
 ]>
 <?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?>
 
 <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
         macanimationtype="document"
         fullscreenbutton="true"
         screenX="4" screenY="4"
         width="960" height="480"
         persist="screenX screenY width height sizemode">
 
   <script type="application/javascript;version=1.8"
           src="chrome://browser/content/devtools/theme-switching.js"/>
@@ -416,22 +417,22 @@
           <description id="black-boxed-message-label">
             &debuggerUI.blackBoxMessage.label;
           </description>
           <button id="black-boxed-message-button"
                   class="devtools-toolbarbutton"
                   label="&debuggerUI.blackBoxMessage.unBlackBoxButton;"
                   command="unBlackBoxCommand"/>
         </vbox>
-        <vbox id="source-progress-container"
-              align="center"
-              pack="center">
-          <progressmeter id="source-progress"
-                         mode="undetermined"/>
-        </vbox>
+        <html:div id="source-progress-container"
+                  align="center">
+          <html:div id="hbox">
+            <html:progress id="source-progress"></html:progress>
+          </html:div>
+        </html:div>
       </deck>
       <splitter id="editor-and-instruments-splitter"
                 class="devtools-side-splitter"/>
       <tabbox id="instruments-pane"
               class="devtools-sidebar-tabs"
               hidden="true">
         <tabs>
           <tab id="variables-tab" label="&debuggerUI.tabs.variables;"/>
--- a/browser/devtools/debugger/test/browser.ini
+++ b/browser/devtools/debugger/test/browser.ini
@@ -138,16 +138,18 @@ skip-if = e10s # TODO
 [browser_dbg_break-on-dom-event-03.js]
 skip-if = e10s # TODO
 [browser_dbg_breakpoints-actual-location.js]
 [browser_dbg_breakpoints-actual-location2.js]
 [browser_dbg_breakpoints-break-on-last-line-of-script-on-reload.js]
 skip-if = e10s # Bug 1093535
 [browser_dbg_breakpoints-button-01.js]
 [browser_dbg_breakpoints-button-02.js]
+[browser_dbg_breakpoints-condition-thrown-message.js]
+skip-if = e10s && debug
 [browser_dbg_breakpoints-contextmenu-add.js]
 [browser_dbg_breakpoints-contextmenu.js]
 [browser_dbg_breakpoints-disabled-reload.js]
 skip-if = e10s # Bug 1093535
 [browser_dbg_breakpoints-editor.js]
 skip-if = e10s && debug
 [browser_dbg_breakpoints-eval.js]
 skip-if = e10s && debug
new file mode 100644
--- /dev/null
+++ b/browser/devtools/debugger/test/browser_dbg_breakpoints-condition-thrown-message.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Make sure that the message which breakpoint condition throws
+ * could be displayed on UI correctly
+ */
+
+const TAB_URL = EXAMPLE_URL + "doc_conditional-breakpoints.html";
+
+function test() {
+  let gTab, gPanel, gDebugger, gEditor;
+  let gSources, gLocation;
+
+  initDebugger(TAB_URL).then(([aTab,, aPanel]) => {
+    gTab = aTab;
+    gPanel = aPanel;
+    gDebugger = gPanel.panelWin;
+    gEditor = gDebugger.DebuggerView.editor;
+    gSources = gDebugger.DebuggerView.Sources;
+
+    waitForSourceAndCaretAndScopes(gPanel, ".html", 17)
+      .then(addBreakpoints)
+      .then(() => resumeAndTestThrownMessage(18))
+      .then(() => resumeAndTestNoThrownMessage(19))
+      .then(() => resumeAndTestThrownMessage(22))
+      .then(() => resumeAndFinishTest())
+      .then(() => closeDebuggerAndFinish(gPanel))
+      .then(null, aError => {
+        ok(false, "Got an error: " + aError.message + "\n" + aError.stack);
+      });
+
+    callInTab(gTab, "ermahgerd");
+  });
+
+  function resumeAndTestThrownMessage(aLine) {
+    EventUtils.sendMouseEvent({ type: "mousedown" },
+      gDebugger.document.getElementById("resume"),
+      gDebugger);
+
+    let finished = waitForCaretUpdated(gPanel, aLine).then(() => {
+      //test that the thrown message is correctly shown
+      let attachment = gSources.getBreakpoint({ actor: gSources.values[0], line: aLine}).attachment;
+      ok(attachment.view.container.classList.contains('dbg-breakpoint-condition-thrown'),
+        "Message on line " + aLine + " should be shown when condition throws.");
+    });
+
+    return finished;
+  }
+
+  function resumeAndTestNoThrownMessage(aLine) {
+    EventUtils.sendMouseEvent({ type: "mousedown" },
+      gDebugger.document.getElementById("resume"),
+      gDebugger);
+
+    let finished = waitForCaretUpdated(gPanel, aLine).then(() => {
+      //test that the thrown message is correctly shown
+      let attachment = gSources.getBreakpoint({ actor: gSources.values[0], line: aLine}).attachment;
+      ok(!attachment.view.container.classList.contains("dbg-breakpoint-condition-thrown"),
+        "Message on line " + aLine + " should be hidden if condition doesn't throw.");
+    });
+    return finished;
+  }
+
+  function resumeAndFinishTest() {
+    let finished = waitForDebuggerEvents(gPanel, gDebugger.EVENTS.AFTER_FRAMES_CLEARED)
+
+    gDebugger.gThreadClient.resume();
+
+    return finished;
+  }
+
+  function addBreakpoints() {
+    return promise.resolve(null)
+      .then(() => gPanel.addBreakpoint({ actor: gSources.selectedValue,
+                                         line: 18,
+                                         condition: " 1a"}))
+      .then(() => initialCheck(18))
+      .then(() => gPanel.addBreakpoint({ actor: gSources.selectedValue,
+                                         line: 19,
+                                         condition: "true"}))
+      .then(() => initialCheck(19))
+      .then(() => gPanel.addBreakpoint({ actor: gSources.selectedValue,
+                                         line: 20,
+                                         condition: "false"}))
+      .then(() => initialCheck(20))
+      .then(() => gPanel.addBreakpoint({ actor: gSources.selectedValue,
+                                         line: 22,
+                                         condition: "randomVar"}))
+      .then(() => initialCheck(22));
+  }
+
+  function initialCheck(aCaretLine) {
+    let bp = gSources.getBreakpoint({ actor: gSources.values[0], line: aCaretLine})
+    let attachment = bp.attachment;
+    ok(attachment,
+      "There should be an item for line " + aCaretLine + " in the sources pane.");
+
+    let thrownNode = attachment.view.container.querySelector(".dbg-breakpoint-condition-thrown-message");
+    ok(thrownNode,
+      "The breakpoint item should contain a thrown message node.")
+
+    ok(!attachment.view.container.classList.contains("dbg-breakpoint-condition-thrown"),
+      "The thrown message on line " + aCaretLine + " should be hidden when condition has not been evaluated.")
+  }
+}
--- a/browser/devtools/styleinspector/rule-view.js
+++ b/browser/devtools/styleinspector/rule-view.js
@@ -430,21 +430,18 @@ function Rule(aElementStyle, aOptions) {
   this.matchedSelectors = aOptions.matchedSelectors || [];
   this.pseudoElement = aOptions.pseudoElement || "";
 
   this.isSystem = aOptions.isSystem;
   this.inherited = aOptions.inherited || null;
   this.keyframes = aOptions.keyframes || null;
   this._modificationDepth = 0;
 
-  if (this.domRule) {
-    let parentRule = this.domRule.parentRule;
-    if (parentRule && parentRule.type == Ci.nsIDOMCSSRule.MEDIA_RULE) {
-      this.mediaText = parentRule.mediaText;
-    }
+  if (this.domRule && this.domRule.mediaText) {
+    this.mediaText = this.domRule.mediaText;
   }
 
   // Populate the text properties with the style's current cssText
   // value, and add in any disabled properties from the store.
   this.textProps = this._getTextProperties();
   this.textProps = this.textProps.concat(this._getDisabledProperties());
 }
 
@@ -502,17 +499,17 @@ Rule.prototype = {
   get sheet() {
     return this.domRule ? this.domRule.parentStyleSheet : null;
   },
 
   /**
    * The rule's line within a stylesheet
    */
   get ruleLine() {
-    return this.domRule ? this.domRule.line : null;
+    return this.domRule ? this.domRule.line : "";
   },
 
   /**
    * The rule's column within a stylesheet
    */
   get ruleColumn() {
     return this.domRule ? this.domRule.column : null;
   },
@@ -524,20 +521,22 @@ Rule.prototype = {
    * @return {Promise}
    *         Promise which resolves with location as an object containing
    *         both the full and short version of the source string.
    */
   getOriginalSourceStrings: function() {
     if (this._originalSourceStrings) {
       return promise.resolve(this._originalSourceStrings);
     }
-    return this.domRule.getOriginalLocation().then(({href, line}) => {
+    return this.domRule.getOriginalLocation().then(({href, line, mediaText}) => {
+      let mediaString = mediaText ? " @" + mediaText : "";
+
       let sourceStrings = {
-        full: href + ":" + line,
-        short: CssLogic.shortSource({href: href}) + ":" + line
+        full: (href || CssLogic.l10n("rule.sourceInline")) + ":" + line + mediaString,
+        short: CssLogic.shortSource({href: href}) + ":" + line + mediaString
       };
 
       this._originalSourceStrings = sourceStrings;
       return sourceStrings;
     }, console.error);
   },
 
   /**
--- a/browser/devtools/styleinspector/test/browser_ruleview_content_01.js
+++ b/browser/devtools/styleinspector/test/browser_ruleview_content_01.js
@@ -7,18 +7,20 @@
 // Test that the rule-view content is correct
 
 add_task(function*() {
   yield addTab("data:text/html;charset=utf-8,browser_ruleview_content.js");
   let {toolbox, inspector, view} = yield openRuleView();
 
   info("Creating the test document");
   let style = "" +
-    "#testid {" +
-    "  background-color: blue;" +
+    "@media screen and (min-width: 10px) {" +
+    "  #testid {" +
+    "    background-color: blue;" +
+    "  }" +
     "}" +
     ".testclass, .unmatched {" +
     "  background-color: green;" +
     "}";
   let styleNode = addStyle(content.document, style);
   content.document.body.innerHTML = "<div id='testid' class='testclass'>Styled Node</div>" +
                                     "<div id='testid2'>Styled Node</div>";
 
@@ -30,14 +32,23 @@ function* testContentAfterNodeSelection(
   is(ruleView.element.querySelectorAll("#noResults").length, 0,
     "After a highlight, no longer has a no-results element.");
 
   yield clearCurrentNodeSelection(inspector)
   is(ruleView.element.querySelectorAll("#noResults").length, 1,
     "After highlighting null, has a no-results element again.");
 
   yield selectNode("#testid", inspector);
+
+  let linkText = getRuleViewLinkTextByIndex(ruleView, 1);
+  is(linkText, "inline:1 @screen and (min-width: 10px)",
+    "link text at index 1 contains media query text.");
+
+  linkText = getRuleViewLinkTextByIndex(ruleView, 2);
+  is(linkText, "inline:1",
+    "link text at index 2 contains no media query text.");
+
   let classEditor = getRuleViewRuleEditor(ruleView, 2);
   is(classEditor.selectorText.querySelector(".ruleview-selector-matched").textContent,
     ".testclass", ".textclass should be matched.");
   is(classEditor.selectorText.querySelector(".ruleview-selector-unmatched").textContent,
     ".unmatched", ".unmatched should not be matched.");
 }
--- a/browser/devtools/styleinspector/test/head.js
+++ b/browser/devtools/styleinspector/test/head.js
@@ -689,16 +689,27 @@ let simulateColorPickerChange = Task.asy
  * @return {DOMNode} The link if any at this index
  */
 function getRuleViewLinkByIndex(view, index) {
   let links = view.doc.querySelectorAll(".ruleview-rule-source");
   return links[index];
 }
 
 /**
+ * Get rule-link text from the rule-view given its index
+ * @param {CssRuleView} view The instance of the rule-view panel
+ * @param {Number} index The index of the link to get
+ * @return {String} The string at this index
+ */
+function getRuleViewLinkTextByIndex(view, index) {
+  let link = getRuleViewLinkByIndex(view, index);
+  return link.querySelector(".source-link-label").value;
+}
+
+/**
  * Get the rule editor from the rule-view given its index
  * @param {CssRuleView} view The instance of the rule-view panel
  * @param {Number} childrenIndex The children index of the element to get
  * @param {Number} nodeIndex The child node index of the element to get
  * @return {DOMNode} The rule editor if any at this index
  */
 function getRuleViewRuleEditor(view, childrenIndex, nodeIndex) {
   return nodeIndex !== undefined ?
--- a/browser/devtools/webide/content/webide.js
+++ b/browser/devtools/webide/content/webide.js
@@ -297,19 +297,18 @@ let UI = {
     let progress = document.querySelector("#action-busy-determined");
     progress.mode = "undetermined";
     win.classList.add("busy-determined");
     win.classList.remove("busy-undetermined");
     return busy;
   },
 
   busyUntil: function(promise, operationDescription) {
-    // Freeze the UI until the promise is resolved. A 30s timeout
-    // will unfreeze the UI, just in case the promise never gets
-    // resolved.
+    // Freeze the UI until the promise is resolved. A timeout will unfreeze the
+    // UI, just in case the promise never gets resolved.
     this._busyPromise = promise;
     this._busyOperationDescription = operationDescription;
     this.setupBusyTimeout();
     this.busy();
     promise.then(() => {
       this.cancelBusyTimeout();
       this.unbusy();
     }, (e) => {
@@ -464,17 +463,23 @@ let UI = {
     let name = runtime.name;
     let promise = AppManager.connectToRuntime(runtime);
     promise.then(() => this.initConnectionTelemetry())
            .catch(() => {
              // Empty rejection handler to silence uncaught rejection warnings
              // |busyUntil| will listen for rejections.
              // Bug 1121100 may find a better way to silence these.
            });
-    return this.busyUntil(promise, "Connecting to " + name);
+    promise = this.busyUntil(promise, "Connecting to " + name);
+    // Stop busy timeout for runtimes that take unknown or long amounts of time
+    // to connect.
+    if (runtime.prolongedConnection) {
+      this.cancelBusyTimeout();
+    }
+    return promise;
   },
 
   updateRuntimeButton: function() {
     let labelNode = document.querySelector("#runtime-panel-button > .panel-button-label");
     if (!AppManager.selectedRuntime) {
       labelNode.setAttribute("value", Strings.GetStringFromName("runtimeButton_label"));
     } else {
       let name = AppManager.selectedRuntime.name;
--- a/browser/devtools/webide/modules/runtimes.js
+++ b/browser/devtools/webide/modules/runtimes.js
@@ -61,16 +61,20 @@ const Strings = Services.strings.createB
  * |id| field
  *   An identifier that is unique in the set of all runtimes with the same
  *   |type|.  WebIDE tries to save the last used runtime via type + id, and
  *   tries to locate it again in the next session, so this value should attempt
  *   to be stable across Firefox sessions.
  * |name| field
  *   A user-visible label to identify the runtime that will be displayed in a
  *   runtime list.
+ * |prolongedConnection| field
+ *   A boolean value which should be |true| if the connection process is
+ *   expected to take a unknown or large amount of time.  A UI may use this as a
+ *   hint to skip timeouts or other time-based code paths.
  * connect()
  *   Configure the passed |connection| object with any settings need to
  *   successfully connect to the runtime, and call the |connection|'s connect()
  *   method.
  *   @param  Connection connection
  *           A |Connection| object from the DevTools |ConnectionManager|.
  *   @return Promise
  *           Resolved once you've called the |connection|'s connect() method.
@@ -441,16 +445,18 @@ DeprecatedUSBRuntime.prototype = {
 exports._DeprecatedUSBRuntime = DeprecatedUSBRuntime;
 
 function WiFiRuntime(deviceName) {
   this.deviceName = deviceName;
 }
 
 WiFiRuntime.prototype = {
   type: RuntimeTypes.WIFI,
+  // Mark runtime as taking a long time to connect
+  prolongedConnection: true,
   connect: function(connection) {
     let service = discovery.getRemoteService("devtools", this.deviceName);
     if (!service) {
       return promise.reject(new Error("Can't find device: " + this.name));
     }
     connection.advertisement = service;
     connection.authenticator.sendOOB = this.sendOOB;
     // Disable the default connection timeout, since QR scanning can take an
--- a/browser/devtools/webide/test/head.js
+++ b/browser/devtools/webide/test/head.js
@@ -32,16 +32,17 @@ Services.prefs.setCharPref("devtools.web
 
 
 SimpleTest.registerCleanupFunction(() => {
   Services.prefs.clearUserPref("devtools.webide.enabled");
   Services.prefs.clearUserPref("devtools.webide.enableLocalRuntime");
   Services.prefs.clearUserPref("devtools.webide.autoinstallADBHelper");
   Services.prefs.clearUserPref("devtools.webide.autoinstallFxdtAdapters");
   Services.prefs.clearUserPref("devtools.webide.sidebars");
+  Services.prefs.clearUserPref("devtools.webide.busyTimeout");
 });
 
 function openWebIDE(autoInstallAddons) {
   info("opening WebIDE");
 
   Services.prefs.setBoolPref("devtools.webide.autoinstallADBHelper", !!autoInstallAddons);
   Services.prefs.setBoolPref("devtools.webide.autoinstallFxdtAdapters", !!autoInstallAddons);
 
--- a/browser/devtools/webide/test/test_runtime.html
+++ b/browser/devtools/webide/test/test_runtime.html
@@ -57,26 +57,50 @@
               return promise.resolve();
             },
 
             get name() {
               return "fakeRuntime";
             }
           });
 
+          win.AppManager.runtimeList.usb.push({
+            connect: function(connection) {
+              let deferred = promise.defer();
+              return deferred.promise;
+            },
+
+            get name() {
+              return "infiniteRuntime";
+            }
+          });
+
+          win.AppManager.runtimeList.usb.push({
+            connect: function(connection) {
+              let deferred = promise.defer();
+              return deferred.promise;
+            },
+
+            prolongedConnection: true,
+
+            get name() {
+              return "prolongedRuntime";
+            }
+          });
+
           win.AppManager.update("runtimelist");
 
           let packagedAppLocation = getTestFilePath("app");
 
           yield win.Cmds.importPackagedApp(packagedAppLocation);
           yield waitForUpdate(win, "project-validated");
 
           let panelNode = win.document.querySelector("#runtime-panel");
           let items = panelNode.querySelectorAll(".runtime-panel-item-usb");
-          is(items.length, 1, "Found one runtime button");
+          is(items.length, 3, "Found 3 runtime buttons");
 
           let deferred = promise.defer();
           win.AppManager.connection.once(
               win.Connection.Events.CONNECTED,
               () => deferred.resolve());
 
           items[0].click();
 
@@ -99,17 +123,16 @@
           win.AppManager._selectedProject = oldProject;
           win.UI.updateCommands();
 
           yield nextTick();
 
           ok(isPlayActive(), "play button is enabled 3");
           ok(!isStopActive(), "stop button is disabled 3");
 
-
           yield win.Cmds.disconnectRuntime();
 
           is(Object.keys(DebuggerServer._connections).length, 0, "Disconnected");
 
           ok(win.AppManager.selectedProject, "A project is still selected");
           ok(!isPlayActive(), "play button is disabled 4");
           ok(!isStopActive(), "stop button is disabled 4");
 
@@ -132,16 +155,49 @@
           // Toolbox opens automatically for main process / runtime apps
           ok(win.UI.toolboxPromise, "Toolbox promise exists");
           yield win.UI.toolboxPromise;
 
           ok(win.UI.toolboxIframe, "Toolbox iframe exists");
 
           yield win.Cmds.disconnectRuntime();
 
+          Services.prefs.setIntPref("devtools.webide.busyTimeout", 100);
+
+          // Wait for error message since connection never completes
+          let errorDeferred = promise.defer();
+          win.UI.reportError = errorName => {
+            if (errorName === "error_operationTimeout") {
+              errorDeferred.resolve();
+            }
+          };
+
+          // Click the infinite runtime
+          items[1].click();
+          ok(win.document.querySelector("window").className, "busy", "UI is busy");
+          yield errorDeferred.promise;
+
+          // Check for unexpected error message since this is prolonged
+          let noErrorDeferred = promise.defer();
+          win.UI.reportError = errorName => {
+            if (errorName === "error_operationTimeout") {
+              noErrorDeferred.reject();
+            }
+          };
+
+          // Click the prolonged runtime
+          items[2].click();
+          ok(win.document.querySelector("window").className, "busy", "UI is busy");
+
+          setTimeout(() => {
+            noErrorDeferred.resolve();
+          }, 1000);
+
+          yield noErrorDeferred.promise;
+
           SimpleTest.finish();
 
         });
       }
 
 
     </script>
   </body>
--- a/browser/devtools/webide/webide-prefs.js
+++ b/browser/devtools/webide/webide-prefs.js
@@ -8,17 +8,17 @@ pref("devtools.webide.templatesURL", "ht
 pref("devtools.webide.autoinstallADBHelper", true);
 pref("devtools.webide.autoinstallFxdtAdapters", true);
 pref("devtools.webide.autoConnectRuntime", true);
 pref("devtools.webide.restoreLastProject", true);
 pref("devtools.webide.enableLocalRuntime", false);
 pref("devtools.webide.addonsURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/index.json");
 pref("devtools.webide.simulatorAddonsURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/#VERSION#/#OS#/fxos_#SLASHED_VERSION#_simulator-#OS#-latest.xpi");
 pref("devtools.webide.simulatorAddonID", "fxos_#SLASHED_VERSION#_simulator@mozilla.org");
-pref("devtools.webide.simulatorAddonRegExp", "fxos_(.*)_simulator@mozilla\.org$");
+pref("devtools.webide.simulatorAddonRegExp", "fxos_(.*)_simulator@mozilla\\.org$");
 pref("devtools.webide.adbAddonURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/adb-helper/#OS#/adbhelper-#OS#-latest.xpi");
 pref("devtools.webide.adbAddonID", "adbhelper@mozilla.org");
 pref("devtools.webide.adaptersAddonURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxdt-adapters/#OS#/fxdt-adapters-#OS#-latest.xpi");
 pref("devtools.webide.adaptersAddonID", "fxdevtools-adapters@mozilla.org");
 pref("devtools.webide.monitorWebSocketURL", "ws://localhost:9000");
 pref("devtools.webide.lastConnectedRuntime", "");
 pref("devtools.webide.lastSelectedProject", "");
 pref("devtools.webide.logSimulatorOutput", false);
--- a/browser/themes/shared/devtools/debugger.inc.css
+++ b/browser/themes/shared/devtools/debugger.inc.css
@@ -40,16 +40,26 @@
 }
 
 .dbg-breakpoint-checkbox {
   width: 16px;
   height: 16px;
   margin: 2px;
 }
 
+.dbg-breakpoint-condition-thrown-message {
+  display: none;
+  color: var(--theme-highlight-red);
+}
+
+.dbg-breakpoint.dbg-breakpoint-condition-thrown .dbg-breakpoint-condition-thrown-message {
+  display: block;
+  -moz-padding-start: 0;
+}
+
 /* Sources toolbar */
 
 #sources-toolbar > .devtools-toolbarbutton,
 #sources-controls > .devtools-toolbarbutton {
   min-width: 32px;
 }
 
 #black-box {
--- a/browser/themes/shared/incontentprefs/preferences.inc.css
+++ b/browser/themes/shared/incontentprefs/preferences.inc.css
@@ -146,16 +146,21 @@ treecol {
 
 /* Content pane */
 #playDRMContentLink {
   /* Line up with the buttons in the other grid bits: */
   margin-left: 4px !important;
   margin-right: 4px !important;
 }
 
+#defaultFontSizeLabel {
+  /* !important needed to override common !important rule */
+  -moz-margin-start: 4px !important;
+}
+
 /* Applications Pane Styles */
 
 #applicationsContent {
   padding: 15px 0;
 }
 
 #filter {
   -moz-margin-start: 0;
--- a/dom/html/HTMLInputElement.cpp
+++ b/dom/html/HTMLInputElement.cpp
@@ -12,16 +12,17 @@
 #include "nsAttrValueInlines.h"
 
 #include "nsIDOMHTMLInputElement.h"
 #include "nsITextControlElement.h"
 #include "nsIDOMNSEditableElement.h"
 #include "nsIRadioVisitor.h"
 #include "nsIPhonetic.h"
 
+#include "mozilla/Telemetry.h"
 #include "nsIControllers.h"
 #include "nsIStringBundle.h"
 #include "nsFocusManager.h"
 #include "nsColorControlFrame.h"
 #include "nsNumberControlFrame.h"
 #include "nsPIDOMWindow.h"
 #include "nsRepeatService.h"
 #include "nsContentCID.h"
@@ -4492,16 +4493,22 @@ HTMLInputElement::BindToTree(nsIDocument
   // If there is a disabled fieldset in the parent chain, the element is now
   // barred from constraint validation and can't suffer from value missing
   // (call done before).
   UpdateBarredFromConstraintValidation();
 
   // And now make sure our state is up to date
   UpdateState(false);
 
+#ifdef EARLY_BETA_OR_EARLIER
+  if (mType == NS_FORM_INPUT_PASSWORD) {
+    Telemetry::Accumulate(Telemetry::PWMGR_PASSWORD_INPUT_IN_FORM, !!mForm);
+  }
+#endif
+
   return rv;
 }
 
 void
 HTMLInputElement::UnbindFromTree(bool aDeep, bool aNullParent)
 {
   // If we have a form and are unbound from it,
   // nsGenericHTMLFormElementWithState::UnbindFromTree() will unset the form and
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -2983,16 +2983,17 @@ public class BrowserApp extends GeckoApp
         final boolean inGuestMode = GeckoProfile.get(this).inGuestMode();
 
         final boolean isAboutReader = AboutPages.isAboutReader(tab.getURL());
         bookmark.setEnabled(!isAboutReader);
         bookmark.setVisible(!inGuestMode);
         bookmark.setCheckable(true);
         bookmark.setChecked(tab.isBookmark());
         bookmark.setIcon(resolveBookmarkIconID(tab.isBookmark()));
+        bookmark.setTitle(resolveBookmarkTitleID(tab.isBookmark()));
 
         reader.setEnabled(isAboutReader || !AboutPages.isAboutPage(tab.getURL()));
         reader.setVisible(!inGuestMode);
         reader.setCheckable(true);
         final boolean isPageInReadingList = tab.isInReadingList();
         reader.setChecked(isPageInReadingList);
         reader.setIcon(resolveReadingListIconID(isPageInReadingList));
         reader.setTitle(resolveReadingListTitleID(isPageInReadingList));
@@ -3111,16 +3112,20 @@ public class BrowserApp extends GeckoApp
 
         if (isBookmark) {
             return R.drawable.ic_menu_bookmark_remove;
         } else {
             return R.drawable.ic_menu_bookmark_add;
         }
     }
 
+    private int resolveBookmarkTitleID(final boolean isBookmark) {
+        return (isBookmark ? R.string.bookmark_remove : R.string.bookmark);
+    }
+
     private int resolveReadingListIconID(final boolean isInReadingList) {
         return (isInReadingList ? R.drawable.ic_menu_reader_remove : R.drawable.ic_menu_reader_add);
     }
 
     private int resolveReadingListTitleID(final boolean isInReadingList) {
         return (isInReadingList ? R.string.reading_list_remove : R.string.overlay_share_reading_list_btn_label);
     }
 
@@ -3141,20 +3146,22 @@ public class BrowserApp extends GeckoApp
 
         if (itemId == R.id.bookmark) {
             tab = Tabs.getInstance().getSelectedTab();
             if (tab != null) {
                 if (item.isChecked()) {
                     Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.MENU, "bookmark");
                     tab.removeBookmark();
                     item.setIcon(resolveBookmarkIconID(false));
+                    item.setTitle(resolveBookmarkTitleID(false));
                 } else {
                     Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, "bookmark");
                     tab.addBookmark();
                     item.setIcon(resolveBookmarkIconID(true));
+                    item.setTitle(resolveBookmarkTitleID(true));
                 }
             }
             return true;
         }
 
         if (itemId == R.id.reading_list) {
             tab = Tabs.getInstance().getSelectedTab();
             if (tab != null) {
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -37,16 +37,17 @@
 <!ENTITY  launcher_shortcuts_title "&brandShortName; Web Apps">
 <!ENTITY  launcher_shortcuts_empty "No web apps were found">
 
 <!ENTITY choose_file "Choose File">
 
 <!ENTITY url_bar_default_text2 "Search or enter address">
 
 <!ENTITY bookmark "Bookmark">
+<!ENTITY bookmark_remove "Remove bookmark">
 <!ENTITY bookmark_added "Bookmark added">
 <!ENTITY bookmark_removed "Bookmark removed">
 <!ENTITY bookmark_updated "Bookmark updated">
 <!ENTITY bookmark_options "Options">
 
 <!ENTITY history_today_section "Today">
 <!ENTITY history_yesterday_section "Yesterday">
 <!ENTITY history_week_section2 "Last Week">
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -73,16 +73,17 @@
   <string name="url_bar_default_text">&url_bar_default_text2;</string>
 
   <!-- https://support.mozilla.org/1/mobile/%VERSION%/%OS%/%LOCALE%/ -->
   <string name="help_link">https://support.mozilla.org/1/mobile/&formatS1;/&formatS2;/&formatS3;/</string>
   <string name="help_menu">&help_menu;</string>
 
   <string name="quit">&quit;</string>
   <string name="bookmark">&bookmark;</string>
+  <string name="bookmark_remove">&bookmark_remove;</string>
   <string name="bookmark_added">&bookmark_added;</string>
   <string name="bookmark_removed">&bookmark_removed;</string>
   <string name="bookmark_updated">&bookmark_updated;</string>
   <string name="bookmark_options">&bookmark_options;</string>
 
   <string name="history_today_section">&history_today_section;</string>
   <string name="history_yesterday_section">&history_yesterday_section;</string>
   <string name="history_week_section">&history_week_section2;</string>
--- a/mobile/android/base/tests/components/ToolbarComponent.java
+++ b/mobile/android/base/tests/components/ToolbarComponent.java
@@ -28,20 +28,19 @@ import com.jayway.android.robotium.solo.
 
 /**
  * A class representing any interactions that take place on the Toolbar.
  */
 public class ToolbarComponent extends BaseComponent {
 
     private static final String URL_HTTP_PREFIX = "http://";
 
-    // We are waiting up to 60 seconds instead of the default waiting time
-    // because reader mode parsing can take quite some time on slower devices
-    // See Bug 1142699
-    private static final int READER_MODE_WAIT_MS = 60000;
+    // We are waiting up to 30 seconds instead of the default waiting time because reader mode
+    // parsing can take quite some time on slower devices (Bug 1142699)
+    private static final int READER_MODE_WAIT_MS = 30000;
 
     public ToolbarComponent(final UITestContext testContext) {
         super(testContext);
     }
 
     public ToolbarComponent assertIsEditing() {
         fAssertTrue("The toolbar is in the editing state", isEditing());
         return this;
--- a/mobile/android/base/tests/roboextender/SelectionUtils.js
+++ b/mobile/android/base/tests/roboextender/SelectionUtils.js
@@ -20,16 +20,24 @@ const TYPE_NAME = "Robocop:testSelection
  *
  */
 function getSelectionHandler() {
   return (!this._selectionHandler) ?
     this._selectionHandler = Services.wm.getMostRecentWindow("navigator:browser").SelectionHandler :
     this._selectionHandler;
 }
 
+function getClipboard() {
+  return Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
+}
+
+function getTextValue(aElement) {
+  return aElement.value || aElement.textContent;
+}
+
 function todo(result, msg) {
   return Messaging.sendRequestForResult({
     type: TYPE_NAME,
     todo: result,
     msg: msg
   });
 }
 
--- a/mobile/android/base/tests/roboextender/testSelectionHandler.html
+++ b/mobile/android/base/tests/roboextender/testSelectionHandler.html
@@ -4,32 +4,34 @@
     <meta name="viewport" content="initial-scale=1.0"/>
     <script type="application/javascript"
       src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
     <script type="application/javascript" src="SelectionUtils.js"></script>
     <script type="application/javascript;version=1.8">
 
 const DIV_POINT_TEXT = "Under";
 const INPUT_TEXT = "Text for select all in an <input>";
+const PASTE_TEXT = "Text for testing paste";
+const READONLY_INPUT_TEXT = "readOnly text";
 const TEXTAREA_TEXT = "Text for select all in a <textarea>";
-const READONLY_INPUT_TEXT = "readOnly text";
 
 /* =================================================================================
  *
  * Start of all text selection tests, check initialization state.
  *
  */
 function startTests() {
   testSelectAllDivs().
     then(testSelectDivAtPoint).
     then(testSelectInput).
     then(testSelectTextarea).
     then(testReadonlyInput).
     then(testCloseSelection).
     then(testStartSelectionFail).
+    then(testPaste).
 
     then(testAttachCaret).
     then(testAttachCaretFail).
 
     then(finishTests, function(err) {
       ok(false, "Error in selection test " + err);
       finishTests();
     });
@@ -346,16 +348,54 @@ function testAttachCaretFail() {
       is(attachCaretResult, sh.ATTACH_ERROR_INCOMPATIBLE,
         "attachCaret() should have failed predictably."),
       is(sh._activeType, sh.TYPE_NONE,
         "Selection should not be active at end of testAttachCaretFail."),
     ]);
   });
 }
 
+/* =================================================================================
+ *
+ * Tests to ensure we can paste text inside editable elements
+ *
+ */
+function testPaste() {
+  let sh = getSelectionHandler();
+  let clipboard = getClipboard();
+  clipboard.copyString(PASTE_TEXT, document);
+
+  // Add a contentEditable element to the document.
+  let div = document.createElement("div");
+  div.contentEditable = true;
+  div.dataset.editable = true;
+  document.body.appendChild(div);
+
+  let elements = document.querySelectorAll("div, input, textarea");
+  let promises = [];
+
+  for (var i = 0; i < elements.length; i++) {
+    sh.startSelection(elements[i]);
+    if (sh.isElementEditableText(elements[i]) && !elements[i].disabled) {
+      sh.actions.PASTE.action(elements[i]);
+    }
+    if (elements[i].dataset.editable) {
+      promises.push(is(getTextValue(elements[i]), PASTE_TEXT, "Pasted correctly"));
+      promises.push(ok(sh.isElementEditableText(elements[i]), "Element is editable"));
+    } else {
+      promises.push(isNot(getTextValue(elements[i]), PASTE_TEXT, "Paste failed correctly"));
+    }
+  }
+
+  document.body.removeChild(div);
+  div = null;
+
+  return Promise.all(promises);
+}
+
     </script>
   </head>
 
   <body onload="startTests();">
 
     <div id="selDiv">Under sufficiently extreme conditions, quarks may become
       deconfined and exist as free particles. In the course of asymptotic freedom,
       the strong interaction becomes weaker at higher temperatures. Eventually,
@@ -376,19 +416,19 @@ function testAttachCaretFail() {
       platea dictumst. Sed placerat tellus quis lacus condimentum, quis luctus elit
       pellentesque. Mauris cursus neque diam, sit amet gravida quam porta ac.
       Aliquam aliquam feugiat vestibulum. Proin commodo nulla ligula, in bibendum
       massa euismod a. Ut ac lobortis dui. Ut id augue id arcu ornare suscipit eu
       ornare lorem. Pellentesque nec dictum ante. Nam quis ligula ultricies, auctor
       nunc vel, fringilla turpis. Nulla lacinia, leo ut egestas hendrerit, risus
       ligula interdum enim, vel varius libero sem ut ligula.</div><br>
 
-    <input id="inputNode" type="text"><br>
+    <input data-editable="true" id="inputNode" type="text"><br>
 
-    <textarea id="textareaNode"></textarea><br>
+    <textarea data-editable="true" id="textareaNode"></textarea><br>
 
     <input id="readOnlyTextInput" type="text" readonly><br>
 
     <input id="inputButton" type="button" value="Click me"><br>
 
     <input id="inputDisabled" type="text" disabled><br>
   </body>
 </html>
--- a/mobile/android/base/tests/testReaderModeTitle.java
+++ b/mobile/android/base/tests/testReaderModeTitle.java
@@ -1,16 +1,19 @@
 package org.mozilla.gecko.tests;
 
+import org.mozilla.gecko.tests.helpers.GeckoHelper;
 import org.mozilla.gecko.tests.helpers.NavigationHelper;
 
 /**
  * This tests ensures that the toolbar in reader mode displays the original page url.
  */
 public class testReaderModeTitle extends UITest {
     public void testReaderModeTitle() {
+        GeckoHelper.blockForReady();
+
         NavigationHelper.enterAndLoadUrl(StringHelper.ROBOCOP_READER_MODE_BASIC_ARTICLE);
 
         mToolbar.pressReaderModeButton();
 
         mToolbar.assertTitle(StringHelper.ROBOCOP_READER_MODE_BASIC_ARTICLE);
     }
 }
--- a/mobile/android/chrome/content/SelectionHandler.js
+++ b/mobile/android/chrome/content/SelectionHandler.js
@@ -674,17 +674,18 @@ var SelectionHandler = {
 
         // copySelection closes the selection. Show a caret where we just cut the text.
         SelectionHandler.attachCaret(aElement);
         UITelemetry.addEvent("action.1", "actionbar", null, "cut");
       },
       order: 4,
       selector: {
         matches: function(aElement) {
-          return SelectionHandler.isElementEditableText(aElement) ?
+          // Disallow cut for contentEditable elements (until Bug 1112276 is fixed).
+          return !aElement.isContentEditable && SelectionHandler.isElementEditableText(aElement) ?
             SelectionHandler.isSelectionActive() : false;
         }
       }
     },
 
     COPY: {
       label: Strings.browser.GetStringFromName("contextmenu.copy"),
       id: "copy_action",
@@ -706,20 +707,20 @@ var SelectionHandler = {
       }
     },
 
     PASTE: {
       label: Strings.browser.GetStringFromName("contextmenu.paste"),
       id: "paste_action",
       icon: "drawable://ab_paste",
       action: function(aElement) {
-        if (aElement && (aElement instanceof Ci.nsIDOMNSEditableElement)) {
-          let target = aElement.QueryInterface(Ci.nsIDOMNSEditableElement);
-          target.editor.paste(Ci.nsIClipboard.kGlobalClipboard);
-          target.focus();
+        if (aElement) {
+          let target = SelectionHandler._getEditor();
+          aElement.focus();
+          target.paste(Ci.nsIClipboard.kGlobalClipboard);
           SelectionHandler._closeSelection();
           UITelemetry.addEvent("action.1", "actionbar", null, "paste");
         }
       },
       order: 2,
       selector: {
         matches: function(aElement) {
           if (SelectionHandler.isElementEditableText(aElement)) {
@@ -890,17 +891,18 @@ var SelectionHandler = {
 
   // Used by the contextmenu "matches" functions in ClipboardHelper
   isSelectionActive: function sh_isSelectionActive() {
     return (this._activeType == this.TYPE_SELECTION);
   },
 
   isElementEditableText: function (aElement) {
     return (((aElement instanceof HTMLInputElement && aElement.mozIsTextField(false)) ||
-            (aElement instanceof HTMLTextAreaElement)) && !aElement.readOnly);
+            (aElement instanceof HTMLTextAreaElement)) && !aElement.readOnly) ||
+            aElement.isContentEditable;
   },
 
   _isNonTextInputElement: function(aElement) {
     return (aElement instanceof HTMLInputElement && !aElement.mozIsTextField(false));
   },
 
   /*
    * Moves the selection as the user drags a handle.
@@ -958,17 +960,17 @@ var SelectionHandler = {
     } else {
       selection.extend(caretPos.offsetNode, caretPos.offset);
     }
   },
 
   _moveCaret: function sh_moveCaret(aX, aY) {
     // Get rect of text inside element
     let range = document.createRange();
-    range.selectNodeContents(this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.rootElement);
+    range.selectNodeContents(this._getEditor().rootElement);
     let textBounds = range.getBoundingClientRect();
 
     // Get rect of editor
     let editorBounds = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_EDITOR_RECT, 0, 0, 0, 0,
                                                                this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
     // the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so
     // divide by the pixel ratio
     let editorRect = new Rect(editorBounds.left / window.devicePixelRatio,
--- a/toolkit/components/passwordmgr/nsLoginManagerPrompter.js
+++ b/toolkit/components/passwordmgr/nsLoginManagerPrompter.js
@@ -5,16 +5,20 @@
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
 Cu.import("resource://gre/modules/SharedPromptUtils.jsm");
 
+const LoginInfo =
+      Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+                             "nsILoginInfo", "init");
+
 /* Constants for password prompt telemetry.
  * Mirrored in mobile/android/components/LoginManagerPrompter.js */
 const PROMPT_DISPLAYED = 0;
 
 const PROMPT_ADD_OR_UPDATE = 1;
 const PROMPT_NOTNOW = 2;
 const PROMPT_NEVER = 3;
 
@@ -787,50 +791,118 @@ LoginManagerPrompter.prototype = {
    *        new password.
    * @param {string} type
    *        This is "password-save" or "password-change" depending on the
    *        original notification type. This is used for telemetry and tests.
    */
   _showLoginCaptureDoorhanger(login, type) {
     let { browser } = this._getNotifyWindow();
 
-    let msgNames = type == "password-save" ? {
+    let saveMsgNames = {
       prompt: "rememberPasswordMsgNoUsername",
       buttonLabel: "notifyBarRememberPasswordButtonText",
       buttonAccessKey: "notifyBarRememberPasswordButtonAccessKey",
-    } : {
+    };
+
+    let changeMsgNames = {
       // We reuse the existing message, even if it expects a username, until we
       // switch to the final terminology in bug 1144856.
       prompt: "updatePasswordMsg",
       buttonLabel: "notifyBarUpdateButtonText",
       buttonAccessKey: "notifyBarUpdateButtonAccessKey",
     };
 
+    let initialMsgNames = type == "password-save" ? saveMsgNames
+                                                  : changeMsgNames;
+
     let histogramName = type == "password-save" ? "PWMGR_PROMPT_REMEMBER_ACTION"
                                                 : "PWMGR_PROMPT_UPDATE_ACTION";
     let histogram = Services.telemetry.getHistogramById(histogramName);
     histogram.add(PROMPT_DISPLAYED);
 
+    let chromeDoc = browser.ownerDocument;
+
+    let currentNotification;
+
+    let updateButtonLabel = () => {
+      let foundLogins = Services.logins.findLogins({}, login.hostname,
+                                                   login.formSubmitURL,
+                                                   login.httpRealm);
+      let logins = foundLogins.filter(l => l.username == login.username);
+      let msgNames = (logins.length == 0) ? saveMsgNames : changeMsgNames;
+
+      // Update the label based on whether this will be a new login or not.
+      let label = this._getLocalizedString(msgNames.buttonLabel);
+      let accessKey = this._getLocalizedString(msgNames.buttonAccessKey);
+
+      // Update the labels for the next time the panel is opened.
+      currentNotification.mainAction.label = label;
+      currentNotification.mainAction.accessKey = accessKey;
+
+      // Update the labels in real time if the notification is displayed.
+      let element = [...currentNotification.owner.panel.childNodes]
+                    .find(n => n.notification == currentNotification);
+      if (element) {
+        element.setAttribute("buttonlabel", label);
+        element.setAttribute("buttonaccesskey", accessKey);
+      }
+    };
+
+    let writeDataToUI = () => {
+      chromeDoc.getElementById("password-notification-username")
+               .setAttribute("placeholder", usernamePlaceholder);
+      chromeDoc.getElementById("password-notification-username")
+               .setAttribute("value", login.username);
+      chromeDoc.getElementById("password-notification-password")
+               .setAttribute("value", login.password);
+      updateButtonLabel();
+    };
+
+    let readDataFromUI = () => {
+      login.username =
+        chromeDoc.getElementById("password-notification-username").value;
+      login.password =
+        chromeDoc.getElementById("password-notification-password").value;
+    };
+
+    let onUsernameInput = () => {
+      readDataFromUI();
+      updateButtonLabel();
+    };
+
+    let persistData = () => {
+      let foundLogins = Services.logins.findLogins({}, login.hostname,
+                                                   login.formSubmitURL,
+                                                   login.httpRealm);
+      let logins = foundLogins.filter(l => l.username == login.username);
+      if (logins.length == 0) {
+        // The original login we have been provided with might have its own
+        // metadata, but we don't want it propagated to the newly created one.
+        Services.logins.addLogin(new LoginInfo(login.hostname,
+                                               login.formSubmitURL,
+                                               login.httpRealm,
+                                               login.username,
+                                               login.password,
+                                               login.usernameField,
+                                               login.passwordField));
+      } else if (logins.length == 1) {
+        this._updateLogin(logins[0], login.password);
+      } else {
+        Cu.reportError("Unexpected match of multiple logins.");
+      }
+    };
+
     // The main action is the "Remember" or "Update" button.
     let mainAction = {
-      label: this._getLocalizedString(msgNames.buttonLabel),
-      accessKey: this._getLocalizedString(msgNames.buttonAccessKey),
+      label: this._getLocalizedString(initialMsgNames.buttonLabel),
+      accessKey: this._getLocalizedString(initialMsgNames.buttonAccessKey),
       callback: () => {
         histogram.add(PROMPT_ADD_OR_UPDATE);
-        let foundLogins = Services.logins.findLogins({}, login.hostname,
-                                                     login.formSubmitURL,
-                                                     login.httpRealm);
-        let logins = foundLogins.filter(l => l.username == login.username);
-        if (logins.length == 0) {
-          Services.logins.addLogin(login);
-        } else if (logins.length == 1) {
-          this._updateLogin(logins[0], login.password);
-        } else {
-          Cu.reportError("Unexpected match of multiple logins.");
-        }
+        readDataFromUI();
+        persistData();
         browser.focus();
       }
     };
 
     // Include a "Never for this site" button when saving a new password.
     let secondaryActions = type == "password-save" ? [{
       label: this._getLocalizedString("notifyBarNeverRememberButtonText"),
       accessKey: this._getLocalizedString("notifyBarNeverRememberButtonAccessKey"),
@@ -842,37 +914,42 @@ LoginManagerPrompter.prototype = {
     }] : null;
 
     let usernamePlaceholder = this._getLocalizedString("noUsernamePlaceholder");
     let displayHost = this._getShortDisplayHost(login.hostname);
 
     this._getPopupNote().show(
       browser,
       "password",
-      this._getLocalizedString(msgNames.prompt, [displayHost]),
+      this._getLocalizedString(initialMsgNames.prompt, [displayHost]),
       "password-notification-icon",
       mainAction,
       secondaryActions,
       {
         timeout: Date.now() + 10000,
         persistWhileVisible: true,
         passwordNotificationType: type,
         eventCallback: function (topic) {
-          if (topic != "showing") {
-            return false;
+          switch (topic) {
+            case "showing":
+              currentNotification = this;
+              writeDataToUI();
+              chromeDoc.getElementById("password-notification-username")
+                       .addEventListener("input", onUsernameInput);
+              break;
+            case "dismissed":
+              readDataFromUI();
+              // Fall through.
+            case "removed":
+              currentNotification = null;
+              chromeDoc.getElementById("password-notification-username")
+                       .removeEventListener("input", onUsernameInput);
+              break;
           }
-
-          let chromeDoc = this.browser.ownerDocument;
-
-          chromeDoc.getElementById("password-notification-username")
-                   .setAttribute("placeholder", usernamePlaceholder);
-          chromeDoc.getElementById("password-notification-username")
-                   .setAttribute("value", login.username);
-          chromeDoc.getElementById("password-notification-password")
-                   .setAttribute("value", login.password);
+          return false;
         },
       }
     );
   },
 
   /*
    * _showSaveLoginNotification
    *
--- a/toolkit/components/passwordmgr/test/browser/browser_notifications.js
+++ b/toolkit/components/passwordmgr/test/browser/browser_notifications.js
@@ -76,8 +76,124 @@ add_task(function* test_save_change() {
       Assert.equal(login.username, username);
       Assert.equal(login.password, password);
     });
 
     // Clean up the database before the next test case is executed.
     Services.logins.removeAllLogins();
   }
 });
+
+/**
+ * Test changing the username inside the doorhanger notification for passwords.
+ *
+ * We have to test combination of existing and non-existing logins both for
+ * the original one from the webpage and the final one used by the dialog.
+ *
+ * We also check switching to and from empty usernames.
+ */
+add_task(function* test_edit_username() {
+  let testCases = [{
+    usernameInPage: "username",
+    usernameChangedTo: "newUsername",
+  }, {
+    usernameInPage: "username",
+    usernameInPageExists: true,
+    usernameChangedTo: "newUsername",
+  }, {
+    usernameInPage: "username",
+    usernameChangedTo: "newUsername",
+    usernameChangedToExists: true,
+  }, {
+    usernameInPage: "username",
+    usernameInPageExists: true,
+    usernameChangedTo: "newUsername",
+    usernameChangedToExists: true,
+  }, {
+    usernameInPage: "",
+    usernameChangedTo: "newUsername",
+  }, {
+    usernameInPage: "newUsername",
+    usernameChangedTo: "",
+  }, {
+    usernameInPage: "",
+    usernameChangedTo: "newUsername",
+    usernameChangedToExists: true,
+  }, {
+    usernameInPage: "newUsername",
+    usernameChangedTo: "",
+    usernameChangedToExists: true,
+  }];
+
+  for (let testCase of testCases) {
+    info("Test case: " + JSON.stringify(testCase));
+
+    // Create the pre-existing logins when needed.
+    if (testCase.usernameInPageExists) {
+      Services.logins.addLogin(LoginTestUtils.testData.formLogin({
+        hostname: "https://example.com",
+        formSubmitURL: "https://example.com",
+        username: testCase.usernameInPage,
+        password: "old password",
+      }));
+    }
+    if (testCase.usernameChangedToExists) {
+      Services.logins.addLogin(LoginTestUtils.testData.formLogin({
+        hostname: "https://example.com",
+        formSubmitURL: "https://example.com",
+        username: testCase.usernameChangedTo,
+        password: "old password",
+      }));
+    }
+
+    yield BrowserTestUtils.withNewTab({
+      gBrowser,
+      url: "https://example.com/browser/toolkit/components/" +
+           "passwordmgr/test/browser/form_basic.html",
+    }, function* (browser) {
+      // Submit the form in the content page with the credentials from the test
+      // case. This will cause the doorhanger notification to be displayed.
+      let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
+                                                       "Shown");
+      yield ContentTask.spawn(browser, testCase.usernameInPage,
+        function* (usernameInPage) {
+          let doc = content.document;
+          doc.getElementById("form-basic-username").value = usernameInPage;
+          doc.getElementById("form-basic-password").value = "password";
+          doc.getElementById("form-basic").submit();
+        });
+      yield promiseShown;
+
+      // Modify the username in the dialog if requested.
+      if (testCase.usernameChangedTo) {
+        document.getElementById("password-notification-username")
+                .setAttribute("value", testCase.usernameChangedTo);
+      }
+
+      // We expect a modifyLogin notification if the final username used by the
+      // dialog exists in the logins database, otherwise an addLogin one.
+      let expectModifyLogin = testCase.usernameChangedTo
+                              ? testCase.usernameChangedToExists
+                              : testCase.usernameInPageExists;
+
+      // Simulate the action on the notification to request the login to be
+      // saved, and wait for the data to be updated or saved based on the type
+      // of operation we expect.
+      let expectedNotification = expectModifyLogin ? "modifyLogin" : "addLogin";
+      let promiseLogin = TestUtils.topicObserved("passwordmgr-storage-changed",
+                         (_, data) => data == expectedNotification);
+      let notificationElement = PopupNotifications.panel.childNodes[0];
+      notificationElement.button.doCommand();
+      let [result] = yield promiseLogin;
+
+      // Check that the values in the database match the expected values.
+      let login = expectModifyLogin ? result.QueryInterface(Ci.nsIArray)
+                                            .queryElementAt(1, Ci.nsILoginInfo)
+                                    : result.QueryInterface(Ci.nsILoginInfo);
+      Assert.equal(login.username, testCase.usernameChangedTo ||
+                                   testCase.usernameInPage);
+      Assert.equal(login.password, "password");
+    });
+
+    // Clean up the database before the next test case is executed.
+    Services.logins.removeAllLogins();
+  }
+});
--- a/toolkit/components/places/Database.cpp
+++ b/toolkit/components/places/Database.cpp
@@ -735,17 +735,22 @@ Database::InitSchema(bool* aDatabaseMigr
 
       // Firefox 37 uses schema version 26.
 
       if (currentSchemaVersion < 27) {
         rv = MigrateV27Up();
         NS_ENSURE_SUCCESS(rv, rv);
       }
 
-      // Firefox 38 uses schema version 27.
+      if (currentSchemaVersion < 28) {
+        rv = MigrateV28Up();
+        NS_ENSURE_SUCCESS(rv, rv);
+      }
+
+      // Firefox 39 uses schema version 28.
 
       // Schema Upgrades must add migration code here.
 
       rv = UpdateBookmarkRootTitles();
       // We don't want a broken localization to cause us to think
       // the database is corrupt and needs to be replaced.
       MOZ_ASSERT(NS_SUCCEEDED(rv));
     }
@@ -1531,18 +1536,18 @@ Database::MigrateV27Up() {
   // a different post_data value.
   rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
     "INSERT OR REPLACE INTO moz_keywords (id, keyword, place_id, post_data) "
     "SELECT k.id, k.keyword, h.id, MAX(a.content) "
     "FROM moz_places h "
     "JOIN moz_bookmarks b ON b.fk = h.id "
     "JOIN moz_keywords k ON k.id = b.keyword_id "
     "LEFT JOIN moz_items_annos a ON a.item_id = b.id "
-    "LEFT JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id "
-                                   "AND n.name = 'bookmarkProperties/POSTData'"
+                               "AND a.anno_attribute_id = (SELECT id FROM moz_anno_attributes "
+                                                          "WHERE name = 'bookmarkProperties/POSTData') "
     "WHERE k.place_id ISNULL "
     "GROUP BY keyword"));
   NS_ENSURE_SUCCESS(rv, rv);
 
   // Remove any keyword that points to a non-existing place id.
   rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
     "DELETE FROM moz_keywords "
     "WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = moz_keywords.place_id)"));
@@ -1558,16 +1563,48 @@ Database::MigrateV27Up() {
     "(SELECT count(*) FROM moz_bookmarks WHERE fk = moz_places.id) + "
     "(SELECT count(*) FROM moz_keywords WHERE place_id = moz_places.id) "
   ));
   NS_ENSURE_SUCCESS(rv, rv);
 
   return NS_OK;
 }
 
+nsresult
+Database::MigrateV28Up() {
+  MOZ_ASSERT(NS_IsMainThread());
+
+  // v27 migration was bogus and set some unrelated annotations as post_data for
+  // keywords having an annotated bookmark.
+  // The current v27 migration function is fixed, but we still need to handle
+  // users that hit the bogus version.  Since we can't distinguish, we'll just
+  // set again all of the post data.
+  DebugOnly<nsresult> rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+    "UPDATE moz_keywords "
+    "SET post_data = ( "
+      "SELECT content FROM moz_items_annos a "
+      "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
+      "JOIN moz_bookmarks b on b.id = a.item_id "
+      "WHERE n.name = 'bookmarkProperties/POSTData' "
+      "AND b.keyword_id = moz_keywords.id "
+      "ORDER BY b.lastModified DESC "
+      "LIMIT 1 "
+    ") "
+    "WHERE EXISTS(SELECT 1 FROM moz_bookmarks WHERE keyword_id = moz_keywords.id) "
+  ));
+  // In case the update fails a constraint, we don't want to throw away the
+  // whole database for just a few keywords.  In rare cases the user might have
+  // to recreate them.  Though, at this point, there shouldn't be 2 keywords
+  // pointing to the same url and post data, cause the previous migration step
+  // removed them.
+  MOZ_ASSERT(NS_SUCCEEDED(rv));
+
+  return NS_OK;
+}
+
 void
 Database::Shutdown()
 {
   MOZ_ASSERT(NS_IsMainThread());
   MOZ_ASSERT(!mShuttingDown);
   MOZ_ASSERT(!mClosed);
 
   mShuttingDown = true;
--- a/toolkit/components/places/Database.h
+++ b/toolkit/components/places/Database.h
@@ -11,17 +11,17 @@
 #include "nsIObserver.h"
 #include "mozilla/storage.h"
 #include "mozilla/storage/StatementCache.h"
 #include "mozilla/Attributes.h"
 #include "nsIEventTarget.h"
 
 // This is the schema version. Update it at any schema change and add a
 // corresponding migrateVxx method below.
-#define DATABASE_SCHEMA_VERSION 27
+#define DATABASE_SCHEMA_VERSION 28
 
 // Fired after Places inited.
 #define TOPIC_PLACES_INIT_COMPLETE "places-init-complete"
 // Fired when initialization fails due to a locked database.
 #define TOPIC_DATABASE_LOCKED "places-database-locked"
 // This topic is received when the profile is about to be lost.  Places does
 // initial shutdown work and notifies TOPIC_PLACES_SHUTDOWN to all listeners.
 // Any shutdown work that requires the Places APIs should happen here.
@@ -270,16 +270,17 @@ protected:
   nsresult MigrateV20Up();
   nsresult MigrateV21Up();
   nsresult MigrateV22Up();
   nsresult MigrateV23Up();
   nsresult MigrateV24Up();
   nsresult MigrateV25Up();
   nsresult MigrateV26Up();
   nsresult MigrateV27Up();
+  nsresult MigrateV28Up();
 
   nsresult UpdateBookmarkRootTitles();
 
 private:
   ~Database();
 
   /**
    * Singleton getter, invoked by class instantiation.
--- a/toolkit/components/places/tests/head_common.js
+++ b/toolkit/components/places/tests/head_common.js
@@ -1,14 +1,14 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
  * 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/. */
 
-const CURRENT_SCHEMA_VERSION = 27;
+const CURRENT_SCHEMA_VERSION = 28;
 const FIRST_UPGRADABLE_SCHEMA_VERSION = 11;
 
 const NS_APP_USER_PROFILE_50_DIR = "ProfD";
 const NS_APP_PROFILE_DIR_STARTUP = "ProfDS";
 
 // Shortcuts to transitions type.
 const TRANSITION_LINK = Ci.nsINavHistoryService.TRANSITION_LINK;
 const TRANSITION_TYPED = Ci.nsINavHistoryService.TRANSITION_TYPED;
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..9a27db32409aef699041ce3c04300e13215db582
GIT binary patch
literal 1212416
zc%1FsdtjY)eK7EI%jG0ZX+>602PxXp&bCRKq%9y~FKJqOzXIKO!)bDop5`PcJ?ErJ
z3hhJCDJZ_v>Fd~rz#J;>6?Ieg>LyMmQ>QT9WP&>I%4Bm+#S4yEe4lf2Y1*cM`=^SZ
z51i-s{Jy{I^E<!i$#eedj%^z=#dLjlF5j0b);CA4i^O7)SJl@?B9VCbnHM?c-0+i_
zl8=U;mqeafJhJ#-tLqMbDDtx2t0G6PKm6fq|LSnznuo7_-Jws6JaOpSk+VlWGW^M*
zuMEF&C_41k!4DR97OM(37n<^am*0Khiw9mY@RtLPxqEW2?Eka=*1miCR%G9nO@ti*
z0000000000000000000000000000000Py@Y`m&c*&Dr+y=;)Q1{;u?JU+z#xrkL(4
zbfo(GbA|GGs#wft_6`=))76-_dVAZ-oo)5IHm~2dtF3<h<~40s)IWPK^;<TdSgU?P
zDbtbZT2x=kcT6@2LkmaGcxly~_Vc2nZIhE2>v|xY>P(+J!*b!vPBphvHl3K(_=L(e
zPBo+R&aawt<-F+VrpZBj(<4K<eAjr-1Gz%6D^*N&4EARZ45m-2_0m(#?%BJUnC3*y
zsWUCr?<h4`I2xN<HD~<=(b2h+WADx7dizrO-a<#uV5aNDnN60Sf2ygSy8XnICTr9$
zC{<Z_u)b=}>bcR;vnEF=Pj&hUj1|p2)##^aIWhED@%Xrf^Qz{o49A^);<$tP?DWId
zKg+NuG(2(Cuz2C<>~pK;><Cx9b($3~?e1)<Q0&WfWx6xzu9K^rH$H>u)_dC4(=2*-
z@1pury-nlw79Rf1syUaQViOFc^6CEK$pz;=!$hXEnP&L0((*3M6f?R0g$G_zHRs}R
z__|Z9$jRZ~e3n&srYkRw45Sy1#$O!nxrV8iyf0HI=JF#4GvTZXrMn|NebGx!8v7J2
zC&oXnxPC!7E!PTX@uFv*MRz{eH+|8IpJf)4El)9vQh7%?vv73IIaPBug|oQy6tgIA
z-%~Hhi=JgF$2UL4e2zVwrmWAAv%?4a+^NemexH?gZ@J!avl=Tr=cMVJveAk6XQ@WV
zl=1uKRL!|096x#D_}%$*XS%<0<b?RyryBaW`X`P$QMhpbZ&b}$KHYHTDRt%s`-{hd
zb51qZvC1b7G<Id>qJ`<Rs^+wYV?|FKYhU=c;e^<4oNB1ax+jj5PaiB7F6?_@cx#8l
z)Sk40se_r$T>lB-vrf5yQ>ve|eq&{&Ke!vttei7%Ui7z%slC~B;XpQg7IYMfsbbUA
z^B0Z}vUBCCjcxT)OBNLOW*23;h8LA`3$Op37gWu8`OBlP$(6f1;Z@{>?3u@PdeUo4
zeSM}YysDjT?QPrZw{Bm*Y324S>o>Grxu|~pg=VsF^Ol|Un|E#8IL-USM4d(TeVM*=
zN9jRUzkB8O)$3MnU(mdCVf~u6wJUdRoM;{{NilrR)^FUhxqZCWqWbP^s;4kH)<lEG
zMfK_7flNMCdK^zzS{`bec<C-#+0~Wqnyzr_pO}-&7hb<%cGaBo&WpY#J-NJ-3tK*U
z!AVQ{tcyAE6#4aA&CywBRLyB_j*hk;x0=VUxG+s<*0d`;rNT3<b$_bw_yrq(DVkXF
zg>ADc=Umwwjg)WN@!KplSj?649n*}~(KJnP_Gm+0Rq3X_e(vOzjo<Nwspm6Kx|WHO
zXS#^+jcn>yI(`l1uOd~yYU7qwr`R3GeZ{2?7S)$tDeHH(U9ofF@|l%$Hq3jb3!j+U
z*cDIY&e%Gmvh={Y^0>!WI((b#?>sSEH|;}gyl_G3yIB<G7LJ}<Q(nB$U~=&$eh^GO
zuRUq;j{R&n)#BB!+_iJd`psd>O>LWZE~+1IQC@<{bv*Hgobuaae64DqZLKDM>NHKw
z%^0n!9^3G(lUH`^9WbBE6${gZk|$l~2^C70TRQ1DZg)2=TR3?|-MMU6IzQ!-7Pcjy
zZJ(bo$k>&f5UjcW1yxmZF1R52>duod=(tSvsW0N0w))hkPIwu;Cp|>Q9&}@G=f{0t
znW+!5u%k>dn?C+QIR331_eGB{RC(CaS3S-1ZThF$)LUtEQAPQW#Pt_UE=uXWqA+<<
zdD3E(!_Tx3r3z!abmCEU?8_a06`OLg;q*`bb29b8JdjU!XNJe7+f-ROr|rCF+6v{_
zjctJPY1Qbmc-5T6i=)?{ckC^re_*gU{(gL7K5_DU#+1_1Wk18;z;e~&o_EK}1`Fx2
zSHol1KJ{jw@`7IOW#Ph06P0szEPkeIojS9zOP(63h(scZ(e_x?oQ8(z^_Lv`ey9F0
zI4KZ6`TIWphs3GB_hUbKrtI#Mo=?YK|4)n^e*+cLg~F8EV&V1IM62e^og4k#m6KO8
z_JVrsEOye|CthLepYva+X>Y%Y*X`q@Q(pI{d!g8u32((|-cP3NiQ_l_bTy8>dr!IX
zPq>LD8!m2|((;7gT9cKh-2J6zNIsqE=|9y)9DCQO3pZ2kHQ$N^?+i8_`QDM6kE}g1
z`|y#&E3bX<+FTe0000000000000000000000000000000000000D$M6(dyc|$TjO1
zC#$QY(aL$%`-;VZ!bJ@YLqkJL`f`Ud*=%Y_F5lCT?q9rXM?-fepYF~LH}q$E_7$@u
zrF@~7AM9^fJ62G?d90v*ZK<Gs`(S_l)>KcrC!brA%4Rc%a(zom9asE3QZf7J%zeum
zm$o15z3|}bEnAnaUD{bw7s*8yCzI8YXPU_==WyaEUFAW#BD1!wziivkitc?YFTXI;
zo2-ij&pyaNKDR&JSu9MB)_mcT##Ga@4YudOeVr{UE=;c~boK5ZZo0U-F0w5WsSXFL
zj?7Dz|6%%zO8@@*CCla)a{1yVO)c~LQp4NRg~4pGa7k0+p5f;F!Jc&Mo`K%((OFe9
zBcmfHHt)`5yVChfR&Cj`VbjX(8+LSTYTLYP{&;lP=C$iLwyo)yES)IWxn;}7RV%m8
zKQNfikL)bXplN&tOOKzy{Pb{VcCahGEv%SHhmrJ_-0E%J%T~8GuNr8stc$EXaYE&8
zRvh0;b6cvA+1H-g(LU0)wW2PvE3&vc++eY=(KBzb)ZW}+@ubB~L~a@w-m>b@j>|?i
zY+k*4SE4S`@mzcC%=H)ZnZ1L>^hw>uBR3V=2llSnQAo8f&9wYZye`uBT)Umv7oCHJ
zVy-W9=;YpGk(+uCW;<7}$!sd@Z%*%y)kQkXy(gc0@B7l(fs;Cp+|;!v-E^Su!h>z=
zGN~)0b&*WD-^ed8>vXs%sq8deuQ=E<*x$AFiq4h;YwN=2Lp=CYB>2zZD`5fv00000
z000000000000000000000000000000006(rUJ$Q}M5C3_>R4^0V)oIQ`<69U%#Kw>
zlBHNpIn))IRWU1C6{(I^CaaU>(4GhP)s<RbcbaDp0000000000000000000000000
z00000000000001|SxxD1-D#c#000000000000000000000000000000000000001-
zW_95w8hj@b{to~E000000000000000000000000000000000000KX2-j8;Zo8mo@<
z<ql=C+0>F;z9({1O;`{Oeh>-&2LJ#70000000000000000000000000000000001h
zUmr8$FHQF44rQ|0)RJ7jr!EYgaqWjA!9NGL1Xl-*N1izH;Um``dF7Gl;ZGla%i-TX
z{E}-Q3yT2&00000000000000000000000000000000000fL~%SKVwED8i_{3)5^+u
z@mMTUG5cuU9p`^-B5lv@9ckS&(73*PWOwV#8Ifc(ntW-wa&>Hbx-;EhtlydHDU_@K
z>Dyj4mOlCTwsq|->sAczXv?&<TvAtVR^41~RyE!%J5s+Ym+S3I<$KdzW6df)JeKyZ
z8yZ~OooZ{}-rBKY=du}N&F7VySH?Hx3dQ=}nL?&G-tHZPjbrJTKfb1Q*@i7US`IB<
z+Ond#w%jiIo8@*DRq>U3bA!eDU1M{-?OWd;OMg1Nr?6-9#{T_{`wtCmUtTlTXl$@V
zRjfT-tY6=MFqb_zKH6=+vtleQ+_}Gf*S7tOH||a^9^QO$a%^oH#ul<_^<bfx>&qNU
z*RRdw)7`n@a-&<nvUM!|&xcoRT)wVr^RB*ih3@<-s>d#|soXeLwJx0<s898G)$bfE
z=JJ_Tc5KeK9(Z^xz2)=G>vtYX_mAx9?>W$RQPtRY`0}yVv2caT|1SOUBR5|$aoua1
z`c|jfcJ1G>q5bj|mEp+AXmYG=JiOXsroX3tM=_P3Sl+ik@aeJiM-LCQZ(Oru&lUT(
zZES0PMMY`UWORJa@m>9eT&d0Ew>mcGZD%bgr?))y@L+#;CY$c+C|QyiYkk)EoG16t
zMEx)=hOv%u7sbo<$Co!YIY#}?TrRsel|Qa+xFL#T;d0yMv2xq$g(tS%l<prqu3@;}
z`@-17pbMj=hSk+|V+~K7Up|*BPE-x6L@I($MS>p(-w7TK9t=+b000000000000000
z00000000000000000000007`uRc%H5#nHk3?o2k_RZ*RYpA{{pdJ2i!co;6`a@oD9
ze7rgqhWpa}gRx{Zeok^Ao9av#YQn}9!Tpio$>6)ew}OYmQvd(}000000000000000
z00000000000000000000_?1>0e{poMzdMsncU6?mi@98OZz><Jj-M4Rrg{pAWc-}u
zKsMExE|fxj>Hfi3U8&)9r+MN400000000000000000000000000000000000003~B
z)szm`o#sgZ00000000000000000000000000000000000006*gR#!S)cbX>w00000
z0000000000000000000000000000000001|SzY+4DBKV^eE&5!k8}++hNl1k00000
z0000000000000000000000000008hy=XLWcl9AE#ZW=zXa^AjTaiDNfLqlKgP%huI
zq_eAkNqCycW>ZW0)5V6qbXO+Tkm>7b=+5NR-MQh0?$p6dXRd!qSLd>2&8>~CjeEOW
zmxPfUw{C8)t9emP*ni#nb!)bV>8#SHx-vYT_rss3B9Yk9^=nq{T=B-czHoTs{RduB
z_5I)b*M%2-XZNNpLstzQ4j#+Q_{h!w`08~RUX}Uxzqn}ARqsmQb;m32K70P`bnhka
zy?@c-ckI04w_E<YcS+}acDyw|bk*zL+;V33Q}-PGMBkN9|M2ObAN}&TfA7iHJsw=}
zp24keeejjr-}dm+{cDa6y!G<lPyO`1s_)(ZN6QKa_TBXlM{jG}@UhE_UH^6TTVKmR
zl6d(@^~f7%t{nPm{d>O~kL`JH^?RFd|EmvvZ^x$Z{@o|mv~*tfUpKD$+AHpS^Tu;8
zE)Fbhz4Xkx|4+@Dv(A0Zr|y2`@ba6#zIA!^wj0mArnlJmqTl|<k(dA1qc6OD<-dLG
zzux)!dw>7ldk_6>)w^Dh{MJW$zP|0j8?M}S$p_y5_-i{?R=w@K{rCO!&Y8C@{@oAk
z|MtS{b<6Jk;P8syO$}dK)q8H$7Zxu3UtNiBY(Mg%TWUTu@b|s1{?6#d0~<c_`q#hf
zk3atSIWsPO#}Aj?zI^@O*7sa^^A}d!^sRy6YgWGYEekIjIA`$t_h(=G*+;)K>(0+K
zEq~%A{U6#n`tvJZ|FP~r{O8J7yy2{)*WUAnd++%A)i-?eO>_RT^0RxJn=buW?1#J3
zH{Z43zFR(j?fq|l_cuTF(_P>1Z2$c4eB{;(&cFEl^Z)7P_q`?FG-GY+>;B>DjtB3I
zeW0<i?$)pT#RukHSh4w!|6=YHe^~SWfBvm&uQ})X+q!3duJ@7o**Pth>;I~5_Ka8m
z`Hc7Pobj%mGvD8Q^y<$%Hjw+hh8KQjLG{sZyy*`<IJ4v8e`)%Yp*0U3c<47?JTUUc
zm$m=R=$~x4xABg>XJ2;posWJf(e$yWXKnbz*Ux`s=6i-_9eV$#8+U(i^yx1=`r$vn
z>fnQwFRMvx9@;&8Pw>$nEPuhLuKt$?{$%c<<WKJUaeL~gzjejT!r6=8bnh(>eEyE_
z|Lb|#!GBqiu5EkeH{O2N8(#L&Bk@<yy*^d{+SGzu@4d78>@POnbVu{ezkkk*%VvM|
z-19G5{>UHA+4q{i{oq4~@A&H7`PaUC?u^&=&1g9PoTr8wf3)D7e}8Q6D<8OO>-sfc
zc>2DL=kHlG@2~%5QBUfj^o@`AzU8)?|K^^Z_g%jDCqK=<_9HVE{O-(muKCd0ZupOP
z$Dh9M)*IHW`Oxccxc<fkZ@nRQ!@9L=Vo!bN%}1hV|Mwei|JrEBUzTpc^=+Hiyl>Uj
z*Jt`tJ?Vyl{+>w13vT@C{!h;uZY&&Z>FFI=v19eB*EA)<`!xQ#MdSBrc$*Fl4Ncyk
z;r-gMw6U?Zp)b{yUOYIkc>LCEXkOgZvUGV<YjaEUvexG1&*9FDRPNo-z9#bX(to$#
z^X|8Vk>tR-D+`fG?U|)dH1bEcoE-+Mit9JERXsXm#*ErCE8h04<zeVIGdr&+uFm!K
zrTdHJr_1VmI#o<})emKg`|8`*Z`#_mBwZDWL|e)?|7ZX1_y2rTeA}!wUtRRJxwj|x
z-yT_h-RIVQ?7FvJ7h5no^V4sxU;DT9EB@{L^Yi<!i{1J0hc8YH_58;t?)bo$jy!PN
z&kz3O6Tu%uUcWx`m8-AX{m+k#G%c*V?%tpD?RsGI|DOL?=HZ9dylVapPrmz8UmE@7
zH~;O`2fuN1%O|T_m%ntN`=JLaR?n(Qy?FhKAGR*qzx2hoRNpkW`1N<@67_Ri*2F%1
z@Tr<te_`E$6>s|X=l1=-x&P4hk?-I6zMh6i+qPTJAAR5(&FzDSe;#XG@~)nD%)Rl?
z{_N4}yECu4AohVD{o}m5kGya5qO)5*(>;IvAJ6{Ee-B^t)F(fE{ulr0lmGVU>p%AQ
z%YJM3jJsC$zT@GYe|^>HHD~YX?R@0!E!Vwq)mNL}|Gytu-gw8{BTqct*mvG{o?g>A
z>#ol&`$2AD><_=xpE_&RZ6Ckp1L=zw-}RUCfA-LiD+-Uj`HHpQ%&$7-o_X}uZ++9g
zmVq5jot=Y?JNs6?rYZhE+A~Y1?wQ7Bd**q)XKEilu&eiq)S7{Hmml1cdred9f3#<s
zrtF!P<&E~t^Lfu)|GMg3JKEN-=~%UC@tTdjuW5?@kM>OC)IHPEYR^2c_srtPfmIu_
zdsnt^+<W=uZKZFy;>i0V!N-Fjc;%6!;VA$B000000000000000000000000000000
z000000KfD$Cq~bU?kg4t3Kumr^yLoa@;ysByZV=er<rUvwWL2?Z0JjOWl{~9zMh8e
zOg`P68*b=M9n5s*`j>QdE?d^z+SuB-x4U&o7^!$wVl-avF6?b+XlSyVu-k^Ejg74h
zeW|YW;=zH%<2^JqFK%jCy1c2ixutnoYxDBNFWkY>sU0*n$A94tnx=Hnvb-^NY1l#X
z7wMpJY6mT?b>XVUg8L%DBjEu600000000000000000000000000000000000004f4
z&56Y$ty{CH&UB$+M_(#mT$Ri9hH3BIXgsoh?|5cwJ~xog7c*g{u5_U@pBX4-a{V=7
z`&jUIk>K0m0RR9100000000000000000000000000000000000ewEFQuTSpH<$C*4
z`QEMh+(0^C%%lqqUFkw+J~L3v<oeH!w??;SQ=MUSM_8-4YP@!=uGIdz(>#9w00000
z00000000000000000000000000000000216>cUSVsEGt`4fX}C;Ryf$0000000000
z00000000000000000000000000KeuICTb(mXk>16G+J3XGqye5neH#{O!X8J@kqt&
zqji7!qsT<s6fak8Dp#!=ubLfMmCN<^rSiS$u8Af+S5KtB6)QJ+X}QUa*rr^exI0tG
z6esJx<BJcErC<K|{^*QIG#MEks~lh1)m6WDyz4@JF;`#QmnqbD=dxYtd?{N<r^8UX
zuf8i&7|5nZ(p~kr{!*lV)nu!kxm<Q{DnCBf+uv~YL|PYKc_L_v1osDT4z3PQ00000
z0000000000000000000000000000000002sxmcV?TyXK6zTBaXy}4X(Un<{QI4;wX
zDW>}}{axwd*81?jhIrzFOP{IwKq{Z^FOD~8u5Y?9mbhTYv-O`%6^ebiu1t3(-F0lN
zrP0I%>(6|Kragn1u8zU}%z?r5c&nzG@RDkSPey{D1y2S~1m6uF555^Z8ay2QQ*eJ6
z1^@s60000000000000000000000000000000001hUm+FoXtbiXbWBu~j`2k47>g(3
z$!IJQk3?%qhh*tcT{={i4wdnEBv})-t_?1W1pgiUB=|w_Nbu?4gTbxAjX^Q!2*UsX
z000000000000000000000000000000000000PqWXVSI74C!brA%4Rc%a(zpNhK82Z
zP8Y11E|{DySTS8NF<me|T`+cLd~x!m=J80hx^$>29V%<Wnb!uNi3C3jo(!G{z8gFq
zd^317_)_p-@VPJy000000000000000000000000000000000000002LViNIWw6=7p
zDIJoDcy+Y8bf_vFDoclo(jieg#7l=*JYJow30u|%_e6rHf*%Lp555yT7JN1MQt(f~
zXM_8~FaQ7m00000000000000000000000000000000000{PL@e$D)y&D#nt;SP~yg
zVioaNGAxTnqg^%SV=@u1j#ifrRi#5^Dfsi+ny{PN;4dP<&w?j|CxY(=j|blj9u2+}
z+#mc+7zO|U00000000000000000000000000000000000fL~HG;+4@z#q6Uq_bqFz
zoy>GaX4On)_B^;RnTSWC)ulsK=}=iZRFn>h(ji_t#NzQtvL@^?9y}Nco(R4fCIA2c
z00000000000000000000000000000000000@VqfI5v`2YMk;0>ow;vWW4xTLDQCMP
zvto5&HkmAE_dK|-CafP1eh>+M7Cady00000000000000000000000000000000000
z0002+>!BhZjmF}McrrZ3tCKZhC?0${68tFmc9;MF0000000000000000000000000
z00000000000KoIijCf@<QZf7J%zeum<CB@L$gEgxyfRtN?0IlsO<41c;146gQ^5~|
z{|Fun9t|D}J|BE0_(bs0;O^jk!MlUo!(spc000000000000000000000000000000
z00000060A=6VYU}dS*GPsw*dzGs;OtZ8=HQl#_U}oW!at63MViWg-%d#?L4xv8u78
zVk}9FCGoK&RuLx2SWP(88NnY!g8vSF9DFZ$JotL><=_j!XM;}!9}Vsf-WR+(xGgLO
z00000000000000000000000000000000000004l~p(+uLMyukrV`;3WG7(KitCC?-
z9j&S^=i-&+Tzp2kPApzEmd4H~m&Rt6OJjAFiDWbyuP7(6L`5PQwhWJ9Q6f=YovaB5
zJ0n;S34Rzn7CaREeel=8UBPX^n}h3vLeLfL3f2b8!(spc00000000000000000000
z00000000000000004ST8h*n0cBNelc*8S;|kIyV;tH!du>*~tc%5wG{U;g-va<-zJ
zz3r#NwdHJLEL*s<rkstJv$y`|!^v_sHkQ5R^VM~UWM#CRd;5<buB=N$#&dU^|FtuY
z%f*i6-Z9u1KbE`gTi>rbmb>MthbxZdww<*gF(b?+Cvr96wbdTJJrX<=yeAk6Rs}CO
z^2H;!AK82OUk`sO3<CfF00000000000000000000000000000000000{L+{o-xeLn
zraIFX6-Nfrmn@rK$mNTdG_}m{OAT*N7Y4J%!X-_O)#t`nj#n#}uQ<NA@|wix$ar~o
zF58vPU$Sb;mJOR$Zr`w@V^iDaUGvAIyEd;~zp-si$7Jb5!OksPHm+K^eg1*Lbbe%K
zX(mnMGg*54Q1jEno!P;z^tP~KCLKnqE{O-xeZ}HH;i86yp`oEAeYr!KY&Nwdm+xs9
z$mjN_JBx*e?o2-2of~dwzHmuns;QJM6!U}q4awGc?v$-38ue#-_7$@u$6D1~9KYt2
zt<wFAckMWJ+uDYB=W}ekFP$A|h_%G~o@4#a!9p?DmpPPfh&RPE$>-Q8-CxY7vJH#l
z9nUgMXD+On**jQFHzXFscRfq()ZW}+v7w?S++(%D+DP!d;L+e8f_sAZ27es9J{Sr1
z1(%0m000000000000000000000000000000000000002^1<Z=iix%1k_O97cNVPA`
zv?NM_f#EHy4(+&XWW(mwyDLh8-h<iB)oU`F3j3SW@lqhYCAWH8_p;Tk&8r5gN`bCD
z>81mH7anX|mr2D+fzFl{7p7Mgx_b8yHziAfJ;TlUgFWfiJp;Yn)uq6;^_OiMTG734
z<>eP<YR-tyORhNBGuYp?^@`4x18Ykan%h!^%)a)_j`oqZ%2Hry`@!A|53b&_b?Mrr
zwKd`F&j@0X;CsQN!2`iZgZ~@+QE+209P|XcgSA0x@Uq~{uowUU000000000000000
z00000000000000000000fakqg@p;jq!L{9~w)X9<9UFGml>&qP-I;8<tD~f%6e#9$
z*}bWJIS?-gQay!^afvhH^OE^ot~e1WRTyaBxMs(mEB0^O*w$QG3iPG>2PZ1TN`d|D
zySD9Lym5DW@$lxFQeaPE&*qK&`y2Nk8roi43T#`~-m-4R;EuLTTg%K+pgp&Dq;=0g
z<NEHA-Di{nYn%F3r`mSy-?5?n@~To`P3y7^TXwV@TD-Jn#f(y5#m439x;F3XTUY4L
zCrg3m^*axx`$zWl_Z(=et|=|Sb*K3&00000000000000000000000000000000000
z0001h)2ya+xb8Gh0ssI200000000000000000000000000000000000PP3ZulL)>M
z37!g`2>v}h0RR910000000000000000000000000000000001h=cTGdG#Z^5OO(^P
RSiGFhh{Y-r(PT3I{{gy^q7485
--- a/toolkit/components/places/tests/migration/test_current_from_v26.js
+++ b/toolkit/components/places/tests/migration/test_current_from_v26.js
@@ -5,22 +5,25 @@ add_task(function* setup() {
   yield setupPlacesDatabase("places_v26.sqlite");
   // Setup database contents to be migrated.
   let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
   let db = yield Sqlite.openConnection({ path });
   // Add pages.
   yield db.execute(`INSERT INTO moz_places (url, guid)
                     VALUES ("http://test1.com/", "test1_______")
                          , ("http://test2.com/", "test2_______")
+                         , ("http://test3.com/", "test3_______")
                    `);
   // Add keywords.
   yield db.execute(`INSERT INTO moz_keywords (keyword)
                     VALUES ("kw1")
                          , ("kw2")
                          , ("kw3")
+                         , ("kw4")
+                         , ("kw5")
                    `);
   // Add bookmarks.
   let now = Date.now() * 1000;
   let index = 0;
   yield db.execute(`INSERT INTO moz_bookmarks (type, fk, parent, position, dateAdded, lastModified, keyword_id, guid)
                     VALUES (1, (SELECT id FROM moz_places WHERE guid = 'test1_______'), 3, ${index++}, ${now}, ${now},
                              (SELECT id FROM moz_keywords WHERE keyword = 'kw1'), "bookmark1___")
                             /* same uri, different keyword */
@@ -33,25 +36,38 @@ add_task(function* setup() {
                          , (1, (SELECT id FROM moz_places WHERE guid = 'test1_______'), 3, ${index++}, ${now}, ${now},
                              (SELECT id FROM moz_keywords WHERE keyword = 'kw1'), "bookmark4___")
                            /* same uri, same keyword as 2 */
                          , (1, (SELECT id FROM moz_places WHERE guid = 'test2_______'), 3, ${index++}, ${now}, ${now},
                              (SELECT id FROM moz_keywords WHERE keyword = 'kw2'), "bookmark5___")
                            /* different uri, same keyword as 1 */
                          , (1, (SELECT id FROM moz_places WHERE guid = 'test1_______'), 3, ${index++}, ${now}, ${now},
                              (SELECT id FROM moz_keywords WHERE keyword = 'kw3'), "bookmark6___")
+                         , (1, (SELECT id FROM moz_places WHERE guid = 'test3_______'), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = 'kw4'), "bookmark7___")
+                         /* same uri and post_data as bookmark7, different keyword */
+                         , (1, (SELECT id FROM moz_places WHERE guid = 'test3_______'), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = 'kw5'), "bookmark8___")
                    `);
   // Add postData.
   yield db.execute(`INSERT INTO moz_anno_attributes (name)
-                    VALUES ("bookmarkProperties/POSTData")`);
+                    VALUES ("bookmarkProperties/POSTData")
+                         , ("someOtherAnno")`);
   yield db.execute(`INSERT INTO moz_items_annos(anno_attribute_id, item_id, content)
                     VALUES ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
                             (SELECT id FROM moz_bookmarks WHERE guid = "bookmark3___"), "postData1")
                          , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
-                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "postData2")`);
+                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "postData2")
+                         , ((SELECT id FROM moz_anno_attributes where name = "someOtherAnno"),
+                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "zzzzzzzzzz")
+                         , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark7___"), "postData3")
+                         , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark8___"), "postData3")
+                    `);
   yield db.close();
 });
 
 add_task(function* database_is_valid() {
   Assert.equal(PlacesUtils.history.databaseStatus,
                PlacesUtils.history.DATABASE_STATUS_UPGRADED);
 
   let db = yield PlacesUtils.promiseDBConnection();
@@ -64,12 +80,20 @@ add_task(function* test_keywords() {
   let [ url1, postData1 ] = PlacesUtils.getURLAndPostDataForKeyword("kw1");
   Assert.equal(url1, "http://test2.com/");
   Assert.equal(postData1, "postData1");
   let [ url2, postData2 ] = PlacesUtils.getURLAndPostDataForKeyword("kw2");
   Assert.equal(url2, "http://test2.com/");
   Assert.equal(postData2, "postData2");
   let [ url3, postData3 ] = PlacesUtils.getURLAndPostDataForKeyword("kw3");
   Assert.equal(url3, "http://test1.com/");
+  Assert.equal(postData3, null);
+  let [ url4, postData4 ] = PlacesUtils.getURLAndPostDataForKeyword("kw4");
+  Assert.equal(url4, null);
+  Assert.equal(postData4, null);
+  let [ url5, postData5 ] = PlacesUtils.getURLAndPostDataForKeyword("kw5");
+  Assert.equal(url5, "http://test3.com/");
+  Assert.equal(postData5, "postData3");
 
   Assert.equal((yield foreign_count("http://test1.com/")), 5); // 4 bookmark2 + 1 keywords
   Assert.equal((yield foreign_count("http://test2.com/")), 4); // 2 bookmark2 + 2 keywords
+  Assert.equal((yield foreign_count("http://test3.com/")), 3); // 2 bookmark2 + 1 keywords
 });
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v27.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+  yield setupPlacesDatabase("places_v27.sqlite");
+  // Setup database contents to be migrated.
+  let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+  let db = yield Sqlite.openConnection({ path });
+  // Add pages.
+  yield db.execute(`INSERT INTO moz_places (url, guid)
+                    VALUES ("http://test1.com/", "test1_______")
+                         , ("http://test2.com/", "test2_______")
+                   `);
+  // Add keywords.
+  yield db.execute(`INSERT INTO moz_keywords (keyword, place_id, post_data)
+                    VALUES ("kw1", (SELECT id FROM moz_places WHERE guid = "test2_______"), "broken data")
+                         , ("kw2", (SELECT id FROM moz_places WHERE guid = "test2_______"), NULL)
+                         , ("kw3", (SELECT id FROM moz_places WHERE guid = "test1_______"), "zzzzzzzzzz")
+                   `);
+  // Add bookmarks.
+  let now = Date.now() * 1000;
+  let index = 0;
+  yield db.execute(`INSERT INTO moz_bookmarks (type, fk, parent, position, dateAdded, lastModified, keyword_id, guid)
+                    VALUES (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = "kw1"), "bookmark1___")
+                            /* same uri, different keyword */
+                         , (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = "kw2"), "bookmark2___")
+                           /* different uri, same keyword as 1 */
+                         , (1, (SELECT id FROM moz_places WHERE guid = "test2_______"), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = "kw1"), "bookmark3___")
+                           /* same uri, same keyword as 1 */
+                         , (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = "kw1"), "bookmark4___")
+                           /* same uri, same keyword as 2 */
+                         , (1, (SELECT id FROM moz_places WHERE guid = "test2_______"), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = "kw2"), "bookmark5___")
+                           /* different uri, same keyword as 1 */
+                         , (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+                             (SELECT id FROM moz_keywords WHERE keyword = "kw3"), "bookmark6___")
+                   `);
+  // Add postData.
+  yield db.execute(`INSERT INTO moz_anno_attributes (name)
+                    VALUES ("bookmarkProperties/POSTData")
+                         , ("someOtherAnno")`);
+  yield db.execute(`INSERT INTO moz_items_annos(anno_attribute_id, item_id, content)
+                    VALUES ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark3___"), "postData1")
+                         , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "postData2")
+                         , ((SELECT id FROM moz_anno_attributes where name = "someOtherAnno"),
+                            (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "zzzzzzzzzz")
+                    `);
+  yield db.close();
+});
+
+add_task(function* database_is_valid() {
+  Assert.equal(PlacesUtils.history.databaseStatus,
+               PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+  let db = yield PlacesUtils.promiseDBConnection();
+  Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_keywords() {
+  // When 2 urls have the same keyword, if one has postData it will be
+  // preferred.
+  let [ url1, postData1 ] = PlacesUtils.getURLAndPostDataForKeyword("kw1");
+  Assert.equal(url1, "http://test2.com/");
+  Assert.equal(postData1, "postData1");
+  let [ url2, postData2 ] = PlacesUtils.getURLAndPostDataForKeyword("kw2");
+  Assert.equal(url2, "http://test2.com/");
+  Assert.equal(postData2, "postData2");
+  let [ url3, postData3 ] = PlacesUtils.getURLAndPostDataForKeyword("kw3");
+  Assert.equal(url3, "http://test1.com/");
+  Assert.equal(postData3, null);
+});
--- a/toolkit/components/places/tests/migration/xpcshell.ini
+++ b/toolkit/components/places/tests/migration/xpcshell.ini
@@ -11,16 +11,18 @@ support-files =
   places_v19.sqlite
   places_v21.sqlite
   places_v22.sqlite
   places_v23.sqlite
   places_v24.sqlite
   places_v25.sqlite
   places_v26.sqlite
   places_v27.sqlite
+  places_v28.sqlite
 
 [test_current_from_downgraded.js]
 [test_current_from_v6.js]
 [test_current_from_v16.js]
 [test_current_from_v19.js]
 [test_current_from_v24.js]
 [test_current_from_v25.js]
 [test_current_from_v26.js]
+[test_current_from_v27.js]
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -7621,16 +7621,21 @@
   },
   "PWMGR_LOGIN_LAST_USED_DAYS": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": 750,
     "n_buckets" : 40,
     "description": "Time in days each saved login was last used"
   },
+  "PWMGR_PASSWORD_INPUT_IN_FORM": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Whether an <input type=password> is associated with a <form> when it is added to a document"
+  },
   "PWMGR_PROMPT_REMEMBER_ACTION" : {
     "expires_in_version": "never",
     "kind": "enumerated",
     "n_values": 5,
     "description": "Action taken by user through prompt for creating a login. (0=Prompt displayed [always recorded], 1=Add login, 2=Don't save now, 3=Never save)"
   },
   "PWMGR_PROMPT_UPDATE_ACTION" : {
     "expires_in_version": "never",
--- a/toolkit/components/telemetry/TelemetryFile.jsm
+++ b/toolkit/components/telemetry/TelemetryFile.jsm
@@ -119,16 +119,38 @@ this.TelemetryFile = {
       p.push(this.savePing(ping, false));
       return p;}, [this.savePing(sessionPing, true)]);
 
     pendingPings = [];
     return Promise.all(p);
   },
 
   /**
+   * Add a ping to the saved pings directory so that it gets along with other pings. Note
+   * that the original ping file will not be modified.
+   *
+   * @param {String} aFilePath The path to the ping file that needs to be added to the
+   *                           saved pings directory.
+   * @return {Promise} A promise resolved when the ping is saved to the pings directory.
+   */
+  addPendingPing: function(aPingPath) {
+    // Pings in the saved ping directory need to have the ping id or slug (old format) as
+    // the file name. We load the ping content, check that it is valid, and use it to save
+    // the ping file with the correct file name.
+    return loadPingFile(aPingPath).then(ping => {
+        // Append the ping to the pending list.
+        pendingPings.push(ping);
+        // Since we read a ping successfully, update the related histogram.
+        Telemetry.getHistogramById("READ_SAVED_PING_SUCCESS").add(1);
+        // Save the ping to the saved pings directory.
+        return this.savePing(ping, false);
+      });
+  },
+
+  /**
    * Remove the file for a ping
    *
    * @param {object} ping The ping.
    * @returns {promise}
    */
   cleanupPingFile: function(ping) {
     return OS.File.remove(pingFilePath(ping));
   },
@@ -272,34 +294,42 @@ function getPingDirectory() {
       yield OS.File.makeDir(directory, { unixMode: OS.Constants.S_IRWXU });
       isPingDirectoryCreated = true;
     }
 
     return directory;
   });
 }
 
+/**
+ * Loads a ping file.
+ * @param {String} aFilePath The path of the ping file.
+ * @return {Promise<Object>} A promise resolved with the ping content or rejected if the
+ *                           ping contains invalid data.
+ */
+let loadPingFile = Task.async(function* (aFilePath) {
+  let array = yield OS.File.read(aFilePath);
+  let decoder = new TextDecoder();
+  let string = decoder.decode(array);
+
+  let ping = JSON.parse(string);
+  // The ping's payload used to be stringified JSON.  Deal with that.
+  if (typeof(ping.payload) == "string") {
+    ping.payload = JSON.parse(ping.payload);
+  }
+  return ping;
+});
+
 function addToPendingPings(file) {
   function onLoad(success) {
     let success_histogram = Telemetry.getHistogramById("READ_SAVED_PING_SUCCESS");
     success_histogram.add(success);
   }
 
-  return Task.spawn(function*() {
-    try {
-      let array = yield OS.File.read(file);
-      let decoder = new TextDecoder();
-      let string = decoder.decode(array);
-
-      let ping = JSON.parse(string);
-      // The ping's payload used to be stringified JSON.  Deal with that.
-      if (typeof(ping.payload) == "string") {
-        ping.payload = JSON.parse(ping.payload);
-      }
-
+  return loadPingFile(file).then(ping => {
       pendingPings.push(ping);
       onLoad(true);
-    } catch (e) {
+    },
+    () => {
       onLoad(false);
-      yield OS.File.remove(file);
-    }
-  });
+      return OS.File.remove(file);
+    });
 }
--- a/toolkit/components/telemetry/TelemetryPing.jsm
+++ b/toolkit/components/telemetry/TelemetryPing.jsm
@@ -9,16 +9,17 @@ const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cr = Components.results;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/debug.js", this);
 Cu.import("resource://gre/modules/Services.jsm", this);
 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
+Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/DeferredTask.jsm", this);
 Cu.import("resource://gre/modules/Preferences.jsm");
 
 const LOGGER_NAME = "Toolkit.Telemetry";
 const LOGGER_PREFIX = "TelemetryPing::";
 
 const PREF_BRANCH = "toolkit.telemetry.";
@@ -141,16 +142,29 @@ this.TelemetryPing = Object.freeze({
   /**
    * Sets a server to send pings to.
    */
   setServer: function(aServer) {
     return Impl.setServer(aServer);
   },
 
   /**
+   * Adds a ping to the pending ping list by moving it to the saved pings directory
+   * and adding it to the pending ping list.
+   *
+   * @param {String} aPingPath The path of the ping to add to the pending ping list.
+   * @param {Boolean} [aRemoveOriginal] If true, deletes the ping at aPingPath after adding
+   *                  it to the saved pings directory.
+   * @return {Promise} Resolved when the ping is correctly moved to the saved pings directory.
+   */
+  addPendingPing: function(aPingPath, aRemoveOriginal) {
+    return Impl.addPendingPing(aPingPath, aRemoveOriginal);
+  },
+
+  /**
    * Send payloads to the server.
    *
    * @param {String} aType The type of the ping.
    * @param {Object} aPayload The actual data payload for the ping.
    * @param {Object} [aOptions] Options object.
    * @param {Number} [aOptions.retentionDays=14] The number of days to keep the ping on disk
    *                 if sending fails.
    * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client
@@ -200,60 +214,33 @@ this.TelemetryPing = Object.freeze({
    * @param {Number} [aOptions.retentionDays=14] The number of days to keep the ping on disk
    *                 if sending fails.
    * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client
    *                  id, false otherwise.
    * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the
    *                  environment data.
    * @param {Boolean} [aOptions.overwrite=false] true overwrites a ping with the same name,
    *                  if found.
+   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
+   *                 ping location if not provided.
    *
-   * @returns {Promise} A promise that resolves when the ping is saved to disk.
+   * @returns {Promise<Integer>} A promise that resolves with the ping id when the ping is
+   *                             saved to disk.
    */
   savePing: function(aType, aPayload, aOptions = {}) {
     let options = aOptions;
     options.retentionDays = aOptions.retentionDays || DEFAULT_RETENTION_DAYS;
     options.addClientId = aOptions.addClientId || false;
     options.addEnvironment = aOptions.addEnvironment || false;
     options.overwrite = aOptions.overwrite || false;
 
     return Impl.savePing(aType, aPayload, options);
   },
 
   /**
-   * Only used for testing. Saves a ping to disk and return the ping id once done.
-   *
-   * @param {String} aType The type of the ping.
-   * @param {Object} aPayload The actual data payload for the ping.
-   * @param {Object} [aOptions] Options object.
-   * @param {Number} [aOptions.retentionDays=14] The number of days to keep the ping on disk
-   *                 if sending fails.
-   * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client
-   *                  id, false otherwise.
-   * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the
-   *                  environment data.
-   * @param {Boolean} [aOptions.overwrite=false] true overwrites a ping with the same name,
-   *                  if found.
-   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
-   *                 ping location if not provided.
-   *
-   * @returns {Promise<Integer>} A promise that resolves with the ping id when the ping is
-   *                             saved to disk.
-   */
-  testSavePingToFile: function(aType, aPayload, aOptions = {}) {
-    let options = aOptions;
-    options.retentionDays = aOptions.retentionDays || DEFAULT_RETENTION_DAYS;
-    options.addClientId = aOptions.addClientId || false;
-    options.addEnvironment = aOptions.addEnvironment || false;
-    options.overwrite = aOptions.overwrite || false;
-
-    return Impl.testSavePingToFile(aType, aPayload, options);
-  },
-
-  /**
    * The client id send with the telemetry ping.
    *
    * @return The client id as string, or null.
    */
    get clientID() {
     return Impl.clientID;
    },
 
@@ -373,16 +360,33 @@ let Impl = {
   /**
    * Only used in tests.
    */
   setServer: function (aServer) {
     this._server = aServer;
   },
 
   /**
+   * Adds a ping to the pending ping list by moving it to the saved pings directory
+   * and adding it to the pending ping list.
+   *
+   * @param {String} aPingPath The path of the ping to add to the pending ping list.
+   * @param {Boolean} [aRemoveOriginal] If true, deletes the ping at aPingPath after adding
+   *                  it to the saved pings directory.
+   * @return {Promise} Resolved when the ping is correctly moved to the saved pings directory.
+   */
+  addPendingPing: function(aPingPath, aRemoveOriginal) {
+    return TelemetryFile.addPendingPing(aPingPath).then(() => {
+        if (aRemoveOriginal) {
+          return OS.File.remove(aPingPath);
+        }
+      }, error => this._log.error("addPendingPing - Unable to add the pending ping", error));
+  },
+
+  /**
    * Build a complete ping and send data to the server. Record success/send-time in
    * histograms.
    *
    * @param {String} aType The type of the ping.
    * @param {Object} aPayload The actual data payload for the ping.
    * @param {Object} aOptions Options object.
    * @param {Number} aOptions.retentionDays The number of days to keep the ping on disk
    *                 if sending fails.
@@ -453,60 +457,36 @@ let Impl = {
    * @param {Object} aOptions Options object.
    * @param {Number} aOptions.retentionDays The number of days to keep the ping on disk
    *                 if sending fails.
    * @param {Boolean} aOptions.addClientId true if the ping should contain the client id,
    *                  false otherwise.
    * @param {Boolean} aOptions.addEnvironment true if the ping should contain the
    *                  environment data.
    * @param {Boolean} aOptions.overwrite true overwrites a ping with the same name, if found.
+   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
+   *                 ping location if not provided.
    *
-   * @returns {Promise} A promise that resolves when the ping is saved to disk.
+   * @returns {Promise} A promise that resolves with the ping id when the ping is saved to
+   *                    disk.
    */
   savePing: function savePing(aType, aPayload, aOptions) {
     this._log.trace("savePing - Type " + aType + ", Server " + this._server +
                     ", aOptions " + JSON.stringify(aOptions));
 
     return this.assemblePing(aType, aPayload, aOptions)
-        .then(pingData => TelemetryFile.savePing(pingData, aOptions.overwrite),
-              error => this._log.error("savePing - Rejection", error));
-  },
-
-  /**
-   * Save a ping to disk and return the ping id when done.
-   *
-   * @param {String} aType The type of the ping.
-   * @param {Object} aPayload The actual data payload for the ping.
-   * @param {Object} aOptions Options object.
-   * @param {Number} aOptions.retentionDays The number of days to keep the ping on disk
-   *                 if sending fails.
-   * @param {Boolean} aOptions.addClientId true if the ping should contain the client id,
-   *                  false otherwise.
-   * @param {Boolean} aOptions.addEnvironment true if the ping should contain the
-   *                  environment data.
-   * @param {Boolean} aOptions.overwrite true overwrites a ping with the same name, if found.
-   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
-   *                 ping location if not provided.
-   *
-   * @returns {Promise} A promise that resolves with the ping id when the ping is saved to
-   *                    disk.
-   */
-  testSavePingToFile: function testSavePingToFile(aType, aPayload, aOptions) {
-    this._log.trace("testSavePingToFile - Type " + aType + ", Server " + this._server +
-                    ", aOptions " + JSON.stringify(aOptions));
-    return this.assemblePing(aType, aPayload, aOptions)
-        .then(pingData => {
-            if (aOptions.filePath) {
-              return TelemetryFile.savePingToFile(pingData, aOptions.filePath, aOptions.overwrite)
-                                  .then(() => { return pingData.id; });
-            } else {
-              return TelemetryFile.savePing(pingData, aOptions.overwrite)
-                                  .then(() => { return pingData.id; });
-            }
-        }, error => this._log.error("testSavePing - Rejection", error));
+      .then(pingData => {
+        if ("filePath" in aOptions) {
+          return TelemetryFile.savePingToFile(pingData, aOptions.filePath, aOptions.overwrite)
+                              .then(() => { return pingData.id; });
+        } else {
+          return TelemetryFile.savePing(pingData, aOptions.overwrite)
+                              .then(() => { return pingData.id; });
+        }
+      }, error => this._log.error("savePing - Rejection", error));
   },
 
   finishPingRequest: function finishPingRequest(success, startTime, ping, isPersisted) {
     this._log.trace("finishPingRequest - Success " + success + ", Persisted " + isPersisted);
 
     let hping = Telemetry.getHistogramById("TELEMETRY_PING");
     let hsuccess = Telemetry.getHistogramById("TELEMETRY_SUCCESS");
 
--- a/toolkit/components/telemetry/TelemetrySession.jsm
+++ b/toolkit/components/telemetry/TelemetrySession.jsm
@@ -16,29 +16,32 @@ Cu.import("resource://gre/modules/osfile
 Cu.import("resource://gre/modules/Services.jsm", this);
 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/DeferredTask.jsm", this);
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 
+const myScope = this;
+
 const IS_CONTENT_PROCESS = (function() {
   // We cannot use Services.appinfo here because in telemetry xpcshell tests,
   // appinfo is initially unavailable, and becomes available only later on.
   let runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
   return runtime.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
 })();
 
 // When modifying the payload in incompatible ways, please bump this version number
 const PAYLOAD_VERSION = 4;
 const PING_TYPE_MAIN = "main";
 const PING_TYPE_SAVED_SESSION = "saved-session";
 const RETENTION_DAYS = 14;
 
+const REASON_ABORTED_SESSION = "aborted-session";
 const REASON_DAILY = "daily";
 const REASON_SAVED_SESSION = "saved-session";
 const REASON_IDLE_DAILY = "idle-daily";
 const REASON_GATHER_PAYLOAD = "gather-payload";
 const REASON_TEST_PING = "test-ping";
 const REASON_ENVIRONMENT_CHANGE = "environment-change";
 const REASON_SHUTDOWN = "shutdown";
 
@@ -62,33 +65,51 @@ const PREF_ENABLED = PREF_BRANCH + "enab
 const PREF_PREVIOUS_BUILDID = PREF_BRANCH + "previousBuildID";
 const PREF_CACHED_CLIENTID = PREF_BRANCH + "cachedClientID"
 const PREF_FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
 const PREF_ASYNC_PLUGIN_INIT = "dom.ipc.plugins.asyncInit";
 
 const MESSAGE_TELEMETRY_PAYLOAD = "Telemetry:Payload";
 const MESSAGE_TELEMETRY_GET_CHILD_PAYLOAD = "Telemetry:GetChildPayload";
 
+const DATAREPORTING_DIRECTORY = "datareporting";
+const ABORTED_SESSION_FILE_NAME = "aborted-session-ping";
+
 const SESSION_STATE_FILE_NAME = "session-state.json";
 
 // Maximum number of content payloads that we are willing to store.
 const MAX_NUM_CONTENT_PAYLOADS = 10;
 
 // Do not gather data more than once a minute
 const TELEMETRY_INTERVAL = 60000;
 // Delay before intializing telemetry (ms)
 const TELEMETRY_DELAY = 60000;
 // Delay before initializing telemetry if we're testing (ms)
 const TELEMETRY_TEST_DELAY = 100;
+// Execute a scheduler tick every 5 minutes.
+const SCHEDULER_TICK_INTERVAL_MS = 5 * 60 * 1000;
+// The maximum number of times a scheduled operation can fail.
+const SCHEDULER_RETRY_ATTEMPTS = 3;
+
+// The tolerance we have when checking if it's midnight (15 minutes).
+const SCHEDULER_MIDNIGHT_TOLERANCE_MS = 15 * 60 * 1000;
+
+// Coalesce the daily and aborted-session pings if they are both due within
+// two minutes from each other.
+const SCHEDULER_COALESCE_THRESHOLD_MS = 2 * 60 * 1000;
 
 // Seconds of idle time before pinging.
 // On idle-daily a gather-telemetry notification is fired, during it probes can
 // start asynchronous tasks to gather data.  On the next idle the data is sent.
 const IDLE_TIMEOUT_SECONDS = 5 * 60;
 
+// The frequency at which we persist session data to the disk to prevent data loss
+// in case of aborted sessions (currently 5 minutes).
+const ABORTED_SESSION_UPDATE_INTERVAL_MS = 5 * 60 * 1000;
+
 var gLastMemoryPoll = null;
 
 let gWasDebuggerAttached = false;
 
 function getLocale() {
   return Cc["@mozilla.org/chrome/chrome-registry;1"].
          getService(Ci.nsIXULChromeRegistry).
          getSelectedLocale('global');
@@ -142,31 +163,63 @@ function generateUUID() {
 
 /**
  * This is a policy object used to override behavior for testing.
  */
 let Policy = {
   now: () => new Date(),
   generateSessionUUID: () => generateUUID(),
   generateSubsessionUUID: () => generateUUID(),
-  setDailyTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
-  clearDailyTimeout: (id) => clearTimeout(id),
+  setSchedulerTickTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
+  clearSchedulerTickTimeout: id => clearTimeout(id),
 };
 
 /**
  * Takes a date and returns it trunctated to a date with daily precision.
  */
 function truncateToDays(date) {
   return new Date(date.getFullYear(),
                   date.getMonth(),
                   date.getDate(),
                   0, 0, 0, 0);
 }
 
 /**
+ * Check if the difference between the times is within the provided tolerance.
+ * @param {Number} t1 A time in milliseconds.
+ * @param {Number} t2 A time in milliseconds.
+ * @param {Number} tolerance The tolerance, in milliseconds.
+ * @return {Boolean} True if the absolute time difference is within the tolerance, false
+ *                   otherwise.
+ */
+function areTimesClose(t1, t2, tolerance) {
+  return Math.abs(t1 - t2) <= tolerance;
+}
+
+/**
+ * Get the midnight which is closer to the provided date.
+ * @param {Object} date The date object to check.
+ * @return {Object} The Date object representing the closes midnight, or null if midnight
+ *                  is not within the midnight tolerance.
+ */
+function getNearestMidnight(date) {
+  let lastMidnight = truncateToDays(date);
+  if (areTimesClose(date.getTime(), lastMidnight.getTime(), SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+    return lastMidnight;
+  }
+
+  let nextMidnightDate = new Date(lastMidnight);
+  nextMidnightDate.setDate(nextMidnightDate.getDate() + 1);
+  if (areTimesClose(date.getTime(), nextMidnightDate.getTime(), SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+    return nextMidnightDate;
+  }
+  return null;
+}
+
+/**
  * Get the ping type based on the payload.
  * @param {Object} aPayload The ping payload.
  * @return {String} A string representing the ping type.
  */
 function getPingType(aPayload) {
   // To remain consistent with server-side ping handling, set "saved-session" as the ping
   // type for "saved-session" payload reasons.
   if (aPayload.info.reason == REASON_SAVED_SESSION) {
@@ -253,21 +306,23 @@ let processInfo = {
 
 /**
  * This object allows the serialisation of asynchronous tasks. This is particularly
  * useful to serialise write access to the disk in order to prevent race conditions
  * to corrupt the data being written.
  * We are using this to synchronize saving to the file that TelemetrySession persists
  * its state in.
  */
-let gStateSaveSerializer = {
-  _queuedOperations: [],
-  _queuedInProgress: false,
-  _log: Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX),
+function SaveSerializer() {
+  this._queuedOperations = [];
+  this._queuedInProgress = false;
+  this._log = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX);
+}
 
+SaveSerializer.prototype = {
   /**
    * Enqueues an operation to a list to serialise their execution in order to prevent race
    * conditions. Useful to serialise access to disk.
    *
    * @param {Function} aFunction The task function to enqueue. It must return a promise.
    * @return {Promise} A promise resolved when the enqueued task completes.
    */
   enqueueTask: function (aFunction) {
@@ -335,16 +390,275 @@ let gStateSaveSerializer = {
                        error);
         this._queuedInProgress = false;
         reject(error);
         this._popAndPerformQueuedOperation();
       });
   },
 };
 
+/**
+ * TelemetryScheduler contains a single timer driving all regularly-scheduled
+ * Telemetry related jobs. Having a single place with this logic simplifies
+ * reasoning about scheduling actions in a single place, making it easier to
+ * coordinate jobs and coalesce them.
+ */
+let TelemetryScheduler = {
+  _lastDailyPingTime: 0,
+  _lastSessionCheckpointTime: 0,
+
+  // For sanity checking.
+  _lastAdhocPingTime: 0,
+  _lastTickTime: 0,
+
+  _log: null,
+
+  // The number of times a daily ping fails.
+  _dailyPingRetryAttempts: 0,
+
+  // The timer which drives the scheduler.
+  _schedulerTimer: null,
+  _shuttingDown: true,
+
+  /**
+   * Initialises the scheduler and schedules the first daily/aborted session pings.
+   */
+  init: function() {
+    this._log = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, "TelemetryScheduler::");
+    this._log.trace("init");
+    this._shuttingDown = false;
+    // Initialize the last daily ping and aborted session last due times to the current time.
+    // Otherwise, we might end up sending daily pings even if the subsession is not long enough.
+    let now = Policy.now();
+    this._lastDailyPingTime = now.getTime();
+    this._lastSessionCheckpointTime = now.getTime();
+    this._rescheduleTimeout();
+  },
+
+  /**
+   * Reschedules the tick timer.
+   */
+  _rescheduleTimeout: function() {
+    this._log.trace("_rescheduleTimeout");
+    if (this._shuttingDown) {
+      this._log.warn("_rescheduleTimeout - already shutdown");
+      return;
+    }
+
+    if (this._schedulerTimer) {
+      Policy.clearSchedulerTickTimeout(this._schedulerTimer);
+    }
+
+    this._schedulerTimer =
+      Policy.setSchedulerTickTimeout(() => this._onSchedulerTick(), SCHEDULER_TICK_INTERVAL_MS);
+  },
+
+  /**
+   * Checks if we can send a daily ping or not.
+   * @param {Object} nowDate A date object.
+   * @return {Boolean} True if we can send the daily ping, false otherwise.
+   */
+  _isDailyPingDue: function(nowDate) {
+    let nearestMidnight = getNearestMidnight(nowDate);
+    if (nearestMidnight) {
+      let subsessionLength = Math.abs(nowDate.getTime() - this._lastDailyPingTime);
+      if (subsessionLength < MIN_SUBSESSION_LENGTH_MS) {
+        // Generating a daily ping now would create a very short subsession.
+        return false;
+      } else if (areTimesClose(this._lastDailyPingTime, nearestMidnight.getTime(),
+                               SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+        // We've already sent a ping for this midnight.
+        return false;
+      }
+      return true;
+    }
+
+    let lastDailyPingDate = truncateToDays(new Date(this._lastDailyPingTime));
+    // This is today's date and also the previous midnight (0:00).
+    let todayDate = truncateToDays(nowDate);
+    // Check that _lastDailyPingTime isn't today nor within SCHEDULER_MIDNIGHT_TOLERANCE_MS of the
+    // *previous* midnight.
+    if ((lastDailyPingDate.getTime() != todayDate.getTime()) &&
+        !areTimesClose(this._lastDailyPingTime, todayDate.getTime(), SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+      // Computer must have gone to sleep, the daily ping is overdue.
+      return true;
+    }
+    return false;
+  },
+
+  /**
+   * An helper function to save an aborted-session ping.
+   * @param {Number} now The current time, in milliseconds.
+   * @param {Object} [competingPayload=null] If we are coalescing the daily and the
+   *                 aborted-session pings, this is the payload for the former. Note
+   *                 that the reason field of this payload will be changed.
+   * @return {Promise} A promise resolved when the ping is saved.
+   */
+  _saveAbortedPing: function(now, competingPayload=null) {
+    this._lastSessionCheckpointTime = now;
+    return Impl._saveAbortedSessionPing(competingPayload)
+                .catch(e => this._log.error("_saveAbortedPing - Failed", e));
+  },
+
+  /**
+   * Performs a scheduler tick. This function manages Telemetry recurring operations.
+   * @return {Promise} A promise, only used when testing, resolved when the scheduled
+   *                   operation completes.
+   */
+  _onSchedulerTick: function() {
+    if (this._shuttingDown) {
+      this._log.warn("_onSchedulerTick - already shutdown.");
+      return;
+    }
+
+    let promise = Promise.resolve();
+    try {
+      promise = this._schedulerTickLogic();
+    } catch (e) {
+      this._log.error("_onSchedulerTick - There was an exception", e);
+    } finally {
+      this._rescheduleTimeout();
+    }
+
+    // This promise is returned to make testing easier.
+    return promise;
+  },
+
+  /**
+   * Implements the scheduler logic.
+   * @return {Promise} Resolved when the scheduled task completes. Only used in tests.
+   */
+  _schedulerTickLogic: function() {
+    this._log.trace("_schedulerTickLogic");
+
+    let nowDate = Policy.now();
+    let now = nowDate.getTime();
+
+    if (now - this._lastTickTime > 1.1 * SCHEDULER_TICK_INTERVAL_MS) {
+      this._log.trace("_schedulerTickLogic - First scheduler tick after sleep or startup.");
+    }
+    this._lastTickTime = now;
+
+    // Check if aborted-session ping is due.
+    let isAbortedPingDue =
+      (now - this._lastSessionCheckpointTime) >= ABORTED_SESSION_UPDATE_INTERVAL_MS;
+    // Check if daily ping is due.
+    let shouldSendDaily = this._isDailyPingDue(nowDate);
+    // We can combine the daily-ping and the aborted-session ping in the following cases:
+    // - If both the daily and the aborted session pings are due (a laptop that wakes
+    //   up after a few hours).
+    // - If either the daily ping is due and the other one would follow up shortly
+    //   (whithin the coalescence threshold).
+    let nextSessionCheckpoint =
+      this._lastSessionCheckpointTime + ABORTED_SESSION_UPDATE_INTERVAL_MS;
+    let combineActions = (shouldSendDaily && isAbortedPingDue) || (shouldSendDaily &&
+                          areTimesClose(now, nextSessionCheckpoint, SCHEDULER_COALESCE_THRESHOLD_MS));
+
+    if (combineActions) {
+      this._log.trace("_schedulerTickLogic - Combining pings.");
+      // Send the daily ping and also save its payload as an aborted-session ping.
+      return Impl._sendDailyPing(true).then(() => this._dailyPingSucceeded(now),
+                                            () => this._dailyPingFailed(now));
+    } else if (shouldSendDaily) {
+      this._log.trace("_schedulerTickLogic - Daily ping due.");
+      return Impl._sendDailyPing().then(() => this._dailyPingSucceeded(now),
+                                        () => this._dailyPingFailed(now));
+    } else if (isAbortedPingDue) {
+      this._log.trace("_schedulerTickLogic - Aborted session ping due.");
+      return this._saveAbortedPing(now);
+    }
+
+    // No ping is due.
+    this._log.trace("_schedulerTickLogic - No ping due.");
+    // It's possible, because of sleeps, that we're no longer within midnight tolerance for
+    // daily pings. Because of that, daily retry attempts would not be 0 on the next midnight.
+    // Reset that count on do-nothing ticks.
+    this._dailyPingRetryAttempts = 0;
+    return Promise.resolve();
+  },
+
+  /**
+   * Update the scheduled pings if some other ping was sent.
+   * @param {String} reason The reason of the ping that was sent.
+   * @param {Object} [competingPayload=null] The payload of the ping that was sent. The
+   *                 reason of this payload will be changed.
+   */
+  reschedulePings: function(reason, competingPayload = null) {
+    if (this._shuttingDown) {
+      this._log.error("reschedulePings - already shutdown");
+      return;
+    }
+
+    this._log.trace("reschedulePings - reason: " + reason);
+    let now = Policy.now();
+    this._lastAdhocPingTime = now.getTime();
+    if (reason == REASON_ENVIRONMENT_CHANGE) {
+      // We just generated an environment-changed ping, save it as an aborted session and
+      // update the schedules.
+      this._saveAbortedPing(now.getTime(), competingPayload);
+      // If we're close to midnight, skip today's daily ping and reschedule it for tomorrow.
+      let nearestMidnight = getNearestMidnight(now);
+      if (nearestMidnight) {
+        this._lastDailyPingTime = now.getTime();
+      }
+    }
+
+    this._rescheduleTimeout();
+  },
+
+  /**
+   * Called when a scheduled operation successfully completes (ping sent or saved).
+   * @param {Number} now The current time, in milliseconds.
+   */
+  _dailyPingSucceeded: function(now) {
+    this._log.trace("_dailyPingSucceeded");
+    this._lastDailyPingTime = now;
+    this._dailyPingRetryAttempts = 0;
+  },
+
+  /**
+   * Called when a scheduled operation fails (ping sent or saved).
+   * @param {Number} now The current time, in milliseconds.
+   */
+  _dailyPingFailed: function(now) {
+    this._log.error("_dailyPingFailed");
+    this._dailyPingRetryAttempts++;
+
+    // If we reach the maximum number of retry attempts for a daily ping, log the error
+    // and skip this daily ping.
+    if (this._dailyPingRetryAttempts >= SCHEDULER_RETRY_ATTEMPTS) {
+      this._log.error("_pingFailed - The daily ping failed too many times. Skipping it.");
+      this._dailyPingRetryAttempts = 0;
+      this._lastDailyPingTime = now;
+    }
+  },
+
+  /**
+   * Stops the scheduler.
+   */
+  shutdown: function() {
+    if (this._shuttingDown) {
+      if (this._log) {
+        this._log.error("shutdown - Already shut down");
+      } else {
+        Cu.reportError("TelemetryScheduler.shutdown - Already shut down");
+      }
+      return;
+    }
+
+    this._log.trace("shutdown");
+    if (this._schedulerTimer) {
+      Policy.clearSchedulerTickTimeout(this._schedulerTimer);
+      this._schedulerTimer = null;
+    }
+
+    this._shuttingDown = true;
+  }
+};
+
 this.EXPORTED_SYMBOLS = ["TelemetrySession"];
 
 this.TelemetrySession = Object.freeze({
   Constants: Object.freeze({
     PREF_ENABLED: PREF_ENABLED,
     PREF_SERVER: PREF_SERVER,
     PREF_PREVIOUS_BUILDID: PREF_PREVIOUS_BUILDID,
   }),
@@ -485,22 +799,24 @@ let Impl = {
   // null on first run.
   _previousSubsessionId: null,
   // The running no. of subsessions since the start of the browser session
   _subsessionCounter: 0,
   // The running no. of all subsessions for the whole profile life time
   _profileSubsessionCounter: 0,
   // Date of the last session split
   _subsessionStartDate: null,
-  // The timer used for daily collections.
-  _dailyTimerId: null,
   // A task performing delayed initialization of the chrome process
   _delayedInitTask: null,
   // The deferred promise resolved when the initialization task completes.
   _delayedInitTaskDeferred: null,
+  // Used to serialize session state writes to disk.
+  _stateSaveSerializer: new SaveSerializer(),
+  // Used to serialize aborted session ping writes to disk.
+  _abortedSessionSerializer: new SaveSerializer(),
 
   /**
    * Gets a series of simple measurements (counters). At the moment, this
    * only returns startup data from nsIAppStartup.getStartupInfo().
    *
    * @return simple measurements as a dictionary.
    */
   getSimpleMeasurements: function getSimpleMeasurements(forSavedSession) {
@@ -1009,18 +1325,17 @@ let Impl = {
     let measurements = this.getSimpleMeasurements(reason == REASON_SAVED_SESSION);
     let info = !IS_CONTENT_PROCESS ? this.getMetadata(reason) : null;
     let payload = this.assemblePayloadWithMeasurements(measurements, info, reason, clearSubsession);
 
     if (!IS_CONTENT_PROCESS && clearSubsession) {
       this.startNewSubsession();
       // Persist session data to disk (don't wait until it completes).
       let sessionData = this._getSessionDataObject();
-      gStateSaveSerializer.enqueueTask(() => this._saveSessionData(sessionData));
-      this._rescheduleDailyTimer();
+      this._stateSaveSerializer.enqueueTask(() => this._saveSessionData(sessionData));
     }
 
     return payload;
   },
 
   /**
    * Send data to the server. Record success/send-time in histograms
    */
@@ -1163,19 +1478,30 @@ let Impl = {
             this._log.error("setupChromeProcess - Could not write session data to disk."));
         }
         this.attachObservers();
         this.gatherMemory();
 
         Telemetry.asyncFetchTelemetryData(function () {});
 
 #if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
-        this._rescheduleDailyTimer();
+        // Check for a previously written aborted session ping.
+        yield this._checkAbortedSessionPing();
+
         TelemetryEnvironment.registerChangeListener(ENVIRONMENT_CHANGE_LISTENER,
                                                     () => this._onEnvironmentChange());
+        // Write the first aborted-session ping as early as possible. Just do that
+        // if we are not testing, since calling Telemetry.reset() will make a previous
+        // aborted ping a pending ping.
+        if (!testing) {
+          yield this._saveAbortedSessionPing();
+        }
+
+        // Start the scheduler.
+        TelemetryScheduler.init();
 #endif
 
         this._delayedInitTaskDeferred.resolve();
       } catch (e) {
         this._delayedInitTaskDeferred.reject();
       } finally {
         this._delayedInitTask = null;
         this._delayedInitTaskDeferred = null;
@@ -1316,17 +1642,17 @@ let Impl = {
     let payload = this.getSessionPayload(REASON_SAVED_SESSION, false);
     let options = {
       retentionDays: RETENTION_DAYS,
       addClientId: true,
       addEnvironment: true,
       overwrite: true,
       filePath: file.path,
     };
-    return TelemetryPing.testSavePingToFile(getPingType(payload), payload, options);
+    return TelemetryPing.savePing(getPingType(payload), payload, options);
   },
 
   /**
    * Remove observers to avoid leaks
    */
   uninstall: function uninstall() {
     this.detachObservers();
     if (this._hasWindowRestoredObserver) {
@@ -1496,31 +1822,32 @@ let Impl = {
    *                can send pings or not, which is used for testing.
    */
   shutdownChromeProcess: function(testing = false) {
     this._log.trace("shutdownChromeProcess - testing: " + testing);
 
     let cleanup = () => {
 #if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
       TelemetryEnvironment.unregisterChangeListener(ENVIRONMENT_CHANGE_LISTENER);
+      TelemetryScheduler.shutdown();
 #endif
-      if (this._dailyTimerId) {
-        Policy.clearDailyTimeout(this._dailyTimerId);
-        this._dailyTimerId = null;
-      }
       this.uninstall();
 
       let reset = () => {
         this._initStarted = false;
         this._initialized = false;
       };
 
       if (Telemetry.canSend || testing) {
         return this.savePendingPings()
-                .then(() => gStateSaveSerializer.flushTasks())
+                .then(() => this._stateSaveSerializer.flushTasks())
+#if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
+                .then(() => this._abortedSessionSerializer
+                                .enqueueTask(() => this._removeAbortedSessionPing()))
+#endif
                 .then(reset);
       }
 
       reset();
       return Promise.resolve();
     };
 
     // We can be in one the following states here:
@@ -1540,66 +1867,49 @@ let Impl = {
       // We already ran the delayed initialization.
       return cleanup();
      }
 
     // This handles 2) and 3).
     return this._delayedInitTask.finalize().then(cleanup);
    },
 
-  _rescheduleDailyTimer: function() {
-    if (this._dailyTimerId) {
-      this._log.trace("_rescheduleDailyTimer - clearing existing timeout");
-      Policy.clearDailyTimeout(this._dailyTimerId);
-    }
-
-    let now = Policy.now();
-    let midnight = truncateToDays(now).getTime() + MS_IN_ONE_DAY;
-    let msUntilCollection = midnight - now.getTime();
-    if (msUntilCollection < MIN_SUBSESSION_LENGTH_MS) {
-      msUntilCollection += MS_IN_ONE_DAY;
-    }
-
-    this._log.trace("_rescheduleDailyTimer - now: " + now
-                    + ", scheduled: " + new Date(now.getTime() + msUntilCollection));
-    this._dailyTimerId = Policy.setDailyTimeout(() => this._onDailyTimer(), msUntilCollection);
-  },
-
-  _onDailyTimer: function() {
-    if (!this._initStarted) {
-      if (this._log) {
-        this._log.warn("_onDailyTimer - not initialized");
-      } else {
-        Cu.reportError("TelemetrySession._onDailyTimer - not initialized");
-      }
-      return;
-    }
-
-    this._log.trace("_onDailyTimer");
+  /**
+   * Gather and send a daily ping.
+   * @param {Boolean} [saveAsAborted=false] Also saves the payload as an aborted-session
+   *                  ping.
+   * @return {Promise} Resolved when the ping is sent.
+   */
+  _sendDailyPing: function(saveAsAborted = false) {
+    this._log.trace("_sendDailyPing");
     let payload = this.getSessionPayload(REASON_DAILY, true);
 
     let options = {
       retentionDays: RETENTION_DAYS,
       addClientId: true,
       addEnvironment: true,
     };
+
     let promise = TelemetryPing.send(getPingType(payload), payload, options);
-
-    this._rescheduleDailyTimer();
-    // Return the promise so tests can wait on the ping submission.
+#if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
+    // If required, also save the payload as an aborted session.
+    if (saveAsAborted) {
+      return promise.then(() => this._saveAbortedSessionPing(payload));
+    }
+#endif
     return promise;
   },
 
   /**
    * Loads session data from the session data file.
    * @return {Promise<boolean>} A promise which is resolved with a true argument when
    *                            loading has completed, with false otherwise.
    */
   _loadSessionData: Task.async(function* () {
-    let dataFile = OS.Path.join(OS.Constants.Path.profileDir, "datareporting",
+    let dataFile = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
                                 SESSION_STATE_FILE_NAME);
 
     // Try to load the "profileSubsessionCounter" from the state file.
     try {
       let data = yield CommonUtils.readJSON(dataFile);
       if (data &&
           "profileSubsessionCounter" in data &&
           typeof(data.profileSubsessionCounter) == "number" &&
@@ -1627,37 +1937,40 @@ let Impl = {
       profileSubsessionCounter: this._profileSubsessionCounter,
     };
   },
 
   /**
    * Saves session data to disk.
    */
   _saveSessionData: Task.async(function* (sessionData) {
-    let dataDir = OS.Path.join(OS.Constants.Path.profileDir, "datareporting");
+    let dataDir = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY);
     yield OS.File.makeDir(dataDir);
 
     let filePath = OS.Path.join(dataDir, SESSION_STATE_FILE_NAME);
     try {
       yield CommonUtils.writeJSON(sessionData, filePath);
     } catch(e) {
       this._log.error("_saveSessionData - Failed to write session data to " + filePath, e);
     }
   }),
 
   _onEnvironmentChange: function() {
     this._log.trace("_onEnvironmentChange");
     let payload = this.getSessionPayload(REASON_ENVIRONMENT_CHANGE, true);
 
+    let clonedPayload = Cu.cloneInto(payload, myScope);
+    TelemetryScheduler.reschedulePings(REASON_ENVIRONMENT_CHANGE, clonedPayload);
+
     let options = {
       retentionDays: RETENTION_DAYS,
       addClientId: true,
       addEnvironment: true,
     };
-    let promise = TelemetryPing.send(getPingType(payload), payload, options);
+    TelemetryPing.send(getPingType(payload), payload, options);
   },
 
   _isClassicReason: function(reason) {
     const classicReasons = [
       REASON_SAVED_SESSION,
       REASON_IDLE_DAILY,
       REASON_GATHER_PAYLOAD,
       REASON_TEST_PING,
@@ -1668,12 +1981,78 @@ let Impl = {
   /**
    * Get an object describing the current state of this module for AsyncShutdown diagnostics.
    */
   _getState: function() {
     return {
       initialized: this._initialized,
       initStarted: this._initStarted,
       haveDelayedInitTask: !!this._delayedInitTask,
-      dailyTimerScheduled: !!this._dailyTimerId,
     };
   },
+
+  /**
+   * Deletes the aborted session ping. This is called during shutdown.
+   * @return {Promise} Resolved when the aborted session ping is removed or if it doesn't
+   *                   exist.
+   */
+  _removeAbortedSessionPing: function() {
+    const FILE_PATH = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
+                                   ABORTED_SESSION_FILE_NAME);
+    try {
+      return OS.File.remove(FILE_PATH);
+    } catch (ex if ex.becauseNoSuchFile) { }
+    return Promise.resolve();
+  },
+
+  /**
+   * Check if there's any aborted session ping available. If so, tell TelemetryPing about
+   * it.
+   */
+  _checkAbortedSessionPing: Task.async(function* () {
+    // Create the subdirectory that will contain te aborted session ping. We put it in a
+    // subdirectory so that it doesn't get picked up as a pending ping. Please note that
+    // this does nothing if the directory does not already exist.
+    const ABORTED_SESSIONS_DIR =
+      OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY);
+    yield OS.File.makeDir(ABORTED_SESSIONS_DIR, { ignoreExisting: true });
+
+    const FILE_PATH = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
+                                   ABORTED_SESSION_FILE_NAME);
+    let abortedExists = yield OS.File.exists(FILE_PATH);
+    if (abortedExists) {
+      this._log.trace("_checkAbortedSessionPing - aborted session found: " + FILE_PATH);
+      yield this._abortedSessionSerializer.enqueueTask(
+        () => TelemetryPing.addPendingPing(FILE_PATH, true));
+    }
+  }),
+
+  /**
+   * Saves the aborted session ping to disk.
+   * @param {Object} [aProvidedPayload=null] A payload object to be used as an aborted
+   *                 session ping. The reason of this payload is changed to aborted-session.
+   *                 If not provided, a new payload is gathered.
+   */
+  _saveAbortedSessionPing: function(aProvidedPayload = null) {
+    const FILE_PATH = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
+                                   ABORTED_SESSION_FILE_NAME);
+    this._log.trace("_saveAbortedSessionPing - ping path: " + FILE_PATH);
+
+    let payload = null;
+    if (aProvidedPayload) {
+      payload = aProvidedPayload;
+      // Overwrite the original reason.
+      payload.info.reason = REASON_ABORTED_SESSION;
+    } else {
+      payload = this.getSessionPayload(REASON_ABORTED_SESSION, false);
+    }
+
+    let options = {
+      retentionDays: RETENTION_DAYS,
+      addClientId: true,
+      addEnvironment: true,
+      overwrite: true,
+      filePath: FILE_PATH,
+    };
+    return this._abortedSessionSerializer.enqueueTask(() =>
+      TelemetryPing.savePing(getPingType(payload), payload, options));
+  },
 };
--- a/toolkit/components/telemetry/docs/main-ping.rst
+++ b/toolkit/components/telemetry/docs/main-ping.rst
@@ -2,19 +2,20 @@
 "main" ping
 ===========
 
 This is the "main" Telemetry ping type, whose payload contains most of the measurements that are used to track the performance and health of Firefox in the wild.
 It includes the histograms and other performance and diagnostic data.
 
 This ping is triggered by different scenarios, which is documented by the ``reason`` field:
 
+* ``aborted-session`` - this ping is regularly saved to disk (every 5 minutes), overwriting itself, and deleted at shutdown. If a previous aborted session ping is found at startup, it gets sent to the server. The first aborted-session ping is generated as soon as Telemetry starts
 * ``environment-change`` - the :doc:`environment` changed, so the session measurements got reset and a new subsession starts
 * ``shutdown`` - triggered when the browser session ends
-* ``daily`` - a session split triggered in 24h hour intervals at local midnight
+* ``daily`` - a session split triggered in 24h hour intervals at local midnight. If an ``environment-change`` ping is generated by the time it should be sent, the daily ping is rescheduled for the next midnight
 * ``saved-session`` - the *"classic"* Telemetry payload with measurements covering the whole browser session (only submitted for a transition period)
 
 Most reasons lead to a session split, initiating a new *subsession*. We reset important measurements for those subsessions.
 
 Structure::
 
     {
       version: 4,
--- a/toolkit/components/telemetry/tests/unit/head.js
+++ b/toolkit/components/telemetry/tests/unit/head.js
@@ -73,22 +73,22 @@ function createAppInfo(id, name, version
       return gAppInfo.QueryInterface(iid);
     }
   };
   var registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
   registrar.registerFactory(XULAPPINFO_CID, "XULAppInfo",
                             XULAPPINFO_CONTRACTID, XULAppInfoFactory);
 }
 
-// Fake setTimeout and clearTimeout for the daily timer in tests for controllable behavior.
-function fakeDailyTimers(set, clear) {
+// Fake the timeout functions for the TelemetryScheduler.
+function fakeSchedulerTimer(set, clear) {
   let session = Components.utils.import("resource://gre/modules/TelemetrySession.jsm");
-  session.Policy.setDailyTimeout = set;
-  session.Policy.clearDailyTimeout = clear;
+  session.Policy.setSchedulerTickTimeout = set;
+  session.Policy.clearSchedulerTickTimeout = clear;
 }
 
 // Set logging preferences for all the tests.
 Services.prefs.setCharPref("toolkit.telemetry.log.level", "Trace");
 Services.prefs.setBoolPref("toolkit.telemetry.log.dump", true);
 TelemetryPing.initLogging();
 
 // Avoid timers interrupting test behavior.
-fakeDailyTimers(() => {}, () => {});
+fakeSchedulerTimer(() => {}, () => {});
--- a/toolkit/components/telemetry/tests/unit/test_TelemetrySendOldPings.js
+++ b/toolkit/components/telemetry/tests/unit/test_TelemetrySendOldPings.js
@@ -68,17 +68,17 @@ let gSeenPings = 0;
 let createSavedPings = Task.async(function* (aPingInfos) {
   let pingIds = [];
   let now = Date.now();
 
   for (let type in aPingInfos) {
     let num = aPingInfos[type].num;
     let age = now - aPingInfos[type].age;
     for (let i = 0; i < num; ++i) {
-      let pingId = yield TelemetryPing.testSavePingToFile("test-ping", {}, { overwrite: true });
+      let pingId = yield TelemetryPing.savePing("test-ping", {}, { overwrite: true });
       if (aPingInfos[type].age) {
         // savePing writes to the file synchronously, so we're good to
         // modify the lastModifedTime now.
         let filePath = getSavePathForPingId(pingId);
         yield File.setDates(filePath, null, age);
       }
       gCreatedPings++;
       pingIds.push(pingId);
--- a/toolkit/components/telemetry/tests/unit/test_TelemetrySession.js
+++ b/toolkit/components/telemetry/tests/unit/test_TelemetrySession.js
@@ -26,16 +26,17 @@ Cu.import("resource://gre/modules/Task.j
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 
 const PING_FORMAT_VERSION = 4;
 const PING_TYPE_MAIN = "main";
 const PING_TYPE_SAVED_SESSION = "saved-session";
 
+const REASON_ABORTED_SESSION = "aborted-session";
 const REASON_SAVED_SESSION = "saved-session";
 const REASON_SHUTDOWN = "shutdown";
 const REASON_TEST_PING = "test-ping";
 const REASON_DAILY = "daily";
 const REASON_ENVIRONMENT_CHANGE = "environment-change";
 
 const PLATFORM_VERSION = "1.9.2";
 const APP_VERSION = "1";
@@ -60,25 +61,34 @@ const RW_OWNER = parseInt("0600", 8);
 const NUMBER_OF_THREADS_TO_LAUNCH = 30;
 let gNumberOfThreadsLaunched = 0;
 
 const SEC_IN_ONE_DAY  = 24 * 60 * 60;
 const MS_IN_ONE_DAY   = SEC_IN_ONE_DAY * 1000;
 
 const PREF_BRANCH = "toolkit.telemetry.";
 const PREF_ENABLED = PREF_BRANCH + "enabled";
+const PREF_SERVER = PREF_BRANCH + "server";
 const PREF_FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
 const PREF_FHR_SERVICE_ENABLED = "datareporting.healthreport.service.enabled";
 
 const HAS_DATAREPORTINGSERVICE = "@mozilla.org/datareporting/service;1" in Cc;
 const SESSION_RECORDER_EXPECTED = HAS_DATAREPORTINGSERVICE &&
                                   Preferences.get(PREF_FHR_SERVICE_ENABLED, true);
 
+const DATAREPORTING_DIR = "datareporting";
+const ABORTED_PING_FILE_NAME = "aborted-session-ping";
+const ABORTED_SESSION_UPDATE_INTERVAL_MS = 5 * 60 * 1000;
+
 const Telemetry = Cc["@mozilla.org/base/telemetry;1"].getService(Ci.nsITelemetry);
 
+XPCOMUtils.defineLazyGetter(this, "DATAREPORTING_PATH", function() {
+  return OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIR);
+});
+
 let gHttpServer = new HttpServer();
 let gServerStarted = false;
 let gRequestIterator = null;
 let gDataReportingClientID = null;
 
 XPCOMUtils.defineLazyGetter(this, "gDatareportingService",
   () => Cc["@mozilla.org/datareporting/service;1"]
           .getService(Ci.nsISupports)
@@ -573,16 +583,17 @@ add_task(function* test_noServerPing() {
   yield sendPing();
 });
 
 // Checks that a sent ping is correctly received by a dummy http server.
 add_task(function* test_simplePing() {
   gHttpServer.start(-1);
   gServerStarted = true;
   gRequestIterator = Iterator(new Request());
+  Preferences.set(PREF_SERVER, "http://localhost:" + gHttpServer.identity.primaryPort);
 
   let now = new Date(2020, 1, 1, 12, 0, 0);
   let expectedDate = new Date(2020, 1, 1, 0, 0, 0);
   fakeNow(now);
 
   const expectedSessionUUID = "bd314d15-95bf-4356-b682-b6c4a8942202";
   const expectedSubsessionUUID = "3e2e5f6c-74ba-4e4d-a93f-a48af238a8c7";
   fakeGenerateUUID(() => expectedSessionUUID, () => expectedSubsessionUUID);
@@ -844,116 +855,228 @@ add_task(function* test_checkSubsession(
 add_task(function* test_dailyCollection() {
   if (gIsAndroid) {
     // We don't do daily collections yet on Android.
     return;
   }
 
   let now = new Date(2030, 1, 1, 12, 0, 0);
   let nowDay = new Date(2030, 1, 1, 0, 0, 0);
-  let timerCallback = null;
-  let timerDelay = null;
+  let schedulerTickCallback = null;
 
   gRequestIterator = Iterator(new Request());
 
   fakeNow(now);
-  fakeDailyTimers((callback, timeout) => {
-    dump("fake setDailyTimeout(" + callback + ", " + timeout + ")\n");
-    timerCallback = callback;
-    timerDelay = timeout;
-    return 1;
-  }, () => {});
+
+  // Fake scheduler functions to control daily collection flow in tests.
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
 
   // Init and check timer.
   yield TelemetrySession.setup();
   TelemetryPing.setServer("http://localhost:" + gHttpServer.identity.primaryPort);
 
-  Assert.ok(!!timerCallback);
-  Assert.ok(Number.isFinite(timerDelay));
-  let timerDate = futureDate(now, timerDelay);
-  let expectedDate = futureDate(nowDay, MS_IN_ONE_DAY);
-  Assert.equal(timerDate.toISOString(), expectedDate.toISOString());
-
   // Set histograms to expected state.
   const COUNT_ID = "TELEMETRY_TEST_COUNT";
   const KEYED_ID = "TELEMETRY_TEST_KEYED_COUNT";
   const count = Telemetry.getHistogramById(COUNT_ID);
   const keyed = Telemetry.getKeyedHistogramById(KEYED_ID);
 
   count.clear();
   keyed.clear();
   count.add(1);
   keyed.add("a", 1);
   keyed.add("b", 1);
   keyed.add("b", 1);
 
-  // Trigger and collect daily ping.
-  yield timerCallback();
+  // Make sure the daily ping gets triggered.
+  let expectedDate = nowDay;
+  now = futureDate(nowDay, MS_IN_ONE_DAY);
+  fakeNow(now);
+
+  Assert.ok(!!schedulerTickCallback);
+  // Run a scheduler tick: it should trigger the daily ping.
+  yield schedulerTickCallback();
+
+  // Collect the daily ping.
   let request = yield gRequestIterator.next();
   Assert.ok(!!request);
   let ping = decodeRequestPayload(request);
 
   Assert.equal(ping.type, PING_TYPE_MAIN);
   Assert.equal(ping.payload.info.reason, REASON_DAILY);
   let subsessionStartDate = new Date(ping.payload.info.subsessionStartDate);
-  Assert.equal(subsessionStartDate.toISOString(), nowDay.toISOString());
+  Assert.equal(subsessionStartDate.toISOString(), expectedDate.toISOString());
 
   Assert.equal(ping.payload.histograms[COUNT_ID].sum, 1);
   Assert.equal(ping.payload.keyedHistograms[KEYED_ID]["a"].sum, 1);
   Assert.equal(ping.payload.keyedHistograms[KEYED_ID]["b"].sum, 2);
 
-  // Trigger and collect another ping. The histograms should be reset.
-  yield timerCallback();
+  // The daily ping is rescheduled for "tomorrow".
+  expectedDate = futureDate(expectedDate, MS_IN_ONE_DAY);
+  now = futureDate(now, MS_IN_ONE_DAY);
+  fakeNow(now);
+
+  // Run a scheduler tick. Trigger and collect another ping. The histograms should be reset.
+  yield schedulerTickCallback();
+
   request = yield gRequestIterator.next();
   Assert.ok(!!request);
   ping = decodeRequestPayload(request);
 
   Assert.equal(ping.type, PING_TYPE_MAIN);
   Assert.equal(ping.payload.info.reason, REASON_DAILY);
   subsessionStartDate = new Date(ping.payload.info.subsessionStartDate);
-  Assert.equal(subsessionStartDate.toISOString(), nowDay.toISOString());
+  Assert.equal(subsessionStartDate.toISOString(), expectedDate.toISOString());
 
   Assert.equal(ping.payload.histograms[COUNT_ID].sum, 0);
   Assert.deepEqual(ping.payload.keyedHistograms[KEYED_ID], {});
 
   // Trigger and collect another daily ping, with the histograms being set again.
   count.add(1);
   keyed.add("a", 1);
   keyed.add("b", 1);
 
-  yield timerCallback();
+  // The daily ping is rescheduled for "tomorrow".
+  expectedDate = futureDate(expectedDate, MS_IN_ONE_DAY);
+  now = futureDate(now, MS_IN_ONE_DAY);
+  fakeNow(now);
+
+  yield schedulerTickCallback();
   request = yield gRequestIterator.next();
   Assert.ok(!!request);
   ping = decodeRequestPayload(request);
 
   Assert.equal(ping.type, PING_TYPE_MAIN);
   Assert.equal(ping.payload.info.reason, REASON_DAILY);
   subsessionStartDate = new Date(ping.payload.info.subsessionStartDate);
-  Assert.equal(subsessionStartDate.toISOString(), nowDay.toISOString());
+  Assert.equal(subsessionStartDate.toISOString(), expectedDate.toISOString());
 
   Assert.equal(ping.payload.histograms[COUNT_ID].sum, 1);
   Assert.equal(ping.payload.keyedHistograms[KEYED_ID]["a"].sum, 1);
   Assert.equal(ping.payload.keyedHistograms[KEYED_ID]["b"].sum, 1);
+
+  // Shutdown to cleanup the aborted-session if it gets created.
+  yield TelemetrySession.shutdown();
+});
+
+add_task(function* test_dailyDuplication() {
+  if (gIsAndroid) {
+    // We don't do daily collections yet on Android.
+    return;
+  }
+
+  gRequestIterator = Iterator(new Request());
+
+  let schedulerTickCallback = null;
+  let now = new Date(2030, 1, 1, 0, 0, 0);
+  fakeNow(now);
+  // Fake scheduler functions to control daily collection flow in tests.
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
+  yield TelemetrySession.setup();
+
+  // Make sure the daily ping gets triggered just before midnight.
+  let firstDailyDue = new Date(2030, 1, 1, 23, 45, 0);
+  fakeNow(firstDailyDue);
+
+  // Run a scheduler tick: it should trigger the daily ping.
+  Assert.ok(!!schedulerTickCallback);
+  yield schedulerTickCallback();
+
+  // Get the first daily ping.
+  let request = yield gRequestIterator.next();
+  Assert.ok(!!request);
+  let ping = decodeRequestPayload(request);
+
+  Assert.equal(ping.type, PING_TYPE_MAIN);
+  Assert.equal(ping.payload.info.reason, REASON_DAILY);
+
+  // We don't expect to receive any other daily ping in this test, so assert if we do.
+  registerPingHandler((req, res) => {
+    Assert.ok(false, "No more daily pings should be sent/received in this test.");
+  });
+
+  // Set the current time to a bit after midnight.
+  let secondDailyDue = new Date(firstDailyDue);
+  secondDailyDue.setDate(firstDailyDue.getDate() + 1);
+  secondDailyDue.setHours(0);
+  secondDailyDue.setMinutes(15);
+  fakeNow(secondDailyDue);
+
+  // Run a scheduler tick: it should NOT trigger the daily ping.
+  Assert.ok(!!schedulerTickCallback);
+  yield schedulerTickCallback();
+
+  // Shutdown to cleanup the aborted-session if it gets created.
+  yield TelemetrySession.shutdown();
+});
+
+add_task(function* test_dailyOverdue() {
+  if (gIsAndroid) {
+    // We don't do daily collections yet on Android.
+    return;
+  }
+
+  let schedulerTickCallback = null;
+  let now = new Date(2030, 1, 1, 11, 0, 0);
+  fakeNow(now);
+  // Fake scheduler functions to control daily collection flow in tests.
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
+  yield TelemetrySession.setup();
+
+  // Skip one hour ahead: nothing should be due.
+  now.setHours(now.getHours() + 1);
+  fakeNow(now);
+
+  // Assert if we receive something!
+  registerPingHandler((req, res) => {
+    Assert.ok(false, "No daily ping should be received if not overdue!.");
+  });
+
+  // This tick should not trigger any daily ping.
+  Assert.ok(!!schedulerTickCallback);
+  yield schedulerTickCallback();
+
+  // Restore the non asserting ping handler. This is done by the Request() constructor.
+  gRequestIterator = Iterator(new Request());
+
+  // Simulate an overdue ping: we're not close to midnight, but the last daily ping
+  // time is too long ago.
+  let dailyOverdue = new Date(2030, 1, 2, 13, 00, 0);
+  fakeNow(dailyOverdue);
+
+  // Run a scheduler tick: it should trigger the daily ping.
+  Assert.ok(!!schedulerTickCallback);
+  yield schedulerTickCallback();
+
+  // Get the first daily ping.
+  let request = yield gRequestIterator.next();
+  Assert.ok(!!request);
+  let ping = decodeRequestPayload(request);
+
+  Assert.equal(ping.type, PING_TYPE_MAIN);
+  Assert.equal(ping.payload.info.reason, REASON_DAILY);
+
+  // Shutdown to cleanup the aborted-session if it gets created.
+  yield TelemetrySession.shutdown();
 });
 
 add_task(function* test_environmentChange() {
   if (gIsAndroid) {
     // We don't split subsessions on environment changes yet on Android.
     return;
   }
 
   let now = new Date(2040, 1, 1, 12, 0, 0);
   let nowDay = new Date(2040, 1, 1, 0, 0, 0);
   let timerCallback = null;
   let timerDelay = null;
 
   gRequestIterator = Iterator(new Request());
 
   fakeNow(now);
-  fakeDailyTimers(() => {}, () => {});
 
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   Preferences.reset(PREF_TEST);
   let prefsToWatch = {};
   prefsToWatch[PREF_TEST] = TelemetryEnvironment.RECORD_PREF_VALUE;
 
   // Setup.
   yield TelemetrySession.setup();
@@ -1041,21 +1164,20 @@ add_task(function* test_savedPingsOnShut
     Assert.equal(ping.payload.info.reason, expectedReason);
     Assert.equal(ping.clientId, gDataReportingClientID);
   }
 });
 
 add_task(function* test_savedSessionData() {
   // Create the directory which will contain the data file, if it doesn't already
   // exist.
-  const dataDir  = OS.Path.join(OS.Constants.Path.profileDir, "datareporting");
-  yield OS.File.makeDir(dataDir);
+  yield OS.File.makeDir(DATAREPORTING_PATH);
 
   // Write test data to the session data file.
-  const dataFilePath = OS.Path.join(dataDir, "session-state.json");
+  const dataFilePath = OS.Path.join(DATAREPORTING_PATH, "session-state.json");
   const sessionState = {
     previousSubsessionId: null,
     profileSubsessionCounter: 3785,
   };
   yield CommonUtils.writeJSON(sessionState, dataFilePath);
 
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   Preferences.reset(PREF_TEST);
@@ -1091,21 +1213,20 @@ add_task(function* test_savedSessionData
   let data = yield CommonUtils.readJSON(dataFilePath);
   Assert.equal(data.profileSubsessionCounter, expectedSubsessions);
   Assert.equal(data.previousSubsessionId, expectedUUID);
 });
 
 add_task(function* test_invalidSessionData() {
   // Create the directory which will contain the data file, if it doesn't already
   // exist.
-  const dataDir  = OS.Path.join(OS.Constants.Path.profileDir, "datareporting");
-  yield OS.File.makeDir(dataDir);
+  yield OS.File.makeDir(DATAREPORTING_PATH);
 
   // Write test data to the session data file.
-  const dataFilePath = OS.Path.join(dataDir, "session-state.json");
+  const dataFilePath = OS.Path.join(DATAREPORTING_PATH, "session-state.json");
   const sessionState = {
     profileSubsessionCounter: "not-a-number?",
     someOtherField: 12,
   };
   yield CommonUtils.writeJSON(sessionState, dataFilePath);
 
   // The session data file should not load. Only expect the current subsession.
   const expectedSubsessions = 1;
@@ -1118,16 +1239,275 @@ add_task(function* test_invalidSessionDa
   yield TelemetrySession.shutdown();
 
   // Load back the serialised session data.
   let data = yield CommonUtils.readJSON(dataFilePath);
   Assert.equal(data.profileSubsessionCounter, expectedSubsessions);
   Assert.equal(data.previousSubsessionId, null);
 });
 
+add_task(function* test_abortedSession() {
+  if (gIsAndroid || gIsGonk) {
+    // We don't have the aborted session ping here.
+    return;
+  }
+
+  const ABORTED_FILE = OS.Path.join(DATAREPORTING_PATH, ABORTED_PING_FILE_NAME);
+
+  // Make sure the aborted sessions directory does not exist to test its creation.
+  yield OS.File.removeDir(DATAREPORTING_PATH, { ignoreAbsent: true });
+
+  let schedulerTickCallback = null;
+  let now = new Date(2040, 1, 1, 0, 0, 0);
+  fakeNow(now);
+  // Fake scheduler functions to control aborted-session flow in tests.
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
+  yield TelemetrySession.reset();
+
+  Assert.ok((yield OS.File.exists(DATAREPORTING_PATH)),
+            "Telemetry must create the aborted session directory when starting.");
+
+  // Fake now again so that the scheduled aborted-session save takes place.
+  now = futureDate(now, ABORTED_SESSION_UPDATE_INTERVAL_MS);
+  fakeNow(now);
+  // The first aborted session checkpoint must take place right after the initialisation.
+  Assert.ok(!!schedulerTickCallback);
+  // Execute one scheduler tick.
+  yield schedulerTickCallback();
+  // Check that the aborted session is due at the correct time.
+  Assert.ok((yield OS.File.exists(ABORTED_FILE)),
+            "There must be an aborted session ping.");
+
+  // This ping is not yet in the pending pings folder, so we can't access it using
+  // TelemetryFile.popPendingPings().
+  let pingContent = yield OS.File.read(ABORTED_FILE, { encoding: "utf-8" });
+  let abortedSessionPing = JSON.parse(pingContent);
+
+  // Validate the ping.
+  checkPingFormat(abortedSessionPing, PING_TYPE_MAIN, true, true);
+  Assert.equal(abortedSessionPing.payload.info.reason, REASON_ABORTED_SESSION);
+
+  // Trigger a another aborted-session ping and check that it overwrites the previous one.
+  now = futureDate(now, ABORTED_SESSION_UPDATE_INTERVAL_MS);
+  fakeNow(now);
+  yield schedulerTickCallback();
+
+  pingContent = yield OS.File.read(ABORTED_FILE, { encoding: "utf-8" });
+  let updatedAbortedSessionPing = JSON.parse(pingContent);
+  checkPingFormat(updatedAbortedSessionPing, PING_TYPE_MAIN, true, true);
+  Assert.equal(updatedAbortedSessionPing.payload.info.reason, REASON_ABORTED_SESSION);
+  Assert.notEqual(abortedSessionPing.id, updatedAbortedSessionPing.id);
+  Assert.notEqual(abortedSessionPing.creationDate, updatedAbortedSessionPing.creationDate);
+
+  yield TelemetrySession.shutdown();
+  Assert.ok(!(yield OS.File.exists(ABORTED_FILE)),
+            "No aborted session ping must be available after a shutdown.");
+
+  // Write the ping to the aborted-session file. TelemetrySession will add it to the
+  // saved pings directory when it starts.
+  yield TelemetryFile.savePingToFile(abortedSessionPing, ABORTED_FILE, false);
+
+  gRequestIterator = Iterator(new Request());
+  yield TelemetrySession.reset();
+
+  Assert.ok(!(yield OS.File.exists(ABORTED_FILE)),
+            "The aborted session ping must be removed from the aborted session ping directory.");
+
+  // TelemetryFile requires all the pings to have their ID as filename. When appending
+  // the aborted-session ping to the pending pings, we must verify that it exists.
+  const PENDING_PING_FILE =
+    OS.Path.join(TelemetryFile.pingDirectoryPath, abortedSessionPing.id);
+  Assert.ok((yield OS.File.exists(PENDING_PING_FILE)),
+            "The aborted session ping must exist in the saved pings directory.");
+
+  // Trick: make the aborted ping file overdue so that it gets sent immediately when
+  // resetting TelemetryPing.
+  const OVERDUE_PING_FILE_AGE = TelemetryFile.OVERDUE_PING_FILE_AGE + 60 * 1000;
+  yield OS.File.setDates(PENDING_PING_FILE, null, Date.now() - OVERDUE_PING_FILE_AGE);
+  yield TelemetryPing.reset();
+
+  // Wait for the aborted-session ping.
+  let request = yield gRequestIterator.next();
+  let receivedPing = decodeRequestPayload(request);
+  Assert.equal(receivedPing.payload.info.reason, REASON_ABORTED_SESSION);
+  Assert.equal(receivedPing.id, abortedSessionPing.id);
+
+  yield TelemetrySession.shutdown();
+});
+
+add_task(function* test_abortedDailyCoalescing() {
+  if (gIsAndroid || gIsGonk) {
+    // We don't have the aborted session or the daily ping here.
+    return;
+  }
+
+  const ABORTED_FILE = OS.Path.join(DATAREPORTING_PATH, ABORTED_PING_FILE_NAME);
+
+  // Make sure the aborted sessions directory does not exist to test its creation.
+  yield OS.File.removeDir(DATAREPORTING_PATH, { ignoreAbsent: true });
+
+  let schedulerTickCallback = null;
+  gRequestIterator = Iterator(new Request());
+
+  let nowDate = new Date(2009, 10, 18, 00, 00, 0);
+  fakeNow(nowDate);
+
+  // Fake scheduler functions to control aborted-session flow in tests.
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
+  yield TelemetrySession.reset();
+
+  Assert.ok((yield OS.File.exists(DATAREPORTING_PATH)),
+            "Telemetry must create the aborted session directory when starting.");
+
+  // Delay the callback around midnight so that the aborted-session ping gets merged with the
+  // daily ping.
+  let dailyDueDate = futureDate(nowDate, MS_IN_ONE_DAY);
+  fakeNow(dailyDueDate);
+  // Trigger both the daily ping and the saved-session.
+  Assert.ok(!!schedulerTickCallback);
+  // Execute one scheduler tick.
+  yield schedulerTickCallback();
+
+  // Wait for the daily ping.
+  let request = yield gRequestIterator.next();
+  let dailyPing = decodeRequestPayload(request);
+  Assert.equal(dailyPing.payload.info.reason, REASON_DAILY);
+
+  // Check that an aborted session ping was also written to disk.
+  Assert.ok((yield OS.File.exists(ABORTED_FILE)),
+            "There must be an aborted session ping.");
+
+  // Read aborted session ping and check that the session/subsession ids equal the
+  // ones in the daily ping.
+  let pingContent = yield OS.File.read(ABORTED_FILE, { encoding: "utf-8" });
+  let abortedSessionPing = JSON.parse(pingContent);
+  Assert.equal(abortedSessionPing.payload.info.sessionId, dailyPing.payload.info.sessionId);
+  Assert.equal(abortedSessionPing.payload.info.subsessionId, dailyPing.payload.info.subsessionId);
+
+  yield TelemetrySession.shutdown();
+});
+
+add_task(function* test_schedulerComputerSleep() {
+  if (gIsAndroid || gIsGonk) {
+    // We don't have the aborted session or the daily ping here.
+    return;
+  }
+
+  const ABORTED_FILE = OS.Path.join(DATAREPORTING_PATH, ABORTED_PING_FILE_NAME);
+
+  gRequestIterator = Iterator(new Request());
+
+  // Remove any aborted-session ping from the previous tests.
+  yield OS.File.removeDir(DATAREPORTING_PATH, { ignoreAbsent: true });
+
+  // Set a fake current date and start Telemetry.
+  let nowDate = new Date(2009, 10, 18, 0, 00, 0);
+  fakeNow(nowDate);
+  let schedulerTickCallback = null;
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
+  yield TelemetrySession.reset();
+
+  // Set the current time 3 days in the future at midnight, before running the callback.
+  let future = futureDate(nowDate, MS_IN_ONE_DAY * 3);
+  fakeNow(future);
+  Assert.ok(!!schedulerTickCallback);
+  // Execute one scheduler tick.
+  yield schedulerTickCallback();
+
+  let request = yield gRequestIterator.next();
+  let dailyPing = decodeRequestPayload(request);
+  Assert.equal(dailyPing.payload.info.reason, REASON_DAILY);
+
+  Assert.ok((yield OS.File.exists(ABORTED_FILE)),
+            "There must be an aborted session ping.");
+
+  yield TelemetrySession.shutdown();
+});
+
+add_task(function* test_schedulerEnvironmentReschedules() {
+  if (gIsAndroid || gIsGonk) {
+    // We don't have the aborted session or the daily ping here.
+    return;
+  }
+
+  // Reset the test preference.
+  const PREF_TEST = "toolkit.telemetry.test.pref1";
+  Preferences.reset(PREF_TEST);
+  let prefsToWatch = {};
+  prefsToWatch[PREF_TEST] = TelemetryEnvironment.RECORD_PREF_VALUE;
+
+  gRequestIterator = Iterator(new Request());
+
+  // Set a fake current date and start Telemetry.
+  let nowDate = new Date(2009, 10, 18, 0, 00, 0);
+  fakeNow(nowDate);
+  let schedulerTickCallback = null;
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
+  yield TelemetrySession.reset();
+  TelemetryEnvironment._watchPreferences(prefsToWatch);
+
+  // Set the current time at midnight.
+  let future = futureDate(nowDate, MS_IN_ONE_DAY);
+  fakeNow(future);
+
+  // Trigger the environment change.
+  Preferences.set(PREF_TEST, 1);
+
+  // Wait for the environment-changed ping.
+  yield gRequestIterator.next();
+
+  // We don't expect to receive any daily ping in this test, so assert if we do.
+  registerPingHandler((req, res) => {
+    Assert.ok(false, "No ping should be sent/received in this test.");
+  });
+
+  // Execute one scheduler tick. It should not trigger a daily ping.
+  Assert.ok(!!schedulerTickCallback);
+  yield schedulerTickCallback();
+
+  yield TelemetrySession.shutdown();
+});
+
+add_task(function* test_schedulerNothingDue() {
+  if (gIsAndroid || gIsGonk) {
+    // We don't have the aborted session or the daily ping here.
+    return;
+  }
+
+  const ABORTED_FILE = OS.Path.join(DATAREPORTING_PATH, ABORTED_PING_FILE_NAME);
+
+  // Remove any aborted-session ping from the previous tests.
+  yield OS.File.removeDir(DATAREPORTING_PATH, { ignoreAbsent: true });
+
+  // We don't expect to receive any ping in this test, so assert if we do.
+  registerPingHandler((req, res) => {
+    Assert.ok(false, "No ping should be sent/received in this test.");
+  });
+
+  // Set a current date/time away from midnight, so that the daily ping doesn't get
+  // sent.
+  let nowDate = new Date(2009, 10, 18, 11, 0, 0);
+  fakeNow(nowDate);
+  let schedulerTickCallback = null;
+  fakeSchedulerTimer(callback => schedulerTickCallback = callback, () => {});
+  yield TelemetrySession.reset();
+
+  // Delay the callback execution to a time when no ping should be due.
+  let nothingDueDate = futureDate(nowDate, ABORTED_SESSION_UPDATE_INTERVAL_MS / 2);
+  fakeNow(nothingDueDate);
+  Assert.ok(!!schedulerTickCallback);
+  // Execute one scheduler tick.
+  yield schedulerTickCallback();
+
+  // Check that no aborted session ping was written to disk.
+  Assert.ok(!(yield OS.File.exists(ABORTED_FILE)));
+
+  yield TelemetrySession.shutdown();
+});
+
 add_task(function* stopServer(){
   gHttpServer.stop(do_test_finished);
 });
 
 // An iterable sequence of http requests
 function Request() {
   let defers = [];
   let current = 0;
--- a/toolkit/devtools/server/actors/script.js
+++ b/toolkit/devtools/server/actors/script.js
@@ -3,17 +3,17 @@
 /* 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 Services = require("Services");
 const { Cc, Ci, Cu, components, ChromeWorker } = require("chrome");
-const { ActorPool, OriginalLocation, GeneratedLocation, getOffsetColumn } = require("devtools/server/actors/common");
+const { ActorPool, OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common");
 const { DebuggerServer } = require("devtools/server/main");
 const DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
 const { dbg_assert, dumpn, update, fetch } = DevToolsUtils;
 const { dirname, joinURI } = require("devtools/toolkit/path");
 const promise = require("promise");
 const PromiseDebugging = require("PromiseDebugging");
 const xpcInspector = require("xpcInspector");
 const mapURIToAddonID = require("./utils/map-uri-to-addon-id");
@@ -439,32 +439,29 @@ function ThreadActor(aParent, aGlobal)
   this._state = "detached";
   this._frameActors = [];
   this._parent = aParent;
   this._dbg = null;
   this._gripDepth = 0;
   this._threadLifetimePool = null;
   this._tabClosed = false;
   this._scripts = null;
-  this._sources = null;
   this._pauseOnDOMEvents = null;
 
   this._options = {
     useSourceMaps: false,
     autoBlackBox: false
   };
 
-  this.breakpointActorMap = new BreakpointActorMap;
-  this.sourceActorStore = new SourceActorStore;
-  this.blackBoxedSources = new Set;
-  this.prettyPrintedSources = new Map;
+  this.breakpointActorMap = new BreakpointActorMap();
+  this.sourceActorStore = new SourceActorStore();
 
   // A map of actorID -> actor for breakpoints created and managed by the
   // server.
-  this._hiddenBreakpoints = new Map;
+  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.uncaughtExceptionHook = this.uncaughtExceptionHook.bind(this);
   this.onDebuggerStatement = this.onDebuggerStatement.bind(this);
@@ -517,21 +514,17 @@ ThreadActor.prototype = {
     if (!this._scripts) {
       this._scripts = new ScriptStore();
       this._scripts.addScripts(this.dbg.findScripts());
     }
     return this._scripts;
   },
 
   get sources() {
-    if (!this._sources) {
-      this._sources = new ThreadSources(this, this._options,
-                                        this._allowSource, this.onNewSource);
-    }
-    return this._sources;
+    return this._parent.sources;
   },
 
   get youngestFrame() {
     if (this.state != "paused") {
       return null;
     }
     return this.dbg.getNewestFrame();
   },
@@ -662,16 +655,20 @@ ThreadActor.prototype = {
     if (this.state !== "detached") {
       return { error: "wrongState",
                message: "Current state is " + this.state };
     }
 
     this._state = "attached";
 
     update(this._options, aRequest.options || {});
+    this.sources.reconfigure(this._options);
+    this.sources.on('newSource', (name, source) => {
+      this.onNewSource(source);
+    });
 
     // 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
     });
@@ -716,18 +713,18 @@ ThreadActor.prototype = {
   },
 
   onReconfigure: function (aRequest) {
     if (this.state == "exited") {
       return { error: "wrongState" };
     }
 
     update(this._options, aRequest.options || {});
-    // Clear existing sources, so they can be recreated on next access.
-    this._sources = null;
+    // Update the global source store
+    this.sources.reconfigure(this._options);
 
     return {};
   },
 
   /**
    * Pause the debuggee, by entering a nested event loop, and return a 'paused'
    * packet to the client.
    *
@@ -2009,28 +2006,16 @@ ThreadActor.prototype = {
     this.conn.send({
       from: this.actorID,
       type: "newSource",
       source: aSource.form()
     });
   },
 
   /**
-   * Check if scripts from the provided source URL are allowed to be stored in
-   * the cache.
-   *
-   * @param aSourceUrl String
-   *        The url of the script's source that will be stored.
-   * @returns true, if the script can be added, false otherwise.
-   */
-  _allowSource: function (aSource) {
-    return !isHiddenSource(aSource);
-  },
-
-  /**
    * Restore any pre-existing breakpoints to the scripts that we have access to.
    */
   _restoreBreakpoints: function () {
     if (this.breakpointActorMap.size === 0) {
       return;
     }
 
     for (let s of this.scripts.getAllScripts()) {
@@ -2041,25 +2026,30 @@ ThreadActor.prototype = {
   /**
    * Add the provided script to the server cache.
    *
    * @param aScript Debugger.Script
    *        The source script that will be stored.
    * @returns true, if the script was added; false otherwise.
    */
   _addScript: function (aScript) {
-    if (!this._allowSource(aScript.source)) {
+    if (!this.sources.allowSource(aScript.source)) {
       return false;
     }
 
     // Set any stored breakpoints.
     let promises = [];
     let sourceActor = this.sources.createNonSourceMappedActor(aScript.source);
     let endLine = aScript.startLine + aScript.lineCount - 1;
-    for (let actor of this.breakpointActorMap.findActors()) {
+    for (let _actor of this.breakpointActorMap.findActors()) {
+      // XXX bug 1142115: We do async work in here, so we need to
+      // create a fresh binding because for/of does not yet do that in
+      // SpiderMonkey
+      let actor = _actor;
+
       if (actor.isPending) {
         promises.push(sourceActor._setBreakpointForActor(actor));
       } else {
         promises.push(this.sources.getGeneratedLocation(actor.originalLocation)
                                   .then((generatedLocation) => {
           // Limit the search to the line numbers contained in the new script.
           if (generatedLocation.generatedSourceActor.actorID === sourceActor.actorID &&
               generatedLocation.generatedLine >= aScript.startLine &&
@@ -3265,16 +3255,17 @@ SourceActor.prototype.requestTypes = {
   "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;
 
 /**
  * Determine if a given value is non-primitive.
  *
  * @param Any aValue
  *        The value to test.
  * @return Boolean
  *         Whether the value is non-primitive.
@@ -4921,40 +4912,53 @@ BreakpointActor.prototype = {
     for (let script of this.scripts) {
       script.clearBreakpoint(this);
     }
     this.scripts.clear();
   },
 
   /**
    * Check if this breakpoint has a condition that doesn't error and
-   * evaluates to true in aFrame
+   * evaluates to true in aFrame.
    *
    * @param aFrame Debugger.Frame
    *        The frame to evaluate the condition in
-   * @returns Boolean
-   *          Indicates whether to pause or not, returns undefined when
-   *          evaluation was killed
+   * @returns Object
+   *          - result: boolean|undefined
+   *            True when the conditional breakpoint should trigger a pause, false otherwise.
+   *            If the condition evaluation failed/killed, `result` will be `undefined`.
+   *          - message: string
+   *            The thrown message converted to a string, when the condition throws.
    */
   checkCondition: function(aFrame) {
     let completion = aFrame.eval(this.condition);
     if (completion) {
       if (completion.throw) {
-        // The evaluation failed and threw an error, currently
-        // we will only return true to break on the error
-        return true;
+        // The evaluation failed and threw
+        let message = "Unknown exception";
+        try {
+          if (completion.throw.getOwnPropertyDescriptor) {
+            message = completion.throw.getOwnPropertyDescriptor("message").value;
+          } else if (completion.toString) {
+            message = completion.toString();
+          }
+        } catch (ex) {}
+        return {
+          result: true,
+          message: message
+        };
       } else if (completion.yield) {
         dbg_assert(false,
                    "Shouldn't ever get yield completions from an eval");
       } else {
-        return completion.return ? true : false;
+        return { result: completion.return ? true : false };
       }
     } else {
       // The evaluation was killed (possibly by the slow script dialog)
-      return undefined;
+      return { result: undefined };
     }
   },
 
   /**
    * A function that the engine calls when a breakpoint has been hit.
    *
    * @param aFrame Debugger.Frame
    *        The stack frame that contained the breakpoint.
@@ -4971,22 +4975,34 @@ BreakpointActor.prototype = {
         || aFrame.onStep) {
       return undefined;
     }
 
     let reason = {};
 
     if (this.threadActor._hiddenBreakpoints.has(this.actorID)) {
       reason.type = "pauseOnDOMEvents";
-    } else if (!this.condition || this.checkCondition(aFrame)) {
+    } else if (!this.condition) {
       reason.type = "breakpoint";
       // TODO: add the rest of the breakpoints on that line (bug 676602).
       reason.actors = [ this.actorID ];
     } else {
-      return undefined;
+      let { result, message } = this.checkCondition(aFrame)
+
+      if (result) {
+        if (!message) {
+          reason.type = "breakpoint";
+        } else {
+          reason.type = "breakpointConditionThrown";
+          reason.message = message;
+        }
+        reason.actors = [ this.actorID ];
+      } else {
+        return undefined;
+      }
     }
     return this.threadActor._pauseAndRespond(aFrame, reason);
   },
 
   /**
    * Handle a protocol request to remove this breakpoint.
    *
    * @param aRequest object
@@ -5297,749 +5313,24 @@ function AddonThreadActor(aConnect, aPar
 }
 
 AddonThreadActor.prototype = Object.create(ThreadActor.prototype);
 
 update(AddonThreadActor.prototype, {
   constructor: AddonThreadActor,
 
   // A constant prefix that will be used to form the actor ID by the server.
-  actorPrefix: "addonThread",
-
-  /**
-   * Override the eligibility check for scripts and sources to make
-   * sure every script and source with a URL is stored when debugging
-   * add-ons.
-   */
-  _allowSource: function(aSource) {
-    let url = aSource.url;
-
-    if (isHiddenSource(aSource)) {
-      return false;
-    }
-
-    // XPIProvider.jsm evals some code in every add-on's bootstrap.js. Hide it.
-    if (url === "resource://gre/modules/addons/XPIProvider.jsm") {
-      return false;
-    }
-
-    return true;
-  },
-
+  actorPrefix: "addonThread"
 });
 
 exports.AddonThreadActor = AddonThreadActor;
 
-/**
- * Manages the sources for a thread. Handles source maps, locations in the
- * sources, etc for ThreadActors.
- */
-function ThreadSources(aThreadActor, aOptions, aAllowPredicate,
-                       aOnNewSource) {
-  this._thread = aThreadActor;
-  this._useSourceMaps = aOptions.useSourceMaps;
-  this._autoBlackBox = aOptions.autoBlackBox;
-  this._allow = aAllowPredicate;
-  this._onNewSource = DevToolsUtils.makeInfallible(
-    aOnNewSource,
-    "ThreadSources.prototype._onNewSource"
-  );
-  this._anonSourceMapId = 1;
-
-  // generated Debugger.Source -> promise of SourceMapConsumer
-  this._sourceMaps = new Map();
-  // sourceMapURL -> promise of SourceMapConsumer
-  this._sourceMapCache = Object.create(null);
-  // Debugger.Source -> SourceActor
-  this._sourceActors = new Map();
-  // url -> SourceActor
-  this._sourceMappedSourceActors = Object.create(null);
-}
-
-/**
- * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular
- * expression matches, we can be fairly sure that the source is minified, and
- * treat it as such.
- */
-const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/;
-
-ThreadSources.prototype = {
-  /**
-   * Return the source actor representing the `source` (or
-   * `originalUrl`), creating one if none exists already. May return
-   * null if the source is disallowed.
-   *
-   * @param Debugger.Source source
-   *        The source to make an actor for
-   * @param String originalUrl
-   *        The original source URL of a sourcemapped source
-   * @param optional Debguger.Source generatedSource
-   *        The generated source that introduced this source via source map,
-   *        if any.
-   * @param optional String contentType
-   *        The content type of the source, if immediately available.
-   * @returns a SourceActor representing the source or null.
-   */
-  source: function  ({ source, originalUrl, generatedSource,
-              isInlineSource, contentType }) {
-    dbg_assert(source || (originalUrl && generatedSource),
-               "ThreadSources.prototype.source needs an originalUrl or a source");
-
-    if (source) {
-      // If a source is passed, we are creating an actor for a real
-      // source, which may or may not be sourcemapped.
-
-      if (!this._allow(source)) {
-        return null;
-      }
-
-      // It's a hack, but inline HTML scripts each have real sources,
-      // but we want to represent all of them as one source as the
-      // HTML page. The actor representing this fake HTML source is
-      // stored in this array, which always has a URL, so check it
-      // first.
-      if (source.url in this._sourceMappedSourceActors) {
-        return this._sourceMappedSourceActors[source.url];
-      }
-
-      if (isInlineSource) {
-        // If it's an inline source, the fake HTML source hasn't been
-        // created yet (would have returned above), so flip this source
-        // into a sourcemapped state by giving it an `originalUrl` which
-        // is the HTML url.
-        originalUrl = source.url;
-        source = null;
-      }
-      else if (this._sourceActors.has(source)) {
-        return this._sourceActors.get(source);
-      }
-    }
-    else if (originalUrl) {
-      // Not all "original" scripts are distinctly separate from the
-      // generated script. Pretty-printed sources have a sourcemap for
-      // themselves, so we need to make sure there a real source
-      // doesn't already exist with this URL.
-      for (let [source, actor] of this._sourceActors) {
-        if (source.url === originalUrl) {
-          return actor;
-        }
-      }
-
-      if (originalUrl in this._sourceMappedSourceActors) {
-        return this._sourceMappedSourceActors[originalUrl];
-      }
-    }
-
-    let actor = new SourceActor({
-      thread: this._thread,
-      source: source,
-      originalUrl: originalUrl,
-      generatedSource: generatedSource,
-      contentType: contentType
-    });
-
-    let sourceActorStore = this._thread.sourceActorStore;
-    var id = sourceActorStore.getReusableActorId(source, originalUrl);
-    if (id) {
-      actor.actorID = id;
-    }
-
-    this._thread.threadLifetimePool.addActor(actor);
-    sourceActorStore.setReusableActorId(source, originalUrl, actor.actorID);
-
-    if (this._autoBlackBox && this._isMinifiedURL(actor.url)) {
-      this.blackBox(actor.url);
-    }
-
-    if (source) {
-      this._sourceActors.set(source, actor);
-    }
-    else {
-      this._sourceMappedSourceActors[originalUrl] = actor;
-    }
-
-    this._emitNewSource(actor);
-    return actor;
-  },
-
-  _emitNewSource: function(actor) {
-    if(!actor.source) {
-      // Always notify if we don't have a source because that means
-      // it's something that has been sourcemapped, or it represents
-      // the HTML file that contains inline sources.
-      this._onNewSource(actor);
-    }
-    else {
-      // If sourcemapping is enabled and a source has sourcemaps, we
-      // create `SourceActor` instances for both the original and
-      // generated sources. The source actors for the generated
-      // sources are only for internal use, however; breakpoints are
-      // managed by these internal actors. We only want to notify the
-      // user of the original sources though, so if the actor has a
-      // `Debugger.Source` instance and a valid source map (meaning
-      // it's a generated source), don't send the notification.
-      this.fetchSourceMap(actor.source).then(map => {
-        if(!map) {
-          this._onNewSource(actor);
-        }
-      });
-    }
-  },
-
-  getSourceActor: function(source) {
-    if (source.url in this._sourceMappedSourceActors) {
-      return this._sourceMappedSourceActors[source.url];
-    }
-
-    if (this._sourceActors.has(source)) {
-      return this._sourceActors.get(source);
-    }
-
-    throw new Error('getSource: could not find source actor for ' +
-                    (source.url || 'source'));
-  },
-
-  getSourceActorByURL: function(url) {
-    if (url) {
-      for (let [source, actor] of this._sourceActors) {
-        if (source.url === url) {
-          return actor;
-        }
-      }
-
-      if (url in this._sourceMappedSourceActors) {
-        return this._sourceMappedSourceActors[url];
-      }
-    }
-
-    throw new Error('getSourceByURL: could not find source for ' + url);
-  },
-
-  /**
-   * Returns true if the URL likely points to a minified resource, false
-   * otherwise.
-   *
-   * @param String aURL
-   *        The URL to test.
-   * @returns Boolean
-   */
-  _isMinifiedURL: function (aURL) {
-    try {
-      let url = Services.io.newURI(aURL, null, null)
-                           .QueryInterface(Ci.nsIURL);
-      return MINIFIED_SOURCE_REGEXP.test(url.fileName);
-    } catch (e) {
-      // Not a valid URL so don't try to parse out the filename, just test the
-      // whole thing with the minified source regexp.
-      return MINIFIED_SOURCE_REGEXP.test(aURL);
-    }
-  },
-
-  /**
-   * Create a source actor representing this source. This ignores
-   * source mapping and always returns an actor representing this real
-   * source. Use `createSourceActors` if you want to respect source maps.
-   *
-   * @param Debugger.Source aSource
-   *        The source instance to create an actor for.
-   * @returns SourceActor
-   */
-  createNonSourceMappedActor: function (aSource) {
-    // Don't use getSourceURL because we don't want to consider the
-    // displayURL property if it's an eval source. We only want to
-    // consider real URLs, otherwise if there is a URL but it's
-    // invalid the code below will not set the content type, and we
-    // will later try to fetch the contents of the URL to figure out
-    // the content type, but it's a made up URL for eval sources.
-    let url = isEvalSource(aSource) ? null : aSource.url;
-    let spec = { source: aSource };
-
-    // XXX bug 915433: We can't rely on Debugger.Source.prototype.text
-    // if the source is an HTML-embedded <script> tag. Since we don't
-    // have an API implemented to detect whether this is the case, we
-    // need to be conservative and only treat valid js files as real
-    // sources. Otherwise, use the `originalUrl` property to treat it
-    // as an HTML source that manages multiple inline sources.
-    if (url) {
-      try {
-        let urlInfo = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
-        if (urlInfo.fileExtension === "html") {
-          spec.isInlineSource = true;
-        }
-        else if (urlInfo.fileExtension === "js") {
-          spec.contentType = "text/javascript";
-        }
-      } catch(ex) {
-        // Not a valid URI.
-
-        // bug 1124536: fix getSourceText on scripts associated "javascript:SOURCE" urls
-        // (e.g. 'evaluate(sandbox, sourcecode, "javascript:"+sourcecode)' )
-        if (url.indexOf("javascript:") === 0) {
-          spec.contentType = "text/javascript";
-        } 
-      }
-    }
-    else {
-      // Assume the content is javascript if there's no URL
-      spec.contentType = "text/javascript";
-    }
-
-    return this.source(spec);
-  },
-
-  /**
-   * This is an internal function that returns a promise of an array
-   * of source actors representing all the source mapped sources of
-   * `aSource`, or `null` if the source is not sourcemapped or
-   * sourcemapping is disabled. Users should call `createSourceActors`
-   * instead of this.
-   *
-   * @param Debugger.Source aSource
-   *        The source instance to create actors for.
-   * @return Promise of an array of source actors
-   */
-  _createSourceMappedActors: function (aSource) {
-    if (!this._useSourceMaps || !aSource.sourceMapURL) {
-      return resolve(null);
-    }
-
-    return this.fetchSourceMap(aSource)
-      .then(map => {
-        if (map) {
-          return [
-            this.source({ originalUrl: s, generatedSource: aSource })
-            for (s of map.sources)
-          ].filter(isNotNull);
-        }
-        return null;
-      });
-  },
-
-  /**
-   * Creates the source actors representing the appropriate sources
-   * of `aSource`. If sourcemapped, returns actors for all of the original
-   * sources, otherwise returns a 1-element array with the actor for
-   * `aSource`.
-   *
-   * @param Debugger.Source aSource
-   *        The source instance to create actors for.
-   * @param Promise of an array of source actors
-   */
-  createSourceActors: function(aSource) {
-    return this._createSourceMappedActors(aSource).then(actors => {
-      let actor = this.createNonSourceMappedActor(aSource);
-      return (actors || [actor]).filter(isNotNull);
-    });
-  },
-
-  /**
-   * Return a promise of a SourceMapConsumer for the source map for
-   * `aSource`; if we already have such a promise extant, return that.
-   * This will fetch the source map if we don't have a cached object
-   * and source maps are enabled (see `_fetchSourceMap`).
-   *
-   * @param Debugger.Source aSource
-   *        The source instance to get sourcemaps for.
-   * @return Promise of a SourceMapConsumer
-   */
-  fetchSourceMap: function (aSource) {
-    if (this._sourceMaps.has(aSource)) {
-      return this._sourceMaps.get(aSource);
-    }
-    else if (!aSource || !aSource.sourceMapURL) {
-      return resolve(null);
-    }
-
-    let sourceMapURL = aSource.sourceMapURL;
-    if (aSource.url) {
-      sourceMapURL = this._normalize(sourceMapURL, aSource.url);
-    }
-    let result = this._fetchSourceMap(sourceMapURL, aSource.url);
-
-    // The promises in `_sourceMaps` must be the exact same instances
-    // as returned by `_fetchSourceMap` for `clearSourceMapCache` to work.
-    this._sourceMaps.set(aSource, result);
-    return result;
-  },
-
-  /**
-   * Return a promise of a SourceMapConsumer for the source map for
-   * `aSource`. The resolved result may be null if the source does not
-   * have a source map or source maps are disabled.
-   */
-  getSourceMap: function(aSource) {
-    return resolve(this._sourceMaps.get(aSource));
-  },
-
-  /**
-   * Set a SourceMapConsumer for the source map for
-   * |aSource|.
-   */
-  setSourceMap: function(aSource, aMap) {
-    this._sourceMaps.set(aSource, resolve(aMap));
-  },
-
-  /**
-   * Return a promise of a SourceMapConsumer for the source map located at
-   * |aAbsSourceMapURL|, which must be absolute. If there is already such a
-   * promise extant, return it. This will not fetch if source maps are
-   * disabled.
-   *
-   * @param string aAbsSourceMapURL
-   *        The source map URL, in absolute form, not relative.
-   * @param string aScriptURL
-   *        When the source map URL is a data URI, there is no sourceRoot on the
-   *        source map, and the source map's sources are relative, we resolve
-   *        them from aScriptURL.
-   */
-  _fetchSourceMap: function (aAbsSourceMapURL, aSourceURL) {
-    if (this._sourceMapCache[aAbsSourceMapURL]) {
-      return this._sourceMapCache[aAbsSourceMapURL];
-    }
-    else if (!this._useSourceMaps) {
-      return resolve(null);
-    }
-
-    let fetching = fetch(aAbsSourceMapURL, { loadFromCache: false })
-      .then(({ content }) => {
-        let map = new SourceMapConsumer(content);
-        this._setSourceMapRoot(map, aAbsSourceMapURL, aSourceURL);
-        return map;
-      })
-      .then(null, error => {
-        if (!DevToolsUtils.reportingDisabled) {
-          DevToolsUtils.reportException("ThreadSources.prototype._fetchSourceMap", error);
-        }
-        return null;
-      });
-    this._sourceMapCache[aAbsSourceMapURL] = fetching;
-    return fetching;
-  },
-
-  /**
-   * Sets the source map's sourceRoot to be relative to the source map url.
-   */
-  _setSourceMapRoot: function (aSourceMap, aAbsSourceMapURL, aScriptURL) {
-    const base = this._dirname(
-      aAbsSourceMapURL.indexOf("data:") === 0
-        ? aScriptURL
-        : aAbsSourceMapURL);
-    aSourceMap.sourceRoot = aSourceMap.sourceRoot
-      ? this._normalize(aSourceMap.sourceRoot, base)
-      : base;
-  },
-
-  _dirname: function (aPath) {
-    return Services.io.newURI(
-      ".", null, Services.io.newURI(aPath, null, null)).spec;
-  },
-
-  /**
-   * Clears the source map cache. Source maps are cached by URL so
-   * they can be reused across separate Debugger instances (once in
-   * this cache, they will never be reparsed again). They are
-   * also cached by Debugger.Source objects for usefulness. By default
-   * this just removes the Debugger.Source cache, but you can remove
-   * the lower-level URL cache with the `hard` option.
-   *
-   * @param aSourceMapURL string
-   *        The source map URL to uncache
-   * @param opts object
-   *        An object with the following properties:
-   *        - hard: Also remove the lower-level URL cache, which will
-   *          make us completely forget about the source map.
-   */
-  clearSourceMapCache: function(aSourceMapURL, opts = { hard: false }) {
-    let oldSm = this._sourceMapCache[aSourceMapURL];
-
-    if (opts.hard) {
-      delete this._sourceMapCache[aSourceMapURL];
-    }
-
-    if (oldSm) {
-      // Clear out the current cache so all sources will get the new one
-      for (let [source, sm] of this._sourceMaps.entries()) {
-        if (sm === oldSm) {
-          this._sourceMaps.delete(source);
-        }
-      }
-    }
-  },
-
-  /*
-   * Forcefully change the source map of a source, changing the
-   * sourceMapURL and installing the source map in the cache. This is
-   * necessary to expose changes across Debugger instances
-   * (pretty-printing is the use case). Generate a random url if one
-   * isn't specified, allowing you to set "anonymous" source maps.
-   *
-   * @param aSource Debugger.Source
-   *        The source to change the sourceMapURL property
-   * @param aUrl string
-   *        The source map URL (optional)
-   * @param aMap SourceMapConsumer
-   *        The source map instance
-   */
-  setSourceMapHard: function(aSource, aUrl, aMap) {
-    let url = aUrl;
-    if (!url) {
-      // This is a littly hacky, but we want to forcefully set a
-      // sourcemap regardless of sourcemap settings. We want to
-      // literally change the sourceMapURL so that all debuggers will
-      // get this and pretty-printing will Just Work (Debugger.Source
-      // instances are per-debugger, so we can't key off that). To
-      // avoid tons of work serializing the sourcemap into a data url,
-      // 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);
-  },
-
-  /**
-   * 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.
-   * @returns Object
-   *          Returns an object of the form { source, line, column }
-   */
-  getFrameLocation: function (aFrame) {
-    if (!aFrame || !aFrame.script) {
-      return new GeneratedLocation();
-    }
-    return new GeneratedLocation(
-      this.createNonSourceMappedActor(aFrame.script.source),
-      aFrame.script.getOffsetLine(aFrame.offset),
-      getOffsetColumn(aFrame.offset, aFrame.script)
-    );
-  },
-
-  /**
-   * Returns a promise of the location in the original source if the source is
-   * source mapped, otherwise a promise of the same location. This can
-   * be called with a source from *any* Debugger instance and we make
-   * sure to that it works properly, reusing source maps if already
-   * fetched. Use this from any actor that needs sourcemapping.
-   */
-  getOriginalLocation: function (generatedLocation) {
-    let {
-      generatedSourceActor,
-      generatedLine,
-      generatedColumn
-    } = generatedLocation;
-    let source = generatedSourceActor.source;
-    let url = source ? source.url : generatedSourceActor._originalUrl;
-
-    // In certain scenarios the source map may have not been fetched
-    // yet (or at least tied to this Debugger.Source instance), so use
-    // `fetchSourceMap` instead of `getSourceMap`. This allows this
-    // function to be called from anywere (across debuggers) and it
-    // should just automatically work.
-    return this.fetchSourceMap(source).then(map => {
-      if (map) {
-        let {
-          source: originalUrl,
-          line: originalLine,
-          column: originalColumn,
-          name: originalName
-        } = map.originalPositionFor({
-          line: generatedLine,
-          column: generatedColumn == null ? Infinity : generatedColumn
-        });
-
-        // Since the `Debugger.Source` instance may come from a
-        // different `Debugger` instance (any actor can call this
-        // method), we can't rely on any of the source discovery
-        // setup (`_discoverSources`, etc) to have been run yet. So
-        // we have to assume that the actor may not already exist,
-        // and we might need to create it, so use `source` and give
-        // it the required parameters for a sourcemapped source.
-        return new OriginalLocation(
-          originalUrl ? this.source({
-            originalUrl: originalUrl,
-            generatedSource: source
-          }) : null,
-          originalLine,
-          originalColumn,
-          originalName
-        );
-      }
-
-      // No source map
-      return OriginalLocation.fromGeneratedLocation(generatedLocation);
-    });
-  },
-
-  /**
-   * Returns a promise of the location in the generated source corresponding to
-   * the original source and line given.
-   *
-   * When we pass a script S representing generated code to `sourceMap`,
-   * above, that returns a promise P. The process of resolving P populates
-   * the tables this function uses; thus, it won't know that S's original
-   * source URLs map to S until P is resolved.
-   */
-  getGeneratedLocation: function (originalLocation) {
-    let { originalSourceActor } = originalLocation;
-
-    // Both original sources and normal sources could have sourcemaps,
-    // because normal sources can be pretty-printed which generates a
-    // sourcemap for itself. Check both of the source properties to make it work
-    // for both kinds of sources.
-    let source = originalSourceActor.source || originalSourceActor.generatedSource;
-
-    // See comment about `fetchSourceMap` in `getOriginalLocation`.
-    return this.fetchSourceMap(source).then((map) => {
-      if (map) {
-        let {
-          originalLine,
-          originalColumn
-        } = originalLocation;
-
-        let {
-          line: generatedLine,
-          column: generatedColumn
-        } = map.generatedPositionFor({
-          source: originalSourceActor.url,
-          line: originalLine,
-          column: originalColumn == null ? 0 : originalColumn,
-          bias: SourceMapConsumer.LEAST_UPPER_BOUND
-        });
-
-        return new GeneratedLocation(
-          this.createNonSourceMappedActor(source),
-          generatedLine,
-          generatedColumn
-        );
-      }
-
-      return GeneratedLocation.fromOriginalLocation(originalLocation);
-    });
-  },
-
-  /**
-   * Returns true if URL for the given source is black boxed.
-   *
-   * @param aURL String
-   *        The URL of the source which we are checking whether it is black
-   *        boxed or not.
-   */
-  isBlackBoxed: function (aURL) {
-    return this._thread.blackBoxedSources.has(aURL);
-  },
-
-  /**
-   * Add the given source URL to the set of sources that are black boxed.
-   *
-   * @param aURL String
-   *        The URL of the source which we are black boxing.
-   */
-  blackBox: function (aURL) {
-    this._thread.blackBoxedSources.add(aURL);
-  },
-
-  /**
-   * Remove the given source URL to the set of sources that are black boxed.
-   *
-   * @param aURL String
-   *        The URL of the source which we are no longer black boxing.
-   */
-  unblackBox: function (aURL) {
-    this._thread.blackBoxedSources.delete(aURL);
-  },
-
-  /**
-   * Returns true if the given URL is pretty printed.
-   *
-   * @param aURL String
-   *        The URL of the source that might be pretty printed.
-   */
-  isPrettyPrinted: function (aURL) {
-    return this._thread.prettyPrintedSources.has(aURL);
-  },
-
-  /**
-   * Add the given URL to the set of sources that are pretty printed.
-   *
-   * @param aURL String
-   *        The URL of the source to be pretty printed.
-   */
-  prettyPrint: function (aURL, aIndent) {
-    this._thread.prettyPrintedSources.set(aURL, aIndent);
-  },
-
-  /**
-   * Return the indent the given URL was pretty printed by.
-   */
-  prettyPrintIndent: function (aURL) {
-    return this._thread.prettyPrintedSources.get(aURL);
-  },
-
-  /**
-   * Remove the given URL from the set of sources that are pretty printed.
-   *
-   * @param aURL String
-   *        The URL of the source that is no longer pretty printed.
-   */
-  disablePrettyPrint: function (aURL) {
-    this._thread.prettyPrintedSources.delete(aURL);
-  },
-
-  /**
-   * Normalize multiple relative paths towards the base paths on the right.
-   */
-  _normalize: function (...aURLs) {
-    dbg_assert(aURLs.length > 1, "Should have more than 1 URL");
-    let base = Services.io.newURI(aURLs.pop(), null, null);
-    let url;
-    while ((url = aURLs.pop())) {
-      base = Services.io.newURI(url, null, base);
-    }
-    return base.spec;
-  },
-
-  iter: function () {
-    let actors = Object.keys(this._sourceMappedSourceActors).map(k => {
-      return this._sourceMappedSourceActors[k];
-    });
-    for (let actor of this._sourceActors.values()) {
-      if (!this._sourceMaps.has(actor.source)) {
-        actors.push(actor);
-      }
-    }
-    return actors;
-  }
-};
-
-exports.ThreadSources = ThreadSources;
-
 // Utility functions.
 
 /**
- * Checks if a source should never be displayed to the user because
- * it's either internal or we don't support in the UI yet.
- */
-function isHiddenSource(aSource) {
-  // Ignore the internal Function.prototype script
-  return aSource.text === '() {\n}';
-}
-
-/**
- * Returns true if its argument is not null.
- */
-function isNotNull(aThing) {
-  return aThing !== null;
-}
-
-/**
  * Report the given error in the error console and to stdout.
  *
  * @param Error aError
  *        The error object you wish to report.
  * @param String aPrefix
  *        An optional prefix for the reported error message.
  */
 let oldReportError = reportError;
@@ -6082,16 +5373,17 @@ function isEvalSource(source) {
   // 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) {
   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 &&
@@ -6100,16 +5392,17 @@ function getSourceURL(source) {
       return joinURI(dirname(source.introductionScript.source.url),
                      source.displayURL);
     }
 
     return source.displayURL;
   }
   return source.url;
 }
+exports.getSourceURL = getSourceURL;
 
 /**
  * Find the scripts which contain offsets that are an entry point to the given
  * line.
  *
  * @param Array scripts
  *        The set of Debugger.Scripts to consider.
  * @param Number line
--- a/toolkit/devtools/server/actors/styles.js
+++ b/toolkit/devtools/server/actors/styles.js
@@ -986,16 +986,27 @@ var StyleRuleActor = protocol.ActorClass
       actor: this.actorID,
       type: this.type,
       line: this.line || undefined,
       column: this.column
     };
 
     if (this.rawRule.parentRule) {
       form.parentRule = this.pageStyle._styleRef(this.rawRule.parentRule).actorID;
+
+      // CSS rules that we call media rules are STYLE_RULES that are children
+      // of MEDIA_RULEs. We need to check the parentRule to check if a rule is
+      // a media rule so we do this here instead of in the switch statement
+      // below.
+      if (this.rawRule.parentRule.type === Ci.nsIDOMCSSRule.MEDIA_RULE) {
+        form.media = [];
+        for (let i = 0, n = this.rawRule.parentRule.media.length; i < n; i++) {
+          form.media.push(this.rawRule.parentRule.media.item(i));
+        }
+      }
     }
     if (this.rawRule.parentStyleSheet) {
       form.parentStyleSheet = this.pageStyle._sheetRef(this.rawRule.parentStyleSheet).actorID;
     }
 
     switch (this.type) {
       case Ci.nsIDOMCSSRule.STYLE_RULE:
         form.selectors = CssLogic.getSelectors(this.rawRule);
@@ -1009,22 +1020,16 @@ var StyleRuleActor = protocol.ActorClass
         form.cssText = this.rawStyle.cssText || "";
         break;
       case Ci.nsIDOMCSSRule.CHARSET_RULE:
         form.encoding = this.rawRule.encoding;
         break;
       case Ci.nsIDOMCSSRule.IMPORT_RULE:
         form.href = this.rawRule.href;
         break;
-      case Ci.nsIDOMCSSRule.MEDIA_RULE:
-        form.media = [];
-        for (let i = 0, n = this.rawRule.media.length; i < n; i++) {
-          form.media.push(this.rawRule.media.item(i));
-        }
-        break;
       case Ci.nsIDOMCSSRule.KEYFRAMES_RULE:
         form.cssText = this.rawRule.cssText;
         form.name = this.rawRule.name;
         break;
       case Ci.nsIDOMCSSRule.KEYFRAME_RULE:
         form.cssText = this.rawStyle.cssText || "";
         form.keyText = this.rawRule.keyText || "";
         break;
@@ -1240,37 +1245,39 @@ var StyleRuleFront = protocol.FrontClass
     };
   },
 
   getOriginalLocation: function()
   {
     if (this._originalLocation) {
       return promise.resolve(this._originalLocation);
     }
-
     let parentSheet = this.parentStyleSheet;
     if (!parentSheet) {
+      // This rule doesn't belong to a stylesheet so it is an inline style.
+      // Inline styles do not have any mediaText so we can return early.
       return promise.resolve(this.location);
     }
     return parentSheet.getOriginalLocation(this.line, this.column)
       .then(({ fromSourceMap, source, line, column }) => {
         let location = {
           href: source,
           line: line,
-          column: column
-        }
+          column: column,
+          mediaText: this.mediaText
+        };
         if (fromSourceMap === false) {
           location.source = this.parentStyleSheet;
         }
         if (!source) {
           location.href = this.href;
         }
         this._originalLocation = location;
         return location;
-      })
+      });
   }
 });
 
 /**
  * Convenience API for building a list of attribute modifications
  * for the `modifyAttributes` request.
  */
 var RuleModificationList = Class({
--- a/toolkit/devtools/server/actors/stylesheets.js
+++ b/toolkit/devtools/server/actors/stylesheets.js
@@ -739,17 +739,17 @@ let StyleSheetActor = protocol.ActorClas
       if (sourceMap) {
         return sourceMap.originalPositionFor({ line: line, column: column });
       }
       return {
         fromSourceMap: false,
         source: this.href,
         line: line,
         column: column
-      }
+      };
     });
   }, {
     request: {
       line: Arg(0, "number"),
       column: Arg(1, "number")
     },
     response: RetVal(types.addDictType("originallocationresponse", {
       source: "string",
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/server/actors/utils/TabSources.js
@@ -0,0 +1,751 @@
+const Services = require("Services");
+const { Ci, Cu } = require("chrome");
+const DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const { dbg_assert, fetch } = require("devtools/toolkit/DevToolsUtils");
+const { OriginalLocation, GeneratedLocation, getOffsetColumn } = require("devtools/server/actors/common");
+const { resolve } = require("promise");
+
+loader.lazyRequireGetter(this, "SourceActor", "devtools/server/actors/script", true);
+loader.lazyRequireGetter(this, "isEvalSource", "devtools/server/actors/script", true);
+loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true);
+loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true);
+
+/**
+ * Manages the sources for a thread. Handles source maps, locations in the
+ * sources, etc for ThreadActors.
+ */
+function TabSources(threadActor, allowSourceFn=() => true) {
+  EventEmitter.decorate(this);
+
+  this._thread = threadActor;
+  this._useSourceMaps = true;
+  this._autoBlackBox = true;
+  this._anonSourceMapId = 1;
+  this.allowSource = source => {
+    return !isHiddenSource(source) && allowSourceFn(source);
+  }
+
+  this.blackBoxedSources = new Set();
+  this.prettyPrintedSources = new Map();
+
+  // generated Debugger.Source -> promise of SourceMapConsumer
+  this._sourceMaps = new Map();
+  // sourceMapURL -> promise of SourceMapConsumer
+  this._sourceMapCache = Object.create(null);
+  // Debugger.Source -> SourceActor
+  this._sourceActors = new Map();
+  // url -> SourceActor
+  this._sourceMappedSourceActors = Object.create(null);
+}
+
+/**
+ * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular
+ * expression matches, we can be fairly sure that the source is minified, and
+ * treat it as such.
+ */
+const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/;
+
+TabSources.prototype = {
+  /**
+   * Update preferences and clear out existing sources
+   */
+  reconfigure: function(options) {
+    if('useSourceMaps' in options) {
+      this._useSourceMaps = options.useSourceMaps;
+    }
+
+    if('autoBlackBox' in options) {
+      this._autoBlackBox = options.autoBlackBox;
+    }
+
+    this.reset();
+  },
+
+  /**
+   * Clear existing sources so they are recreated on the next access.
+   *
+   * @param Object opts
+   *        Specify { sourceMaps: true } if you also want to clear
+   *        the source map cache (usually done on reload).
+   */
+  reset: function(opts={}) {
+    this._sourceActors = new Map();
+    this._sourceMaps = new Map();
+    this._sourceMappedSourceActors = Object.create(null);
+
+    if(opts.sourceMaps) {
+      this._sourceMapCache = Object.create(null);
+    }
+  },
+
+  /**
+   * Return the source actor representing the `source` (or
+   * `originalUrl`), creating one if none exists already. May return
+   * null if the source is disallowed.
+   *
+   * @param Debugger.Source source
+   *        The source to make an actor for
+   * @param String originalUrl
+   *        The original source URL of a sourcemapped source
+   * @param optional Debguger.Source generatedSource
+   *        The generated source that introduced this source via source map,
+   *        if any.
+   * @param optional String contentType
+   *        The content type of the source, if immediately available.
+   * @returns a SourceActor representing the source or null.
+   */
+  source: function  ({ source, originalUrl, generatedSource,
+              isInlineSource, contentType }) {
+    dbg_assert(source || (originalUrl && generatedSource),
+               "TabSources.prototype.source needs an originalUrl or a source");
+
+    if (source) {
+      // If a source is passed, we are creating an actor for a real
+      // source, which may or may not be sourcemapped.
+
+      if (!this.allowSource(source)) {
+        return null;
+      }
+
+      // It's a hack, but inline HTML scripts each have real sources,
+      // but we want to represent all of them as one source as the
+      // HTML page. The actor representing this fake HTML source is
+      // stored in this array, which always has a URL, so check it
+      // first.
+      if (source.url in this._sourceMappedSourceActors) {
+        return this._sourceMappedSourceActors[source.url];
+      }
+
+      if (isInlineSource) {
+        // If it's an inline source, the fake HTML source hasn't been
+        // created yet (would have returned above), so flip this source
+        // into a sourcemapped state by giving it an `originalUrl` which
+        // is the HTML url.
+        originalUrl = source.url;
+        source = null;
+      }
+      else if (this._sourceActors.has(source)) {
+        return this._sourceActors.get(source);
+      }
+    }
+    else if (originalUrl) {
+      // Not all "original" scripts are distinctly separate from the
+      // generated script. Pretty-printed sources have a sourcemap for
+      // themselves, so we need to make sure there a real source
+      // doesn't already exist with this URL.
+      for (let [source, actor] of this._sourceActors) {
+        if (source.url === originalUrl) {
+          return actor;
+        }
+      }
+
+      if (originalUrl in this._sourceMappedSourceActors) {
+        return this._sourceMappedSourceActors[originalUrl];
+      }
+    }
+
+    let actor = new SourceActor({
+      thread: this._thread,
+      source: source,
+      originalUrl: originalUrl,
+      generatedSource: generatedSource,
+      contentType: contentType
+    });
+
+    let sourceActorStore = this._thread.sourceActorStore;
+    var id = sourceActorStore.getReusableActorId(source, originalUrl);
+    if (id) {
+      actor.actorID = id;
+    }
+
+    this._thread.threadLifetimePool.addActor(actor);
+    sourceActorStore.setReusableActorId(source, originalUrl, actor.actorID);
+
+    if (this._autoBlackBox && this._isMinifiedURL(actor.url)) {
+      this.blackBox(actor.url);
+    }
+
+    if (source) {
+      this._sourceActors.set(source, actor);
+    }
+    else {
+      this._sourceMappedSourceActors[originalUrl] = actor;
+    }
+
+    this._emitNewSource(actor);
+    return actor;
+  },
+
+  _emitNewSource: function(actor) {
+    if(!actor.source) {
+      // Always notify if we don't have a source because that means
+      // it's something that has been sourcemapped, or it represents
+      // the HTML file that contains inline sources.
+      this.emit('newSource', actor);
+    }
+    else {
+      // If sourcemapping is enabled and a source has sourcemaps, we
+      // create `SourceActor` instances for both the original and
+      // generated sources. The source actors for the generated
+      // sources are only for internal use, however; breakpoints are
+      // managed by these internal actors. We only want to notify the
+      // user of the original sources though, so if the actor has a
+      // `Debugger.Source` instance and a valid source map (meaning
+      // it's a generated source), don't send the notification.
+      this.fetchSourceMap(actor.source).then(map => {
+        if(!map) {
+          this.emit('newSource', actor);
+        }
+      });
+    }
+  },
+
+  getSourceActor: function(source) {
+    if (source.url in this._sourceMappedSourceActors) {
+      return this._sourceMappedSourceActors[source.url];
+    }
+
+    if (this._sourceActors.has(source)) {
+      return this._sourceActors.get(source);
+    }
+
+    throw new Error('getSource: could not find source actor for ' +
+                    (source.url || 'source'));
+  },
+
+  getSourceActorByURL: function(url) {
+    if (url) {
+      for (let [source, actor] of this._sourceActors) {
+        if (source.url === url) {
+          return actor;
+        }
+      }
+
+      if (url in this._sourceMappedSourceActors) {
+        return this._sourceMappedSourceActors[url];
+      }
+    }
+
+    throw new Error('getSourceByURL: could not find source for ' + url);
+  },
+
+  /**
+   * Returns true if the URL likely points to a minified resource, false
+   * otherwise.
+   *
+   * @param String aURL
+   *        The URL to test.
+   * @returns Boolean
+   */
+  _isMinifiedURL: function (aURL) {
+    try {
+      let url = Services.io.newURI(aURL, null, null)
+                           .QueryInterface(Ci.nsIURL);
+      return MINIFIED_SOURCE_REGEXP.test(url.fileName);
+    } catch (e) {
+      // Not a valid URL so don't try to parse out the filename, just test the
+      // whole thing with the minified source regexp.
+      return MINIFIED_SOURCE_REGEXP.test(aURL);
+    }
+  },
+
+  /**
+   * Create a source actor representing this source. This ignores
+   * source mapping and always returns an actor representing this real
+   * source. Use `createSourceActors` if you want to respect source maps.
+   *
+   * @param Debugger.Source aSource
+   *        The source instance to create an actor for.
+   * @returns SourceActor
+   */
+  createNonSourceMappedActor: function (aSource) {
+    // Don't use getSourceURL because we don't want to consider the
+    // displayURL property if it's an eval source. We only want to
+    // consider real URLs, otherwise if there is a URL but it's
+    // invalid the code below will not set the content type, and we
+    // will later try to fetch the contents of the URL to figure out
+    // the content type, but it's a made up URL for eval sources.
+    let url = isEvalSource(aSource) ? null : aSource.url;
+    let spec = { source: aSource };
+
+    // XXX bug 915433: We can't rely on Debugger.Source.prototype.text
+    // if the source is an HTML-embedded <script> tag. Since we don't
+    // have an API implemented to detect whether this is the case, we
+    // need to be conservative and only treat valid js files as real
+    // sources. Otherwise, use the `originalUrl` property to treat it
+    // as an HTML source that manages multiple inline sources.
+    if (url) {
+      try {
+        let urlInfo = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
+        if (urlInfo.fileExtension === "html" || urlInfo.fileExtension === "xml") {
+          spec.isInlineSource = true;
+        }
+        else if (urlInfo.fileExtension === "js") {
+          spec.contentType = "text/javascript";
+        }
+      } catch(ex) {
+        // Not a valid URI.
+
+        // bug 1124536: fix getSourceText on scripts associated "javascript:SOURCE" urls
+        // (e.g. 'evaluate(sandbox, sourcecode, "javascript:"+sourcecode)' )
+        if (url.indexOf("javascript:") === 0) {
+          spec.contentType = "text/javascript";
+        }
+      }
+    }
+    else {
+      // Assume the content is javascript if there's no URL
+      spec.contentType = "text/javascript";
+    }
+
+    return this.source(spec);
+  },
+
+  /**
+   * This is an internal function that returns a promise of an array
+   * of source actors representing all the source mapped sources of
+   * `aSource`, or `null` if the source is not sourcemapped or
+   * sourcemapping is disabled. Users should call `createSourceActors`
+   * instead of this.
+   *
+   * @param Debugger.Source aSource
+   *        The source instance to create actors for.
+   * @return Promise of an array of source actors
+   */
+  _createSourceMappedActors: function (aSource) {
+    if (!this._useSourceMaps || !aSource.sourceMapURL) {
+      return resolve(null);
+    }
+
+    return this.fetchSourceMap(aSource)
+      .then(map => {
+        if (map) {
+          return [
+            this.source({ originalUrl: s, generatedSource: aSource })
+            for (s of map.sources)
+          ].filter(isNotNull);
+        }
+        return null;
+      });
+  },
+
+  /**
+   * Creates the source actors representing the appropriate sources
+   * of `aSource`. If sourcemapped, returns actors for all of the original
+   * sources, otherwise returns a 1-element array with the actor for
+   * `aSource`.
+   *
+   * @param Debugger.Source aSource
+   *        The source instance to create actors for.
+   * @param Promise of an array of source actors
+   */
+  createSourceActors: function(aSource) {
+    return this._createSourceMappedActors(aSource).then(actors => {
+      let actor = this.createNonSourceMappedActor(aSource);
+      return (actors || [actor]).filter(isNotNull);
+    });
+  },
+
+  /**
+   * Return a promise of a SourceMapConsumer for the source map for
+   * `aSource`; if we already have such a promise extant, return that.
+   * This will fetch the source map if we don't have a cached object
+   * and source maps are enabled (see `_fetchSourceMap`).
+   *
+   * @param Debugger.Source aSource
+   *        The source instance to get sourcemaps for.
+   * @return Promise of a SourceMapConsumer
+   */
+  fetchSourceMap: function (aSource) {
+    if (this._sourceMaps.has(aSource)) {
+      return this._sourceMaps.get(aSource);
+    }
+    else if (!aSource || !aSource.sourceMapURL) {
+      return resolve(null);
+    }
+
+    let sourceMapURL = aSource.sourceMapURL;
+    if (aSource.url) {
+      sourceMapURL = this._normalize(sourceMapURL, aSource.url);
+    }
+    let result = this._fetchSourceMap(sourceMapURL, aSource.url);
+
+    // The promises in `_sourceMaps` must be the exact same instances
+    // as returned by `_fetchSourceMap` for `clearSourceMapCache` to work.
+    this._sourceMaps.set(aSource, result);
+    return result;
+  },
+
+  /**
+   * Return a promise of a SourceMapConsumer for the source map for
+   * `aSource`. The resolved result may be null if the source does not
+   * have a source map or source maps are disabled.
+   */
+  getSourceMap: function(aSource) {
+    return resolve(this._sourceMaps.get(aSource));
+  },
+
+  /**
+   * Set a SourceMapConsumer for the source map for
+   * |aSource|.
+   */
+  setSourceMap: function(aSource, aMap) {
+    this._sourceMaps.set(aSource, resolve(aMap));
+  },
+
+  /**
+   * Return a promise of a SourceMapConsumer for the source map located at
+   * |aAbsSourceMapURL|, which must be absolute. If there is already such a
+   * promise extant, return it. This will not fetch if source maps are
+   * disabled.
+   *
+   * @param string aAbsSourceMapURL
+   *        The source map URL, in absolute form, not relative.
+   * @param string aScriptURL
+   *        When the source map URL is a data URI, there is no sourceRoot on the
+   *        source map, and the source map's sources are relative, we resolve
+   *        them from aScriptURL.
+   */
+  _fetchSourceMap: function (aAbsSourceMapURL, aSourceURL) {
+    if (!this._useSourceMaps) {
+      return resolve(null);
+    }
+    else if (this._sourceMapCache[aAbsSourceMapURL]) {
+      return this._sourceMapCache[aAbsSourceMapURL];
+    }
+
+    let fetching = fetch(aAbsSourceMapURL, { loadFromCache: false })
+      .then(({ content }) => {
+        let map = new SourceMapConsumer(content);
+        this._setSourceMapRoot(map, aAbsSourceMapURL, aSourceURL);
+        return map;
+      })
+      .then(null, error => {
+        if (!DevToolsUtils.reportingDisabled) {
+          DevToolsUtils.reportException("TabSources.prototype._fetchSourceMap", error);
+        }
+        return null;
+      });
+    this._sourceMapCache[aAbsSourceMapURL] = fetching;
+    return fetching;
+  },
+
+  /**
+   * Sets the source map's sourceRoot to be relative to the source map url.
+   */
+  _setSourceMapRoot: function (aSourceMap, aAbsSourceMapURL, aScriptURL) {
+    const base = this._dirname(
+      aAbsSourceMapURL.indexOf("data:") === 0
+        ? aScriptURL
+        : aAbsSourceMapURL);
+    aSourceMap.sourceRoot = aSourceMap.sourceRoot
+      ? this._normalize(aSourceMap.sourceRoot, base)
+      : base;
+  },
+
+  _dirname: function (aPath) {
+    return Services.io.newURI(
+      ".", null, Services.io.newURI(aPath, null, null)).spec;
+  },
+
+  /**
+   * Clears the source map cache. Source maps are cached by URL so
+   * they can be reused across separate Debugger instances (once in
+   * this cache, they will never be reparsed again). They are
+   * also cached by Debugger.Source objects for usefulness. By default
+   * this just removes the Debugger.Source cache, but you can remove
+   * the lower-level URL cache with the `hard` option.
+   *
+   * @param aSourceMapURL string
+   *        The source map URL to uncache
+   * @param opts object
+   *        An object with the following properties:
+   *        - hard: Also remove the lower-level URL cache, which will
+   *          make us completely forget about the source map.
+   */
+  clearSourceMapCache: function(aSourceMapURL, opts = { hard: false }) {
+    let oldSm = this._sourceMapCache[aSourceMapURL];
+
+    if (opts.hard) {
+      delete this._sourceMapCache[aSourceMapURL];
+    }
+
+    if (oldSm) {
+      // Clear out the current cache so all sources will get the new one
+      for (let [source, sm] of this._sourceMaps.entries()) {
+        if (sm === oldSm) {
+          this._sourceMaps.delete(source);
+        }
+      }
+    }
+  },
+
+  /*
+   * Forcefully change the source map of a source, changing the
+   * sourceMapURL and installing the source map in the cache. This is
+   * necessary to expose changes across Debugger instances
+   * (pretty-printing is the use case). Generate a random url if one
+   * isn't specified, allowing you to set "anonymous" source maps.
+   *
+   * @param aSource Debugger.Source
+   *        The source to change the sourceMapURL property
+   * @param aUrl string
+   *        The source map URL (optional)
+   * @param aMap SourceMapConsumer
+   *        The source map instance
+   */
+  setSourceMapHard: function(aSource, aUrl, aMap) {
+    let url = aUrl;
+    if (!url) {
+      // This is a littly hacky, but we want to forcefully set a
+      // sourcemap regardless of sourcemap settings. We want to
+      // literally change the sourceMapURL so that all debuggers will
+      // get this and pretty-printing will Just Work (Debugger.Source
+      // instances are per-debugger, so we can't key off that). To
+      // avoid tons of work serializing the sourcemap into a data url,
+      // 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);
+  },
+
+  /**
+   * 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.
+   * @returns Object
+   *          Returns an object of the form { source, line, column }
+   */
+  getFrameLocation: function (aFrame) {
+    if (!aFrame || !aFrame.script) {
+      return new GeneratedLocation();
+    }
+    return new GeneratedLocation(
+      this.createNonSourceMappedActor(aFrame.script.source),
+      aFrame.script.getOffsetLine(aFrame.offset),
+      getOffsetColumn(aFrame.offset, aFrame.script)
+    );
+  },
+
+  /**
+   * Returns a promise of the location in the original source if the source is
+   * source mapped, otherwise a promise of the same location. This can
+   * be called with a source from *any* Debugger instance and we make
+   * sure to that it works properly, reusing source maps if already
+   * fetched. Use this from any actor that needs sourcemapping.
+   */
+  getOriginalLocation: function (generatedLocation) {
+    let {
+      generatedSourceActor,
+      generatedLine,
+      generatedColumn
+    } = generatedLocation;
+    let source = generatedSourceActor.source;
+    let url = source ? source.url : generatedSourceActor._originalUrl;
+
+    // In certain scenarios the source map may have not been fetched
+    // yet (or at least tied to this Debugger.Source instance), so use
+    // `fetchSourceMap` instead of `getSourceMap`. This allows this
+    // function to be called from anywere (across debuggers) and it
+    // should just automatically work.
+    return this.fetchSourceMap(source).then(map => {
+      if (map) {
+        let {
+          source: originalUrl,
+          line: originalLine,
+          column: originalColumn,
+          name: originalName
+        } = map.originalPositionFor({
+          line: generatedLine,
+          column: generatedColumn == null ? Infinity : generatedColumn
+        });
+
+        // Since the `Debugger.Source` instance may come from a
+        // different `Debugger` instance (any actor can call this
+        // method), we can't rely on any of the source discovery
+        // setup (`_discoverSources`, etc) to have been run yet. So
+        // we have to assume that the actor may not already exist,
+        // and we might need to create it, so use `source` and give
+        // it the required parameters for a sourcemapped source.
+        return new OriginalLocation(
+          originalUrl ? this.source({
+            originalUrl: originalUrl,
+            generatedSource: source
+          }) : null,
+          originalLine,
+          originalColumn,
+          originalName
+        );
+      }
+
+      // No source map
+      return OriginalLocation.fromGeneratedLocation(generatedLocation);
+    });
+  },
+
+  /**
+   * Returns a promise of the location in the generated source corresponding to
+   * the original source and line given.
+   *
+   * When we pass a script S representing generated code to `sourceMap`,
+   * above, that returns a promise P. The process of resolving P populates
+   * the tables this function uses; thus, it won't know that S's original
+   * source URLs map to S until P is resolved.
+   */
+  getGeneratedLocation: function (originalLocation) {
+    let { originalSourceActor } = originalLocation;
+
+    // Both original sources and normal sources could have sourcemaps,
+    // because normal sources can be pretty-printed which generates a
+    // sourcemap for itself. Check both of the source properties to make it work
+    // for both kinds of sources.
+    let source = originalSourceActor.source || originalSourceActor.generatedSource;
+
+    // See comment about `fetchSourceMap` in `getOriginalLocation`.
+    return this.fetchSourceMap(source).then((map) => {
+      if (map) {
+        let {
+          originalLine,
+          originalColumn
+        } = originalLocation;
+
+        let {
+          line: generatedLine,
+          column: generatedColumn
+        } = map.generatedPositionFor({
+          source: originalSourceActor.url,
+          line: originalLine,
+          column: originalColumn == null ? 0 : originalColumn,
+          bias: SourceMapConsumer.LEAST_UPPER_BOUND
+        });
+
+        return new GeneratedLocation(
+          this.createNonSourceMappedActor(source),
+          generatedLine,
+          generatedColumn
+        );
+      }
+
+      return GeneratedLocation.fromOriginalLocation(originalLocation);
+    });
+  },
+
+  /**
+   * Returns true if URL for the given source is black boxed.
+   *
+   * @param aURL String
+   *        The URL of the source which we are checking whether it is black
+   *        boxed or not.
+   */
+  isBlackBoxed: function (aURL) {
+    return this.blackBoxedSources.has(aURL);
+  },
+
+  /**
+   * Add the given source URL to the set of sources that are black boxed.
+   *
+   * @param aURL String
+   *        The URL of the source which we are black boxing.
+   */
+  blackBox: function (aURL) {
+    this.blackBoxedSources.add(aURL);
+  },
+
+  /**
+   * Remove the given source URL to the set of sources that are black boxed.
+   *
+   * @param aURL String
+   *        The URL of the source which we are no longer black boxing.
+   */
+  unblackBox: function (aURL) {
+    this.blackBoxedSources.delete(aURL);
+  },
+
+  /**
+   * Returns true if the given URL is pretty printed.
+   *
+   * @param aURL String
+   *        The URL of the source that might be pretty printed.
+   */
+  isPrettyPrinted: function (aURL) {
+    return this.prettyPrintedSources.has(aURL);
+  },
+
+  /**
+   * Add the given URL to the set of sources that are pretty printed.
+   *
+   * @param aURL String
+   *        The URL of the source to be pretty printed.
+   */
+  prettyPrint: function (aURL, aIndent) {
+    this.prettyPrintedSources.set(aURL, aIndent);
+  },
+
+  /**
+   * Return the indent the given URL was pretty printed by.
+   */
+  prettyPrintIndent: function (aURL) {
+    return this.prettyPrintedSources.get(aURL);
+  },
+
+  /**
+   * Remove the given URL from the set of sources that are pretty printed.
+   *
+   * @param aURL String
+   *        The URL of the source that is no longer pretty printed.
+   */
+  disablePrettyPrint: function (aURL) {
+    this.prettyPrintedSources.delete(aURL);
+  },
+
+  /**
+   * Normalize multiple relative paths towards the base paths on the right.
+   */
+  _normalize: function (...aURLs) {
+    dbg_assert(aURLs.length > 1, "Should have more than 1 URL");
+    let base = Services.io.newURI(aURLs.pop(), null, null);
+    let url;
+    while ((url = aURLs.pop())) {
+      base = Services.io.newURI(url, null, base);
+    }
+    return base.spec;
+  },
+
+  iter: function () {
+    let actors = Object.keys(this._sourceMappedSourceActors).map(k => {
+      return this._sourceMappedSourceActors[k];
+    });
+    for (let actor of this._sourceActors.values()) {
+      if (!this._sourceMaps.has(actor.source)) {
+        actors.push(actor);
+      }
+    }
+    return actors;
+  }
+};
+
+/*
+ * Checks if a source should never be displayed to the user because
+ * it's either internal or we don't support in the UI yet.
+ */
+function isHiddenSource(aSource) {
+  // Ignore the internal Function.prototype script
+  return aSource.text === '() {\n}';
+}
+
+/**
+ * Returns true if its argument is not null.
+ */
+function isNotNull(aThing) {
+  return aThing !== null;
+}
+
+exports.TabSources = TabSources;
+exports.isHiddenSource = isHiddenSource;
--- a/toolkit/devtools/server/actors/webbrowser.js
+++ b/toolkit/devtools/server/actors/webbrowser.js
@@ -8,16 +8,17 @@
 
 let { Ci, Cu } = require("chrome");
 let Services = require("Services");
 let { ActorPool, createExtraActors, appendExtraActors } = require("devtools/server/actors/common");
 let { RootActor } = require("devtools/server/actors/root");
 let { DebuggerServer } = require("devtools/server/main");
 let DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
 let { dbg_assert } = DevToolsUtils;
+let { TabSources, isHiddenSource } = require("./utils/TabSources");
 let makeDebugger = require("./utils/make-debugger");
 let mapURIToAddonID = require("./utils/map-uri-to-addon-id");
 
 let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 loader.lazyRequireGetter(this, "AddonThreadActor", "devtools/server/actors/script", true);
 loader.lazyRequireGetter(this, "ThreadActor", "devtools/server/actors/script", true);
@@ -593,16 +594,17 @@ exports.BrowserTabList = BrowserTabList;
  */
 function TabActor(aConnection)
 {
   this.conn = aConnection;
   this._tabActorPool = null;
   // A map of actor names to actor instances provided by extensions.
   this._extraActors = {};
   this._exited = false;
+  this._sources = null;
 
   // Map of DOM stylesheets to StyleSheetActors
   this._styleSheetActors = new Map();
 
   this._shouldAddNewGlobalAsDebuggee = this._shouldAddNewGlobalAsDebuggee.bind(this);
 
   this.makeDebugger = makeDebugger.bind(null, {
     findDebuggees: () => this.windows,
@@ -762,16 +764,24 @@ TabActor.prototype = {
     if (this.webNavigation.currentURI) {
       return this.webNavigation.currentURI.spec;
     }
     // Abrupt closing of the browser window may leave callbacks without a
     // currentURI.
     return null;
   },
 
+  get sources() {
+    if (!this._sources) {
+      dbg_assert(this.threadActor, "threadActor should exist when creating sources.");
+      this._sources = new TabSources(this.threadActor);
+    }
+    return this._sources;
+  },
+
   /**
    * This is called by BrowserTabList.getList for existing tab actors prior to
    * calling |form| below.  It can be used to do any async work that may be
    * needed to assemble the form.
    */
   update: function() {
     return promise.resolve(this);
   },
@@ -1113,16 +1123,17 @@ TabActor.prototype = {
    */
   _popContext: function BTA_popContext() {
     dbg_assert(!!this._contextPool, "No context to pop.");
 
     this.conn.removeActorPool(this._contextPool);
     this._contextPool = null;
     this.threadActor.exit();
     this.threadActor = null;
+    this._sources = null;
   },
 
   /**
    * Does the actual work of detaching from a tab.
    *
    * @returns false if the tab wasn't attached or true of detaching succeeds.
    */
   _detach: function BTA_detach() {
@@ -1397,22 +1408,21 @@ TabActor.prototype = {
     events.emit(this, "window-ready", {
       window: window,
       isTopLevel: isTopLevel,
       id: getWindowID(window)
     });
 
     // TODO bug 997119: move that code to ThreadActor by listening to window-ready
     let threadActor = this.threadActor;
-    if (isTopLevel) {
+    if (isTopLevel && threadActor.state != "detached") {
+      this.sources.reset({ sourceMaps: true });
       threadActor.clearDebuggees();
-      if (threadActor.dbg) {
-        threadActor.dbg.enabled = true;
-        threadActor.maybePauseOnExceptions();
-      }
+      threadActor.dbg.enabled = true;
+      threadActor.maybePauseOnExceptions();
       // Update the global no matter if the debugger is on or off,
       // otherwise the global will be wrong when enabled later.
       threadActor.global = window;
     }
 
     for (let sheetActor of this._styleSheetActors.values()) {
       this._tabPool.removeActor(sheetActor);
     }
@@ -1846,16 +1856,25 @@ BrowserAddonActor.prototype = {
   get attached() {
     return this._threadActor;
   },
 
   get global() {
     return this._global;
   },
 
+  get sources() {
+    if (!this._sources) {
+      dbg_assert(this.threadActor, "threadActor should exist when creating sources.");
+      this._sources = new TabSources(this._threadActor, this._allowSource);
+    }
+    return this._sources;
+  },
+
+
   form: function BAA_form() {
     dbg_assert(this.actorID, "addon should have an actorID.");
     if (!this._consoleActor) {
       let {AddonConsoleActor} = require("devtools/server/actors/webconsole");
       this._consoleActor = new AddonConsoleActor(this._addon, this.conn, this);
       this._contextPool.addActor(this._consoleActor);
     }
 
@@ -1926,16 +1945,17 @@ BrowserAddonActor.prototype = {
   onDetach: function BAA_onDetach() {
     if (!this.attached) {
       return { error: "wrongState" };
     }
 
     this._contextPool.removeActor(this._threadActor);
 
     this._threadActor = null;
+    this._sources = null;
 
     return { type: "detached" };
   },
 
   preNest: function() {
     let e = Services.wm.getEnumerator(null);
     while (e.hasMoreElements()) {
       let win = e.getNext();
@@ -2000,16 +2020,30 @@ BrowserAddonActor.prototype = {
         return id.value === this.id;
       }
     }
 
     return false;
   },
 
   /**
+   * Override the eligibility check for scripts and sources to make
+   * sure every script and source with a URL is stored when debugging
+   * add-ons.
+   */
+  _allowSource: function(aSource) {
+    // XPIProvider.jsm evals some code in every add-on's bootstrap.js. Hide it.
+    if (aSource.url === "resource://gre/modules/addons/XPIProvider.jsm") {
+      return false;
+    }
+
+    return true;
+  },
+
+  /**
    * Yield the current set of globals associated with this addon that should be
    * added as debuggees.
    */
   _findDebuggees: function (dbg) {
     return dbg.findAllGlobals().filter(this._shouldAddNewGlobalAsDebuggee);
   }
 };
 
--- a/toolkit/devtools/server/moz.build
+++ b/toolkit/devtools/server/moz.build
@@ -76,11 +76,12 @@ EXTRA_JS_MODULES.devtools.server.actors 
 EXTRA_JS_MODULES.devtools.server.actors.utils += [
     'actors/utils/actor-registry-utils.js',
     'actors/utils/audionodes.json',
     'actors/utils/automation-timeline.js',
     'actors/utils/make-debugger.js',
     'actors/utils/map-uri-to-addon-id.js',
     'actors/utils/ScriptStore.js',
     'actors/utils/stack.js',
+    'actors/utils/TabSources.js'
 ]
 
 FAIL_ON_WARNINGS = True
--- a/toolkit/devtools/server/tests/mochitest/test_styles-applied.html
+++ b/toolkit/devtools/server/tests/mochitest/test_styles-applied.html
@@ -117,17 +117,17 @@ addTest(function matchedSelectors() {
 addTest(function testMediaQuery() {
   promiseDone(gWalker.querySelector(gWalker.rootNode, "#mediaqueried").then(node => {
     return gStyles.getApplied(node, {
       inherited: false, filter: "user", matchedSelectors: true
     });
   }).then(applied => {
     is(applied[1].rule.type, 1, "Entry 1 is a rule style");
     is(applied[1].rule.parentRule.type, 4, "Entry 1's parent rule is a media rule");
-    is(applied[1].rule.parentRule.media[0], "screen", "Entry 1's parent rule has the expected medium");
+    is(applied[1].rule.media[0], "screen", "Entry 1's rule has the expected medium");
   }).then(runNextTest));
 });
 
 addTest(function cleanup() {
   delete gStyles;
   delete gWalker;
   delete gClient;
   runNextTest();
--- a/toolkit/devtools/server/tests/unit/test_conditional_breakpoint-03.js
+++ b/toolkit/devtools/server/tests/unit/test_conditional_breakpoint-03.js
@@ -28,17 +28,17 @@ function test_simple_breakpoint()
   gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) {
     let source = gThreadClient.source(aPacket.frame.where.source);
     source.setBreakpoint({
       line: 3,
       condition: "throw new Error()"
     }, function (aResponse, bpClient) {
       gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) {
         // Check the return value.
-        do_check_eq(aPacket.why.type, "breakpoint");
+        do_check_eq(aPacket.why.type, "breakpointConditionThrown");
         do_check_eq(aPacket.frame.where.line, 3);
 
         // Remove the breakpoint.
         bpClient.remove(function (aResponse) {
           gThreadClient.resume(function () {
             finishClient(gClient);
           });
         });
--- a/toolkit/devtools/server/tests/unit/testactors.js
+++ b/toolkit/devtools/server/tests/unit/testactors.js
@@ -1,15 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const { ActorPool, appendExtraActors, createExtraActors } = require("devtools/server/actors/common");
 const { RootActor } = require("devtools/server/actors/root");
 const { ThreadActor } = require("devtools/server/actors/script");
 const { DebuggerServer } = require("devtools/server/main");
+const { TabSources } = require("devtools/server/actors/utils/TabSources");
 const promise = require("promise");
 const makeDebugger = require("devtools/server/actors/utils/make-debugger");
 
 var gTestGlobals = [];
 DebuggerServer.addTestGlobal = function(aGlobal) {
   gTestGlobals.push(aGlobal);
 };
 
@@ -86,16 +87,23 @@ TestTabActor.prototype = {
   get window() {
     return { wrappedJSObject: this._global };
   },
 
   get url() {
     return this._global.__name;
   },
 
+  get sources() {
+    if (!this._sources) {
+      this._sources = new TabSources(this.threadActor);
+    }
+    return this._sources;
+  },
+
   form: function() {
     let response = { actor: this.actorID, title: this._global.__name };
 
     // Walk over tab actors added by extensions and add them to a new ActorPool.
     let actorPool = new ActorPool(this.conn);
     this._createExtraActors(DebuggerServer.tabActorFactories, actorPool);
     if (!actorPool.isEmpty()) {
       this._tabActorPool = actorPool;
@@ -119,16 +127,17 @@ TestTabActor.prototype = {
   onDetach: function(aRequest) {
     if (!this._attached) {
       return { "error":"wrongState" };
     }
     return { type: "detached" };
   },
 
   onReload: function(aRequest) {
+    this.sources.reset({ sourceMaps: true });
     this.threadActor.clearDebuggees();
     this.threadActor.dbg.addDebuggees();
     return {};
   },
 
   removeActorByName: function(aName) {
     const actor = this._extraActors[aName];
     if (this._tabActorPool) {
--- a/toolkit/mozapps/extensions/internal/GMPProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/GMPProvider.jsm
@@ -333,24 +333,17 @@ GMPWrapper.prototype = {
                                            "onEnabled" : "onDisabled",
                                            this);
   },
 
   onPrefEMEGlobalEnabledChanged: function() {
     AddonManagerPrivate.callAddonListeners("onPropertyChanged", this,
                                            ["appDisabled"]);
     if (this.appDisabled) {
-      AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
-      if (this._gmpPath) {
-        this._log.info("onPrefEMEGlobalEnabledChanged() - unregistering gmp " +
-                       "directory " + this._gmpPath);
-        gmpService.removeAndDeletePluginDirectory(this._gmpPath);
-      }
-      GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_VERSION, this.id);
-      AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+      this.uninstallPlugin();
     } else {
       AddonManagerPrivate.callInstallListeners("onExternalInstall", null, this,
                                                null, false);
       AddonManagerPrivate.callAddonListeners("onInstalling", this, false);
       AddonManagerPrivate.callAddonListeners("onInstalled", this);
       if (!this._isUpdateCheckPending) {
         this._isUpdateCheckPending = true;
         GMPPrefs.reset(GMPPrefs.KEY_UPDATE_LAST_CHECK, null);
@@ -400,16 +393,27 @@ GMPWrapper.prototype = {
     if (this._gmpPath && this.isActive) {
       this._log.info("onPrefVersionChanged() - registering gmp directory " +
                      this._gmpPath);
       gmpService.addPluginDirectory(this._gmpPath);
     }
     AddonManagerPrivate.callAddonListeners("onInstalled", this);
   },
 
+  uninstallPlugin: function() {
+    AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+    if (this.gmpPath) {
+      this._log.info("uninstallPlugin() - unregistering gmp directory " +
+                     this.gmpPath);
+      gmpService.removeAndDeletePluginDirectory(this.gmpPath);
+    }
+    GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_VERSION, this.id);
+    AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+  },
+
   shutdown: function() {
     Preferences.ignore(GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED,
                                            this._plugin.id),
                        this.onPrefEnabledChanged, this);
     Preferences.ignore(GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION,
                                            this._plugin.id),
                        this.onPrefVersionChanged, this);
     if (this._plugin.isEME) {
@@ -426,16 +430,17 @@ let GMPProvider = {
   _plugins: null,
 
   startup: function() {
     configureLogging();
     this._log = Log.repository.getLoggerWithMessagePrefix("Toolkit.GMP",
                                                           "GMPProvider.");
     let telemetry = {};
     this.buildPluginList();
+    this.ensureProperCDMInstallState();
 
     Preferences.observe(GMPPrefs.KEY_LOG_BASE, configureLogging);
 
     for (let [id, plugin] of this._plugins) {
       let wrapper = plugin.wrapper;
       let gmpPath = wrapper.gmpPath;
       let isEnabled = wrapper.isActive;
       this._log.trace("startup - enabled=" + isEnabled + ", gmpPath=" +
@@ -558,16 +563,27 @@ let GMPProvider = {
         wrapper: null,
         isEME: aPlugin.isEME,
       };
       plugin.fullDescription = this.generateFullDescription(aPlugin);
       plugin.wrapper = new GMPWrapper(plugin);
       this._plugins.set(plugin.id, plugin);
     }
   },
+
+  ensureProperCDMInstallState: function() {
+    if (!GMPPrefs.get(GMPPrefs.KEY_EME_ENABLED, true)) {
+      for (let [id, plugin] of this._plugins) {
+        if (plugin.isEME && plugin.wrapper.isInstalled) {
+          gmpService.addPluginDirectory(plugin.wrapper.gmpPath);
+          plugin.wrapper.uninstallPlugin();
+        }
+      }
+    }
+  },
 };
 
 AddonManagerPrivate.registerProvider(GMPProvider, [
   new AddonManagerPrivate.AddonType("plugin", URI_EXTENSION_STRINGS,
                                     STRING_TYPE_NAME,
                                     AddonManager.VIEW_TYPE_LIST, 6000,
                                     AddonManager.TYPE_SUPPORTS_ASK_TO_ACTIVATE)
 ]);