Bug 1055319 - Add DNS-based soft-start mechanism for Loop in release builds. r=dolske, a=lmandel
authorAdam Roach [:abr] <adam@nostrum.com>
Mon, 18 Aug 2014 17:52:26 -0500
changeset 224890 193ec5695bd9ee5c8192f7fabbec690bcb33b4ca
parent 224889 52e09c968b30c28f54172bb9c8abcb0fb74796ff
child 224891 098e2b50221b292d5f50593473484970cca3491a
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdolske, lmandel
bugs1055319
milestone34.0a2
Bug 1055319 - Add DNS-based soft-start mechanism for Loop in release builds. r=dolske, a=lmandel
browser/app/profile/firefox.js
browser/base/content/browser-loop.js
browser/components/loop/MozLoopService.jsm
browser/components/loop/test/mochitest/browser.ini
browser/components/loop/test/mochitest/browser_mozLoop_softStart.js
testing/profiles/prefs_general.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1567,18 +1567,22 @@ pref("shumway.disabled", true);
 // The maximum amount of decoded image data we'll willingly keep around (we
 // might keep around more than this, but we'll try to get down to this value).
 // (This is intentionally on the high side; see bug 746055.)
 pref("image.mem.max_decoded_image_kb", 256000);
 
 // Enable by default development builds up until early beta
 #ifdef EARLY_BETA_OR_EARLIER
 pref("loop.enabled", true);
+pref("loop.throttled", false);
 #else
-pref("loop.enabled", false);
+pref("loop.enabled", true);
+pref("loop.throttled", true);
+pref("loop.soft_start_ticket_number", -1);
+pref("loop.soft_start_hostname", "soft-start.loop-dev.stage.mozaws.net");
 #endif
 
 pref("loop.server", "https://loop.services.mozilla.com");
 pref("loop.seenToS", "unseen");
 pref("loop.legal.ToS_url", "https://accounts.firefox.com/legal/terms");
 pref("loop.legal.privacy_url", "https://www.mozilla.org/privacy/");
 pref("loop.do_not_disturb", false);
 pref("loop.ringtone", "chrome://browser/content/loop/shared/sounds/Firefox-Long.ogg");
--- a/browser/base/content/browser-loop.js
+++ b/browser/base/content/browser-loop.js
@@ -44,16 +44,22 @@ XPCOMUtils.defineLazyModuleGetter(this, 
       if (!Services.prefs.getBoolPref("loop.enabled")) {
         this.toolbarButton.node.hidden = true;
         return;
       }
 
       // Add observer notifications before the service is initialized
       Services.obs.addObserver(this, "loop-status-changed", false);
 
+      // If we're throttled, check to see if it's our turn to be unthrottled
+      if (Services.prefs.getBoolPref("loop.throttled")) {
+        this.toolbarButton.node.hidden = true;
+        MozLoopService.checkSoftStart(this.toolbarButton.node);
+        return;
+      }
 
       MozLoopService.initialize();
       this.updateToolbarState();
     },
 
     uninit: function() {
       Services.obs.removeObserver(this, "loop-status-changed");
     },
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -5,16 +5,21 @@
 "use strict";
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 // Invalid auth token as per
 // https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
 const INVALID_AUTH_TOKEN = 110;
 
+// Ticket numbers are 24 bits in length.
+// The highest valid ticket number is 16777214 (2^24 - 2), so that a "now
+// serving" number of 2^24 - 1 is greater than it.
+const MAX_SOFT_START_TICKET_NUMBER = 16777214;
+
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
 
 this.EXPORTED_SYMBOLS = ["MozLoopService"];
@@ -44,16 +49,21 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                   "resource:///modules/loop/MozLoopPushHandler.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
                                    "@mozilla.org/uuid-generator;1",
                                    "nsIUUIDGenerator");
 
+XPCOMUtils.defineLazyServiceGetter(this, "gDNSService",
+                                   "@mozilla.org/network/dns-service;1",
+                                   "nsIDNSService");
+
+
 // The current deferred for the registration process. This is set if in progress
 // or the registration was successful. This is null if a registration attempt was
 // unsuccessful.
 let gRegisteredDeferred = null;
 let gPushHandler = null;
 let gHawkClient = null;
 let gRegisteredLoopServer = false;
 let gLocalizedStrings =  null;
