UNPKG

bedrock

Version:

A core foundation for rich Web applications.

805 lines (724 loc) 24.2 kB
/*! * Copyright (c) 2012-2021 Digital Bazaar, Inc. All rights reserved. */ import * as brUtil from './util.js'; import cluster from 'cluster'; import {config} from './config.js'; import cycle from 'cycle'; import {deprecate} from 'util'; import {emitter as events} from './events.js'; import errio from 'errio'; import {fileURLToPath} from 'url'; import lodashGet from 'lodash.get'; import {container as loggers} from './loggers/index.js'; import {cpus} from 'os'; import path from 'path'; import program from 'commander'; import {version} from '../package.json'; const {BedrockError} = brUtil; const cc = brUtil.config.main.computer(); export {config, events, loggers, brUtil as util, version}; // calculate main program script (including worker case) let main = process.main || process.mainModule; if(main.filename.endsWith('/bedrock/lib/worker.cjs')) { // in this version, `children[0]` is `esm.js`, `children[1]` is `worker.js` let m = main.children[1].children[0]; // if main was rewritten because of `esm.js` as well, use the module it loads if(m.filename.endsWith('/esm.js')) { m = main.children[1].children[1]; } main = m; } export {main}; // set `__dirname` constant const __dirname = path.dirname(fileURLToPath(import.meta.url)); // primary process state let WORKERS_EXITED; const PRIMARY_STATE = { switchedUser: false, runOnce: {}, workersExited: new Promise(resolve => { WORKERS_EXITED = resolve; }) }; // exiting state let EXITING = false; // worker bedrock started state let BEDROCK_STARTED = false; // register error class errio.register(BedrockError); // config paths // configured here instead of config.js due to util dependency issues // FIXME: v2.0.0: remove when removing warnings below. // see: https://github.com/digitalbazaar/bedrock/issues/93 const _warningShown = { cache: false, log: false }; cc({ 'paths.cache': () => { // FIXME: v2.0.0: remove warning and default and throw exception . // see: https://github.com/digitalbazaar/bedrock/issues/93 //throw new BedrockError( // 'bedrock.config.paths.cache not set.', // 'ConfigError'); const cachePath = path.join(__dirname, '..', '.cache'); if(!_warningShown.cache) { loggers.get('app').error( `"bedrock.config.paths.cache" not set, using default: "${cachePath}"`); _warningShown.cache = true; } return cachePath; }, 'paths.log': () => { // FIXME: v2.0.0: remove warning and default and throw exception . // see: https://github.com/digitalbazaar/bedrock/issues/93 //throw new BedrockError( // 'bedrock.config.paths.log not set.', // 'ConfigError'); const logPath = path.join('/tmp/bedrock-dev'); if(!_warningShown.log) { // Using console since this config value used during logger setup. console.warn('WARNING: ' + `"bedrock.config.paths.log" not set, using default: "${logPath}"`); _warningShown.log = true; } return logPath; } }); // expose bedrock program const _program = program.version(version); export {_program as program}; /** * Starts the bedrock application. * * @param {object} [options={}] - The options to use: * [script] the script to execute when forking bedrock workers, by * default this will be process.argv[1]. * * @returns {Promise} Resolves when the application has started or an error has * occured. */ export async function start(options = {}) { const startTime = Date.now(); function collect(val, memo) { memo.push(val); return memo; } // add built-in CLI options program .option('--config <config>', 'Load a config file. (repeatable)', collect, []) .option('--log-level <level>', 'Console log level: ' + 'silly, verbose, debug, info, warning, error, critical.') .option('--log-timestamps <timestamps>', 'Override console log timestamps config. (boolean)', brUtil.boolify) .option('--log-colorize <colorize>', 'Override console log colorization config. (boolean)', brUtil.boolify) .option('--log-exclude <modules>', 'Do not log events from the specified comma separated modules.') .option('--log-only <modules>', 'Only log events from the specified comma separated modules.') .option('--log-transports <spec>', 'Transport spec. Use category=[-|+]transport[;...][, ...] ' + 'eg, access=+console;-access,app=-console') .option('--silent', 'Show no console output.') .option('--workers <num>', 'The number of workers to use (0: # of cpus).', parseInt); await events.emit('bedrock-cli.init'); _parseCommandLine(); await events.emit('bedrock-cli.parsed'); await _loadConfigs(); _configureLoggers(); _configureWorkers(); _configureProcess(); await events.emit('bedrock-loggers.init'); try { await loggers.init(); } catch(err) { // can't log, quit console.error('Failed to initialize logging system:', err); await _exit(1); } // run if(cluster.isMaster) { _runPrimary(startTime, options); // don't emit `bedrock.error` in primary process return; } try { await _runWorker(startTime); } catch(err) { await events.emit('bedrock.error', err); throw err; } } /** * Called from workers to set the worker and primary process user if it hasn't * already been set. */ let _switchedProcessUser = false; export function setProcessUser() { if(_switchedProcessUser) { return; } _switchedProcessUser = true; // switch group if(config.core.running.groupId && process.setgid) { process.setgid(config.core.running.groupId); } // switch user if(config.core.running.userId && process.setuid) { process.setuid(config.core.running.userId); } // send message to primary process.send({type: 'bedrock.switchProcessUser'}, _exitOnError); } /** * Called from a worker to execute the given function in only one worker. * * @param {string} id - A unique identifier for the function to execute. * @param {Function} fn - The async function to execute. * @param {object} [options={}] - The options to use: * [allowOnRestart] true to allow this function to execute again, * but only once, on worker restart; this option is useful for * behavior that persists but should only run in a single worker. * * @returns {Promise} Resolves when the operation completes. */ export async function runOnce(id, fn, options = {}) { const type = 'bedrock.runOnce'; // notify primary to schedule work (if not already scheduled/run) process.send({type, id, options}, _exitOnError); // wait for scheduling result let msg = await _waitForOneMessage({type, id}); // work completed in another worker, finish if(msg.done) { if(msg.error) { throw errio.fromObject(msg.error, {stack: true}); } return; } // run in this worker msg = {type, id, done: true}; let error; try { await fn(); } catch(e) { error = e; msg.error = cycle.decycle(errio.toObject(e, {stack: true})); } // notify other workers that work is complete process.send(msg, _exitOnError); if(error) { throw error; } } /** * **DEPRECATED**: runOnceAsync() is deprecated. Use runOnce() instead. */ export const runOnceAsync = deprecate( runOnce, 'runOnceAsync() is deprecated. Use runOnce() instead.'); /** * Called from a worker to exit gracefully and without an error code. Typically * used by subcommands. Use `process.exit(1)` (or other error code) to exit * with an error code. */ export function exit() { cluster.worker.kill(); } // export `default` for backwards compatibility export default { config, events, loggers, util: brUtil, version, program: _program, start, setProcessUser, runOnce, runOnceAsync, exit, main }; async function _waitForOneMessage({type, id}) { // get coordinated message from primary return new Promise(resolve => { // listen to run function once process.on('message', _listenOnce); function _listenOnce(msg) { // ignore other messages if(!(_isMessageType(msg, type) && msg.id === id)) { return; } process.removeListener('message', _listenOnce); resolve(msg); } }); } function _parseCommandLine() { program.parse(process.argv); if(config.cli.command === null) { // set default command config.cli.command = new program.Command('bedrock'); } } async function _loadConfigs() { for(const cfg of program.config) { await import(path.resolve(process.cwd(), cfg)); } } function _configureLoggers() { // set console log flags if('logLevel' in program) { config.loggers.console.level = program.logLevel; } if('logColorize' in program) { config.loggers.console.colorize = program.logColorize; } if('logTimestamps' in program) { config.loggers.console.timestamp = program.logTimestamps; } if('logExclude' in program) { config.loggers.console.bedrock.excludeModules = program.logExclude.split(','); } if('logOnly' in program) { config.loggers.console.bedrock.onlyModules = program.logOnly.split(','); } // adjust transports if('logTransports' in program) { const t = program.logTransports; const cats = t.split(','); cats.forEach(function(cat) { const catName = cat.split('=')[0]; let catTransports; if(catName in config.loggers.categories) { catTransports = config.loggers.categories[catName]; } else { catTransports = config.loggers.categories[catName] = []; } const transports = cat.split('=')[1].split(';'); transports.forEach(function(transport) { if(transport.indexOf('-') === 0) { const tName = transport.slice(1); const tIndex = catTransports.indexOf(tName); if(tIndex !== -1) { catTransports.splice(tIndex, 1); } } else if(transport.indexOf('+') === 0) { const tName = transport.slice(1); const tIndex = catTransports.indexOf(tName); if(tIndex === -1) { catTransports.push(tName); } } else { const tName = transport; const tIndex = catTransports.indexOf(tName); if(tIndex === -1) { catTransports.push(tName); } } }); }); } if(program.silent || program.logLevel === 'none') { config.loggers.console.silent = true; } } function _configureWorkers() { if('workers' in program) { config.core.workers = program.workers; } if(config.core.workers <= 0) { config.core.workers = cpus().length; } } function _configureProcess() { // set no limit on event listeners process.setMaxListeners(Infinity); if(cluster.isMaster) { cluster.setupMaster({ exec: path.join(__dirname, 'worker.cjs') }); // set group before initializing loggers if(config.core.starting.groupId && process.setgid) { try { process.setgid(config.core.starting.groupId); } catch(ex) { console.warn('Failed to set primary starting gid: ' + ex); } } // set user before initializing loggers if(config.core.starting.userId && process.setuid) { try { process.setuid(config.core.starting.userId); } catch(ex) { console.warn('Failed to set primary starting uid: ' + ex); } } } } function _setupUncaughtExceptionHandler(logger, logPrefix) { // log uncaught exception and exit, except in test mode if(config.cli.command.name() !== 'test') { process.on('uncaughtException', async function(error) { process.removeAllListeners('uncaughtException'); logger.critical(`${logPrefix} uncaught error`, {error}); await _exit(1); }); } } function _setupUnhandledRejectionHandler(logger, logPrefix) { // log uncaught exception and exit, except in test mode if(config.cli.command.name() !== 'test') { process.on('unhandledRejection', async function(error) { process.removeAllListeners('unhandledRejection'); logger.critical(`${logPrefix} unhandled promise rejection`, {error}); await _exit(1); }); } } function _setupSignalHandler(logger, logPrefix) { const SIGNALS = { /* The SIGHUP ("hang-up") signal is used to report that the user’s terminal is disconnected, perhaps because a network or telephone connection was broken. */ SIGHUP: 1, /* The SIGINT (“program interrupt”) signal is sent when the user types the INTR character (normally C-c). */ SIGINT: 2, /* The SIGTERM signal is a generic signal used to cause program termination. Unlike SIGKILL, this signal can be blocked, handled, and ignored. It is the normal way to politely ask a program to terminate. */ SIGTERM: 15 }; Object.keys(SIGNALS).forEach(signal => { process.on(signal, async function exitProcess() { logger.info(`${logPrefix} received signal.`, {signal}); await _exit(); }); }); } function _runPrimary(startTime, options) { // FIXME: use child logger // see: https://github.com/digitalbazaar/bedrock/issues/90 const logger = loggers.get('app'); const logPrefix = '[bedrock/primary]'; // setup cluster if running with istanbul coverage if(process.env.running_under_istanbul) { // re-call cover with no reporting and using pid named output cluster.setupMaster({ exec: './node_modules/.bin/istanbul', args: [ 'cover', '--report', 'none', '--print', 'none', '--include-pid', process.argv[1], '--'].concat(process.argv.slice(2)) }); } // set 'ps' title const args = process.argv.slice(2).join(' '); // TODO: Remove all use of config.core.master in next major release (5.x) // see: https://github.com/digitalbazaar/bedrock/issues/89 const processTitle = config.core.master ? config.core.master.title : config.core.primary.title; process.title = processTitle + (args ? (' ' + args) : ''); _setupUncaughtExceptionHandler(logger, logPrefix); _setupUnhandledRejectionHandler(logger, logPrefix); _setupSignalHandler(logger, logPrefix); logger.info( `${logPrefix} starting process "${processTitle}"`, {pid: process.pid}); // get starting script const script = options.script || process.argv[1]; // if nothing else is scheduled on the event loop, attempt an orderly exit process.once('beforeExit', async function() { await _exit(); }); // handle worker exit cluster.on('exit', async function(worker, code, signal) { // if the worker called kill() or disconnect(), it was intentional, so exit // the process const shouldExit = worker.exitedAfterDisconnect; if(shouldExit) { logger.info( `${logPrefix} worker "${worker.process.pid}" exited on purpose ` + `with code "${code}" and signal "${signal}"; exiting primary process.`); } else { // accidental worker exit (crash) logger.critical( `${logPrefix} worker "${worker.process.pid}" exited with code ` + `"${code}" and signal "${signal}".`); } // if configured, fork a replacement worker if(!shouldExit && !EXITING && config.core.worker.restart) { // clear any runOnce records w/allowOnRestart option set for(const id in PRIMARY_STATE.runOnce) { if(PRIMARY_STATE.runOnce[id].worker === worker.id && PRIMARY_STATE.runOnce[id].options.allowOnRestart) { delete PRIMARY_STATE.runOnce[id]; } } _startWorker(script); } else { // if all workers have exited, resolve `WORKERS_EXITED` let allDead = true; for(const id in cluster.workers) { if(!cluster.workers[id].isDead()) { allDead = false; break; } } if(allDead) { WORKERS_EXITED(); } // exit primary (will wait on `WORKERS_EXITED` if not all workers have // quit yet to allow an orderly exit) await _exit(code); } }); // fork each app process const workers = config.core.workers; for(let i = 0; i < workers; ++i) { _startWorker(script); } logger.info(`${logPrefix} started`, {timeMs: Date.now() - startTime}); } async function _runWorker(startTime) { // FIXME: use child logger // https://github.com/digitalbazaar/bedrock/issues/90 const logger = loggers.get('app'); const logPrefix = '[bedrock/worker]'; // set 'ps' title const args = process.argv.slice(2).join(' '); process.title = config.core.worker.title + (args ? (' ' + args) : ''); _setupUncaughtExceptionHandler(logger, logPrefix); _setupUnhandledRejectionHandler(logger, logPrefix); _setupSignalHandler(logger, logPrefix); logger.info(`${logPrefix} starting process "${config.core.worker.title}"`); const cliReady = await events.emit('bedrock-cli.ready'); // skip default behavior if cancelled (do not emit bedrock core events) // used for CLI commands if(cliReady === false) { return; } BEDROCK_STARTED = true; // snapshot the values of the fields that must be changed // during the `bedrock.configure` event let configOverrideSnapshot; if(config.ensureConfigOverride.enable) { configOverrideSnapshot = _snapshotOverrideFields({ config, fields: config.ensureConfigOverride.fields, }); } await events.emit('bedrock.configure'); // ensure that the values captured in the snapshot have been changed if(configOverrideSnapshot) { // throws on failure which will prevent application startup _ensureConfigOverride({config, configOverrideSnapshot}); } await events.emit('bedrock.admin.init'); // set process user setProcessUser(); await events.emit('bedrock.init'); await events.emit('bedrock.start'); await events.emit('bedrock.ready'); await events.emit('bedrock.started'); logger.info(`${logPrefix} started`, {timeMs: Date.now() - startTime}); } function _startWorker(script) { const worker = cluster.fork(); loggers.attach(worker); // listen to start requests from workers worker.once('online', function initWorker() { // notify worker to initialize and provide the cwd and script to run worker.send({ type: 'bedrock.worker.init', cwd: process.cwd(), script }, err => { if(err) { // failure to send init message should hard terminate the worker worker.process.kill(); } }); }); // if app process user hasn't been switched yet, wait for a message // from a worker indicating to do so if(!PRIMARY_STATE.switchedUser) { worker.on('message', switchProcessUserListener); } function switchProcessUserListener(msg) { if(!_isMessageType(msg, 'bedrock.switchProcessUser')) { return; } worker.removeListener('message', switchProcessUserListener); if(!PRIMARY_STATE.switchedUser) { PRIMARY_STATE.switchedUser = true; // switch group if(config.core.running.groupId && process.setgid) { process.setgid(config.core.running.groupId); } // switch user if(config.core.running.userId && process.setuid) { process.setuid(config.core.running.userId); } } } // listen to schedule run once functions; when this message is received, it // means a worker has asked the primary to schedule a function to run just // once on the first worker that requests it to be run, causing the others // to wait worker.on('message', function(msg) { if(!_isMessageType(msg, 'bedrock.runOnce')) { return; } const {type} = msg; if(msg.done) { PRIMARY_STATE.runOnce[msg.id].done = true; PRIMARY_STATE.runOnce[msg.id].error = msg.error || null; // notify workers to call callback const notify = PRIMARY_STATE.runOnce[msg.id].notify; while(notify.length > 0) { const id = notify.shift(); if(id in cluster.workers) { cluster.workers[id].send({ type, id: msg.id, done: true, error: msg.error }, _exitOnError); } } return; } if(msg.id in PRIMARY_STATE.runOnce) { if(PRIMARY_STATE.runOnce[msg.id].done) { // already ran, notify worker immediately worker.send({ type, id: msg.id, done: true, error: PRIMARY_STATE.runOnce[msg.id].error }, _exitOnError); } else { // still running, add worker ID to notify queue for later notification PRIMARY_STATE.runOnce[msg.id].notify.push(worker.id); } return; } // run in this worker PRIMARY_STATE.runOnce[msg.id] = { worker: worker.id, notify: [], options: msg.options, done: false, error: null }; worker.send({type, id: msg.id, done: false}, _exitOnError); }); } function _isMessageType(msg, type) { return (typeof msg === 'object' && msg.type === type); } function _ensureConfigOverride({config, configOverrideSnapshot}) { const logger = loggers.get('app').child('bedrock/worker'); logger.debug('Verifying configuration overrides.'); for(const [key, value] of configOverrideSnapshot) { if(lodashGet(config, key) === value) { const error = new Error( `The config field "${key}" must be changed during the ` + '"bedrock.configuration" event.'); logger.error(error); throw error; } } logger.debug('Configuration overrides have been verified.', { fields: [...configOverrideSnapshot.keys()]}); } function _snapshotOverrideFields({config, fields}) { const snapshot = new Map(); for(const field of fields) { snapshot.set(field, lodashGet(config, field)); } return snapshot; } async function _preparePrimaryExit() { let allDead = true; for(const id in cluster.workers) { const worker = cluster.workers[id]; if(!worker.isDead()) { // if any error occurs when telling the worker to terminate, hard // terminate it instead and ignore any errors that occur as a result // of that worker.once('error', () => { worker.once('error', () => {}); worker.process.kill(); }); cluster.workers[id].kill(); allDead = false; } } if(allDead) { // all workers are dead WORKERS_EXITED(); } } async function _exit(code) { /* Notes on exiting: When the primary does an orderly exit, it must notify all workers to exit, wait for them to do so and then finally exit itself. When a worker does an orderly exit, it emits and awaits bedrock events based on the current state of the application and then exits. Upon exiting, a message will be sent to the primary with the exit status code. Regardless of whether a worker or the primary exit first, the primary will be notified of a worker exiting and it will decide whether or not to kill all workers and exit itself. If any worker or the primary are asked to exit while an orderly exit is in progress, this request will be ignored and the process will exit once the orderly exit completes. */ // orderly exit already in progress if(EXITING) { return; } EXITING = true; try { if(cluster.isMaster) { await _preparePrimaryExit(); await PRIMARY_STATE.workersExited; } else { // these events are only emitted in workers if(BEDROCK_STARTED) { await events.emit('bedrock.stop'); } await events.emit('bedrock-cli.exit'); await events.emit('bedrock.exit'); } } finally { await _logExit(code); process.exit(code); } } async function _exitOnError(err) { if(err) { // a failure to send an IPC message is fatal, exit await _exit(1); } } async function _logExit(code = 0) { if(!cluster.isMaster) { return; } // log final message and wait for logger to flush const logger = loggers.get('app'); const logPrefix = '[bedrock/primary]'; try { const p = new Promise(resolve => { logger.info( `${logPrefix} primary process exiting with code "${code}".`, {code}); logger.once('finish', () => resolve()); logger.once('error', () => resolve()); logger.end(); }); await p; } finally {} }