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 242569 c2da2db00b08cb901be5c09a223a7c8ad7c00cab
parent 242568 55d489bb8ad56d0ec55123f48e44682653ec8ee1
child 242570 84a6852a3733e3a3e91582e97d5dd0e5c8dc5424
push id12773
push userjsantell@mozilla.com
push dateWed, 06 May 2015 19:15:51 +0000
treeherderfx-team@c2da2db00b08 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins
bugs1126457
milestone40.0a1
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()});
+  `;
+}
+
+});