summaryrefslogtreecommitdiffstats
path: root/devtools/shared/worker
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/worker')
-rw-r--r--devtools/shared/worker/helper.js138
-rw-r--r--devtools/shared/worker/moz.build13
-rw-r--r--devtools/shared/worker/tests/browser/browser.ini9
-rw-r--r--devtools/shared/worker/tests/browser/browser_worker-01.js110
-rw-r--r--devtools/shared/worker/tests/browser/browser_worker-02.js83
-rw-r--r--devtools/shared/worker/tests/browser/browser_worker-03.js63
-rw-r--r--devtools/shared/worker/worker.js198
7 files changed, 614 insertions, 0 deletions
diff --git a/devtools/shared/worker/helper.js b/devtools/shared/worker/helper.js
new file mode 100644
index 0000000000..20ccd3de5e
--- /dev/null
+++ b/devtools/shared/worker/helper.js
@@ -0,0 +1,138 @@
+/* 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/. */
+
+/* eslint-env amd */
+
+"use strict";
+
+/* global workerHelper */
+/* exported workerHelper */
+(function (root, factory) {
+ if (typeof define === "function" && define.amd) {
+ define(factory);
+ } else if (typeof exports === "object") {
+ module.exports = factory();
+ } else {
+ root.workerHelper = factory();
+ }
+})(this, function () {
+ /**
+ * 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;
+ }
+
+ /**
+ * Creates the `self.onmessage` handler for a Worker.
+ *
+ * @param {object} self
+ * @return {function}
+ */
+ function createHandler(self) {
+ return function (e) {
+ const { id, task, data } = e.data;
+ const taskFn = self._tasks[task];
+
+ if (!taskFn) {
+ self.postMessage({ id, error: `Task "${task}" not found in worker.` });
+ return;
+ }
+
+ try {
+ handleResponse(taskFn(data));
+ } catch (ex) {
+ handleError(ex);
+ }
+
+ function handleResponse(response) {
+ // If a promise
+ if (response && typeof response.then === "function") {
+ response.then(
+ val => self.postMessage({ id, response: val }),
+ handleError
+ );
+ } else if (response instanceof Error) {
+ // If an error object
+ handleError(response);
+ } else {
+ // If anything else
+ self.postMessage({ id, response });
+ }
+ }
+
+ function handleError(error = "Error") {
+ try {
+ // First, try and structured clone the error across directly.
+ self.postMessage({ id, error });
+ } catch (x) {
+ // We could not clone whatever error value was given. Do our best to
+ // stringify it.
+ let errorString = `Error while performing task "${task}": `;
+
+ try {
+ errorString += error.toString();
+ } catch (ex) {
+ errorString += "<could not stringify error>";
+ }
+
+ if ("stack" in error) {
+ try {
+ errorString += "\n" + error.stack;
+ } catch (err) {
+ // Do nothing
+ }
+ }
+
+ self.postMessage({ id, error: errorString });
+ }
+ }
+ };
+ }
+
+ return { createTask };
+});
diff --git a/devtools/shared/worker/moz.build b/devtools/shared/worker/moz.build
new file mode 100644
index 0000000000..432b740a85
--- /dev/null
+++ b/devtools/shared/worker/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; 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/.
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+DevToolsModules(
+ "helper.js",
+ "worker.js",
+)
diff --git a/devtools/shared/worker/tests/browser/browser.ini b/devtools/shared/worker/tests/browser/browser.ini
new file mode 100644
index 0000000000..a64916dfff
--- /dev/null
+++ b/devtools/shared/worker/tests/browser/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ ../../../../server/tests/browser/head.js
+
+[browser_worker-01.js]
+[browser_worker-02.js]
+[browser_worker-03.js]
diff --git a/devtools/shared/worker/tests/browser/browser_worker-01.js b/devtools/shared/worker/tests/browser/browser_worker-01.js
new file mode 100644
index 0000000000..a8dafcf4cb
--- /dev/null
+++ b/devtools/shared/worker/tests/browser/browser_worker-01.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that the devtools/shared/worker communicates properly
+// as both CommonJS module and as a JSM.
+
+const BUFFER_SIZE = 8;
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
+});
+
+add_task(async function () {
+ // Test both CJS and JSM versions
+
+ await testWorker("JSM", () =>
+ ChromeUtils.import("resource://devtools/shared/worker/worker.js")
+ );
+ await testWorker("CommonJS", () =>
+ require("resource://devtools/shared/worker/worker.js")
+ );
+ await testTransfer();
+});
+
+async function testWorker(context, workerFactory) {
+ // Needed for blob:null
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ const { DevToolsWorker, workerify } = workerFactory();
+
+ const blob = new Blob(
+ [
+ `
+importScripts("resource://gre/modules/workers/require.js");
+const { createTask } = require("resource://devtools/shared/worker/helper.js");
+
+createTask(self, "groupByField", function({
+ items,
+ groupField
+}) {
+ const groups = {};
+ for (const item of items) {
+ if (!groups[item[groupField]]) {
+ groups[item[groupField]] = [];
+ }
+ groups[item[groupField]].push(item);
+ }
+ return { groups };
+});
+ `,
+ ],
+ { type: "application/javascript" }
+ );
+
+ const WORKER_URL = URL.createObjectURL(blob);
+ const worker = new DevToolsWorker(WORKER_URL);
+
+ const results = await worker.performTask("groupByField", {
+ items: [
+ { name: "Paris", country: "France" },
+ { name: "Lagos", country: "Nigeria" },
+ { name: "Lyon", country: "France" },
+ ],
+ groupField: "country",
+ });
+
+ is(
+ Object.keys(results.groups).join(","),
+ "France,Nigeria",
+ `worker should have returned the expected result in ${context}`
+ );
+
+ URL.revokeObjectURL(WORKER_URL);
+
+ const fn = workerify(x => x * x);
+ is(await fn(5), 25, `workerify works in ${context}`);
+ fn.destroy();
+
+ worker.destroy();
+}
+
+async function testTransfer() {
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ const { workerify } = ChromeUtils.import(
+ "resource://devtools/shared/worker/worker.js"
+ );
+ const workerFn = workerify(({ buf }) => buf.byteLength);
+ const buf = new ArrayBuffer(BUFFER_SIZE);
+
+ is(
+ buf.byteLength,
+ BUFFER_SIZE,
+ "Size of the buffer before transfer is correct."
+ );
+
+ is(await workerFn({ buf }), 8, "Sent array buffer to worker");
+ is(buf.byteLength, 8, "Array buffer was copied, not transferred.");
+
+ is(await workerFn({ buf }, [buf]), 8, "Sent array buffer to worker");
+ is(buf.byteLength, 0, "Array buffer was transferred, not copied.");
+
+ workerFn.destroy();
+}
diff --git a/devtools/shared/worker/tests/browser/browser_worker-02.js b/devtools/shared/worker/tests/browser/browser_worker-02.js
new file mode 100644
index 0000000000..80c50cf887
--- /dev/null
+++ b/devtools/shared/worker/tests/browser/browser_worker-02.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests errors are handled properly by the DevToolsWorker.
+
+const {
+ DevToolsWorker,
+} = require("resource://devtools/shared/worker/worker.js");
+
+const blob = new Blob(
+ [
+ `
+importScripts("resource://gre/modules/workers/require.js");
+const { createTask } = require("resource://devtools/shared/worker/helper.js");
+
+createTask(self, "myTask", function({
+ shouldThrow,
+} = {}) {
+ if (shouldThrow) {
+ throw new Error("err");
+ }
+
+ return "OK";
+});
+ `,
+ ],
+ { type: "application/javascript" }
+);
+
+add_task(async function () {
+ try {
+ 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");
+ }
+
+ const WORKER_URL = URL.createObjectURL(blob);
+ const worker = new DevToolsWorker(WORKER_URL);
+ try {
+ await worker.performTask("myTask", { shouldThrow: true });
+ 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 {
+ await 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 {
+ await worker.performTask("myTask");
+ 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"
+ );
+ }
+
+ URL.revokeObjectURL(WORKER_URL);
+});
diff --git a/devtools/shared/worker/tests/browser/browser_worker-03.js b/devtools/shared/worker/tests/browser/browser_worker-03.js
new file mode 100644
index 0000000000..185ba92d5e
--- /dev/null
+++ b/devtools/shared/worker/tests/browser/browser_worker-03.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that the devtools/shared/worker can handle:
+// returned primitives (or promise or Error)
+//
+// And tests `workerify` by doing so.
+
+const { workerify } = require("resource://devtools/shared/worker/worker.js");
+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"));
+}
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
+});
+
+add_task(async function () {
+ // Needed for blob:null
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ let fn = workerify(square);
+ is(await fn(5), 25, "return primitives successful");
+ fn.destroy();
+
+ fn = workerify(squarePromise);
+ is(await fn(5), 25, "promise primitives successful");
+ fn.destroy();
+
+ fn = workerify(squareError);
+ try {
+ await fn(5);
+ ok(false, "return error should reject");
+ } catch (e) {
+ ok(true, "return error should reject");
+ }
+ fn.destroy();
+
+ fn = workerify(squarePromiseReject);
+ try {
+ await fn(5);
+ ok(false, "returned rejected promise rejects");
+ } catch (e) {
+ ok(true, "returned rejected promise rejects");
+ }
+ fn.destroy();
+});
diff --git a/devtools/shared/worker/worker.js b/devtools/shared/worker/worker.js
new file mode 100644
index 0000000000..4d753a928e
--- /dev/null
+++ b/devtools/shared/worker/worker.js
@@ -0,0 +1,198 @@
+/* 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";
+
+/* global ChromeWorker */
+
+(function (factory) {
+ if (this.module && module.id.includes("worker")) {
+ // require
+ const dumpn = require("devtools/shared/DevToolsUtils").dumpn;
+ factory.call(
+ this,
+ require,
+ exports,
+ module,
+ { Cc, Ci, Cu },
+ ChromeWorker,
+ dumpn
+ );
+ } else {
+ // Cu.import
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ this.isWorker = false;
+ this.console = console;
+ factory.call(
+ this,
+ require,
+ this,
+ { exports: this },
+ { Cc, Ci, Cu },
+ ChromeWorker,
+ null
+ );
+ this.EXPORTED_SYMBOLS = ["DevToolsWorker", "workerify"];
+ }
+}).call(
+ this,
+ function (require, exports, module, { Ci, Cc }, ChromeWorker, dumpn) {
+ 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`.
+ *
+ * @param {string} url
+ * The URL of the worker.
+ * @param Object opts
+ * An option with the following optional fields:
+ * - name: a name that will be printed with logs
+ * - verbose: log incoming and outgoing messages
+ */
+ function DevToolsWorker(url, opts) {
+ opts = opts || {};
+ this._worker = new ChromeWorker(url);
+ this._verbose = opts.verbose;
+ this._name = opts.name;
+
+ this._worker.addEventListener("error", this.onError);
+ }
+ 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.
+ * @param {undefined|Array} transfer
+ * Optional array of transferable objects to transfer ownership of.
+ * @return {Promise}
+ */
+ DevToolsWorker.prototype.performTask = function (task, data, transfer) {
+ if (this._destroyed) {
+ return Promise.reject(
+ "Cannot call performTask on a destroyed DevToolsWorker"
+ );
+ }
+ const worker = this._worker;
+ const id = ++MESSAGE_COUNTER;
+ const payload = { task, id, data };
+
+ if (this._verbose && dumpn) {
+ dumpn(
+ "Sending message to worker" +
+ (this._name ? " (" + this._name + ")" : "") +
+ ": " +
+ JSON.stringify(payload, null, 2)
+ );
+ }
+ worker.postMessage(payload, transfer);
+
+ return new Promise((resolve, reject) => {
+ const listener = ({ data: result }) => {
+ if (this._verbose && dumpn) {
+ dumpn(
+ "Received message from worker" +
+ (this._name ? " (" + this._name + ")" : "") +
+ ": " +
+ JSON.stringify(result, null, 2)
+ );
+ }
+
+ if (result.id !== id) {
+ return;
+ }
+ worker.removeEventListener("message", listener);
+ if (result.error) {
+ reject(result.error);
+ } else {
+ resolve(result.response);
+ }
+ };
+
+ worker.addEventListener("message", listener);
+ });
+ };
+
+ /**
+ * Terminates the underlying worker. Use when no longer needing the worker.
+ */
+ DevToolsWorker.prototype.destroy = function () {
+ this._worker.terminate();
+ this._worker = null;
+ this._destroyed = true;
+ };
+
+ DevToolsWorker.prototype.onError = function ({
+ message,
+ filename,
+ lineno,
+ }) {
+ dump(new Error(message + " @ " + filename + ":" + lineno) + "\n");
+ };
+
+ /**
+ * Takes a function and returns a Worker-wrapped version of the same function.
+ * Returns a promise upon resolution.
+ * @see `./devtools/shared/shared/tests/browser/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 modules here as we don't want to include it normally.
+ const { URL, Blob } =
+ Services.wm.getMostRecentWindow("navigator:browser");
+ const stringifiedFn = createWorkerString(fn);
+ const blob = new Blob([stringifiedFn]);
+ const url = URL.createObjectURL(blob);
+ const worker = new DevToolsWorker(url);
+
+ const wrapperFn = (data, transfer) =>
+ worker.performTask("workerifiedTask", data, transfer);
+
+ 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://devtools/shared/worker/helper.js");
+ createTask(self, "workerifiedTask", ${fn.toString()});`;
+ }
+ }
+);