System/System.js

import childProcess from 'child_process';
import passwd from 'etc-passwd';
import util from 'util';
import Vachan from '../Vachan';
import gracefulServerShutdown from './gracefulServerShutdown';

/**
 * System and process utilities
 * @namespace System
 */

const setImmediatePromise = util.promisify(setImmediate);
const setTimeoutPromise = util.promisify(setTimeout);

let oldUmask = -1;
let hrtimeDelta;

// assign properties to global to avoid issues in case of multiple sm-utils in node_modules
// NOTE: don't change globalDataKey or globalData properties
// it should be consistent across multiple sm-utils versions
const globalDataKey = '_SmUtils_System';
if (!global[globalDataKey]) global[globalDataKey] = {};

/**
 * @type {object}
 * @memberof System
 * @private
 */
const globalData = global[globalDataKey];

globalData.processExit = globalData.processExit || process.exit.bind(process);
globalData.onExitHandlers = globalData.onExitHandlers || [];
globalData.exitCalled = globalData.exitCalled || false;

/**
 * @typedef {object} processObject
 * @property {ChildProcess} childProcess
 * @property {Buffer} stdout
 * @property {Buffer} stderr
 */

/**
  * @ignore
  * @param {string} method which method of childProcess to call, 'exec', 'spawn'
  * @param {Array<any>} args
  * @return {Promise<processObject>}
  */
async function execWrapper(method, args) {
	return new Promise((resolve, reject) => {
		let cp;

		// add callback to arguments
		args.push((err, stdout, stderr) => {
			if (err) {
				const commandStr = args[0] + (Array.isArray(args[1]) ? (' ' + args[1].join(' ')) : '');
				err.message += ' `' + commandStr + '` (exited with error code ' + err.code + ')';
				err.stdout = stdout;
				err.stderr = stderr;
				reject(err);
			}
			else {
				resolve({
					childProcess: cp,
					stdout,
					stderr,
				});
			}
		});

		cp = childProcess[method](...args);
	});
}

/**
 * Execute the given command in a shell.
 * @memberof System
 * @param {string} command
 * @param {object} options options object
 * options: {timeout (in ms), cwd, uid, gid, env (object), shell (eg. /bin/sh), encoding}
 * @return {Promise<processObject>}
 */
async function exec(...args) {
	return execWrapper('exec', args);
}

/**
 * Similar to exec but instead executes a given file
 * @memberof System
 * @param {Array<any>} args
 * @return {Promise<processObject>}
 */
async function execFile(...args) {
	return execWrapper('execFile', args);
}

/**
 * execute a command and return its output
 *
 * @memberof System
 * @param {Array<any>} args
 * @return {string} output of the command's execution
 */
async function execOut(...args) {
	return (await exec.apply(this, args)).stdout.toString();
}

/**
 * execute a file and return its output
 *
 * @memberof System
 * @param {Array<any>} args
 * @return {string} output of the file's execution
 */
async function execFileOut(...args) {
	return (await execFile.apply(this, args)).stdout.toString();
}


/**
 * turn off umask for the current process
 * @memberof System
 * @return {number} the old umask
 */
function noUmask() {
	oldUmask = process.umask(0);
	return oldUmask;
}

/**
 * restores (turns on) the previous umask
 * @memberof System
 * @return {number} new umask
 */
function yesUmask() {
	let newUmask = -1;
	if (oldUmask >= 0) {
		newUmask = process.umask(oldUmask);
	}
	return newUmask;
}

/**
 * get the uid of the user running current process
 *
 * @memberof System
 * @return {number}  uid
 */
function getuid() {
	return process.getuid();
}

/**
 * get user info from username or uid
 * currently gets user info from /etc/passwd
 *
 * @memberof System
 * @param  {string|number} user username or uid
 * @return {object}             the user's information
 */
async function getUserInfo(user) {
	user = (user === undefined) ? process.getuid() : user;
	let opts;

	if (Number.isInteger(user)) {
		opts = {uid: user};
	}
	else {
		opts = {username: user};
	}

	return new Promise((resolve, reject) => {
		passwd.getUser(opts, (err, userObj) => {
			if (err) reject(err);
			else resolve(userObj);
		});
	});
}

/**
 * get all users in the system
 * currently gets user info from /etc/passwd
 *
 * @memberof System
 * @return {object}  object containing info for all users, as username:info pairs
 */
async function getAllUsers() {
	return new Promise((resolve, reject) => {
		passwd.getUsers((err, users) => {
			if (err) {
				reject(err);
				return;
			}

			const usersObj = {};
			users.forEach((user) => {
				usersObj[user.username] = user;
			});

			resolve(usersObj);
		});
	});
}

/**
 * get current time in seconds
 *
 * @memberof System
 * @return {number}  current time in seconds
 */
function time() {
	return Math.floor(Date.now() / 1000);
}

/**
 * get current time in milliseconds (as double)
 *
 * @memberof System
 * @return {number}  current time in milliseconds
 */
function millitime() {
	return (Date.now() / 1000);
}

/**
 * get current time in nanoseconds (as double)
 *
 * @memberof System
 * @return {number}  current time in nanoseconds
 */
function nanotime() {
	const hrtime = process.hrtime();
	const hrtimeFloat = Number(hrtime[0] + '.' + hrtime[1]);
	hrtimeDelta = hrtimeDelta || (millitime() - hrtimeFloat);
	return hrtimeDelta + hrtimeFloat;
}

