UNPKG

dcp-client

Version:

Core libraries for accessing DCP network

438 lines (392 loc) 16.5 kB
/** * @file standaloneWorker.js A Node module which implements the class standaloneWorker, * which knows how to execute jobs over the network in a * standalone worker. * * @author Wes Garland, wes@sparc.network * @date March 2018 */ 'use strict'; const { setBackoffInterval, clearBackoffInterval, Reference } = require('dcp/dcp-timers'); const { leafMerge, deepClone } = require('dcp/utils'); var debugging = require('dcp/internal/debugging').scope('saw'); /** * StandaloneWorker constructor * The running configuration is derived by merging: * - baked-in defaults * - dcpConfig.evaluator * - option.hostname/port/location * * @param options Options for the Worker constructor. These can be options per the specification * for Web Workers (note: not current propagated) or any of these options: * * hostname: The hostname of the Evaluator server; default to dcpConfig.eveluator.hostname or localhost. * port: The port number of the Evaluator server; default to dcpConfig.evaluator.port or 9000; * location: URL which contains hostname and port * readStream: An instance of Stream.readable connected to an Evaluator * writeStream: An instance of Stream.writeable connected to the same Evaluator, default=readStream * * @returns an object that inherits from dcp-timers::References, having the following * - methods: * . addEventListener * . removeEventListener * . postMessage * . terminate * . create * - properties: * . onmessage * . onerror * . serial sequence number * . serialize current serialization function * . deserialize current deserialization function * . config derived configuration * - events: * . error * . message */ function StandaloneWorker(options = {}) { const that = this; var readStream, writeStream; var ee = new (require('events').EventEmitter)('StandaloneWorker'); var readBuf = '' var connected = false var dieTimer var shutdown; Reference.call(this, debugging, () => `sa-worker@${this.serial}/${this.config.location}`); const defaultConfig = { tuning: { noDelay: true, connectBackoff: { maxInterval: 5 * 60 * 1000, // max: 5 minutes baseInterval: 10 * 1000, // start: 10s backoffFactor: 1.1 // each fail, back off by 10% }, }, }; this.config = leafMerge( defaultConfig, deepClone(dcpConfig.evaluator), ); delete this.config.listen; if (options.location) this.config.location = options.location; if (options.hostname) this.config.location.hostname = options.hostname; if (options.port) this.config.location.port = options.port; if (typeof options === 'object' && options.readStream) { debugging('lifecycle') && console.debug('Connecting via supplied streams'); readStream = options.readStream; writeStream = options.writeStream || options.readStream; delete options.readStream; delete options.writeStream; } if (!readStream) { /* No supplied streams - reach out and connect over TCP/IP */ let socket = readStream = writeStream = new (require('net')).Socket().unref(); debugging() && console.debug('Connecting to evaluator on', this.config.location.hostname + ':' + this.config.location.port); socket.setNoDelay(this.config.tuning.noDelay ?? true); socket.connect(this.config.location.port, this.config.location.hostname); socket.on('connect', beginSession.bind(this)); let alreadySignaled = false; const sandboxDeath = () => { connected = false; if (!alreadySignaled) { ee.emit('end'); alreadySignaled = true; } }; // common culprits include ECONNRESET, ECONNREFUSED, ECONNABORTED, ETIMEDOUT socket.on('error', sandboxDeath); // when the evaluator half closes the connection, the sandbox can't be useful anymore socket.on('end', sandboxDeath); socket.on('error', function saWorker$$socket$errorHandler(e) { debugging() && console.debug(`Evaluator ${this.serial} - socket error:`, e.code === 'ECONNREFUSED' ? e.message : e); if (options.onsocketerror) options.onsocketerror(e, this); }.bind(this)); } else { setImmediate(beginSession.bind(this)); } this.addEventListener = ee.addListener.bind(ee) this.removeEventListener = ee.removeListener.bind(ee) this.removeAllListeners = ee.removeAllListeners.bind(ee); this.serial = StandaloneWorker.lastSerial = (StandaloneWorker.lastSerial || 0) + 1 this.serialize = JSON.stringify this.deserialize = JSON.parse /** @param data Buffer or string */ function writeOrQueue(data) { /* We queue writes in pendingWrites between the call to connect() and * the actual establishment of the TCP socket. Once connected, we drain that * queue into the write queue in Node's net module. */ let realWrite = writeOrQueue.realWrite; let pendingWrites = writeOrQueue.pendingWrites; if (!connected) { if (data !== null) pendingWrites.push(data); return; } if (typeof data === 'object' && data instanceof Buffer) data = Buffer.from(data); while (pendingWrites.length && !writeStream.destroyed) { realWrite(pendingWrites.shift()); } if (data && writeStream.destroyed) { console.log(`Warning; tried to write data ${data} after writeStream was destroyed`, new Error()); } if (data !== null) /* pass null to force flush pending if connected */ realWrite(data); } writeOrQueue.pendingWrites = []; writeOrQueue.realWrite = writeStream.write.bind(writeStream); delete writeOrQueue.write; function beginSession () { connected = true readStream.on ('error', shutdown); readStream.on ('end', shutdown); readStream.on ('close', shutdown); if (readStream !== writeStream) { writeStream.on('error', shutdown); writeStream.on('end', shutdown); writeStream.on('close', shutdown); } readStream.setEncoding('utf-8'); if (writeStream.setEncoding) writeStream.setEncoding('utf-8'); debugging() && console.debug('Connected; ' + writeOrQueue.pendingWrites.length + ' pending messages.'); writeOrQueue(null); /* drain pending */ /* @todo Make this auto-detected /wg jul 2018 * changeSerializer.bind(this)("/var/dcp/lib/serialize.js") */ } /** Change the protocol's serialization implementation. Must be in * a format which returns an 'exports' object on evaluation that * has serialize and deserialize methods that are call-compatible * with JSON.stringify and JSON.parse. * * @param filename The path to the serialization module, or an exports object * @param charset [optional] The character set the code is stored in */ this.changeSerializer = (filename, charset) => { if (this.newSerializer) { throw new Error('Outstanding serialization change on worker #' + this.serial) } try { let code if (typeof filename === 'object') { let expo = filename code = '({ serialize:' + expo.serialize + ',deserialize:' + expo.deserialize + '})' } else { code = require('fs').readFileSync(filename, charset || 'utf-8') } this.newSerializer = eval(code) if (typeof this.newSerializer !== 'object') { throw new TypeError('newSerializer code evaluated as ' + typeof this.newSerializer) } writeOrQueue(this.serialize({ type: 'newSerializer', payload: code }) + '\n'); this.serialize = this.newSerializer.serialize /* do not change deserializer until worker acknowledges change */ } catch (e) { console.log('Cannot change serializer', e) } } /* Receive data from the network, turning it into debug output, * remote exceptions, worker messages, etc. */ readStream.on('data', function standaloneWorker$$Worker$recvData (data) { var line, lineObj /* line of data coming over the network */ var nl readBuf += data while ((nl = readBuf.indexOf('\n')) !== -1) { try { line = readBuf.slice(0, nl) readBuf = readBuf.slice(nl + 1) if (!line.length) { continue } if (line.match(/^DIE: */)) { /* Remote telling us they are dying */ debugging('lifecycle') && console.debug('Worker is dying (', line + ')'); shutdown(); clearTimeout(dieTimer) break } if (line.match(/^LOG: */)) { debugging('log') && console.log('Worker', this.serial, 'Log:', line.slice(4)); continue } if (!line.match(/^MSG: */)) { debugging('messages') && console.debug('worker:', line); continue } lineObj = this.deserialize(line.slice(4)) switch (lineObj.type) { case 'workerMessage': /* Remote posted message */ ee.emit('message', {data: lineObj.message}) break case 'nop': break case 'result': if (lineObj.hasOwnProperty('exception')) { /* Remote threw exception */ let e2 = new Error(lineObj.exception.message) e2.stack = 'Worker #' + this.serial + ' ' + lineObj.exception.stack + '\n via' + e2.stack.split('\n').slice(1).join('\n').slice(6) e2.name = 'Worker' + lineObj.exception.name if (lineObj.exception.fileName) { e2.fileName = lineObj.exception.fileName } if (lineObj.exception.lineNumber) { e2.lineNumber = lineObj.exception.lineNumber } ee.emit('error', e2) continue } else { if (lineObj.success && lineObj.origin === 'newSerializer') { /* Remote acknowledges change of serialization */ this.deserialize = this.newSerializer.deserialize delete this.newSerializer } else { debugging() && console.log('Worker', this.serial, 'returned result object: ', lineObj.result); } } break default: ee.emit('error', new Error('Unrecognized message type from worker #' + this.serial + ', \'' + lineObj.type + '\'')) } } catch (e) { ee.emit('error', 'Error processing remote response: \'' + line + '\' (' + e.name + ': ' + e.message + e.stack.split('\n')[1].replace(/^ */, ' ') + ')') } } }.bind(this)) ee.on('error', function standaloneWorker$$Worker$recvData$error (e) { debugging() && console.error("Evaluator threw an error:", e); }) ee.on('message', function standaloneWorker$$Worker$recvData$message (ev) { debugging() && console.log("Worker relayed a message:", ev); }); /** * Exclude certain socket errors from reporting. * @Wes Please verify that the 5 error codes below should not be reported to a worker or a dcp-client. * @param {Error} error * @returns (boolean) -- true when error is reportable */ const isErrorReportable = function standaloneWorker$$Worker$isErrorReportable (error) { switch(error.code) { case 'ECONNREFUSED': /* remote not listening - evaluator not running? */ case 'ECONNRESET': /* RST packet sent: remote closed - throttled? */ case 'ECONNABORTED': /* unexpectedly aborted by the other side */ case 'EPIPE': case 'ETIMEDOUT': debugging() && console.debug('Non-reportable error:', error) return false; default: return true; } } /* Shutdown the stream(s) which are connected to the evaluator */ shutdown = (e) => { if ((e instanceof Error) && isErrorReportable(e)) console.error(e); debugging('lifecycle') && console.debug('Shutting down evaluator connection ' + this.serial + ''); try { writeOrQueue(null); readStream.destroy(); if (readStream !== writeStream) writeStream.destroy(); } catch(error) { console.error(`Warning: could not shutdown evaluator connection ${e.code}`, error); }; connected = false; } debugging('lifecycle') && console.debug('Connecting to', this.config.location.hostname + ':' + this.config.location.port); /** Send a message over the network to a standalone worker */ this.postMessage = function standaloneWorker$$Worker$postMessage (message) { var wrappedMessage = this.serialize({ type: 'workerMessage', message: message }) + '\n' writeOrQueue(wrappedMessage); } /* Tell the sandbox die. The sandbox should respond with a message back of type DIE:, provided the * work function hops off the event loop before the timeout expires. Afterwards, we will close the * socket which should cause something like an NMI at the evaluator end that will terminate any * running work. */ this.terminate = function standaloneWorker$$Worker$terminate () { debugging() && console.log(`saw.terminate on Evaluator ${this.serial}`); var wrappedMessage = this.serialize({ type: 'die' }) + '\n' try { writeOrQueue(wrappedMessage); } catch (e) { // Socket may have already been destroyed } finally { this.unref(); } /* If DIE: response doesn't arrive in a reasonable time -- clean up */ dieTimer = setTimeout(shutdown, 7000); dieTimer.unref(); } } StandaloneWorker.prototype = Object.create(Reference.prototype); /** * Function to create constructors which behave very much like the WindowOrWorkerGlobalScope Worker * constructor, except they instanciate StandaloneWorker in place of a Web Worker. This factory * captures the options parameter, so that invocation of the returned constructor does not need to * * @param options options with which to invoke the StandaloneWorker constructor * when constructing workers. */ exports.workerFactory = function standaloneWorker$$WorkerFactory(options) { const KVIN = new (require('kvin').KVIN)(); function Worker() { if (!new.target) throw new TypeError('Worker constructor: \'new\' is required'); return new StandaloneWorker(options); } /** * There is a data encapsulation design bug in our stack which has accidentally made KVIN part of the * evaluator -> sa-worker communication stack. The net effect of this is that node programs using * the StandaloneWorker class must decode messages from the evaluator using the same serialization as * that evaluator used to encode them. This is correctly specified in the sandbox-definitions.js file * as kvin/kvin.js (May 2025). * * This work-around function decodes messages exactly how they were encoded, and returns the plain * object containing the message. Eventually, this function will go away, leaving behind something * like "return arguments[0]" as a backwards compat API entry point. */ Worker.decodeMessageEvent = function saWorker$$decodeMessageEvent(event) { const eventData = KVIN.unmarshal(event.data); return { data: eventData }; } /** * See comments for decodeWorkerMessageEvent. This same design flaw also requires an encoding function * that will also eventually become a NOP. */ Worker.encodeMessage = function saWorker$$encodeMessage(message) { return KVIN.marshal(message); } return Worker; } /* Attach setters for onmessage, onerror, etc on the Worker.prototype * which are implemented with this.addEventListener. */ const onHandlerTypes = ['message', 'error'] StandaloneWorker.prototype.onHandlers = {} for (let i = 0; i < onHandlerTypes.length; i++) { let onHandlerType = onHandlerTypes[i] Object.defineProperty(StandaloneWorker.prototype, 'on' + onHandlerType, { enumerable: true, configurable: false, set: function (cb) { /* maintain on{eventName} singleton pattern */ if (this.onHandlers.hasOwnProperty(onHandlerType)) { this.removeEventListener(onHandlerType, this.onHandlers[onHandlerType]) } this.addEventListener(onHandlerType, cb) this.onHandlers[onHandlerType] = cb }, get: function () { return this.onHandlers[onHandlerType] } }) }