@@ -627,51 +637,157 @@ this.MozLoopService = {
 
   resetFxA: function() {
     gFxAOAuthClientPromise = null;
     gFxAOAuthClient = null;
     gFxAOAuthTokenData = null;
   },
 #endif
 
+  _DNSService: gDNSService,
+
   set initializeTimerFunc(value) {
     gInitializeTimerFunc = value;
   },
 
   /**
    * Initialized the loop service, and starts registration with the
    * push and loop servers.
    */
   initialize: function() {
     // Don't do anything if loop is not enabled.
-    if (!Services.prefs.getBoolPref("loop.enabled")) {
+    if (!Services.prefs.getBoolPref("loop.enabled") ||
+        Services.prefs.getBoolPref("loop.throttled")) {
       return;
     }
 
     // If expiresTime is in the future then kick-off registration.
     if (MozLoopServiceInternal.urlExpiryTimeIsInFuture()) {
       gInitializeTimerFunc();
     }
   },
 
   /**
+   * If we're operating the service in "soft start" mode, and this browser
+   * isn't already activated, check whether it's time for it to become active.
+   * If so, activate the loop service.
+   *
+   * @param {Object} buttonNode DOM node representing the Loop button -- if we
+   *                            change from inactive to active, we need this
+   *                            in order to unhide the Loop button.
+   * @param {Function} doneCb   [optional] Callback that is called when the
+   *                            check has completed.
+   */
+  checkSoftStart(buttonNode, doneCb) {
+    if (!Services.prefs.getBoolPref("loop.throttled")) {
+      if (typeof(doneCb) == "function") {
+        doneCb(new Error("Throttling is not active"));
+      }
+      return;
+    }
+
+    if (Services.io.offline) {
+      if (typeof(doneCb) == "function") {
+        doneCb(new Error("Cannot check soft-start value: browser is offline"));
+      }
+      return;
+    }
+
+    let ticket = Services.prefs.getIntPref("loop.soft_start_ticket_number");
+    if (!ticket || ticket > MAX_SOFT_START_TICKET_NUMBER || ticket < 0) {
+      // Ticket value isn't valid (probably isn't set up yet) -- pick a random
+      // number from 1 to MAX_SOFT_START_TICKET_NUMBER, inclusive, and write it
+      // into prefs.
+      ticket = Math.floor(Math.random() * MAX_SOFT_START_TICKET_NUMBER) + 1;
+      // Floating point numbers can be imprecise, so we need to deal with
+      // the case that Math.random() effectively rounds to 1.0
+      if (ticket > MAX_SOFT_START_TICKET_NUMBER) {
+        ticket = MAX_SOFT_START_TICKET_NUMBER;
+      }
+      Services.prefs.setIntPref("loop.soft_start_ticket_number", ticket);
+    }
+
+    let onLookupComplete = (request, record, status) => {
+      // We don't bother checking errors -- if the DNS query fails,
+      // we just don't activate this time around. We'll check again on
+      // next startup.
+      if (!Components.isSuccessCode(status)) {
+        if (typeof(doneCb) == "function") {
+          doneCb(new Error("Error in DNS Lookup: " + status));
+        }
+        return;
+      }
+
+      let address = record.getNextAddrAsString().split(".");
+      if (address.length != 4) {
+        if (typeof(doneCb) == "function") {
+          doneCb(new Error("Invalid IP address"));
+        }
+        return;
+      }
+
+      if (address[0] != 127) {
+        if (typeof(doneCb) == "function") {
+          doneCb(new Error("Throttling IP address is not on localhost subnet"));
+        }
+        return
+      }
+
+      // Can't use bitwise operations here because JS treats all bitwise
+      // operations as 32-bit *signed* integers.
+      let now_serving = ((parseInt(address[1]) * 0x10000) +
+                         (parseInt(address[2]) * 0x100) +
+                         parseInt(address[3]));
+
+      if (now_serving > ticket) {
+        // Hot diggity! It's our turn! Activate the service.
+        console.log("MozLoopService: Activating Loop via soft-start");
+        Services.prefs.setBoolPref("loop.throttled", false);
+        buttonNode.hidden = false;
+        this.initialize();
+      }
+      if (typeof(doneCb) == "function") {
+        doneCb(null);
+      }
+    };
+
+    // We use DNS to propagate the slow-start value, since it has well-known
+    // scaling properties. Ideally, this would use something more semantic,
+    // like a TXT record; but we don't support TXT in our DNS resolution (see
+    // Bug 14328), so we instead treat the lowest 24 bits of the IP address
+    // corresponding to our "slow start DNS name" as a 24-bit integer. To
+    // ensure that these addresses aren't routable, the highest 8 bits must
+    // be "127" (reserved for localhost).
+    let host = Services.prefs.getCharPref("loop.soft_start_hostname");
+    let task = this._DNSService.asyncResolve(host,
+                                             this._DNSService.RESOLVE_DISABLE_IPV6,
+                                             onLookupComplete,
+                                             Services.tm.mainThread);
+  },
+
+
+  /**
    * Starts registration of Loop with the push server, and then will register
    * with the Loop server. It will return early if already registered.
    *
    * @param {Object} mockPushHandler Optional, test-only mock push handler. Used
    *                                 to allow mocking of the MozLoopPushHandler.
    * @returns {Promise} a promise that is resolved with no params on completion, or
    *          rejected with an error code or string.
    */
   register: function(mockPushHandler) {
     // Don't do anything if loop is not enabled.
     if (!Services.prefs.getBoolPref("loop.enabled")) {
       throw new Error("Loop is not enabled");
     }
 
+    if (Services.prefs.getBoolPref("loop.throttled")) {
+      throw new Error("Loop is disabled by the soft-start mechanism");
+    }
+
     return MozLoopServiceInternal.promiseRegisteredWithServers(mockPushHandler);
   },
 
   /**
    * Used to note a call url expiry time. If the time is later than the current
    * latest expiry time, then the stored expiry time is increased. For times
    * sooner, this function is a no-op; this ensures we always have the latest
    * expiry time for a url.
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -7,11 +7,12 @@ support-files =
 [browser_CardDavImporter.js]
 [browser_fxa_login.js]
 skip-if = !debug
 [browser_loop_fxa_server.js]
 [browser_LoopContacts.js]
 [browser_mozLoop_appVersionInfo.js]
 [browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
+[browser_mozLoop_softStart.js]
 skip-if = buildapp == 'mulet'
 [browser_toolbarbutton.js]
 [browser_mozLoop_pluralStrings.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_softStart.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const SOFT_START_HOSTNAME = "soft-start.example.invalid";
+
+let MockDNSService = {
+  RESOLVE_DISABLE_IPV6: 32,
+  nowServing: 0,
+  resultCode: 0,
+  ipFirstOctet: 127,
+
+  getNowServingAddress: function() {
+    let ip = this.ipFirstOctet + "." +
+             ((this.nowServing >>> 16) & 0xFF) + "." +
+             ((this.nowServing >>> 8) & 0xFF) + "." +
+             ((this.nowServing) & 0xFF);
+    info("Using 'now serving' of " + this.nowServing + " (" + ip + ")");
+    return ip;
+  },
+
+  asyncResolve: function(host, flags, callback) {
+    let mds = this;
+    Assert.equal(flags, this.RESOLVE_DISABLE_IPV6,
+                 "AAAA lookup should be disabled");
+    Assert.equal(host, SOFT_START_HOSTNAME,
+                 "Configured hostname should be used");
+    callback(null,
+             {getNextAddrAsString: mds.getNowServingAddress.bind(mds)},
+             this.resultCode);
+  }
+};
+
+// We need an unfrozen copy of the LoopService so we can monkeypatch it.
+let LoopService = {};
+for (var prop in MozLoopService) {
+  if (MozLoopService.hasOwnProperty(prop)) {
+    LoopService[prop] = MozLoopService[prop];
+  }
+}
+LoopService._DNSService = MockDNSService;
+
+let MockButton = {
+  hidden: true
+};
+
+let runCheck = function(expectError) {
+  return new Promise((resolve, reject) => {
+    LoopService.checkSoftStart(MockButton, error => {
+      if ((!!error) != (!!expectError)) {
+        reject(error);
+      } else {
+        resolve(error);
+      }
+    })
+  });
+}
+
+add_task(function* test_mozLoop_softStart() {
+  // Set associated variables to proper values
+  Services.prefs.setBoolPref("loop.throttled", true);
+  Services.prefs.setCharPref("loop.soft_start_hostname", SOFT_START_HOSTNAME);
+  Services.prefs.setIntPref("loop.soft_start_ticket_number", -1);
+
+  let throttled;
+  let ticket;
+
+  info("Ensure that we pick a valid ticket number.");
+  yield runCheck();
+  throttled = Services.prefs.getBoolPref("loop.throttled");
+  ticket = Services.prefs.getIntPref("loop.soft_start_ticket_number");
+  Assert.equal(MockButton.hidden, true, "Button should still be hidden");
+  Assert.equal(throttled, true, "Feature should still be throttled");
+  Assert.notEqual(ticket, -1, "Ticket should be changed");
+  Assert.ok((ticket < 16777214 && ticket > 0), "Ticket should be in range");
+
+  // Try some "interesting" ticket numbers
+  for (ticket of [1, 256, 65535, 10000000, 16777214]) {
+    MockButton.hidden = true;
+    Services.prefs.setBoolPref("loop.throttled", true);
+    Services.prefs.setBoolPref("loop.soft_start", true);
+    Services.prefs.setIntPref("loop.soft_start_ticket_number", ticket);
+
+    info("Ensure that we don't activate when the now serving " +
+         "number is less than our value.");
+    MockDNSService.nowServing = ticket - 1;
+    yield runCheck();
+    throttled = Services.prefs.getBoolPref("loop.throttled");
+    Assert.equal(MockButton.hidden, true, "Button should still be hidden");
+    Assert.equal(throttled, true, "Feature should still be throttled");
+
+    info("Ensure that we don't activate when the now serving " +
+         "number is equal to our value");
+    MockDNSService.nowServing = ticket;
+    yield runCheck();
+    throttled = Services.prefs.getBoolPref("loop.throttled");
+    Assert.equal(MockButton.hidden, true, "Button should still be hidden");
+    Assert.equal(throttled, true, "Feature should still be throttled");
+
+    info("Ensure that we *do* activate when the now serving " +
+         "number is greater than our value");
+    MockDNSService.nowServing = ticket + 1;
+    yield runCheck();
+    throttled = Services.prefs.getBoolPref("loop.throttled");
+    Assert.equal(MockButton.hidden, false, "Button should not be hidden");
+    Assert.equal(throttled, false, "Feature should be unthrottled");
+  }
+
+  info("Check DNS error behavior");
+  MockDNSService.nowServing = 0;
+  MockDNSService.resultCode = 0x80000000;
+  Services.prefs.setBoolPref("loop.throttled", true);
+  Services.prefs.setBoolPref("loop.soft_start", true);
+  MockButton.hidden = true;
+  yield runCheck(true);
+  throttled = Services.prefs.getBoolPref("loop.throttled");
+  Assert.equal(MockButton.hidden, true, "Button should be hidden");
+  Assert.equal(throttled, true, "Feature should be throttled");
+
+  info("Check DNS misconfiguration behavior");
+  MockDNSService.nowServing = ticket + 1;
+  MockDNSService.resultCode = 0;
+  MockDNSService.ipFirstOctet = 6;
+  Services.prefs.setBoolPref("loop.throttled", true);
+  Services.prefs.setBoolPref("loop.soft_start", true);
+  MockButton.hidden = true;
+  yield runCheck(true);
+  throttled = Services.prefs.getBoolPref("loop.throttled");
+  Assert.equal(MockButton.hidden, true, "Button should be hidden");
+  Assert.equal(throttled, true, "Feature should be throttled");
+});
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -237,12 +237,13 @@ user_pref("browser.aboutHomeSnippets.upd
 user_pref("dom.mozApps.debug", true);
 
 // Don't fetch or send directory tiles data from real servers
 user_pref("browser.newtabpage.directory.source", 'data:application/json,{"testing":1}');
 user_pref("browser.newtabpage.directory.ping", "");
 
 // Enable Loop
 user_pref("loop.enabled", true);
+user_pref("loop.throttled", false);
 
 // Ensure UITour won't hit the network
 user_pref("browser.uitour.pinnedTabUrl", "http://%(server)s/uitour-dummy/pinnedTab");
 user_pref("browser.uitour.url", "http://%(server)s/uitour-dummy/tour");