Bug 1409208 (part 2) - implement disconnect and sanitize functionality. r?eoger,kitcambridge draft
authorMark Hammond <mhammond@skippinet.com.au>
Fri, 27 Apr 2018 09:18:47 +1000
changeset 799194 000170d90a27303d571723c87befc585f57e70e5
parent 799156 88e8b0037e034b74059ef8f4c722505d254d5673
push id110961
push userbmo:markh@mozilla.com
push dateThu, 24 May 2018 05:28:59 +0000
reviewerseoger, kitcambridge
bugs1409208
milestone62.0a1
Bug 1409208 (part 2) - implement disconnect and sanitize functionality. r?eoger,kitcambridge MozReview-Commit-ID: 3Fqc6MiaQ4O
browser/components/preferences/in-content/sync.js
browser/components/preferences/in-content/syncDisconnect.js
browser/components/preferences/in-content/tests/browser.ini
browser/components/preferences/in-content/tests/browser_sync_sanitize.js
services/sync/modules/SyncDisconnect.jsm
services/sync/moz.build
services/sync/tests/unit/test_disconnect_shutdown.js
services/sync/tests/unit/xpcshell.ini
--- a/browser/components/preferences/in-content/sync.js
+++ b/browser/components/preferences/in-content/sync.js
@@ -7,18 +7,20 @@
 ChromeUtils.import("resource://services-sync/main.js");
 ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function() {
   return ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js", {});
 });
 
-ChromeUtils.defineModuleGetter(this, "UIState",
-  "resource://services-sync/UIState.jsm");
+XPCOMUtils.defineLazyModuleGetters(this, {
+  SyncDisconnect: "resource://services-sync/SyncDisconnect.jsm",
+  UIState: "resource://services-sync/UIState.jsm",
+});
 
 const FXA_PAGE_LOGGED_OUT = 0;
 const FXA_PAGE_LOGGED_IN = 1;
 
 // Indexes into the "login status" deck.
 // We are in a successful verified state - everything should work!
 const FXA_LOGIN_VERIFIED = 0;
 // We have logged in to an unverified account.
