diff options
Diffstat (limited to 'devtools/shared/worker')
-rw-r--r-- | devtools/shared/worker/helper.js | 138 | ||||
-rw-r--r-- | devtools/shared/worker/moz.build | 13 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser.ini | 9 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser_worker-01.js | 110 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser_worker-02.js | 83 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser_worker-03.js | 63 | ||||
-rw-r--r-- | devtools/shared/worker/worker.js | 198 |
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()});`; + } + } +); |