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 290951 7aefa268e50cecfece06621123e5f6f2bd5e57ff
parent 290950 69fe21c66f7f9edb46c6269fa95d0f0079dab5fa
child 290952 210366388d9047c28ee2180a50c1bb193f40f069
push id19656
push usergwagner@mozilla.com
push dateMon, 04 Apr 2016 13:43:23 +0000
treeherderb2g-inbound@e99061fde28a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdragana
bugs1246341
milestone48.0a1
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();
         }
       });