diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/shared/worker | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/worker')
-rw-r--r-- | devtools/shared/worker/helper.js | 136 | ||||
-rw-r--r-- | devtools/shared/worker/loader.js | 539 | ||||
-rw-r--r-- | devtools/shared/worker/moz.build | 14 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/.eslintrc.js | 6 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser.ini | 9 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser_worker-01.js | 87 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser_worker-02.js | 65 | ||||
-rw-r--r-- | devtools/shared/worker/tests/browser/browser_worker-03.js | 63 | ||||
-rw-r--r-- | devtools/shared/worker/worker.js | 202 |
9 files changed, 1121 insertions, 0 deletions
diff --git a/devtools/shared/worker/helper.js b/devtools/shared/worker/helper.js new file mode 100644 index 0000000000..a7a9e6a152 --- /dev/null +++ b/devtools/shared/worker/helper.js @@ -0,0 +1,136 @@ +/* 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"; + +(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: createTask }; +}); diff --git a/devtools/shared/worker/loader.js b/devtools/shared/worker/loader.js new file mode 100644 index 0000000000..52f2fa5585 --- /dev/null +++ b/devtools/shared/worker/loader.js @@ -0,0 +1,539 @@ +/* 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 worker, DebuggerNotificationObserver */ + +// A CommonJS module loader that is designed to run inside a worker debugger. +// We can't simply use the SDK module loader, because it relies heavily on +// Components, which isn't available in workers. +// +// In principle, the standard instance of the worker loader should provide the +// same built-in modules as its devtools counterpart, so that both loaders are +// interchangable on the main thread, making them easier to test. +// +// On the worker thread, some of these modules, in particular those that rely on +// the use of Components, and for which the worker debugger doesn't provide an +// alternative API, will be replaced by vacuous objects. Consequently, they can +// still be required, but any attempts to use them will lead to an exception. +// +// Note: to see dump output when running inside the worker thread, you might +// need to enable the browser.dom.window.dump.enabled pref. + +this.EXPORTED_SYMBOLS = ["WorkerDebuggerLoader", "worker"]; + +// Some notes on module ids and URLs: +// +// An id is either a relative id or an absolute id. An id is relative if and +// only if it starts with a dot. An absolute id is a normalized id if and only +// if it contains no redundant components. +// +// Every normalized id is a URL. A URL is either an absolute URL or a relative +// URL. A URL is absolute if and only if it starts with a scheme name followed +// by a colon and 2 or 3 slashes. + +/** + * Convert the given relative id to an absolute id. + * + * @param String id + * The relative id to be resolved. + * @param String baseId + * The absolute base id to resolve the relative id against. + * + * @return String + * An absolute id + */ +function resolveId(id, baseId) { + return baseId + "/../" + id; +} + +/** + * Convert the given absolute id to a normalized id. + * + * @param String id + * The absolute id to be normalized. + * + * @return String + * A normalized id. + */ +function normalizeId(id) { + // An id consists of an optional root and a path. A root consists of either + // a scheme name followed by 2 or 3 slashes, or a single slash. Slashes in the + // root are not used as separators, so only normalize the path. + const [, root, path] = id.match(/^(\w+:\/\/\/?|\/)?(.*)/); + + const stack = []; + path.split("/").forEach(function(component) { + switch (component) { + case "": + case ".": + break; + case "..": + if (stack.length === 0) { + if (root !== undefined) { + throw new Error("Can't normalize absolute id '" + id + "'!"); + } else { + stack.push(".."); + } + } else if (stack[stack.length - 1] == "..") { + stack.push(".."); + } else { + stack.pop(); + } + break; + default: + stack.push(component); + break; + } + }); + + return (root ? root : "") + stack.join("/"); +} + +/** + * Create a module object with the given normalized id. + * + * @param String + * The normalized id of the module to be created. + * + * @return Object + * A module with the given id. + */ +function createModule(id) { + return Object.create(null, { + // CommonJS specifies the id property to be non-configurable and + // non-writable. + id: { + configurable: false, + enumerable: true, + value: id, + writable: false, + }, + + // CommonJS does not specify an exports property, so follow the NodeJS + // convention, which is to make it non-configurable and writable. + exports: { + configurable: false, + enumerable: true, + value: Object.create(null), + writable: true, + }, + }); +} + +/** + * Create a CommonJS loader with the following options: + * - createSandbox: + * A function that will be used to create sandboxes. It should take the name + * and prototype of the sandbox to be created, and return the newly created + * sandbox as result. This option is required. + * - globals: + * A map of names to built-in globals that will be exposed to every module. + * Defaults to the empty map. + * - loadSubScript: + * A function that will be used to load scripts in sandboxes. It should take + * the URL from and the sandbox in which the script is to be loaded, and not + * return a result. This option is required. + * - modules: + * A map from normalized ids to built-in modules that will be added to the + * module cache. Defaults to the empty map. + * - paths: + * A map of paths to base URLs that will be used to resolve relative URLs to + * absolute URLS. Defaults to the empty map. + * - resolve: + * A function that will be used to resolve relative ids to absolute ids. It + * should take the relative id of a module to be required and the absolute + * id of the requiring module as arguments, and return the absolute id of + * the module to be required as result. Defaults to resolveId above. + */ +function WorkerDebuggerLoader(options) { + /** + * Convert the given relative URL to an absolute URL, using the map of paths + * given below. + * + * @param String url + * The relative URL to be resolved. + * + * @return String + * An absolute URL. + */ + function resolveURL(url) { + let found = false; + for (const [path, baseURL] of paths) { + if (url.startsWith(path)) { + found = true; + url = url.replace(path, baseURL); + break; + } + } + if (!found) { + throw new Error("Can't resolve relative URL '" + url + "'!"); + } + + // If the url has no extension, use ".js" by default. + return url.endsWith(".js") ? url : url + ".js"; + } + + /** + * Load the given module with the given url. + * + * @param Object module + * The module object to be loaded. + * @param String url + * The URL to load the module from. + */ + function loadModule(module, url) { + // CommonJS specifies 3 free variables: require, exports, and module. These + // must be exposed to every module, so define these as properties on the + // sandbox prototype. Additional built-in globals are exposed by making + // the map of built-in globals the prototype of the sandbox prototype. + const prototype = Object.create(globals); + prototype.Components = {}; + prototype.require = createRequire(module); + prototype.exports = module.exports; + prototype.module = module; + + const sandbox = createSandbox(url, prototype); + try { + loadSubScript(url, sandbox); + } catch (error) { + if (/^Error opening input stream/.test(String(error))) { + throw new Error( + "Can't load module '" + module.id + "' with url '" + url + "'!" + ); + } + throw error; + } + + // The value of exports may have been changed by the module script, so + // freeze it if and only if it is still an object. + if (typeof module.exports === "object" && module.exports !== null) { + Object.freeze(module.exports); + } + } + + /** + * Create a require function for the given module. If no module is given, + * create a require function for the top-level module instead. + * + * @param Object requirer + * The module for which the require function is to be created. + * + * @return Function + * A require function for the given module. + */ + function createRequire(requirer) { + return function require(id) { + // Make sure an id was passed. + if (id === undefined) { + throw new Error("Can't require module without id!"); + } + + // Built-in modules are cached by id rather than URL, so try to find the + // module to be required by id first. + let module = modules[id]; + if (module === undefined) { + // Failed to find the module to be required by id, so convert the id to + // a URL and try again. + + // If the id is relative, convert it to an absolute id. + if (id.startsWith(".")) { + if (requirer === undefined) { + throw new Error( + "Can't require top-level module with relative id " + + "'" + + id + + "'!" + ); + } + id = resolve(id, requirer.id); + } + + // Convert the absolute id to a normalized id. + id = normalizeId(id); + + // Convert the normalized id to a URL. + let url = id; + + // If the URL is relative, resolve it to an absolute URL. + if (url.match(/^\w+:\/\//) === null) { + url = resolveURL(id); + } + + // Try to find the module to be required by URL. + module = modules[url]; + if (module === undefined) { + // Failed to find the module to be required in the cache, so create + // a new module, load it from the given URL, and add it to the cache. + + // Add modules to the cache early so that any recursive calls to + // require for the same module will return the partially-loaded module + // from the cache instead of triggering a new load. + module = modules[url] = createModule(id); + + try { + loadModule(module, url); + } catch (error) { + // If the module failed to load, remove it from the cache so that + // subsequent calls to require for the same module will trigger a + // new load, instead of returning a partially-loaded module from + // the cache. + delete modules[url]; + throw error; + } + + Object.freeze(module); + } + } + + return module.exports; + }; + } + + const createSandbox = options.createSandbox; + const globals = options.globals || Object.create(null); + const loadSubScript = options.loadSubScript; + + // Create the module cache, by converting each entry in the map from + // normalized ids to built-in modules to a module object, with the exports + // property of each module set to a frozen version of the original entry. + const modules = options.modules || {}; + for (const id in modules) { + const module = createModule(id); + module.exports = Object.freeze(modules[id]); + modules[id] = module; + } + + // Convert the map of paths to base URLs into an array for use by resolveURL. + // The array is sorted from longest to shortest path to ensure that the + // longest path is always the first to be found. + let paths = options.paths || Object.create(null); + paths = Object.keys(paths) + .sort((a, b) => b.length - a.length) + .map(path => [path, paths[path]]); + + const resolve = options.resolve || resolveId; + + this.require = createRequire(); +} + +this.WorkerDebuggerLoader = WorkerDebuggerLoader; + +// The following APIs rely on the use of Components, and the worker debugger +// does not provide alternative definitions for them. Consequently, they are +// stubbed out both on the main thread and worker threads. + +var chrome = { + CC: undefined, + Cc: undefined, + ChromeWorker: undefined, + Cm: undefined, + Ci: undefined, + Cu: undefined, + Cr: undefined, + components: undefined, +}; + +var loader = { + lazyGetter: function(object, name, lambda) { + Object.defineProperty(object, name, { + get: function() { + delete object[name]; + object[name] = lambda.apply(object); + return object[name]; + }, + configurable: true, + enumerable: true, + }); + }, + lazyImporter: function() { + throw new Error("Can't import JSM from worker thread!"); + }, + lazyServiceGetter: function() { + throw new Error("Can't import XPCOM service from worker thread!"); + }, + lazyRequireGetter: function(obj, properties, module, destructure) { + if (Array.isArray(properties) && !destructure) { + throw new Error( + "Pass destructure=true to call lazyRequireGetter with an array of properties" + ); + } + + if (!Array.isArray(properties)) { + properties = [properties]; + } + + for (const property of properties) { + Object.defineProperty(obj, property, { + get: () => + destructure + ? worker.require(module)[property] + : worker.require(module || property), + }); + } + }, +}; + +// The following APIs are defined differently depending on whether we are on the +// main thread or a worker thread. On the main thread, we use the Components +// object to implement them. On worker threads, we use the APIs provided by +// the worker debugger. + +/* eslint-disable no-shadow */ +var { + Debugger, + URL, + createSandbox, + dump, + rpc, + loadSubScript, + reportError, + setImmediate, + xpcInspector, +} = function() { + // Main thread + if (typeof Components === "object") { + const { Constructor: CC } = Components; + + const principal = CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(); + + // To ensure that the this passed to addDebuggerToGlobal is a global, the + // Debugger object needs to be defined in a sandbox. + const sandbox = Cu.Sandbox(principal, {}); + Cu.evalInSandbox( + "Components.utils.import('resource://gre/modules/jsdebugger.jsm');" + + "addDebuggerToGlobal(this);", + sandbox + ); + const Debugger = sandbox.Debugger; + + const createSandbox = function(name, prototype) { + return Cu.Sandbox(principal, { + invisibleToDebugger: true, + sandboxName: name, + sandboxPrototype: prototype, + wantComponents: false, + wantXrays: false, + }); + }; + + const rpc = undefined; + + // eslint-disable-next-line mozilla/use-services + const subScriptLoader = Cc[ + "@mozilla.org/moz/jssubscript-loader;1" + ].getService(Ci.mozIJSSubScriptLoader); + + const loadSubScript = function(url, sandbox) { + subScriptLoader.loadSubScript(url, sandbox); + }; + + const reportError = Cu.reportError; + + const Timer = ChromeUtils.import("resource://gre/modules/Timer.jsm"); + + const setImmediate = function(callback) { + Timer.setTimeout(callback, 0); + }; + + const xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService( + Ci.nsIJSInspector + ); + + const { URL } = Cu.Sandbox(principal, { + wantGlobalProperties: ["URL"], + }); + + return { + Debugger, + URL: URL, + createSandbox, + dump: this.dump, + rpc, + loadSubScript, + reportError, + setImmediate, + xpcInspector, + }; + } + // Worker thread + const requestors = []; + + const scope = this; + + const xpcInspector = { + get eventLoopNestLevel() { + return requestors.length; + }, + + get lastNestRequestor() { + return requestors.length === 0 ? null : requestors[requestors.length - 1]; + }, + + enterNestedEventLoop: function(requestor) { + requestors.push(requestor); + scope.enterEventLoop(); + return requestors.length; + }, + + exitNestedEventLoop: function() { + requestors.pop(); + scope.leaveEventLoop(); + return requestors.length; + }, + }; + + return { + Debugger: this.Debugger, + URL: this.URL, + createSandbox: this.createSandbox, + dump: this.dump, + rpc: this.rpc, + loadSubScript: this.loadSubScript, + reportError: this.reportError, + setImmediate: this.setImmediate, + xpcInspector: xpcInspector, + }; +}.call(this); +/* eslint-enable no-shadow */ + +// Create the default instance of the worker loader, using the APIs we defined +// above. + +this.worker = new WorkerDebuggerLoader({ + createSandbox: createSandbox, + globals: { + isWorker: true, + dump: dump, + loader: loader, + reportError: reportError, + rpc: rpc, + URL: URL, + setImmediate: setImmediate, + retrieveConsoleEvents: this.retrieveConsoleEvents, + setConsoleEventHandler: this.setConsoleEventHandler, + console: console, + btoa: this.btoa, + atob: this.atob, + }, + loadSubScript: loadSubScript, + modules: { + Debugger: Debugger, + Services: Object.create(null), + chrome: chrome, + xpcInspector: xpcInspector, + ChromeUtils: ChromeUtils, + DebuggerNotificationObserver: DebuggerNotificationObserver, + }, + paths: { + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + devtools: "resource://devtools", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + promise: "resource://gre/modules/Promise-backend.js", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + "xpcshell-test": "resource://test", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + }, +}); diff --git a/devtools/shared/worker/moz.build b/devtools/shared/worker/moz.build new file mode 100644 index 0000000000..dd86df3a67 --- /dev/null +++ b/devtools/shared/worker/moz.build @@ -0,0 +1,14 @@ +# -*- 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", + "loader.js", + "worker.js", +) diff --git a/devtools/shared/worker/tests/browser/.eslintrc.js b/devtools/shared/worker/tests/browser/.eslintrc.js new file mode 100644 index 0000000000..2eba290f7d --- /dev/null +++ b/devtools/shared/worker/tests/browser/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../../.eslintrc.mochitests.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..d200a1ee58 --- /dev/null +++ b/devtools/shared/worker/tests/browser/browser_worker-01.js @@ -0,0 +1,87 @@ +/* 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 WORKER_URL = "resource://devtools/client/shared/widgets/GraphsWorker.js"; + +const BUFFER_SIZE = 8; +const count = 100000; +const WORKER_DATA = (function() { + const timestamps = []; + for (let i = 0; i < count; i++) { + timestamps.push(i); + } + return timestamps; +})(); +const INTERVAL = 100; +const DURATION = 1000; + +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("devtools/shared/worker/worker")); + 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 worker = new DevToolsWorker(WORKER_URL); + const results = await 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}` + ); + + 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..6cf2e2d9ad --- /dev/null +++ b/devtools/shared/worker/tests/browser/browser_worker-02.js @@ -0,0 +1,65 @@ +/* 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("devtools/shared/worker/worker"); +const WORKER_URL = "resource://devtools/client/shared/widgets/GraphsWorker.js"; + +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 = 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 + await 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 { + 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("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" + ); + } +}); 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..7bcfea00f4 --- /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("devtools/shared/worker/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")); +} + +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..b703d72693 --- /dev/null +++ b/devtools/shared/worker/worker.js @@ -0,0 +1,202 @@ +/* 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 { Cc, Ci, Cu, ChromeWorker } = require("chrome"); + const dumpn = require("devtools/shared/DevToolsUtils").dumpn; + factory.call( + this, + require, + exports, + module, + { Cc, Ci, Cu }, + ChromeWorker, + dumpn + ); + } else { + // Cu.import + const { require } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" + ); + this.isWorker = false; + this.Promise = require("resource://gre/modules/Promise.jsm").Promise; + 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`. + * + * @see `./devtools/client/shared/widgets/GraphsWorker.js` + * + * @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 Services = require("Services"); + 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()});`; + } +})); |