Bug 1344590: Part 4 - Store parsed and normalized extension data in indexedDB. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Wed, 08 Mar 2017 09:16:01 -0800
changeset 495358 7c51ea2b2fcb231f93ec77fc7836f14f53dca6a0
parent 495357 68bb2377b51ca42a594bb1b467b658883ca5f1fd
child 548347 07e7c8879485e5b5f93ca9801c26f4b10b2cf233
push id48299
push usermaglione.k@gmail.com
push dateWed, 08 Mar 2017 17:22:14 +0000
reviewersaswan
bugs1344590
milestone54.0a1
Bug 1344590: Part 4 - Store parsed and normalized extension data in indexedDB. r?aswan MozReview-Commit-ID: HA0PJfbGa9w
devtools/server/tests/unit/test_addon_reload.js
testing/specialpowers/content/SpecialPowersObserverAPI.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionManagement.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js
toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js
toolkit/components/extensions/test/xpcshell/test_locale_data.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/browser/browser_webext_options.js
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
toolkit/mozapps/extensions/test/xpcshell/test_reload.js
toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
--- a/devtools/server/tests/unit/test_addon_reload.js
+++ b/devtools/server/tests/unit/test_addon_reload.js
@@ -14,53 +14,73 @@ function promiseAddonEvent(event) {
         resolve(args);
       }
     };
 
     AddonManager.addAddonListener(listener);
   });
 }
 
+function promiseWebExtensionStartup() {
+  const {Management} = Components.utils.import("resource://gre/modules/Extension.jsm", {});
+
+  return new Promise(resolve => {
+    let listener = (evt, extension) => {
+      Management.off("ready", listener);
+      resolve(extension);
+    };
+
+    Management.on("ready", listener);
+  });
+}
+
 function* findAddonInRootList(client, addonId) {
   const result = yield client.listAddons();
   const addonActor = result.addons.filter(addon => addon.id === addonId)[0];
   ok(addonActor, `Found add-on actor for ${addonId}`);
   return addonActor;
 }
 
