Bug 1126457 - Implement a worker utility to easily offload tasks into a worker. r=bgrins
authorJordan Santell <jsantell@mozilla.com>
Tue, 05 May 2015 16:39:48 -0700
changeset 242656 c2da2db00b08cb901be5c09a223a7c8ad7c00cab
parent 242655 55d489bb8ad56d0ec55123f48e44682653ec8ee1
child 242657 84a6852a3733e3a3e91582e97d5dd0e5c8dc5424
push id28705
push usercbook@mozilla.com
push dateThu, 07 May 2015 13:34:47 +0000
treeherdermozilla-central@6c8b6e1d328f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins
bugs1126457
milestone40.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 1126457 - Implement a worker utility to easily offload tasks into a worker. r=bgrins
browser/devtools/shared/moz.build
browser/devtools/shared/test/browser.ini
browser/devtools/shared/test/browser_devtools-worker-01.js
browser/devtools/shared/test/browser_devtools-worker-02.js
browser/devtools/shared/test/browser_devtools-worker-03.js
browser/devtools/shared/test/head.js
browser/devtools/shared/widgets/Graphs.jsm
browser/devtools/shared/widgets/GraphsWorker.js
browser/devtools/shared/worker-helper.js
browser/devtools/shared/worker.js
--- a/browser/devtools/shared/moz.build
+++ b/browser/devtools/shared/moz.build
@@ -56,16 +56,18 @@ EXTRA_JS_MODULES.devtools.shared += [
     'observable-object.js',
     'options-view.js',
     'poller.js',
     'source-utils.js',
     'telemetry.js',
     'theme-switching.js',
     'theme.js',
     'undo.js',
+    'worker-helper.js',
+    'worker.js',
 ]
 
 EXTRA_JS_MODULES.devtools.shared.widgets += [
     'widgets/CubicBezierPresets.js',
     'widgets/CubicBezierWidget.js',
     'widgets/FastListWidget.js',
     'widgets/FilterWidget.js',
     'widgets/FlameGraph.js',
--- a/browser/devtools/shared/test/browser.ini
+++ b/browser/devtools/shared/test/browser.ini
@@ -20,16 +20,19 @@ support-files =
 
 [browser_css_color.js]
 [browser_cubic-bezier-01.js]
 [browser_cubic-bezier-02.js]
 [browser_cubic-bezier-03.js]
 [browser_cubic-bezier-04.js]
 [browser_cubic-bezier-05.js]
 [browser_cubic-bezier-06.js]
+[browser_devtools-worker-01.js]
+[browser_devtools-worker-02.js]
+[browser_devtools-worker-03.js]
 [browser_filter-editor-01.js]
 [browser_filter-editor-02.js]
 [browser_filter-editor-03.js]
 [browser_filter-editor-04.js]
 [browser_filter-editor-05.js]
 [browser_filter-editor-06.js]
 [browser_filter-editor-07.js]
 [browser_filter-editor-08.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_devtools-worker-01.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the devtools/shared/worker communicates properly
+// as both CommonJS module and as a JSM.
+
+const WORKER_URL = "resource:///modules/devtools/GraphsWorker.js";
+
+const count = 100000;
+const WORKER_DATA = (function () {
+  let timestamps = [];
+  for (let i = 0; i < count; i++) {
+    timestamps.push(i);
+  }
+  return timestamps;
+})();
+const INTERVAL = 100;
+const DURATION = 1000;
+
+add_task(function*() {
+  // Test both CJS and JSM versions
+  yield testWorker("JSM", () => Cu.import("resource:///modules/devtools/shared/worker.js", {}));
+  yield testWorker("CommonJS", () => devtools.require("devtools/shared/worker"));
+});
+
+function *testWorker (context, workerFactory) {
+  let { DevToolsWorker, workerify } = workerFactory();
+  let worker = new DevToolsWorker(WORKER_URL);
+  let results = yield worker.performTask("plotTimestampsGraph", {
+    timestamps: WORKER_DATA,
+    interval: INTERVAL,
+    duration: DURATION
+  });
+
+  ok(results.plottedData.length,
+    `worker should have returned an object with array properties in ${context}`);
+  
+  let fn = workerify(function (x) { return x * x });
+  is((yield fn(5)), 25, `workerify works in ${context}`);
+  fn.destroy();
+
+
+  worker.destroy();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_devtools-worker-02.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests errors are handled properly by the DevToolsWorker.
+
+const { DevToolsWorker } = devtools.require("devtools/shared/worker");
+const WORKER_URL = "resource:///modules/devtools/GraphsWorker.js";
+
+add_task(function*() {
+  try {
+    let workerNotFound = new DevToolsWorker("resource://i/dont/exist.js");
+    ok(false, "Creating a DevToolsWorker with an invalid URL throws");
+  } catch (e) {
+    ok(true, "Creating a DevToolsWorker with an invalid URL throws");
+  }
+
+  let worker = new DevToolsWorker(WORKER_URL);
+  try {
+    // plotTimestampsGraph requires timestamp, interval an duration props on the object
+    // passed in so there should be an error thrown in the worker
+    let results = yield worker.performTask("plotTimestampsGraph", {});
+    ok(false, "DevToolsWorker returns a rejected promise when an error occurs in the worker");
+  } catch (e) {
+    ok(true, "DevToolsWorker returns a rejected promise when an error occurs in the worker");
+  }
+
+  try {
+    let results = yield worker.performTask("not a real task");
+    ok(false, "DevToolsWorker returns a rejected promise when task does not exist");
+  } catch (e) {
+    ok(true, "DevToolsWorker returns a rejected promise when task does not exist");
+  }
+
+  worker.destroy();
+  try {
+    let results = yield worker.performTask("plotTimestampsGraph", {
+      timestamps: [0,1,2,3,4,5,6,7,8,9],
+      interval: 1,
+      duration: 1
+    });
+    ok(false, "DevToolsWorker rejects when performing a task on a destroyed worker");
+  } catch (e) {
+    ok(true, "DevToolsWorker rejects when performing a task on a destroyed worker");
+  };
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_devtools-worker-03.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the devtools/shared/worker can handle:
+// returned primitives (or promise or Error)
+//
+// And tests `workerify` by doing so.
+
+const { DevToolsWorker, workerify } = devtools.require("devtools/shared/worker");
+function square (x) {
+  return x * x;
+}
+
+function squarePromise (x) {
+  return new Promise((resolve) => resolve(x*x));
+}
+
+function squareError (x) {
+  return new Error("Nope");
+}
+
+function squarePromiseReject (x) {
+  return new Promise((_, reject) => reject("Nope"));
+}
+
+add_task(function*() {
+  let fn = workerify(square);
+  is((yield fn(5)), 25, "return primitives successful");
+  fn.destroy();
+
+  fn = workerify(squarePromise);
+  is((yield fn(5)), 25, "promise primitives successful");
+  fn.destroy();
+
+  fn = workerify(squareError);
+  try {
+    yield fn(5);
+    ok(false, "return error should reject");
+  } catch (e) {
+    ok(true, "return error should reject");
+  }
+  fn.destroy();
+
+  fn = workerify(squarePromiseReject);
+  try {
+    yield fn(5);
+    ok(false, "returned rejected promise rejects");
+  } catch (e) {
+    ok(true, "returned rejected promise rejects");
+  }
+  fn.destroy();
+});
--- a/browser/devtools/shared/test/head.js
+++ b/browser/devtools/shared/test/head.js
@@ -3,16 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 let {TargetFactory, require} = devtools;
 let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 let {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
 const {DOMHelpers} = Cu.import("resource:///modules/devtools/DOMHelpers.jsm", {});
 const {Hosts} = require("devtools/framework/toolbox-hosts");
+const {defer} = require("sdk/core/promise");
 
 gDevTools.testing = true;
 SimpleTest.registerCleanupFunction(() => {
   gDevTools.testing = false;
 });
 
 const TEST_URI_ROOT = "http://example.com/browser/browser/devtools/shared/test/";
 const OPTIONS_VIEW_URL = TEST_URI_ROOT + "doc_options-view.xul";
@@ -256,10 +257,8 @@ function waitUntil(predicate, interval =
     return Promise.resolve(true);
   }
   return new Promise(resolve => {
     setTimeout(function() {
       waitUntil(predicate).then(() => resolve(true));
     }, interval);
   });
 }
-
-// EventUtils just doesn't work!
--- a/browser/devtools/shared/widgets/Graphs.jsm
+++ b/browser/devtools/shared/widgets/Graphs.jsm
@@ -4,16 +4,17 @@
 "use strict";
 
 const Cu = Components.utils;
 
 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
 const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
 const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+const {DevToolsWorker} = Cu.import("resource:///modules/devtools/shared/worker.js", {});
 
 this.EXPORTED_SYMBOLS = [
   "GraphCursor",
   "GraphArea",
   "GraphAreaDragger",
   "GraphAreaResizer",
   "AbstractCanvasGraph",
   "LineGraphWidget",
@@ -2143,50 +2144,24 @@ this.CanvasGraphUtils = {
     });
   },
 
   /**
    * Performs the given task in a chrome worker, assuming it exists.
    *
    * @param string task
    *        The task name. Currently supported: "plotTimestampsGraph".
-   * @param any args
+   * @param any data
    *        Extra arguments to pass to the worker.
-   * @param array transferrable [optional]
-   *        A list of transferrable objects, if any.
    * @return object
    *         A promise that is resolved once the worker finishes the task.
    */
-  _performTaskInWorker: function(task, args, transferrable) {
-    let worker = this._graphUtilsWorker || new ChromeWorker(WORKER_URL);
-    let id = this._graphUtilsTaskId++;
-    worker.postMessage({ task, id, args }, transferrable);
-    return this._waitForWorkerResponse(worker, id);
-  },
-
-  /**
-   * Waits for the specified worker to finish a task.
-   *
-   * @param ChromeWorker worker
-   *        The worker for which to add a message listener.
-   * @param number id
-   *        The worker task id.
-   */
-  _waitForWorkerResponse: function(worker, id) {
-    let deferred = promise.defer();
-
-    worker.addEventListener("message", function listener({ data }) {
-      if (data.id != id) {
-        return;
-      }
-      worker.removeEventListener("message", listener);
-      deferred.resolve(data);
-    });
-
-    return deferred.promise;
+  _performTaskInWorker: function(task, data) {
+    let worker = this._graphUtilsWorker || new DevToolsWorker(WORKER_URL);
+    return worker.performTask(task, data);
   }
 };
 
 /**
  * Maps a value from one range to another.
  * @param number value, istart, istop, ostart, ostop
  * @return number
  */
--- a/browser/devtools/shared/widgets/GraphsWorker.js
+++ b/browser/devtools/shared/widgets/GraphsWorker.js
@@ -1,40 +1,33 @@
 /* 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";
 
-self.onmessage = e => {
-  const { id, task, args } = e.data;
-
-  switch (task) {
-    case "plotTimestampsGraph":
-      plotTimestampsGraph(id, args);
-      break;
-    default:
-      self.postMessage({ id, error: e.message + "\n" + e.stack });
-      break;
-  }
-};
+/**
+ * Import `createTask` to communicate with `devtools/shared/worker`.
+ */
+importScripts("resource://gre/modules/workers/require.js");
+const { createTask } = require("resource:///modules/devtools/shared/worker-helper");
 
 /**
  * @see LineGraphWidget.prototype.setDataFromTimestamps in Graphs.jsm
  * @param number id
  * @param array timestamps
  * @param number interval
  * @param number duration
  */
-function plotTimestampsGraph(id, args) {
-  let plottedData = plotTimestamps(args.timestamps, args.interval);
-  let plottedMinMaxSum = getMinMaxAvg(plottedData, args.timestamps, args.duration);
+createTask(self, "plotTimestampsGraph", function ({ timestamps, interval, duration }) {
+  let plottedData = plotTimestamps(timestamps, interval);
+  let plottedMinMaxSum = getMinMaxAvg(plottedData, timestamps, duration);
 
-  let response = { id, plottedData, plottedMinMaxSum };
-  self.postMessage(response);
-}
+  return { plottedData, plottedMinMaxSum };
+});
+
 
 /**
  * Gets the min, max and average of the values in an array.
  * @param array source
  * @param array timestamps
  * @param number duration
  * @return object
  */
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/worker-helper.js
@@ -0,0 +1,100 @@
+/* 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 file is to only be included by ChromeWorkers. This exposes
+ * a `createTask` function to workers to register tasks for communication
+ * back to `devtools/shared/worker`.
+ *
+ * Tasks can be send their responses via a return value, either a primitive
+ * or a promise.
+ *
+ * createTask(self, "average", function (data) {
+ *   return data.reduce((sum, val) => sum + val, 0) / data.length;
+ * });
+ *
+ * createTask(self, "average", function (data) {
+ *   return new Promise((resolve, reject) => {
+ *     resolve(data.reduce((sum, val) => sum + val, 0) / data.length);
+ *   });
+ * });
+ *
+ *
+ * Errors:
+ *
+ * Returning an Error value, or if the returned promise is rejected, this
+ * propagates to the DevToolsWorker as a rejected promise. If an error is
+ * thrown in a synchronous function, that error is also propagated.
+ */
+
+/**
+ * Takes a worker's `self` object, a task name, and a function to
+ * be called when that task is called. The task is called with the
+ * passed in data as the first argument
+ *
+ * @param {object} self
+ * @param {string} name
+ * @param {function} fn
+ */
+function createTask (self, name, fn) {
+  // Store a hash of task name to function on the Worker
+  if (!self._tasks) {
+    self._tasks = {};
+  }
+
+  // Create the onmessage handler if not yet created.
+  if (!self.onmessage) {
+    self.onmessage = createHandler(self);
+  }
+
+  // Store the task on the worker.
+  self._tasks[name] = fn;
+}
+
+exports.createTask = createTask;
+
+/**
+ * Creates the `self.onmessage` handler for a Worker.
+ *
+ * @param {object} self
+ * @return {function}
+ */
+function createHandler (self) {
+  return function (e) {
+    let { id, task, data } = e.data;
+    let taskFn = self._tasks[task];
+
+    if (!taskFn) {
+      self.postMessage({ id, error: `Task "${task}" not found in worker.` });
+      return;
+    }
+
+    try {
+      let results;
+      handleResponse(taskFn(data));
+    } catch (e) {
+      handleError(e);
+    }
+
+    function handleResponse (response) {
+      // If a promise
+      if (response && typeof response.then === "function") {
+        response.then(val => self.postMessage({ id, response: val }), handleError);
+      }
+      // If an error object
+      else if (response instanceof Error) {
+        handleError(response);
+      }
+      // If anything else
+      else {
+        self.postMessage({ id, response });
+      }
+    }
+
+    function handleError (e="Error") {
+      self.postMessage({ id, error: e.message || e });
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/worker.js
@@ -0,0 +1,134 @@
+/* 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";
+
+(function (factory) { // Module boilerplate
+  if (this.module && module.id.indexOf("worker") >= 0) { // require
+    const { Cc, Ci, ChromeWorker } = require("chrome");
+    factory.call(this, require, exports, module, { Cc, Ci }, ChromeWorker);
+  } else { // Cu.import
+      const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+      const { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+      this.isWorker = false;
+      this.Promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+      this.console = Cu.import("resource://gre/modules/devtools/Console.jsm", {}).console;
+      factory.call(this, devtools.require, this, { exports: this }, { Cc, Ci }, ChromeWorker);
+      this.EXPORTED_SYMBOLS = ["DevToolsWorker"];
+  }
+}).call(this, function (require, exports, module, { Ci, Cc }, ChromeWorker ) {
+
+let MESSAGE_COUNTER = 0;
+
+/**
+ * Creates a wrapper around a ChromeWorker, providing easy
+ * communication to offload demanding tasks. The corresponding URL
+ * must implement the interface provided by `devtools/shared/worker-helper`.
+ *
+ * @see `./browser/devtools/shared/widgets/GraphsWorker.js`
+ *
+ * @param {string} url
+ *        The URL of the worker.
+ */
+function DevToolsWorker (url) {
+  this._worker = new ChromeWorker(url);
+}
+exports.DevToolsWorker = DevToolsWorker;
+
+/**
+ * Performs the given task in a chrome worker, passing in data.
+ * Returns a promise that resolves when the task is completed, resulting in
+ * the return value of the task.
+ *
+ * @param {string} task
+ *        The name of the task to execute in the worker.
+ * @param {any} data
+ *        Data to be passed into the task implemented by the worker.
+ * @return {Promise}
+ */
+DevToolsWorker.prototype.performTask = function DevToolsWorkerPerformTask (task, data) {
+  if (this._destroyed) {
+    return Promise.reject("Cannot call performTask on a destroyed DevToolsWorker");
+  }
+  let worker = this._worker;
+  let id = ++MESSAGE_COUNTER;
+  worker.postMessage({ task, id, data });
+
+  return new Promise(function (resolve, reject) {
+    worker.addEventListener("message", function listener({ data }) {
+      if (data.id !== id) {
+        return;
+      }
+      worker.removeEventListener("message", listener);
+      if (data.error) {
+        reject(data.error);
+      } else {
+        resolve(data.response);
+      }
+    });
+  });
+}
+
+/**
+ * Terminates the underlying worker. Use when no longer needing the worker.
+ */
+DevToolsWorker.prototype.destroy = function DevToolsWorkerDestroy () {
+  this._worker.terminate();
+  this._worker = null;
+  this._destroyed = true;
+};
+
+/**
+ * Takes a function and returns a Worker-wrapped version of the same function.
+ * Returns a promise upon resolution.
+ * @see `./browser/devtools/shared/test/browser_devtools-worker-03.js
+ *
+ * * * * ! ! ! This should only be used for tests or A/B testing performance ! ! ! * * * * * *
+ *
+ * The original function must:
+ *
+ * Be a pure function, that is, not use any variables not declared within the
+ * function, or its arguments.
+ *
+ * Return a value or a promise.
+ *
+ * Note any state change in the worker will not affect the callee's context.
+ *
+ * @param {function} fn
+ * @return {function}
+ */
+function workerify (fn) {
+  console.warn(`\`workerify\` should only be used in tests or measuring performance.
+  This creates an object URL on the browser window, and should not be used in production.`)
+  // Fetch via window/utils here as we don't want to include
+  // this module normally.
+  let { getMostRecentBrowserWindow } = require("sdk/window/utils");
+  let { URL, Blob } = getMostRecentBrowserWindow();
+  let stringifiedFn = createWorkerString(fn);
+  let blob = new Blob([stringifiedFn]);
+  let url = URL.createObjectURL(blob);
+  let worker = new DevToolsWorker(url);
+
+  let wrapperFn = data => worker.performTask("workerifiedTask", data);
+
+  wrapperFn.destroy = function () {
+    URL.revokeObjectURL(url);
+    worker.destroy();
+  };
+
+  return wrapperFn;
+}
+exports.workerify = workerify;
+
+/**
+ * Takes a function, and stringifies it, attaching the worker-helper.js
+ * boilerplate hooks.
+ */
+function createWorkerString (fn) {
+  return `importScripts("resource://gre/modules/workers/require.js");
+    const { createTask } = require("resource:///modules/devtools/shared/worker-helper");
+    createTask(self, "workerifiedTask", ${fn.toString()});
+  `;
+}
+
+});