Bug 1246341 - Include status codes in "ack" and "unregister" requests. r=dragana
authorKit Cambridge <kcambridge@mozilla.com>
Mon, 28 Mar 2016 12:29:25 -0700
changeset 328763 7aefa268e50cecfece06621123e5f6f2bd5e57ff
parent 328762 69fe21c66f7f9edb46c6269fa95d0f0079dab5fa
child 328764 210366388d9047c28ee2180a50c1bb193f40f069
push id6048
push userkmoir@mozilla.com
push dateMon, 06 Jun 2016 19:02:08 +0000
treeherdermozilla-beta@46d72a56c57d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdragana
bugs1246341
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1246341 - Include status codes in "ack" and "unregister" requests. r=dragana MozReview-Commit-ID: Gsh3FhTfvkX
dom/push/PushComponents.js
dom/push/PushService.jsm
dom/push/PushServiceAndroidGCM.jsm
dom/push/PushServiceHttp2.jsm
dom/push/PushServiceWebSocket.jsm
dom/push/test/xpcshell/test_clear_origin_data.js
dom/push/test/xpcshell/test_handler_service.js
dom/push/test/xpcshell/test_notification_ack.js
dom/push/test/xpcshell/test_notification_data.js
dom/push/test/xpcshell/test_permissions.js
dom/push/test/xpcshell/test_quota_exceeded.js
dom/push/test/xpcshell/test_quota_observer.js
dom/push/test/xpcshell/test_register_rollback.js
dom/push/test/xpcshell/test_unregister_success.js
--- a/dom/push/PushComponents.js
+++ b/dom/push/PushComponents.js
@@ -50,16 +50,17 @@ function PushServiceBase() {
 PushServiceBase.prototype = {
   classID: Components.ID("{daaa8d73-677e-4233-8acd-2c404bd01658}"),
   contractID: "@mozilla.org/push/Service;1",
   QueryInterface: XPCOMUtils.generateQI([
     Ci.nsIObserver,
     Ci.nsISupportsWeakReference,
     Ci.nsIPushService,
     Ci.nsIPushQuotaManager,
+    Ci.nsIPushErrorReporter,
   ]),
 
   pushTopic: OBSERVER_TOPIC_PUSH,
   subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
 
   _handleReady() {},
 
   _addListeners() {
@@ -112,16 +113,17 @@ Object.assign(PushServiceParent.prototyp
 
   _messages: [
     "Push:Register",
     "Push:Registration",
     "Push:Unregister",
     "Push:Clear",
     "Push:NotificationForOriginShown",
     "Push:NotificationForOriginClosed",
+    "Push:ReportError",
   ],
 
   // nsIPushService methods
 
   subscribe(scope, principal, callback) {
     return this._handleRequest("Push:Register", principal, {
       scope: scope,
     }).then(result => {
@@ -166,32 +168,42 @@ Object.assign(PushServiceParent.prototyp
   notificationForOriginShown(origin) {
     this.service.notificationForOriginShown(origin);
   },
 
   notificationForOriginClosed(origin) {
     this.service.notificationForOriginClosed(origin);
   },
 
+  // nsIPushErrorReporter methods
+
+  reportDeliveryError(messageId, reason) {
+    this.service.reportDeliveryError(messageId, reason);
+  },
+
   receiveMessage(message) {
     if (!this._isValidMessage(message)) {
       return;
     }
     let {name, principal, target, data} = message;
     if (name === "Push:NotificationForOriginShown") {
       this.notificationForOriginShown(data);
       return;
     }
     if (name === "Push:NotificationForOriginClosed") {
       this.notificationForOriginClosed(data);
       return;
     }
     if (!target.assertPermission("push")) {
       return;
     }
+    if (name === "Push:ReportError") {
+      this.reportDeliveryError(data.messageId, data.reason);
+      return;
+    }
     let sender = target.QueryInterface(Ci.nsIMessageSender);
     return this._handleRequest(name, principal, data).then(result => {
       sender.sendAsyncMessage(this._getResponseName(name, "OK"), {
         requestID: data.requestID,
         result: result
       });
     }, error => {
       sender.sendAsyncMessage(this._getResponseName(name, "KO"), {
@@ -349,16 +361,25 @@ Object.assign(PushServiceContent.prototy
   notificationForOriginShown(origin) {
     this._mm.sendAsyncMessage("Push:NotificationForOriginShown", origin);
   },
 
   notificationForOriginClosed(origin) {
     this._mm.sendAsyncMessage("Push:NotificationForOriginClosed", origin);
   },
 
+  // nsIPushErrorReporter methods
+
+  reportDeliveryError(messageId, reason) {
+    this._mm.sendAsyncMessage("Push:ReportError", {
+      messageId: messageId,
+      reason: reason,
+    });
+  },
+
   _addRequest(data) {
     let id = ++this._requestId;
     this._requests.set(id, data);
     return id;
   },
 
   _takeRequest(requestId) {
     let d = this._requests.get(requestId);
--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -308,36 +308,39 @@ this.PushService = {
       return Promise.resolve();
     }
 
     let pattern = JSON.parse(data);
     return this._db.clearIf(record => {
       if (!record.matchesOriginAttributes(pattern)) {
         return false;
       }
-      this._backgroundUnregister(record);
+      this._backgroundUnregister(record,
+                                 Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL);
       return true;
     });
   },
 
   /**
    * Sends an unregister request to the server in the background. If the
    * service is not connected, this function is a no-op.
    *
    * @param {PushRecord} record The record to unregister.
+   * @param {Number} reason An `nsIPushErrorReporter` unsubscribe reason,
+   *  indicating why this record was removed.
    */
-  _backgroundUnregister: function(record) {
+  _backgroundUnregister(record, reason) {
     console.debug("backgroundUnregister()");
 
     if (!this._service.isConnected() || !record) {
       return;
     }
 
     console.debug("backgroundUnregister: Notifying server", record);
-    this._sendUnregister(record).catch(e => {
+    this._sendUnregister(record, reason).catch(e => {
       console.error("backgroundUnregister: Error notifying server", e);
     });
   },
 
   // utility function used to add/remove observers in startObservers() and
   // stopObservers()
   getNetworkStateChangeEventName: function() {
     try {
@@ -748,100 +751,124 @@ this.PushService = {
 
   /**
    * Dispatches an incoming message to a service worker, recalculating the
    * quota for the associated push registration. If the quota is exceeded,
    * the registration and message will be dropped, and the worker will not
    * be notified.
    *
    * @param {String} keyID The push registration ID.
-   * @param {String} message The message contents.
+   * @param {String} messageID The message ID, used to report service worker
+   *  delivery failures. For Web Push messages, this is the version. If empty,
+   *  failures will not be reported.
+   * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
    * @param {Object} cryptoParams The message encryption settings.
    * @param {Function} updateFunc A function that receives the existing
    *  registration record as its argument, and returns a new record. If the
    *  function returns `null` or `undefined`, the record will not be updated.
    *  `PushServiceWebSocket` uses this to drop incoming updates with older
    *  versions.
+   * @returns {Promise} Resolves with an `nsIPushErrorReporter` ack status
+   *  code, indicating whether the message was delivered successfully.
    */
-  receivedPushMessage: function(keyID, message, cryptoParams, updateFunc) {
+  receivedPushMessage(keyID, messageID, data, cryptoParams, updateFunc) {
     console.debug("receivedPushMessage()");
     Services.telemetry.getHistogramById("PUSH_API_NOTIFICATION_RECEIVED").add();
 
-    let shouldNotify = false;
+    return this._updateRecordAfterPush(keyID, updateFunc).then(record => {
+      if (!record) {
+        throw new Error("Ignoring update for key ID " + keyID);
+      }
+      // Update quota after the delay, at which point
+      // we check for visible notifications.
+      let timeoutID = setTimeout(_ =>
+        {
+          this._updateQuota(keyID);
+          if (!this._updateQuotaTimeouts.delete(timeoutID)) {
+            console.debug("receivedPushMessage: quota update timeout missing?");
+          }
+        }, prefs.get("quotaUpdateDelay"));
+      this._updateQuotaTimeouts.add(timeoutID);
+      return this._decryptAndNotifyApp(record, messageID, data, cryptoParams);
+    }).catch(error => {
+      console.error("receivedPushMessage: Error notifying app", error);
+      return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
+    });
+  },
+
+  /**
+   * Updates a registration record after receiving a push message.
+   *
+   * @param {String} keyID The push registration ID.
+   * @param {Function} updateFunc The function passed to `receivedPushMessage`.
+   * @returns {Promise} Resolves with the updated record, or `null` if the
+   *  record was not updated.
+   */
+  _updateRecordAfterPush(keyID, updateFunc) {
     return this.getByKeyID(keyID).then(record => {
       if (!record) {
         this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_KEY_NOT_FOUND);
         throw new Error("No record for key ID " + keyID);
       }
-      return record.getLastVisit();
-    }).then(lastVisit => {
-      // As a special case, don't notify the service worker if the user
-      // cleared their history.
-      shouldNotify = isFinite(lastVisit);
-      if (!shouldNotify) {
+      return record.getLastVisit().then(lastVisit => {
+        // As a special case, don't notify the service worker if the user
+        // cleared their history.
+        if (!isFinite(lastVisit)) {
           this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_NO_HISTORY);
-      }
-      return this._db.update(keyID, record => {
-        let newRecord = updateFunc(record);
-        if (!newRecord) {
-          this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_NO_VERSION_INCREMENT);
-          return null;
+          throw new Error("Ignoring message sent to unvisited origin");
         }
-        // Because `unregister` is advisory only, we can still receive messages
-        // for stale Simple Push registrations from the server. To work around
-        // this, we check if the record has expired before *and* after updating
-        // the quota.
-        if (newRecord.isExpired()) {
-          console.error("receivedPushMessage: Ignoring update for expired key ID",
-            keyID);
-          return null;
-        }
-        newRecord.receivedPush(lastVisit);
-        return newRecord;
+        return lastVisit;
+      }).then(lastVisit => {
+        // Update the record, resetting the quota if the user has visited the
+        // site since the last push.
+        return this._db.update(keyID, record => {
+          let newRecord = updateFunc(record);
+          if (!newRecord) {
+            this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_NO_VERSION_INCREMENT);
+            return null;
+          }
+          // Because `unregister` is advisory only, we can still receive messages
+          // for stale Simple Push registrations from the server. To work around
+          // this, we check if the record has expired before *and* after updating
+          // the quota.
+          if (newRecord.isExpired()) {
+            return null;
+          }
+          newRecord.receivedPush(lastVisit);
+          return newRecord;
+        });
       });
-    }).then(record => {
-      var notified = false;
-      if (!record) {
-        return notified;
-      }
-      let decodedPromise;
-      if (cryptoParams) {
-        decodedPromise = PushCrypto.decodeMsg(
-          message,
-          record.p256dhPrivateKey,
-          record.p256dhPublicKey,
-          cryptoParams.dh,
-          cryptoParams.salt,
-          cryptoParams.rs,
-          cryptoParams.auth ? record.authenticationSecret : null,
-          cryptoParams.padSize
-        );
-      } else {
-        decodedPromise = Promise.resolve(null);
-      }
-      return decodedPromise.then(message => {
-        if (shouldNotify) {
-          notified = this._notifyApp(record, message);
-        }
-        // Update quota after the delay, at which point
-        // we check for visible notifications.
-        let timeoutID = setTimeout(_ =>
-          {
-            this._updateQuota(keyID);
-            if (!this._updateQuotaTimeouts.delete(timeoutID)) {
-              console.debug("receivedPushMessage: quota update timeout missing?");
-            }
-          }, prefs.get("quotaUpdateDelay"));
-        this._updateQuotaTimeouts.add(timeoutID);
-        return notified;
-      }, error => {
-        console.error("receivedPushMessage: Error decrypting message", error);
-      });
-    }).catch(error => {
-      console.error("receivedPushMessage: Error notifying app", error);
+    });
+  },
+
+  /**
+   * Decrypts an incoming message and notifies the associated service worker.
+   *
+   * @param {PushRecord} record The receiving registration.
+   * @param {String} messageID The message ID.
+   * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
+   * @param {Object} cryptoParams The message encryption settings.
+   * @returns {Promise} Resolves with an ack status code.
+   */
+  _decryptAndNotifyApp(record, messageID, data, cryptoParams) {
+    if (!cryptoParams) {
+      return this._notifyApp(record, messageID, null);
+    }
+    return PushCrypto.decodeMsg(
+      data,
+      record.p256dhPrivateKey,
+      record.p256dhPublicKey,
+      cryptoParams.dh,
+      cryptoParams.salt,
+      cryptoParams.rs,
+      cryptoParams.auth ? record.authenticationSecret : null,
+      cryptoParams.padSize
+    ).then(message => this._notifyApp(record, messageID, message), error => {
+      console.error("receivedPushMessage: Error decrypting message", error);
+      return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR;
     });
   },
 
   _updateQuota: function(keyID) {
     console.debug("updateQuota()");
 
     this._db.update(keyID, record => {
       // Record may have expired from an earlier quota update.
@@ -857,17 +884,18 @@ this.PushService = {
       }
       return record;
     }).then(record => {
       if (record && record.isExpired()) {
         this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_EXPIRED);
         // Drop the registration in the background. If the user returns to the
         // site, the service worker will be notified on the next `idle-daily`
         // event.
-        this._backgroundUnregister(record);
+        this._backgroundUnregister(record,
+          Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED);
       }
       if (this._updateQuotaTestCallback) {
         // Callback so that test may be notified when the quota update is complete.
         this._updateQuotaTestCallback();
       }
     }).catch(error => {
       console.debug("updateQuota: Error while trying to update quota", error);
     });
@@ -895,68 +923,85 @@ this.PushService = {
     }
     if (count > 1) {
       this._visibleNotifications.set(origin, count - 1);
     } else {
       this._visibleNotifications.delete(origin);
     }
   },
 
-  _notifyApp: function(aPushRecord, message) {
+  reportDeliveryError(messageID, reason) {
+    console.debug("reportDeliveryError()", messageID, reason);
+    if (this._state == PUSH_SERVICE_RUNNING &&
+        this._service.isConnected()) {
+
+      // Only report errors if we're initialized and connected.
+      this._service.reportDeliveryError(messageID, reason);
+    }
+  },
+
+  _notifyApp(aPushRecord, messageID, message) {
     if (!aPushRecord || !aPushRecord.scope ||
         aPushRecord.originAttributes === undefined) {
       console.error("notifyApp: Invalid record", aPushRecord);
-      return false;
+      return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
     }
 
     console.debug("notifyApp()", aPushRecord.scope);
 
     // If permission has been revoked, trash the message.
     if (!aPushRecord.hasPermission()) {
       console.warn("notifyApp: Missing push permission", aPushRecord);
-      return false;
+      return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
     }
 
     let payload = ArrayBuffer.isView(message) ?
                   new Uint8Array(message.buffer) : message;
 
     if (aPushRecord.quotaApplies()) {
       // Don't record telemetry for chrome push messages.
       Services.telemetry.getHistogramById("PUSH_API_NOTIFY").add();
     }
 
     if (payload) {
       gPushNotifier.notifyPushWithData(aPushRecord.scope,
                                        aPushRecord.principal,
-                                       payload.length, payload);
+                                       messageID, payload.length, payload);
     } else {
-      gPushNotifier.notifyPush(aPushRecord.scope, aPushRecord.principal);
+      gPushNotifier.notifyPush(aPushRecord.scope, aPushRecord.principal,
+                               messageID);
     }
 
-    return true;
+    return Ci.nsIPushErrorReporter.ACK_DELIVERED;
   },
 
   getByKeyID: function(aKeyID) {
     return this._db.getByKeyID(aKeyID);
   },
 
   getAllUnexpired: function() {
     return this._db.getAllUnexpired();
   },
 
-  _sendRequest: function(action, aRecord) {
+  _sendRequest(action, ...params) {
     if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) {
       return Promise.reject(new Error("Push service disabled"));
     } else if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) {
       if (this._service.serviceType() == "WebSocket" && action == "unregister") {
         return Promise.resolve();
       }
       return Promise.reject(new Error("Push service offline"));
     }
-    return this._service.request(action, aRecord);
+    switch (action) {
+      case "register":
+        return this._service.register(...params);
+      case "unregister":
+        return this._service.unregister(...params);
+    }
+    return Promise.reject(new Error("Unknown request type: " + action));
   },
 
   /**
    * Called on message from the child process. aPageRecord is an object sent by
    * navigator.push, identifying the sending page and other fields.
    */
   _registerWithServer: function(aPageRecord) {
     console.debug("registerWithServer()", aPageRecord);
@@ -969,19 +1014,19 @@ this.PushService = {
         this._deletePendingRequest(aPageRecord);
         return record.toSubscription();
       }, err => {
         this._deletePendingRequest(aPageRecord);
         throw err;
      });
   },
 
-  _sendUnregister: function(aRecord) {
+  _sendUnregister(aRecord, aReason) {
     Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_ATTEMPT").add();
-    return this._sendRequest("unregister", aRecord).then(function(v) {
+    return this._sendRequest("unregister", aRecord, aReason).then(function(v) {
       Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_SUCCEEDED").add();
       return v;
     }).catch(function(e) {
       Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_FAILED").add();
       return Promise.reject(e);
     });
   },
 
@@ -995,17 +1040,18 @@ this.PushService = {
     return this._db.put(aRecord)
       .then(record => {
         Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_SUCCEEDED").add();
         return record;
       })
       .catch(error => {
         Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_FAILED").add()
         // Unable to save. Destroy the subscription in the background.
-        this._backgroundUnregister(aRecord);
+        this._backgroundUnregister(aRecord,
+                                   Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL);
         throw error;
       });
   },
 
   /**
    * Exceptions thrown in _onRegisterError are caught by the promise obtained
    * from _service.request, causing the promise to be rejected instead.
    */
@@ -1082,17 +1128,18 @@ this.PushService = {
 
     return this._getByPageRecord(aPageRecord)
       .then(record => {
         if (record === undefined) {
           return false;
         }
 
         return Promise.all([
-          this._sendUnregister(record),
+          this._sendUnregister(record,
+                               Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL),
           this._db.delete(record.keyID),
         ]).then(() => true);
       });
   },
 
   clear: function(info) {
     if (info.domain == "*") {
       return this._clearAll();
@@ -1192,17 +1239,18 @@ this.PushService = {
 
     if (data == "cleared") {
       // If the permission list was cleared, drop all registrations
       // that are subject to quota.
       return this._db.clearIf(record => {
         if (record.quotaApplies()) {
           if (!record.isExpired()) {
             // Drop the registration in the background.
-            this._backgroundUnregister(record);
+            this._backgroundUnregister(record,
+              Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED);
           }
           return true;
         }
         return false;
       });
     }
 
     let permission = subject.QueryInterface(Ci.nsIPermission);
@@ -1268,17 +1316,18 @@ this.PushService = {
   _permissionDenied: function(record, cursor) {
     console.debug("permissionDenied()");
 
     if (!record.quotaApplies() || record.isExpired()) {
       // Ignore already-expired records.
       return;
     }
     // Drop the registration in the background.
-    this._backgroundUnregister(record);
+    this._backgroundUnregister(record,
+      Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED);
     record.setQuota(0);
     cursor.update(record);
   },
 
   /**
    * The update function called for each registration record if the push
    * permission is granted. If the record has expired, it will be dropped;
    * otherwise, its quota will be reset to the default value.
--- a/dom/push/PushServiceAndroidGCM.jsm
+++ b/dom/push/PushServiceAndroidGCM.jsm
@@ -190,30 +190,18 @@ this.PushServiceAndroidGCM = {
   isConnected: function() {
     return this._mainPushService != null;
   },
 
   disconnect: function() {
     console.debug("disconnect");
   },
 
-  request: function(action, record) {
-    switch (action) {
-    case "register":
-      console.debug("register:", record);
-      return this._register(record);
-    case "unregister":
-      console.debug("unregister: ", record);
-      return this._unregister(record);
-    default:
-      console.debug("Ignoring unrecognized request action:", action);
-    }
-  },
-
-  _register: function(record) {
+  register: function(record) {
+    console.debug("register:", record);
     let ctime = Date.now();
     // Caller handles errors.
     return Messaging.sendRequestForResult({
       type: "PushServiceAndroidGCM:SubscribeChannel",
     }).then(data => {
       console.debug("Got data:", data);
       return PushCrypto.generateKeys()
         .then(exportedKeys =>
@@ -229,17 +217,18 @@ this.PushServiceAndroidGCM = {
             p256dhPublicKey: exportedKeys[0],
             p256dhPrivateKey: exportedKeys[1],
             authenticationSecret: PushCrypto.generateAuthenticationSecret(),
           })
       );
     });
   },
 
-  _unregister: function(record) {
+  unregister: function(record) {
+    console.debug("unregister: ", record);
     return Messaging.sendRequestForResult({
       type: "PushServiceAndroidGCM:UnsubscribeChannel",
       channelID: record.keyID,
     });
   },
 };
 
 function PushRecordAndroidGCM(record) {
--- a/dom/push/PushServiceHttp2.jsm
+++ b/dom/push/PushServiceHttp2.jsm
@@ -461,17 +461,17 @@ this.PushServiceHttp2 = {
                       .createInstance(Ci.nsILoadGroup);
     chan.loadGroup = loadGroup;
     return chan;
   },
 
   /**
    * Subscribe new resource.
    */
-  _subscribeResource: function(aRecord) {
+  register: function(aRecord) {
     console.debug("subscribeResource()");
 
     return this._subscribeResourceInternal({
       record: aRecord,
       retries: 0
     })
     .then(result =>
       PushCrypto.generateKeys()
@@ -702,37 +702,30 @@ this.PushServiceHttp2 = {
     this._mainPushService = null;
   },
 
   _abortPendingSubscriptionRetries: function() {
     this._listenersPendingRetry.forEach((listener) => listener.abortRetry());
     this._listenersPendingRetry.clear();
   },
 
-  request: function(action, aRecord) {
-    switch (action) {
-      case "register":
-        return this._subscribeResource(aRecord);
-     case "unregister":
-        this._shutdownSubscription(aRecord.subscriptionUri);
-        return this._unsubscribeResource(aRecord.subscriptionUri);
-      default:
-        return Promise.reject(new Error("Unknown request type: " + action));
-    }
+  unregister: function(aRecord) {
+    this._shutdownSubscription(aRecord.subscriptionUri);
+    return this._unsubscribeResource(aRecord.subscriptionUri);
   },
 
   /** Push server has deleted subscription.
    *  Re-subscribe - if it succeeds send update db record and send
    *                 pushsubscriptionchange,
    *               - on error delete record and send pushsubscriptionchange
    *  TODO: maybe pushsubscriptionerror will be included.
    */
   _resubscribe: function(aSubscriptionUri) {
     this._mainPushService.getByKeyID(aSubscriptionUri)
-      .then(record => this._subscribeResource(record)
+      .then(record => this.register(record)
         .then(recordNew => {
           if (this._mainPushService) {
             this._mainPushService
                 .updateRegistrationAndNotifyApp(aSubscriptionUri, recordNew)
                 .catch(Cu.reportError);
           }
         }, error => {
           if (this._mainPushService) {
@@ -785,17 +778,17 @@ this.PushServiceHttp2 = {
       console.debug("removeListenerPendingRetry: listener not in list?");
     }
   },
 
   _pushChannelOnStop: function(aUri, aAckUri, aMessage, cryptoParams) {
     console.debug("pushChannelOnStop()");
 
     this._mainPushService.receivedPushMessage(
-      aUri, aMessage, cryptoParams, record => {
+      aUri, "", aMessage, cryptoParams, record => {
         // Always update the stored record.
         return record;
       }
     )
     .then(_ => this._ackMsgRecv(aAckUri))
     .catch(err => {
       console.error("pushChannelOnStop: Error receiving message",
         err);
--- a/dom/push/PushServiceWebSocket.jsm
+++ b/dom/push/PushServiceWebSocket.jsm
@@ -41,16 +41,36 @@ var threadManager = Cc["@mozilla.org/thr
 const kPUSHWSDB_DB_NAME = "pushapi";
 const kPUSHWSDB_DB_VERSION = 5; // Change this if the IndexedDB format changes
 const kPUSHWSDB_STORE_NAME = "pushapi";
 
 const kUDP_WAKEUP_WS_STATUS_CODE = 4774;  // WebSocket Close status code sent
                                           // by server to signal that it can
                                           // wake client up using UDP.
 
+// Maps ack statuses, unsubscribe reasons, and delivery error reasons to codes
+// included in request payloads.
+const kACK_STATUS_TO_CODE = {
+  [Ci.nsIPushErrorReporter.ACK_DELIVERED]: 100,
+  [Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR]: 101,
+  [Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED]: 102,
+};
+
+const kUNREGISTER_REASON_TO_CODE = {
+  [Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL]: 200,
+  [Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED]: 201,
+  [Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED]: 202,
+};
+
+const kDELIVERY_REASON_TO_CODE = {
+  [Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION]: 301,
+  [Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION]: 302,
+  [Ci.nsIPushErrorReporter.DELIVERY_INTERNAL_ERROR]: 303,
+};
+
 const prefs = new Preferences("dom.push.");
 
 this.EXPORTED_SYMBOLS = ["PushServiceWebSocket"];
 
 XPCOMUtils.defineLazyGetter(this, "console", () => {
   let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
   return new ConsoleAPI({
     maxLogLevelPref: "dom.push.loglevel",
@@ -900,49 +920,48 @@ this.PushServiceWebSocket = {
 
   _handleDataUpdate: function(update) {
     let promise;
     if (typeof update.channelID != "string") {
       console.warn("handleDataUpdate: Discarding update without channel ID",
         update);
       return;
     }
-    // Unconditionally ack the update. This is important because the Push
-    // server requires the client to ack all outstanding updates before
-    // resuming delivery. However, the server doesn't verify the encryption
-    // params, and can't ensure that an update is encrypted correctly because
-    // it doesn't have the private key. Thus, if we only acked valid updates,
-    // it would be possible for a single invalid one to block delivery of all
-    // subsequent updates. A nack would be more appropriate for this case, but
-    // the protocol doesn't currently support them.
-    this._sendAck(update.channelID, update.version);
     if (typeof update.data != "string") {
       promise = this._mainPushService.receivedPushMessage(
         update.channelID,
+        update.version,
         null,
         null,
         record => record
       );
     } else {
       let params = getCryptoParams(update.headers);
-      if (!params) {
-        console.warn("handleDataUpdate: Discarding invalid encrypted message",
-          update);
-        return;
+      if (params) {
+        let message = base64UrlDecode(update.data);
+        promise = this._mainPushService.receivedPushMessage(
+          update.channelID,
+          update.version,
+          message,
+          params,
+          record => record
+        );
+      } else {
+        promise = Promise.reject(new Error("Invalid crypto headers"));
       }
-      let message = base64UrlDecode(update.data);
-      promise = this._mainPushService.receivedPushMessage(
-        update.channelID,
-        message,
-        params,
-        record => record
-      );
     }
-    promise.catch(err => {
-      console.error("handleDataUpdate: Error delivering message", err);
+    promise.then(status => {
+      this._sendAck(update.channelID, update.version, status);
+    }, err => {
+      console.error("handleDataUpdate: Error delivering message", update, err);
+      this._sendAck(update.channelID, update.version,
+        Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR);
+    }).catch(err => {
+      console.error("handleDataUpdate: Error acknowledging message", update,
+        err);
     });
   },
 
   /**
    * Protocol handler invoked by server message.
    */
   _handleNotificationReply: function(reply) {
     console.debug("handleNotificationReply()");
@@ -976,72 +995,94 @@ this.PushServiceWebSocket = {
       if (typeof version === "string") {
         version = parseInt(version, 10);
       }
 
       if (typeof version === "number" && version >= 0) {
         // FIXME(nsm): this relies on app update notification being infallible!
         // eventually fix this
         this._receivedUpdate(update.channelID, version);
-        this._sendAck(update.channelID, version);
       }
     }
   },
 
-  // FIXME(nsm): batch acks for efficiency reasons.
-  _sendAck: function(channelID, version) {
+  reportDeliveryError(messageID, reason) {
+    console.debug("reportDeliveryError()");
+    let code = kDELIVERY_REASON_TO_CODE[reason];
+    if (!code) {
+      throw new Error('Invalid delivery error reason');
+    }
+    let data = {messageType: 'nack',
+                version: messageID,
+                code: code};
+    this._queueRequest(data);
+  },
+
+  _sendAck(channelID, version, status) {
     console.debug("sendAck()");
-    var data = {messageType: 'ack',
+    let code = kACK_STATUS_TO_CODE[status];
+    if (!code) {
+      throw new Error('Invalid ack status');
+    }
+    let data = {messageType: 'ack',
                 updates: [{channelID: channelID,
-                           version: version}]
-               };
+                           version: version,
+                           code: code}]};
     this._queueRequest(data);
   },
 
   _generateID: function() {
     let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"]
                           .getService(Ci.nsIUUIDGenerator);
     // generateUUID() gives a UUID surrounded by {...}, slice them off.
     return uuidGenerator.generateUUID().toString().slice(1, -1);
   },
 
-  request: function(action, record) {
-    console.debug("request() ", action);
+  register(record) {
+    console.debug("register() ", record);
 
     // start the timer since we now have at least one request
     this._startRequestTimeoutTimer();
 
-    if (action == "register") {
-      let data = {channelID: this._generateID(),
-                  messageType: action};
+    let data = {channelID: this._generateID(),
+                messageType: "register"};
 
-      return new Promise((resolve, reject) => {
-        this._registerRequests.set(data.channelID, {
-          record: record,
-          resolve: resolve,
-          reject: reject,
-          ctime: Date.now(),
+    return new Promise((resolve, reject) => {
+      this._registerRequests.set(data.channelID, {
+        record: record,
+        resolve: resolve,
+        reject: reject,
+        ctime: Date.now(),
+      });
+      this._queueRequest(data);
+    }).then(record => {
+      if (!this._dataEnabled) {
+        return record;
+      }
+      return PushCrypto.generateKeys()
+        .then(([publicKey, privateKey]) => {
+          record.p256dhPublicKey = publicKey;
+          record.p256dhPrivateKey = privateKey;
+          record.authenticationSecret = PushCrypto.generateAuthenticationSecret();
+          return record;
         });
-        this._queueRequest(data);
-      }).then(record => {
-        if (!this._dataEnabled) {
-          return record;
-        }
-        return PushCrypto.generateKeys()
-          .then(([publicKey, privateKey]) => {
-            record.p256dhPublicKey = publicKey;
-            record.p256dhPrivateKey = privateKey;
-            record.authenticationSecret = PushCrypto.generateAuthenticationSecret();
-            return record;
-          });
-      });
+    });
+  },
+
+  unregister(record, reason) {
+    console.debug("unregister() ", record, reason);
+
+    let code = kUNREGISTER_REASON_TO_CODE[reason];
+    if (!code) {
+      return Promise.reject(new Error('Invalid unregister reason'));
     }
-
-    this._queueRequest({channelID: record.channelID,
-                        messageType: action});
+    let data = {channelID: record.channelID,
+                messageType: "unregister",
+                code: code};
+    this._queueRequest(data);
     return Promise.resolve();
   },
 
   _queueStart: Promise.resolve(),
   _notifyRequestQueue: null,
   _queue: null,
   _enqueue: function(op) {
     console.debug("enqueue()");
@@ -1099,27 +1140,32 @@ this.PushServiceWebSocket = {
         this._notifyRequestQueue = null;
       }
     }
   },
 
   _receivedUpdate: function(aChannelID, aLatestVersion) {
     console.debug("receivedUpdate: Updating", aChannelID, "->", aLatestVersion);
 
-    this._mainPushService.receivedPushMessage(aChannelID, null, null, record => {
+    this._mainPushService.receivedPushMessage(aChannelID, "", null, null, record => {
       if (record.version === null ||
           record.version < aLatestVersion) {
         console.debug("receivedUpdate: Version changed for", aChannelID,
           aLatestVersion);
         record.version = aLatestVersion;
         return record;
       }
       console.debug("receivedUpdate: No significant version change for",
         aChannelID, aLatestVersion);
       return null;
+    }).then(status => {
+      this._sendAck(aChannelID, aLatestVersion, status);
+    }).catch(err => {
+      console.error("receivedUpdate: Error acknowledging message", aChannelID,
+        aLatestVersion, err);
     });
   },
 
   // begin Push protocol handshake
   _wsOnStart: function(context) {
     console.debug("wsOnStart()");
     this._releaseWakeLock();
 
--- a/dom/push/test/xpcshell/test_clear_origin_data.js
+++ b/dom/push/test/xpcshell/test_clear_origin_data.js
@@ -103,16 +103,17 @@ add_task(function* test_webapps_cleardat
             messageType: 'register',
             status: 200,
             channelID: data.channelID,
             uaid: userAgentID,
             pushEndpoint: 'https://example.com/update/' + Math.random(),
           }));
         },
         onUnregister(data) {
+          equal(data.code, 200, 'Expected manual unregister reason');
           unregisterDone();
         },
       });
     }
   });
 
   yield Promise.all(testRecords.map(test =>
     PushService.register({
--- a/dom/push/test/xpcshell/test_handler_service.js
+++ b/dom/push/test/xpcshell/test_handler_service.js
@@ -10,17 +10,17 @@ const kServiceContractID = "@mozilla.org
 let pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService);
 
 add_test(function test_service_instantiation() {
   do_load_manifest("PushServiceHandler.manifest");
 
   let scope = "chrome://test-scope";
   let pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(Ci.nsIPushNotifier);
   let principal = Services.scriptSecurityManager.getSystemPrincipal();
-  pushNotifier.notifyPush(scope, principal);
+  pushNotifier.notifyPush(scope, principal, "");
 
   // Now get a handle to our service and check it received the notification.
   let handlerService = Cc[kServiceContractID]
                        .getService(Ci.nsISupports)
                        .wrappedJSObject;
 
   equal(handlerService.observed.length, 1);
   equal(handlerService.observed[0].topic, pushService.pushTopic);
--- a/dom/push/test/xpcshell/test_notification_ack.js
+++ b/dom/push/test/xpcshell/test_notification_ack.js
@@ -72,45 +72,48 @@ add_task(function* test_notification_ack
               channelID: '21668e05-6da8-42c9-b8ab-9cc3f4d5630c',
               version: 2
             }]
           }));
         },
         onACK(request) {
           equal(request.messageType, 'ack', 'Should send acknowledgements');
           let updates = request.updates;
-          ok(Array.isArray(updates),
-            'Should send an array of acknowledged updates');
-          equal(updates.length, 1,
-            'Should send one acknowledged update per packet');
           switch (++acks) {
           case 1:
+            deepEqual([{
+              channelID: '21668e05-6da8-42c9-b8ab-9cc3f4d5630c',
+              version: 2,
+              code: 100,
+            }], updates, 'Wrong updates for acknowledgement 1');
             this.serverSendMsg(JSON.stringify({
               messageType: 'notification',
               updates: [{
                 channelID: '9a5ff87f-47c9-4215-b2b8-0bdd38b4b305',
                 version: 4
               }, {
                 channelID: '5477bfda-22db-45d4-9614-fee369630260',
                 version: 6
               }]
             }));
             break;
 
           case 2:
             deepEqual([{
               channelID: '9a5ff87f-47c9-4215-b2b8-0bdd38b4b305',
-              version: 4
+              version: 4,
+              code: 100,
             }], updates, 'Wrong updates for acknowledgement 2');
             break;
 
           case 3:
             deepEqual([{
               channelID: '5477bfda-22db-45d4-9614-fee369630260',
-              version: 6
+              version: 6,
+              code: 100,
             }], updates, 'Wrong updates for acknowledgement 3');
             ackDone();
             break;
 
           default:
             ok(false, 'Unexpected acknowledgement ' + acks);
           }
         }
--- a/dom/push/test/xpcshell/test_notification_data.js
+++ b/dom/push/test/xpcshell/test_notification_data.js
@@ -103,17 +103,17 @@ add_task(function* test_notification_ack
             status: 200,
             use_webpush: true,
           }));
           server = this;
           setupDone();
         },
         onACK(request) {
           if (ackDone) {
-            ackDone(request.updates);
+            ackDone(request);
           }
         }
       });
     }
   });
   yield setupDonePromise;
 });
 
@@ -126,48 +126,51 @@ add_task(function* test_notification_ack
         headers: {
           encryption_key: 'keyid="notification1"; dh="BO_tgGm-yvYAGLeRe16AvhzaUcpYRiqgsGOlXpt0DRWDRGGdzVLGlEVJMygqAUECarLnxCiAOHTP_znkedrlWoU"',
           encryption: 'keyid="notification1";salt="uAZaiXpOSfOLJxtOCZ09dA"',
           encoding: 'aesgcm128',
         },
         data: 'NwrrOWPxLE8Sv5Rr0Kep7n0-r_j3rsYrUw_CXPo',
         version: 'v1',
       },
+      ackCode: 100,
       receive: {
         scope: 'https://example.com/page/1',
         data: 'Some message'
       }
     },
     {
       channelID: 'subscription2',
       version: 'v2',
       send: {
         headers: {
           encryption_key: 'keyid="notification2"; dh="BKVdQcgfncpNyNWsGrbecX0zq3eHIlHu5XbCGmVcxPnRSbhjrA6GyBIeGdqsUL69j5Z2CvbZd-9z1UBH0akUnGQ"',
           encryption: 'keyid="notification2";salt="vFn3t3M_k42zHBdpch3VRw"',
           encoding: 'aesgcm128',
         },
         data: 'Zt9dEdqgHlyAL_l83385aEtb98ZBilz5tgnGgmwEsl5AOCNgesUUJ4p9qUU',
       },
+      ackCode: 100,
       receive: {
         scope: 'https://example.com/page/2',
         data: 'Some message'
       }
     },
     {
       channelID: 'subscription3',
       version: 'v3',
       send: {
         headers: {
           encryption_key: 'keyid="notification3";dh="BD3xV_ACT8r6hdIYES3BJj1qhz9wyv7MBrG9vM2UCnjPzwE_YFVpkD-SGqE-BR2--0M-Yf31wctwNsO1qjBUeMg"',
           encryption: 'keyid="notification3"; salt="DFq188piWU7osPBgqn4Nlg"; rs=24',
           encoding: 'aesgcm128',
         },
         data: 'LKru3ZzxBZuAxYtsaCfaj_fehkrIvqbVd1iSwnwAUgnL-cTeDD-83blxHXTq7r0z9ydTdMtC3UjAcWi8LMnfY-BFzi0qJAjGYIikDA',
       },
+      ackCode: 100,
       receive: {
         scope: 'https://example.com/page/3',
         data: 'Some message'
       }
     },
     // This message uses the newer, authenticated form based on the crypto-key
     // header field.  No padding or record size changes.
     {
@@ -176,16 +179,17 @@ add_task(function* test_notification_ack
       send: {
         headers: {
           crypto_key: 'keyid=v4;dh="BHqG01j7rOfp12BEDzxWXxlCaU4cdOx2DZAwCt3QuzEsnXN9lCna9QmZCkVpXsx7sAlaEmtl_VfF1lHlFS7XWcA"',
           encryption: 'keyid="v4";salt="X5-iy5rzhm4naNmMHdSYJw"',
           encoding: 'aesgcm128',
         },
         data: '7YlxyNlZsNX4UNknHxzTqFrcrzz58W95uXBa0iY',
       },
+      ackCode: 100,
       receive: {
         scope: 'https://example.com/page/1',
         data: 'Some message'
       }
     },
     // A message encoded with `aesgcm` (2 bytes of padding).
     {
       channelID: 'subscription1',
@@ -193,16 +197,17 @@ add_task(function* test_notification_ack
       send: {
         headers: {
           crypto_key: 'dh="BMh_vsnqu79ZZkMTYkxl4gWDLdPSGE72Lr4w2hksSFW398xCMJszjzdblAWXyhSwakRNEU_GopAm4UGzyMVR83w"',
           encryption: 'salt="C14Wb7rQTlXzrgcPHtaUzw"',
           encoding: 'aesgcm',
         },
         data: 'pus4kUaBWzraH34M-d_oN8e0LPpF_X6acx695AMXovDe',
       },
+      ackCode: 100,
       receive: {
         scope: 'https://example.com/page/1',
         data: 'Another message'
       }
     },
     // A message with 17 bytes of padding and rs of 24
     {
       channelID: 'subscription2',
@@ -210,16 +215,17 @@ add_task(function* test_notification_ack
       send: {
         headers: {
           crypto_key: 'keyid="v5"; dh="BJhyKIH5P30YUKn1bolj_LMnael1-KZT_aGXgD2CRspBfv9gcUhVAmpxToZrw7QQEKl9K83b3zcqNY6G_dFhEsI"',
           encryption: 'keyid=v5;salt="bLmqCy550eK1Ao41tD7orA";rs=24',
           encoding: 'aesgcm128',
         },
         data: 'SQDlDg1ftLkM_ruZlmyB2bk9L78HYtkcbA-y4-uAxwL-G4KtOA-J-A_rJ007Vi6NUkQe9K4kSZeIBrIUpmGv',
       },
+      ackCode: 100,
       receive: {
         scope: 'https://example.com/page/2',
         data: 'Some message'
       }
     },
     // A message without key identifiers.
     {
       channelID: 'subscription3',
@@ -227,39 +233,58 @@ add_task(function* test_notification_ack
       send: {
         headers: {
           crypto_key: 'dh="BEgnDmVw9Gcn1fWA5t53Jtpsgfewk_pzsjSc_PBPpPmROWGQA2v8ESrSsQgosNXx0o-uMMhi9tDAUeks3380kd8"',
           encryption: 'salt=T9DM8bNxuMHRVTn4LzkJDQ',
           encoding: 'aesgcm128',
         },
         data: '7KUCi0dBBJbWmsYTqEqhFrgTv4ZOo_BmQRQ_2kY',
       },
+      ackCode: 100,
       receive: {
         scope: 'https://example.com/page/3',
         data: 'Some message'
       }
     },
+    // A malformed encrypted message.
+    {
+      channelID: 'subscription3',
+      version: 'v7',
+      send: {
+        headers: {
+          crypto_key: 'dh=AAAAAAAA',
+          encryption: 'salt=AAAAAAAA',
+        },
+        data: 'AAAAAAAA',
+      },
+      ackCode: 101,
+      receive: null,
+    },
   ];
 
   let sendAndReceive = testData => {
-    let messageReceived = promiseObserverNotification(PushServiceComponent.pushTopic, (subject, data) => {
+    let messageReceived = testData.receive ? promiseObserverNotification(PushServiceComponent.pushTopic, (subject, data) => {
       let notification = subject.QueryInterface(Ci.nsIPushMessage);
       equal(notification.text(), testData.receive.data,
             'Check data for notification ' + testData.version);
       equal(data, testData.receive.scope,
             'Check scope for notification ' + testData.version);
       return true;
-    });
+    }) : Promise.resolve();
 
     let ackReceived = new Promise(resolve => ackDone = resolve)
         .then(ackData => {
-          deepEqual([{
-            channelID: testData.channelID,
-            version: testData.version
-          }], ackData, 'Check updates for acknowledgment ' + testData.version);
+          deepEqual({
+            messageType: 'ack',
+            updates: [{
+              channelID: testData.channelID,
+              version: testData.version,
+              code: testData.ackCode,
+            }],
+          }, ackData, 'Check updates for acknowledgment ' + testData.version);
         });
 
     let msg = JSON.parse(JSON.stringify(testData.send));
     msg.messageType = 'notification';
     msg.channelID = testData.channelID;
     msg.version = testData.version;
     server.serverSendMsg(JSON.stringify(msg));
 
--- a/dom/push/test/xpcshell/test_permissions.js
+++ b/dom/push/test/xpcshell/test_permissions.js
@@ -113,16 +113,18 @@ add_task(function* setUp() {
           }));
           handshakeDone();
         },
         onUnregister(request) {
           let resolve = unregisterDefers[request.channelID];
           equal(typeof resolve, 'function',
             'Dropped unexpected channel ID ' + request.channelID);
           delete unregisterDefers[request.channelID];
+          equal(request.code, 202,
+            'Expected permission revoked unregister reason');
           resolve();
         },
         onACK(request) {},
       });
     }
   });
   yield handshakePromise;
 });
--- a/dom/push/test/xpcshell/test_quota_exceeded.js
+++ b/dom/push/test/xpcshell/test_quota_exceeded.js
@@ -118,16 +118,17 @@ add_task(function* test_expiration_origi
             updates: [{
               channelID: '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
               version: 1,
             }],
           }));
         },
         onUnregister(request) {
           equal(request.channelID, 'eb33fc90-c883-4267-b5cb-613969e8e349', 'Unregistered wrong channel ID');
+          equal(request.code, 201, 'Expected quota exceeded unregister reason');
           unregisterDone();
         },
         // We expect to receive acks, but don't care about their
         // contents.
         onACK(request) {},
       });
     },
   });
--- a/dom/push/test/xpcshell/test_quota_observer.js
+++ b/dom/push/test/xpcshell/test_quota_observer.js
@@ -86,16 +86,17 @@ add_task(function* test_expiration_histo
             updates: [{
               channelID: '379c0668-8323-44d2-a315-4ee83f1a9ee9',
               version: 2,
             }],
           }));
         },
         onUnregister(request) {
           equal(request.channelID, '379c0668-8323-44d2-a315-4ee83f1a9ee9', 'Dropped wrong channel ID');
+          equal(request.code, 201, 'Expected quota exceeded unregister reason');
           unregisterDone();
         },
         onACK(request) {},
       });
     }
   });
 
   yield subChangePromise;
--- a/dom/push/test/xpcshell/test_register_rollback.js
+++ b/dom/push/test/xpcshell/test_register_rollback.js
@@ -54,16 +54,17 @@ add_task(function* test_register_rollbac
             status: 200,
             uaid: userAgentID,
             channelID,
             pushEndpoint: 'https://example.com/update/rollback'
           }));
         },
         onUnregister(request) {
           equal(request.channelID, channelID, 'Unregister: wrong channel ID');
+          equal(request.code, 200, 'Expected manual unregister reason');
           this.serverSendMsg(JSON.stringify({
             messageType: 'unregister',
             status: 200,
             channelID
           }));
           unregisterDone();
         }
       });
--- a/dom/push/test/xpcshell/test_unregister_success.js
+++ b/dom/push/test/xpcshell/test_unregister_success.js
@@ -37,16 +37,17 @@ add_task(function* test_unregister_succe
           this.serverSendMsg(JSON.stringify({
             messageType: 'hello',
             status: 200,
             uaid: 'fbe865a6-aeb8-446f-873c-aeebdb8d493c'
           }));
         },
         onUnregister(request) {
           equal(request.channelID, channelID, 'Should include the channel ID');
+          equal(request.code, 200, 'Expected manual unregister reason');
           this.serverSendMsg(JSON.stringify({
             messageType: 'unregister',
             status: 200,
             channelID
           }));
           unregisterDone();
         }
       });