/**
 * get current time in microseconds (as double)
 *
 * @memberof System
 * @return {number}  current time in microseconds
 */
function microtime() {
	return nanotime();
}

/**
 * exit and kill the process gracefully (after completing all onExit handlers)
 * code can be an exit code or a message (string)
 * if a message is given then it will be logged to console before exiting
 *
 * @memberof System
 * @param {number|string} code exit code or the message to be logged
 * @return {void}
 */
function exit(code) {
	if (code === undefined || Number.isInteger(code)) {
		// eslint-disable-next-line no-use-before-define
		return _exitHandler({exitCode: code});
	}

	// eslint-disable-next-line no-use-before-define
	return _exitHandler({exitCode: code});
}

/**
 * force exit the process
 * no onExit handler will run when force exiting a process
 * same as original process.exit (which we override)
 *
 * @memberof System
 * @param {number|string} code exit code or the message to be logged
 * @return {void}
 */
function forceExit(code) {
	if (code === undefined || Number.isInteger(code)) {
		return globalData.processExit(code);
	}

	console.log(code);
	return globalData.processExit(0);
}

function _exitHandler(options = {}) {
	if (globalData.exitCalled) {
		return;
	}
	globalData.exitCalled = true;

	const promises = [];
	const exitHandlers = globalData.onExitHandlers;

	exitHandlers.forEach((handler) => {
		let result;
		try {
			result = handler.callback();
		}
		catch (e) {
			console.error(e);
		}

		if (result && result.then) {
			promises.push(Vachan.timeout(result, handler.options.timeout));
		}
	});

	let promisesPending = promises.length;
	if (!promisesPending) {
		forceExit(options.exitCode || 0);
	}

	promises.forEach((promise) => {
		promise.then(() => {
			promisesPending--;
			if (!promisesPending) {
				forceExit(options.exitCode || 0);
			}
		}, (e) => {
			promisesPending--;
			console.error(e);
			if (!promisesPending) {
				forceExit(options.exitCode || 0);
			}
		});
	});
}

/**
 * @typedef {object} timeoutOpts
 * @property {number} [timeout=10000] Milliseconds before timing out (default 10000)
 */

/**
 * Add an exit handler that runs when process receives an exit signal
 * callback can be an async function, process will exit when all handlers have completed
 * @memberof System
 * @param {function} callback function to call on exit
 * @param {number|timeoutOpts} [options={}] can be {timeout} or a number
 * @return {Promise<void>}
 */
function onExit(callback, options = {}) {
	if (typeof options === 'number') {
		options = {timeout: options};
	}
	else if (!options || typeof options !== 'object') {
		throw new TypeError('Incorrect options format, must be an object or a number');
	}

	// set default timeout of 10s
	options.timeout = options.timeout || 10000;

	const exitHandlers = globalData.onExitHandlers;
	exitHandlers.push({
		callback,
		options,
	});

	// only set handlers first time
	if (exitHandlers.length === 1) {
		process.once('SIGINT', _exitHandler);
		process.once('SIGTERM', _exitHandler);

		// override process.exit to not immediately exit
		// but to exit after waiting for exit handlers to complete
		// this is because some other library might call process.exit
		// which we have no control over (like graceful-http-shutdown)
		process.exit = exit;
	}
}

/**
 * install graceful server exit handler on a tcp server
 * this will make sure that the process exits only
 * after all the current requests are served
 * @memberof System
 * @param {*} server
 * @param {number|timeoutOpts} [options={}]
 */
function gracefulServerExit(server, options = {}) {
	onExit(gracefulServerShutdown(server), options);
}

/**
 * set the max memory that the current node process can use
 * @memberof System
 * @param {number} memory max memory in megabytes
 */
function setMaxMemory(memory) {
	if (typeof memory !== 'number') {
		throw new TypeError('memory must be a number');
	}
	if (memory < 128) {
		throw new TypeError('memory is too low, must be >= 128');
	}

	// eslint-disable-next-line global-require
	const v8 = require('v8');
	const memoryInt = Math.floor(memory);
	v8.setFlagsFromString(`--max_old_space_size=${memoryInt}`);
}

/**
 * get the current git branch name (in cwd)
 * @memberof System
 * @returns {string} the current branch name, empty string if not found
 */
async function getGitBranch() {
	try {
		const branch = (await execOut('git symbolic-ref --short HEAD')).trim();
		return branch || '';
	}
	catch (e) {
		return '';
	}
}

module.exports = {
	_globalData: globalData,
	exec,
	execFile,
	execOut,
	execFileOut,
	noUmask,
	yesUmask,
	getuid,
	getUserInfo,
	getAllUsers,
	time,
	millitime,
	microtime,
	nanotime,
	/**
	 * Sleep for a specified time (in milliseconds)
	 *   Example: await System.sleep(2000);
	 * @memberof System
	 * @return {Promise<void>}
	 */
	sleep: setTimeoutPromise,
	/**
	 * wait till the next event loop cycle
	 * this function is useful if we are running a long blocking task
	 * and need to make sure that other callbacks can complete.
	 * @memberof System
	 * @return {Promise<void>}
	 */
	tick: setImmediatePromise,
	exit,
	forceExit,
	onExit,
	gracefulServerExit,
	setMaxMemory,
	getGitBranch,
};