Merge services-central to mozilla-central.
authorRichard Newman <rnewman@mozilla.com>
Wed, 01 Jun 2011 18:20:09 -0700
changeset 70447 cadc21488ae788341eeb3695f4fa6b30c834330f
parent 70426 a4107cfe6b211679caf99d37a50f4084a755dc18 (current diff)
parent 70446 17afae19309183ed6b66242f99acde905738a1e1 (diff)
child 70448 b3760bf020265db8b97f12ff0f723642570780e6
push id20321
push userrnewman@mozilla.com
push dateThu, 02 Jun 2011 01:21:25 +0000
treeherdermozilla-central@cadc21488ae7 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone7.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
Merge services-central to mozilla-central.
services/sync/tests/unit/test_utils_queryAsync.js
--- a/browser/base/content/browser-syncui.js
+++ b/browser/base/content/browser-syncui.js
@@ -323,17 +323,17 @@ let gSyncUI = {
     if (!lastSync || this._needsSetup()) {
       syncButton.removeAttribute("tooltiptext");
       return;
     }
 
     // Show the day-of-week and time (HH:MM) of last sync
     let lastSyncDate = new Date(lastSync).toLocaleFormat("%a %H:%M");
     let lastSyncLabel =
-      this._stringBundle.formatStringFromName("lastSync.label", [lastSyncDate], 1);
+      this._stringBundle.formatStringFromName("lastSync2.label", [lastSyncDate], 1);
 
     syncButton.setAttribute("tooltiptext", lastSyncLabel);
   },
 
   _onSyncEnd: function SUI__onSyncEnd(success) {
     let title = this._stringBundle.GetStringFromName("error.sync.title");
     if (!success) {
       if (Weave.Status.login != Weave.LOGIN_SUCCEEDED) {
--- a/browser/base/content/syncAddDevice.xul
+++ b/browser/base/content/syncAddDevice.xul
@@ -49,16 +49,17 @@
 %syncBrandDTD;
 %syncSetupDTD;
 ]>
 <wizard xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         xmlns:html="http://www.w3.org/1999/xhtml"
         id="wizard"
         title="&addDevice.title.label;"
         windowtype="Sync:AddDevice"
+        persist="screenX screenY"
         onwizardnext="return gSyncAddDevice.onWizardAdvance();"
         onwizardback="return gSyncAddDevice.onWizardBack();"
         onwizardcancel="gSyncAddDevice.onWizardCancel();"
         onload="gSyncAddDevice.init();">
 
   <script type="application/javascript"
           src="chrome://browser/content/syncAddDevice.js"/>
   <script type="application/javascript"
--- a/browser/base/content/syncGenericChange.xul
+++ b/browser/base/content/syncGenericChange.xul
@@ -50,16 +50,17 @@
 %brandDTD;
 %syncBrandDTD;
 %syncSetupDTD;
 ]>
 <wizard xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         xmlns:html="http://www.w3.org/1999/xhtml"
         id="change-dialog"
         windowtype="Weave:ChangeSomething"
+        persist="screenX screenY"
         onwizardnext="Change.onLoad()"
         onwizardfinish="return Change.onDialogAccept();">
 
   <script type="application/javascript"
           src="chrome://browser/content/syncGenericChange.js"/>
   <script type="application/javascript"
           src="chrome://browser/content/syncUtils.js"/>
   <script type="application/javascript"
--- a/browser/base/content/syncKey.xhtml
+++ b/browser/base/content/syncKey.xhtml
@@ -46,16 +46,17 @@
   %syncKeyDTD;
   <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd" >
   %globalDTD;
 ]>
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
   <title>&syncKey.page.title;</title>
   <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+  <meta name="robots" content="noindex"/>
   <style type="text/css">
     #synckey { font-size: 150% }
     footer { font-size: 70% }
 # Bug 575675: Need to have an a:visited rule in a chrome document.
     a:visited { color: purple; }
   </style>
 </head>
 
--- a/browser/base/content/syncQuota.xul
+++ b/browser/base/content/syncQuota.xul
@@ -45,16 +45,17 @@
 <!ENTITY % syncBrandDTD SYSTEM "chrome://browser/locale/syncBrand.dtd">
 <!ENTITY % syncQuotaDTD SYSTEM "chrome://browser/locale/syncQuota.dtd">
 %brandDTD;
 %syncBrandDTD;
 %syncQuotaDTD;
 ]>
 <dialog id="quotaDialog"
         windowtype="Sync:ViewQuota"
+        persist="screenX screenY width height"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         xmlns:html="http://www.w3.org/1999/xhtml"
         onload="gSyncQuota.init()"
         buttons="accept,cancel"
         title="&quota.dialogTitle.label;"
         ondialogcancel="return true;"
         ondialogaccept="return gSyncQuota.onAccept();">
 
--- a/browser/base/content/syncSetup.xul
+++ b/browser/base/content/syncSetup.xul
@@ -49,16 +49,17 @@
 <!ENTITY % syncBrandDTD SYSTEM "chrome://browser/locale/syncBrand.dtd">
 <!ENTITY % syncSetupDTD SYSTEM "chrome://browser/locale/syncSetup.dtd">
 %brandDTD;
 %syncBrandDTD;
 %syncSetupDTD;
 ]>
 <wizard id="accountSetup" title="&accountSetupTitle.label;"
         windowtype="Weave:AccountSetup"
+        persist="screenX screenY"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         xmlns:html="http://www.w3.org/1999/xhtml"
         onwizardnext="return gSyncSetup.onWizardAdvance()"
         onwizardback="return gSyncSetup.onWizardBack()"
         onwizardfinish="gSyncSetup.onWizardFinish()"
         onwizardcancel="gSyncSetup.onWizardCancel()"
         onload="gSyncSetup.init()">
 
--- a/mobile/chrome/content/sync.js
+++ b/mobile/chrome/content/sync.js
@@ -432,32 +432,32 @@ let WeaveGlue = {
       if (Weave.Service.locked) {
         connect.firstChild.disabled = true;
         sync.firstChild.disabled = true;
 
         if (aTopic == "weave:service:login:start")
           connect.setAttribute("title", self._bundle.GetStringFromName("connecting.label"));
 
         if (aTopic == "weave:service:sync:start")
-          sync.setAttribute("title", self._bundle.GetStringFromName("lastSyncInProgress.label"));
+          sync.setAttribute("title", self._bundle.GetStringFromName("lastSyncInProgress2.label"));
       } else {
         connect.firstChild.disabled = false;
         sync.firstChild.disabled = false;
       }
     }, 0, this);
 
     // Dynamically generate some strings
     let accountStr = this._bundle.formatStringFromName("account.label", [Weave.Service.account], 1);
     disconnect.setAttribute("title", accountStr);
 
     // Show the day-of-week and time (HH:MM) of last sync
     let lastSync = Weave.Svc.Prefs.get("lastSync");
     if (lastSync != null) {
       let syncDate = new Date(lastSync).toLocaleFormat("%a %R");
-      let dateStr = this._bundle.formatStringFromName("lastSync.label", [syncDate], 1);
+      let dateStr = this._bundle.formatStringFromName("lastSync2.label", [syncDate], 1);
       sync.setAttribute("title", dateStr);
     }
 
     // Show what went wrong with login if necessary
     if (aTopic == "weave:service:login:error")
       connect.setAttribute("desc", Weave.Utils.getErrorString(Weave.Status.login));
     else
       connect.removeAttribute("desc");
--- a/mobile/locales/en-US/chrome/sync.properties
+++ b/mobile/locales/en-US/chrome/sync.properties
@@ -1,13 +1,13 @@
 # Mobile Sync
 
 # %S is the date and time at which the last sync successfully completed
-lastSync.label=Last update: %S
-lastSyncInProgress.label=Last update: in progress…
+lastSync2.label=Last sync: %S
+lastSyncInProgress2.label=Last sync: in progress…
 
 # %S is the username logged in
 account.label=Account: %S
 notconnected.label=Not connected
 connecting.label=Connecting…
 
 notificationDisconnect.label=Your Firefox Sync account has been removed
 notificationDisconnect.button=Undo
--- a/services/sync/locales/en-US/sync.properties
+++ b/services/sync/locales/en-US/sync.properties
@@ -1,19 +1,13 @@
 # %1: the user name (Ed), %2: the app name (Firefox), %3: the operating system (Android)
 client.name2 = %1$S's %2$S on %3$S
 
 # %S is the date and time at which the last sync successfully completed
-lastSync.label = Last Update: %S
-lastSyncInProgress.label = Last Update: in progress…
-
-# %S is the username logged in
-connected.label = Connected: %S
-disconnected.label = Disconnected
-connecting.label = Connecting…
+lastSync2.label = Last sync: %S
 
 mobile.label = Mobile Bookmarks
 
 remote.pending.label = Remote tabs are being synced…
 remote.missing.label = Sync your other computers again to access their tabs
 remote.opened.label = All remote tabs are already open
 remote.notification.label = Recent desktop tabs will be available once they sync
 
new file mode 100644
--- /dev/null
+++ b/services/sync/modules/async.js
@@ -0,0 +1,337 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Firefox Sync.
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2011
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *  Richard Newman <rnewman@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+const EXPORTED_SYMBOLS = ['Async'];
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cr = Components.results;
+const Cu = Components.utils;
+
+// Constants for makeSyncCallback, waitForSyncCallback.
+const CB_READY = {};
+const CB_COMPLETE = {};
+const CB_FAIL = {};
+
+const REASON_ERROR = Ci.mozIStorageStatementCallback.REASON_ERROR;
+
+Cu.import("resource://services-sync/util.js");
+
+/*
+ * Helpers for various async operations.
+ */
+let Async = {
+
+  /**
+   * Helpers for making asynchronous calls within a synchronous API possible.
+   *
+   * If you value your sanity, do not look closely at the following functions.
+   */
+
+  /**
+   * Create a sync callback that remembers state, in particular whether it has
+   * been called.
+   */
+  makeSyncCallback: function makeSyncCallback() {
+    // The main callback remembers the value it was passed, and that it got data.
+    let onComplete = function onComplete(data) {
+      onComplete.state = CB_COMPLETE;
+      onComplete.value = data;
+    };
+
+    // Initialize private callback data in preparation for being called.
+    onComplete.state = CB_READY;
+    onComplete.value = null;
+
+    // Allow an alternate callback to trigger an exception to be thrown.
+    onComplete.throw = function onComplete_throw(data) {
+      onComplete.state = CB_FAIL;
+      onComplete.value = data;
+
+      // Cause the caller to get an exception and stop execution.
+      throw data;
+    };
+
+    return onComplete;
+  },
+
+  /**
+   * Wait for a sync callback to finish.
+   */
+  waitForSyncCallback: function waitForSyncCallback(callback) {
+    // Grab the current thread so we can make it give up priority.
+    let thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
+
+    // Keep waiting until our callback is triggered (unless the app is quitting).
+    while (Utils.checkAppReady() && callback.state == CB_READY) {
+      thread.processNextEvent(true);
+    }
+
+    // Reset the state of the callback to prepare for another call.
+    let state = callback.state;
+    callback.state = CB_READY;
+
+    // Throw the value the callback decided to fail with.
+    if (state == CB_FAIL) {
+      throw callback.value;
+    }
+
+    // Return the value passed to the callback.
+    return callback.value;
+  },
+
+  /**
+   * Return the two things you need to make an asynchronous call synchronous
+   * by spinning the event loop.
+   */
+  makeSpinningCallback: function makeSpinningCallback() {
+    let cb = Async.makeSyncCallback();
+    function callback(error, ret) {
+      if (error)
+        cb.throw(error);
+      cb(ret);
+    }
+    callback.wait = function() Async.waitForSyncCallback(cb);
+    return callback;
+  },
+
+  /**
+   * Synchronously invoke a method that takes only a `callback` argument.
+   */
+  callSpinningly: function callSpinningly(self, method) {
+    let callback = this.makeSpinningCallback();
+    method.call(self, callback);
+    return callback.wait();
+  },
+
+  /*
+   * Produce a sequence of callbacks which -- when all have been executed
+   * successfully *or* any have failed -- invoke the output callback.
+   *
+   * Returns a generator.
+   *
+   * Each input callback should have the signature (error, result), and should
+   * return a truthy value if the computation should be considered to have
+   * failed.
+   *
+   * The contents of ".data" on each input callback are copied to the
+   * resultant callback items. This can save some effort on the caller's side.
+   *
+   * These callbacks are assumed to be single- or double-valued (a "result" and
+   * a "context", say), which covers the common cases without the expense of
+   * `arguments`.
+   */
+  barrieredCallbacks: function (callbacks, output) {
+    if (!output) {
+      throw "No output callback provided to barrieredCallbacks.";
+    }
+
+    let counter = callbacks.length;
+    function makeCb(input) {
+      let cb = function(error, result, context) {
+        if (!output) {
+          return;
+        }
+
+        let err;
+        try {
+          err = input(error, result, context);
+        } catch (ex) {
+          output(ex);
+          output = undefined;
+          return;
+        }
+        if ((0 == --counter) || err) {
+          output(err);
+          output = undefined;
+        }
+      };
+      cb.data = input.data;
+      return cb;
+    }
+    return (makeCb(i) for each (i in callbacks));
+  },
+
+  /*
+   * Similar to barrieredCallbacks, but with the same callback each time.
+   */
+  countedCallback: function (componentCb, count, output) {
+    if (!output) {
+      throw "No output callback provided to countedCallback.";
+    }
+
+    if (!count || (count <= 0)) {
+      throw "Invalid count provided to countedCallback.";
+    }
+
+    let counter = count;
+    return function (error, result, context) {
+      if (!output) {
+        return;
+      }
+
+      let err;
+      try {
+        err = componentCb(error, result, context);
+      } catch (ex) {
+        output(ex);
+        // We're done; make sure output callback is only called once.
+        output = undefined;
+        return;
+      }
+      if ((0 == --counter) || err) {
+        output(err);             // If this throws, then... oh well.
+        output = undefined;
+        return;
+      }
+    };
+  },
+
+  /*
+   * Invoke `f` with each item and a wrapped version of `componentCb`.
+   * When each component callback is invoked, the next invocation of `f` is
+   * begun, unless the return value is truthy. (See barrieredCallbacks.)
+   *
+   * Finally, invoke the output callback.
+   *
+   * If there are no items, the output callback is invoked immediately.
+   */
+  serially: function serially(items, f, componentCb, output) {
+    if (!output) {
+      throw "No output callback provided to serially.";
+    }
+
+    if (!items || !items.length) {
+      output();
+      return;
+    }
+
+    let count = items.length;
+    let i = 0;
+    function cb(error, result, context) {
+      let err = error;
+      if (!err) {
+        try {
+          err = componentCb(error, result, context);
+        } catch (ex) {
+          err = ex;
+        }
+      }
+      if ((++i == count) || err) {
+        output(err);
+        return;
+      }
+      Utils.delay(function () { f(items[i], cb); });
+    }
+    f(items[i], cb);
+  },
+
+  /*
+   * Return a callback which executes `f` then `callback`, regardless of
+   * whether it was invoked with an error. If an exception is thrown during the
+   * evaluation of `f`, it takes precedence over an error provided to the
+   * callback.
+   *
+   * When used to wrap a callback, this offers similar behavior to try..finally
+   * in plain JavaScript.
+   */
+  finallyCallback: function (callback, f) {
+    return function(err) {
+      try {
+        f();
+        callback(err);
+      } catch (ex) {
+        callback(ex);
+      }
+    };
+  },
+
+  // Prototype for mozIStorageCallback, used in querySpinningly.
+  // This allows us to define the handle* functions just once rather
+  // than on every querySpinningly invocation.
+  _storageCallbackPrototype: {
+    results: null,
+
+    // These are set by queryAsync.
+    names: null,
+    syncCb: null,
+
+    handleResult: function handleResult(results) {
+      if (!this.names) {
+        return;
+      }
+      if (!this.results) {
+        this.results = [];
+      }
+      let row;
+      while ((row = results.getNextRow()) != null) {
+        let item = {};
+        for each (name in this.names) {
+          item[name] = row.getResultByName(name);
+        }
+        this.results.push(item);
+      }
+    },
+    handleError: function handleError(error) {
+      this.syncCb.throw(error);
+    },
+    handleCompletion: function handleCompletion(reason) {
+
+      // If we got an error, handleError will also have been called, so don't
+      // call the callback! We never cancel statements, so we don't need to
+      // address that quandary.
+      if (reason == REASON_ERROR)
+        return;
+
+      // If we were called with column names but didn't find any results,
+      // the calling code probably still expects an array as a return value.
+      if (this.names && !this.results) {
+        this.results = [];
+      }
+      this.syncCb(this.results);
+    }
+  },
+
+  querySpinningly: function querySpinningly(query, names) {
+    // 'Synchronously' asyncExecute, fetching all results by name.
+    let storageCallback = {names: names,
+                           syncCb: Async.makeSyncCallback()};
+    storageCallback.__proto__ = Async._storageCallbackPrototype;
+    query.executeAsync(storageCallback);
+    return Async.waitForSyncCallback(storageCallback.syncCb);
+  },
+};
--- a/services/sync/modules/engines.js
+++ b/services/sync/modules/engines.js
@@ -41,16 +41,17 @@
 const EXPORTED_SYMBOLS = ['Engines', 'Engine', 'SyncEngine',
                           'Tracker', 'Store'];
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cr = Components.results;
 const Cu = Components.utils;
 
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/ext/Observers.js");
 Cu.import("resource://services-sync/identity.js");
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://services-sync/resource.js");
 Cu.import("resource://services-sync/util.js");
 
