UNPKG

glov-build

Version:
1,538 lines (1,442 loc) 90.6 kB
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