@@ -443,34 +445,30 @@ var gSyncPane = {
     };
 
     fxAccounts.resendVerificationEmail()
       .then(fxAccounts.getSignedInUser, onError)
       .then(onSuccess, onError);
   },
 
   unlinkFirefoxAccount(confirm) {
-    let doUnlink = () => {
-      fxAccounts.signOut().then(() => {
-        this.updateWeavePrefs();
-      });
-    };
     if (confirm) {
       gSubDialog.open("chrome://browser/content/preferences/in-content/syncDisconnect.xul",
                       "resizable=no", /* aFeatures */
                       null, /* aParams */
                       event => { /* aClosingCallback */
                         if (event.detail.button == "accept") {
-                          doUnlink();
+                          this.updateWeavePrefs();
                         }
                       });
-      return;
+    } else {
+      // no confirmation implies no data removal, so just disconnect - but
+      // we still disconnect via the SyncDisconnect module for consistency.
+      SyncDisconnect.disconnect().finally(() => this.updateWeavePrefs());
     }
-    // no confirmation implies no data removal, so just disconnect.
-    doUnlink();
   },
 
   _populateComputerName(value) {
     let textbox = document.getElementById("fxaSyncComputerName");
     if (!textbox.hasAttribute("placeholder")) {
       textbox.setAttribute("placeholder",
         Weave.Utils.getDefaultDeviceName());
     }
--- a/browser/components/preferences/in-content/syncDisconnect.js
+++ b/browser/components/preferences/in-content/syncDisconnect.js
@@ -1,30 +1,45 @@
 // This Source Code Form is subject to the terms of the Mozilla Public
 // License, v. 2.0. If a copy of the MPL was not distributed with this
 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  SyncDisconnect: "resource://services-sync/SyncDisconnect.jsm",
+});
+
 let gSyncDisconnectDialog = {
   init() {
+    if (SyncDisconnect.promiseDisconnectFinished) {
+      // There's a sanitization under way - just re-show our "waiting" state
+      // and close the dialog when it's complete.
+      this.waitForCompletion(SyncDisconnect.promiseDisconnectFinished);
+    }
   },
 
   // when either of the checkboxes are changed.
   onDeleteOptionChange() {
     let eitherChecked = document.getElementById("deleteRemoteSyncData").checked ||
                         document.getElementById("deleteRemoteOtherData").checked;
     let newTitle = eitherChecked ? "sync-disconnect-confirm-disconnect-delete" :
                                    "sync-disconnect-confirm-disconnect";
     let butDisconnect = document.getElementById("butDisconnect");
     document.l10n.setAttributes(butDisconnect, newTitle);
   },
 
   accept(event) {
-    // * Check the check-boxes
-    // * Start the disconnect and get the completion promise.
-    this.waitForCompletion(Promise.resolve());
+    let options = {
+      sanitizeSyncData: document.getElementById("deleteRemoteSyncData").checked,
+      sanitizeBrowserData: document.getElementById("deleteRemoteOtherData").checked,
+    };
+
+    // And do the santize.
+    this.waitForCompletion(SyncDisconnect.disconnect(options));
   },
 
   waitForCompletion(promiseComplete) {
     // Change the dialog to show we are waiting for completion.
     document.getElementById("deleteOptionsContent").hidden = true;
     document.getElementById("deletingContent").hidden = false;
 
     // And do the santize.
--- a/browser/components/preferences/in-content/tests/browser.ini
+++ b/browser/components/preferences/in-content/tests/browser.ini
@@ -80,14 +80,15 @@ run-if = nightly_build
 [browser_security-2.js]
 [browser_spotlight.js]
 [browser_site_login_exceptions.js]
 [browser_permissions_dialog.js]
 [browser_subdialogs.js]
 support-files =
   subdialog.xul
   subdialog2.xul
+[browser_sync_sanitize.js]
 [browser_telemetry.js]
 # Skip this test on Android as FHR and Telemetry are separate systems there.
 skip-if = !healthreport || !telemetry || (os == 'linux' && debug) || (os == 'android')
 [browser_containers_name_input.js]
 run-if = nightly_build # Containers is enabled only on Nightly
 [browser_fluent.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/in-content/tests/browser_sync_sanitize.js
@@ -0,0 +1,259 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+/* global sinon */
+
+"use strict";
+
+const {UIState} = ChromeUtils.import("resource://services-sync/UIState.jsm", {});
+const {Log} = ChromeUtils.import("resource://gre/modules/Log.jsm", {});
+const {AsyncShutdown} = ChromeUtils.import("resource://gre/modules/AsyncShutdown.jsm", {});
+
+const {SyncDisconnect, SyncDisconnectInternal} = ChromeUtils.import("resource://services-sync/SyncDisconnect.jsm", {});
+
+// Use sinon for mocking.
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
+registerCleanupFunction(() => {
+  delete window.sinon; // test fails with this reference left behind.
+});
+
+
+add_task(async function setup() {
+  // Sync start-up will interfere with our tests, don't let UIState send UI updates.
+  const origNotifyStateUpdated = UIState._internal.notifyStateUpdated;
+  UIState._internal.notifyStateUpdated = () => {};
+
+  const origGet = UIState.get;
+  UIState.get = () => { return { status: UIState.STATUS_SIGNED_IN, email: "foo@bar.com" }; };
+
+  // browser_sync_sanitize uses the sync log, so arrange for that to end up
+  // in the test output.
+  const log = Log.repository.getLogger("Sync");
+  const appender = new Log.DumpAppender();
+  log.addAppender(appender);
+  log.level = appender.level = Log.Level.Trace;
+
+  registerCleanupFunction(() => {
+    UIState._internal.notifyStateUpdated = origNotifyStateUpdated;
+    UIState.get = origGet;
+  });
+});
+
+add_task(async function testDisconnectUI() {
+  await runTestWithSanitizeDialog(async (win, sinon) => {
+    let doc = win.document;
+    let butDisconnect = doc.getElementById("butDisconnect");
+    let butDeleteSync = doc.getElementById("deleteRemoteSyncData");
+    let butDeleteOther = doc.getElementById("deleteRemoteOtherData");
+
+    // mock both sanitize functions and the fxa signout.
+    let spyBrowser = sinon.spy(SyncDisconnectInternal, "doSanitizeBrowserData");
+    let spySync = sinon.spy(SyncDisconnectInternal, "doSanitizeSyncData");
+    let spySignout = sinon.spy(SyncDisconnectInternal, "doSyncAndAccountDisconnect");
+
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect");
+
+    // Both checkboxes default to unchecked.
+    Assert.ok(!butDeleteSync.checked);
+    Assert.ok(!butDeleteOther.checked);
+
+    // Hitting either of the checkboxes should change the text on the disconnect button/
+    butDeleteSync.click();
+    Assert.ok(butDeleteSync.checked);
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect-delete");
+
+    butDeleteOther.click();
+    Assert.ok(butDeleteOther.checked);
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect-delete");
+
+    butDeleteSync.click();
+    Assert.ok(!butDeleteSync.checked);
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect-delete");
+
+    butDeleteOther.click();
+    Assert.ok(!butDeleteOther.checked);
+    // button text should be back to "just disconnect"
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect");
+
+    // Cancel the dialog - ensure it closes without sanitizing anything and
+    // without disconnecting FxA.
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    doc.getElementById("butCancel").click();
+
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+
+    Assert.equal(spyBrowser.callCount, 0, "should not have sanitized the browser");
+    Assert.equal(spySync.callCount, 0, "should not have sanitized Sync");
+    Assert.equal(spySignout.callCount, 0, "should not have signed out of FxA");
+  });
+});
+
+add_task(async function testDisconnectNoSanitize() {
+  await runTestWithSanitizeDialog(async (win, sinon) => {
+    let doc = win.document;
+    let butDisconnect = doc.getElementById("butDisconnect");
+
+    let spySignout = sinon.spy(SyncDisconnectInternal, "doSyncAndAccountDisconnect");
+    let spySync = sinon.spy(SyncDisconnectInternal, "doSanitizeSyncData");
+    let spyBrowser = sinon.spy(SyncDisconnectInternal, "doSanitizeBrowserData");
+
+    let Weave = {
+      Service: {
+        enabled: true,
+        lock: sinon.stub().returns(true),
+        unlock: sinon.spy(),
+        startOver: sinon.spy(),
+      }
+    };
+    let weaveStub = sinon.stub(SyncDisconnectInternal, "getWeave");
+    weaveStub.returns(Weave);
+
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    butDisconnect.click();
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+
+    Assert.equal(Weave.Service.lock.callCount, 1, "should have taken the lock");
+    Assert.equal(Weave.Service.unlock.callCount, 1, "should have unlocked at the end");
+    Assert.equal(Weave.Service.enabled, true, "sync should be enabled");
+    Assert.equal(spySync.callCount, 0, "should not have sanitized sync data");
+    Assert.equal(spySignout.callCount, 1, "should have disconnected");
+    Assert.equal(spyBrowser.callCount, 0, "should not sanitized browser data");
+    Assert.equal(Weave.Service.startOver.callCount, 1, "should have reset sync");
+  });
+});
+
+add_task(async function testSanitizeSync() {
+  await runTestWithSanitizeDialog(async (win, sinon) => {
+    let doc = win.document;
+    let butDisconnect = doc.getElementById("butDisconnect");
+    let butDeleteSync = doc.getElementById("deleteRemoteSyncData");
+
+    SyncDisconnectInternal.lockRetryInterval = 100;
+
+    let spySignout = sinon.spy(SyncDisconnectInternal, "doSyncAndAccountDisconnect");
+
+    // mock the "browser" sanitize function - it should not be called by
+    // this test.
+    let spyBrowser = sinon.spy(SyncDisconnectInternal, "doSanitizeBrowserData");
+    // mock Sync
+    let mockEngine1 = {
+      enabled: true,
+      name: "Test Engine 1",
+      wipeClient: sinon.spy(),
+    };
+    let mockEngine2 = {
+      enabled: false,
+      name: "Test Engine 2",
+      wipeClient: sinon.spy(),
+    };
+
+    let lockStub = sinon.stub();
+    lockStub.onCall(0).returns(false); // first call fails to get the lock.
+    lockStub.onCall(1).returns(true); // second call gets the lock.
+    let Weave = {
+      Service: {
+        enabled: true,
+        startOver: sinon.spy(),
+        lock: lockStub,
+        unlock: sinon.spy(),
+
+        engineManager: {
+          getAll: sinon.stub().returns([mockEngine1, mockEngine2]),
+        },
+        errorHandler: {
+          resetFileLog: sinon.spy(),
+        }
+      }
+    };
+    let weaveStub = sinon.stub(SyncDisconnectInternal, "getWeave");
+    weaveStub.returns(Weave);
+
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    butDeleteSync.click();
+    butDisconnect.click();
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+
+    Assert.equal(Weave.Service.lock.callCount, 2, "should have tried the lock twice");
+    Assert.equal(Weave.Service.unlock.callCount, 1, "should have unlocked at the end");
+    Assert.ok(Weave.Service.enabled, "Weave should be enabled");
+    Assert.equal(Weave.Service.errorHandler.resetFileLog.callCount, 1, "should have reset the log");
+    Assert.equal(mockEngine1.wipeClient.callCount, 1, "enabled engine should have been wiped");
+    Assert.equal(mockEngine2.wipeClient.callCount, 0, "disabled engine should not have been wiped");
+    Assert.equal(spyBrowser.callCount, 0, "should not sanitize the browser");
+    Assert.equal(spySignout.callCount, 1, "should have signed out of FxA");
+    Assert.equal(Weave.Service.startOver.callCount, 1, "should have reset sync");
+  });
+});
+
+add_task(async function testSanitizeBrowser() {
+  await runTestWithSanitizeDialog(async (win, sinon) => {
+    let doc = win.document;
+
+    // The dialog should have the main UI visible.
+    Assert.equal(doc.getElementById("deleteOptionsContent").hidden, false);
+    Assert.equal(doc.getElementById("deletingContent").hidden, true);
+
+    let butDisconnect = doc.getElementById("butDisconnect");
+    let butDeleteOther = doc.getElementById("deleteRemoteOtherData");
+
+    let spySignout = sinon.spy(SyncDisconnectInternal, "doSyncAndAccountDisconnect");
+
+    // mock both sanitize functions.
+    let spyBrowser = sinon.spy(SyncDisconnectInternal, "doSanitizeBrowserData");
+    let spySync = sinon.spy(SyncDisconnectInternal, "doSanitizeSyncData");
+
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    butDeleteOther.click();
+    butDisconnect.click();
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+
+    Assert.equal(spyBrowser.callCount, 1, "should have sanitized the browser");
+    Assert.equal(spySync.callCount, 0, "should not have sanitized Sync");
+    Assert.equal(spySignout.callCount, 1, "should have signed out of FxA");
+  });
+});
+
+add_task(async function testDisconnectAlreadyRunning() {
+  // Mock the sanitize process to indicate one is already in progress.
+  let resolveExisting;
+  SyncDisconnectInternal.promiseDisconnectFinished =
+    new Promise(resolve => resolveExisting = resolve);
+
+  await runTestWithSanitizeDialog(async (win, sinon) => {
+    let doc = win.document;
+    // The dialog should have "waiting" visible.
+    Assert.equal(doc.getElementById("deleteOptionsContent").hidden, true);
+    Assert.equal(doc.getElementById("deletingContent").hidden, false);
+
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    resolveExisting();
+
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+  });
+  SyncDisconnectInternal.promiseDisconnectFinished = null;
+});
+
+async function runTestWithSanitizeDialog(test) {
+  await openPreferencesViaOpenPreferencesAPI("paneSync", {leaveOpen: true});
+
+  let doc = gBrowser.contentDocument;
+
+  let promiseSubDialogLoaded =
+      promiseLoadSubDialog("chrome://browser/content/preferences/in-content/syncDisconnect.xul");
+  doc.getElementById("fxaUnlinkButton").doCommand();
+
+  let win = await promiseSubDialogLoaded;
+
+  let ss = sinon.sandbox.create();
+
+  await test(win, ss);
+
+  ss.restore();
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
new file mode 100644
--- /dev/null
+++ b/services/sync/modules/SyncDisconnect.jsm
@@ -0,0 +1,205 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+// This module provides a facility for disconnecting Sync and FxA, optionally
+// sanitizing profile data as part of the process.
+
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  Log: "resource://gre/modules/Log.jsm",
+  Sanitizer: "resource:///modules/Sanitizer.jsm",
+  AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+  fxAccounts: "resource://gre/modules/FxAccounts.jsm",
+  setTimeout: "resource://gre/modules/Timer.jsm",
+});
+
+this.EXPORTED_SYMBOLS = ["SyncDisconnect"];
+
+this.SyncDisconnectInternal = {
+  lockRetryInterval: 1000, // wait 1 seconds before trying for the lock again.
+  lockRetryCount: 120, // Try 120 times (==2 mins) before giving up in disgust.
+  promiseDisconnectFinished: null, // If we are sanitizing, a promise for completion.
+
+  // mocked by tests.
+  getWeave() {
+    return ChromeUtils.import("resource://services-sync/main.js", {}).Weave;
+  },
+
+  // Returns a promise that resolves when we are not syncing, waiting until
+  // a current Sync completes if necessary. Resolves with true if we
+  // successfully waited, in which case the sync lock will have been taken to
+  // ensure future syncs don't state, or resolves with false if we gave up
+  // waiting for the sync to complete (in which case we didn't take a lock -
+  // but note that Sync probably remains locked in this case regardless.)
+  async promiseNotSyncing(abortController) {
+    let weave = this.getWeave();
+    let log = Log.repository.getLogger("Sync.Service");
+    // We might be syncing - poll for up to 2 minutes waiting for the lock.
+    // (2 minutes seems extreme, but should be very rare.)
+    return new Promise(resolve => {
+      abortController.signal.onabort = () => {
+        resolve(false);
+      };
+
+      let attempts = 0;
+      let checkLock = () => {
+        if (abortController.signal.aborted) {
+          // We've already resolved, so don't want a new timer to ever start.
+          return;
+        }
+        if (weave.Service.lock()) {
+          resolve(true);
+          return;
+        }
+        attempts += 1;
+        if (attempts >= this.lockRetryCount) {
+          log.error("Gave up waiting for the sync lock - going ahead with sanitize anyway");
+          resolve(false);
+          return;
+        }
+        log.debug("Waiting a couple of seconds to get the sync lock");
+        setTimeout(checkLock, this.lockRetryInterval);
+      };
+      checkLock();
+    });
+  },
+
+  // Sanitize Sync-related data.
+  async doSanitizeSyncData() {
+    let weave = this.getWeave();
+    // Get the sync logger - if stuff goes wrong it can be useful to have that
+    // recorded in the sync logs.
+    let log = Log.repository.getLogger("Sync.Service");
+    log.info("Starting santitize of Sync data");
+    try {
+      // We clobber data for all Sync engines that are enabled.
+      await weave.Service.promiseInitialized;
+      weave.Service.enabled = false;
+
+      log.info("starting actual sanitization");
+      for (let engine of weave.Service.engineManager.getAll()) {
+        if (engine.enabled) {
+          try {
+            log.info("Wiping engine", engine.name);
+            await engine.wipeClient();
+          } catch (ex) {
+            log.error("Failed to wipe engine", ex);
+          }
+        }
+      }
+      log.info("Finished wiping sync data");
+    } catch (ex) {
+      log.error("Failed to sanitize Sync data", ex);
+      console.error("Failed to sanitize Sync data", ex);
+    }
+    try {
+      // ensure any logs we wrote are flushed to disk.
+      await weave.Service.errorHandler.resetFileLog();
+    } catch (ex) {
+      console.log("Failed to flush the Sync log", ex);
+    }
+  },
+
+  // Sanitize all Browser data.
+  async doSanitizeBrowserData() {
+    try {
+      // sanitize everything other than "open windows" (and we don't do that
+      // because it may confuse the user - they probably want to see
+      // about:prefs with the disconnection reflected.
+      let itemsToClear = Object.keys(Sanitizer.items).filter(k => k != "openWindows");
+      await Sanitizer.sanitize(itemsToClear);
+    } catch (ex) {
+      console.error("Failed to sanitize other data", ex);
+    }
+  },
+
+  async doSyncAndAccountDisconnect(shouldUnlock) {
+    // We do a startOver of Sync first - if we do the account first we end
+    // up with Sync configured but FxA not configured, which causes the browser
+    // UI to briefly enter a "needs reauth" state.
+    let Weave = this.getWeave();
+    await Weave.Service.startOver();
+    await fxAccounts.signOut();
+    // Sync may have been disabled if we santized, so re-enable it now or
+    // else the user will be unable to resync should they sign in before a
+    // restart.
+    Weave.Service.enabled = true;
+
+    // and finally, if we managed to get the lock before, we should unlock it
+    // now.
+    if (shouldUnlock) {
+      Weave.Service.unlock();
+    }
+  },
+
+  // Start the sanitization process. Returns a promise that resolves when
+  // the sanitize is complete, and an AbortController which can be used to
+  // abort the process of waiting for a sync to complete.
+  async _startDisconnect(abortController,
+                         {sanitizeSyncData = false, sanitizeBrowserData = false} = {}) {
+    // This is a bit convoluted - we want to wait for a sync to finish before
+    // sanitizing, but want to abort that wait if the browser shuts down while
+    // we are waiting (in which case we'll charge ahead anyway).
+    // So we do this by using an AbortController and passing that to the
+    // function that waits for the sync lock - it will immediately resolve
+    // if the abort controller is aborted.
+    let log = Log.repository.getLogger("Sync.Service");
+    log.info("waiting for any existing syncs to complete");
+    let locked = await this.promiseNotSyncing(abortController);
+
+    if (sanitizeSyncData) {
+      await this.doSanitizeSyncData();
+    }
+
+    // We disconnect before sanitizing the browser data - in a worst-case
+    // scenario where the sanitize takes so long that even the shutdown
+    // blocker doesn't allow it to finish, we should still at least be in
+    // a disconnected state on the next startup.
+    log.info("disconnecting account");
+    await this.doSyncAndAccountDisconnect(locked);
+
+    if (sanitizeBrowserData) {
+      await this.doSanitizeBrowserData();
+    }
+
+  },
+
+  async disconnect(options) {
+    if (this.promiseDisconnectFinished) {
+        throw new Error("A disconnect is already in progress");
+    }
+    let abortController = new AbortController();
+    let promiseDisconnectFinished = this._startDisconnect(abortController, options);
+    this.promiseDisconnectFinished = promiseDisconnectFinished;
+    let shutdownBlocker = () => {
+      // oh dear - we are sanitizing (probably stuck waiting for a sync to
+      // complete) and the browser is shutting down. Let's avoid the wait
+      // for sync to complete and continue the process anyway.
+      abortController.abort();
+      return promiseDisconnectFinished;
+    };
+    AsyncShutdown.quitApplicationGranted.addBlocker(
+      "SyncDisconnect: removing requested data",
+      shutdownBlocker);
+
+    // wait for it to finish - hopefully without the blocker being called.
+    await promiseDisconnectFinished;
+    this.promiseDisconnectFinished = null;
+
+    // sanitize worked so remove our blocker - it's a noop if the blocker
+    // did call us.
+    AsyncShutdown.quitApplicationGranted.removeBlocker(shutdownBlocker);
+  },
+};
+
+this.SyncDisconnect = {
+    get promiseDisconnectFinished() {
+        return SyncDisconnectInternal.promiseDisconnectFinished;
+    },
+
+    disconnect(options) {
+      return SyncDisconnectInternal.disconnect(options);
+    }
+};
--- a/services/sync/moz.build
+++ b/services/sync/moz.build
@@ -29,16 +29,17 @@ EXTRA_JS_MODULES['services-sync'] += [
     'modules/engines.js',
     'modules/keys.js',
     'modules/main.js',
     'modules/policies.js',
     'modules/record.js',
     'modules/resource.js',
     'modules/service.js',
     'modules/status.js',
+    'modules/SyncDisconnect.jsm',
     'modules/SyncedTabs.jsm',
     'modules/telemetry.js',
     'modules/UIState.jsm',
     'modules/util.js',
 ]
 
 EXTRA_JS_MODULES['services-sync'].engines += [
     'modules/engines/addons.js',
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_disconnect_shutdown.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* global sinon */
+
+"use strict";
+
+const {SyncDisconnect, SyncDisconnectInternal} = ChromeUtils.import("resource://services-sync/SyncDisconnect.jsm", {});
+const {AsyncShutdown} = ChromeUtils.import("resource://gre/modules/AsyncShutdown.jsm", {});
+
+add_task(async function test_shutdown_blocker() {
+  let spySignout = sinon.stub(SyncDisconnectInternal, "doSyncAndAccountDisconnect");
+
+  // We don't need to check for the lock regularly as we end up aborting the wait.
+  SyncDisconnectInternal.lockRetryInterval = 1000;
+  // Force the retry count to a very large value - this test should never
+  // abort due to the retry count and we want the test to fail (aka timeout)
+  // should our abort code not work.
+  SyncDisconnectInternal.lockRetryCount = 10000;
+
+  // mock the "browser" sanitize function - it should not be called by
+  // this test.
+  let spyBrowser = sinon.stub(SyncDisconnectInternal, "doSanitizeBrowserData");
+  // mock Sync
+  let mockEngine1 = {
+    enabled: true,
+    name: "Test Engine 1",
+    wipeClient: sinon.spy(),
+  };
+  let mockEngine2 = {
+    enabled: false,
+    name: "Test Engine 2",
+    wipeClient: sinon.spy(),
+  };
+
+  // This weave mock never gives up the lock.
+  let Weave = {
+    Service: {
+      enabled: true,
+      lock: () => false, // so we never get the lock.
+      unlock: sinon.spy(),
+
+      engineManager: {
+        getAll: sinon.stub().returns([mockEngine1, mockEngine2]),
+      },
+      errorHandler: {
+        resetFileLog: sinon.spy(),
+      }
+    }
+  };
+  let weaveStub = sinon.stub(SyncDisconnectInternal, "getWeave");
+  weaveStub.returns(Weave);
+
+  let promiseDisconnected = SyncDisconnect.disconnect({sanitizeSyncData: true});
+
+  // Pretend we hit the shutdown blocker.
+  info("simulating quitApplicationGranted");
+  Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
+  AsyncShutdown.quitApplicationGranted._trigger();
+  Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+
+  info("waiting for disconnect to complete");
+  await promiseDisconnected;
+
+  Assert.equal(Weave.Service.unlock.callCount, 0, "should not have unlocked at the end");
+  Assert.ok(!Weave.Service.enabled, "Weave should be and remain disabled");
+  Assert.equal(Weave.Service.errorHandler.resetFileLog.callCount, 1, "should have reset the log");
+  Assert.equal(mockEngine1.wipeClient.callCount, 1, "enabled engine should have been wiped");
+  Assert.equal(mockEngine2.wipeClient.callCount, 0, "disabled engine should not have been wiped");
+  Assert.equal(spyBrowser.callCount, 0, "should not sanitize the browser");
+  Assert.equal(spySignout.callCount, 1, "should have signed out of FxA");
+});
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -191,8 +191,9 @@ support-files = prefs_test_prefs_store.j
 # Synced tabs.
 [test_syncedtabs.js]
 
 [test_telemetry.js]
 requesttimeoutfactor = 4
 
 [test_uistate.js]
 [test_412.js]
+[test_disconnect_shutdown.js]