Bug 975591 - Part 1: Add device discovery service. r=paul
authorJ. Ryan Stinnett <jryans@gmail.com>
Thu, 26 Jun 2014 16:40:00 +0200
changeset 191168 57f870a3e9c11f6d7a26d9f90e58a64eda156fba
parent 191167 328c548bb1167fb221cd2fb063ea95d278030d65
child 191169 82a75a2df1c249ad088e4498b5c55dac4111cd43
push id8436
push usercbook@mozilla.com
push dateFri, 27 Jun 2014 13:56:57 +0000
treeherderb2g-inbound@22ea396750e8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspaul
bugs975591
milestone33.0a1
Bug 975591 - Part 1: Add device discovery service. r=paul
modules/libpref/src/init/all.js
testing/xpcshell/xpcshell_android.ini
testing/xpcshell/xpcshell_b2g.ini
toolkit/devtools/discovery/discovery.js
toolkit/devtools/discovery/moz.build
toolkit/devtools/discovery/tests/moz.build
toolkit/devtools/discovery/tests/unit/test_discovery.js
toolkit/devtools/discovery/tests/unit/xpcshell.ini
toolkit/devtools/moz.build
--- a/modules/libpref/src/init/all.js
+++ b/modules/libpref/src/init/all.js
@@ -633,16 +633,19 @@ pref("devtools.debugger.prompt-connectio
 pref("devtools.debugger.forbid-certified-apps", true);
 
 // DevTools default color unit
 pref("devtools.defaultColorUnit", "hex");
 
 // Used for devtools debugging
 pref("devtools.dump.emit", false);
 
+// Disable device discovery logging
+pref("devtools.discovery.log", false);
+
 // view source
 pref("view_source.syntax_highlight", true);
 pref("view_source.wrap_long_lines", false);
 pref("view_source.editor.external", false);
 pref("view_source.editor.path", "");
 // allows to add further arguments to the editor; use the %LINE% placeholder
 // for jumping to a specific line (e.g. "/line:%LINE%" or "--goto %LINE%")
 pref("view_source.editor.args", "");
--- a/testing/xpcshell/xpcshell_android.ini
+++ b/testing/xpcshell/xpcshell_android.ini
@@ -36,8 +36,9 @@
 [include:intl/uconv/tests/unit/xpcshell.ini]
 [include:uriloader/exthandler/tests/unit/xpcshell.ini]
 [include:storage/test/unit/xpcshell.ini]
 [include:docshell/test/unit/xpcshell.ini]
 [include:js/xpconnect/tests/unit/xpcshell.ini]
 [include:js/jsd/test/xpcshell.ini]
 [include:security/manager/ssl/tests/unit/xpcshell.ini]
 [include:toolkit/devtools/qrcode/tests/unit/xpcshell.ini]
+[include:toolkit/devtools/discovery/tests/unit/xpcshell.ini]
--- a/testing/xpcshell/xpcshell_b2g.ini
+++ b/testing/xpcshell/xpcshell_b2g.ini
@@ -15,8 +15,9 @@
 [include:toolkit/devtools/sourcemap/tests/unit/xpcshell.ini]
 [include:toolkit/mozapps/downloads/tests/unit/xpcshell.ini]
 [include:toolkit/mozapps/update/tests/unit_aus_update/xpcshell.ini]
 [include:toolkit/mozapps/update/tests/unit_base_updater/xpcshell.ini]
 [include:toolkit/mozapps/update/tests/unit_timermanager/xpcshell.ini]
 [include:ipc/testshell/tests/xpcshell.ini]
 [include:b2g/components/test/unit/xpcshell.ini]
 [include:security/manager/ssl/tests/unit/xpcshell.ini]
+[include:toolkit/devtools/discovery/tests/unit/xpcshell.ini]
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/discovery/discovery.js
@@ -0,0 +1,399 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * This implements a UDP mulitcast device discovery protocol that:
+ *   * Is optimized for mobile devices
+ *   * Doesn't require any special schema for service info
+ *
+ * To ensure it works well on mobile devices, there is no heartbeat or other
+ * recurring transmission.
+ *
+ * Devices are typically in one of two groups: scanning for services or
+ * providing services (though they may be in both groups as well).
+ *
+ * Scanning devices listen on UPDATE_PORT for UDP multicast traffic.  When the
+ * scanning device wants to force an update of the services available, it sends
+ * a status packet to SCAN_PORT.
+ *
+ * Service provider devices listen on SCAN_PORT for any packets from scanning
+ * devices.  If one is recevied, the provider device sends a status packet
+ * (listing the services it offers) to UPDATE_PORT.
+ *
+ * Scanning devices purge any previously known devices after REPLY_TIMEOUT ms
+ * from that start of a scan if no reply is received during the most recent
+ * scan.
+ *
+ * When a service is registered, is supplies a regular object with any details
+ * about itself (a port number, for example) in a service-defined format, which
+ * is then available to scanning devices.
+ */
+
+const { Cu, CC, Cc, Ci } = require("chrome");
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const { setTimeout, clearTimeout } = require("sdk/timers");
+
+const UDPSocket = CC("@mozilla.org/network/udp-socket;1",
+                     "nsIUDPSocket",
+                     "init");
+
+// TODO Bug 1027456: May need to reserve these with IANA
+const SCAN_PORT = 50624;
+const UPDATE_PORT = 50625;
+const ADDRESS = "224.0.0.200";
+const REPLY_TIMEOUT = 5000;
+
+const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+
+XPCOMUtils.defineLazyGetter(this, "converter", () => {
+  let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+             createInstance(Ci.nsIScriptableUnicodeConverter);
+  conv.charset = "utf8";
+  return conv;
+});
+
+XPCOMUtils.defineLazyGetter(this, "sysInfo", () => {
+  return Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2);
+});
+
+XPCOMUtils.defineLazyGetter(this, "libcutils", function () {
+  Cu.import("resource://gre/modules/systemlibs.js");
+  return libcutils;
+});
+
+let logging = Services.prefs.getBoolPref("devtools.discovery.log");
+function log(msg) {
+  if (logging) {
+    console.log("DISCOVERY: " + msg);
+  }
+}
+
+/**
+ * Each Transport instance owns a single UDPSocket.
+ * @param port integer
+ *        The port to listen on for incoming UDP multicast packets.
+ */
+function Transport(port) {
+  EventEmitter.decorate(this);
+  try {
+    this.socket = new UDPSocket(port, false);
+    this.socket.joinMulticast(ADDRESS);
+    this.socket.asyncListen(this);
+  } catch(e) {
+    log("Failed to start new socket: " + e);
+  }
+}
+
+Transport.prototype = {
+
+  /**
+   * Send a object to some UDP port.
+   * @param object object
+   *        Object which is the message to send
+   * @param port integer
+   *        UDP port to send the message to
+   */
+  send: function(object, port) {
+    if (logging) {
+      log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
+    }
+    let message = JSON.stringify(object);
+    let rawMessage = converter.convertToByteArray(message);
+    try {
+      this.socket.send(ADDRESS, port, rawMessage, rawMessage.length);
+    } catch(e) {
+      log("Failed to send message: " + e);
+    }
+  },
+
+  destroy: function() {
+    this.socket.close();
+  },
+
+  // nsIUDPSocketListener
+
+  onPacketReceived: function(socket, message) {
+    let messageData = message.data;
+    let object = JSON.parse(messageData);
+    object.from = message.fromAddr.address;
+    let port = message.fromAddr.port;
+    if (port == this.socket.port) {
+      log("Ignoring looped message");
+      return;
+    }
+    if (logging) {
+      log("Recv on " + this.socket.port + ":\n" +
+          JSON.stringify(object, null, 2));
+    }
+    this.emit("message", object);
+  },
+
+  onStopListening: function() {}
+
+};
+
+function Discovery() {
+  EventEmitter.decorate(this);
+
+  this.localServices = {};
+  this.remoteServices = {};
+  this.device = { name: "unknown" };
+  this.replyTimeout = REPLY_TIMEOUT;
+
+  // Defaulted to Transport, but can be altered by tests
+  this._factories = { Transport: Transport };
+
+  this._transports = {
+    scan: null,
+    update: null
+  };
+  this._expectingReplies = {
+    from: new Set()
+  };
+
+  this._onRemoteScan = this._onRemoteScan.bind(this);
+  this._onRemoteUpdate = this._onRemoteUpdate.bind(this);
+  this._purgeMissingDevices = this._purgeMissingDevices.bind(this);
+
+  this._getSystemInfo();
+}
+
+Discovery.prototype = {
+
+  /**
+   * Add a new service offered by this device.
+   * @param service string
+   *        Name of the service
+   * @param info object
+   *        Arbitrary data about the service to announce to scanning devices
+   */
+  addService: function(service, info) {
+    log("ADDING LOCAL SERVICE");
+    if (Object.keys(this.localServices).length === 0) {
+      this._startListeningForScan();
+    }
+    this.localServices[service] = info;
+  },
+
+  /**
+   * Remove a service offered by this device.
+   * @param service string
+   *        Name of the service
+   */
+  removeService: function(service) {
+    delete this.localServices[service];
+    if (Object.keys(this.localServices).length === 0) {
+      this._stopListeningForScan();
+    }
+  },
+
+  /**
+   * Scan for service updates from other devices.
+   */
+  scan: function() {
+    this._startListeningForUpdate();
+    this._waitForReplies();
+    // TODO Bug 1027457: Use timer to debounce
+    this._sendStatusTo(SCAN_PORT);
+  },
+
+  /**
+   * Get a list of all remote devices currently offering some service.:w
+   */
+  getRemoteDevices: function() {
+    let devices = new Set();
+    for (let service in this.remoteServices) {
+      for (let device in this.remoteServices[service]) {
+        devices.add(device);
+      }
+    }
+    return [...devices];
+  },
+
+  /**
+   * Get a list of all remote devices currently offering a particular service.
+   */
+  getRemoteDevicesWithService: function(service) {
+    let devicesWithService = this.remoteServices[service] || {};
+    return Object.keys(devicesWithService);
+  },
+
+  /**
+   * Get service info (any details registered by the remote device) for a given
+   * service on a device.
+   */
+  getRemoteService: function(service, device) {
+    let devicesWithService = this.remoteServices[service] || {};
+    return devicesWithService[device];
+  },
+
+  _waitForReplies: function() {
+    clearTimeout(this._expectingReplies.timer);
+    this._expectingReplies.from = new Set(this.getRemoteDevices());
+    this._expectingReplies.timer =
+      setTimeout(this._purgeMissingDevices, this.replyTimeout);
+  },
+
+  /**
+   * Determine a unique name to identify the current device.
+   */
+  _getSystemInfo: function() {
+    // TODO Bug 1027787: Uniquify device name somehow?
+    try {
+      if (Services.appinfo.widgetToolkit == "gonk") {
+        this.device.name = libcutils.property_get("ro.product.device");
+      } else {
+        this.device.name = sysInfo.get("host");
+      }
+      log("Device: " + this.device.name);
+    } catch(e) {
+      log("Failed to get system info");
+      this.device.name = "unknown";
+    }
+  },
+
+  get Transport() {
+    return this._factories.Transport;
+  },
+
+  _startListeningForScan: function() {
+    if (this._transports.scan) {
+      return; // Already listening
+    }
+    log("LISTEN FOR SCAN");
+    this._transports.scan = new this.Transport(SCAN_PORT);
+    this._transports.scan.on("message", this._onRemoteScan);
+  },
+
+  _stopListeningForScan: function() {
+    if (!this._transports.scan) {
+      return; // Not listening
+    }
+    this._transports.scan.off("message", this._onRemoteScan);
+    this._transports.scan.destroy();
+    this._transports.scan = null;
+  },
+
+  _startListeningForUpdate: function() {
+    if (this._transports.update) {
+      return; // Already listening
+    }
+    log("LISTEN FOR UPDATE");
+    this._transports.update = new this.Transport(UPDATE_PORT);
+    this._transports.update.on("message", this._onRemoteUpdate);
+  },
+
+  _stopListeningForUpdate: function() {
+    if (!this._transports.update) {
+      return; // Not listening
+    }
+    this._transports.update.off("message", this._onRemoteUpdate);
+    this._transports.update.destroy();
+    this._transports.update = null;
+  },
+
+  /**
+   * When sending message, we can use either transport, so just pick the first
+   * one currently alive.
+   */
+  get _outgoingTransport() {
+    if (this._transports.scan) {
+      return this._transports.scan;
+    }
+    if (this._transports.update) {
+      return this._transports.update;
+    }
+    return null;
+  },
+
+  _sendStatusTo: function(port) {
+    let status = {
+      device: this.device.name,
+      services: this.localServices
+    };
+    this._outgoingTransport.send(status, port);
+  },
+
+  _onRemoteScan: function() {
+    // Send my own status in response
+    log("GOT SCAN REQUEST");
+    this._sendStatusTo(UPDATE_PORT);
+  },
+
+  _onRemoteUpdate: function(e, update) {
+    log("GOT REMOTE UPDATE");
+
+    let remoteDevice = update.device;
+    let remoteHost = update.from;
+
+    // First, loop over the known services
+    for (let service in this.remoteServices) {
+      let devicesWithService = this.remoteServices[service];
+      let hadServiceForDevice = !!devicesWithService[remoteDevice];
+      let haveServiceForDevice = service in update.services;
+      // If the remote device used to have service, but doesn't any longer, then
+      // it was deleted, so we remove it here.
+      if (hadServiceForDevice && !haveServiceForDevice) {
+        delete devicesWithService[remoteDevice];
+        log("REMOVED " + service + ", DEVICE " + remoteDevice);
+        this.emit(service + "-device-removed", remoteDevice);
+      }
+    }
+
+    // Second, loop over the services in the received update
+    for (let service in update.services) {
+      // Detect if this is a new device for this service
+      let newDevice = !this.remoteServices[service] ||
+                      !this.remoteServices[service][remoteDevice];
+
+      // Look up the service info we may have received previously from the same
+      // remote device
+      let devicesWithService = this.remoteServices[service] || {};
+      let oldDeviceInfo = devicesWithService[remoteDevice];
+
+      // Store the service info from the remote device
+      let newDeviceInfo = Cu.cloneInto(update.services[service], {});
+      newDeviceInfo.host = remoteHost;
+      devicesWithService[remoteDevice] = newDeviceInfo;
+      this.remoteServices[service] = devicesWithService;
+
+      // If this is a new service for the remote device, announce the addition
+      if (newDevice) {
+        log("ADDED " + service + ", DEVICE " + remoteDevice);
+        this.emit(service + "-device-added", remoteDevice, newDeviceInfo);
+      }
+
+      // If we've seen this service from the remote device, but the details have
+      // changed, announce the update
+      if (!newDevice &&
+          JSON.stringify(oldDeviceInfo) != JSON.stringify(newDeviceInfo)) {
+        log("UPDATED " + service + ", DEVICE " + remoteDevice);
+        this.emit(service + "-device-updated", remoteDevice, newDeviceInfo);
+      }
+    }
+  },
+
+  _purgeMissingDevices: function() {
+    log("PURGING MISSING DEVICES");
+    for (let service in this.remoteServices) {
+      let devicesWithService = this.remoteServices[service];
+      for (let remoteDevice in devicesWithService) {
+        // If we're still expecting a reply from a remote device when it's time
+        // to purge, then the service is removed.
+        if (this._expectingReplies.from.has(remoteDevice)) {
+          delete devicesWithService[remoteDevice];
+          log("REMOVED " + service + ", DEVICE " + remoteDevice);
+          this.emit(service + "-device-removed", remoteDevice);
+        }
+      }
+    }
+  }
+
+};
+
+let discovery = new Discovery();
+
+module.exports = discovery;
copy from toolkit/devtools/moz.build
copy to toolkit/devtools/discovery/moz.build
--- a/toolkit/devtools/moz.build
+++ b/toolkit/devtools/discovery/moz.build
@@ -1,23 +1,13 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-PARALLEL_DIRS += [
-    'server',
-    'client',
-    'gcli',
-    'sourcemap',
-    'webconsole',
-    'apps',
-    'styleinspector',
-    'acorn',
-    'pretty-fast',
-    'qrcode',
-    'transport',
-    'tern',
+TEST_DIRS += ['tests']
+
+JS_MODULES_PATH = 'modules/devtools/discovery'
+
+EXTRA_JS_MODULES += [
+    'discovery.js',
 ]
-
-MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
-XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
copy from toolkit/devtools/moz.build
copy to toolkit/devtools/discovery/tests/moz.build
--- a/toolkit/devtools/moz.build
+++ b/toolkit/devtools/discovery/tests/moz.build
@@ -1,23 +1,7 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-PARALLEL_DIRS += [
-    'server',
-    'client',
-    'gcli',
-    'sourcemap',
-    'webconsole',
-    'apps',
-    'styleinspector',
-    'acorn',
-    'pretty-fast',
-    'qrcode',
-    'transport',
-    'tern',
-]
-
-MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
-XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
+XPCSHELL_TESTS_MANIFESTS += ['unit/xpcshell.ini']
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/discovery/tests/unit/test_discovery.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const Cu = Components.utils;
+
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+Services.prefs.setBoolPref("devtools.discovery.log", true);
+
+do_register_cleanup(() => {
+  Services.prefs.clearUserPref("devtools.discovery.log");
+});
+
+const { devtools } =
+  Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const { Promise: promise } =
+  Cu.import("resource://gre/modules/Promise.jsm", {});
+const { require } = devtools;
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const discovery = require("devtools/toolkit/discovery/discovery");
+const { setTimeout, clearTimeout } = require("sdk/timers");
+
+function log(msg) {
+  do_print("DISCOVERY: " + msg);
+}
+
+// Global map of actively listening ports to TestTransport instances
+let gTestTransports = {};
+
+/**
+ * Implements the same API as Transport in discovery.js.  Here, no UDP sockets
+ * are used.  Instead, messages are delivered immediately.
+ */
+function TestTransport(port) {
+  EventEmitter.decorate(this);
+  this.port = port;
+  gTestTransports[this.port] = this;
+}
+
+TestTransport.prototype = {
+
+  send: function(object, port) {
+    log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
+    if (!gTestTransports[port]) {
+      log("No listener on port " + port);
+      return;
+    }
+    let message = JSON.stringify(object);
+    gTestTransports[port].onPacketReceived(null, message);
+  },
+
+  destroy: function() {
+    delete gTestTransports[this.port];
+  },
+
+  // nsIUDPSocketListener
+
+  onPacketReceived: function(socket, message) {
+    let object = JSON.parse(message);
+    object.from = "localhost";
+    log("Recv on " + this.port + ":\n" + JSON.stringify(object, null, 2));
+    this.emit("message", object);
+  },
+
+  onStopListening: function(socket, status) {}
+
+};
+
+// Use TestTransport instead of the usual Transport
+discovery._factories.Transport = TestTransport;
+discovery.device.name = "test-device";
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function*() {
+  // At startup, no remote devices are known
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), []);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  discovery.scan();
+
+  // No services added yet, still empty
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), []);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  discovery.addService("devtools", { port: 1234 });
+
+  // Changes not visible until next scan
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), []);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  yield scanForChange("devtools", "added");
+
+  // Now we see the new service
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), ["test-device"]);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  discovery.addService("penguins", { tux: true });
+  yield scanForChange("penguins", "added");
+
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), ["test-device"]);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), ["test-device"]);
+  deepEqual(discovery.getRemoteDevices(), ["test-device"]);
+
+  deepEqual(discovery.getRemoteService("devtools", "test-device"),
+            { port: 1234, host: "localhost" });
+  deepEqual(discovery.getRemoteService("penguins", "test-device"),
+            { tux: true,  host: "localhost" });
+
+  discovery.removeService("devtools");
+  yield scanForChange("devtools", "removed");
+
+  discovery.addService("penguins", { tux: false });
+  yield scanForChange("penguins", "updated");
+
+  // Split the scanning side from the service side to simulate the machine with
+  // the service becoming unreachable
+  gTestTransports = {};
+
+  discovery.removeService("penguins");
+  yield scanForChange("penguins", "removed");
+});
+
+function scanForChange(service, changeType) {
+  let deferred = promise.defer();
+  let timer = setTimeout(() => {
+    deferred.reject(new Error("Reply never arrived"));
+  }, discovery.replyTimeout + 500);
+  discovery.on(service + "-device-" + changeType, function onChange() {
+    discovery.off(service + "-device-" + changeType, onChange);
+    clearTimeout(timer);
+    deferred.resolve();
+  });
+  discovery.scan();
+  return deferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/discovery/tests/unit/xpcshell.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+head =
+tail =
+
+[test_discovery.js]
--- a/toolkit/devtools/moz.build
+++ b/toolkit/devtools/moz.build
@@ -12,12 +12,13 @@ PARALLEL_DIRS += [
     'webconsole',
     'apps',
     'styleinspector',
     'acorn',
     'pretty-fast',
     'qrcode',
     'transport',
     'tern',
+    'discovery'
 ]
 
 MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
 XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']