-function* reloadAddon(client, addonActor) {
+async function reloadAddon(client, addonActor) {
   // The add-on will be re-installed after a successful reload.
   const onInstalled = promiseAddonEvent("onInstalled");
-  yield client.request({to: addonActor.actor, type: "reload"});
-  yield onInstalled;
+  await client.request({to: addonActor.actor, type: "reload"});
+  await onInstalled;
 }
 
 function getSupportFile(path) {
   const allowMissing = false;
   return do_get_file(path, allowMissing);
 }
 
 add_task(function* testReloadExitedAddon() {
   const client = yield new Promise(resolve => {
     get_chrome_actors(client => resolve(client));
   });
 
   // Install our main add-on to trigger reloads on.
   const addonFile = getSupportFile("addons/web-extension");
-  const installedAddon = yield AddonManager.installTemporaryAddon(
-    addonFile);
+  const [installedAddon] = yield Promise.all([
+    AddonManager.installTemporaryAddon(addonFile),
+    promiseWebExtensionStartup(),
+  ]);
 
   // Install a decoy add-on.
   const addonFile2 = getSupportFile("addons/web-extension2");
-  const installedAddon2 = yield AddonManager.installTemporaryAddon(
-    addonFile2);
+  const [installedAddon2] = yield Promise.all([
+    AddonManager.installTemporaryAddon(addonFile2),
+    promiseWebExtensionStartup(),
+  ]);
 
   let addonActor = yield findAddonInRootList(client, installedAddon.id);
 
-  yield reloadAddon(client, addonActor);
+  yield Promise.all([
+    reloadAddon(client, addonActor),
+    promiseWebExtensionStartup(),
+  ]);
 
   // Uninstall the decoy add-on, which should cause its actor to exit.
   const onUninstalled = promiseAddonEvent("onUninstalled");
   installedAddon2.uninstall();
   const [uninstalledAddon] = yield onUninstalled;
 
   // Try to re-list all add-ons after a reload.
   // This was throwing an exception because of the exited actor.
@@ -74,18 +94,20 @@ add_task(function* testReloadExitedAddon
     client.addListener("addonListChanged", function listener() {
       client.removeListener("addonListChanged", listener);
       resolve();
     });
   });
 
   // Install an upgrade version of the first add-on.
   const addonUpgradeFile = getSupportFile("addons/web-extension-upgrade");
-  const upgradedAddon = yield AddonManager.installTemporaryAddon(
-    addonUpgradeFile);
+  const [upgradedAddon] = yield Promise.all([
+    AddonManager.installTemporaryAddon(addonUpgradeFile),
+    promiseWebExtensionStartup(),
+  ]);
 
   // Waiting for addonListChanged unsolicited event
   yield onAddonListChanged;
 
   // re-list all add-ons after an upgrade.
   const upgradedAddonActor = yield findAddonInRootList(client, upgradedAddon.id);
   equal(upgradedAddonActor.id, addonActor.id);
   // The actor id should be the same after the upgrade.
--- a/testing/specialpowers/content/SpecialPowersObserverAPI.js
+++ b/testing/specialpowers/content/SpecialPowersObserverAPI.js
@@ -545,26 +545,26 @@ SpecialPowersObserverAPI.prototype = {
         extension.on("startup", () => {
           this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionSetId", args: [extension.id]});
         });
 
         // Make sure the extension passes the packaging checks when
         // they're run on a bare archive rather than a running instance,
         // as the add-on manager runs them.
         let extensionData = new ExtensionData(extension.rootURI);
-        extensionData.readManifest().then(
+        extensionData.loadManifest().then(
           () => {
             return extensionData.initAllLocales().then(() => {
               if (extensionData.errors.length) {
                 return Promise.reject("Extension contains packaging errors");
               }
             });
           },
           () => {
-            // readManifest() will throw if we're loading an embedded
+            // loadManifest() will throw if we're loading an embedded
             // extension, so don't worry about locale errors in that
             // case.
           }
         ).then(() => {
           return extension.startup();
         }).then(() => {
           this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionStarted", args: []});
         }).catch(e => {
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -78,16 +78,17 @@ var {
   GlobalManager,
   ParentAPIManager,
   apiManager: Management,
 } = ExtensionParent;
 
 const {
   EventEmitter,
   LocaleData,
+  StartupCache,
   getUniqueId,
 } = ExtensionUtils;
 
 XPCOMUtils.defineLazyGetter(this, "console", ExtensionUtils.getConsole);
 
 const LOGGER_ID_BASE = "addons.webextension.";
 const UUID_MAP_PREF = "extensions.webextensions.uuids";
 const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
@@ -212,17 +213,17 @@ UninstallObserver.init();
 
 // Represents the data contained in an extension, contained either
 // in a directory or a zip file, which may or may not be installed.
 // This class implements the functionality of the Extension class,
 // primarily related to manifest parsing and localization, which is
 // useful prior to extension installation or initialization.
 //
 // No functionality of this class is guaranteed to work before
-// |readManifest| has been called, and completed.
+// |loadManifest| has been called, and completed.
 this.ExtensionData = class {
   constructor(rootURI) {
     this.rootURI = rootURI;
 
     this.manifest = null;
     this.id = null;
     this.uuid = null;
     this.localeData = null;
@@ -396,19 +397,17 @@ this.ExtensionData = class {
     // a *.domain.com to specific-host.domain.com that's actually a
     // drop in permissions but the simple test below will cause a prompt.
     return {
       hosts: newPermissions.hosts.filter(perm => !oldPermissions.hosts.includes(perm)),
       permissions: newPermissions.permissions.filter(perm => !oldPermissions.permissions.includes(perm)),
     };
   }
 
-  // Reads the extension's |manifest.json| file, and stores its
-  // parsed contents in |this.manifest|.
-  readManifest() {
+  parseManifest() {
     return Promise.all([
       this.readJSON("manifest.json"),
       Management.lazyInit(),
     ]).then(([manifest]) => {
       this.manifest = manifest;
       this.rawManifest = manifest;
 
       if (manifest && manifest.default_locale) {
@@ -430,60 +429,64 @@ this.ExtensionData = class {
       if (this.localeData) {
         context.preprocessors.localize = (value, context) => this.localize(value);
       }
 
       let normalized = Schemas.normalize(this.manifest, "manifest.WebExtensionManifest", context);
       if (normalized.error) {
         this.manifestError(normalized.error);
       } else {
-        this.manifest = normalized.value;
+        return normalized.value;
       }
+    });
+  }
+
+  // Reads the extension's |manifest.json| file, and stores its
+  // parsed contents in |this.manifest|.
+  async loadManifest() {
+    [this.manifest] = await Promise.all([
+      this.parseManifest(),
+      Management.lazyInit(),
+    ]);
 
-      try {
-        // Do not override the add-on id that has been already assigned.
-        if (!this.id && this.manifest.applications.gecko.id) {
-          this.id = this.manifest.applications.gecko.id;
-        }
-      } catch (e) {
-        // Errors are handled by the type checks above.
+    if (!this.manifest) {
+      return;
+    }
+
+    try {
+      // Do not override the add-on id that has been already assigned.
+      if (!this.id && this.manifest.applications.gecko.id) {
+        this.id = this.manifest.applications.gecko.id;
       }
+    } catch (e) {
+      // Errors are handled by the type checks above.
+    }
 
-      let containersEnabled = true;
-      try {
-        containersEnabled = Services.prefs.getBoolPref("privacy.userContext.enabled");
-      } catch (e) {
-        // If we fail here, we are in some xpcshell test.
+    let whitelist = [];
+    for (let perm of this.manifest.permissions) {
+      if (perm == "contextualIdentities" && !Preferences.get("privacy.userContext.enabled")) {
+        continue;
       }
 
-      let permissions = this.manifest.permissions || [];
-
-      let whitelist = [];
-      for (let perm of permissions) {
-        if (perm == "contextualIdentities" && !containersEnabled) {
-          continue;
-        }
-
-        this.permissions.add(perm);
+      this.permissions.add(perm);
 
-        let match = /^(\w+)(?:\.(\w+)(?:\.\w+)*)?$/.exec(perm);
-        if (!match) {
-          whitelist.push(perm);
-        } else if (match[1] == "experiments" && match[2]) {
-          this.apiNames.add(match[2]);
-        }
+      let match = /^(\w+)(?:\.(\w+)(?:\.\w+)*)?$/.exec(perm);
+      if (!match) {
+        whitelist.push(perm);
+      } else if (match[1] == "experiments" && match[2]) {
+        this.apiNames.add(match[2]);
       }
-      this.whiteListedHosts = new MatchPattern(whitelist);
+    }
+    this.whiteListedHosts = new MatchPattern(whitelist);
 
-      for (let api of this.apiNames) {
-        this.dependencies.add(`${api}@experiments.addons.mozilla.org`);
-      }
+    for (let api of this.apiNames) {
+      this.dependencies.add(`${api}@experiments.addons.mozilla.org`);
+    }
 
-      return this.manifest;
-    });
+    return this.manifest;
   }
 
   localizeMessage(...args) {
     return this.localeData.localizeMessage(...args);
   }
 
   localize(...args) {
     return this.localeData.localize(...args);
@@ -632,16 +635,20 @@ this.Extension = class extends Extension
       Services.obs.addObserver(this, "xpcom-shutdown", false);
       this.cleanupFile = addonData.cleanupFile || null;
       delete addonData.cleanupFile;
     }
 
     this.addonData = addonData;
     this.startupReason = startupReason;
 
+    if (["ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(startupReason)) {
+      StartupCache.clearAddonData(addonData.id);
+    }
+
     this.remote = ExtensionManagement.useRemoteWebExtensions;
 
     if (this.remote && processCount !== 1) {
       throw new Error("Out-of-process WebExtensions are not supported with multiple child processes");
     }
     // This is filled in the first time an extension child is created.
     this.parentMessageManager = null;
 
@@ -716,18 +723,35 @@ this.Extension = class extends Extension
   // Checks that the given URL is a child of our baseURI.
   isExtensionURL(url) {
     let uri = Services.io.newURI(url);
 
     let common = this.baseURI.getCommonBaseSpec(uri);
     return common == this.baseURI.spec;
   }
 
-  readManifest() {
-    return super.readManifest().then(manifest => {
+  readLocaleFile(locale) {
+    return StartupCache.locales.get([this.id, locale],
+                                    () => super.readLocaleFile(locale))
+      .then(result => {
+        this.localeData.messages.set(locale, result);
+      });
+  }
+
+  parseManifest() {
+    return StartupCache.manifests.get([this.id, Locale.getLocale()],
+                                      () => super.parseManifest());
+  }
+
+  loadManifest() {
+    return super.loadManifest().then(manifest => {
+      if (this.errors.length) {
+        return Promise.reject({errors: this.errors});
+      }
+
       if (AppConstants.RELEASE_OR_BETA) {
         return manifest;
       }
 
       // Load Experiments APIs that this extension depends on.
       return Promise.all(
         Array.from(this.apiNames, api => ExtensionAPIs.load(api))
       ).then(apis => {
@@ -834,39 +858,34 @@ this.Extension = class extends Extension
     return new Map([
       ["@@extension_id", this.uuid],
     ]);
   }
 
   // Reads the locale file for the given Gecko-compatible locale code, or if
   // no locale is given, the available locale closest to the UI locale.
   // Sets the currently selected locale on success.
-  initLocale(locale = undefined) {
-    // Ugh.
-    let super_ = super.initLocale.bind(this);
-
-    return Task.spawn(function* () {
-      if (locale === undefined) {
-        let locales = yield this.promiseLocales();
+  async initLocale(locale = undefined) {
+    if (locale === undefined) {
+      let locales = await this.promiseLocales();
 
-        let localeList = Array.from(locales.keys(), locale => {
-          return {name: locale, locales: [locale]};
-        });
+      let localeList = Array.from(locales.keys(), locale => {
+        return {name: locale, locales: [locale]};
+      });
 
-        let match = Locale.findClosestLocale(localeList);
-        locale = match ? match.name : this.defaultLocale;
-      }
+      let match = Locale.findClosestLocale(localeList);
+      locale = match ? match.name : this.defaultLocale;
+    }
 
-      return super_(locale);
-    }.bind(this));
+    return super.initLocale(locale);
   }
 
   startup() {
     let started = false;
-    return this.readManifest().then(() => {
+    return this.loadManifest().then(() => {
       ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this);
       started = true;
 
       if (!this.hasShutdown) {
         return this.initLocale();
       }
     }).then(() => {
       if (this.errors.length) {
@@ -921,16 +940,21 @@ this.Extension = class extends Extension
       file.remove(false);
     }).catch(Cu.reportError);
   }
 
   shutdown(reason) {
     this.shutdownReason = reason;
     this.hasShutdown = true;
 
+    if (this.cleanupFile ||
+        ["ADDON_INSTALL", "ADDON_UNINSTALL", "ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(reason)) {
+      StartupCache.clearAddonData(this.id);
+    }
+
     Services.ppmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this);
 
     if (!this.manifest) {
       ExtensionManagement.shutdownExtension(this.uuid);
 
       this.cleanupGeneratedFile();
       return;
     }
--- a/toolkit/components/extensions/ExtensionManagement.jsm
+++ b/toolkit/components/extensions/ExtensionManagement.jsm
@@ -311,17 +311,27 @@ function getAPILevelForWindow(window, ad
     // (see Bug 1214658 for rationale)
     return CONTENTSCRIPT_PRIVILEGES;
   }
 
   // WebExtension URLs loaded into top frames UI could have full API level privileges.
   return FULL_PRIVILEGES;
 }
 
+let cacheInvalidated = 0;
+function onCacheInvalidate() {
+  cacheInvalidated++;
+}
+Services.obs.addObserver(onCacheInvalidate, "startupcache-invalidate", false);
+
 ExtensionManagement = {
+  get cacheInvalidated() {
+    return cacheInvalidated;
+  },
+
   get isExtensionProcess() {
     if (this.useRemoteWebExtensions) {
       return Services.appinfo.remoteType === E10SUtils.EXTENSION_REMOTE_TYPE;
     }
     return Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
   },
 
   startupExtension: Service.startupExtension.bind(Service),
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -17,16 +17,20 @@ Cu.import("resource://gre/modules/Servic
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ConsoleAPI",
                                   "resource://gre/modules/Console.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
+                                  "resource://gre/modules/ExtensionManagement.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "IndexedDB",
+                                  "resource://gre/modules/IndexedDB.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
                                   "resource:///modules/translation/LanguageDetector.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
@@ -50,16 +54,128 @@ XPCOMUtils.defineLazyGetter(this, "conso
 
 let nextId = 0;
 XPCOMUtils.defineLazyGetter(this, "uniqueProcessID", () => Services.appinfo.uniqueProcessID);
 
 function getUniqueId() {
   return `${nextId++}-${uniqueProcessID}`;
 }
 
+let StartupCache = {
+  DB_NAME: "ExtensionStartupCache",
+
+  SCHEMA_VERSION: 1,
+
+  STORE_NAMES: Object.freeze(["locales", "manifests", "schemas"]),
+
+  dbPromise: null,
+
+  cacheInvalidated: 0,
+
+  initDB(db) {
+    for (let name of StartupCache.STORE_NAMES) {
+      try {
+        db.deleteObjectStore(name);
+      } catch (e) {
+        // Don't worry if the store doesn't already exist.
+      }
+      db.createObjectStore(name);
+    }
+  },
+
+  clearAddonData(id) {
+    let range = IDBKeyRange.bound([id], [id, "\uFFFF"]);
+
+    return Promise.all([
+      this.locales.delete(range),
+      this.manifests.delete(range),
+    ]).catch(e => {
+      // Ignore the error. It happens when we try to flush the add-on
+      // data after the AddonManager has flushed the entire startup cache.
+    });
+  },
+
+  async reallyOpen(invalidate = false) {
+    if (this.dbPromise) {
+      let db = await this.dbPromise;
+      db.close();
+    }
+
+    if (invalidate) {
+      this.cacheInvalidated = ExtensionManagement.cacheInvalidated;
+
+      if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT) {
+        IndexedDB.deleteDatabase(this.DB_NAME, {storage: "persistent"});
+      }
+    }
+
+    return IndexedDB.open(this.DB_NAME,
+                          {storage: "persistent", version: this.SCHEMA_VERSION},
+                          db => this.initDB(db));
+  },
+
+  async open() {
+    if (ExtensionManagement.cacheInvalidated > this.cacheInvalidated) {
+      this.dbPromise = this.reallyOpen(true);
+    } else if (!this.dbPromise) {
+      this.dbPromise = this.reallyOpen();
+    }
+
+    return this.dbPromise;
+  },
+
+  observe(subject, topic, data) {
+    if (topic === "startupcache-invalidate") {
+      this.dbPromise = this.reallyOpen(true).catch(e => {});
+    }
+  },
+};
+
+Services.obs.addObserver(StartupCache, "startupcache-invalidate", false);
+
+class CacheStore {
+  constructor(storeName) {
+    this.storeName = storeName;
+  }
+
+  async get(key, createFunc) {
+    let db;
+    let value;
+    try {
+      db = await StartupCache.open();
+
+      value = await db.objectStore(this.storeName)
+                      .get(key);
+    } catch (e) {
+      Cu.reportError(e);
+
+      return createFunc(key);
+    }
+
+    if (value === undefined) {
+      value = await createFunc(key);
+
+      db.objectStore(this.storeName, "readwrite")
+        .put(value, key);
+    }
+
+    return value;
+  }
+
+  async delete(key) {
+    let db = await StartupCache.open();
+
+    return db.objectStore(this.storeName, "readwrite").delete(key);
+  }
+}
+
+for (let name of StartupCache.STORE_NAMES) {
+  StartupCache[name] = new CacheStore(name);
+}
+
 /**
  * An Error subclass for which complete error messages are always passed
  * to extensions, rather than being interpreted as an unknown error.
  */
 class ExtensionError extends Error {}
 
 function filterStack(error) {
   return String(error.stack).replace(/(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, "<Promise Chain>\n");
@@ -1192,11 +1308,12 @@ this.ExtensionUtils = {
   EventEmitter,
   ExtensionError,
   IconDetails,
   LimitedSet,
   LocaleData,
   MessageManagerProxy,
   SingletonEventManager,
   SpreadArgs,
+  StartupCache,
 };
 
 XPCOMUtils.defineLazyGetter(this.ExtensionUtils, "PlatformInfo", PlatformInfo);
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -16,16 +16,17 @@ Cu.importGlobalProperties(["URL"]);
 Cu.import("resource://gre/modules/AppConstants.jsm");
 Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   DefaultMap,
+  StartupCache,
   instanceOf,
 } = ExtensionUtils;
 
 XPCOMUtils.defineLazyServiceGetter(this, "contentPolicyService",
                                    "@mozilla.org/addons/content-policy;1",
                                    "nsIAddonContentPolicy");
 
 this.EXPORTED_SYMBOLS = ["Schemas"];
@@ -2255,17 +2256,17 @@ this.Schemas = {
     for (let namespace of json) {
       this.getNamespace(namespace.namespace)
           .addSchema(namespace);
     }
   },
 
   load(url) {
     if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_CONTENT) {
-      return readJSON(url).then(json => {
+      return StartupCache.schemas.get(url, readJSON).then(json => {
         this.schemaJSON.set(url, json);
 
         let data = Services.ppmm.initialProcessData;
         data["Extension:Schemas"] = this.schemaJSON;
 
         Services.ppmm.broadcastAsyncMessage("Schema:Add", {url, schema: json});
 
         this.flushSchemas();
--- a/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js
@@ -22,16 +22,16 @@ add_task(function* test_json_parser() {
     "version": "0.1\\",
   };
 
   let fileURI = Services.io.newFileURI(xpi);
   let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`);
 
   let extension = new ExtensionData(uri);
 
-  yield extension.readManifest();
+  yield extension.parseManifest();
 
   Assert.deepEqual(extension.rawManifest, expectedManifest,
                    "Manifest with correctly-filtered comments");
 
   Services.obs.notifyObservers(xpi, "flush-cache-entry", null);
   xpi.remove(false);
 });
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js
@@ -0,0 +1,110 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org";
+
+function makeExtension(opts) {
+  return {
+    useAddonManager: "permanent",
+
+    manifest: {
+      "version": opts.version,
+      "applications": {"gecko": {"id": ADDON_ID}},
+
+      "name": "__MSG_name__",
+
+      "default_locale": "en_US",
+    },
+
+    files: {
+      "_locales/en_US/messages.json": {
+        name: {
+          message: `en-US ${opts.version}`,
+          description: "Name.",
+        },
+      },
+      "_locales/fr/messages.json": {
+        name: {
+          message: `fr ${opts.version}`,
+          description: "Name.",
+        },
+      },
+    },
+
+    background() {
+      browser.test.onMessage.addListener(msg => {
+        if (msg === "get-manifest") {
+          browser.test.sendMessage("manifest", browser.runtime.getManifest());
+        }
+      });
+    },
+  };
+}
+
+add_task(function* () {
+  Preferences.set("extensions.logging.enabled", false);
+  yield AddonTestUtils.promiseStartupManager();
+
+  let extension = ExtensionTestUtils.loadExtension(
+    makeExtension({version: "1.0"}));
+
+  function getManifest() {
+    extension.sendMessage("get-manifest");
+    return extension.awaitMessage("manifest");
+  }
+
+
+  yield extension.startup();
+
+  equal(extension.version, "1.0", "Expected extension version");
+  let manifest = yield getManifest();
+  equal(manifest.name, "en-US 1.0", "Got expected manifest name");
+
+
+  do_print("Restart and re-check");
+  yield AddonTestUtils.promiseRestartManager();
+  yield extension.awaitStartup();
+
+  equal(extension.version, "1.0", "Expected extension version");
+  manifest = yield getManifest();
+  equal(manifest.name, "en-US 1.0", "Got expected manifest name");
+
+
+  do_print("Change locale to 'fr' and restart");
+  Preferences.set("general.useragent.locale", "fr");
+  yield AddonTestUtils.promiseRestartManager();
+  yield extension.awaitStartup();
+
+  equal(extension.version, "1.0", "Expected extension version");
+  manifest = yield getManifest();
+  equal(manifest.name, "fr 1.0", "Got expected manifest name");
+
+
+  do_print("Update to version 1.1");
+  yield extension.upgrade(makeExtension({version: "1.1"}));
+
+  equal(extension.version, "1.1", "Expected extension version");
+  manifest = yield getManifest();
+  equal(manifest.name, "fr 1.1", "Got expected manifest name");
+
+
+  do_print("Change locale to 'en-US' and restart");
+  Preferences.set("general.useragent.locale", "en-US");
+  yield AddonTestUtils.promiseRestartManager();
+  yield extension.awaitStartup();
+
+  equal(extension.version, "1.1", "Expected extension version");
+  manifest = yield getManifest();
+  equal(manifest.name, "en-US 1.1", "Got expected manifest name");
+
+
+  yield extension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/test_locale_data.js
+++ b/toolkit/components/extensions/test/xpcshell/test_locale_data.js
@@ -17,17 +17,17 @@ function* generateAddon(data) {
     Services.obs.notifyObservers(xpi, "flush-cache-entry", null);
     xpi.remove(false);
   });
 
   let fileURI = Services.io.newFileURI(xpi);
   let jarURI = NetUtil.newURI(`jar:${fileURI.spec}!/webextension/`);
 
   let extension = new ExtensionData(jarURI);
-  yield extension.readManifest();
+  yield extension.loadManifest();
 
   return extension;
 }
 
 add_task(function* testMissingDefaultLocale() {
   let extension = yield generateAddon({
     "files": {
       "_locales/en_US/messages.json": {},
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -63,16 +63,17 @@ skip-if = true # This test no longer tes
 [test_ext_runtime_sendMessage_no_receiver.js]
 [test_ext_runtime_sendMessage_self.js]
 [test_ext_schemas.js]
 [test_ext_schemas_api_injection.js]
 [test_ext_schemas_async.js]
 [test_ext_schemas_allowed_contexts.js]
 [test_ext_shutdown_cleanup.js]
 [test_ext_simple.js]
+[test_ext_startup_cache.js]
 [test_ext_storage.js]
 [test_ext_storage_sync.js]
 head = head.js head_sync.js
 skip-if = os == "android"
 [test_ext_storage_sync_crypto.js]
 skip-if = os == "android"
 [test_ext_topSites.js]
 skip-if = os == "android"
--- a/toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
+++ b/toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
@@ -6,25 +6,25 @@
 
 /* exported startup, shutdown, install, uninstall */
 
 Components.utils.import("resource://gre/modules/Extension.jsm");
 
 var extension;
 
 const BOOTSTRAP_REASON_TO_STRING_MAP = {
-  1: "APP_STARTUP",
-  2: "APP_SHUTDOWN",
-  3: "ADDON_ENABLE",
-  4: "ADDON_DISABLE",
-  5: "ADDON_INSTALL",
-  6: "ADDON_UNINSTALL",
-  7: "ADDON_UPGRADE",
-  8: "ADDON_DOWNGRADE",
-}
+  [this.APP_STARTUP]: "APP_STARTUP",
+  [this.APP_SHUTDOWN]: "APP_SHUTDOWN",
+  [this.ADDON_ENABLE]: "ADDON_ENABLE",
+  [this.ADDON_DISABLE]: "ADDON_DISABLE",
+  [this.ADDON_INSTALL]: "ADDON_INSTALL",
+  [this.ADDON_UNINSTALL]: "ADDON_UNINSTALL",
+  [this.ADDON_UPGRADE]: "ADDON_UPGRADE",
+  [this.ADDON_DOWNGRADE]: "ADDON_DOWNGRADE",
+};
 
 function install(data, reason) {
 }
 
 function startup(data, reason) {
   extension = new Extension(data, BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
   extension.startup();
 }
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -958,17 +958,17 @@ function getRDFProperty(aDs, aResource, 
  */
 var loadManifestFromWebManifest = Task.async(function*(aUri) {
   // We're passed the URI for the manifest file. Get the URI for its
   // parent directory.
   let uri = NetUtil.newURI("./", null, aUri);
 
   let extension = new ExtensionData(uri);
 
-  let manifest = yield extension.readManifest();
+  let manifest = yield extension.loadManifest();
   let theme = !!manifest.theme;
 
   // Read the list of available locales, and pre-load messages for
   // all locales.
   let locales = yield extension.initAllLocales();
 
   // If there were any errors loading the extension, bail out now.
   if (extension.errors.length)
--- a/toolkit/mozapps/extensions/test/browser/browser_webext_options.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_webext_options.js
@@ -41,30 +41,49 @@ function* runTest(installer) {
   button = mgrWindow.document.getElementById("detail-prefs-btn");
   is_element_hidden(button, "Preferences button should not be visible");
 
   yield close_manager(mgrWindow);
 
   addon.uninstall();
 }
 
+function promiseWebExtensionStartup() {
+  const {Management} = Components.utils.import("resource://gre/modules/Extension.jsm", {});
+
+  return new Promise(resolve => {
+    let listener = (event, extension) => {
+      Management.off("startup", listener);
+      resolve(extension);
+    };
+
+    Management.on("startup", listener);
+  });
+}
+
 // Test that deferred handling of optionsURL works for a signed webextension
 add_task(function* test_options_signed() {
   yield* runTest(function*() {
     // The extension in-tree is signed with this ID:
     const ID = "{9792932b-32b2-4567-998c-e7bf6c4c5e35}";
 
-    yield install_addon("addons/options_signed.xpi");
+    yield Promise.all([
+      promiseWebExtensionStartup(),
+      install_addon("addons/options_signed.xpi"),
+    ]);
     let addon = yield promiseAddonByID(ID);
 
     return {addon, id: ID};
   });
 });
 
 add_task(function* test_options_temporary() {
   yield* runTest(function*() {
     let dir = get_addon_file_url("options_signed").file;
-    let addon = yield AddonManager.installTemporaryAddon(dir);
+    let [addon] = yield Promise.all([
+      AddonManager.installTemporaryAddon(dir),
+      promiseWebExtensionStartup(),
+    ]);
     isnot(addon, null, "Extension is installed (temporarily)");
 
     return {addon, id: addon.id};
   });
 });
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -1068,17 +1068,17 @@ function promiseWebExtensionStartup() {
 }
 
 function promiseInstallWebExtension(aData) {
   let addonFile = createTempWebExtensionFile(aData);
 
   return promiseInstallAllFiles([addonFile]).then(installs => {
     Services.obs.notifyObservers(addonFile, "flush-cache-entry", null);
     // Since themes are disabled by default, it won't start up.
-    if ("theme" in aData.manifest)
+    if (aData.manifest.theme)
       return installs[0].addon;
     return promiseWebExtensionStartup();
   });
 }
 
 // By default use strict compatibility
 Services.prefs.setBoolPref("extensions.strictCompatibility", true);
 
--- a/toolkit/mozapps/extensions/test/xpcshell/test_reload.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_reload.js
@@ -75,17 +75,20 @@ add_task(function* test_reloading_a_temp
         // This should be the last event called.
         AddonManager.removeAddonListener(listener);
         resolve();
       },
     }
     AddonManager.addAddonListener(listener);
   });
 
-  yield addon.reload();
+  yield Promise.all([
+    addon.reload(),
+    promiseAddonStartup(),
+  ]);
   yield onReload;
 
   // Make sure reload() doesn't trigger uninstall events.
   equal(receivedOnUninstalled, false, "reload should not trigger onUninstalled");
   equal(receivedOnUninstalling, false, "reload should not trigger onUninstalling");
 
   // Make sure reload() triggers install events, like an upgrade.
   equal(receivedOnInstalling, true, "reload should trigger onInstalling");
@@ -106,17 +109,20 @@ add_task(function* test_can_reload_perma
       disabledCalled = true
     },
     onEnabled: (aAddon) => {
       do_check_true(disabledCalled);
       enabledCalled = true
     }
   })
 
-  yield addon.reload();
+  yield Promise.all([
+    addon.reload(),
+    promiseAddonStartup(),
+  ]);
 
   do_check_true(disabledCalled);
   do_check_true(enabledCalled);
 
   notEqual(addon, null);
   equal(addon.appDisabled, false);
   equal(addon.userDisabled, false);
 
--- a/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
@@ -31,21 +31,24 @@ function mapManifest(aPath, aManifestDat
 
 function serveManifest(request, response) {
   let manifest = gUpdateManifests[request.path];
 
   response.setHeader("Content-Type", manifest.contentType, false);
   response.write(manifest.data);
 }
 
-
 function promiseInstallWebExtension(aData) {
   let addonFile = createTempWebExtensionFile(aData);
 
+  let startupPromise = promiseWebExtensionStartup();
+
   return promiseInstallAllFiles([addonFile]).then(() => {
+    return startupPromise;
+  }).then(() => {
     Services.obs.notifyObservers(addonFile, "flush-cache-entry", null);
     return promiseAddonByID(aData.id);
   });
 }
 
 var checkUpdates = Task.async(function* (aData, aReason = AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) {
   function provide(obj, path, value) {
     path = path.split(".");
@@ -170,17 +173,20 @@ add_task(function* checkUpdateToWebExt()
     }
   });
 
   ok(!update.compatibilityUpdate, "have no compat update");
   ok(update.updateAvailable, "have add-on update");
 
   equal(update.addon.version, "1.0", "add-on version");
 
-  yield promiseCompleteAllInstalls([update.updateAvailable]);
+  yield Promise.all([
+    promiseCompleteAllInstalls([update.updateAvailable]),
+    promiseWebExtensionStartup(),
+  ]);
 
   let addon = yield promiseAddonByID(update.addon.id);
   equal(addon.version, "1.2", "new add-on version");
 
   addon.uninstall();
 });
 
 
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
@@ -27,17 +27,20 @@ add_task(function* test_implicit_id() {
   // This test needs to read the xpi certificate which only works
   // if signing is enabled.
   ok(ADDON_SIGNING, "Add-on signing is enabled");
 
   let addon = yield promiseAddonByID(IMPLICIT_ID_ID);
   equal(addon, null, "Add-on is not installed");
 
   let xpifile = do_get_file(IMPLICIT_ID_XPI);
-  yield promiseInstallAllFiles([xpifile]);
+  yield Promise.all([
+    promiseInstallAllFiles([xpifile]),
+    promiseWebExtensionStartup(),
+  ]);
 
   addon = yield promiseAddonByID(IMPLICIT_ID_ID);
   notEqual(addon, null, "Add-on is installed");
 
   addon.uninstall();
 });
 
 // We should also be able to install webext-implicit-id.xpi temporarily
@@ -47,17 +50,20 @@ add_task(function* test_implicit_id_temp
   // This test needs to read the xpi certificate which only works
   // if signing is enabled.
   ok(ADDON_SIGNING, "Add-on signing is enabled");
 
   let addon = yield promiseAddonByID(IMPLICIT_ID_ID);
   equal(addon, null, "Add-on is not installed");
 
   let xpifile = do_get_file(IMPLICIT_ID_XPI);
-  yield AddonManager.installTemporaryAddon(xpifile);
+  yield Promise.all([
+    AddonManager.installTemporaryAddon(xpifile),
+    promiseWebExtensionStartup(),
+  ]);
 
   addon = yield promiseAddonByID(IMPLICIT_ID_ID);
   notEqual(addon, null, "Add-on is installed");
 
   // The sourceURI of a temporary installed addon should be equal to the
   // file url of the installed xpi file.
   equal(addon.sourceURI && addon.sourceURI.spec,
         Services.io.newFileURI(xpifile).spec,
@@ -75,29 +81,36 @@ add_task(function* test_unsigned_no_id_t
     description: "extension without an ID",
     manifest_version: 2,
     version: "1.0"
   };
 
   const addonDir = yield promiseWriteWebManifestForExtension(manifest, gTmpD,
                                                 "the-addon-sub-dir");
   const testDate = new Date();
-  const addon = yield AddonManager.installTemporaryAddon(addonDir);
+  const [addon] = yield Promise.all([
+    AddonManager.installTemporaryAddon(addonDir),
+    promiseWebExtensionStartup(),
+  ]);
+
   ok(addon.id, "ID should have been auto-generated");
   ok(Math.abs(addon.installDate - testDate) < 10000, "addon has an expected installDate");
   ok(Math.abs(addon.updateDate - testDate) < 10000, "addon has an expected updateDate");
 
   // The sourceURI of a temporary installed addon should be equal to the
   // file url of the installed source dir.
   equal(addon.sourceURI && addon.sourceURI.spec,
         Services.io.newFileURI(addonDir).spec,
         "SourceURI of the add-on has the expected value");
 
   // Install the same directory again, as if re-installing or reloading.
-  const secondAddon = yield AddonManager.installTemporaryAddon(addonDir);
+  const [secondAddon] = yield Promise.all([
+    AddonManager.installTemporaryAddon(addonDir),
+    promiseWebExtensionStartup(),
+  ]);
   // The IDs should be the same.
   equal(secondAddon.id, addon.id, "Reinstalled add-on has the expected ID");
 
   secondAddon.uninstall();
   Services.obs.notifyObservers(addonDir, "flush-cache-entry", null);
   addonDir.remove(true);
   AddonTestUtils.useRealCertChecks = false;
 });
@@ -475,17 +488,17 @@ add_task(function* test_strict_min_max()
   notEqual(addon, null, "Add-on is installed");
   equal(addon.id, newId, "Installed add-on has the expected ID");
 
   yield extension.unload();
   AddonManager.checkCompatibility = savedCheckCompatibilityValue;
 });
 
 // Check permissions prompt
-add_task(function* test_permissions() {
+add_task(function* test_permissions_prompt() {
   const manifest = {
     name: "permissions test",
     description: "permissions test",
     manifest_version: 2,
     version: "1.0",
 
     permissions: ["tabs", "storage", "https://*.example.com/*", "<all_urls>", "experiments.test"],
   };
@@ -513,17 +526,17 @@ add_task(function* test_permissions() {
   let addon = yield promiseAddonByID(perminfo.addon.id);
   notEqual(addon, null, "Extension was installed");
 
   addon.uninstall();
   yield OS.File.remove(xpi.path);
 });
 
 // Check permissions prompt cancellation
-add_task(function* test_permissions() {
+add_task(function* test_permissions_prompt_cancel() {
   const manifest = {
     name: "permissions test",
     description: "permissions test",
     manifest_version: 2,
     version: "1.0",
 
     permissions: ["webRequestBlocking"],
   };