Bug 1525729 - Stop blocking extension startup on searchInitialized r=aswan
authorRob Wu <rob@robwu.nl>
Wed, 13 Mar 2019 18:48:57 +0000
changeset 521990 f28acbccbc62a92d3116b30f35afcd7203ff07d7
parent 521989 57a544b33b511811350a56cd2f4206c2a4dc155a
child 521991 9d33ee85f2d4e5f45d6c795c2be3c27ec31f2bae
push id10870
push usernbeleuzu@mozilla.com
push dateFri, 15 Mar 2019 20:00:07 +0000
treeherdermozilla-beta@c594aee5b7a4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan
bugs1525729
milestone67.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1525729 - Stop blocking extension startup on searchInitialized r=aswan Depends on D23311 Differential Revision: https://phabricator.services.mozilla.com/D19701
browser/components/extensions/parent/ext-browser.js
browser/components/extensions/parent/ext-chrome-settings-overrides.js
browser/components/extensions/test/browser/browser_ext_search.js
browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js
browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js
browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js
browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js
browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js
browser/components/extensions/test/xpcshell/xpcshell-common.ini
browser/components/preferences/in-content/tests/browser_extension_controlled.js
toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
--- a/browser/components/extensions/parent/ext-browser.js
+++ b/browser/components/extensions/parent/ext-browser.js
@@ -239,18 +239,18 @@ global.TabContext = class extends EventE
     windowTracker.removeListener("progress", this);
     windowTracker.removeListener("TabSelect", this);
     tabTracker.off("tab-adopted", this.tabAdopted);
   }
 };
 
 // This promise is used to wait for the search service to be initialized.
 // None of the code in the WebExtension modules requests that initialization.
-// It is assumed that it is started at some point. If tests start to fail
-// because this promise never resolves, that's likely the cause.
+// It is assumed that it is started at some point. That might never happen,
+// e.g. if the application shuts down before the search service initializes.
 XPCOMUtils.defineLazyGetter(global, "searchInitialized", () => {
   if (Services.search.isInitialized) {
     return Promise.resolve();
   }
   return ExtensionUtils.promiseObserved("browser-search-service", (_, data) => data == "init-complete");
 });
 
 class WindowTracker extends WindowTrackerBase {
--- a/browser/components/extensions/parent/ext-chrome-settings-overrides.js
+++ b/browser/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -74,16 +74,23 @@ async function handleInitialHomepagePopu
     if (currentUrl == homepageUrl && gBrowser.selectedTab == tab) {
       homepagePopup.open();
       return;
     }
   }
   homepagePopup.addObserver(extensionId);
 }
 
