lib/runs/fork.js

const v8 = require('node:v8');
const fork = require('node:child_process').fork;
const path = require('node:path');
const runAuto = require('./auto.js');
const NanoTime = require('../NanoTime.js');
const createReport = require('../createReport.js');

/**
 * @module pete/lib/runs/fork
 */
module.exports = createRunFork;

/**
 * @private
 */
const NANO2MILLI = BigInt(1000000); // eslint-disable-line no-magic-numbers

/**
 * This run forks target function to separate process and then simply passes any reports from it.
 * Single, and only run, is finished after child process exits.
 *
 * @alias module:pete/lib/runs/fork
 * @param {Function|string} fn        to run
 * @param {object}          options
 * @param {object}          state     to pass to the reporting function
 * @return {module:pete/lib/runner~run}
 */
function createRunFork (fn, options, state) {
	state.runType = module.filename;

	if (!options.id) {
		state.error = new Error('When using `fork`, `options.id` is required');
		return null;
	}

	// Prevent multi-level forks
	if (process.send && (!process.env.PETE_FORKED_FROM || process.env.PETE_FORKED_FROM === options.id)) {
		return runAuto(fn, options, state);
	}

	return runFork.bind(null, fn, options, state);
}

/**
 * @private
 * @param {Function}                         fn        to run
 * @param {object}                           options
 * @param {object}                           state     to pass to the reporting function
 * @param {module:pete/lib/getReporters~report} report
 */
function runFork (fn, options, state, report) {
	/*
	 * Bun does not include column number for the first of error traces, when test is called from the beginning of a line.
	 * So we get just the line number :(.
	 * See {module:pete/lib/createId} for more info.
	 */
	var filepath = typeof fn === 'string' ? fn : options.id.replace(/:\d+(?::\d+)?$/, '');
	/* eslint-disable array-element-newline */
	var args = [
		// '--name', filepath,
		'--report', 'process',
		'--exitOnError', options.exitOnError ? 'true' : 'false',
		'--limitCPUTime', options.limitCPUTime === Infinity ? 'Infinity' : Number(options.limitCPUTime / NANO2MILLI),
		'--limitRealTime', options.limitRealTime === Infinity ? 'Infinity' : Number(options.limitRealTime / NANO2MILLI),
		'--limitSamples', options.limitSamples,
		'--warmupSamples', options.warmupSamples
	];
	/* eslint-enable array-element-newline */

	if (typeof fn === 'function') {
		args.push('--only', options.id);
	}

	state.runType = 'fork';
	state.mode = 'regular';
	state.started = process.hrtime.bigint();
	state.current = state.started;

	if (!options.name) {
		options.name = filepath;
	}

	// Ignore `options.args` - they will be set up by original test code anyway
	var env = Object.assign({}, process.env, {PETE_FORKED_FROM: options.id});
	var s = fork(path.resolve(process.cwd(), filepath), args, {env});

	s.on('message', onMessage);
	s.once('error', err => {
		state.error = err;
		report(err);
		if (options.exitOnError) {
			s.off('message', onMessage);
			s.kill();
			s = null;
		}
	});
	s.once('close', () => { // CONSIDER: Use `code` passed from Node.js to report additional error?
		state.finished = process.hrtime.bigint();

		if (state.samplesCount < 1) {
			console.warn(`"${filepath}" finished without any reports.`);
			if (!state.error) {
				// Assuming it was a standalone "raw" test file.
				state.totalCPUTime = state.finished - state.started;
				state.totalRealTime = state.finished - state.started;
				state.samplesCount = 1;
				state.hdr.record(Number(state.finished - state.current));
				report(null, createReport(options, state));
			}
		}

		// Finish
		report();
	});

	/**
	 * @private
	 * @param {object} data
	 */
	function onMessage (data) {
		if (!data || (!data.error && !data.report)) {
			return;
		}

		state.samplesCount += 1;

		if (data.report) {
			data.report = v8.deserialize(Buffer.from(data.report, 'base64'));
			if (data.types && data.types.NanoTime) {
				data.types.NanoTime.forEach(key => {
					data.report[key] = NanoTime.from(data.report[key]);
				});
			}
		}

		var error = null;
		if (data.error) {
			// Reconstruct error
			error = new Error(data.error.split('\n')[0]);
			error.stack = data.error;

			if (!state.error) {
				state.error = error;
			}

			if (options.exitOnError && (!data.report || data.report.exitOnError)) {
				s.off('message', onMessage);
				s.kill();
				s = null;
			}
		}

		if (data.report) {
			report(error, data.report);
		}
	}
}