| 'use strict'; | |
| var {Promise} = require('./Promise'); | |
| var environment = require('./environment'); | |
| const {validateOptions, forkOptsNames, workerThreadOptsNames, workerOptsNames} = require("./validateOptions"); | |
| /** | |
| * Special message sent by parent which causes a child process worker to terminate itself. | |
| * Not a "message object"; this string is the entire message. | |
| */ | |
| var TERMINATE_METHOD_ID = '__workerpool-terminate__'; | |
| /** | |
| * Special message by parent which causes a child process worker to perform cleaup | |
| * steps before determining if the child process worker should be terminated. | |
| */ | |
| var CLEANUP_METHOD_ID = '__workerpool-cleanup__'; | |
| function ensureWorkerThreads() { | |
| var WorkerThreads = tryRequireWorkerThreads() | |
| if (!WorkerThreads) { | |
| throw new Error('WorkerPool: workerType = \'thread\' is not supported, Node >= 11.7.0 required') | |
| } | |
| return WorkerThreads; | |
| } | |
| // check whether Worker is supported by the browser | |
| function ensureWebWorker() { | |
| // Workaround for a bug in PhantomJS (Or QtWebkit): https://github.com/ariya/phantomjs/issues/14534 | |
| if (typeof Worker !== 'function' && (typeof Worker !== 'object' || typeof Worker.prototype.constructor !== 'function')) { | |
| throw new Error('WorkerPool: Web Workers not supported'); | |
| } | |
| } | |
| function tryRequireWorkerThreads() { | |
| try { | |
| return require('worker_threads'); | |
| } catch(error) { | |
| if (typeof error === 'object' && error !== null && error.code === 'MODULE_NOT_FOUND') { | |
| // no worker_threads available (old version of node.js) | |
| return null; | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } | |
| // get the default worker script | |
| function getDefaultWorker() { | |
| if (environment.platform === 'browser') { | |
| // test whether the browser supports all features that we need | |
| if (typeof Blob === 'undefined') { | |
| throw new Error('Blob not supported by the browser'); | |
| } | |
| if (!window.URL || typeof window.URL.createObjectURL !== 'function') { | |
| throw new Error('URL.createObjectURL not supported by the browser'); | |
| } | |
| // use embedded worker.js | |
| var blob = new Blob([require('./generated/embeddedWorker')], {type: 'text/javascript'}); | |
| return window.URL.createObjectURL(blob); | |
| } | |
| else { | |
| // use external worker.js in current directory | |
| return __dirname + '/worker.js'; | |
| } | |
| } | |
| function setupWorker(script, options) { | |
| if (options.workerType === 'web') { // browser only | |
| ensureWebWorker(); | |
| return setupBrowserWorker(script, options.workerOpts, Worker); | |
| } else if (options.workerType === 'thread') { // node.js only | |
| WorkerThreads = ensureWorkerThreads(); | |
| return setupWorkerThreadWorker(script, WorkerThreads, options); | |
| } else if (options.workerType === 'process' || !options.workerType) { // node.js only | |
| return setupProcessWorker(script, resolveForkOptions(options), require('child_process')); | |
| } else { // options.workerType === 'auto' or undefined | |
| if (environment.platform === 'browser') { | |
| ensureWebWorker(); | |
| return setupBrowserWorker(script, options.workerOpts, Worker); | |
| } | |
| else { // environment.platform === 'node' | |
| var WorkerThreads = tryRequireWorkerThreads(); | |
| if (WorkerThreads) { | |
| return setupWorkerThreadWorker(script, WorkerThreads, options); | |
| } else { | |
| return setupProcessWorker(script, resolveForkOptions(options), require('child_process')); | |
| } | |
| } | |
| } | |
| } | |
| function setupBrowserWorker(script, workerOpts, Worker) { | |
| // validate the options right before creating the worker (not when creating the pool) | |
| validateOptions(workerOpts, workerOptsNames, 'workerOpts') | |
| // create the web worker | |
| var worker = new Worker(script, workerOpts); | |
| worker.isBrowserWorker = true; | |
| // add node.js API to the web worker | |
| worker.on = function (event, callback) { | |
| this.addEventListener(event, function (message) { | |
| callback(message.data); | |
| }); | |
| }; | |
| worker.send = function (message, transfer) { | |
| this.postMessage(message, transfer); | |
| }; | |
| return worker; | |
| } | |
| function setupWorkerThreadWorker(script, WorkerThreads, options) { | |
| // validate the options right before creating the worker thread (not when creating the pool) | |
| validateOptions(options?.workerThreadOpts, workerThreadOptsNames, 'workerThreadOpts') | |
| var worker = new WorkerThreads.Worker(script, { | |
| stdout: options?.emitStdStreams ?? false, // pipe worker.STDOUT to process.STDOUT if not requested | |
| stderr: options?.emitStdStreams ?? false, // pipe worker.STDERR to process.STDERR if not requested | |
| ...options?.workerThreadOpts | |
| }); | |
| worker.isWorkerThread = true; | |
| worker.send = function(message, transfer) { | |
| this.postMessage(message, transfer); | |
| }; | |
| worker.kill = function() { | |
| this.terminate(); | |
| return true; | |
| }; | |
| worker.disconnect = function() { | |
| this.terminate(); | |
| }; | |
| if (options?.emitStdStreams) { | |
| worker.stdout.on('data', (data) => worker.emit("stdout", data)) | |
| worker.stderr.on('data', (data) => worker.emit("stderr", data)) | |
| } | |
| return worker; | |
| } | |
| function setupProcessWorker(script, options, child_process) { | |
| // validate the options right before creating the child process (not when creating the pool) | |
| validateOptions(options.forkOpts, forkOptsNames, 'forkOpts') | |
| // no WorkerThreads, fallback to sub-process based workers | |
| var worker = child_process.fork( | |
| script, | |
| options.forkArgs, | |
| options.forkOpts | |
| ); | |
| // ignore transfer argument since it is not supported by process | |
| var send = worker.send; | |
| worker.send = function (message) { | |
| return send.call(worker, message); | |
| }; | |
| if (options.emitStdStreams) { | |
| worker.stdout.on('data', (data) => worker.emit("stdout", data)) | |
| worker.stderr.on('data', (data) => worker.emit("stderr", data)) | |
| } | |
| worker.isChildProcess = true; | |
| return worker; | |
| } | |
| // add debug flags to child processes if the node inspector is active | |
| function resolveForkOptions(opts) { | |
| opts = opts || {}; | |
| var processExecArgv = process.execArgv.join(' '); | |
| var inspectorActive = processExecArgv.indexOf('--inspect') !== -1; | |
| var debugBrk = processExecArgv.indexOf('--debug-brk') !== -1; | |
| var execArgv = []; | |
| if (inspectorActive) { | |
| execArgv.push('--inspect=' + opts.debugPort); | |
| if (debugBrk) { | |
| execArgv.push('--debug-brk'); | |
| } | |
| } | |
| process.execArgv.forEach(function(arg) { | |
| if (arg.indexOf('--max-old-space-size') > -1) { | |
| execArgv.push(arg) | |
| } | |
| }) | |
| return Object.assign({}, opts, { | |
| forkArgs: opts.forkArgs, | |
| forkOpts: Object.assign({}, opts.forkOpts, { | |
| execArgv: (opts.forkOpts && opts.forkOpts.execArgv || []) | |
| .concat(execArgv), | |
| stdio: opts.emitStdStreams ? "pipe": undefined | |
| }) | |
| }); | |
| } | |
| /** | |
| * Converts a serialized error to Error | |
| * @param {Object} obj Error that has been serialized and parsed to object | |
| * @return {Error} The equivalent Error. | |
| */ | |
| function objectToError (obj) { | |
| var temp = new Error('') | |
| var props = Object.keys(obj) | |
| for (var i = 0; i < props.length; i++) { | |
| temp[props[i]] = obj[props[i]] | |
| } | |
| return temp | |
| } | |
| function handleEmittedStdPayload(handler, payload) { | |
| // TODO: refactor if parallel task execution gets added | |
| Object.values(handler.processing) | |
| .forEach(task => task?.options?.on(payload)); | |
| Object.values(handler.tracking) | |
| .forEach(task => task?.options?.on(payload)); | |
| } | |
| /** | |
| * A WorkerHandler controls a single worker. This worker can be a child process | |
| * on node.js or a WebWorker in a browser environment. | |
| * @param {String} [script] If no script is provided, a default worker with a | |
| * function run will be created. | |
| * @param {import('./types.js').WorkerPoolOptions} [_options] See docs | |
| * @constructor | |
| */ | |
| function WorkerHandler(script, _options) { | |
| var me = this; | |
| var options = _options || {}; | |
| this.script = script || getDefaultWorker(); | |
| this.worker = setupWorker(this.script, options); | |
| this.debugPort = options.debugPort; | |
| this.forkOpts = options.forkOpts; | |
| this.forkArgs = options.forkArgs; | |
| this.workerOpts = options.workerOpts; | |
| this.workerThreadOpts = options.workerThreadOpts | |
| this.workerTerminateTimeout = options.workerTerminateTimeout; | |
| // The ready message is only sent if the worker.add method is called (And the default script is not used) | |
| if (!script) { | |
| this.worker.ready = true; | |
| } | |
| // queue for requests that are received before the worker is ready | |
| this.requestQueue = []; | |
| this.worker.on("stdout", function (data) { | |
| handleEmittedStdPayload(me, {"stdout": data.toString()}) | |
| }) | |
| this.worker.on("stderr", function (data) { | |
| handleEmittedStdPayload(me, {"stderr": data.toString()}) | |
| }) | |
| this.worker.on('message', function (response) { | |
| if (me.terminated) { | |
| return; | |
| } | |
| if (typeof response === 'string' && response === 'ready') { | |
| me.worker.ready = true; | |
| dispatchQueuedRequests(); | |
| } else { | |
| // find the task from the processing queue, and run the tasks callback | |
| var id = response.id; | |
| var task = me.processing[id]; | |
| if (task !== undefined) { | |
| if (response.isEvent) { | |
| if (task.options && typeof task.options.on === 'function') { | |
| task.options.on(response.payload); | |
| } | |
| } else { | |
| // remove the task from the queue | |
| delete me.processing[id]; | |
| // test if we need to terminate | |
| if (me.terminating === true) { | |
| // complete worker termination if all tasks are finished | |
| me.terminate(); | |
| } | |
| // resolve the task's promise | |
| if (response.error) { | |
| task.resolver.reject(objectToError(response.error)); | |
| } | |
| else { | |
| task.resolver.resolve(response.result); | |
| } | |
| } | |
| } else { | |
| // if the task is not the current, it might be tracked for cleanup | |
| var task = me.tracking[id]; | |
| if (task !== undefined) { | |
| if (response.isEvent) { | |
| if (task.options && typeof task.options.on === 'function') { | |
| task.options.on(response.payload); | |
| } | |
| } | |
| } | |
| } | |
| if (response.method === CLEANUP_METHOD_ID) { | |
| var trackedTask = me.tracking[response.id]; | |
| if (trackedTask !== undefined) { | |
| if (response.error) { | |
| clearTimeout(trackedTask.timeoutId); | |
| trackedTask.resolver.reject(objectToError(response.error)) | |
| } else { | |
| me.tracking && clearTimeout(trackedTask.timeoutId); | |
| // if we do not encounter an error wrap the the original timeout error and reject | |
| trackedTask.resolver.reject(new WrappedTimeoutError(trackedTask.error)); | |
| } | |
| } | |
| delete me.tracking[id]; | |
| } | |
| } | |
| }); | |
| // reject all running tasks on worker error | |
| function onError(error) { | |
| me.terminated = true; | |
| for (var id in me.processing) { | |
| if (me.processing[id] !== undefined) { | |
| me.processing[id].resolver.reject(error); | |
| } | |
| } | |
| me.processing = Object.create(null); | |
| } | |
| // send all queued requests to worker | |
| function dispatchQueuedRequests() | |
| { | |
| for(const request of me.requestQueue.splice(0)) { | |
| me.worker.send(request.message, request.transfer); | |
| } | |
| } | |
| var worker = this.worker; | |
| // listen for worker messages error and exit | |
| this.worker.on('error',function (error) { | |
| const message = error && error.message ? error.message : String(error || 'Unknown worker error') | |
| onError(new TerminateError('Workerpool Worker error: ' + message, error)) | |
| }); | |
| this.worker.on('exit', function (exitCode, signalCode) { | |
| var message = 'Workerpool Worker terminated Unexpectedly\n'; | |
| message += ' exitCode: `' + exitCode + '`\n'; | |
| message += ' signalCode: `' + signalCode + '`\n'; | |
| message += ' workerpool.script: `' + me.script + '`\n'; | |
| message += ' spawnArgs: `' + worker.spawnargs + '`\n'; | |
| message += ' spawnfile: `' + worker.spawnfile + '`\n' | |
| message += ' stdout: `' + worker.stdout + '`\n' | |
| message += ' stderr: `' + worker.stderr + '`\n' | |
| onError(new TerminateError(message)); | |
| }); | |
| this.processing = Object.create(null); // queue with tasks currently in progress | |
| this.tracking = Object.create(null); // queue with tasks being monitored for cleanup status | |
| this.terminating = false; | |
| this.terminated = false; | |
| this.cleaning = false; | |
| this.terminationHandler = null; | |
| this.lastId = 0; | |
| } | |
| /** | |
| * Get a list with methods available on the worker. | |
| * @return {Promise.<String[], Error>} methods | |
| */ | |
| WorkerHandler.prototype.methods = function () { | |
| return this.exec('methods'); | |
| }; | |
| /** | |
| * Execute a method with given parameters on the worker | |
| * @param {String} method | |
| * @param {Array} [params] | |
| * @param {{resolve: Function, reject: Function}} [resolver] | |
| * @param {import('./types.js').ExecOptions} [options] | |
| * @return {Promise.<*, Error>} result | |
| */ | |
| WorkerHandler.prototype.exec = function(method, params, resolver, options) { | |
| if (!resolver) { | |
| resolver = Promise.defer(); | |
| } | |
| // generate a unique id for the task | |
| var id = ++this.lastId; | |
| // register a new task as being in progress | |
| this.processing[id] = { | |
| id: id, | |
| resolver: resolver, | |
| options: options | |
| }; | |
| // build a JSON-RPC request | |
| var request = { | |
| message: { | |
| id: id, | |
| method: method, | |
| params: params | |
| }, | |
| transfer: options && options.transfer | |
| }; | |
| if (this.terminated) { | |
| resolver.reject(new TerminateError('Worker is terminated')); | |
| } else if (this.worker.ready) { | |
| // send the request to the worker | |
| this.worker.send(request.message, request.transfer); | |
| } else { | |
| this.requestQueue.push(request); | |
| } | |
| // on cancellation, force the worker to terminate | |
| var me = this; | |
| return resolver.promise.catch(function (error) { | |
| if (error instanceof Promise.CancellationError || error instanceof Promise.TimeoutError) { | |
| me.tracking[id] = { | |
| id, | |
| resolver: Promise.defer(), | |
| options: options, | |
| error, | |
| }; | |
| // remove this task from the queue. It is already rejected (hence this | |
| // catch event), and else it will be rejected again when terminating | |
| delete me.processing[id]; | |
| me.tracking[id].resolver.promise = me.tracking[id].resolver.promise.catch(function(err) { | |
| delete me.tracking[id]; | |
| // if we find the error is an instance of WrappedTimeoutError we know the error should not cause termination | |
| // as the response from the worker did not contain an error. We still wish to throw the original timeout error | |
| // to the caller. | |
| if (err instanceof WrappedTimeoutError) { | |
| throw err.error; | |
| } | |
| var promise = me.terminateAndNotify(true) | |
| .then(function() { | |
| throw err; | |
| }, function(err) { | |
| throw err; | |
| }); | |
| return promise; | |
| }); | |
| me.worker.send({ | |
| id, | |
| method: CLEANUP_METHOD_ID | |
| }); | |
| /** | |
| * Sets a timeout to reject the cleanup operation if the message sent to the worker | |
| * does not receive a response. see worker.tryCleanup for worker cleanup operations. | |
| * Here we use the workerTerminateTimeout as the worker will be terminated if the timeout does invoke. | |
| * | |
| * We need this timeout in either case of a Timeout or Cancellation Error as if | |
| * the worker does not send a message we still need to give a window of time for a response. | |
| * | |
| * The workerTerminateTimeout is used here if this promise is rejected the worker cleanup | |
| * operations will occur. | |
| */ | |
| me.tracking[id].timeoutId = setTimeout(function() { | |
| me.tracking[id].resolver.reject(error); | |
| }, me.workerTerminateTimeout); | |
| return me.tracking[id].resolver.promise; | |
| } else { | |
| throw error; | |
| } | |
| }) | |
| }; | |
| /** | |
| * Test whether the worker is processing any tasks or cleaning up before termination. | |
| * @return {boolean} Returns true if the worker is busy | |
| */ | |
| WorkerHandler.prototype.busy = function () { | |
| // A worker with entries in `tracking` has a cancelled or timed-out task whose | |
| // cleanup may force-terminate the worker after `workerTerminateTimeout`. New | |
| // tasks must not be assigned to it during that window — if they were, the | |
| // forced terminate would reject them with "Worker terminated" even though | |
| // they were unrelated to the original cancellation. | |
| return this.cleaning | |
| || Object.keys(this.processing).length > 0 | |
| || Object.keys(this.tracking).length > 0; | |
| }; | |
| /** | |
| * Terminate the worker. | |
| * @param {boolean} [force=false] If false (default), the worker is terminated | |
| * after finishing all tasks currently in | |
| * progress. If true, the worker will be | |
| * terminated immediately. | |
| * @param {function} [callback=null] If provided, will be called when process terminates. | |
| */ | |
| WorkerHandler.prototype.terminate = function (force, callback) { | |
| var me = this; | |
| if (force) { | |
| // cancel all tasks in progress | |
| for (var id in this.processing) { | |
| if (this.processing[id] !== undefined) { | |
| this.processing[id].resolver.reject(new Error('Worker terminated')); | |
| } | |
| } | |
| this.processing = Object.create(null); | |
| } | |
| // If we are terminating, cancel all tracked task for cleanup | |
| for (var task of Object.values(me.tracking)) { | |
| clearTimeout(task.timeoutId); | |
| task.resolver.reject(new Error('Worker Terminating')); | |
| } | |
| me.tracking = Object.create(null); | |
| if (typeof callback === 'function') { | |
| this.terminationHandler = callback; | |
| } | |
| if (!this.busy()) { | |
| // all tasks are finished. kill the worker | |
| var cleanup = function(err) { | |
| me.terminated = true; | |
| me.cleaning = false; | |
| if (me.worker != null && me.worker.removeAllListeners) { | |
| // removeAllListeners is only available for child_process | |
| me.worker.removeAllListeners('message'); | |
| } | |
| me.worker = null; | |
| me.terminating = false; | |
| if (me.terminationHandler) { | |
| me.terminationHandler(err, me); | |
| } else if (err) { | |
| throw err; | |
| } | |
| } | |
| if (this.worker) { | |
| if (typeof this.worker.kill === 'function') { | |
| if (this.worker.killed) { | |
| cleanup(new Error('worker already killed!')); | |
| return; | |
| } | |
| // child process and worker threads | |
| var cleanExitTimeout = setTimeout(function() { | |
| if (me.worker) { | |
| me.worker.kill(); | |
| } | |
| }, this.workerTerminateTimeout); | |
| this.worker.once('exit', function() { | |
| clearTimeout(cleanExitTimeout); | |
| if (me.worker) { | |
| me.worker.killed = true; | |
| } | |
| cleanup(); | |
| }); | |
| if (this.worker.ready) { | |
| this.worker.send(TERMINATE_METHOD_ID); | |
| } else { | |
| this.requestQueue.push({ message: TERMINATE_METHOD_ID }); | |
| } | |
| // mark that the worker is cleaning up resources | |
| // to prevent new tasks from being executed | |
| this.cleaning = true; | |
| return; | |
| } | |
| else if (typeof this.worker.terminate === 'function') { | |
| this.worker.terminate(); // web worker | |
| this.worker.killed = true; | |
| } | |
| else { | |
| throw new Error('Failed to terminate worker'); | |
| } | |
| } | |
| cleanup(); | |
| } | |
| else { | |
| // we can't terminate immediately, there are still tasks being executed | |
| this.terminating = true; | |
| } | |
| }; | |
| /** | |
| * Terminate the worker, returning a Promise that resolves when the termination has been done. | |
| * @param {boolean} [force=false] If false (default), the worker is terminated | |
| * after finishing all tasks currently in | |
| * progress. If true, the worker will be | |
| * terminated immediately. | |
| * @param {number} [timeout] If provided and non-zero, worker termination promise will be rejected | |
| * after timeout if worker process has not been terminated. | |
| * @return {Promise.<WorkerHandler, Error>} | |
| */ | |
| WorkerHandler.prototype.terminateAndNotify = function (force, timeout) { | |
| var resolver = Promise.defer(); | |
| if (timeout) { | |
| resolver.promise.timeout(timeout); | |
| } | |
| this.terminate(force, function(err, worker) { | |
| if (err) { | |
| resolver.reject(err); | |
| } else { | |
| resolver.resolve(worker); | |
| } | |
| }); | |
| return resolver.promise; | |
| }; | |
| /** | |
| * Wrapper error type to denote that a TimeoutError has already been processed | |
| * and we should skip cleanup operations | |
| * @param {Promise.TimeoutError} timeoutError | |
| */ | |
| function WrappedTimeoutError(timeoutError) { | |
| this.error = timeoutError; | |
| this.stack = (new Error()).stack; | |
| } | |
| class TerminateError extends Error { | |
| /** | |
| * Create a timeout error | |
| * @param {String} [message] | |
| * @param {Error=} [cause] | |
| */ | |
| constructor(message, cause) { | |
| super(message || 'worker terminated'); | |
| this.cause = cause; | |
| } | |
| } | |
| module.exports = WorkerHandler; | |
| module.exports._tryRequireWorkerThreads = tryRequireWorkerThreads; | |
| module.exports._setupProcessWorker = setupProcessWorker; | |
| module.exports._setupBrowserWorker = setupBrowserWorker; | |
| module.exports._setupWorkerThreadWorker = setupWorkerThreadWorker; | |
| module.exports.ensureWorkerThreads = ensureWorkerThreads; | |
| module.exports.TerminateError = TerminateError |