Bug 1055319 - Add DNS-based soft-start mechanism for Loop in release builds r=dolske
authorAdam Roach [:abr] <adam@nostrum.com>
Mon, 18 Aug 2014 17:52:26 -0500
changeset 203119 e4ef776167ca22f7b0f8a3860104741407e755ff
parent 203118 c474691b9c0fcd27e6a05045bfa1f41f3ee2d4aa
child 203120 e6cf0718093412bbf0619db307f6ad6581116a8c
push id27421
push userkwierso@gmail.com
push dateWed, 03 Sep 2014 02:33:37 +0000
treeherdermozilla-central@e58842c764dd [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdolske
bugs1055319
milestone35.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 1055319 - Add DNS-based soft-start mechanism for Loop in release builds r=dolske
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
@@ -1577,18 +1577,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;
@@ -629,51 +639,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");