Bug 1231172 - provide API for add-ons to delay restartless updates r?aswan draft
authorRobert Helmer <rhelmer@mozilla.com>
Thu, 24 Mar 2016 10:48:03 -0700
changeset 376022 8cc4c854500241435fd898783a83209eaa3f9395
parent 375974 3c68dbe24474f01d74e2b520b674d43f0a1f5913
child 523047 61dcd90c5cd6965b4cb2e9a8b5775b3e22c0e7b0
push id20467
push userrhelmer@mozilla.com
push dateTue, 07 Jun 2016 07:03:24 +0000
reviewersaswan
bugs1231172
milestone49.0a1
Bug 1231172 - provide API for add-ons to delay restartless updates r?aswan MozReview-Commit-ID: A9XGqLu4JGo
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/addons/test_delay_update_complete_v2/bootstrap.js
toolkit/mozapps/extensions/test/addons/test_delay_update_complete_v2/install.rdf
toolkit/mozapps/extensions/test/addons/test_delay_update_defer_v2/bootstrap.js
toolkit/mozapps/extensions/test/addons/test_delay_update_defer_v2/install.rdf
toolkit/mozapps/extensions/test/addons/test_delay_update_ignore_v2/bootstrap.js
toolkit/mozapps/extensions/test/addons/test_delay_update_ignore_v2/install.rdf
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_update_complete/bootstrap.js
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_update_defer/bootstrap.js
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_update_ignore/bootstrap.js
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.rdf
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.rdf
toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.rdf
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
toolkit/mozapps/extensions/test/xpcshell/test_delay_update.js
toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -674,16 +674,17 @@ var AddonManagerInternal = {
   typeListeners: [],
   pendingProviders: new Set(),
   providers: new Set(),
   providerShutdowns: new Map(),
   types: {},
   startupChanges: {},
   // Store telemetry details per addon provider
   telemetryDetails: {},
+  upgradeListeners: new Map(),
 
   recordTimestamp: function(name, value) {
     this.TelemetryTimestamps.add(name, value);
   },
 
   validateBlocklist: function() {
     let appBlocklist = FileUtils.getFile(KEY_APPDIR, [FILE_BLOCKLIST]);
 
@@ -1457,17 +1458,17 @@ var AddonManagerInternal = {
               onUpdateAvailable: function(aAddon, aInstall) {
                 // Start installing updates when the add-on can be updated and
                 // background updates should be applied.
                 logger.debug("Found update for add-on ${id}", aAddon);
                 if (aAddon.permissions & AddonManager.PERM_CAN_UPGRADE &&
                     AddonManager.shouldAutoUpdate(aAddon)) {
                   // XXX we really should resolve when this install is done,
                   // not when update-available check completes, no?
-                  logger.debug("Starting install of ${id}", aAddon);
+                  logger.debug(`Starting upgrade install of ${aAddon.id}`);
                   aInstall.install();
                 }
               },
 
               onUpdateFinished: aAddon => { logger.debug("onUpdateFinished for ${id}", aAddon); resolve(); }
             }, AddonManager.UPDATE_WHEN_PERIODIC_UPDATE);
           }));
         }
@@ -2243,16 +2244,66 @@ var AddonManagerInternal = {
     let pos = 0;
     while (pos < this.installListeners.length) {
       if (this.installListeners[pos] == aListener)
         this.installListeners.splice(pos, 1);
       else
         pos++;
     }
   },
