glov-build
Version:
GLOV.js Build System
1,538 lines (1,442 loc) • 90.6 kB
JavaScript
const assert = require('assert');
const child_process = require('child_process');
const chalk = require('chalk');
const chokidar = require('chokidar');
const crc32 = require('./crc32.js');
const { dumpDepTree } = require('./debug.js');
const { EventEmitter } = require('events');
const fast_glob = require('fast-glob');
const { filesCreate, isBuildFile, DELETED_TIMESTAMP } = require('./files.js');
const { filesForkedCreate } = require('./files_forked.js');
const { asyncEach, asyncEachLimit, asyncEachSeries, asyncLimiter, asyncSeries } = require('glov-async');
const { max } = Math;
const micromatch = require('micromatch');
const minimist = require('minimist');
const path = require('path');
const readdirRecursive = require('recursive-readdir');
const { createTaskState } = require('./task_state.js');
const timestamp = require('time-stamp');
const {
callbackify,
cmpTaskPhase,
deleteFileWithRmdir,
empty,
forwardSlashes,
merge,
ridx,
unpromisify,
} = require('./util.js');
const util = require('util');
const xxhashmod = require('xxhash-wasm');
const fg = callbackify(fast_glob);
const fork = process.argv.includes('--fork');
// general path mapping (all via files.bucket_dirs)
// foo/bar.baz -> config.source
// taskname:foo/bar.baz -> task.intdir
// targetname:foo/bar.baz -> config.targets[targetname]
const STATUS_PENDING = 'pending';
const STATUS_PREPARING_INPUTS = 'inputs';
const STATUS_PREPARING_DEPS = 'deps';
const STATUS_RUNNING = 'running';
const STATUS_DONE = 'done';
const STATUS_ERROR = 'error';
const STATUS_ABORTED = 'aborted';
const STATUS_IS_STOPPED = {};
STATUS_IS_STOPPED[STATUS_PENDING] = true;
STATUS_IS_STOPPED[STATUS_DONE] = true;
STATUS_IS_STOPPED[STATUS_ERROR] = true;
STATUS_IS_STOPPED[STATUS_ABORTED] = true;
const TIME_BEFORE_IDLE_INIT = 100;
const ASYNC_LIMIT = 4;
const ALL = 'all'; // func ran on all inputs at once
const SINGLE = 'single'; // func ran on each input
const ASYNC_DEFAULT = 'default'; // obeys config.parallel.tasks (for most tasks)
const ASYNC_INPROC = 'inproc'; // overrides config.parallel.tasks (for network IO-bound tasks)
const ASYNC_FORK = 'fork'; // also runs in another process (for CPU-bound tasks, high overhead)
const LOG_SILLY = 0;
const LOG_DEBUG = 1;
const LOG_INFO = 2;
const LOG_NOTICE = 3;
const LOG_WARN = 4;
const LOG_ERROR = 5;
// const LOG_SILENT = 6;
let log_level = LOG_DEBUG;
let log_timestamp = 'HH:mm:ss';
const LOG_LEVEL_TO_PARAM = [
'debug',
'debug',
'info',
'info',
'warn',
'error',
];
const LOG_LEVEL_TO_COLOR = [
chalk.gray,
chalk.gray,
chalk.white,
chalk.whiteBright,
chalk.yellowBright,
chalk.redBright,
];
const STATE_COLORED = {};
STATE_COLORED[STATUS_PENDING] = chalk.gray(STATUS_PENDING);
STATE_COLORED[STATUS_PREPARING_INPUTS] = chalk.cyan(STATUS_PREPARING_INPUTS);
STATE_COLORED[STATUS_PREPARING_DEPS] = chalk.cyan(STATUS_PREPARING_DEPS);
STATE_COLORED[STATUS_RUNNING] = chalk.blueBright(STATUS_RUNNING);
STATE_COLORED[STATUS_DONE] = chalk.greenBright(STATUS_DONE);
STATE_COLORED[STATUS_ERROR] = chalk.red(STATUS_ERROR);
STATE_COLORED[STATUS_ABORTED] = chalk.gray(STATUS_ABORTED);
const reload = chalk.blue;
const taskname = chalk.cyan;
let worker_id;
function log(level, msg, ...args) {
if (level < log_level) {
return;
}
if (fork) {
if (log_level <= LOG_DEBUG) {
msg = `[worker:${worker_id || process.pid}] ${msg}`;
}
process.send({
cmd: 'log',
level, msg, args,
});
return;
}
let fn = LOG_LEVEL_TO_PARAM[level];
let color = LOG_LEVEL_TO_COLOR[level];
let header = log_timestamp ? `[${chalk.gray(timestamp(log_timestamp))}] ` : '';
console[fn](`${header}${color(msg)}`, ...args);
}
const lsilly = log.bind(null, LOG_SILLY);
const ldebug = log.bind(null, LOG_DEBUG);
const linfo = log.bind(null, LOG_INFO);
const lnotice = log.bind(null, LOG_NOTICE);
const lwarn = log.bind(null, LOG_WARN);
const lerror = log.bind(null, LOG_ERROR);
function plural(number, label) {
return `${number} ${label}${number === 1 ? '' : 's'}`;
}
function GlovBuild() {
this.reset();
this.generation = 0;
this.exit_handler = () => {
let any_active = false;
for (let key in this.tasks) {
let task = this.tasks[key];
if (task.active &&
task.status !== STATUS_DONE && task.status !== STATUS_ERROR && task.status !== STATUS_ABORTED
) {
if (!this.watcher) {
lnotice(`Warning: Task "${taskname(task.name)}" still active (status=${task.status}) upon process exit`);
}
any_active = true;
}
}
if (any_active) {
if (!this.watcher) {
lnotice('A job or init callback probably failed to signal completion');
}
if (!process.exitCode) {
process.exitCode = 1;
}
}
};
this.forked_response_cb = {};
this.forked_response_last_id = 0;
}
util.inherits(GlovBuild, EventEmitter);
// Constants
GlovBuild.prototype.ALL = ALL;
GlovBuild.prototype.SINGLE = SINGLE;
// Logging convenience functions
GlovBuild.prototype.silly = lsilly;
GlovBuild.prototype.debug = ldebug;
GlovBuild.prototype.info = linfo;
GlovBuild.prototype.notice = lnotice;
GlovBuild.prototype.warn = lwarn;
GlovBuild.prototype.error = lerror;
GlovBuild.prototype.LOG_SILLY = LOG_SILLY;
GlovBuild.prototype.LOG_DEBUG = LOG_DEBUG;
GlovBuild.prototype.LOG_INFO = LOG_INFO;
GlovBuild.prototype.LOG_NOTICE = LOG_NOTICE;
GlovBuild.prototype.LOG_WARN = LOG_WARN;
GlovBuild.prototype.LOG_ERROR = LOG_ERROR;
GlovBuild.prototype.ASYNC_DEFAULT = ASYNC_DEFAULT;
GlovBuild.prototype.ASYNC_INPROC = ASYNC_INPROC;
GlovBuild.prototype.ASYNC_FORK = ASYNC_FORK;
GlovBuild.prototype.reset = function () {
// Default configuration options
this.config = {
root: '.',
statedir: './.gbstate',
targets: {},
watch: false,
parallel: {
tasks: 1,
tasks_async: 8,
jobs: 4,
jobs_async: 8,
},
allow_fork: true,
};
this.tasks = {};
this.tasks_finalized = false;
this.running = false;
this.watcher = null;
this.start_time = Date.now();
this.last_finish_time = Date.now();
this.was_all_done = false;
this.aborting = false;
this.abort_start_time = null;
this.fs_all_events = null;
this.fs_invalidated_tasks = null; // all invalidated tasks
this.fs_invalidated_tasks_root = null; // those directly invalidated by a file change
this.delays = {
inputs_pre: 0,
inputs_post: 0, // same as deps_pre would be
deps: 0,
// task: 0,
// job: 0,
};
this.resetStats();
//this.resetFiles();
};
GlovBuild.prototype.resetStats = function () {
this.stats = {
files_updated: 0,
warnings: 0,
errors: 0,
jobs: 0,
phase_inputs: 0,
phase_deps: 0,
phase_run: 0,
crc_calcs: 0,
};
this.stats_upon_last_abort = null;
if (this.files) {
this.files.resetStats();
}
};
const invalid_file_char = /[/\\'"`$%:]/;
function hashReplacer(key, value) {
if (typeof value === 'object') {
return value;
}
return String(value); // especially Functions
}
function addDep(task, bucket) {
if (bucket !== 'source' && task.deps.indexOf(bucket) === -1) {
task.deps.push(bucket);
}
}
let last_task_uid=0;
function BuildTask(gb, opts) {
this.gb = gb;
// Run-time state
this.phase = 0; // for the UI, where it fits horizontally; how deep is the dep tree to us
this.uid = ++last_task_uid;
this.dependors = [];
this.dependors_active = null; // filtered by active
this.task_state = null; // task/job state tracking
this.intdir = null; // intermediate dir, only actually written to if no target specified; taskdir/out
this.outdir = null; // location for output files, may be intdir or may be a (shared) target
this.user_data = null;
this.did_gather_globs = false;
this.did_init = false; // Has run init for this execution pass
this.ever_did_init = false; // Has ever run init for the history of this process
this.post_init = null;
this.last_run_time = Date.now();
if (!fork) {
this.child = null;
this.waiting_on_fork = 0;
this.post_waiting_err = null;
this.post_waiting = null;
}
// Validate and parse task options
assert(opts);
this.name = opts.name;
this.deps = (opts.deps || []).slice(0);
this.type = opts.type;
this.input = opts.input;
this.func = opts.func;
this.init = opts.init;
this.finish = opts.finish;
this.target = opts.target;
this.read = opts.read !== false;
this.async = opts.async === ASYNC_INPROC ? ASYNC_INPROC : opts.async === ASYNC_FORK ? ASYNC_FORK : ASYNC_DEFAULT;
this.async_task = opts.async_task || this.name;
this.version = opts.version || [];
if (Array.isArray(this.version)) {
// If no version specified, or, version is an array of references,
// add references to all of the known parameters that would require
// reprocessing upon change.
this.version.push(opts.func, opts.init, opts.finish, opts.target);
if (this.type === gb.ALL) {
// Hash the input list, so an ALL task re-runs if an input is no longer specified (but still exists)
this.version.push(opts.input);
}
this.version = `CRC#${crc32(JSON.stringify(this.version, hashReplacer)).toString(16)}`;
}
assert(this.name, 'Task missing required parameter "name"');
assert(!this.name.match(invalid_file_char),
`Task "${taskname(this.name)}": name must be a valid, safe file name (none of /\\'"\`$%:)`);
assert(Array.isArray(this.deps),
`Task "${taskname(this.name)}": "deps" must be an Array, found ${typeof this.deps}`);
if (this.input) {
if (typeof this.input === 'string') {
this.input = [this.input];
}
assert(Array.isArray(this.input),
`Task "${taskname(this.name)}": "input" must be null, a string, or an Array, found ${typeof this.input}`);
assert(this.input.length,
`Task "${taskname(this.name)}": "input" array must not be empty (omit if task has no inputs)`);
this.input = this.input.map(forwardSlashes);
for (let ii = 0; ii < this.input.length; ++ii) {
let input = this.input[ii];
assert(typeof input === 'string',
`Task "${taskname(this.name)}": entries in "input" must be strings, found ${typeof input}`);
let split = input.split(':');
assert(split.length <= 2,
`Task "${taskname(this.name)}": entries in "input" must be of the form ` +
`"glob" or "task:glob", found "${input}"`);
if (split.length === 1) {
// depending on 'source' bucket
} else {
let bucket = split[0];
// let glob = split[1];
addDep(this, bucket);
}
}
assert(this.type === ALL || this.type === SINGLE,
`Task "${taskname(this.name)}": "type" must be gb.SINGLE, gb.ALL, found ${this.type}`);
assert(typeof this.func === 'function',
`Task "${taskname(this.name)}": "func" must be of type function, found ${typeof this.func}`);
if (this.target) {
assert(typeof this.target === 'string',
`Task "${taskname(this.name)}": "target" must be of type string, found ${typeof this.target}`);
}
} else {
// no input, must at least have deps!
assert(this.deps && this.deps.length,
`Task "${taskname(this.name)}": Task must specify at least "deps" or "input"`);
assert(!this.func,
`Task "${taskname(this.name)}": A task with no inputs must not specify a "func"`);
assert(!this.target,
`Task "${taskname(this.name)}": A task with no inputs must not specify a "target"`);
this.globs_by_bucket = {};
}
for (let ii = 0; ii < this.deps.length; ++ii) {
let dep_name = this.deps[ii];
assert(typeof dep_name === 'string',
`Task "${taskname(this.name)}": entries in "deps" must be strings, found ${typeof dep_name}`);
}
this.taskdir = path.join(gb.config.statedir, 'tasks', this.name);
this.reset();
this.intdir = path.join(this.taskdir, 'out');
if (this.target) {
let targetdir = gb.config.targets[this.target];
assert(targetdir,
`Task "${taskname(this.name)}": "target" must be empty or reference a target specified in configure(),` +
` found "${this.target}"`);
this.outdir = targetdir;
this.bucket_out = this.target;
} else {
this.outdir = this.intdir;
this.bucket_out = this.name;
}
}
function gatherGlobs(gb, task) {
if (task.did_gather_globs) {
return;
}
task.did_gather_globs = true;
let globs_by_bucket = {};
function addSourceGlob(bucket, glob) {
globs_by_bucket[bucket] = globs_by_bucket[bucket] || [];
globs_by_bucket[bucket].push(glob);
if (bucket === 'source') {
return;
}
let source_task = gb.tasks[bucket];
if (source_task && !source_task.input) {
// it's a meta task, with no function, so, add it's sources as our sources
gatherGlobs(gb, source_task);
assert(!source_task.func);
assert(source_task.deps.length);
for (let jj = 0; jj < source_task.deps.length; ++jj) {
let dep_task_name = source_task.deps[jj];
assert.equal(typeof dep_task_name, 'string');
// important: also depend directly on the sub-tasks, so that they
// get us added as a dependor for propagating fs events
addDep(task, dep_task_name);
addSourceGlob(dep_task_name, glob);
}
}
}
if (task.input) {
assert(Array.isArray(task.input)); // checked earlier
for (let ii = 0; ii < task.input.length; ++ii) {
let input = task.input[ii];
assert(typeof input === 'string'); // checked earlier
let split = input.split(':');
assert(split.length <= 2); // checked earlier
if (split.length === 1) {
addSourceGlob('source', input);
} else {
let bucket = split[0];
let glob = split[1];
addSourceGlob(bucket, glob);
}
}
}
task.globs_by_bucket = globs_by_bucket;
}
BuildTask.prototype.reset = function () {
this.active = false; // depended on by an a active task
this.status = STATUS_PENDING;
this.last_time = Date.now();
this.err = null;
this.task_state = createTaskState({ gb: this.gb, dir: this.taskdir, name: this.name });
this.jobs = null;
this.task_has_run = false;
this.task_first_run_errored = false;
this.fs_events = {};
taskEndChild(this);
};
function setLogLevel(new_level) {
if (new_level !== undefined) {
log_level = new_level;
if (log_level === LOG_SILLY) {
log_timestamp = 'HH:mm:ss.ms';
} else {
log_timestamp = 'HH:mm:ss';
}
}
}
// For adding a new target at run-time, does what happens in configure + resetFiles
GlovBuild.prototype.addTarget = function (key, folder) {
assert(!this.config.targets[key], `Target "${key}" already registered`);
this.config.targets[key] = forwardSlashes(folder);
assert(!this.files.getBucketDir(key), `Target "${key}": must not be a reserved name`);
this.files.addBucket(key, this.config.targets[key]);
};
GlovBuild.prototype.configure = function (opts) {
assert(opts);
this.config = merge(this.config, opts);
// Replace target paths with canonical forward slashes
for (let key in this.config.targets) {
this.config.targets[key] = forwardSlashes(this.config.targets[key]);
}
this.config.statedir = forwardSlashes(this.config.statedir);
this.config.source = forwardSlashes(this.config.source);
this.resetFiles();
setLogLevel(opts.log_level);
if (opts.log_timestamp !== undefined) {
log_timestamp = opts.log_timestamp;
}
this.job_queue = asyncLimiter(this.config.parallel.jobs);
this.job_queue_async = asyncLimiter(this.config.parallel.jobs_async);
};
GlovBuild.prototype.getSourceRoot = function () {
return this.config.source;
};
GlovBuild.prototype.getDiskPath = function (key) {
return this.files.getDiskPath(...parseBucket(key));
};
GlovBuild.prototype.resetFiles = function () {
if (this.isFork()) {
this.files = filesForkedCreate(this);
} else {
this.files = filesCreate(this);
}
if (this.config.source) {
this.files.addBucket('source', this.config.source);
}
for (let key in this.config.targets) {
assert(!this.files.getBucketDir(key), `Target "${key}": must not be a reserved name`);
this.files.addBucket(key, this.config.targets[key]);
}
for (let key in this.tasks) { // only upon reset during testing
let task = this.tasks[key];
this.files.addBucket(task.name, task.intdir);
}
};
GlovBuild.prototype.task = function (task) {
task = new BuildTask(this, task);
assert(!this.tasks_finalized, 'Cannot add tasks after starting build');
assert(!this.tasks[task.name], `Task "${taskname(task.name)}": task already declared`);
assert(!this.config.targets[task.name], `Task "${taskname(task.name)}": must not be named the same as a target`);
assert(!this.files.getBucketDir(task.name), `Task "${taskname(task.name)}": must not be a reserved name`);
this.files.addBucket(task.name, task.intdir);
this.tasks[task.name] = task;
};
GlovBuild.prototype.tasksFinalize = function () {
if (this.tasks_finalized) {
return;
}
// Build input globs (may have been registered before their dependencies)
// This may also add new dependencies
for (let task_name in this.tasks) {
let task = this.tasks[task_name];
gatherGlobs(this, task);
}
for (let task_name in this.tasks) {
let task = this.tasks[task_name];
// Validate inter-task dependencies
// convert dep names to dep references
// determine the dependency depth ("phase", just for UI?)
let max_phase = 0;
for (let ii = 0; ii < task.deps.length; ++ii) {
let dep_name = task.deps[ii];
assert.equal(!dep_name || typeof dep_name, 'string'); // Called twice?
let dep = this.tasks[dep_name];
assert(dep,
`Task "${taskname(task.name)}": depends on unknown task "${taskname(dep_name)}"`);
task.deps[ii] = dep;
dep.dependors.push(task);
max_phase = max(max_phase, dep.phase);
}
task.phase = max_phase + 1;
task.deps.sort(cmpTaskPhase);
}
this.tasks_finalized = true;
};
function time(dt) {
if (dt < 1200) {
return `${dt}ms`;
} else if (dt < 12000) {
return `${(dt/1000).toFixed(1)}s`;
} else if (dt < 140000) {
return `${(dt/1000).toFixed(0)}s`;
} else {
return `${(dt/60000).toFixed(1)}m`;
}
}
function taskSetStatus(task, status, message) {
let now = Date.now();
let dt = now - task.last_time;
task.last_time = now;
if (status !== STATUS_PENDING && task.status !== STATUS_PENDING || status === STATUS_DONE) {
message = `${time(dt)}${message ? `, ${message}` : ''}`;
}
let log_msg = `Task "${taskname(task.name)}": ${task.status}->` +
`${STATE_COLORED[status]}${message ? ` (${message})` : ''}`;
if (status === STATUS_PENDING || status === STATUS_PREPARING_INPUTS ||
status === STATUS_PREPARING_DEPS
) {
ldebug(log_msg);
} else {
linfo(log_msg);
}
task.status = status;
}
function taskAbort(task) {
taskSetStatus(task, STATUS_ABORTED);
if (task.gb.aborting) {
scheduleTick(task.gb);
}
}
function taskSetErr(task, err) {
task.err = err;
lerror(`Task "${taskname(task.name)}" error:`, err);
taskSetStatus(task, STATUS_ERROR);
process.exitCode = 1;
scheduleTick(task.gb);
}
function BuildJob(gb, task, name, all_files) {
this.gb = gb;
this.task = task;
this.name = name;
all_files.sort(cmpFile);
this.files_all = all_files; // All files we depend on
this.files_base = all_files.slice(0); // Files we intrinsically depend on based on task input
this.files_updated = all_files.slice(0);
this.deps_adding = {};
this.need_sort = true;
this.on_done = null;
this.user_data = null;
this.output_queue = null;
this.warnings = [];
this.errors = [];
if (!fork) {
this.job_state = null;
this.last_job_state = task.task_state.getJobState(this.name);
}
this.job_has_run = false;
this.dirty = false;
this.executing = false;
}
function fileSerializeForFork(file) {
return file.serializeForFork();
}
BuildJob.prototype.serializeForFork = function (file_list) {
function serializeFile(file) {
let idx = file_list.indexOf(file);
if (idx === -1) {
file_list.push(file);
idx = file_list.length - 1;
}
return idx;
}
return {
name: this.name,
files_all: this.files_all.map(serializeFile), // note: every file pushed to file_list
files_base: this.files_base.map(serializeFile), // note: only references existing files
files_updated: this.files_updated.map(serializeFile), // note: may also push deleted files!
};
};
BuildJob.prototype.deserializeForFork = function (data, files) {
function lookupFile(idx) {
return files[idx];
}
assert.equal(this.name, data.name);
this.dirty = true;
this.files_all = data.files_all.map(lookupFile);
this.files_base = data.files_base.map(lookupFile);
this.files_updated = data.files_updated.map(lookupFile);
};
BuildJob.prototype.serializeForParent = function () {
function serializeOutput(file) {
return {
relative: file.relative,
contents: file.contents,
};
}
let output_queue = {};
for (let key in this.output_queue) {
let file = this.output_queue[key];
output_queue[key] = serializeOutput(file);
}
let ret = {
task_name: this.task.name,
job_name: this.name,
error_files: this.error_files,
output_queue,
errors: this.errors,
warnings: this.warnings,
};
this.output_queue = null;
this.warnings = null;
this.errors = null;
this.error_files = null;
return ret;
};
BuildJob.prototype.deserializeForParent = function (data) {
this.error_files = data.error_files;
assert(!this.output_queue);
this.output_queue = data.output_queue;
this.errors = data.errors;
this.warnings = data.warnings;
};
BuildJob.prototype.getUserData = function () {
if (!this.user_data) {
this.user_data = {};
}
return this.user_data;
};
BuildJob.prototype.getTaskUserData = function () {
assert(this.task.user_data, 'getTaskUserData() only valid if the task has an init/finish hook');
return this.task.user_data;
};
BuildJob.prototype.debug = function (msg, unexpected) {
assert(!unexpected);
assert.equal(typeof msg, 'string');
ldebug(` Task "${taskname(this.task.name)}", Job "${this.name}": ${msg}`);
};
BuildJob.prototype.log = function (msg, unexpected) {
assert(!unexpected);
assert.equal(typeof msg, 'string');
linfo(` Task "${taskname(this.task.name)}", Job "${this.name}": ${msg}`);
};
function jobPrintWarning(job, msg) {
lwarn(` Task "${taskname(job.task.name)}", Job "${job.name}": warning:`, msg);
job.gb.stats.warnings++;
}
// Optional first file argument
BuildJob.prototype.warn = function (file, msg, unexpected) {
if (isBuildFile(file)) { // shift args
// TODO: Include file name automatically?
this.error_files[file.key] = true;
} else {
unexpected = msg;
msg = file;
file = null;
}
assert(!unexpected);
assert.equal(typeof msg, 'string');
this.warnings.push(msg);
jobPrintWarning(this, msg);
};
function jobPrintError(job, err) {
let ret;
if (err instanceof Error && err.stack) {
let stack = err.stack;
stack = stack.split('\n');
for (let ii = 0; ii < stack.length; ++ii) {
if (stack[ii].indexOf('glovBuildRunJobFunc') !== -1) {
stack = stack.slice(0, ii);
}
}
ret = stack[0];
lerror(` Task "${taskname(job.task.name)}", Job "${job.name}": error:`,
stack.join('\n '));
} else {
err = String(err);
ret = err;
lerror(` Task "${taskname(job.task.name)}", Job "${job.name}": error:`,
err);
}
job.gb.stats.errors++;
return ret;
}
BuildJob.prototype.error = function (file, err, unexpected) {
if (isBuildFile(file)) {
// TODO: Include file name automatically?
this.error_files[file.key] = true;
assert(err, 'job.error passed a BuildFile but no error!');
} else { // shift args
unexpected = err;
err = file;
file = null;
}
assert(!unexpected);
if (!err) {
return;
}
let msg = jobPrintError(this, err);
this.errors.push(msg);
};
BuildJob.prototype.getTaskType = function () {
return this.task.type;
};
function taskOutputFsEvent(task, event, key) {
for (let ii = 0; ii < task.dependors_active.length; ++ii) {
let next_task = task.dependors_active[ii];
// If we're making changes, those that depend on us must be reset to pending already
// TODO: What if they're still running from a previous update?
assert(next_task.status === STATUS_PENDING || next_task.status === STATUS_ABORTED);
next_task.fs_events[key] = event;
}
}
let xxhashobj;
function withXxhash(fn) {
if (xxhashobj) {
fn(xxhashobj);
} else {
xxhashmod().then(function (obj) {
xxhashobj = obj;
fn(xxhashobj);
});
}
}
// errors added to job.errors
function jobOutputFiles(job, cb) {
assert(!fork);
// updates file.timestamp in BuildFiles
let who = `${job.task.name}:${job.name}`;
let count = 0;
let to_prune = {};
assert(!fork);
let last_outputs = job.last_job_state && job.last_job_state.outputs || {};
for (let key in last_outputs) {
to_prune[key] = true;
}
withXxhash(function (xxhash) {
asyncEach(Object.values(job.output_queue), function (file, next) {
++count;
let buildkey = `${job.task.bucket_out}:${file.relative}`;
delete to_prune[buildkey];
assert(Buffer.isBuffer(file.contents));
let file_data = {
bucket: job.task.bucket_out,
relative: file.relative,
contents: file.contents,
who,
};
let unchanged = false;
let last_data = last_outputs[buildkey];
let crc;
if (file.is_unchanged) {
assert(last_data, `Job output signaled is_unchanged on ${buildkey}, but this is a new file`);
unchanged = true;
crc = last_data.crc;
} else {
// crc = crc32(file.contents);
crc = xxhash.h32Raw(file.contents); // decently faster than crc32()
job.gb.stats.crc_calcs++;
unchanged = (last_data && last_data.crc === crc);
}
if (unchanged) {
// unchanged, do not write to disk, do not invalidate later tasks
job.gb.debug(` Output ${buildkey} unchanged, not updating`);
// Still need to let Files know it exists though, may not have been loaded this session
file_data.skip_write = true;
file_data.timestamp = last_data.ts;
} else {
let notify_key = `${job.task.name}:${file.relative}`;
taskOutputFsEvent(job.task, 'add', notify_key);
}
job.gb.files.put(file_data, function (err, buildfile) {
if (err) {
job.error(err);
} else {
assert(buildfile.timestamp);
job.job_state.outputs[buildfile.key] = { ts: buildfile.timestamp, crc };
}
next();
});
}, function () {
// TODO: Maybe don't prune missing outputs upon error - need to keep them in
// the job state for pruning later, though!
let prune_count = 0;
asyncEach(Object.keys(to_prune), function (key, next) {
++prune_count;
let file = job.gb.files.getPre(...parseBucket(key));
if (file.who && file.who !== who) {
job.warn(`output ${key} from this job should be pruned, but was recently written by "${file.who}", not pruning.` +
' This is highly unusual, but may happen if a task or input has changed so that a new job outputs' +
' what an old job used to output.');
next();
} else {
taskOutputFsEvent(job.task, 'unlink', key);
job.gb.files.prune(...parseBucket(key), next);
}
}, function () {
job.output_queue = null;
cb(count, prune_count);
});
});
});
}
BuildJob.prototype.jobDone = function (next) {
this.job_has_run = true;
if (fork) {
process.send({
cmd: 'job_done',
...this.serializeForParent(),
});
return next();
}
assert(this.job_state !== this.last_job_state); // no longer gets here if nothing changed?
this.dirty = false;
// Writing all output files after all user code has run for this job
jobOutputFiles(this, (num_files, num_pruned) => {
let any_errors = false;
if (this.errors.length) {
any_errors = true;
this.job_state.errors = this.errors;
} else {
delete this.job_state.errors;
}
if (this.warnings.length) {
any_errors = true;
this.job_state.warnings = this.warnings;
} else {
delete this.job_state.warnings;
}
this.job_state.version = this.task.version;
// Do we want to keep everything as "updated" until errors go away? No,
// we just print out the previous errors for SINGLE jobs, and ALL jobs
// handle it themselves facilitated by the fact that we keep any files
// that were specifically passed to .warn or .error as dirty.
if (!any_errors) {
this.files_updated.length = 0;
} else {
this.files_updated = this.files_updated.filter((file) => this.error_files[file.key]);
}
// Flush job state immediately after writing all files
// Might be slightly delayed when all jobs state are in the same file, though
this.last_job_state = this.job_state;
this.task.task_state.setJobState(this.name, this.job_state, (err) => {
if (err) {
lerror(`Internal error writing job state for ${this.task.name}:${this.name}`, err);
}
if (this.errors.length) {
linfo(` Task "${taskname(this.task.name)}", Job "${this.name}": failed`);
} else {
let num_deps = Object.keys(this.job_state.deps).length;
this.task.count_outputs += num_files;
this.task.count_deps += num_deps;
(this.warnings.length ? linfo : ldebug)(` Task "${taskname(this.task.name)}", Job "${this.name}": complete (` +
`${plural(num_deps, 'dep')}` +
`, ${plural(num_files, 'output')}` +
`${this.warnings.length ? `, ${plural(this.warnings.length, 'warning')}` : ''}` +
`${num_pruned ? `, ${num_pruned} pruned` : ''}` +
')');
}
next(err);
});
});
};
function cmpFile(a, b) {
assert(a.bucket !== b.bucket || a.relative !== b.relative);
if (a.bucket < b.bucket || a.bucket === b.bucket && a.relative < b.relative) {
return -1;
}
return 1;
}
BuildJob.prototype.sort = function () {
if (!this.need_sort) {
return;
}
this.need_sort = false;
// TODO: if task.type=ALL and task.input is more than
// one entry, for each file search the input globs to see which it came from
// and then sort by that, so input lists implicitly cause sort?
this.files_all.sort(cmpFile);
this.files_updated.sort(cmpFile);
};
BuildJob.prototype.getFile = function () {
assert.equal(this.task.type, SINGLE);
assert.equal(this.files_base.length, 1);
return this.files_base[0];
};
BuildJob.prototype.getFiles = function () {
this.sort();
return this.files_all;
};
BuildJob.prototype.getFilesUpdated = function () {
this.sort();
this.gb.stats.files_updated += this.files_updated.length;
return this.files_updated;
};
BuildJob.prototype.isFileBase = function (file) {
return this.files_base.indexOf(file) !== -1;
};
BuildJob.prototype.isFileUpdated = function (file) {
// PERFTODO: lazy-build lookup table?
return this.files_updated.indexOf(file) !== -1;
};
BuildJob.prototype.out = function (file) {
// flush all files simultaneously at end of job, and immediately before flushing job state
let key = file.relative; // bucket ignored, that's probably the source! `${file.bucket}:${file.relative}`;
assert(key.indexOf('\\') === -1, // Maybe auto-fix?
`job.out() called with non-normalized path "${file.relative}" (contains back slashes)`);
assert(key.indexOf('../') === -1,
`job.out() called with potentially escaping relative path "${file.relative}" (contains ../)`);
if (typeof file.contents === 'string') {
file.contents = Buffer.from(file.contents);
}
assert(Buffer.isBuffer(file.contents),
`job.out() called with non-string/non-Buffer contents (${file.contents ? typeof file.contents : file.contents})`);
if (this.output_queue[key]) {
let err = new Error(`Job is outputting the same file ("${key}") twice`);
if (isBuildFile(file)) {
this.error(file, err);
} else {
this.error(err);
}
} else {
this.output_queue[key] = file;
}
};
BuildJob.prototype.getOutputQueue = function () {
assert(this.output_queue, 'job.getOutputQueue() is only allowed during job execution');
return this.output_queue;
};
BuildJob.prototype.depReset = function () {
let expected_deps = {};
for (let ii = 0; ii < this.files_base.length; ++ii) {
expected_deps[this.files_base[ii].key] = 1;
}
function expected(file) {
return expected_deps[file.key];
}
if (fork) {
// Just filter lists, and parent process will modify job state
// Note: preserves sort state
this.files_all = this.files_all.filter(expected);
this.files_updated = this.files_updated.filter(expected);
process.send({
cmd: 'job_dep_reset',
task_name: this.task.name,
job_name: this.name,
});
return;
}
for (let key in this.job_state.deps) {
if (!expected_deps[key]) {
// This should only happen during run-time re-run of job that's ran in the
// same process
assert(this.job_has_run);
delete this.job_state.deps[key];
// also prune from files_updated and files_all!
jobFileListRemove(this, 'files_all', key);
jobFileListRemove(this, 'files_updated', key);
}
}
};
// Returns any error if not up to date
BuildJob.prototype.isUpToDate = function (cb) {
assert(!fork);
if (!this.last_job_state) {
return cb('no previous state');
}
let { gb, task, last_job_state, files_updated } = this;
let { deps, outputs, warnings, errors, version } = last_job_state;
if (task.version !== (version || 0)) {
linfo(` Task "${taskname(this.task.name)}", Job "${this.name}": ` +
`New task version (current: ${task.version}, previous: ${version||0})`);
return cb('mismatched task version');
}
// check that all current inputs are in the previous deps
for (let ii = 0; ii < files_updated.length; ++ii) {
let file = files_updated[ii];
if (!deps[file.key]) {
linfo(` Task "${taskname(this.task.name)}", Job "${this.name}": "${file.key}"` +
' not in previous inputs, updating...');
return cb('new input');
}
// timestamp equality will be checked below
}
// TODO: We actually (kind of) want the early-out behavior of `async.each` here,
// in that as soon as we know we need to update something, we don't need to
// keep checking the other deps. However, this produces inconsistent test
// cases (change these to be `eachSeries`?), and we *do* want the behavior
// that the final callback is not called until all (running) tasks are done,
// lest we get log messages showing up after it's already started the job
// executing.
asyncEach([['dep', deps], ['output', outputs]], (pair, next) => {
let [coll_key, coll] = pair;
asyncEachLimit(Object.keys(coll), ASYNC_LIMIT, (key, next) => {
let dep_timestamp = coll[key];
if (dep_timestamp && dep_timestamp.ts) { // outputs[]
dep_timestamp = dep_timestamp.ts;
}
gb.files.getStat(...parseBucket(key), `${this.task.name}:${this.name}`, (err, file) => {
if (err) {
if (coll === deps && dep_timestamp === DELETED_TIMESTAMP) {
// This file is missing, but it was already missing, possibly an optional dependency of a smart task
// Do NOT forcibly re-run the task, nothing has changed.
} else {
linfo(` Task "${taskname(this.task.name)}", Job "${this.name}": "${key}" ` +
`(${coll_key}) missing or errored, updating...`);
// invalidate the job state so any outputted file will be saved to disk
// even if it is the same as a previous (missing) file
coll[key] = null;
return next(err);
}
}
if (file.timestamp !== dep_timestamp) {
if (coll[key] && coll[key].crc) { // outputs[]
// Clear cached CRC - if this job outputs the same as the previous run, we still have to write it!
coll[key].crc = null;
}
ldebug(` Task "${taskname(this.task.name)}", Job "${this.name}": "${key}" ` +
`(${coll_key}) changed, updating...`);
return next('timestamp');
}
next();
});
}, next);
}, (err) => {
if (!err) {
// This could be checked earlier, but the log message doesn't make sense
// when we're dynamically re-running this due to a source file change.
if (warnings || errors) {
linfo(` Task "${taskname(this.task.name)}", Job "${this.name}": ` +
`Previous run ${errors ? 'errored' : 'warned'}, re-running...`);
return cb('previous run warned or errored');
}
// do not change job state
this.job_state = this.last_job_state;
}
cb(err);
});
};
function parseBucket(filename) {
assert(filename);
let split = filename.split(':');
assert(split.length <= 2);
if (split.length === 2) {
return split;
} else {
return ['source', filename];
}
}
function taskDependsOnTask(task, other_name) {
for (let ii = 0; ii < task.deps.length; ++ii) {
if (task.deps[ii].name === other_name) {
return true;
}
}
return false;
}
function depAddInternalFinish(job, file) {
assert(job.files_all.indexOf(file) === -1);
job.files_all.push(file);
job.files_updated.push(file);
job.need_sort = true;
}
function depAddInternal(job, bucket, relative, cb) {
job.gb.files.get(bucket, relative, function (err, file) {
assert.equal(file.err, err);
depAddInternalFinish(job, file);
job.job_state.deps[file.key] = file.timestamp; // will be -1 if `file.err`
cb(file);
});
}
BuildJob.prototype.depAdd = function (name, cb) {
assert(this.executing, 'job.depAdd() called on job that is no longer executing!');
name = forwardSlashes(name);
let [bucket, relative] = parseBucket(name);
let source_task = this.gb.tasks[bucket];
if (source_task && source_task.target) {
bucket = source_task.target;
}
let need_late_dep_check = false;
if (bucket !== 'source') {
// must reference a dependency of this task
if (this.gb.config.targets[bucket]) {
// references a target, once we've located the file, make sure we depend
// on the job that output it
need_late_dep_check = true;
} else {
assert(this.gb.tasks[bucket],
`Job "${this.name}" in Task "${taskname(this.task.name)}" references output from "${bucket}"` +
' which is not a declared task or target');
assert(taskDependsOnTask(this.task, bucket),
`Job "${this.name}" in Task "${taskname(this.task.name)}" references output from "${bucket}"` +
' which is not an explicit dependency');
}
}
let key = `${bucket}:${relative}`;
if (this.deps_adding[key]) {
this.deps_adding[key].push(cb);
return;
}
// already a dep? PERFTODO: something faster?
for (let ii = 0; ii < this.files_all.length; ++ii) {
let file = this.files_all[ii];
if (file.key === key) {
return cb(file.err, file);
}
}
let adding = this.deps_adding[key] = [cb];
let job = this;
function onFile(file) {
assert.equal(job.deps_adding[key], adding);
delete job.deps_adding[key];
if (need_late_dep_check && file.who) {
let pair = file.who.split(':');
if (pair.length === 2) {
let bucket = pair[0];
// how does the file exist if it's not a task? shouldn't be possible
assert(job.gb.tasks[bucket],
`Job "${job.name}" in Task "${taskname(job.task.name)}" references output from "${bucket}"` +
` via "${name}" which is not a declared task or target`);
if (!taskDependsOnTask(job.task, bucket)) {
job.error(`References output from "${bucket}"` +
` via "${name}" which is not an explicit dependency`);
}
}
// Note: if we don't have file.who (e.g. because the source task was not
// loaded in this run), we miss this check as we have no idea where
// a given output file came from - let's hope the user doesn't ignore
// the error the first time it pops up.
}
for (let ii = 0; ii < adding.length; ++ii) {
adding[ii](file.err, file);
}
}
if (fork) {
let resp_id = job.gb.onForkedResponse((obj) => {
let file = job.gb.files.addFiles([obj.file])[0];
depAddInternalFinish(job, file);
onFile(file);
});
process.send({
cmd: 'job_dep_add',
task_name: job.task.name,
job_name: job.name,
bucket,
relative,
resp_id,
});
} else {
depAddInternal(job, bucket, relative, onFile);
}
};
function delayRun(gb, key, fn, ...args) {
if (gb.delays[key]) {
setTimeout(fn.bind(null, ...args), gb.delays[key]);
} else {
fn(...args);
}
}
function taskStart(gb, task) {
assert.equal(task.status, STATUS_PENDING);
if (!task.input) {
assert(!task.func);
taskSetStatus(task, STATUS_DONE);
return scheduleTick(gb);
}
taskSetStatus(task, STATUS_PREPARING_INPUTS);
++gb.stats.phase_inputs;
delayRun(gb, 'inputs_pre', function () {
task.task_state.loadAll(taskGatherInputs.bind(null, gb, task));
});
}
function taskGatherInputs(gb, task) {
assert.equal(task.status, STATUS_PREPARING_INPUTS);
if (gb.aborting) {
return taskAbort(task);
}
if (task.task_has_run) {
taskGatherInputsDynamic(gb, task);
} else {
task.task_has_run = true;
taskGatherInputsFirstRun(gb, task);
}
}
function mapValuesSeries(obj, iteratee, callback) {
let ret = {};
asyncEachSeries(Object.keys(obj), function (key, next) {
iteratee(obj[key], key, function (err, res) {
ret[key] = res;
next(err);
});
}, function (err) {
callback(err, ret);
});
}
function getIndex(value, idx) {
return idx;
}
function mapArrayLimit(arr, limit, iteratee, callback) {
let ret = new Array(arr.length);
asyncEachLimit(arr.map(getIndex), limit, function (idx, next) {
iteratee(arr[idx], function (err, res) {
ret[idx] = res;
next(err);
});
}, function (err) {
callback(err, ret);
});
}
function taskGatherInputsFirstRun(gb, task) {
assert.equal(task.status, STATUS_PREPARING_INPUTS);
task.fs_events = {};
// gather inputs (just names and stat of files)
mapValuesSeries(task.globs_by_bucket, function (globs, bucket, next) {
if (bucket === 'source') {
// Raw from filesystem
fg(globs, {
cwd: gb.files.getBucketDir(bucket),
objectMode: true,
}, function (err, entries) {
if (err) {
return next(err);
}
mapArrayLimit(entries, ASYNC_LIMIT, function (file, next) {
gb.files.getStat(bucket, file.path, task.name, function (err, buildfile) {
if (err) {
return next(err);
}
return next(null, buildfile);
});
}, function (err, mapped) {
if (err) {
return next(err);
}
next(null, mapped);
});
});
} else {
// intermediate bucket, use in-memory information
// This will have the outputs from a previous run because each task has
// don a getStat on all of its outputs by now, I think?
let source_task = gb.tasks[bucket];
if (source_task.target) {
// the specific task outputs to a target, cannot just glob all the files
// in the target - use the outputs in the task state
let job_states = source_task.task_state.getAllJobStates();
let inputs = [];
asyncEachSeries(Object.values(job_states), function (job_state, next) {
let { outputs } = job_state;
if (!outputs) {
return next();
}
let files = [];
for (let key in outputs) {
let relative = key.split(':')[1];
if (micromatch(relative, globs).length) {
files.push(relative);
}
}
asyncEachSeries(files, function (relative, next) {
gb.files.getStat(source_task.target, relative, task.name, function (err, buildfile) {
if (buildfile) {
inputs.push(buildfile);
}
next(err);
});
}, next);
}, function (err) {
next(err, inputs);
});
} else {
// Glob a whole bucket
let mapped = gb.files.glob(bucket, globs);
next(null, Object.values(mapped));
}
}
}, function (err, files_by_bucket) {
if (err) {
// We failed very early from a low-level error (presumably race condition
// while starting up during a git branch switch or something), we cannot
// just abort now, as we have not started prepping the jobs which are
// needed to handle reloads/watch events. Instead, just treat it as if
// it has not ran at all, it'll start from scratch next time (and
// likely not get the low-level error).
task.task_has_run = false;
task.task_first_run_errored = true;
return taskSetErr(task, err);
}
task.task_first_run_errored = false;
let files = [];
for (let key in files_by_bucket) {
files = files.concat(files_by_bucket[key]);
}
taskPrepJobsFirstRun(gb, task, files);
});
}
function taskPrepJobsFirstRun(gb, task, input_files) {
assert.equal(task.status, STATUS_PREPARING_INPUTS);
// Not safe to abort here, we need to finish processing the on-disk files we scanned!
// Create BuildJobs for those that currently exist
// files_all = files_updated = base files
// Prune outputs from disk and state for jobs that don't exist anymore
// Stat all of the dependencies
// Determine which jobs need to be executed
// Any whose inputs changed
// Any whose version changed / previously warned / previously errored / weren't there before
let job_states = task.task_state.getAllJobStates();
let waiting = 1;
function done() {
--waiting;
if (!waiting) {
delayRun(gb, 'inputs_post', taskPrepDeps.bind(null, gb, task));
}
}
function pruneFile(filename) {
++waiting;
taskOutputFsEvent(task, 'unlink', filename);
gb.files.prune(...parseBucket(filename), done);
}
assert(!task.jobs);
let jobs = task.jobs = {};
let expected = {};
if (task.type === SINGLE) {
// make a job for each input
for (let ii = 0; ii < input_files.length; ++ii) {
let the_file = input_files[ii];
let key = the_file.key;
expected[key] = true;
let job = new BuildJob(gb, task, key, [the_file]);
jobs[key] = job;
}
} else if (task.type === ALL) {
// make a single job
expected.all = true;
jobs.all = new BuildJob(gb, task, 'all', input_files);
}
for (let job_name in job_states) {
let job_state = job_states[job_name];
if (!expected[job_name]) {
// Prune jobs that are no longer valid
linfo(` Task "${taskname(task.name)}", Job "${job_name}": not in new input, pruning...`);
++waiting;
task.task_state.setJobState(job_name, null, done);
for (let key in job_state.outputs) {
pruneFile(key);
}
}
}
asyncEachLimit(Object.values(jobs), 1, function (job, next) {
job.isUpToDate(function (err) {
if (err) {
// needs updating
job.dirty = true;
} else {
// Register outputs for valid jobs, so conflicting output detection can catch them
let who =`${task.name}:${job.name}`;
let job_state = job_states[job.name];
for (let key in job_state.outputs) {
task.gb.files.getPre(...parseBucket(key)).who = who;
}
lsilly(` Task "${taskname(task.name)}", Job "${job.name}": up to date`);
}
next();
});
}, done);
}
function jobFileListFind(job, list_name, key) {
let list = job[list_name];
for (let jj = 0; jj < list.length; ++jj) {
let buildfile = list[jj];
if (buildfile.key === key) {
return jj;
}
}
return -1;
}
function jobFileListRemove(job, list_name, key) {
let idx = jobFileListFind(job, list_name, key);
if (idx !== -1) {
let list = job[list_name];
let buildfile = list[idx];
ridx(list, idx);
job.need_sort = true;
return buildfile;
}
return null;
}
function jobFileListUpdate(job, list_name, file, required) {
let idx = jobFileListFind(job, list_name, file.key);
if (idx !== -1) {
job[list_name][idx] = file;
job.dirty = true;
return true;
}
console.log(job[list_name]);
assert(!required);
return false;
}
function jobFileListAdd(job, list_name, file) {
let list = job[list_name];
job.dirty = true;
let idx = jobFileListFind(job, list_name, file.key);
if (idx !== -1) {
list[idx] = file;
return false;
}
list.push(file);
job.need_sort = true;
return true;
}
function taskGatherInputsDynamic(gb, task) {
assert(!fork);
assert.equal(task.status, STATUS_PREPARING_INPUTS);
let { fs_events, jobs, globs_by_bucket } = task;
task.fs_events = {};
let waiting = 1;
function done() {
--waiting;
if (!waiting) {
delayRun(gb, 'inputs_post', taskPrepDeps, gb, task);
}
}
function pruneFile(filename) {
++waiting;
taskOutputFsEvent(task, 'unlink', filename);
gb.files.prune(...parseBucket(filename), done);
}
// Dirty appropriate jobs based on fs_events
// Deleted file:
// Prune outputs from disk and state for jobs that don't exist anymore
// Find jobs that depended on this file (either gb.ALL or an external dep, not a base SINGLE)
// ALL and base file:
// Remove from files_all, potentially add to files_updated
// any