+// When an extension starts up, a search engine may asynchronously be
+// registered, without blocking the startup. When an extension is
+// uninstalled, we need to wait for this registration to finish
+// before running the uninstallation handler.
+// Map[extension id -> Promise]
+var pendingSearchSetupTasks = new Map();
+
 this.chrome_settings_overrides = class extends ExtensionAPI {
   static async processDefaultSearchSetting(action, id) {
     await ExtensionSettingsStore.initialize();
     let item = ExtensionSettingsStore.getSetting(DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME);
     if (!item) {
       return;
     }
     if (Services.search.defaultEngine.name != item.value &&
@@ -126,17 +133,21 @@ this.chrome_settings_overrides = class e
 
   static removeSearchSettings(id) {
     return Promise.all([
       this.processDefaultSearchSetting("removeSetting", id),
       this.removeEngine(id),
     ]);
   }
 
-  static onUninstall(id) {
+  static async onUninstall(id) {
+    let searchStartupPromise = pendingSearchSetupTasks.get(id);
+    if (searchStartupPromise) {
+      await searchStartupPromise;
+    }
     // Note: We do not have to deal with homepage here as it is managed by
     // the ExtensionPreferencesManager.
     return Promise.all([
       this.removeSearchSettings(id),
       homepagePopup.clearConfirmation(id),
     ]);
   }
 
@@ -185,78 +196,100 @@ this.chrome_settings_overrides = class e
         close: () => {
           if (extension.shutdownReason == "ADDON_DISABLE") {
             homepagePopup.clearConfirmation(extension.id);
           }
         },
       });
     }
     if (manifest.chrome_settings_overrides.search_provider) {
-      await searchInitialized;
-      extension.callOnClose({
-        close: () => {
-          if (extension.shutdownReason == "ADDON_DISABLE") {
-            chrome_settings_overrides.processDefaultSearchSetting("disable", extension.id);
-            chrome_settings_overrides.removeEngine(extension.id);
+      // Registering a search engine can potentially take a long while,
+      // or not complete at all (when searchInitialized is never resolved),
+      // so we are deliberately not awaiting the returned promise here.
+      let searchStartupPromise =
+        this.processSearchProviderManifestEntry().finally(() => {
+          if (pendingSearchSetupTasks.get(extension.id) === searchStartupPromise) {
+            pendingSearchSetupTasks.delete(extension.id);
           }
-        },
-      });
+        });
+
+      // Save the promise so we can await at onUninstall.
+      pendingSearchSetupTasks.set(extension.id, searchStartupPromise);
+    }
+  }
+
+  async processSearchProviderManifestEntry() {
+    await searchInitialized;
 
-      let searchProvider = manifest.chrome_settings_overrides.search_provider;
-      let engineName = searchProvider.name.trim();
-      if (searchProvider.is_default) {
-        let engine = Services.search.getEngineByName(engineName);
-        let defaultEngines = await Services.search.getDefaultEngines();
-        if (engine && defaultEngines.some(defaultEngine => defaultEngine.name == engineName)) {
-          // Needs to be called every time to handle reenabling, but
-          // only sets default for install or enable.
-          await this.setDefault(engineName);
-          // For built in search engines, we don't do anything further
-          return;
+    let {extension} = this;
+    if (!extension) {
+      Cu.reportError(`Extension shut down before search provider was registered`);
+      return;
+    }
+    extension.callOnClose({
+      close: () => {
+        if (extension.shutdownReason == "ADDON_DISABLE") {
+          chrome_settings_overrides.processDefaultSearchSetting("disable", extension.id);
+          chrome_settings_overrides.removeEngine(extension.id);
         }
+      },
+    });
+
+    let {manifest} = extension;
+    let searchProvider = manifest.chrome_settings_overrides.search_provider;
+    let engineName = searchProvider.name.trim();
+    if (searchProvider.is_default) {
+      let engine = Services.search.getEngineByName(engineName);
+      let defaultEngines = await Services.search.getDefaultEngines();
+      if (engine && defaultEngines.some(defaultEngine => defaultEngine.name == engineName)) {
+        // Needs to be called every time to handle reenabling, but
+        // only sets default for install or enable.
+        await this.setDefault(engineName);
+        // For built in search engines, we don't do anything further
+        return;
       }
-      await this.addSearchEngine();
-      if (searchProvider.is_default) {
-        if (extension.startupReason === "ADDON_INSTALL") {
-          // Don't ask if it already the current engine
-          let engine = Services.search.getEngineByName(engineName);
-          let defaultEngine = await Services.search.getDefault();
-          if (defaultEngine.name != engine.name) {
-            let subject = {
-              wrappedJSObject: {
-                // This is a hack because we don't have the browser of
-                // the actual install. This means the popup might show
-                // in a different window. Will be addressed in a followup bug.
-                browser: windowTracker.topWindow.gBrowser.selectedBrowser,
-                name: this.extension.name,
-                icon: this.extension.iconURL,
-                currentEngine: defaultEngine.name,
-                newEngine: engineName,
-                respond(allow) {
-                  if (allow) {
-                    ExtensionSettingsStore.addSetting(
-                      extension.id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME, engineName, () => defaultEngine.name);
-                    Services.search.defaultEngine = Services.search.getEngineByName(engineName);
-                  }
-                },
+    }
+    await this.addSearchEngine();
+    if (searchProvider.is_default) {
+      if (extension.startupReason === "ADDON_INSTALL") {
+        // Don't ask if it already the current engine
+        let engine = Services.search.getEngineByName(engineName);
+        let defaultEngine = await Services.search.getDefault();
+        if (defaultEngine.name != engine.name) {
+          let subject = {
+            wrappedJSObject: {
+              // This is a hack because we don't have the browser of
+              // the actual install. This means the popup might show
+              // in a different window. Will be addressed in a followup bug.
+              browser: windowTracker.topWindow.gBrowser.selectedBrowser,
+              name: this.extension.name,
+              icon: this.extension.iconURL,
+              currentEngine: defaultEngine.name,
+              newEngine: engineName,
+              respond(allow) {
+                if (allow) {
+                  ExtensionSettingsStore.addSetting(
+                    extension.id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME, engineName, () => defaultEngine.name);
+                  Services.search.defaultEngine = Services.search.getEngineByName(engineName);
+                }
               },
-            };
-            Services.obs.notifyObservers(subject, "webextension-defaultsearch-prompt");
-          }
-        } else {
-          // Needs to be called every time to handle reenabling, but
-          // only sets default for install or enable.
-          this.setDefault(engineName);
+            },
+          };
+          Services.obs.notifyObservers(subject, "webextension-defaultsearch-prompt");
         }
-      } else if (ExtensionSettingsStore.hasSetting(extension.id,
-                                                   DEFAULT_SEARCH_STORE_TYPE,
-                                                   DEFAULT_SEARCH_SETTING_NAME)) {
-        // is_default has been removed, but we still have a setting. Remove it.
-        chrome_settings_overrides.processDefaultSearchSetting("removeSetting", extension.id);
+      } else {
+        // Needs to be called every time to handle reenabling, but
+        // only sets default for install or enable.
+        this.setDefault(engineName);
       }
+    } else if (ExtensionSettingsStore.hasSetting(extension.id,
+                                                 DEFAULT_SEARCH_STORE_TYPE,
+                                                 DEFAULT_SEARCH_SETTING_NAME)) {
+      // is_default has been removed, but we still have a setting. Remove it.
+      chrome_settings_overrides.processDefaultSearchSetting("removeSetting", extension.id);
     }
   }
 
   async setDefault(engineName) {
     let {extension} = this;
     if (extension.startupReason === "ADDON_INSTALL") {
       let defaultEngine = await Services.search.getDefault();
       let item = await ExtensionSettingsStore.addSetting(
--- a/browser/components/extensions/test/browser/browser_ext_search.js
+++ b/browser/components/extensions/test/browser/browser_ext_search.js
@@ -1,15 +1,19 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
+const {AddonTestUtils} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
 const SEARCH_TERM = "test";
 const SEARCH_URL = "https://localhost/?q={searchTerms}";
 
+AddonTestUtils.initMochitest(this);
+
 add_task(async function test_search() {
   async function background(SEARCH_TERM) {
     function awaitSearchResult() {
       return new Promise(resolve => {
         async function listener(tabId, info, changedTab) {
           if (changedTab.url == "about:blank") {
             // Ignore events related to the initial tab open.
             return;
@@ -52,16 +56,17 @@ add_task(async function test_search() {
           "search_url": SEARCH_URL,
         },
       },
     },
     background: `(${background})("${SEARCH_TERM}")`,
     useAddonManager: "temporary",
   });
   await extension.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(extension);
 
   let addonEngines = await extension.awaitMessage("engines");
   let engines = (await Services.search.getEngines()).filter(engine => !engine.hidden);
   is(addonEngines.length, engines.length, "Engine lengths are the same.");
   let defaultEngine = addonEngines.filter(engine => engine.isDefault === true);
   is(defaultEngine.length, 1, "One default engine");
   is(defaultEngine[0].name, (await Services.search.getDefault()).name, "Default engine is correct");
 
--- a/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js
+++ b/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js
@@ -1,19 +1,23 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 
 "use strict";
 
 ChromeUtils.defineModuleGetter(this, "AddonManager",
                                "resource://gre/modules/AddonManager.jsm");
 
+const {AddonTestUtils} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
 const EXTENSION1_ID = "extension1@mozilla.com";
 const EXTENSION2_ID = "extension2@mozilla.com";
 
+AddonTestUtils.initMochitest(this);
+
 var defaultEngineName;
 
 async function restoreDefaultEngine() {
   let engine = Services.search.getEngineByName(defaultEngineName);
   await Services.search.setDefault(engine);
 }
 
 add_task(async function setup() {
@@ -32,16 +36,17 @@ add_task(async function test_extension_s
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   await ext1.unload();
 
   is((await Services.search.getDefault()).name, defaultEngineName, `Default engine is ${defaultEngineName}`);
 });
 
@@ -145,20 +150,22 @@ add_task(async function test_extension_s
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   await ext2.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext2);
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
 
   await ext2.unload();
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   await ext1.unload();
@@ -191,20 +198,22 @@ add_task(async function test_extension_s
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   await ext2.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext2);
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
 
   await ext1.unload();
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
 
   await ext2.unload();
@@ -224,16 +233,17 @@ add_task(async function test_user_changi
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   let engine = Services.search.getEngineByName("Twitter");
   await Services.search.setDefault(engine);
 
   await ext1.unload();
 
@@ -258,16 +268,17 @@ add_task(async function test_user_change
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   let engine = Services.search.getEngineByName("Twitter");
   await Services.search.setDefault(engine);
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
 
@@ -323,27 +334,29 @@ add_task(async function test_two_addons_
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID);
   let addon1 = await AddonManager.getAddonByID(EXTENSION1_ID);
   await addon1.disable();
   await disabledPromise;
 
   is((await Services.search.getDefault()).name, defaultEngineName, `Default engine is ${defaultEngineName}`);
 
   await ext2.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext2);
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
 
   let enabledPromise = awaitEvent("ready", EXTENSION1_ID);
   await addon1.enable();
   await enabledPromise;
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
@@ -391,20 +404,22 @@ add_task(async function test_two_addons_
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   await ext2.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext2);
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
 
   let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID);
   let addon1 = await AddonManager.getAddonByID(EXTENSION1_ID);
   await addon1.disable();
   await disabledPromise;
 
@@ -459,20 +474,22 @@ add_task(async function test_two_addons_
           "is_default": true,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   is((await Services.search.getDefault()).name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   await ext2.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext2);
 
   is((await Services.search.getDefault()).name, "Twitter", "Default engine is Twitter");
 
   let disabledPromise = awaitEvent("shutdown", EXTENSION2_ID);
   let addon2 = await AddonManager.getAddonByID(EXTENSION2_ID);
   await addon2.disable();
   await disabledPromise;
 
--- a/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js
@@ -60,16 +60,17 @@ add_task(async function test_overrides_u
   };
   let extension = ExtensionTestUtils.loadExtension(extensionInfo);
 
   let defaultHomepageURL = HomePage.get();
   let defaultEngineName = (await Services.search.getDefault()).name;
 
   let prefPromise = promisePrefChanged(HOMEPAGE_URI);
   await extension.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(extension);
   await prefPromise;
 
   equal(extension.version, "1.0", "The installed addon has the expected version.");
   ok(HomePage.get().endsWith(HOMEPAGE_URI),
      "Home page url is overridden by the extension.");
   equal((await Services.search.getDefault()).name,
         "DuckDuckGo",
         "Default engine is overridden by the extension");
--- a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js
@@ -37,16 +37,17 @@ add_task(async function test_extension_a
           "suggest_url": kSearchSuggestURL,
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   let engine = Services.search.getEngineByName("MozSearch");
   ok(engine, "Engine should exist.");
 
   let {baseURI} = ext1.extension;
   equal(engine.iconURI.spec, baseURI.resolve("foo.ico"), "icon path matches");
   let icons = engine.getIcons();
   equal(icons.length, 2, "both icons avialable");
@@ -78,16 +79,17 @@ add_task(async function test_extension_a
           "search_url": "https://example.com/?q={searchTerms}",
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   let engine = Services.search.getEngineByName("MozSearch");
   ok(engine, "Engine should exist.");
 
   await ext1.unload();
   await delay();
 
   engine = Services.search.getEngineByName("MozSearch");
@@ -111,16 +113,17 @@ add_task(async function test_upgrade_def
         },
       },
       "version": "0.1",
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   let engine = Services.search.getEngineByName("MozSearch");
   await Services.search.setDefault(engine);
   await Services.search.moveEngine(engine, 1);
 
   await ext1.upgrade({
     manifest: {
       "chrome_settings_overrides": {
@@ -134,16 +137,17 @@ add_task(async function test_upgrade_def
         "gecko": {
           "id": "testengine@mozilla.com",
         },
       },
       "version": "0.2",
     },
     useAddonManager: "temporary",
   });
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   engine = Services.search.getEngineByName("MozSearch");
   equal(Services.search.defaultEngine, engine, "Default engine should still be MozSearch");
   equal((await Services.search.getEngines()).map(e => e.name).indexOf(engine.name),
         1, "Engine is in position 1");
 
   await ext1.unload();
   await delay();
@@ -165,16 +169,17 @@ add_task(async function test_extension_p
           "suggest_url_post_params": "foo=bar&bar=foo",
         },
       },
     },
     useAddonManager: "temporary",
   });
 
   await ext1.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(ext1);
 
   let engine = Services.search.getEngineByName("MozSearch");
   ok(engine, "Engine should exist.");
 
   let url = engine.wrappedJSObject._getURLOfType("text/html");
   equal(url.method, "POST", "Search URLs method is POST");
 
   let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm);
--- a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js
@@ -55,16 +55,17 @@ add_task(async function test_extension_s
           "search_url": "https://example.com/?q={searchTerms}",
           "params": [...mozParams, ...params],
         },
       },
     },
     useAddonManager: "permanent",
   });
   await extension.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(extension);
   equal(extension.extension.isPrivileged, true, "extension is priviledged");
 
   let engine = Services.search.getEngineByName("MozParamsTest");
 
   let extraParams = [];
   for (let p of params) {
     if (p.condition == "pref") {
       extraParams.push(`${p.name}=good`);
@@ -108,14 +109,15 @@ add_task(async function test_extension_s
             {name: "q", value: "{searchTerms}"},
           ],
         },
       },
     },
     useAddonManager: "permanent",
   });
   await extension.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(extension);
   equal(extension.extension.isPrivileged, false, "extension is not priviledged");
   let engine = Services.search.getEngineByName("MozParamsTest");
   let expectedURL = engine.getSubmission("test", null, "contextmenu").uri.spec;
   equal(expectedURL, "https://example.com/?q=test", "engine cannot have conditional or pref params");
   await extension.unload();
 });
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js
@@ -0,0 +1,95 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+const {AddonTestUtils} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+// Lazily import ExtensionParent to allow AddonTestUtils.createAppInfo to
+// override Services.appinfo.
+ChromeUtils.defineModuleGetter(this, "ExtensionParent",
+                               "resource://gre/modules/ExtensionParent.jsm");
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+add_task(async function shutdown_during_search_provider_startup() {
+  await AddonTestUtils.promiseStartupManager();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: {
+      chrome_settings_overrides: {
+        search_provider: {
+          name: "dummy name",
+          search_url: "https://example.com/",
+        },
+      },
+    },
+  });
+
+  info("Starting up search extension");
+  await extension.startup();
+  let extStartPromise = AddonTestUtils.waitForSearchProviderStartup(extension, {
+    // Search provider registration is expected to be pending because the search
+    // service has not been initialized yet.
+    expectPending: true,
+  });
+
+  let initialized = false;
+  ExtensionParent.apiManager.global.searchInitialized.then(() => {
+    initialized = true;
+  });
+
+  await extension.addon.disable();
+
+  info("Extension managed to shut down despite the uninitialized search");
+  // Initialize search after extension shutdown to check that it does not cause
+  // any problems, and that the test can continue to test uninstall behavior.
+  Assert.ok(!initialized, "Search service should not have been initialized");
+
+  extension.addon.enable();
+  await extension.awaitStartup();
+
+  // Check that uninstall is blocked until the search registration at startup
+  // has finished. This registration only finished once the search service is
+  // initialized.
+  let uninstallingPromise = new Promise(resolve => {
+    let Management = ExtensionParent.apiManager;
+    Management.on("uninstall", function listener(eventName, {id}) {
+      Management.off("uninstall", listener);
+      Assert.equal(id, extension.id, "Expected extension");
+      resolve();
+    });
+  });
+
+  let extRestartPromise = AddonTestUtils.waitForSearchProviderStartup(extension, {
+    // Search provider registration is expected to be pending again,
+    // because the search service has still not been initialized yet.
+    expectPending: true,
+  });
+
+  let uninstalledPromise = extension.addon.uninstall();
+  let uninstalled = false;
+  uninstalledPromise.then(() => { uninstalled = true; });
+
+  await uninstallingPromise;
+  Assert.ok(!uninstalled, "Uninstall should not be finished yet");
+  Assert.ok(!initialized, "Search service should still be uninitialized");
+  await Services.search.init();
+  Assert.ok(initialized, "Search service should be initialized");
+
+  // After initializing the search service, the search provider registration
+  // promises should settle eventually.
+
+  // Despite the interrupted startup, the promise should still resolve without
+  // an error.
+  await extStartPromise;
+  // The extension that is still active. The promise should just resolve.
+  await extRestartPromise;
+
+  // After initializing the search service, uninstall should eventually finish.
+  await uninstalledPromise;
+
+  await AddonTestUtils.promiseShutdownManager();
+});
--- a/browser/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/browser/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -5,11 +5,12 @@
 [test_ext_browsingData_passwords.js]
 [test_ext_browsingData_settings.js]
 [test_ext_chrome_settings_overrides_update.js]
 [test_ext_distribution_popup.js]
 [test_ext_geckoProfiler_control.js]
 [test_ext_history.js]
 [test_ext_settings_overrides_search.js]
 [test_ext_settings_overrides_search_mozParam.js]
+[test_ext_settings_overrides_shutdown.js]
 [test_ext_url_overrides_newtab.js]
 [test_ext_url_overrides_newtab_update.js]
 
--- a/browser/components/preferences/in-content/tests/browser_extension_controlled.js
+++ b/browser/components/preferences/in-content/tests/browser_extension_controlled.js
@@ -4,16 +4,19 @@ const PROXY_PREF = "network.proxy.type";
 
 ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
                                "resource://gre/modules/ExtensionSettingsStore.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
                                    "@mozilla.org/browser/aboutnewtab-service;1",
                                    "nsIAboutNewTabService");
 XPCOMUtils.defineLazyPreferenceGetter(this, "proxyType", PROXY_PREF);
 
+const {AddonTestUtils} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+AddonTestUtils.initMochitest(this);
+
 const TEST_DIR = gTestPath.substr(0, gTestPath.lastIndexOf("/"));
 const CHROME_URL_ROOT = TEST_DIR + "/";
 const PERMISSIONS_URL = "chrome://browser/content/preferences/sitePermissions.xul";
 let sitePermissionsDialog;
 
 function getSupportsFile(path) {
   let cr = Cc["@mozilla.org/chrome/chrome-registry;1"]
     .getService(Ci.nsIChromeRegistry);
@@ -463,16 +466,17 @@ add_task(async function testExtensionCon
   // Install an extension that will set the default search engine.
   let originalExtension = ExtensionTestUtils.loadExtension({
     useAddonManager: "permanent",
     manifest: Object.assign({}, manifest, {version: "1.0"}),
   });
 
   let messageShown = waitForMessageShown("browserDefaultSearchExtensionContent");
   await originalExtension.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(originalExtension);
   await messageShown;
 
   let addon = await AddonManager.getAddonByID(extensionId);
   is(addon.version, "1.0", "The addon has the expected version.");
 
   // The default search engine has been set by the extension and the user is notified.
   let controlledLabel = controlledContent.querySelector("description");
   let extensionEngine = Services.search.defaultEngine;
@@ -507,16 +511,17 @@ add_task(async function testExtensionCon
   await waitForMessageHidden(controlledContent.id);
 
   // Update the extension and wait for "ready".
   let updatedExtension = ExtensionTestUtils.loadExtension({
     useAddonManager: "permanent",
     manifest: Object.assign({}, manifest, {version: "2.0"}),
   });
   await updatedExtension.startup();
+  await AddonTestUtils.waitForSearchProviderStartup(updatedExtension);
   addon = await AddonManager.getAddonByID(extensionId);
 
   // Verify the extension is updated and search engine didn't change.
   is(addon.version, "2.0", "The updated addon has the expected version");
   is(controlledContent.hidden, true, "The extension controlled row is hidden after update");
   is(initialEngine, Services.search.defaultEngine,
      "default search engine is still the initial engine after update");
 
--- a/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
@@ -1470,16 +1470,51 @@ var AddonTestUtils = {
           Management.off("ready", listener);
           resolve(extension);
         }
       });
     });
   },
 
   /**
+   * Wait until an extension with a search provider has been loaded.
+   * This should be called after the extension has started, but before shutdown.
+   *
+   * @param {object} extension
+   *        The return value of ExtensionTestUtils.loadExtension.
+   *        For browser tests, see mochitest/tests/SimpleTest/ExtensionTestUtils.js
+   *        For xpcshell tests, see toolkit/components/extensions/ExtensionXPCShellUtils.jsm
+   * @param {object} [options]
+   *        Optional options.
+   * @param {boolean} [options.expectPending = false]
+   *        Whether to expect the search provider to still be starting up.
+   */
+  async waitForSearchProviderStartup(extension, {expectPending = false} = {}) {
+    // In xpcshell tests, equal/ok are defined in the global scope.
+    let {equal, ok} = this.testScope;
+    if (!equal || !ok) {
+      // In mochitests, these are available via Assert.jsm.
+      let {Assert} = this.testScope;
+      equal = Assert.equal.bind(Assert);
+      ok = Assert.ok.bind(Assert);
+    }
+
+    equal(extension.state, "running", "Search provider extension should be running");
+    ok(extension.id, "Extension ID of search provider should be set");
+
+    // The map of promises from browser/components/extensions/parent/ext-chrome-settings-overrides.js
+    let {pendingSearchSetupTasks} = Management.global;
+    let searchStartupPromise = pendingSearchSetupTasks.get(extension.id);
+    if (expectPending) {
+      ok(searchStartupPromise, "Search provider registration should be in progress");
+    }
+    return searchStartupPromise;
+  },
+
+  /**
    * Initializes the URLPreloader, which is required in order to load
    * built_in_addons.json. This has the side-effect of setting
    * preferences which flip Cu.isInAutomation to true.
    */
   initializeURLPreloader() {
     Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, true);
     aomStartup.initializeURLPreloader();
   },