+  /*
+   * Adds new or overrides existing UpgradeListener.
+   *
+   * @param  aInstanceID
+   *         The instance ID of an addon to listen to register a listener for.
+   * @param  aCallback
+   *         The callback to invoke when updates are available for this addon.
+   * @throws if there is no addon matching the instanceID
+   */
+  addUpgradeListener: function(aInstanceID, aCallback) {
+   if (!aInstanceID || typeof aInstanceID != "symbol")
+     throw Components.Exception("aInstanceID must be a symbol",
+                                Cr.NS_ERROR_INVALID_ARG);
+
+  if (!aCallback || typeof aCallback != "function")
+    throw Components.Exception("aCallback must be a function",
+                               Cr.NS_ERROR_INVALID_ARG);
+
+   this.getAddonByInstanceID(aInstanceID).then(wrapper => {
+     if (!wrapper) {
+       throw Error("No addon matching instanceID:", aInstanceID.toString());
+     }
+     let addonId = wrapper.addonId();
+     logger.debug(`Registering upgrade listener for ${addonId}`)
+     this.upgradeListeners.set(addonId, aCallback);
+   });
+  },
+
+  /**
+   * Removes an UpgradeListener if the listener is registered.
+   *
+   * @param  aInstanceID
+   *         The instance ID of the addon to remove
+   */
+  removeUpgradeListener: function(aInstanceID) {
+    if (!aInstanceID || typeof aInstanceID != "symbol")
+      throw Components.Exception("aInstanceID must be a symbol",
+                                 Cr.NS_ERROR_INVALID_ARG);
+
+    this.getAddonByInstanceID(aInstanceID).then(addon => {
+      if (!addon) {
+        throw Error("No addon for instanceID:", aInstanceID.toString());
+      }
+      if (this.upgradeListeners.has(addon.id)) {
+        this.upgradeListeners.delete(addon.id);
+      } else {
+        throw Error("No upgrade listener registered for addon ID:", addon.id);
+      }
+    });
+  },
 
   /**
    * Installs a temporary add-on from a local file or directory.
    * @param  aFile
    *         An nsIFile for the file or directory of the add-on to be
    *         temporarily installed.
    * @return a Promise that rejects if the add-on is not a valid restartless
    *         add-on or if the same ID is already temporarily installed.
@@ -3055,16 +3106,24 @@ this.AddonManagerPrivate = {
     if ("onUpdateFinished" in listener) {
       safeCall(listener.onUpdateFinished.bind(listener), addon);
     }
   },
 
   get webExtensionsMinPlatformVersion() {
     return gWebExtensionsMinPlatformVersion;
   },
+
+  hasUpgradeListener: function(aId) {
+    return AddonManagerInternal.upgradeListeners.has(aId);
+  },
+
+  getUpgradeListener: function(aId) {
+    return AddonManagerInternal.upgradeListeners.get(aId);
+  },
 };
 
 /**
  * This is the public API that UI and developers should be calling. All methods
  * just forward to AddonManagerInternal.
  */
 this.AddonManager = {
   // Constants for the AddonInstall.state property
@@ -3075,24 +3134,26 @@ this.AddonManager = {
     // The install is being downloaded.
     ["STATE_DOWNLOADING",  1],
     // The install is checking for compatibility information.
     ["STATE_CHECKING", 2],
     // The install is downloaded and ready to install.
     ["STATE_DOWNLOADED", 3],
     // The download failed.
     ["STATE_DOWNLOAD_FAILED", 4],
+    // The install has been postponed.
+    ["STATE_POSTPONED", 5],
     // The add-on is being installed.
-    ["STATE_INSTALLING", 5],
+    ["STATE_INSTALLING", 6],
     // The add-on has been installed.
-    ["STATE_INSTALLED", 6],
+    ["STATE_INSTALLED", 7],
     // The install failed.
-    ["STATE_INSTALL_FAILED", 7],
+    ["STATE_INSTALL_FAILED", 8],
     // The install has been cancelled.
-    ["STATE_CANCELLED", 8],
+    ["STATE_CANCELLED", 9],
   ]),
 
   // Constants representing different types of errors while downloading an
   // add-on.
   // These will show up as AddonManager.ERROR_* (eg, ERROR_NETWORK_FAILURE)
   _errors: new Map([
     // The download failed due to network problems.
     ["ERROR_NETWORK_FAILURE", -1],
@@ -3415,16 +3476,28 @@ this.AddonManager = {
   addInstallListener: function(aListener) {
     AddonManagerInternal.addInstallListener(aListener);
   },
 
   removeInstallListener: function(aListener) {
     AddonManagerInternal.removeInstallListener(aListener);
   },
 
+  getUpgradeListener: function(aId) {
+    return AddonManagerInternal.upgradeListeners.get(aId);
+  },
+
+  addUpgradeListener: function(aInstanceID, aCallback) {
+    AddonManagerInternal.addUpgradeListener(aInstanceID, aCallback);
+  },
+
+  removeUpgradeListener: function(aInstanceID) {
+    AddonManagerInternal.removeUpgradeListener(aInstanceID);
+  },
+
   addAddonListener: function(aListener) {
     AddonManagerInternal.addAddonListener(aListener);
   },
 
   removeAddonListener: function(aListener) {
     AddonManagerInternal.removeAddonListener(aListener);
   },
 
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -5363,16 +5363,19 @@ AddonInstall.prototype = {
       this.state = AddonManager.STATE_AVAILABLE;
       this.error = 0;
       this.progress = 0;
       this.maxProgress = -1;
       this.hash = this.originalHash;
       XPIProvider.installs.push(this);
       this.startDownload();
       break;
+    case AddonManager.STATE_POSTPONED:
+      logger.debug(`Postponing install of ${this.addon.id}`);
+      break;
     case AddonManager.STATE_DOWNLOADING:
     case AddonManager.STATE_CHECKING:
     case AddonManager.STATE_INSTALLING:
       // Installation is already running
       return;
     default:
       throw new Error("Cannot start installing from this state");
     }
@@ -5415,16 +5418,28 @@ AddonInstall.prototype = {
         this.existingAddon.pendingUpgrade = null;
       }
 
       AddonManagerPrivate.callAddonListeners("onOperationCancelled", this.addon.wrapper);
 
       AddonManagerPrivate.callInstallListeners("onInstallCancelled",
                                                this.listeners, this.wrapper);
       break;
+    case AddonManager.STATE_POSTPONED:
+      logger.debug(`Cancelling postponed install of ${this.addon.id}`);
+      this.state = AddonManager.STATE_CANCELLED;
+      XPIProvider.removeActiveInstall(this);
+      AddonManagerPrivate.callInstallListeners("onInstallCancelled",
+                                               this.listeners, this.wrapper);
+      this.removeTemporaryFile();
+
+      let stagingDir = this.installLocation.getStagingDir();
+      let stagedAddon = stagingDir.clone();
+
+      this.unstageInstall(stagedAddon);
     default:
       throw new Error("Cannot cancel install of " + this.sourceURI.spec +
                       " from this state (" + this.state + ")");
     }
   },
 
   /**
    * Adds an InstallListener for this instance if the listener is not already
@@ -6005,22 +6020,68 @@ AddonInstall.prototype = {
 
       if (AddonManagerPrivate.callInstallListeners("onDownloadEnded",
                                                    this.listeners,
                                                    this.wrapper)) {
         // If a listener changed our state then do not proceed with the install
         if (this.state != AddonManager.STATE_DOWNLOADED)
           return;
 
-        this.install();
-
-        if (this.linkedInstalls) {
-          for (let install of this.linkedInstalls) {
-            if (install.state == AddonManager.STATE_DOWNLOADED)
-              install.install();
+        // If an upgrade listener is registered for this add-on, pass control
+        // over the upgrade to the add-on.
+        if (AddonManagerPrivate.hasUpgradeListener(this.addon.id)) {
+          logger.info(`${this.addon.id} has an upgrade listener, postponing until restart`);
+          this.state = AddonManager.STATE_POSTPONED;
+
+          let stagingDir = this.installLocation.getStagingDir();
+          let stagedAddon = stagingDir.clone();
+
+          Task.spawn((function*() {
+            yield this.installLocation.requestStagingDir();
+
+            yield this.unstageInstall(stagedAddon);
+
+            stagedAddon.append(this.addon.id);
+            stagedAddon.leafName = this.addon.id + ".xpi";
+
+            yield this.stageInstall(true, stagedAddon, true);
+
+            AddonManagerPrivate.callInstallListeners("onInstallPostponed",
+                                                     this.listeners, this.wrapper)
+
+            // upgrade has been staged for restart, notify the add-on and give
+            // it a way to resume.
+            let callback = AddonManagerPrivate.getUpgradeListener(this.addon.id);
+            callback({
+              install: () => {
+                switch (this.state) {
+                  case AddonManager.STATE_INSTALLED:
+                    // this addon has already been installed, nothing to do
+                    logger.warn(`${this.addon.id} tried to resume postponed upgrade, but it's already installed`);
+                    break;
+                  case AddonManager.STATE_POSTPONED:
+                    logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
+                    this.state = AddonManager.STATE_DOWNLOADED;
+                    this.install();
+                    break;
+                  default:
+                    logger.warn(`${this.addon.id} cannot resume postponed upgrade from state (${this.state})`);
+                    break;
+                }
+              },
+            });
+          }).bind(this));
+        } else {
+          // no upgrade listener present, so proceed with normal install
+          this.install();
+          if (this.linkedInstalls) {
+            for (let install of this.linkedInstalls) {
+              if (install.state == AddonManager.STATE_DOWNLOADED)
+                install.install();
+            }
           }
         }
       }
     });
   },
 
   // TODO This relies on the assumption that we are always installing into the
   // highest priority install location so the resulting add-on will be visible
@@ -6059,72 +6120,29 @@ AddonInstall.prototype = {
                                            this.addon.wrapper,
                                            requiresRestart);
 
     let stagingDir = this.installLocation.getStagingDir();
     let stagedAddon = stagingDir.clone();
 
     Task.spawn((function*() {
       let installedUnpacked = 0;
+
       yield this.installLocation.requestStagingDir();
 
-      // Remove any staged items for this add-on
+      // remove any previously staged files
+      yield this.unstageInstall(stagedAddon);
+
       stagedAddon.append(this.addon.id);
-      yield removeAsync(stagedAddon);
       stagedAddon.leafName = this.addon.id + ".xpi";
-      yield removeAsync(stagedAddon);
-
-      // First stage the file regardless of whether restarting is necessary
-      if (this.addon.unpack || Preferences.get(PREF_XPI_UNPACK, false)) {
-        logger.debug("Addon " + this.addon.id + " will be installed as " +
-            "an unpacked directory");
-        stagedAddon.leafName = this.addon.id;
-        yield OS.File.makeDir(stagedAddon.path);
-        yield ZipUtils.extractFilesAsync(this.file, stagedAddon);
-        installedUnpacked = 1;
-      }
-      else {
-        logger.debug("Addon " + this.addon.id + " will be installed as " +
-            "a packed xpi");
-        stagedAddon.leafName = this.addon.id + ".xpi";
-        yield OS.File.copy(this.file.path, stagedAddon.path);
-      }
+
+      installedUnpacked = yield this.stageInstall(requiresRestart, stagedAddon, isUpgrade);
 
       if (requiresRestart) {
-        // Point the add-on to its extracted files as the xpi may get deleted
-        this.addon._sourceBundle = stagedAddon;
-
-        // Cache the AddonInternal as it may have updated compatibility info
-        let stagedJSON = stagedAddon.clone();
-        stagedJSON.leafName = this.addon.id + ".json";
-        if (stagedJSON.exists())
-          stagedJSON.remove(true);
-        let stream = Cc["@mozilla.org/network/file-output-stream;1"].
-                     createInstance(Ci.nsIFileOutputStream);
-        let converter = Cc["@mozilla.org/intl/converter-output-stream;1"].
-                        createInstance(Ci.nsIConverterOutputStream);
-
-        try {
-          stream.init(stagedJSON, FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE |
-                                  FileUtils.MODE_TRUNCATE, FileUtils.PERMS_FILE,
-                                 0);
-          converter.init(stream, "UTF-8", 0, 0x0000);
-          converter.writeString(JSON.stringify(this.addon));
-        }
-        finally {
-          converter.close();
-          stream.close();
-        }
-
-        logger.debug("Staged install of " + this.addon.id + " from " + this.sourceURI.spec + " ready; waiting for restart.");
         this.state = AddonManager.STATE_INSTALLED;
-        if (isUpgrade) {
-          delete this.existingAddon.pendingUpgrade;
-          this.existingAddon.pendingUpgrade = this.addon;
-        }
         AddonManagerPrivate.callInstallListeners("onInstallEnded",
                                                  this.listeners, this.wrapper,
                                                  this.addon.wrapper);
       }
       else {
         // The install is completed so it should be removed from the active list
         XPIProvider.removeActiveInstall(this);
 
@@ -6217,33 +6235,109 @@ AddonInstall.prototype = {
             // listeners because important cleanup hasn't been done yet
             XPIProvider.unloadBootstrapScope(this.addon.id);
           }
         }
         XPIProvider.setTelemetry(this.addon.id, "unpacked", installedUnpacked);
         recordAddonTelemetry(this.addon);
       }
     }).bind(this)).then(null, (e) => {
-      logger.warn("Failed to install " + this.file.path + " from " + this.sourceURI.spec, e);
+      logger.warn("Failed to install " + this.file.path + " from " + this.sourceURI.spec + " to " + stagedAddon.path, e);
       if (stagedAddon.exists())
         recursiveRemove(stagedAddon);
       this.state = AddonManager.STATE_INSTALL_FAILED;
       this.error = AddonManager.ERROR_FILE_ACCESS;
       XPIProvider.removeActiveInstall(this);
       AddonManagerPrivate.callAddonListeners("onOperationCancelled",
                                              this.addon.wrapper);
       AddonManagerPrivate.callInstallListeners("onInstallFailed",
                                                this.listeners,
                                                this.wrapper);
     }).then(() => {
       this.removeTemporaryFile();
       return this.installLocation.releaseStagingDir();
     });
   },
 
+  /**
+   * Stages an upgrade for next application restart.
+   */
+  stageInstall: function*(restartRequired, stagedAddon, isUpgrade) {
+    let stagedJSON = stagedAddon.clone();
+    stagedJSON.leafName = this.addon.id + ".json";
+
+    let installedUnpacked = 0;
+
+    // First stage the file regardless of whether restarting is necessary
+    if (this.addon.unpack || Preferences.get(PREF_XPI_UNPACK, false)) {
+      logger.debug("Addon " + this.addon.id + " will be installed as " +
+          "an unpacked directory");
+      stagedAddon.leafName = this.addon.id;
+      yield OS.File.makeDir(stagedAddon.path);
+      yield ZipUtils.extractFilesAsync(this.file, stagedAddon);
+      installedUnpacked = 1;
+    }
+    else {
+      logger.debug("Addon " + this.addon.id + " will be installed as " +
+          "a packed xpi");
+      stagedAddon.leafName = this.addon.id + ".xpi";
+      yield OS.File.copy(this.file.path, stagedAddon.path);
+    }
+
+    if (restartRequired) {
+      // Point the add-on to its extracted files as the xpi may get deleted
+      this.addon._sourceBundle = stagedAddon;
+
+      // Cache the AddonInternal as it may have updated compatibility info
+      let stream = Cc["@mozilla.org/network/file-output-stream;1"].
+                   createInstance(Ci.nsIFileOutputStream);
+      let converter = Cc["@mozilla.org/intl/converter-output-stream;1"].
+                      createInstance(Ci.nsIConverterOutputStream);
+
+      try {
+        stream.init(stagedJSON, FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE |
+                                FileUtils.MODE_TRUNCATE, FileUtils.PERMS_FILE,
+                               0);
+        converter.init(stream, "UTF-8", 0, 0x0000);
+        converter.writeString(JSON.stringify(this.addon));
+      }
+      finally {
+        converter.close();
+        stream.close();
+      }
+
+      logger.debug("Staged install of " + this.addon.id + " from " + this.sourceURI.spec + " ready; waiting for restart.");
+      if (isUpgrade) {
+        delete this.existingAddon.pendingUpgrade;
+        this.existingAddon.pendingUpgrade = this.addon;
+      }
+    }
+
+    return installedUnpacked;
+  },
+
+  /**
+   * Removes any previously staged upgrade.
+   */
+  unstageInstall: function*(stagedAddon) {
+    let stagedJSON = stagedAddon.clone();
+    let removedAddon = stagedAddon.clone();
+
+    stagedJSON.append(this.addon.id + ".json");
+
+    if (stagedJSON.exists()) {
+      stagedJSON.remove(true);
+    }
+
+    removedAddon.append(this.addon.id);
+    yield removeAsync(removedAddon);
+    removedAddon.leafName = this.addon.id + ".xpi";
+    yield removeAsync(removedAddon);
+  },
+
   getInterface: function(iid) {
     if (iid.equals(Ci.nsIAuthPrompt2)) {
       let win = this.window;
       if (!win && this.browser)
         win = this.browser.ownerDocument.defaultView;
 
       let factory = Cc["@mozilla.org/prompter;1"].
                     getService(Ci.nsIPromptFactory);
@@ -7398,16 +7492,20 @@ AddonWrapper.prototype = {
  */
 function PrivateWrapper(aAddon) {
   AddonWrapper.call(this, aAddon);
 }
 
 PrivateWrapper.prototype = Object.create(AddonWrapper.prototype);
 Object.assign(PrivateWrapper.prototype, {
 
+  addonId() {
+    return this.id;
+  },
+
   /**
    * Defines a global context to be used in the console
    * of the add-on debugging window.
    *
    * @param  global
    *         The object to set as global context. Must be a window object.
    */
   setDebugGlobal(global) {
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_complete_v2/bootstrap.js
@@ -0,0 +1,10 @@
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+const ADDON_ID = "test_delay_update_complete@tests.mozilla.org";
+
+function install(data, reason) {}
+
+function startup(data, reason) {}
+
+function shutdown(data, reason) {}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_complete_v2/install.rdf
@@ -0,0 +1,27 @@
+<?xml version="1.0"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:install-manifest">
+    <em:id>test_delay_update_complete@tests.mozilla.org</em:id>
+    <em:version>2.0</em:version>
+    <em:bootstrap>true</em:bootstrap>
+
+    <!-- Front End MetaData -->
+    <em:name>Test Delay Update Complete</em:name>
+    <em:description>Test Description</em:description>
+
+    <em:iconURL>chrome://foo/skin/icon.png</em:iconURL>
+    <em:aboutURL>chrome://foo/content/about.xul</em:aboutURL>
+    <em:optionsURL>chrome://foo/content/options.xul</em:optionsURL>
+
+    <em:targetApplication>
+      <Description>
+        <em:id>xpcshell@tests.mozilla.org</em:id>
+        <em:minVersion>1</em:minVersion>
+        <em:maxVersion>1</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+
+  </Description>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_defer_v2/bootstrap.js
@@ -0,0 +1,10 @@
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+const ADDON_ID = "test_delay_update_defer@tests.mozilla.org";
+
+function install(data, reason) {}
+
+function startup(data, reason) {}
+
+function shutdown(data, reason) {}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_defer_v2/install.rdf
@@ -0,0 +1,27 @@
+<?xml version="1.0"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:install-manifest">
+    <em:id>test_delay_update_defer@tests.mozilla.org</em:id>
+    <em:version>2.0</em:version>
+    <em:bootstrap>true</em:bootstrap>
+
+    <!-- Front End MetaData -->
+    <em:name>Test Delay Update Defer</em:name>
+    <em:description>Test Description</em:description>
+
+    <em:iconURL>chrome://foo/skin/icon.png</em:iconURL>
+    <em:aboutURL>chrome://foo/content/about.xul</em:aboutURL>
+    <em:optionsURL>chrome://foo/content/options.xul</em:optionsURL>
+
+    <em:targetApplication>
+      <Description>
+        <em:id>xpcshell@tests.mozilla.org</em:id>
+        <em:minVersion>1</em:minVersion>
+        <em:maxVersion>1</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+
+  </Description>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_ignore_v2/bootstrap.js
@@ -0,0 +1,8 @@
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+function install(data, reason) {}
+
+function startup(data, reason) {}
+
+function shutdown(data, reason) {}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_delay_update_ignore_v2/install.rdf
@@ -0,0 +1,28 @@
+<?xml version="1.0"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:install-manifest">
+    <em:id>test_delay_update_ignore@tests.mozilla.org</em:id>
+    <em:version>2.0</em:version>
+    <em:bootstrap>true</em:bootstrap>
+
+    <!-- Front End MetaData -->
+    <em:name>Test Delay Update Ignore</em:name>
+    <em:description>Test Description</em:description>
+
+    <em:iconURL>chrome://foo/skin/icon.png</em:iconURL>
+    <em:aboutURL>chrome://foo/content/about.xul</em:aboutURL>
+    <em:optionsURL>chrome://foo/content/options.xul</em:optionsURL>
+    <em:updateURL>http://localhost:4444/data/test_delay_updates_ignore.rdf</em:updateURL>
+
+    <em:targetApplication>
+      <Description>
+        <em:id>xpcshell@tests.mozilla.org</em:id>
+        <em:minVersion>1</em:minVersion>
+        <em:maxVersion>1</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+
+  </Description>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_update_complete/bootstrap.js
@@ -0,0 +1,24 @@
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+const ADDON_ID = "test_delay_update_complete@tests.mozilla.org";
+const INSTALL_COMPLETE_PREF = "bootstraptest.install_complete_done";
+
+function install(data, reason) {}
+
+// normally we would use BootstrapMonitor here, but we need a reference to
+// the symbol inside `XPIProvider.jsm`.
+function startup(data, reason) {
+  // apply update immediately
+  if (data.hasOwnProperty("instanceID") && data.instanceID) {
+    AddonManager.addUpgradeListener(data.instanceID, (upgrade) => {
+      upgrade.install();
+    });
+  } else {
+    throw Error("no instanceID passed to bootstrap startup");
+  }
+}
+
+function shutdown(data, reason) {}
+
+function uninstall(data, reason) {}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_update_defer/bootstrap.js
@@ -0,0 +1,34 @@
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+const ADDON_ID = "test_delay_update_complete@tests.mozilla.org";
+const INSTALL_COMPLETE_PREF = "bootstraptest.install_complete_done";
+
+// global reference to hold upgrade object
+let gUpgrade;
+
+function install(data, reason) {}
+
+// normally we would use BootstrapMonitor here, but we need a reference to
+// the symbol inside `XPIProvider.jsm`.
+function startup(data, reason) {
+  // do not apply update immediately, hold on to for later
+  if (data.hasOwnProperty("instanceID") && data.instanceID) {
+    AddonManager.addUpgradeListener(data.instanceID, (upgrade) => {
+      gUpgrade = upgrade;
+    });
+  } else {
+    throw Error("no instanceID passed to bootstrap startup");
+  }
+
+  // add a listener so the test can pass control back
+  AddonManager.addAddonListener({
+    onFakeEvent: () => {
+      gUpgrade.install();
+    }
+  })
+}
+
+function shutdown(data, reason) {}
+
+function uninstall(data, reason) {}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_update_ignore/bootstrap.js
@@ -0,0 +1,26 @@
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+const ADDON_ID = "test_delay_update_ignore@tests.mozilla.org";
+const TEST_IGNORE_PREF = "delaytest.ignore";
+
+function install(data, reason) {}
+
+// normally we would use BootstrapMonitor here, but we need a reference to
+// the symbol inside `XPIProvider.jsm`.
+function startup(data, reason) {
+  Services.prefs.setBoolPref(TEST_IGNORE_PREF, false);
+
+  // explicitly ignore update, will be queued for next restart
+  if (data.hasOwnProperty("instanceID") && data.instanceID) {
+    AddonManager.addUpgradeListener(data.instanceID, (upgrade) => {
+      Services.prefs.setBoolPref(TEST_IGNORE_PREF, true);
+    });
+  } else {
+    throw Error("no instanceID passed to bootstrap startup");
+  }
+}
+
+function shutdown(data, reason) {}
+
+function uninstall(data, reason) {}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.rdf
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:extension:test_delay_update_complete@tests.mozilla.org">
+    <em:updates>
+      <Seq>
+        <!-- app id compatible update available -->
+        <li>
+          <Description>
+            <em:version>2.0</em:version>
+            <em:targetApplication>
+              <Description>
+                <em:id>xpcshell@tests.mozilla.org</em:id>
+                <em:minVersion>1</em:minVersion>
+                <em:maxVersion>1</em:maxVersion>
+                <em:updateLink>http://localhost:%PORT%/addons/test_delay_update_complete_v2.xpi</em:updateLink>
+              </Description>
+            </em:targetApplication>
+          </Description>
+        </li>
+      </Seq>
+    </em:updates>
+  </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.rdf
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:extension:test_delay_update_defer@tests.mozilla.org">
+    <em:updates>
+      <Seq>
+        <!-- app id compatible update available -->
+        <li>
+          <Description>
+            <em:version>2.0</em:version>
+            <em:targetApplication>
+              <Description>
+                <em:id>xpcshell@tests.mozilla.org</em:id>
+                <em:minVersion>1</em:minVersion>
+                <em:maxVersion>1</em:maxVersion>
+                <em:updateLink>http://localhost:%PORT%/addons/test_delay_update_defer_v2.xpi</em:updateLink>
+              </Description>
+            </em:targetApplication>
+          </Description>
+        </li>
+      </Seq>
+    </em:updates>
+  </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.rdf
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:extension:test_delay_update_ignore@tests.mozilla.org">
+    <em:updates>
+      <Seq>
+        <!-- app id compatible update available -->
+        <li>
+          <Description>
+            <em:version>2.0</em:version>
+            <em:targetApplication>
+              <Description>
+                <em:id>xpcshell@tests.mozilla.org</em:id>
+                <em:minVersion>1</em:minVersion>
+                <em:maxVersion>1</em:maxVersion>
+                <em:updateLink>http://localhost:%PORT%/addons/test_delay_update_ignore_v2.xpi</em:updateLink>
+              </Description>
+            </em:targetApplication>
+          </Description>
+        </li>
+      </Seq>
+    </em:updates>
+  </Description>
+</RDF>
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -1685,17 +1685,18 @@ function completeAllInstalls(aInstalls, 
       do_execute_soon(aCallback);
   }
 
   let listener = {
     onDownloadFailed: installCompleted,
     onDownloadCancelled: installCompleted,
     onInstallFailed: installCompleted,
     onInstallCancelled: installCompleted,
-    onInstallEnded: installCompleted
+    onInstallEnded: installCompleted,
+    onInstallPostponed: installCompleted,
   };
 
   aInstalls.forEach(function(aInstall) {
     aInstall.addListener(listener);
     aInstall.install();
   });
 }
 
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_delay_update.js
@@ -0,0 +1,260 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that delaying an update works
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+Components.utils.import("resource://testing-common/httpd.js");
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const tempdir = gTmpD.clone();
+
+const IGNORE_ID = "test_delay_update_ignore@tests.mozilla.org";
+const COMPLETE_ID = "test_delay_update_complete@tests.mozilla.org";
+const DEFER_ID = "test_delay_update_defer@tests.mozilla.org";
+
+const TEST_IGNORE_PREF = "delaytest.ignore";
+
+// Note that we would normally use BootstrapMonitor but it currently requires
+// the objects in `data` to be serializable, and we need a real reference to the
+// `instanceID` symbol to test.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// Create and configure the HTTP server.
+let testserver = createHttpServer();
+gPort = testserver.identity.primaryPort;
+mapFile("/data/test_delay_updates_complete.rdf", testserver);
+mapFile("/data/test_delay_updates_ignore.rdf", testserver);
+mapFile("/data/test_delay_updates_defer.rdf", testserver);
+testserver.registerDirectory("/addons/", do_get_file("addons"));
+
+function* createIgnoreAddon() {
+  writeInstallRDFToDir({
+    id: IGNORE_ID,
+    version: "1.0",
+    bootstrap: true,
+    unpack: true,
+    updateURL: `http://localhost:${gPort}/data/test_delay_updates_ignore.rdf`,
+    targetApplications: [{
+      id: "xpcshell@tests.mozilla.org",
+      minVersion: "1",
+      maxVersion: "1"
+    }],
+    name: "Test Delay Update Ignore",
+  }, profileDir, IGNORE_ID, "bootstrap.js");
+
+  let unpacked_addon = profileDir.clone();
+  unpacked_addon.append(IGNORE_ID);
+  do_get_file("data/test_delay_update_ignore/bootstrap.js")
+    .copyTo(unpacked_addon, "bootstrap.js");
+}
+
+function* createCompleteAddon() {
+  writeInstallRDFToDir({
+    id: COMPLETE_ID,
+    version: "1.0",
+    bootstrap: true,
+    unpack: true,
+    updateURL: `http://localhost:${gPort}/data/test_delay_updates_complete.rdf`,
+    targetApplications: [{
+      id: "xpcshell@tests.mozilla.org",
+      minVersion: "1",
+      maxVersion: "1"
+    }],
+    name: "Test Delay Update Complete",
+  }, profileDir, COMPLETE_ID, "bootstrap.js");
+
+  let unpacked_addon = profileDir.clone();
+  unpacked_addon.append(COMPLETE_ID);
+  do_get_file("data/test_delay_update_complete/bootstrap.js")
+    .copyTo(unpacked_addon, "bootstrap.js");
+}
+
+function* createDeferAddon() {
+  writeInstallRDFToDir({
+    id: DEFER_ID,
+    version: "1.0",
+    bootstrap: true,
+    unpack: true,
+    updateURL: `http://localhost:${gPort}/data/test_delay_updates_defer.rdf`,
+    targetApplications: [{
+      id: "xpcshell@tests.mozilla.org",
+      minVersion: "1",
+      maxVersion: "1"
+    }],
+    name: "Test Delay Update Defer",
+  }, profileDir, DEFER_ID, "bootstrap.js");
+
+  let unpacked_addon = profileDir.clone();
+  unpacked_addon.append(DEFER_ID);
+  do_get_file("data/test_delay_update_defer/bootstrap.js")
+    .copyTo(unpacked_addon, "bootstrap.js");
+}
+
+// add-on registers upgrade listener, and ignores update.
+add_task(function*() {
+
+  yield createIgnoreAddon();
+
+  startupManager();
+
+  let addon = yield promiseAddonByID(IGNORE_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Test Delay Update Ignore");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  let update = yield promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+
+  yield promiseCompleteAllInstalls([install]);
+
+  do_check_eq(install.state, AddonManager.STATE_POSTPONED);
+
+  // addon upgrade has been delayed
+  let addon_postponed = yield promiseAddonByID(IGNORE_ID);
+  do_check_neq(addon_postponed, null);
+  do_check_eq(addon_postponed.version, "1.0");
+  do_check_eq(addon_postponed.name, "Test Delay Update Ignore");
+  do_check_true(addon_postponed.isCompatible);
+  do_check_false(addon_postponed.appDisabled);
+  do_check_true(addon_postponed.isActive);
+  do_check_eq(addon_postponed.type, "extension");
+  do_check_true(Services.prefs.getBoolPref(TEST_IGNORE_PREF));
+
+  // restarting allows upgrade to proceed
+  yield promiseRestartManager();
+
+  let addon_upgraded = yield promiseAddonByID(IGNORE_ID);
+  do_check_neq(addon_upgraded, null);
+  do_check_eq(addon_upgraded.version, "2.0");
+  do_check_eq(addon_upgraded.name, "Test Delay Update Ignore");
+  do_check_true(addon_upgraded.isCompatible);
+  do_check_false(addon_upgraded.appDisabled);
+  do_check_true(addon_upgraded.isActive);
+  do_check_eq(addon_upgraded.type, "extension");
+
+  yield shutdownManager();
+});
+
+// add-on registers upgrade listener, and allows update.
+add_task(function*() {
+
+  yield createCompleteAddon();
+
+  startupManager();
+
+  let addon = yield promiseAddonByID(COMPLETE_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Test Delay Update Complete");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  let update = yield promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+
+  yield promiseCompleteAllInstalls([install]);
+
+  // upgrade is initially postponed
+  let addon_postponed = yield promiseAddonByID(COMPLETE_ID);
+  do_check_neq(addon_postponed, null);
+  do_check_eq(addon_postponed.version, "1.0");
+  do_check_eq(addon_postponed.name, "Test Delay Update Complete");
+  do_check_true(addon_postponed.isCompatible);
+  do_check_false(addon_postponed.appDisabled);
+  do_check_true(addon_postponed.isActive);
+  do_check_eq(addon_postponed.type, "extension");
+
+  // addon upgrade has been allowed
+  let [addon_allowed] = yield promiseAddonEvent("onInstalled");
+  do_check_neq(addon_allowed, null);
+  do_check_eq(addon_allowed.version, "2.0");
+  do_check_eq(addon_allowed.name, "Test Delay Update Complete");
+  do_check_true(addon_allowed.isCompatible);
+  do_check_false(addon_allowed.appDisabled);
+  do_check_true(addon_allowed.isActive);
+  do_check_eq(addon_allowed.type, "extension");
+
+  // restarting changes nothing
+  yield promiseRestartManager();
+
+  let addon_upgraded = yield promiseAddonByID(COMPLETE_ID);
+  do_check_neq(addon_upgraded, null);
+  do_check_eq(addon_upgraded.version, "2.0");
+  do_check_eq(addon_upgraded.name, "Test Delay Update Complete");
+  do_check_true(addon_upgraded.isCompatible);
+  do_check_false(addon_upgraded.appDisabled);
+  do_check_true(addon_upgraded.isActive);
+  do_check_eq(addon_upgraded.type, "extension");
+
+  yield shutdownManager();
+});
+
+// add-on registers upgrade listener, initially defers update then allows upgrade
+add_task(function*() {
+
+  yield createDeferAddon();
+
+  startupManager();
+
+  let addon = yield promiseAddonByID(DEFER_ID);
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Test Delay Update Defer");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+
+  let update = yield promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+
+  yield promiseCompleteAllInstalls([install]);
+
+  // upgrade is initially postponed
+  let addon_postponed = yield promiseAddonByID(DEFER_ID);
+  do_check_neq(addon_postponed, null);
+  do_check_eq(addon_postponed.version, "1.0");
+  do_check_eq(addon_postponed.name, "Test Delay Update Defer");
+  do_check_true(addon_postponed.isCompatible);
+  do_check_false(addon_postponed.appDisabled);
+  do_check_true(addon_postponed.isActive);
+  do_check_eq(addon_postponed.type, "extension");
+
+  // add-on will not allow upgrade until fake event fires
+  AddonManagerPrivate.callAddonListeners("onFakeEvent");
+
+  // addon upgrade has been allowed
+  let [addon_allowed] = yield promiseAddonEvent("onInstalled");
+  do_check_neq(addon_allowed, null);
+  do_check_eq(addon_allowed.version, "2.0");
+  do_check_eq(addon_allowed.name, "Test Delay Update Defer");
+  do_check_true(addon_allowed.isCompatible);
+  do_check_false(addon_allowed.appDisabled);
+  do_check_true(addon_allowed.isActive);
+  do_check_eq(addon_allowed.type, "extension");
+
+  // restarting changes nothing
+  yield promiseRestartManager();
+
+  let addon_upgraded = yield promiseAddonByID(DEFER_ID);
+  do_check_neq(addon_upgraded, null);
+  do_check_eq(addon_upgraded.version, "2.0");
+  do_check_eq(addon_upgraded.name, "Test Delay Update Defer");
+  do_check_true(addon_upgraded.isCompatible);
+  do_check_false(addon_upgraded.appDisabled);
+  do_check_true(addon_upgraded.isActive);
+  do_check_eq(addon_upgraded.type, "extension");
+
+  yield shutdownManager();
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
@@ -6,26 +6,28 @@
 
 const IGNORE = ["getPreferredIconURL", "escapeAddonURI",
                 "shouldAutoUpdate", "getStartupChanges",
                 "addTypeListener", "removeTypeListener",
                 "addAddonListener", "removeAddonListener",
                 "addInstallListener", "removeInstallListener",
                 "addManagerListener", "removeManagerListener",
                 "mapURIToAddonID", "shutdown", "init",
-                "stateToString", "errorToString"];
+                "stateToString", "errorToString", "getUpgradeListener",
+                "addUpgradeListener", "removeUpgradeListener"];
 
 const IGNORE_PRIVATE = ["AddonAuthor", "AddonCompatibilityOverride",
                         "AddonScreenshot", "AddonType", "startup", "shutdown",
                         "registerProvider", "unregisterProvider",
                         "addStartupChange", "removeStartupChange",
                         "recordTimestamp", "recordSimpleMeasure",
                         "recordException", "getSimpleMeasures", "simpleTimer",
                         "setTelemetryDetails", "getTelemetryDetails",
-                        "callNoUpdateListeners", "backgroundUpdateTimerHandler"];
+                        "callNoUpdateListeners", "backgroundUpdateTimerHandler",
+                        "hasUpgradeListener", "getUpgradeListener"];
 
 function test_functions() {
   for (let prop in AddonManager) {
     if (IGNORE.indexOf(prop) != -1)
       continue;
     if (typeof AddonManager[prop] != "function")
       continue;
 
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -31,11 +31,12 @@ skip-if = appname != "firefox"
 [test_system_update.js]
 [test_system_reset.js]
 [test_XPIcancel.js]
 [test_XPIStates.js]
 [test_temporary.js]
 [test_proxies.js]
 [test_proxy.js]
 [test_pass_symbol.js]
+[test_delay_update.js]
 
 
 [include:xpcshell-shared.ini]