@@ -195,20 +196,20 @@ function Store(name) {
 
   XPCOMUtils.defineLazyGetter(this, "_timer", function() {
     return Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
   });
 }
 Store.prototype = {
 
   _sleep: function _sleep(delay) {
-    let cb = Utils.makeSyncCallback();
+    let cb = Async.makeSyncCallback();
     this._timer.initWithCallback({notify: cb}, delay,
                                  Ci.nsITimer.TYPE_ONE_SHOT);
-    Utils.waitForSyncCallback(cb);
+    Async.waitForSyncCallback(cb);
   },
 
   applyIncomingBatch: function applyIncomingBatch(records) {
     let failed = [];
     for each (let record in records) {
       try {
         this.applyIncoming(record);
       } catch (ex) {
@@ -412,16 +413,17 @@ Engine.prototype = {
   wipeClient: function Engine_wipeClient() {
     this._notify("wipe-client", this.name, this._wipeClient)();
   }
 };
 
 function SyncEngine(name) {
   Engine.call(this, name || "SyncEngine");
   this.loadToFetch();
+  this.loadPreviousFailed();
 }
 
 // Enumeration to define approaches to handling bad records.
 // Attached to the constructor to allow use as a kind of static enumeration.
 SyncEngine.kRecoveryStrategy = {
   ignore: "ignore",
   retry:  "retry",
   error:  "error"
@@ -478,32 +480,58 @@ SyncEngine.prototype = {
     this._log.debug("Resetting " + this.name + " last sync time");
     Svc.Prefs.reset(this.name + ".lastSync");
     Svc.Prefs.set(this.name + ".lastSync", "0");
     this.lastSyncLocal = 0;
   },
 
   get toFetch() this._toFetch,
   set toFetch(val) {
+    // Coerce the array to a string for more efficient comparison.
+    if (val + "" == this._toFetch) {
+      return;
+    }
     this._toFetch = val;
     Utils.delay(function () {
       Utils.jsonSave("toFetch/" + this.name, this, val);
     }, 0, this, "_toFetchDelay");
   },
 
   loadToFetch: function loadToFetch() {
     // Initialize to empty if there's no file
     this._toFetch = [];
     Utils.jsonLoad("toFetch/" + this.name, this, function(toFetch) {
       if (toFetch) {
         this._toFetch = toFetch;
       }
     });
   },
 
+  get previousFailed() this._previousFailed,
+  set previousFailed(val) {
+    // Coerce the array to a string for more efficient comparison.
+    if (val + "" == this._previousFailed) {
+      return;
+    }
+    this._previousFailed = val;
+    Utils.delay(function () {
+      Utils.jsonSave("failed/" + this.name, this, val);
+    }, 0, this, "_previousFailedDelay");
+  },
+
+  loadPreviousFailed: function loadPreviousFailed() {
+    // Initialize to empty if there's no file
+    this._previousFailed = [];
+    Utils.jsonLoad("failed/" + this.name, this, function(previousFailed) {
+      if (previousFailed) {
+        this._previousFailed = previousFailed;
+      }
+    });
+  },
+
   /*
    * lastSyncLocal is a timestamp in local time.
    */
   get lastSyncLocal() {
     return parseInt(Svc.Prefs.get(this.name + ".lastSyncLocal", "0"), 10);
   },
   set lastSyncLocal(value) {
     // Store as a string because pref can only store C longs as numbers.
@@ -613,38 +641,45 @@ SyncEngine.prototype = {
     let isMobile = (Svc.Prefs.get("client.type") == "mobile");
 
     if (isMobile) {
       batchSize = MOBILE_BATCH_SIZE;
     }
     newitems.newer = this.lastSync;
     newitems.full = true;
     newitems.limit = batchSize;
-
-    let count = {applied: 0, failed: 0, reconciled: 0};
+    
+    // applied    => number of items that should be applied.
+    // failed     => number of items that failed in this sync.
+    // newFailed  => number of items that failed for the first time in this sync.
+    // reconciled => number of items that were reconciled.
+    let count = {applied: 0, failed: 0, newFailed: 0, reconciled: 0};
     let handled = [];
     let applyBatch = [];
     let failed = [];
-    let fetchBatch = this.toFetch;
+    let failedInPreviousSync = this.previousFailed;
+    let fetchBatch = Utils.arrayUnion(this.toFetch, failedInPreviousSync);
+    // Reset previousFailed for each sync since previously failed items may not fail again.
+    this.previousFailed = [];
 
     function doApplyBatch() {
       this._tracker.ignoreAll = true;
       failed = failed.concat(this._store.applyIncomingBatch(applyBatch));
       this._tracker.ignoreAll = false;
       applyBatch = [];
     }
 
     function doApplyBatchAndPersistFailed() {
       // Apply remaining batch.
       if (applyBatch.length) {
         doApplyBatch.call(this);
       }
       // Persist failed items so we refetch them.
       if (failed.length) {
-        this.toFetch = Utils.arrayUnion(failed, this.toFetch);
+        this.previousFailed = Utils.arrayUnion(failed, this.previousFailed);
         count.failed += failed.length;
         this._log.debug("Records that failed to apply: " + failed);
         failed = [];
       }
     }
 
     // Not binding this method to 'this' for performance reasons. It gets
     // called for every incoming record.
@@ -782,37 +817,41 @@ SyncEngine.prototype = {
       if (!resp.success) {
         resp.failureCode = ENGINE_DOWNLOAD_FAIL;
         throw resp;
       }
 
       // This batch was successfully applied. Not using
       // doApplyBatchAndPersistFailed() here to avoid writing toFetch twice.
       fetchBatch = fetchBatch.slice(batchSize);
-      let newToFetch = Utils.arraySub(this.toFetch, newitems.ids);
-      this.toFetch = Utils.arrayUnion(newToFetch, failed);
-      count.failed += failed.length;
-      this._log.debug("Records that failed to apply: " + failed);
+      this.toFetch = Utils.arraySub(this.toFetch, newitems.ids);
+      this.previousFailed = Utils.arrayUnion(this.previousFailed, failed);
+      if (failed.length) {
+        count.failed += failed.length;
+        this._log.debug("Records that failed to apply: " + failed);
+      }
       failed = [];
       if (this.lastSync < this.lastModified) {
         this.lastSync = this.lastModified;
       }
     }
 
     // Apply remaining items.
     doApplyBatchAndPersistFailed.call(this);
 
-    if (count.failed) {
+    count.newFailed = Utils.arraySub(this.previousFailed, failedInPreviousSync).length;
+    if (count.newFailed) {
       // Notify observers if records failed to apply. Pass the count object
       // along so that they can make an informed decision on what to do.
       Observers.notify("weave:engine:sync:apply-failed", count, this.name);
     }
     this._log.info(["Records:",
                     count.applied, "applied,",
                     count.failed, "failed to apply,",
+                    count.newFailed, "newly failed to apply,",
                     count.reconciled, "reconciled."].join(" "));
   },
 
   /**
    * Find a GUID of an item that is a duplicate of the incoming item but happens
    * to have a different GUID
    *
    * @return GUID of the similar item; falsy otherwise
@@ -872,17 +911,17 @@ SyncEngine.prototype = {
     if (item.id in this._modified) {
       // If the incoming and local changes are the same, skip
       if (this._isEqual(item)) {
         delete this._modified[item.id];
         return false;
       }
 
       // Records differ so figure out which to take
-      let recordAge = Resource.serverTime - item.modified;
+      let recordAge = AsyncResource.serverTime - item.modified;
       let localAge = Date.now() / 1000 - this._modified[item.id];
       this._log.trace("Record age vs local age: " + [recordAge, localAge]);
 
       // Apply the record if the record is newer (server wins)
       return recordAge < localAge;
     }
 
     this._log.trace("Reconcile step 2: Check for updates");
@@ -1046,16 +1085,17 @@ SyncEngine.prototype = {
       this._log.debug("Failed test decrypt: " + Utils.exceptionStr(ex));
     }
 
     return canDecrypt;
   },
 
   _resetClient: function SyncEngine__resetClient() {
     this.resetLastSync();
+    this.previousFailed = [];
     this.toFetch = [];
   },
 
   wipeServer: function wipeServer() {
     new Resource(this.engineURL).delete();
     this._resetClient();
   },
 
--- a/services/sync/modules/engines/bookmarks.js
+++ b/services/sync/modules/engines/bookmarks.js
@@ -63,16 +63,17 @@ const ANNOS_TO_TRACK = [DESCRIPTION_ANNO
 
 const SERVICE_NOT_SUPPORTED = "Service not supported on this platform";
 const FOLDER_SORTINDEX = 1000000;
 
 Cu.import("resource://gre/modules/PlacesUtils.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/util.js");
 
 Cu.import("resource://services-sync/main.js");      // For access to Service.
 
 function PlacesItem(collection, id, type) {
   CryptoWrapper.call(this, collection, id);
   this.type = type || "item";
 }
@@ -927,17 +928,17 @@ BookmarksStore.prototype = {
       "WHERE parent = :parent " +
       "ORDER BY position");
   },
   _childGUIDsCols: ["item_id", "guid"],
 
   _getChildGUIDsForId: function _getChildGUIDsForId(itemid) {
     let stmt = this._childGUIDsStm;
     stmt.params.parent = itemid;
-    let rows = Utils.queryAsync(stmt, this._childGUIDsCols);
+    let rows = Async.querySpinningly(stmt, this._childGUIDsCols);
     return rows.map(function (row) {
       if (row.guid) {
         return row.guid;
       }
       // A GUID hasn't been assigned to this item yet, do this now.
       return this.GUIDForId(row.item_id);
     }, this);
   },
@@ -1070,17 +1071,17 @@ BookmarksStore.prototype = {
   // Some helper functions to handle GUIDs
   _setGUID: function _setGUID(id, guid) {
     if (!guid)
       guid = Utils.makeGUID();
 
     let stmt = this._setGUIDStm;
     stmt.params.guid = guid;
     stmt.params.item_id = id;
-    Utils.queryAsync(stmt);
+    Async.querySpinningly(stmt);
     return guid;
   },
 
   get _guidForIdStm() {
     return this._getStmt(
       "SELECT guid " +
       "FROM moz_bookmarks " +
       "WHERE id = :item_id");
@@ -1091,17 +1092,17 @@ BookmarksStore.prototype = {
     let special = kSpecialIds.specialGUIDForId(id);
     if (special)
       return special;
 
     let stmt = this._guidForIdStm;
     stmt.params.item_id = id;
 
     // Use the existing GUID if it exists
-    let result = Utils.queryAsync(stmt, this._guidForIdCols)[0];
+    let result = Async.querySpinningly(stmt, this._guidForIdCols)[0];
     if (result && result.guid)
       return result.guid;
 
     // Give the uri a GUID if it doesn't have one
     return this._setGUID(id);
   },
 
   get _idForGUIDStm() {
@@ -1117,17 +1118,17 @@ BookmarksStore.prototype = {
   idForGUID: function idForGUID(guid, noCreate) {
     if (kSpecialIds.isSpecialGUID(guid))
       return kSpecialIds.specialIdForGUID(guid, !noCreate);
 
     let stmt = this._idForGUIDStm;
     // guid might be a String object rather than a string.
     stmt.params.guid = guid.toString();
 
-    let results = Utils.queryAsync(stmt, this._idForGUIDCols);
+    let results = Async.querySpinningly(stmt, this._idForGUIDCols);
     this._log.trace("Number of rows matching GUID " + guid + ": "
                     + results.length);
     
     // Here's the one we care about: the first.
     let result = results[0];
     
     if (!result)
       return -1;
@@ -1144,17 +1145,17 @@ BookmarksStore.prototype = {
     // unvisited bookmark
     let index = 0;
     if (record.parentid == "toolbar")
       index += 150;
 
     // Add in the bookmark's frecency if we have something
     if (record.bmkUri != null) {
       this._frecencyStm.params.url = record.bmkUri;
-      let result = Utils.queryAsync(this._frecencyStm, this._frecencyCols);
+      let result = Async.querySpinningly(this._frecencyStm, this._frecencyCols);
       if (result.length)
         index += result[0].frecency;
     }
 
     return index;
   },
 
   _getChildren: function BStore_getChildren(guid, items) {
--- a/services/sync/modules/engines/forms.js
+++ b/services/sync/modules/engines/forms.js
@@ -38,16 +38,17 @@ const EXPORTED_SYMBOLS = ['FormEngine', 
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/log4moz.js");
 
 const FORMS_TTL = 5184000; // 60 days
 
 function FormRec(collection, id) {
   CryptoWrapper.call(this, collection, id);
@@ -67,36 +68,36 @@ let FormWrapper = {
   getAllEntries: function getAllEntries() {
     // Sort by (lastUsed - minLast) / (maxLast - minLast) * timesUsed / maxTimes
     let query = Svc.Form.DBConnection.createAsyncStatement(
       "SELECT fieldname name, value FROM moz_formhistory " +
       "ORDER BY 1.0 * (lastUsed - (SELECT lastUsed FROM moz_formhistory ORDER BY lastUsed ASC LIMIT 1)) / " +
         "((SELECT lastUsed FROM moz_formhistory ORDER BY lastUsed DESC LIMIT 1) - (SELECT lastUsed FROM moz_formhistory ORDER BY lastUsed ASC LIMIT 1)) * " +
         "timesUsed / (SELECT timesUsed FROM moz_formhistory ORDER BY timesUsed DESC LIMIT 1) DESC " +
       "LIMIT 500");
-    return Utils.queryAsync(query, ["name", "value"]);
+    return Async.querySpinningly(query, ["name", "value"]);
   },
 
   getEntry: function getEntry(guid) {
     let query = Svc.Form.DBConnection.createAsyncStatement(
       "SELECT fieldname name, value FROM moz_formhistory WHERE guid = :guid");
     query.params.guid = guid;
-    return Utils.queryAsync(query, ["name", "value"])[0];
+    return Async.querySpinningly(query, ["name", "value"])[0];
   },
 
   getGUID: function getGUID(name, value) {
     // Query for the provided entry
     let getQuery = Svc.Form.DBConnection.createAsyncStatement(
       "SELECT guid FROM moz_formhistory " +
       "WHERE fieldname = :name AND value = :value");
     getQuery.params.name = name;
     getQuery.params.value = value;
 
     // Give the guid if we found one
-    let item = Utils.queryAsync(getQuery, ["guid"])[0];
+    let item = Async.querySpinningly(getQuery, ["guid"])[0];
     
     if (!item) {
       // Shouldn't happen, but Bug 597400...
       // Might as well just return.
       this._log.warn("GUID query returned " + item + "; turn on Trace logging for details.");
       this._log.trace("getGUID(" + JSON.stringify(name) + ", " +
                       JSON.stringify(value) + ") => " + item);
       return null;
@@ -108,34 +109,34 @@ let FormWrapper = {
     // We need to create a guid for this entry
     let setQuery = Svc.Form.DBConnection.createAsyncStatement(
       "UPDATE moz_formhistory SET guid = :guid " +
       "WHERE fieldname = :name AND value = :value");
     let guid = Utils.makeGUID();
     setQuery.params.guid = guid;
     setQuery.params.name = name;
     setQuery.params.value = value;
-    Utils.queryAsync(setQuery);
+    Async.querySpinningly(setQuery);
 
     return guid;
   },
 
   hasGUID: function hasGUID(guid) {
     let query = Svc.Form.DBConnection.createAsyncStatement(
       "SELECT guid FROM moz_formhistory WHERE guid = :guid LIMIT 1");
     query.params.guid = guid;
-    return Utils.queryAsync(query, ["guid"]).length == 1;
+    return Async.querySpinningly(query, ["guid"]).length == 1;
   },
 
   replaceGUID: function replaceGUID(oldGUID, newGUID) {
     let query = Svc.Form.DBConnection.createAsyncStatement(
       "UPDATE moz_formhistory SET guid = :newGUID WHERE guid = :oldGUID");
     query.params.oldGUID = oldGUID;
     query.params.newGUID = newGUID;
-    Utils.queryAsync(query);
+    Async.querySpinningly(query);
   }
 
 };
 
 function FormEngine() {
   SyncEngine.call(this, "Forms");
 }
 FormEngine.prototype = {
--- a/services/sync/modules/engines/history.js
+++ b/services/sync/modules/engines/history.js
@@ -46,16 +46,17 @@ const Cr = Components.results;
 
 const HISTORY_TTL = 5184000; // 60 days
 const TOPIC_UPDATEPLACES_COMPLETE = "places-updatePlaces-complete";
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/log4moz.js");
 
 function HistoryRec(collection, id) {
   CryptoWrapper.call(this, collection, id);
 }
 HistoryRec.prototype = {
   __proto__: CryptoWrapper.prototype,
@@ -127,34 +128,34 @@ HistoryStore.prototype = {
     uri = uri.spec ? uri.spec : uri;
 
     if (!guid)
       guid = Utils.makeGUID();
 
     let stmt = this._setGUIDStm;
     stmt.params.guid = guid;
     stmt.params.page_url = uri;
-    Utils.queryAsync(stmt);
+    Async.querySpinningly(stmt);
     return guid;
   },
 
   get _guidStm() {
     return this._getStmt(
       "SELECT guid " +
       "FROM moz_places " +
       "WHERE url = :page_url");
   },
   _guidCols: ["guid"],
 
   GUIDForUri: function GUIDForUri(uri, create) {
     let stm = this._guidStm;
     stm.params.page_url = uri.spec ? uri.spec : uri;
 
     // Use the existing GUID if it exists
-    let result = Utils.queryAsync(stm, this._guidCols)[0];
+    let result = Async.querySpinningly(stm, this._guidCols)[0];
     if (result && result.guid)
       return result.guid;
 
     // Give the uri a GUID if it doesn't have one
     if (create)
       return this.setGUID(uri);
   },
 
@@ -183,36 +184,36 @@ HistoryStore.prototype = {
       "ORDER BY frecency DESC " +
       "LIMIT :max_results");
   },
   _allUrlCols: ["url"],
 
   // See bug 320831 for why we use SQL here
   _getVisits: function HistStore__getVisits(uri) {
     this._visitStm.params.url = uri;
-    return Utils.queryAsync(this._visitStm, this._visitCols);
+    return Async.querySpinningly(this._visitStm, this._visitCols);
   },
 
   // See bug 468732 for why we use SQL here
   _findURLByGUID: function HistStore__findURLByGUID(guid) {
     this._urlStm.params.guid = guid;
-    return Utils.queryAsync(this._urlStm, this._urlCols)[0];
+    return Async.querySpinningly(this._urlStm, this._urlCols)[0];
   },
 
   changeItemID: function HStore_changeItemID(oldID, newID) {
     this.setGUID(this._findURLByGUID(oldID).url, newID);
   },
 
 
   getAllIDs: function HistStore_getAllIDs() {
     // Only get places visited within the last 30 days (30*24*60*60*1000ms)
     this._allUrlStm.params.cutoff_date = (Date.now() - 2592000000) * 1000;
     this._allUrlStm.params.max_results = MAX_HISTORY_UPLOAD;
 
-    let urls = Utils.queryAsync(this._allUrlStm, this._allUrlCols);
+    let urls = Async.querySpinningly(this._allUrlStm, this._allUrlCols);
     let self = this;
     return urls.reduce(function(ids, item) {
       ids[self.GUIDForUri(item.url, true)] = item.url;
       return ids;
     }, {});
   },
 
   applyIncomingBatch: function applyIncomingBatch(records) {
@@ -246,29 +247,29 @@ HistoryStore.prototype = {
     }
     records.length = k; // truncate array
 
     // Nothing to do.
     if (!records.length) {
       return failed;
     }
 
-    let cb = Utils.makeSyncCallback();
+    let cb = Async.makeSyncCallback();
     let onPlace = function onPlace(result, placeInfo) {
       if (!Components.isSuccessCode(result)) {
         failed.push(placeInfo.guid);
       }
     };
     let onComplete = function onComplete(subject, topic, data) {
       Svc.Obs.remove(TOPIC_UPDATEPLACES_COMPLETE, onComplete);
       cb();
     };
     Svc.Obs.add(TOPIC_UPDATEPLACES_COMPLETE, onComplete);
     this._asyncHistory.updatePlaces(records, onPlace);
-    Utils.waitForSyncCallback(cb);
+    Async.waitForSyncCallback(cb);
     return failed;
   },
 
   /**
    * Converts a Sync history record to a mozIPlaceInfo.
    * 
    * Throws if an invalid record is encountered (invalid URI, etc.),
    * returns true if the record is to be applied, false otherwise
--- a/services/sync/modules/engines/tabs.js
+++ b/services/sync/modules/engines/tabs.js
@@ -174,17 +174,17 @@ TabStore.prototype = {
     return allTabs;
   },
 
   createRecord: function createRecord(id, collection) {
     let record = new TabSetRecord(collection, id);
     record.clientName = Clients.localName;
 
     // Don't provide any tabs to compare against and ignore the update later.
-    if (Svc.Private.privateBrowsingEnabled && !PBPrefs.get("autostart")) {
+    if (Svc.Private && Svc.Private.privateBrowsingEnabled && !PBPrefs.get("autostart")) {
       record.tabs = [];
       return record;
     }
 
     // Sort tabs in descending-used order to grab the most recently used
     let tabs = this.getAllTabs(true).sort(function(a, b) {
       return b.lastUsed - a.lastUsed;
     });
@@ -211,17 +211,17 @@ TabStore.prototype = {
 
     record.tabs = tabs;
     return record;
   },
 
   getAllIDs: function TabStore_getAllIds() {
     // Don't report any tabs if we're in private browsing for first syncs.
     let ids = {};
-    if (Svc.Private.privateBrowsingEnabled && !PBPrefs.get("autostart"))
+    if (Svc.Private && Svc.Private.privateBrowsingEnabled && !PBPrefs.get("autostart"))
       return ids;
 
     ids[Clients.localID] = true;
     return ids;
   },
 
   wipe: function TabStore_wipe() {
     this._remoteClients = {};
@@ -328,17 +328,17 @@ TabTracker.prototype = {
         break;
       case "private-browsing":
         if (aData == "enter" && !PBPrefs.get("autostart"))
           this.modified = false;
     }
   },
 
   onTab: function onTab(event) {
-    if (Svc.Private.privateBrowsingEnabled && !PBPrefs.get("autostart")) {
+    if (Svc.Private && Svc.Private.privateBrowsingEnabled && !PBPrefs.get("autostart")) {
       this._log.trace("Ignoring tab event from private browsing.");
       return;
     }
 
     this._log.trace("onTab event: " + event.type);
     this.modified = true;
 
     // For pageshow events, only give a partial score bump (~.1)
--- a/services/sync/modules/resource.js
+++ b/services/sync/modules/resource.js
@@ -41,16 +41,17 @@ const EXPORTED_SYMBOLS = ["Resource", "A
                           "Auth", "BrokenBasicAuthenticator",
                           "BasicAuthenticator", "NoOpAuthenticator"];
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cr = Components.results;
 const Cu = Components.utils;
 
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/ext/Observers.js");
 Cu.import("resource://services-sync/ext/Preferences.js");
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://services-sync/util.js");
 
 XPCOMUtils.defineLazyGetter(this, "Auth", function () {
   return new AuthMgr();
@@ -139,16 +140,21 @@ function AsyncResource(uri) {
     Log4Moz.Level[Svc.Prefs.get("log.logger.network.resources")];
   this.uri = uri;
   this._headers = {};
   this._onComplete = Utils.bind2(this, this._onComplete);
 }
 AsyncResource.prototype = {
   _logName: "Net.Resource",
 
+  // ** {{{ AsyncResource.serverTime }}} **
+  //
+  // Caches the latest server timestamp (X-Weave-Timestamp header).
+  serverTime: null,
+
   // The string to use as the base User-Agent in Sync requests.
   // These strings will look something like
   // 
   //   Firefox/4.0 FxSync/1.8.0.20100101.mobile
   //   
   // or
   // 
   //   Firefox Aurora/5.0a1 FxSync/1.9.0.20110409.desktop
@@ -156,79 +162,79 @@ AsyncResource.prototype = {
   _userAgent:
     Services.appinfo.name + "/" + Services.appinfo.version +  // Product.
     " FxSync/" + WEAVE_VERSION + "." +                        // Sync.
     Services.appinfo.appBuildID + ".",                        // Build.
 
   // Wait 5 minutes before killing a request.
   ABORT_TIMEOUT: 300000,
 
-  // ** {{{ Resource.authenticator }}} **
+  // ** {{{ AsyncResource.authenticator }}} **
   //
   // Getter and setter for the authenticator module
   // responsible for this particular resource. The authenticator
   // module may modify the headers to perform authentication
   // while performing a request for the resource, for example.
   get authenticator() {
     if (this._authenticator)
       return this._authenticator;
     else
       return Auth.lookupAuthenticator(this.spec);
   },
   set authenticator(value) {
     this._authenticator = value;
   },
 
-  // ** {{{ Resource.headers }}} **
+  // ** {{{ AsyncResource.headers }}} **
   //
   // Headers to be included when making a request for the resource.
   // Note: Header names should be all lower case, there's no explicit
   // check for duplicates due to case!
   get headers() {
     return this.authenticator.onRequest(this._headers);
   },
   set headers(value) {
     this._headers = value;
   },
   setHeader: function Res_setHeader(header, value) {
     this._headers[header.toLowerCase()] = value;
   },
 
-  // ** {{{ Resource.uri }}} **
+  // ** {{{ AsyncResource.uri }}} **
   //
   // URI representing this resource.
   get uri() {
     return this._uri;
   },
   set uri(value) {
     if (typeof value == 'string')
       this._uri = Utils.makeURI(value);
     else
       this._uri = value;
   },
 
-  // ** {{{ Resource.spec }}} **
+  // ** {{{ AsyncResource.spec }}} **
   //
   // Get the string representation of the URI.
   get spec() {
     if (this._uri)
       return this._uri.spec;
     return null;
   },
 
-  // ** {{{ Resource.data }}} **
+  // ** {{{ AsyncResource.data }}} **
   //
   // Get and set the data encapulated in the resource.
   _data: null,
   get data() this._data,
   set data(value) {
     this._data = value;
   },
 
-  // ** {{{ Resource._createRequest }}} **
+  // ** {{{ AsyncResource._createRequest }}} **
   //
   // This method returns a new IO Channel for requests to be made
   // through. It is never called directly, only {{{_doRequest}}} uses it
   // to obtain a request channel.
   //
   _createRequest: function Res__createRequest() {
     let channel = Services.io.newChannel(this.spec, null, null)
                           .QueryInterface(Ci.nsIRequest)
@@ -436,38 +442,33 @@ AsyncResource.prototype = {
  */
 function Resource(uri) {
   AsyncResource.call(this, uri);
 }
 Resource.prototype = {
 
   __proto__: AsyncResource.prototype,
 
-  // ** {{{ Resource.serverTime }}} **
-  //
-  // Caches the latest server timestamp (X-Weave-Timestamp header).
-  serverTime: null,
-
   // ** {{{ Resource._request }}} **
   //
   // Perform a particular HTTP request on the resource. This method
   // is never called directly, but is used by the high-level
   // {{{get}}}, {{{put}}}, {{{post}}} and {{delete}} methods.
   _request: function Res__request(action, data) {
-    let cb = Utils.makeSyncCallback();
+    let cb = Async.makeSyncCallback();
     function callback(error, ret) {
       if (error)
         cb.throw(error);
       cb(ret);
     }
 
     // The channel listener might get a failure code
     try {
       this._doRequest(action, data, callback);
-      return Utils.waitForSyncCallback(cb);
+      return Async.waitForSyncCallback(cb);
     } catch(ex) {
       // Combine the channel stack with this request stack.  Need to create
       // a new error object for that.
       let error = Error(ex.message);
       error.result = ex.result;
       let chanStack = [];
       if (ex.stack)
         chanStack = ex.stack.trim().split(/\n/).slice(1);
@@ -525,50 +526,51 @@ function ChannelListener(onComplete, onP
 ChannelListener.prototype = {
 
   onStartRequest: function Channel_onStartRequest(channel) {
     this._log.trace("onStartRequest called for channel " + channel + ".");
     channel.QueryInterface(Ci.nsIHttpChannel);
 
     // Save the latest server timestamp when possible.
     try {
-      Resource.serverTime = channel.getResponseHeader("X-Weave-Timestamp") - 0;
+      AsyncResource.serverTime = channel.getResponseHeader("X-Weave-Timestamp") - 0;
     }
     catch(ex) {}
 
     this._log.trace("onStartRequest: " + channel.requestMethod + " " +
                     channel.URI.spec);
     this._data = '';
     this.delayAbort();
   },
 
   onStopRequest: function Channel_onStopRequest(channel, context, status) {
     // Clear the abort timer now that the channel is done.
     this.abortTimer.clear();
 
     let success = Components.isSuccessCode(status);
+    let uri = channel && channel.URI && channel.URI.spec || "<unknown>";
     this._log.trace("Channel for " + channel.requestMethod + " " +
-                    channel.URI.spec + ": isSuccessCode(" + status + ")? " +
+                    uri + ": isSuccessCode(" + status + ")? " +
                     success);
 
     if (this._data == '')
       this._data = null;
 
     // Throw the failure code and stop execution.  Use Components.Exception()
     // instead of Error() so the exception is QI-able and can be passed across
     // XPCOM borders while preserving the status code.
     if (!success) {
       let message = Components.Exception("", status).name;
       let error = Components.Exception(message, status);
       this._onComplete(error);
       return;
     }
 
     this._log.trace("Channel: flags = " + channel.loadFlags +
-                    ", URI = " + channel.URI.spec +
+                    ", URI = " + uri +
                     ", HTTP success? " + channel.requestSucceeded);
     this._onComplete(null, this._data);
   },
 
   onDataAvailable: function Channel_onDataAvail(req, cb, stream, off, count) {
     let siStream = Cc["@mozilla.org/scriptableinputstream;1"].
       createInstance(Ci.nsIScriptableInputStream);
     siStream.init(stream);
--- a/services/sync/modules/util.js
+++ b/services/sync/modules/util.js
@@ -49,22 +49,16 @@ Cu.import("resource://services-sync/ext/
 Cu.import("resource://services-sync/ext/StringBundle.js");
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/PlacesUtils.jsm");
 Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/FileUtils.jsm");
 
-// Constants for makeSyncCallback, waitForSyncCallback
-const CB_READY = {};
-const CB_COMPLETE = {};
-const CB_FAIL = {};
-const REASON_ERROR = Ci.mozIStorageStatementCallback.REASON_ERROR;
-
 /*
  * Utility functions
  */
 
 let Utils = {
   /**
    * Execute an arbitrary number of asynchronous functions one after the
    * other, passing the callback arguments on to the next one.  All functions
@@ -189,71 +183,35 @@ let Utils = {
       return callback.call(thisObj);
     } finally {
       if (hasTransaction) {
         db.commitTransaction();
       }
     }
   },
 
-  // Prototype for mozIStorageCallback, used in queryAsync below.
-  // This allows us to define the handle* functions just once rather
-  // than on every queryAsync invocation.
-  _storageCallbackPrototype: {
-    results: null,
 
-    // These are set by queryAsync.
-    names: null,
-    syncCb: null,
+  /*
+   * Partition the input array into an array of arrays. Return a generator.
+   */
+  slices: function slices(arr, sliceSize) {
+    if (!sliceSize || sliceSize <= 0)
+      throw "Invalid slice size.";
 
-    handleResult: function handleResult(results) {
-      if (!this.names) {
-        return;
-      }
-      if (!this.results) {
-        this.results = [];
-      }
-      let row;
-      while ((row = results.getNextRow()) != null) {
-        let item = {};
-        for each (name in this.names) {
-          item[name] = row.getResultByName(name);
-        }
-        this.results.push(item);
+    if (sliceSize > arr.length) {
+      yield arr;
+    } else {
+      let offset = 0;
+      while (arr.length > offset) {
+        yield arr.slice(offset, offset + sliceSize);
+        offset += sliceSize;
       }
-    },
-    handleError: function handleError(error) {
-      this.syncCb.throw(error);
-    },
-    handleCompletion: function handleCompletion(reason) {
-
-      // If we got an error, handleError will also have been called, so don't
-      // call the callback! We never cancel statements, so we don't need to
-      // address that quandary.
-      if (reason == REASON_ERROR)
-        return;
-
-      // If we were called with column names but didn't find any results,
-      // the calling code probably still expects an array as a return value.
-      if (this.names && !this.results) {
-        this.results = [];
-      }
-      this.syncCb(this.results);
     }
   },
 
-  queryAsync: function(query, names) {
-    // Synchronously asyncExecute fetching all results by name
-    let storageCallback = {names: names,
-                           syncCb: Utils.makeSyncCallback()};
-    storageCallback.__proto__ = Utils._storageCallbackPrototype;
-    query.executeAsync(storageCallback);
-    return Utils.waitForSyncCallback(storageCallback.syncCb);
-  },
-
   byteArrayToString: function byteArrayToString(bytes) {
     return [String.fromCharCode(byte) for each (byte in bytes)].join("");
   },
 
   /**
    * Generate a string of random bytes.
    */
   generateRandomBytes: function generateRandomBytes(length) {
@@ -1158,26 +1116,33 @@ let Utils = {
   // See Bug 562431, comment 4.
   safeAtoB: function safeAtoB(b64) {
     let len = b64.length;
     let over = len % 4;
     return over ? atob(b64.substr(0, len - over)) : atob(b64);
   },
 
   /**
-   * Create an array like the first but without elements of the second
+   * Create an array like the first but without elements of the second. Reuse
+   * arrays if possible.
    */
   arraySub: function arraySub(minuend, subtrahend) {
+    if (!minuend.length || !subtrahend.length)
+      return minuend;
     return minuend.filter(function(i) subtrahend.indexOf(i) == -1);
   },
 
   /**
-   * Build the union of two arrays.
+   * Build the union of two arrays. Reuse arrays if possible.
    */
   arrayUnion: function arrayUnion(foo, bar) {
+    if (!foo.length)
+      return bar;
+    if (!bar.length)
+      return foo;
     return foo.concat(Utils.arraySub(bar, foo));
   },
 
   bind2: function Async_bind2(object, method) {
     return function innerBind() { return method.apply(object, arguments); };
   },
 
   mpLocked: function mpLocked() {
@@ -1206,84 +1171,27 @@ let Utils = {
     try {
       sdr.encryptString("bacon");
       return true;
     } catch(e) {}
     return false;
   },
   
   /**
-   * Helpers for making asynchronous calls within a synchronous API possible.
-   * 
-   * If you value your sanity, do not look closely at the following functions.
-   */
-
-  /**
    * Check if the app is ready (not quitting)
    */
   checkAppReady: function checkAppReady() {
     // Watch for app-quit notification to stop any sync calls
     Svc.Obs.add("quit-application", function() {
       Utils.checkAppReady = function() {
         throw Components.Exception("App. Quitting", Cr.NS_ERROR_ABORT);
       };
     });
     // In the common case, checkAppReady just returns true
     return (Utils.checkAppReady = function() true)();
-  },
-
-  /**
-   * Create a sync callback that remembers state like whether it's been called
-   */
-  makeSyncCallback: function makeSyncCallback() {
-    // The main callback remembers the value it's passed and that it got data
-    let onComplete = function onComplete(data) {
-      onComplete.state = CB_COMPLETE;
-      onComplete.value = data;
-    };
-
-    // Initialize private callback data to prepare to be called
-    onComplete.state = CB_READY;
-    onComplete.value = null;
-
-    // Allow an alternate callback to trigger an exception to be thrown
-    onComplete.throw = function onComplete_throw(data) {
-      onComplete.state = CB_FAIL;
-      onComplete.value = data;
-
-      // Cause the caller to get an exception and stop execution
-      throw data;
-    };
-
-    return onComplete;
-  },
-
-  /**
-   * Wait for a sync callback to finish
-   */
-  waitForSyncCallback: function waitForSyncCallback(callback) {
-    // Grab the current thread so we can make it give up priority
-    let thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
-
-    // Keep waiting until our callback is triggered unless the app is quitting
-    while (Utils.checkAppReady() && callback.state == CB_READY) {
-      thread.processNextEvent(true);
-    }
-
-    // Reset the state of the callback to prepare for another call
-    let state = callback.state;
-    callback.state = CB_READY;
-
-    // Throw the value the callback decided to fail with
-    if (state == CB_FAIL) {
-      throw callback.value;
-    }
-
-    // Return the value passed to the callback
-    return callback.value;
   }
 };
 
 XPCOMUtils.defineLazyGetter(Utils, "_utf8Converter", function() {
   let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                     .createInstance(Ci.nsIScriptableUnicodeConverter);
   converter.charset = "UTF-8";
   return converter;
@@ -1299,22 +1207,31 @@ Svc.Obs = Observers;
 
 let _sessionCID = Services.appinfo.ID == SEAMONKEY_ID ?
   "@mozilla.org/suite/sessionstore;1" :
   "@mozilla.org/browser/sessionstore;1";
 
 [["Form", "@mozilla.org/satchel/form-history;1", "nsIFormHistory2"],
  ["Idle", "@mozilla.org/widget/idleservice;1", "nsIIdleService"],
  ["KeyFactory", "@mozilla.org/security/keyobjectfactory;1", "nsIKeyObjectFactory"],
- ["Private", "@mozilla.org/privatebrowsing;1", "nsIPrivateBrowsingService"],
  ["Session", _sessionCID, "nsISessionStore"]
 ].forEach(function([name, contract, iface]) {
   XPCOMUtils.defineLazyServiceGetter(Svc, name, contract, iface);
 });
 
+// nsIPrivateBrowsingService is not implemented in mobile Firefox.
+// Svc.Private should just return undefined in this case instead of throwing.
+XPCOMUtils.defineLazyGetter(Svc, "Private", function() {
+  try {
+    return Cc["@mozilla.org/privatebrowsing;1"].getService(Ci["nsIPrivateBrowsingService"]);
+  } catch (e) {
+    return undefined;
+  }
+});
+
 Svc.__defineGetter__("Crypto", function() {
   let cryptoSvc;
   let ns = {};
   Cu.import("resource://services-crypto/WeaveCrypto.js", ns);
   cryptoSvc = new ns.WeaveCrypto();
   delete Svc.Crypto;
   return Svc.Crypto = cryptoSvc;
 });
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_async_helpers.js
@@ -0,0 +1,297 @@
+Cu.import("resource://services-sync/async.js");
+
+function chain(fs) {
+  fs.reduce(function (prev, next) next.bind(this, prev),
+            run_next_test)();
+}
+
+// barrieredCallbacks.
+add_test(function test_barrieredCallbacks() {
+  let s1called = false;
+  let s2called = false;
+
+  function reset() {
+    _(" > reset.");
+    s1called = s2called = false;
+  }
+  function succeed1(err, result) {
+    _(" > succeed1.");
+    s1called = true;
+  }
+  function succeed2(err, result) {
+    _(" > succeed2.");
+    s2called = true;
+  }
+  function fail1(err, result) {
+    _(" > fail1.");
+    return "failed";
+  }
+  function throw1(err, result) {
+    _(" > throw1.");
+    throw "Aieeee!";
+  }
+
+  function doneSequential(next, err) {
+    _(" > doneSequential.");
+    do_check_eq(err, "failed");
+    do_check_true(s1called);
+    do_check_true(s2called);
+    next();
+  }
+  function doneFailFirst(next, err) {
+    _(" > doneFailFirst.");
+    do_check_eq(err, "failed");
+    do_check_false(s1called);
+    do_check_false(s2called);
+    next();
+  }
+  function doneOnlySucceed(next, err) {
+    _(" > doneOnlySucceed.");
+    do_check_true(!err);
+    do_check_true(s1called);
+    do_check_true(s2called);
+    next();
+  }
+  function doneThrow(next, err) {
+    _(" > doneThrow.");
+    do_check_eq(err, "Aieeee!");
+    do_check_true(s1called);
+    do_check_false(s2called);
+    next();
+  }
+
+  function sequence_test(label, parts, end) {
+    return function (next) {
+      _("Sequence test '" + label + "':");
+      reset();
+      for (let cb in Async.barrieredCallbacks(parts, end.bind(this, next)))
+        cb();
+    };
+  }
+
+  chain(
+    [sequence_test("failFirst",
+                   [fail1, succeed1, succeed2],
+                   doneFailFirst),
+
+     sequence_test("sequentially",
+                   [succeed1, succeed2, fail1],
+                   doneSequential),
+
+     sequence_test("onlySucceed",
+                   [succeed1, succeed2],
+                   doneOnlySucceed),
+
+     sequence_test("throw",
+                   [succeed1, throw1, succeed2],
+                   doneThrow)]);
+
+});
+
+add_test(function test_empty_barrieredCallbacks() {
+  let err;
+  try {
+    Async.barrieredCallbacks([], function (err) { }).next();
+  } catch (ex) {
+    err = ex;
+  }
+  _("err is " + err);
+  do_check_true(err instanceof StopIteration);
+  run_next_test();
+});
+
+add_test(function test_no_output_barrieredCallbacks() {
+  let err;
+  try {
+    Async.barrieredCallbacks([function (x) {}], null);
+  } catch (ex) {
+    err = ex;
+  }
+  do_check_eq(err, "No output callback provided to barrieredCallbacks.");
+  run_next_test();
+});
+
+add_test(function test_serially() {
+  let called = {};
+  let i      = 1;
+  function reset() {
+    called = {};
+    i      = 0;
+  }
+
+  function f(x, cb) {
+    called[x] = ++i;
+    cb(null, x);
+  }
+
+  function err_on(expected) {
+    return function (err, result, context) {
+      if (err) {
+        return err;
+      }
+      if (result == expected) {
+        return expected;
+      }
+      _("Got " + result + ", passing.");
+    };
+  }
+
+  // Fail in the middle.
+  reset();
+  Async.serially(["a", "b", "d"], f, err_on("b"), function (err) {
+    do_check_eq(1, called["a"]);
+    do_check_eq(2, called["b"]);
+    do_check_false(!!called["d"]);
+    do_check_eq(err, "b");
+
+  // Don't fail.
+  reset();
+  Async.serially(["a", "d", "b"], f, err_on("x"), function (err) {
+    do_check_eq(1, called["a"]);
+    do_check_eq(3, called["b"]);
+    do_check_eq(2, called["d"]);
+    do_check_false(!!err);
+
+  // Empty inputs.
+  reset();
+  Async.serially([], f, err_on("a"), function (err) {
+    do_check_false(!!err);
+
+  reset();
+  Async.serially(undefined, f, err_on("a"), function (err) {
+    do_check_false(!!err);
+    run_next_test();
+  });
+  });
+  });
+  });
+});
+
+add_test(function test_countedCallback() {
+  let error   = null;
+  let output  = null;
+  let context = null;
+  let counter = 0;
+  function cb(err, result, ctx) {
+    counter++;
+    output  = result;
+    error   = err;
+    context = ctx;
+    if (err == "error!")
+      return "Oh dear.";
+  }
+
+  let c1;
+
+  c1 = Async.countedCallback(cb, 3, function (err) {
+    do_check_eq(2, counter);
+    do_check_eq("error!", error);
+    do_check_eq(2, output);
+    do_check_eq("b", context);
+    do_check_eq(err, "Oh dear.");
+
+    // If we call the counted callback again (once this output function is
+    // done, that is), then the component callback is not invoked.
+    Utils.delay(function () {
+      _("Don't expect component callback.");
+      c1("not", "running", "now");
+      do_check_eq(2, counter);
+      do_check_eq("error!", error);
+      do_check_eq(2, output);
+      do_check_eq("b", context);
+      run_next_test();
+    }, 1, this);
+  });
+
+  c1(1, "foo", "a");
+  do_check_eq(1, counter);
+  do_check_eq(1, error);
+  do_check_eq("foo", output);
+  do_check_eq("a", context);
+
+  c1("error!", 2, "b");
+  // Subsequent checks must now take place inside the 'done' callback... read
+  // above!
+});
+
+add_test(function test_finallyCallback() {
+  let fnCalled = false;
+  let cbCalled = false;
+  let error    = undefined;
+
+  function reset() {
+    fnCalled = cbCalled = false;
+    error = undefined;
+  }
+
+  function fn(arg) {
+    do_check_false(!!arg);
+    fnCalled = true;
+  }
+
+  function fnThrow(arg) {
+    do_check_false(!!arg);
+    fnCalled = true;
+    throw "Foo";
+  }
+
+  function cb(next, err) {
+    _("Called with " + err);
+    cbCalled = true;
+    error  = err;
+    next();
+  }
+
+  function allGood(next) {
+    reset();
+    let callback = cb.bind(this, function() {
+      do_check_true(fnCalled);
+      do_check_true(cbCalled);
+      do_check_false(!!error);
+      next();
+    });
+    Async.finallyCallback(callback, fn)(null);
+  }
+
+  function inboundErr(next) {
+    reset();
+    let callback = cb.bind(this, function() {
+      do_check_true(fnCalled);
+      do_check_true(cbCalled);
+      do_check_eq(error, "Baz");
+      next();
+    });
+    Async.finallyCallback(callback, fn)("Baz");
+  }
+
+  function throwsNoErr(next) {
+    reset();
+    let callback = cb.bind(this, function() {
+      do_check_true(fnCalled);
+      do_check_true(cbCalled);
+      do_check_eq(error, "Foo");
+      next();
+    });
+    Async.finallyCallback(callback, fnThrow)(null);
+  }
+
+  function throwsOverrulesErr(next) {
+    reset();
+    let callback = cb.bind(this, function() {
+      do_check_true(fnCalled);
+      do_check_true(cbCalled);
+      do_check_eq(error, "Foo");
+      next();
+    });
+    Async.finallyCallback(callback, fnThrow)("Bar");
+  }
+
+  chain([throwsOverrulesErr,
+         throwsNoErr,
+         inboundErr,
+         allGood]);
+});
+
+function run_test() {
+  run_next_test();
+}
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_async_querySpinningly.js
@@ -0,0 +1,85 @@
+_("Make sure querySpinningly will synchronously fetch rows for a query asyncly");
+Cu.import("resource://services-sync/async.js");
+
+const SQLITE_CONSTRAINT_VIOLATION = 19;  // http://www.sqlite.org/c3ref/c_abort.html
+
+function run_test() {
+  initTestLogging("Trace");
+
+  _("Using the form service to test queries");
+  function c(query) Svc.Form.DBConnection.createStatement(query);
+
+  _("Make sure the call is async and allows other events to process");
+  let isAsync = false;
+  Utils.delay(function() isAsync = true, 0);
+  do_check_false(isAsync);
+
+  _("Empty out the formhistory table");
+  let r0 = Async.querySpinningly(c("DELETE FROM moz_formhistory"));
+  do_check_eq(r0, null);
+
+  _("Make sure there's nothing there");
+  let r1 = Async.querySpinningly(c("SELECT 1 FROM moz_formhistory"));
+  do_check_eq(r1, null);
+
+  _("Insert a row");
+  let r2 = Async.querySpinningly(c("INSERT INTO moz_formhistory (fieldname, value) VALUES ('foo', 'bar')"));
+  do_check_eq(r2, null);
+
+  _("Request a known value for the one row");
+  let r3 = Async.querySpinningly(c("SELECT 42 num FROM moz_formhistory"), ["num"]);
+  do_check_eq(r3.length, 1);
+  do_check_eq(r3[0].num, 42);
+
+  _("Get multiple columns");
+  let r4 = Async.querySpinningly(c("SELECT fieldname, value FROM moz_formhistory"), ["fieldname", "value"]);
+  do_check_eq(r4.length, 1);
+  do_check_eq(r4[0].fieldname, "foo");
+  do_check_eq(r4[0].value, "bar");
+
+  _("Get multiple columns with a different order");
+  let r5 = Async.querySpinningly(c("SELECT fieldname, value FROM moz_formhistory"), ["value", "fieldname"]);
+  do_check_eq(r5.length, 1);
+  do_check_eq(r5[0].fieldname, "foo");
+  do_check_eq(r5[0].value, "bar");
+
+  _("Add multiple entries (sqlite doesn't support multiple VALUES)");
+  let r6 = Async.querySpinningly(c("INSERT INTO moz_formhistory (fieldname, value) SELECT 'foo', 'baz' UNION SELECT 'more', 'values'"));
+  do_check_eq(r6, null);
+
+  _("Get multiple rows");
+  let r7 = Async.querySpinningly(c("SELECT fieldname, value FROM moz_formhistory WHERE fieldname = 'foo'"), ["fieldname", "value"]);
+  do_check_eq(r7.length, 2);
+  do_check_eq(r7[0].fieldname, "foo");
+  do_check_eq(r7[1].fieldname, "foo");
+
+  _("Make sure updates work");
+  let r8 = Async.querySpinningly(c("UPDATE moz_formhistory SET value = 'updated' WHERE fieldname = 'more'"));
+  do_check_eq(r8, null);
+
+  _("Get the updated");
+  let r9 = Async.querySpinningly(c("SELECT value, fieldname FROM moz_formhistory WHERE fieldname = 'more'"), ["fieldname", "value"]);
+  do_check_eq(r9.length, 1);
+  do_check_eq(r9[0].fieldname, "more");
+  do_check_eq(r9[0].value, "updated");
+
+  _("Grabbing fewer fields than queried is fine");
+  let r10 = Async.querySpinningly(c("SELECT value, fieldname FROM moz_formhistory"), ["fieldname"]);
+  do_check_eq(r10.length, 3);
+
+  _("Generate an execution error");
+  let r11, except, query = c("INSERT INTO moz_formhistory (fieldname, value) VALUES ('one', NULL)");
+  try {
+    r11 = Async.querySpinningly(query);
+  } catch(e) {
+    except = e;
+  }
+  do_check_true(!!except);
+  do_check_eq(except.result, SQLITE_CONSTRAINT_VIOLATION);
+
+  _("Cleaning up");
+  Async.querySpinningly(c("DELETE FROM moz_formhistory"));
+
+  _("Make sure the timeout got to run before this function ends");
+  do_check_true(isAsync);
+}
--- a/services/sync/tests/unit/test_bookmark_engine.js
+++ b/services/sync/tests/unit/test_bookmark_engine.js
@@ -1,12 +1,13 @@
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/engines/bookmarks.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/log4moz.js");
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/util.js");
 
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://gre/modules/PlacesUtils.jsm");
 
 Engines.register(BookmarksEngine);
 var syncTesting = new SyncTestingInfrastructure();
 
--- a/services/sync/tests/unit/test_bookmark_smart_bookmarks.js
+++ b/services/sync/tests/unit/test_bookmark_smart_bookmarks.js
@@ -44,17 +44,17 @@ function clearBookmarks() {
   PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.tagsFolder);
   PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.toolbarFolder);
   PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.unfiledBookmarksFolder);
   startCount = smartBookmarkCount();
 }
   
 // Verify that Places smart bookmarks have their annotation uploaded and
 // handled locally.
-function test_annotation_uploaded() {
+add_test(function test_annotation_uploaded() {
   let startCount = smartBookmarkCount();
   
   _("Start count is " + startCount);
   
   if (startCount > 0) {
     // This can happen in XULRunner.
     clearBookmarks();
     _("Start count is now " + startCount);
@@ -163,23 +163,23 @@ function test_annotation_uploaded() {
     store.update(newRecord);
     do_check_eq("LeastVisited", PlacesUtils.annotations.getItemAnnotation(
       newID, SMART_BOOKMARKS_ANNO));
     
 
   } finally {
     // Clean up.
     store.wipe();
-    server.stop(do_test_finished);
     Svc.Prefs.resetBranch("");
     Records.clearCache();
+    server.stop(run_next_test);
   }
-}
+});
 
-function test_smart_bookmarks_duped() {
+add_test(function test_smart_bookmarks_duped() {
   let parent = PlacesUtils.toolbarFolderId;
   let uri =
     Utils.makeURI("place:redirectsMode=" +
                   Ci.nsINavHistoryQueryOptions.REDIRECTS_MODE_TARGET +
                   "&sort=" +
                   Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING +
                   "&maxResults=10");
   let title = "Most Visited";
@@ -230,19 +230,18 @@ function test_smart_bookmarks_duped() {
 
   } finally {
     // Clean up.
     store.wipe();
     server.stop(do_test_finished);
     Svc.Prefs.resetBranch("");
     Records.clearCache();
   }
-}
+});
 
 function run_test() {
   initTestLogging("Trace");
   Log4Moz.repository.getLogger("Engine.Bookmarks").level = Log4Moz.Level.Trace;
 
   generateNewKeys();
 
-  test_annotation_uploaded();
-  test_smart_bookmarks_duped();
+  run_next_test();
 }
--- a/services/sync/tests/unit/test_corrupt_keys.js
+++ b/services/sync/tests/unit/test_corrupt_keys.js
@@ -4,17 +4,17 @@ Cu.import("resource://services-sync/engi
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/status.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/engines/tabs.js");
 Cu.import("resource://services-sync/engines/history.js");
 Cu.import("resource://services-sync/log4moz.js");
   
-function test_locally_changed_keys() {
+add_test(function test_locally_changed_keys() {
   let passphrase = "abcdeabcdeabcdeabcdeabcdea";
 
   // Tracking info/collections.
   let collectionsHelper = track_collections_helper();
   let upd = collectionsHelper.with_updated_collection;
   let collections = collectionsHelper.collections;
 
   let keysWBO = new ServerWBO("keys");
@@ -28,17 +28,16 @@ function test_locally_changed_keys() {
     return function() {
       hmacErrorCount++;
       return f.call(this);
     };
   }
   
   Weave.Service.handleHMACEvent = counting(Weave.Service.handleHMACEvent);
   
-  do_test_pending();
   let server = httpd_setup({
     // Special.
     "/1.1/johndoe/storage/meta/global": upd("meta", meta_global.handler()),
     "/1.1/johndoe/info/collections": collectionsHelper.handler,
     "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()),
       
     // Track modified times.
     "/1.1/johndoe/storage/clients": upd("clients", clients.handler()),
@@ -222,23 +221,20 @@ function test_locally_changed_keys() {
     do_check_true(collections.crypto > old_key_time);
     do_check_eq(hmacErrorCount, 6);
     do_check_false(store.urlExists("http://foo/bar?record-no--5"));
     do_check_false(store.urlExists("http://foo/bar?record-no--6"));
     do_check_false(store.urlExists("http://foo/bar?record-no--7"));
     do_check_false(store.urlExists("http://foo/bar?record-no--8"));
     do_check_false(store.urlExists("http://foo/bar?record-no--9"));
     
-    // Clean up.
-    Weave.Service.startOver();
-    
   } finally {
     Weave.Svc.Prefs.resetBranch("");
-    server.stop(do_test_finished);
+    server.stop(run_next_test);
   }
-}
+});
 
 function run_test() {
   let logger = Log4Moz.repository.rootLogger;
   Log4Moz.repository.rootLogger.addAppender(new Log4Moz.DumpAppender());
   
-  test_locally_changed_keys();
+  run_next_test();
 }
--- a/services/sync/tests/unit/test_history_engine.js
+++ b/services/sync/tests/unit/test_history_engine.js
@@ -2,17 +2,17 @@ Cu.import("resource://services-sync/reco
 Cu.import("resource://services-sync/engines/history.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/identity.js");
 Cu.import("resource://services-sync/util.js");
 
 var syncTesting = new SyncTestingInfrastructure();
 
-function test_processIncoming_mobile_history_batched() {
+add_test(function test_processIncoming_mobile_history_batched() {
   _("SyncEngine._processIncoming works on history engine.");
 
   let FAKE_DOWNLOAD_LIMIT = 100;
   
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
   Svc.Prefs.set("client.type", "mobile");
   PlacesUtils.history.removeAllPages();
@@ -44,17 +44,16 @@ function test_processIncoming_mobile_his
     let wbo = new ServerWBO(id, payload);
     wbo.modified = modified;
     collection.wbos[id] = wbo;
   }
   
   let server = sync_httpd_setup({
       "/1.1/foo/storage/history": collection.handler()
   });
-  do_test_pending();
 
   let engine = new HistoryEngine("history");
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {history: {version: engine.version,
                                            syncID: engine.syncID}};
 
   try {
 
@@ -124,15 +123,15 @@ function test_processIncoming_mobile_his
     }
 
   } finally {
     PlacesUtils.history.removeAllPages();
     server.stop(do_test_finished);
     Svc.Prefs.resetBranch("");
     Records.clearCache();
   }
-}
+});
 
 function run_test() {
   generateNewKeys();
 
-  test_processIncoming_mobile_history_batched();
+  run_next_test();
 }
--- a/services/sync/tests/unit/test_history_store.js
+++ b/services/sync/tests/unit/test_history_store.js
@@ -1,10 +1,11 @@
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://services-sync/engines/history.js");
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/util.js");
 
 const TIMESTAMP1 = (Date.now() - 103406528) * 1000;
 const TIMESTAMP2 = (Date.now() - 6592903) * 1000;
 const TIMESTAMP3 = (Date.now() - 123894) * 1000;
 
 function queryPlaces(uri, options) {
   let query = PlacesUtils.history.getNewQuery();
@@ -161,17 +162,17 @@ add_test(function test_null_title() {
 });
 
 add_test(function test_invalid_records() {
   _("Make sure we handle invalid URLs in places databases gracefully.");
   let query = "INSERT INTO moz_places "
     + "(url, title, rev_host, visit_count, last_visit_date) "
     + "VALUES ('invalid-uri', 'Invalid URI', '.', 1, " + TIMESTAMP3 + ")";
   let stmt = PlacesUtils.history.DBConnection.createAsyncStatement(query);
-  let result = Utils.queryAsync(stmt);    
+  let result = Async.querySpinningly(stmt);
   do_check_eq([id for (id in store.getAllIDs())].length, 4);
 
   _("Make sure we report records with invalid URIs.");
   let invalid_uri_guid = Utils.makeGUID();
   let failed = store.applyIncomingBatch([{
     id: invalid_uri_guid,
     histUri: ":::::::::::::::",
     title: "Doesn't have a valid URI",
--- a/services/sync/tests/unit/test_places_guid_downgrade.js
+++ b/services/sync/tests/unit/test_places_guid_downgrade.js
@@ -1,8 +1,9 @@
+Cu.import("resource://services-sync/async.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/engines/history.js");
 Cu.import("resource://services-sync/engines/bookmarks.js");
 
 const kDBName = "places.sqlite";
 const storageSvc = Cc["@mozilla.org/storage/service;1"]
                      .getService(Ci.mozIStorageService);
@@ -99,33 +100,33 @@ function test_history_guids() {
   dump("fxguid: " + fxguid + "\n");
   dump("tbguid: " + tbguid + "\n");
 
   _("History: Verify GUIDs are added to the guid column.");
   let stmt = PlacesUtils.history.DBConnection.createAsyncStatement(
     "SELECT id FROM moz_places WHERE guid = :guid");
 
   stmt.params.guid = fxguid;
-  let result = Utils.queryAsync(stmt, ["id"]);
+  let result = Async.querySpinningly(stmt, ["id"]);
   do_check_eq(result.length, 1);
 
   stmt.params.guid = tbguid;
-  result = Utils.queryAsync(stmt, ["id"]);
+  result = Async.querySpinningly(stmt, ["id"]);
   do_check_eq(result.length, 1);
 
   _("History: Verify GUIDs weren't added to annotations.");
   stmt = PlacesUtils.history.DBConnection.createAsyncStatement(
     "SELECT a.content AS guid FROM moz_annos a WHERE guid = :guid");
 
   stmt.params.guid = fxguid;
-  result = Utils.queryAsync(stmt, ["guid"]);
+  result = Async.querySpinningly(stmt, ["guid"]);
   do_check_eq(result.length, 0);
 
   stmt.params.guid = tbguid;
-  result = Utils.queryAsync(stmt, ["guid"]);
+  result = Async.querySpinningly(stmt, ["guid"]);
   do_check_eq(result.length, 0);
 }
 
 function test_bookmark_guids() {
   let engine = new BookmarksEngine();
   let store = engine._store;
 
   let fxid = PlacesUtils.bookmarks.insertBookmark(
@@ -142,35 +143,35 @@ function test_bookmark_guids() {
   let fxguid = store.GUIDForId(fxid);
   let tbguid = store.GUIDForId(tbid);
 
   _("Bookmarks: Verify GUIDs are added to the guid column.");
   let stmt = PlacesUtils.history.DBConnection.createAsyncStatement(
     "SELECT id FROM moz_bookmarks WHERE guid = :guid");
 
   stmt.params.guid = fxguid;
-  let result = Utils.queryAsync(stmt, ["id"]);
+  let result = Async.querySpinningly(stmt, ["id"]);
   do_check_eq(result.length, 1);
   do_check_eq(result[0].id, fxid);
 
   stmt.params.guid = tbguid;
-  result = Utils.queryAsync(stmt, ["id"]);
+  result = Async.querySpinningly(stmt, ["id"]);
   do_check_eq(result.length, 1);
   do_check_eq(result[0].id, tbid);
 
   _("Bookmarks: Verify GUIDs weren't added to annotations.");
   stmt = PlacesUtils.history.DBConnection.createAsyncStatement(
     "SELECT a.content AS guid FROM moz_items_annos a WHERE guid = :guid");
 
   stmt.params.guid = fxguid;
-  result = Utils.queryAsync(stmt, ["guid"]);
+  result = Async.querySpinningly(stmt, ["guid"]);
   do_check_eq(result.length, 0);
 
   stmt.params.guid = tbguid;
-  result = Utils.queryAsync(stmt, ["guid"]);
+  result = Async.querySpinningly(stmt, ["guid"]);
   do_check_eq(result.length, 0);
 }
 
 function run_test() {
   setPlacesDatabase("places_v10_from_v11.sqlite");
 
   _("Verify initial setup: v11 database is available");
   test_initial_state();
--- a/services/sync/tests/unit/test_resource.js
+++ b/services/sync/tests/unit/test_resource.js
@@ -296,24 +296,23 @@ function run_test() {
 
   _("JSON conversion of response body");
   let res7 = new Resource("http://localhost:8080/json");
   content = res7.get();
   do_check_eq(content, JSON.stringify(sample_data));
   do_check_eq(content.status, 200);
   do_check_eq(JSON.stringify(content.obj), JSON.stringify(sample_data));
 
-  _("X-Weave-Timestamp header updates Resource.serverTime");
+  _("X-Weave-Timestamp header updates AsyncResource.serverTime");
   // Before having received any response containing the
-  // X-Weave-Timestamp header, Resource.serverTime is null.
-  do_check_eq(Resource.serverTime, null);
+  // X-Weave-Timestamp header, AsyncResource.serverTime is null.
+  do_check_eq(AsyncResource.serverTime, null);
   let res8 = new Resource("http://localhost:8080/timestamp");
   content = res8.get();
-  do_check_eq(Resource.serverTime, TIMESTAMP);
-
+  do_check_eq(AsyncResource.serverTime, TIMESTAMP);
 
   _("GET: no special request headers");
   let res9 = new Resource("http://localhost:8080/headers");
   content = res9.get();
   do_check_eq(content, '{}');
 
   _("PUT: Content-Type defaults to text/plain");
   content = res9.put('data');
@@ -480,10 +479,23 @@ function run_test() {
   error = undefined;
   try {
     content = res19.get();
   } catch (ex) {
     error = ex;
   }
   do_check_eq(error.result, Cr.NS_ERROR_NET_TIMEOUT);
 
+  _("Testing URI construction.");
+  let args = [];
+  args.push("newer=" + 1234);
+  args.push("limit=" + 1234);
+  args.push("sort=" + 1234);
+
+  let query = "?" + args.join("&");
+
+  let uri1 = Utils.makeURL("http://foo/" + query);
+  let uri2 = Utils.makeURL("http://foo/");
+  uri2.query = query;
+  do_check_eq(uri1.query, uri2.query);
+
   server.stop(do_test_finished);
 }
--- a/services/sync/tests/unit/test_resource_async.js
+++ b/services/sync/tests/unit/test_resource_async.js
@@ -408,25 +408,25 @@ function run_test() {
       do_check_eq(content.status, 200);
       do_check_eq(JSON.stringify(content.obj), JSON.stringify(sample_data));
       do_test_finished();
       next();
     }));
 
   }, function (next) {
 
-    _("X-Weave-Timestamp header updates Resource.serverTime");
+    _("X-Weave-Timestamp header updates AsyncResource.serverTime");
     do_test_pending();
     // Before having received any response containing the
-    // X-Weave-Timestamp header, Resource.serverTime is null.
-    do_check_eq(Resource.serverTime, null);
+    // X-Weave-Timestamp header, AsyncResource.serverTime is null.
+    do_check_eq(AsyncResource.serverTime, null);
     let res8 = new AsyncResource("http://localhost:8080/timestamp");
     res8.get(ensureThrows(function (error, content) {
       do_check_eq(error, null);
-      do_check_eq(Resource.serverTime, TIMESTAMP);
+      do_check_eq(AsyncResource.serverTime, TIMESTAMP);
       do_test_finished();
       next();
     }));
 
   }, function (next) {
 
     _("GET: no special request headers");
     do_test_pending();
@@ -680,14 +680,30 @@ function run_test() {
     res19.ABORT_TIMEOUT = 0;
     res19.get(ensureThrows(function (error, content) {
       do_check_eq(error.result, Cr.NS_ERROR_NET_TIMEOUT);
       next();
     }));
 
   }, function (next) {
 
+    _("Testing URI construction.");
+    let args = [];
+    args.push("newer=" + 1234);
+    args.push("limit=" + 1234);
+    args.push("sort=" + 1234);
+
+    let query = "?" + args.join("&");
+
+    let uri1 = Utils.makeURL("http://foo/" + query);
+    let uri2 = Utils.makeURL("http://foo/");
+    uri2.query = query;
+    do_check_eq(uri1.query, uri2.query);
+    next();
+
+  }, function (next) {
+
     // Don't quit test harness before server shuts down.
     server.stop(do_test_finished);
 
   })();
 
 }
--- a/services/sync/tests/unit/test_syncengine.js
+++ b/services/sync/tests/unit/test_syncengine.js
@@ -94,34 +94,66 @@ function test_toFetch() {
     do_check_eq(engine.toFetch.length, 2);
     do_check_eq(engine.toFetch[0], toFetch[0]);
     do_check_eq(engine.toFetch[1], toFetch[1]);
   } finally {
     syncTesting = new SyncTestingInfrastructure(makeSteamEngine);
   }
 }
 
+function test_previousFailed() {
+  _("SyncEngine.previousFailed corresponds to file on disk");
+  let syncTesting = new SyncTestingInfrastructure();
+  const filename = "weave/failed/steam.json";
+  let engine = makeSteamEngine();
+  try {
+    // Ensure pristine environment
+    do_check_eq(engine.previousFailed.length, 0);
+
+    // Write file to disk
+    let previousFailed = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()];
+    engine.previousFailed = previousFailed;
+    do_check_eq(engine.previousFailed, previousFailed);
+    // previousFailed is written asynchronously
+    engine._store._sleep(0);
+    let fakefile = syncTesting.fakeFilesystem.fakeContents[filename];
+    do_check_eq(fakefile, JSON.stringify(previousFailed));
+
+    // Read file from disk
+    previousFailed = [Utils.makeGUID(), Utils.makeGUID()];
+    syncTesting.fakeFilesystem.fakeContents[filename] = JSON.stringify(previousFailed);
+    engine.loadPreviousFailed();
+    do_check_eq(engine.previousFailed.length, 2);
+    do_check_eq(engine.previousFailed[0], previousFailed[0]);
+    do_check_eq(engine.previousFailed[1], previousFailed[1]);
+  } finally {
+    syncTesting = new SyncTestingInfrastructure(makeSteamEngine);
+  }
+}
+
 function test_resetClient() {
   _("SyncEngine.resetClient resets lastSync and toFetch");
   let syncTesting = new SyncTestingInfrastructure();
   let engine = makeSteamEngine();
   try {
     // Ensure pristine environment
     do_check_eq(Svc.Prefs.get("steam.lastSync"), undefined);
     do_check_eq(Svc.Prefs.get("steam.lastSyncLocal"), undefined);
     do_check_eq(engine.toFetch.length, 0);
 
     engine.lastSync = 123.45;
     engine.lastSyncLocal = 67890;
     engine.toFetch = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()];
+    engine.previousFailed = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()];
 
     engine.resetClient();
     do_check_eq(engine.lastSync, 0);
     do_check_eq(engine.lastSyncLocal, 0);
     do_check_eq(engine.toFetch.length, 0);
+    do_check_eq(engine.previousFailed.length, 0);
   } finally {
     syncTesting = new SyncTestingInfrastructure(makeSteamEngine);
     Svc.Prefs.resetBranch("");
   }
 }
 
 function test_wipeServer() {
   _("SyncEngine.wipeServer deletes server data and resets the client.");
@@ -154,11 +186,12 @@ function test_wipeServer() {
   }
 }
 
 function run_test() {
   test_url_attributes();
   test_syncID();
   test_lastSync();
   test_toFetch();
+  test_previousFailed();
   test_resetClient();
   test_wipeServer();
 }
--- a/services/sync/tests/unit/test_syncengine_sync.js
+++ b/services/sync/tests/unit/test_syncengine_sync.js
@@ -593,17 +593,17 @@ function test_processIncoming_store_toFe
     server.stop(do_test_finished);
     Svc.Prefs.resetBranch("");
     Records.clearCache();
   }
 }
 
 
 function test_processIncoming_resume_toFetch() {
-  _("toFetch items left over from previous syncs are fetched on the next sync, along with new items.");
+  _("toFetch and previousFailed items left over from previous syncs are fetched on the next sync, along with new items.");
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
   const LASTSYNC = Date.now() / 1000;
 
   // Server records that will be downloaded
   let collection = new ServerCollection();
@@ -611,25 +611,33 @@ function test_processIncoming_resume_toF
       'flying', encryptPayload({id: 'flying',
                                 denomination: "LNER Class A3 4472"}));
   collection.wbos.scotsman = new ServerWBO(
       'scotsman', encryptPayload({id: 'scotsman',
                                   denomination: "Flying Scotsman"}));
   collection.wbos.rekolok = new ServerWBO(
       'rekolok', encryptPayload({id: 'rekolok',
                                  denomination: "Rekonstruktionslokomotive"}));
+  for (var i = 0; i < 3; i++) {
+    let id = 'failed' + i;
+    let payload = encryptPayload({id: id, denomination: "Record No. " + i});
+    let wbo = new ServerWBO(id, payload);
+    wbo.modified = LASTSYNC - 10;
+    collection.wbos[id] = wbo;
+  }
 
   collection.wbos.flying.modified = collection.wbos.scotsman.modified
     = LASTSYNC - 10;
   collection.wbos.rekolok.modified = LASTSYNC + 10;
 
   // Time travel 10 seconds into the future but still download the above WBOs.
   let engine = makeSteamEngine();
   engine.lastSync = LASTSYNC;
   engine.toFetch = ["flying", "scotsman"];
+  engine.previousFailed = ["failed0", "failed1", "failed2"];
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {steam: {version: engine.version,
                                          syncID: engine.syncID}};
   let server = sync_httpd_setup({
       "/1.1/foo/storage/steam": collection.handler()
   });
   do_test_pending();
@@ -643,17 +651,20 @@ function test_processIncoming_resume_toF
 
     engine._syncStartup();
     engine._processIncoming();
 
     // Local records have been created from the server data.
     do_check_eq(engine._store.items.flying, "LNER Class A3 4472");
     do_check_eq(engine._store.items.scotsman, "Flying Scotsman");
     do_check_eq(engine._store.items.rekolok, "Rekonstruktionslokomotive");
-
+    do_check_eq(engine._store.items.failed0, "Record No. 0");
+    do_check_eq(engine._store.items.failed1, "Record No. 1");
+    do_check_eq(engine._store.items.failed2, "Record No. 2");
+    do_check_eq(engine.previousFailed.length, 0);
   } finally {
     server.stop(do_test_finished);
     Svc.Prefs.resetBranch("");
     Records.clearCache();
   }
 }
 
 
@@ -694,22 +705,23 @@ function test_processIncoming_applyIncom
   try {
 
     // Confirm initial environment
     do_check_eq([id for (id in engine._store.items)].length, 0);
 
     engine._syncStartup();
     engine._processIncoming();
 
-    // Records have been applied.
+    // Records have been applied and the expected failures have failed.
     do_check_eq([id for (id in engine._store.items)].length,
                 APPLY_BATCH_SIZE - 1 - 2);
-    do_check_eq(engine.toFetch.length, 2);
-    do_check_eq(engine.toFetch[0], "record-no-0");
-    do_check_eq(engine.toFetch[1], "record-no-8");
+    do_check_eq(engine.toFetch.length, 0);
+    do_check_eq(engine.previousFailed.length, 2);
+    do_check_eq(engine.previousFailed[0], "record-no-0");
+    do_check_eq(engine.previousFailed[1], "record-no-8");
 
   } finally {
     server.stop(do_test_finished);
     Svc.Prefs.resetBranch("");
     Records.clearCache();
   }
 }
 
@@ -765,16 +777,192 @@ function test_processIncoming_applyIncom
   } finally {
     server.stop(do_test_finished);
     Svc.Prefs.resetBranch("");
     Records.clearCache();
   }
 }
 
 
+function test_processIncoming_failed_items_reported_once() {
+  _("Ensure that failed records are reported only once.");
+  let syncTesting = new SyncTestingInfrastructure();
+  Svc.Prefs.set("clusterURL", "http://localhost:8080/");
+  Svc.Prefs.set("username", "foo");
+  
+  const APPLY_BATCH_SIZE = 5;
+  const NUMBER_OF_RECORDS = 15;
+
+  // Engine that fails the first record.
+  let engine = makeSteamEngine();
+  engine.applyIncomingBatchSize = APPLY_BATCH_SIZE;
+  engine._store._applyIncomingBatch = engine._store.applyIncomingBatch;
+  engine._store.applyIncomingBatch = function (records) {
+    engine._store._applyIncomingBatch(records.slice(1));
+    return [records[0].id];
+  };
+
+  // Create a batch of server side records.
+  let collection = new ServerCollection();
+  for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
+    let id = 'record-no-' + i;
+    let payload = encryptPayload({id: id, denomination: "Record No. " + id});
+    collection.wbos[id] = new ServerWBO(id, payload);
+  }
+
+  let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
+  meta_global.payload.engines = {steam: {version: engine.version,
+                                         syncID: engine.syncID}};
+  let server = sync_httpd_setup({
+      "/1.1/foo/storage/steam": collection.handler()
+  });
+  do_test_pending();
+
+  try {
+    let called = 0;
+    let counts;
+
+    // Confirm initial environment.
+    do_check_eq(engine.lastSync, 0);
+    do_check_eq(engine.toFetch.length, 0);
+    do_check_eq(engine.previousFailed.length, 0);
+    do_check_eq([id for (id in engine._store.items)].length, 0);
+
+    Svc.Obs.add("weave:engine:sync:apply-failed", function(count) {
+      _("Called with " + JSON.stringify(counts));
+      counts = count;
+      called++;
+    });
+
+    // Do sync.
+    engine._syncStartup();
+    engine._processIncoming();
+    
+    // Confirm failures.
+    do_check_eq([id for (id in engine._store.items)].length, 12);
+    do_check_eq(engine.previousFailed.length, 3);
+    do_check_eq(engine.previousFailed[0], "record-no-0");
+    do_check_eq(engine.previousFailed[1], "record-no-5");
+    do_check_eq(engine.previousFailed[2], "record-no-10");
+
+    // There are newly failed records and they are reported.
+    do_check_eq(called, 1);
+    do_check_eq(counts.failed, 3);
+    do_check_eq(counts.applied, 15);
+    do_check_eq(counts.newFailed, 3);
+
+    // Sync again, 1 of the failed items are the same, the rest didn't fail.
+    engine._processIncoming();
+    
+    // Confirming removed failures.
+    do_check_eq([id for (id in engine._store.items)].length, 14);
+    do_check_eq(engine.previousFailed.length, 1);
+    do_check_eq(engine.previousFailed[0], "record-no-0");
+
+    // Failures weren't notified again because there were no newly failed items.
+    do_check_eq(called, 1);
+    do_check_eq(counts.failed, 3);
+    do_check_eq(counts.applied, 15);
+    do_check_eq(counts.newFailed, 3);
+  } finally {
+    server.stop(do_test_finished);
+    Svc.Prefs.resetBranch("");
+    Records.clearCache();
+  }
+}
+
+
+function test_processIncoming_previousFailed() {
+  _("Ensure that failed records are retried.");
+  let syncTesting = new SyncTestingInfrastructure();
+  Svc.Prefs.set("clusterURL", "http://localhost:8080/");
+  Svc.Prefs.set("username", "foo");
+  Svc.Prefs.set("client.type", "mobile");
+  
+  const APPLY_BATCH_SIZE = 4;
+  const NUMBER_OF_RECORDS = 14;
+
+  // Engine that fails the first 2 records.
+  let engine = makeSteamEngine();
+  engine.mobileGUIDFetchBatchSize = engine.applyIncomingBatchSize = APPLY_BATCH_SIZE;  
+  engine._store._applyIncomingBatch = engine._store.applyIncomingBatch;
+  engine._store.applyIncomingBatch = function (records) {
+    engine._store._applyIncomingBatch(records.slice(2));
+    return [records[0].id, records[1].id];
+  };
+
+  // Create a batch of server side records.
+  let collection = new ServerCollection();
+  for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
+    let id = 'record-no-' + i;
+    let payload = encryptPayload({id: id, denomination: "Record No. " + i});
+    collection.wbos[id] = new ServerWBO(id, payload);
+  }
+
+  let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
+  meta_global.payload.engines = {steam: {version: engine.version,
+                                         syncID: engine.syncID}};
+  let server = sync_httpd_setup({
+      "/1.1/foo/storage/steam": collection.handler()
+  });
+  do_test_pending();
+
+  try {
+    // Confirm initial environment.
+    do_check_eq(engine.lastSync, 0);
+    do_check_eq(engine.toFetch.length, 0);
+    do_check_eq(engine.previousFailed.length, 0);
+    do_check_eq([id for (id in engine._store.items)].length, 0);
+
+    // Initial failed items in previousFailed to be reset.
+    let previousFailed = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()];
+    engine.previousFailed = previousFailed;
+    do_check_eq(engine.previousFailed, previousFailed);
+
+    // Do sync.
+    engine._syncStartup();
+    engine._processIncoming();
+
+    // Expected result: 4 sync batches with 2 failures each => 8 failures
+    do_check_eq([id for (id in engine._store.items)].length, 6);
+    do_check_eq(engine.previousFailed.length, 8);
+    do_check_eq(engine.previousFailed[0], "record-no-0");
+    do_check_eq(engine.previousFailed[1], "record-no-1");
+    do_check_eq(engine.previousFailed[2], "record-no-4");
+    do_check_eq(engine.previousFailed[3], "record-no-5");
+    do_check_eq(engine.previousFailed[4], "record-no-8");
+    do_check_eq(engine.previousFailed[5], "record-no-9");
+    do_check_eq(engine.previousFailed[6], "record-no-12");
+    do_check_eq(engine.previousFailed[7], "record-no-13");
+
+    // Sync again with the same failed items (records 0, 1, 8, 9).
+    engine._processIncoming();
+
+    // A second sync with the same failed items should not add the same items again.
+    // Items that did not fail a second time should no longer be in previousFailed.
+    do_check_eq([id for (id in engine._store.items)].length, 10);
+    do_check_eq(engine.previousFailed.length, 4);
+    do_check_eq(engine.previousFailed[0], "record-no-0");
+    do_check_eq(engine.previousFailed[1], "record-no-1");
+    do_check_eq(engine.previousFailed[2], "record-no-8");
+    do_check_eq(engine.previousFailed[3], "record-no-9");
+
+    // Refetched items that didn't fail the second time are in engine._store.items.
+    do_check_eq(engine._store.items['record-no-4'], "Record No. 4");
+    do_check_eq(engine._store.items['record-no-5'], "Record No. 5");
+    do_check_eq(engine._store.items['record-no-12'], "Record No. 12");
+    do_check_eq(engine._store.items['record-no-13'], "Record No. 13");
+  } finally {
+    server.stop(do_test_finished);
+    Svc.Prefs.resetBranch("");
+    Records.clearCache();
+  }
+}
+
+
 function test_processIncoming_failed_records() {
   _("Ensure that failed records from _reconcile and applyIncomingBatch are refetched.");
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
   // Let's create three and a bit batches worth of server side records.
   let collection = new ServerCollection();
@@ -836,16 +1024,17 @@ function test_processIncoming_failed_rec
   });
   do_test_pending();
 
   try {
 
     // Confirm initial environment
     do_check_eq(engine.lastSync, 0);
     do_check_eq(engine.toFetch.length, 0);
+    do_check_eq(engine.previousFailed.length, 0);
     do_check_eq([id for (id in engine._store.items)].length, 0);
 
     let observerSubject;
     let observerData;
     Svc.Obs.add("weave:engine:sync:apply-failed",
                 function onApplyFailed(subject, data) {
       Svc.Obs.remove("weave:engine:sync:apply-failed", onApplyFailed);
       observerSubject = subject;
@@ -855,21 +1044,21 @@ function test_processIncoming_failed_rec
     engine._syncStartup();
     engine._processIncoming();
 
     // Ensure that all records but the bogus 4 have been applied.
     do_check_eq([id for (id in engine._store.items)].length,
                 NUMBER_OF_RECORDS - BOGUS_RECORDS.length);
 
     // Ensure that the bogus records will be fetched again on the next sync.
-    do_check_eq(engine.toFetch.length, BOGUS_RECORDS.length);
-    engine.toFetch.sort();
+    do_check_eq(engine.previousFailed.length, BOGUS_RECORDS.length);
+    engine.previousFailed.sort();
     BOGUS_RECORDS.sort();
-    for (let i = 0; i < engine.toFetch.length; i++) {
-      do_check_eq(engine.toFetch[i], BOGUS_RECORDS[i]);
+    for (let i = 0; i < engine.previousFailed.length; i++) {
+      do_check_eq(engine.previousFailed[i], BOGUS_RECORDS[i]);
     }
 
     // Ensure the observer was notified
     do_check_eq(observerData, engine.name);
     do_check_eq(observerSubject.failed, BOGUS_RECORDS.length);
 
     // Testing batching of failed item fetches.
     // Try to sync again. Ensure that we split the request into chunks to avoid
@@ -947,34 +1136,35 @@ function test_processIncoming_decrypt_fa
       "/1.1/foo/storage/steam": collection.handler()
   });
   do_test_pending();
 
   try {
 
     // Confirm initial state
     do_check_eq(engine.toFetch.length, 0);
+    do_check_eq(engine.previousFailed.length, 0);
 
     let observerSubject;
     let observerData;
     Svc.Obs.add("weave:engine:sync:apply-failed",
                 function onApplyFailed(subject, data) {
       Svc.Obs.remove("weave:engine:sync:apply-failed", onApplyFailed);
       observerSubject = subject;
       observerData = data;
     });
 
     engine.lastSync = collection.wbos.nojson.modified - 1;
     engine.sync();
 
-    do_check_eq(engine.toFetch.length, 4);
-    do_check_eq(engine.toFetch[0], "nojson");
-    do_check_eq(engine.toFetch[1], "nojson2");
-    do_check_eq(engine.toFetch[2], "nodecrypt");
-    do_check_eq(engine.toFetch[3], "nodecrypt2");
+    do_check_eq(engine.previousFailed.length, 4);
+    do_check_eq(engine.previousFailed[0], "nojson");
+    do_check_eq(engine.previousFailed[1], "nojson2");
+    do_check_eq(engine.previousFailed[2], "nodecrypt");
+    do_check_eq(engine.previousFailed[3], "nodecrypt2");
 
     // Ensure the observer was notified
     do_check_eq(observerData, engine.name);
     do_check_eq(observerSubject.applied, 2);
     do_check_eq(observerSubject.failed, 4);
 
   } finally {
     server.stop(do_test_finished);
@@ -1452,16 +1642,18 @@ function run_test() {
   test_processIncoming_emptyServer();
   test_processIncoming_createFromServer();
   test_processIncoming_reconcile();
   test_processIncoming_mobile_batchSize();
   test_processIncoming_store_toFetch();
   test_processIncoming_resume_toFetch();
   test_processIncoming_applyIncomingBatchSize_smaller();
   test_processIncoming_applyIncomingBatchSize_multiple();
+  test_processIncoming_failed_items_reported_once();
+  test_processIncoming_previousFailed();
   test_processIncoming_failed_records();
   test_processIncoming_decrypt_failed();
   test_uploadOutgoing_toEmptyServer();
   test_uploadOutgoing_failed();
   test_uploadOutgoing_MAX_UPLOAD_RECORDS();
   test_syncFinish_noDelete();
   test_syncFinish_deleteByIds();
   test_syncFinish_deleteLotsInBatches();
deleted file mode 100644
--- a/services/sync/tests/unit/test_utils_queryAsync.js
+++ /dev/null
@@ -1,85 +0,0 @@
-_("Make sure queryAsync will synchronously fetch rows for a query asyncly");
-Cu.import("resource://services-sync/util.js");
-
-const SQLITE_CONSTRAINT_VIOLATION = 19;  // http://www.sqlite.org/c3ref/c_abort.html
-
-function run_test() {
-  initTestLogging("Trace");
-
-  _("Using the form service to test queries");
-  function c(query) Svc.Form.DBConnection.createStatement(query);
-
-  _("Make sure the call is async and allows other events to process");
-  let isAsync = false;
-  Utils.delay(function() isAsync = true, 0);
-  do_check_false(isAsync);
-
-  _("Empty out the formhistory table");
-  let r0 = Utils.queryAsync(c("DELETE FROM moz_formhistory"));
-  do_check_eq(r0, null);
-
-  _("Make sure there's nothing there");
-  let r1 = Utils.queryAsync(c("SELECT 1 FROM moz_formhistory"));
-  do_check_eq(r1, null);
-
-  _("Insert a row");
-  let r2 = Utils.queryAsync(c("INSERT INTO moz_formhistory (fieldname, value) VALUES ('foo', 'bar')"));
-  do_check_eq(r2, null);
-
-  _("Request a known value for the one row");
-  let r3 = Utils.queryAsync(c("SELECT 42 num FROM moz_formhistory"), ["num"]);
-  do_check_eq(r3.length, 1);
-  do_check_eq(r3[0].num, 42);
-
-  _("Get multiple columns");
-  let r4 = Utils.queryAsync(c("SELECT fieldname, value FROM moz_formhistory"), ["fieldname", "value"]);
-  do_check_eq(r4.length, 1);
-  do_check_eq(r4[0].fieldname, "foo");
-  do_check_eq(r4[0].value, "bar");
-
-  _("Get multiple columns with a different order");
-  let r5 = Utils.queryAsync(c("SELECT fieldname, value FROM moz_formhistory"), ["value", "fieldname"]);
-  do_check_eq(r5.length, 1);
-  do_check_eq(r5[0].fieldname, "foo");
-  do_check_eq(r5[0].value, "bar");
-
-  _("Add multiple entries (sqlite doesn't support multiple VALUES)");
-  let r6 = Utils.queryAsync(c("INSERT INTO moz_formhistory (fieldname, value) SELECT 'foo', 'baz' UNION SELECT 'more', 'values'"));
-  do_check_eq(r6, null);
-
-  _("Get multiple rows");
-  let r7 = Utils.queryAsync(c("SELECT fieldname, value FROM moz_formhistory WHERE fieldname = 'foo'"), ["fieldname", "value"]);
-  do_check_eq(r7.length, 2);
-  do_check_eq(r7[0].fieldname, "foo");
-  do_check_eq(r7[1].fieldname, "foo");
-
-  _("Make sure updates work");
-  let r8 = Utils.queryAsync(c("UPDATE moz_formhistory SET value = 'updated' WHERE fieldname = 'more'"));
-  do_check_eq(r8, null);
-
-  _("Get the updated");
-  let r9 = Utils.queryAsync(c("SELECT value, fieldname FROM moz_formhistory WHERE fieldname = 'more'"), ["fieldname", "value"]);
-  do_check_eq(r9.length, 1);
-  do_check_eq(r9[0].fieldname, "more");
-  do_check_eq(r9[0].value, "updated");
-
-  _("Grabbing fewer fields than queried is fine");
-  let r10 = Utils.queryAsync(c("SELECT value, fieldname FROM moz_formhistory"), ["fieldname"]);
-  do_check_eq(r10.length, 3);
-
-  _("Generate an execution error");
-  let r11, except, query = c("INSERT INTO moz_formhistory (fieldname, value) VALUES ('one', NULL)");
-  try {
-    r11 = Utils.queryAsync(query);
-  } catch(e) {
-    except = e;
-  }
-  do_check_true(!!except);
-  do_check_eq(except.result, SQLITE_CONSTRAINT_VIOLATION);
-
-  _("Cleaning up");
-  Utils.queryAsync(c("DELETE FROM moz_formhistory"));
-
-  _("Make sure the timeout got to run before this function ends");
-  do_check_true(isAsync);
-}
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_utils_slices.js
@@ -0,0 +1,48 @@
+Cu.import("resource://services-sync/util.js");
+
+function run_test() {
+  let input = [1, 2, 3, 4, 5];
+
+  let err;
+  try {
+    Utils.slices(input, 0).next();
+  } catch (ex) {
+    err = ex;
+  }
+  do_check_eq("Invalid slice size.", err);
+
+  err = undefined;
+  try {
+    Utils.slices(input).next();
+  } catch (ex) {
+    err = ex;
+  }
+  do_check_eq("Invalid slice size.", err);
+
+  function slurp(g) [x for each (x in g)]
+
+  let sliced1 = slurp(Utils.slices(input, 1));
+  let sliced2 = slurp(Utils.slices(input, 2));
+  let sliced3 = slurp(Utils.slices(input, 5));
+  let sliced4 = slurp(Utils.slices(input, 7));
+
+  do_check_eq(sliced1.length, 5);
+  do_check_eq(sliced2.length, 3);
+  do_check_eq(sliced3.length, 1);
+  do_check_eq(sliced4.length, 1);
+  sliced1.every(function(x) x.length == 1);
+  _(JSON.stringify(sliced2));
+  do_check_eq(sliced2[0].length, 2);
+  do_check_eq(sliced2[1].length, 2);
+  do_check_eq(sliced2[2].length, 1);
+  sliced3.every(function(x) x.length == 5);
+  sliced4.every(function(x) x.length == 5);
+
+  let sliced5 = slurp(Utils.slices(["foo"], 50));
+  do_check_eq(sliced5.length, 1);
+  do_check_eq(sliced5[0], "foo");
+
+  let sliced6 = slurp(Utils.slices([], 50));
+  do_check_eq(sliced6.length, 1);
+  do_check_eq(sliced6[0].length, 0);
+}
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -1,14 +1,16 @@
 [DEFAULT]
 head = head_appinfo.js head_helpers.js head_http_server.js
 tail = 
 
 [test_Observers.js]
 [test_Preferences.js]
+[test_async_helpers.js]
+[test_async_querySpinningly.js]
 [test_auth_manager.js]
 [test_bookmark_batch_fail.js]
 [test_bookmark_engine.js]
 [test_bookmark_legacy_microsummaries_support.js]
 [test_bookmark_livemarks.js]
 [test_bookmark_order.js]
 [test_bookmark_places_query_rewriting.js]
 [test_bookmark_record.js]
@@ -88,14 +90,14 @@ tail =
 [test_utils_json.js]
 [test_utils_lazyStrings.js]
 [test_utils_lock.js]
 [test_utils_makeGUID.js]
 [test_utils_makeURI.js]
 [test_utils_notify.js]
 [test_utils_passphrase.js]
 [test_utils_pbkdf2.js]
-[test_utils_queryAsync.js]
 [test_utils_sha1.js]
 [test_utils_sha1hmac.js]
 [test_utils_sha256HMAC.js]
+[test_utils_slices.js]
 [test_utils_stackTrace.js]
 [test_utils_utf8.js]
--- a/services/sync/version.txt
+++ b/services/sync/version.txt
@@ -1,1 +1,1 @@
-1.9.0
+1.10.0